From 5513a6f2b4d445beb4945cc668db05d8159fbc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Tue, 24 Oct 2017 20:56:53 -0700 Subject: [PATCH 01/18] Clean up readme --- README.md | 60 +++++++++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index c489fec92a..09989da757 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ [![CircleCI](https://circleci.com/gh/graphcool/graphcool.svg?style=shield)](https://circleci.com/gh/graphcool/graphcool) [![Slack Status](https://slack.graph.cool/badge.svg)](https://slack.graph.cool) [![npm version](https://img.shields.io/badge/npm%20package-next-brightgreen.svg)](https://badge.fury.io/js/graphcool) -**Graphcool is a GraphQL backend framework** to develop and deploy production-ready GraphQL microservices.
-Think about it like Rails, Django or Meteor but based on [GraphQL](https://www.howtographql.com/) and designed for today's cloud infrastructure. +**The Graphcool backend development framework** is designed to help you develop and deploy production-ready GraphQL microservices. With Graphcool you can design your data model and have a production ready [GraphQL](https://www.howtographql.com/) API online in minutes. -The framework currently supports Node.js & Typescript and is compatible with existing libraries and tools like [GraphQL.js](https://github.com/graphql/graphql-js) and [Apollo Server](https://github.com/apollographql/apollo-server). Graphcool comes with a CLI and a Docker-based runtime which can be deployed to any server or cloud. +The framework integrates with cloud-native serverless functions and is compatible with existing libraries and tools like [GraphQL.js](https://github.com/graphql/graphql-js) and [Apollo Server](https://github.com/apollographql/apollo-server). Graphcool comes with a CLI and a Docker-based runtime which can be deployed to any server or cloud. + + + + + + + + + \ No newline at end of file diff --git a/server/backend-api-fileupload/src/main/scala/Server.scala b/server/backend-api-fileupload/src/main/scala/Server.scala new file mode 100644 index 0000000000..29df461ced --- /dev/null +++ b/server/backend-api-fileupload/src/main/scala/Server.scala @@ -0,0 +1,299 @@ +import akka.NotUsed +import akka.actor.{ActorRef, ActorSystem} +import akka.http.scaladsl.Http +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import akka.http.scaladsl.model.Multipart.FormData +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.{HttpOrigin, HttpOriginRange, Origin, RawHeader, _} +import akka.http.scaladsl.server.directives.FileInfo +import akka.stream.scaladsl.{Broadcast, Flow, GraphDSL, Merge, Sink, Source} +import akka.stream.{ActorMaterializer, FlowShape} +import akka.util.ByteString +import com.amazonaws.services.kinesis.AmazonKinesis +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types._ +import cool.graph.bugsnag.{BugSnagger, GraphCoolRequest} +import cool.graph.client._ +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.client.files.{FileUploadResponse, FileUploader} +import cool.graph.client.finder.ProjectFetcher +import cool.graph.client.server.HealthChecks +import cool.graph.cuid.Cuid +import cool.graph.fileupload.FileuploadServices +import cool.graph.metrics.ClientSharedMetrics +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.externalServices.TestableTime +import cool.graph.shared.logging.RequestLogger +import cool.graph.shared.models.{AuthenticatedRequest, Project, ProjectWithClientId} +import cool.graph.util.ErrorHandlerFactory +import scaldi.akka.AkkaInjectable +import spray.json.{JsNumber, JsObject, JsString, JsValue} +import scala.collection.immutable._ +import scala.concurrent.Future +import scala.concurrent.duration._ + +object Server extends App with AkkaInjectable with LazyLogging { + ClientSharedMetrics // this is just here to kick off the profiler + + implicit val system = ActorSystem("sangria-server") + implicit val materializer = ActorMaterializer() + implicit val inj = new FileuploadServices + + import system.dispatcher + + val globalDatabaseManager = inject[GlobalDatabaseManager] + val kinesis = inject[AmazonKinesis](identified by "kinesis") + val log: String => Unit = (msg: String) => logger.info(msg) + val errorHandlerFactory = ErrorHandlerFactory(log) + val projectSchemaFetcher = inject[ProjectFetcher](identified by "project-schema-fetcher") + val globalApiEndpointManager = inject[GlobalApiEndpointManager] + val bugsnagger = inject[BugSnagger] + val auth = inject[ClientAuth] + val apiMetricActor = inject[ActorRef](identified by "featureMetricActor") + val testableTime = inject[TestableTime] + + val requestHandler: Flow[HttpRequest, HttpResponse, NotUsed] = { + + case class RequestAndSchema(request: HttpRequest, project: Project, clientId: Id, clientOrUserId: Option[AuthenticatedRequest]) + + Flow + .fromGraph(GraphDSL.create() { implicit b => + import akka.http.scaladsl.unmarshalling.Unmarshal + import akka.stream.scaladsl.GraphDSL.Implicits._ + + val src = b.add(Flow[HttpRequest]) + val statusSplit = b.add(Broadcast[HttpRequest](3)) + val optionsFilter = b.add(Flow[HttpRequest].filter(x => x.method == HttpMethods.OPTIONS)) + val statusFilter = b.add(Flow[HttpRequest].filter(x => x.method == HttpMethods.GET)) + val dataFilter = b.add(Flow[HttpRequest].filter(x => x.method == HttpMethods.POST)) + val status = Flow[HttpRequest].mapAsync(5)(_ => statusHandler.map(_ => HttpResponse(status = StatusCodes.Accepted, entity = "OK"))) + + val options = Flow[HttpRequest].map( + request => + HttpResponse( + status = StatusCodes.Accepted, + entity = "OK", + headers = request + .header[Origin] + .map(_.origins) + .map( + origins => + corsHeaders(request + .header[`Access-Control-Request-Headers`] + .map(_.headers) + .getOrElse(Seq.empty), + origins)) + .getOrElse(Seq()) + )) + + val withSchema = b.add(Flow[HttpRequest].mapAsync(5)(request => { + val projectId = request.uri.path.toString().split("/").reverse.head + val authorizationHeader = request.headers.find(_.name() == "Authorization").map(_.value()) + + getAuthContext(projectId, authorizationHeader).map(s => { + RequestAndSchema(request, s._1, s._2, s._3) + }) + })) + + val split = b.add(Broadcast[RequestAndSchema](2)) + val proxyFilter = b.add(Flow[RequestAndSchema].filter(x => x.project.region != globalDatabaseManager.currentRegion)) + val localFilter = b.add(Flow[RequestAndSchema].filter(x => x.project.region == globalDatabaseManager.currentRegion)) + val merge = b.add(Merge[HttpResponse](4)) + + val proxy: Flow[RequestAndSchema, HttpResponse, NotUsed] = Flow[RequestAndSchema].mapAsync(5)(r => { + println("PROXY") + + val host = Uri(globalApiEndpointManager.getEndpointForProject(r.project.region, r.project.id)).authority.host.address() + Http(system) + .outgoingConnection(host, 80) + .runWith( + Source.single( + r.request.copy(headers = r.request.headers.filter(header => !List("remote-address", "timeout-access").contains(header.name.toLowerCase)))), + Sink.head + ) + ._2 + }) + + val local = Flow[RequestAndSchema].mapAsyncUnordered(5)(x => { + println("LOCAL") + + val requestLogger = new RequestLogger(requestIdPrefix = sys.env.getOrElse("AWS_REGION", sys.error("AWS Region not found.")) + ":file", log = log) + val requestId = requestLogger.begin + + Unmarshal(x.request.entity) + .to[Multipart.FormData] + .flatMap { formData => + val onePartSource: Future[List[FormData.BodyPart]] = + formData + .toStrict(600.seconds) + .flatMap(g => g.parts.runFold(List[FormData.BodyPart]())((acc, body) => acc :+ body)) + + onePartSource.map { list => + list + .find(part => part.filename.isDefined && part.name == "data") + .map(part ⇒ (FileInfo(part.name, part.filename.get, part.entity.contentType), part.entity.dataBytes)) + } + } + .flatMap { dataOpt => + val (fileInfo, byteSource) = dataOpt.get + fileHandler( + metadata = fileInfo, + byteSource = byteSource, + project = x.project, + clientId = x.clientId, + authenticatedRequest = x.clientOrUserId, + requestId = requestId, + requestIp = "ip.toString" + ).andThen { + case _ => + requestLogger.end(Some(x.project.id), Some(x.clientId)) + } + } + .map { + case (_, json) => + HttpResponse( + entity = json.prettyPrint, + headers = x.request + .header[Origin] + .map(_.origins) + .map( + origins => + corsHeaders(x.request + .header[`Access-Control-Request-Headers`] + .map(_.headers) + .getOrElse(Seq.empty), + origins)) + .getOrElse(Seq()) :+ RawHeader("Request-Id", requestId) + ) + } + + }) + + src ~> statusSplit + statusSplit ~> statusFilter ~> status ~> merge + statusSplit ~> optionsFilter ~> options ~> merge + statusSplit ~> dataFilter ~> withSchema ~> split + + split ~> proxyFilter ~> proxy ~> merge + split ~> localFilter ~> local ~> merge + + FlowShape(src.in, merge.out) + }) + + } + + Http().bindAndHandle(requestHandler, "0.0.0.0", 8084).onSuccess { + case _ => logger.info("Server running on: 8084") + } + + def accessControlAllowOrigin(origins: Seq[HttpOrigin]): `Access-Control-Allow-Origin` = + `Access-Control-Allow-Origin`.forRange(HttpOriginRange.Default(origins)) + + def accessControlAllowHeaders(requestHeaders: Seq[String]): Option[`Access-Control-Allow-Headers`] = + if (requestHeaders.isEmpty) { None } else { Some(`Access-Control-Allow-Headers`(requestHeaders)) } + + def accessControlAllowMethods = `Access-Control-Allow-Methods`(HttpMethods.GET, HttpMethods.POST, HttpMethods.OPTIONS) + + def corsHeaders(requestHeaders: Seq[String], origins: Seq[HttpOrigin]): Seq[HttpHeader] = + Seq(accessControlAllowOrigin(origins), accessControlAllowMethods) ++ accessControlAllowHeaders(requestHeaders) + + def fileHandler(metadata: FileInfo, + byteSource: Source[ByteString, Any], + project: Project, + clientId: String, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + requestIp: String): Future[(StatusCode with Product with Serializable, JsValue)] = { + apiMetricActor ! ApiFeatureMetric(requestIp, testableTime.DateTime, project.id, clientId, List(FeatureMetric.ApiFiles.toString), isFromConsole = false) + + val uploader = new FileUploader(project) + val uploadResult = uploader.uploadFile(metadata, byteSource) + + createFileNode(project, uploadResult).map( + id => + OK -> JsObject( + "id" -> JsString(id), + "secret" -> JsString(uploadResult.fileSecret), + "url" -> JsString(getUrl(project.id, uploadResult.fileSecret)), + "name" -> JsString(uploadResult.fileName), + "contentType" -> JsString(uploadResult.contentType), + "size" -> JsNumber(uploadResult.size) + )) + } + + def getUrl(projectId: String, fileSecret: String) = s"https://files.graph.cool/$projectId/$fileSecret" + + def createFileNode(project: Project, uploadResponse: FileUploadResponse): Future[String] = { + val id = Cuid.createCuid() + + val item = Map( + "id" -> id, + "secret" -> uploadResponse.fileSecret, + "url" -> getUrl(project.id, uploadResponse.fileSecret), + "name" -> uploadResponse.fileName, + "contentType" -> uploadResponse.contentType, + "size" -> uploadResponse.size + ) + + val query = DatabaseMutationBuilder.createDataItem(project.id, "File", item) + globalDatabaseManager.getDbForProject(project).master.run(query).map(_ => id) + } + + protected def statusHandler: Future[Id] = { + val status = for { + _ <- HealthChecks.checkDatabases(globalDatabaseManager) + _ <- Future(try { kinesis.listStreams() } catch { + case _: com.amazonaws.services.kinesis.model.LimitExceededException => true + }) + } yield () + + status.map(_ => "OK") + } + + protected def getAuthContext(projectId: String, authorizationHeader: Option[String]): Future[(Project, Id, Option[AuthenticatedRequest])] = { + val sessionToken = authorizationHeader.flatMap { + case str if str.startsWith("Bearer ") => Some(str.stripPrefix("Bearer ")) + case _ => None + } + + fetchSchema(projectId) flatMap { + case ProjectWithClientId(project, clientId) => + sessionToken match { + case None => + Future.successful(project, clientId, None) + + case Some(x) => + auth + .authenticateRequest(x, project) + .map(clientOrUserId => (project, clientId, Some(clientOrUserId))) + .recover { + case _ => (project, clientId, None) // the token is invalid, so don't include userId + } + } + } + } + + def fetchSchema(projectId: String): Future[ProjectWithClientId] = { + val result = projectSchemaFetcher.fetch(projectIdOrAlias = projectId) map { + case None => throw UserAPIErrors.ProjectNotFound(projectId) + case Some(schema) => schema + } + + result.onFailure { + case t => + val request = GraphCoolRequest( + requestId = "", + clientId = None, + projectId = Some(projectId), + query = "", + variables = "" + ) + bugsnagger.report(t, request) + } + + result + } +} diff --git a/server/backend-api-fileupload/src/main/scala/cool/graph/fileupload/FileuploadServices.scala b/server/backend-api-fileupload/src/main/scala/cool/graph/fileupload/FileuploadServices.scala new file mode 100644 index 0000000000..2678c13496 --- /dev/null +++ b/server/backend-api-fileupload/src/main/scala/cool/graph/fileupload/FileuploadServices.scala @@ -0,0 +1,83 @@ +package cool.graph.fileupload + +import akka.actor.{ActorRefFactory, ActorSystem, Props} +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} +import com.amazonaws.services.s3.{AmazonS3, AmazonS3ClientBuilder} +import com.typesafe.config.ConfigFactory +import cool.graph.bugsnag.{BugSnagger, BugSnaggerImpl} +import cool.graph.client._ +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.finder.ProjectFetcherImpl +import cool.graph.client.metrics.ApiMetricsMiddleware +import cool.graph.cloudwatch.CloudwatchImpl +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.externalServices.{KinesisPublisher, KinesisPublisherImplementation, TestableTime, TestableTimeImplementation} +import scaldi.Module + +class FileuploadServices(implicit _system: ActorRefFactory, system: ActorSystem, implicit val materializer: akka.stream.ActorMaterializer) extends Module { + lazy val config = ConfigFactory.load() + lazy val testableTime = new TestableTimeImplementation + lazy val apiMetricsFlushInterval = 10 + lazy val kinesis = createKinesis() + lazy val apiMetricsPublisher = new KinesisPublisherImplementation(streamName = sys.env("KINESIS_STREAM_API_METRICS"), kinesis) + lazy val featureMetricActor = system.actorOf(Props(new FeatureMetricActor(apiMetricsPublisher, apiMetricsFlushInterval))) + lazy val clientAuth = ClientAuthImpl() + + bind[GlobalDatabaseManager] toNonLazy GlobalDatabaseManager.initializeForSingleRegion(config) + bind[GlobalApiEndpointManager] toNonLazy createGlobalApiEndpointManager + binding identifiedBy "kinesis" toNonLazy kinesis + binding identifiedBy "cloudwatch" toNonLazy CloudwatchImpl() + binding identifiedBy "s3-fileupload" toNonLazy createS3() + binding identifiedBy "config" toNonLazy config + binding identifiedBy "actorSystem" toNonLazy system destroyWith (_.terminate()) + binding identifiedBy "dispatcher" toNonLazy system.dispatcher + binding identifiedBy "actorMaterializer" toNonLazy materializer + + bind[TestableTime] toNonLazy new TestableTimeImplementation + bind[BugSnagger] toNonLazy BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) + + bind[KinesisPublisher] identifiedBy "kinesisApiMetricsPublisher" toNonLazy new KinesisPublisherImplementation( + streamName = sys.env("KINESIS_STREAM_API_METRICS"), + kinesis + ) + bind[ClientAuth] toNonLazy clientAuth + + binding identifiedBy "featureMetricActor" to featureMetricActor + binding identifiedBy "api-metrics-middleware" toNonLazy new ApiMetricsMiddleware(testableTime, featureMetricActor) + binding identifiedBy "project-schema-fetcher" toNonLazy ProjectFetcherImpl(blockedProjectIds = Vector.empty, config) + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + + private def createGlobalApiEndpointManager = { + GlobalApiEndpointManager( + euWest1 = sys.env("API_ENDPOINT_EU_WEST_1"), + usWest2 = sys.env("API_ENDPOINT_US_WEST_2"), + apNortheast1 = sys.env("API_ENDPOINT_AP_NORTHEAST_1") + ) + } + + private def createS3(): AmazonS3 = { + val credentials = new BasicAWSCredentials( + sys.env("FILEUPLOAD_S3_AWS_ACCESS_KEY_ID"), + sys.env("FILEUPLOAD_S3_AWS_SECRET_ACCESS_KEY") + ) + + AmazonS3ClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("FILEUPLOAD_S3_ENDPOINT"), sys.env("FILEUPLOAD_AWS_REGION"))) + .build + } + + private def createKinesis(): AmazonKinesis = { + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonKinesisClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("KINESIS_ENDPOINT"), sys.env("AWS_REGION"))) + .build() + } +} diff --git a/server/backend-api-relay/build.sbt b/server/backend-api-relay/build.sbt new file mode 100644 index 0000000000..5fd914ec8c --- /dev/null +++ b/server/backend-api-relay/build.sbt @@ -0,0 +1 @@ +name := "backend-api-relay" diff --git a/server/backend-api-relay/project/build.properties b/server/backend-api-relay/project/build.properties new file mode 100644 index 0000000000..27e88aa115 --- /dev/null +++ b/server/backend-api-relay/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 diff --git a/server/backend-api-relay/project/plugins.sbt b/server/backend-api-relay/project/plugins.sbt new file mode 100644 index 0000000000..a86a46d973 --- /dev/null +++ b/server/backend-api-relay/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.3") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.0") diff --git a/server/backend-api-relay/src/main/resources/application.conf b/server/backend-api-relay/src/main/resources/application.conf new file mode 100644 index 0000000000..e791adaba7 --- /dev/null +++ b/server/backend-api-relay/src/main/resources/application.conf @@ -0,0 +1,105 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + remote-address-header = on + request-timeout = 45s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } + http.client { + parsing.max-content-length = 50m + } +} + +jwtSecret = ${?JWT_SECRET} +schemaManagerEndpoint = ${SCHEMA_MANAGER_ENDPOINT} +schemaManagerSecret = ${SCHEMA_MANAGER_SECRET} +awsAccessKeyId = ${AWS_ACCESS_KEY_ID} +awsSecretAccessKey = ${AWS_SECRET_ACCESS_KEY} +awsRegion = ${AWS_REGION} + +internal { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = ${?SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientDatabases { + client1 { + master { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + readonly { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_READONLY_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + readOnly = true + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + } +} + +# Test DBs +internalTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +internalTestRoot { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_CLIENT_HOST}":"${?TEST_SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_CLIENT_USER} + password = ${?TEST_SQL_CLIENT_PASSWORD} + } + numThreads = ${?TEST_SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +slick.dbs.default.db.connectionInitSql="set names utf8mb4" \ No newline at end of file diff --git a/server/backend-api-relay/src/main/resources/graphiql.html b/server/backend-api-relay/src/main/resources/graphiql.html new file mode 100644 index 0000000000..e788b78238 --- /dev/null +++ b/server/backend-api-relay/src/main/resources/graphiql.html @@ -0,0 +1 @@ + Graphcool Playground
Loading GraphQL Playground
\ No newline at end of file diff --git a/server/backend-api-relay/src/main/resources/logback.xml b/server/backend-api-relay/src/main/resources/logback.xml new file mode 100644 index 0000000000..c1f586b1c6 --- /dev/null +++ b/server/backend-api-relay/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/server/backend-api-relay/src/main/scala/RelayMain.scala b/server/backend-api-relay/src/main/scala/RelayMain.scala new file mode 100644 index 0000000000..4cb8cacd15 --- /dev/null +++ b/server/backend-api-relay/src/main/scala/RelayMain.scala @@ -0,0 +1,16 @@ +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnagger +import cool.graph.client.server.ClientServer +import cool.graph.relay.RelayApiDependencies +import scaldi.Injectable + +object RelayMain extends App with Injectable { + implicit val system = ActorSystem("sangria-server") + implicit val materializer = ActorMaterializer() + implicit val inj = RelayApiDependencies() + implicit val bugsnagger = inject[BugSnagger] + + ServerExecutor(port = 8083, ClientServer("relay")).startBlocking() +} diff --git a/server/backend-api-relay/src/main/scala/cool/graph/relay/RelayApiDependencies.scala b/server/backend-api-relay/src/main/scala/cool/graph/relay/RelayApiDependencies.scala new file mode 100644 index 0000000000..a91afc2dc3 --- /dev/null +++ b/server/backend-api-relay/src/main/scala/cool/graph/relay/RelayApiDependencies.scala @@ -0,0 +1,78 @@ +package cool.graph.relay + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.client.database.{DeferredResolverProvider, RelayManyModelDeferredResolver, RelayToManyDeferredResolver} +import cool.graph.client.finder.{CachedProjectFetcherImpl, ProjectFetcherImpl, RefreshableProjectFetcher} +import cool.graph.client.server.{GraphQlRequestHandler, GraphQlRequestHandlerImpl, ProjectSchemaBuilder} +import cool.graph.messagebus.Conversions.{ByteUnmarshaller, Unmarshallers} +import cool.graph.messagebus.pubsub.rabbit.{RabbitAkkaPubSub, RabbitAkkaPubSubSubscriber} +import cool.graph.client.{CommonClientDependencies, FeatureMetric, UserContext} +import cool.graph.messagebus.{Conversions, PubSubPublisher, QueuePublisher} +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.relay.schema.RelaySchemaBuilder +import cool.graph.shared.functions.{EndpointResolver, FunctionEnvironment, LiveEndpointResolver} +import cool.graph.shared.functions.lambda.LambdaFunctionEnvironment +import cool.graph.webhook.Webhook + +import scala.util.Try + +trait RelayApiClientDependencies extends CommonClientDependencies { + import system.dispatcher + + val relayDeferredResolver: DeferredResolverProvider[_, UserContext] = + new DeferredResolverProvider(new RelayToManyDeferredResolver, new RelayManyModelDeferredResolver) + + val relayProjectSchemaBuilder = ProjectSchemaBuilder(project => new RelaySchemaBuilder(project).build()) + + val relayGraphQlRequestHandler = GraphQlRequestHandlerImpl( + errorHandlerFactory = errorHandlerFactory, + log = log, + apiVersionMetric = FeatureMetric.ApiRelay, + apiMetricsMiddleware = apiMetricsMiddleware, + deferredResolver = relayDeferredResolver + ) + + bind[GraphQlRequestHandler] identifiedBy "relay-gql-request-handler" toNonLazy relayGraphQlRequestHandler + bind[ProjectSchemaBuilder] identifiedBy "relay-schema-builder" toNonLazy relayProjectSchemaBuilder +} + +case class RelayApiDependencies(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends RelayApiClientDependencies { + val projectSchemaInvalidationSubscriber: RabbitAkkaPubSubSubscriber[String] = { + val globalRabbitUri = sys.env("GLOBAL_RABBIT_URI") + implicit val unmarshaller: ByteUnmarshaller[String] = Unmarshallers.ToString + + RabbitAkkaPubSub.subscriber[String](globalRabbitUri, "project-schema-invalidation", durable = true) + } + + lazy val blockedProjectIds: Vector[String] = Try { + sys.env("BLOCKED_PROJECT_IDS").split(",").toVector + }.getOrElse(Vector.empty) + + val projectSchemaFetcher: RefreshableProjectFetcher = CachedProjectFetcherImpl( + projectFetcher = ProjectFetcherImpl(blockedProjectIds, config), + projectSchemaInvalidationSubscriber = projectSchemaInvalidationSubscriber + ) + + val functionEnvironment = LambdaFunctionEnvironment( + sys.env.getOrElse("LAMBDA_AWS_ACCESS_KEY_ID", "whatever"), + sys.env.getOrElse("LAMBDA_AWS_SECRET_ACCESS_KEY", "whatever") + ) + + val fromStringMarshaller = Conversions.Marshallers.FromString + + val endpointResolver = LiveEndpointResolver() + val logsPublisher = RabbitQueue.publisher[String](sys.env("RABBITMQ_URI"), "function-logs")(bugSnagger, fromStringMarshaller) + val webhooksPublisher = RabbitQueue.publisher(sys.env("RABBITMQ_URI"), "webhooks")(bugSnagger, Webhook.marshaller) + val sssEventsPublisher = RabbitAkkaPubSub.publisher[String](sys.env("RABBITMQ_URI"), "sss-events", durable = true)(bugSnagger, fromStringMarshaller) + val requestPrefix = sys.env.getOrElse("AWS_REGION", sys.error("AWS Region not found.")) + + binding identifiedBy "project-schema-fetcher" toNonLazy projectSchemaFetcher + + bind[FunctionEnvironment] toNonLazy functionEnvironment + bind[EndpointResolver] identifiedBy "endpointResolver" toNonLazy endpointResolver + bind[QueuePublisher[String]] identifiedBy "logsPublisher" toNonLazy logsPublisher + bind[QueuePublisher[Webhook]] identifiedBy "webhookPublisher" toNonLazy webhooksPublisher + bind[PubSubPublisher[String]] identifiedBy "sss-events-publisher" toNonLazy sssEventsPublisher + bind[String] identifiedBy "request-prefix" toNonLazy requestPrefix +} diff --git a/server/backend-api-relay/src/main/scala/cool/graph/relay/auth/integrations/SigninIntegration.scala b/server/backend-api-relay/src/main/scala/cool/graph/relay/auth/integrations/SigninIntegration.scala new file mode 100644 index 0000000000..b64665bea6 --- /dev/null +++ b/server/backend-api-relay/src/main/scala/cool/graph/relay/auth/integrations/SigninIntegration.scala @@ -0,0 +1,19 @@ +package cool.graph.relay.auth.integrations + +import cool.graph.DataItem +import cool.graph.client.{UserContext$, UserContext} +import sangria.schema.{Field, OptionType, _} + +case class IntegrationSigninData(token: String, user: DataItem) + +object SigninIntegration { + def fieldType(userFieldType: ObjectType[UserContext, DataItem]): ObjectType[UserContext, Option[IntegrationSigninData]] = + ObjectType( + "SigninPayload", + description = "In case signin was successful contains the user and a token or null otherwise", + fields = fields[UserContext, Option[IntegrationSigninData]]( + Field(name = "token", fieldType = OptionType(StringType), resolve = _.value.map(_.token)), + Field(name = "user", fieldType = OptionType(userFieldType), resolve = _.value.map(_.user)) + ) + ) +} diff --git a/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelayOutputMapper.scala b/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelayOutputMapper.scala new file mode 100644 index 0000000000..43e1311920 --- /dev/null +++ b/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelayOutputMapper.scala @@ -0,0 +1,238 @@ +package cool.graph.relay.schema + +import cool.graph.DataItem +import cool.graph.client.database.{DefaultEdge, Edge} +import cool.graph.client.schema.OutputMapper +import cool.graph.client.schema.relay.RelayResolveOutput +import cool.graph.client.UserContext +import cool.graph.shared.{ApiMatrixFactory} +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Model, Project, Relation} +import sangria.schema.{Args, Field, ObjectType, OptionType, fields} +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +class RelayOutputMapper( + viewerType: ObjectType[UserContext, Unit], + edgeObjectTypes: => Map[String, ObjectType[UserContext, Edge[DataItem]]], + modelObjectTypes: Map[String, ObjectType[UserContext, DataItem]], + project: Project +)(implicit inj: Injector) + extends OutputMapper + with Injectable { + + type R = RelayResolveOutput + type C = UserContext + + val apiMatrix = inject[ApiMatrixFactory].create(project) + + def nodePaths(model: Model) = List(List(model.getCamelCasedName)) + + def createUpdateDeleteFields[C](model: Model, objectType: ObjectType[C, DataItem]): List[Field[C, RelayResolveOutput]] = + List( + Field[C, RelayResolveOutput, Any, Any](name = "viewer", fieldType = viewerType, description = None, arguments = List(), resolve = ctx => ()), + Field[C, RelayResolveOutput, Any, Any](name = "clientMutationId", + fieldType = sangria.schema.StringType, + description = None, + arguments = List(), + resolve = ctx => { + ctx.value.clientMutationId + }), + Field[C, RelayResolveOutput, Any, Any](name = model.getCamelCasedName, + fieldType = OptionType(objectType), + description = None, + arguments = List(), + resolve = ctx => { + ctx.value.item + }), + Field[C, RelayResolveOutput, Any, Any]( + name = "edge", + fieldType = OptionType(edgeObjectTypes(model.name)), + description = None, + arguments = List(), + resolve = ctx => DefaultEdge(ctx.value.item, ctx.value.item.id) + ) + ) ++ + model.relationFields + .filter(apiMatrix.includeField) + .filter(!_.isList) + .map(oneConnectionField => + Field[C, RelayResolveOutput, Any, Any]( + name = model.getCamelCasedName match { + case oneConnectionField.name => + s"${oneConnectionField.name}_" + case _ => + oneConnectionField.name + }, + fieldType = OptionType( + modelObjectTypes(oneConnectionField + .relatedModel(project) + .get + .name)), + description = None, + arguments = List(), + resolve = ctx => + ctx.ctx + .asInstanceOf[UserContext] + .mutationDataresolver + .resolveByRelation(oneConnectionField, ctx.value.item.id, None) + .map(_.items.headOption) + )): List[Field[C, RelayResolveOutput]] + + def connectionFields[C](relation: Relation, + fromModel: Model, + fromField: cool.graph.shared.models.Field, + toModel: Model, + objectType: ObjectType[C, DataItem]): List[Field[C, RelayResolveOutput]] = + List( + Field[C, RelayResolveOutput, Any, Any](name = "viewer", fieldType = viewerType, description = None, arguments = List(), resolve = ctx => ()), + Field[C, RelayResolveOutput, Any, Any](name = "clientMutationId", + fieldType = sangria.schema.StringType, + description = None, + arguments = List(), + resolve = ctx => { + ctx.value.clientMutationId + }), + Field[C, RelayResolveOutput, Any, Any](name = relation.bName(project), + fieldType = OptionType(objectType), + description = None, + arguments = List(), + resolve = ctx => { + ctx.value.item + }), + Field[C, RelayResolveOutput, Any, Any]( + name = relation.aName(project), + fieldType = OptionType( + modelObjectTypes( + fromField + .relatedModel(project) + .get + .name)), + description = None, + arguments = List(), + resolve = ctx => { + val mutationKey = + s"${fromField.relation.get.aName(project = project)}Id" + val input = ctx.value.args + .arg[Map[String, String]]("input") + val id = + input(mutationKey) + ctx.ctx + .asInstanceOf[UserContext] + .mutationDataresolver + .resolveByUnique(toModel, "id", id) + .map(_.get) + } + ), + Field[C, RelayResolveOutput, Any, Any]( + name = s"${relation.bName(project)}Edge", + fieldType = OptionType(edgeObjectTypes(fromModel.name)), + description = None, + arguments = List(), + resolve = ctx => { + DefaultEdge(ctx.value.item, ctx.value.item.id) + } + ), + Field[C, RelayResolveOutput, Any, Any]( + name = s"${relation.aName(project)}Edge", + fieldType = OptionType(edgeObjectTypes(fromField.relatedModel(project).get.name)), + description = None, + arguments = List(), + resolve = ctx => { + val mutationKey = + s"${fromField.relation.get.aName(project = project)}Id" + val input = ctx.value.args.arg[Map[String, String]]("input") + val id = input(mutationKey) + + ctx.ctx + .asInstanceOf[UserContext] + .mutationDataresolver + .resolveByUnique(toModel, "id", id) + .map(item => DefaultEdge(item.get, id)) + } + ) + ) + + def deletedIdField[C]() = + Field[C, RelayResolveOutput, Any, Any](name = "deletedId", + fieldType = OptionType(sangria.schema.IDType), + description = None, + arguments = List(), + resolve = ctx => ctx.value.item.id) + + override def mapCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"Create${model.name}Payload", + () => fields[C, RelayResolveOutput](createUpdateDeleteFields(model, objectType): _*) + ) + } + + // this is just a dummy method which isn't used right now, as the subscriptions are only available for the simple schema now + override def mapSubscriptionOutputType[C]( + model: Model, + objectType: ObjectType[C, DataItem], + updatedFields: Option[List[String]] = None, + mutation: ModelMutationType = cool.graph.shared.models.ModelMutationType.Created, + previousValues: Option[DataItem] = None, + dataItem: Option[RelayResolveOutput] + ): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"Create${model.name}Payload", + () => List() + ) + } + + override def mapUpdateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"Update${model.name}Payload", + () => fields[C, RelayResolveOutput](createUpdateDeleteFields(model, objectType): _*) + ) + } + + override def mapUpdateOrCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"UpdateOrCreate${model.name}Payload", + () => fields[C, RelayResolveOutput](createUpdateDeleteFields(model, objectType): _*) + ) + } + + override def mapDeleteOutputType[C](model: Model, objectType: ObjectType[C, DataItem], onlyId: Boolean = false): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"Delete${model.name}Payload", + () => fields[C, RelayResolveOutput](createUpdateDeleteFields(model, objectType) :+ deletedIdField(): _*) + ) + } + + override def mapAddToRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: cool.graph.shared.models.Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"${payloadName}Payload", + () => fields[C, RelayResolveOutput](connectionFields(relation, fromModel, fromField, toModel, objectType): _*) + ) + } + + override def mapRemoveFromRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: cool.graph.shared.models.Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, RelayResolveOutput] = { + ObjectType[C, RelayResolveOutput]( + name = s"${payloadName}Payload", + () => fields[C, RelayResolveOutput](connectionFields(relation, fromModel, fromField, toModel, objectType): _*) + ) + } + + override def mapResolve(item: DataItem, args: Args): RelayResolveOutput = + RelayResolveOutput(args + .arg[Map[String, Any]]("input")("clientMutationId") + .asInstanceOf[String], + item, + args) + +} diff --git a/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelaySchemaBuilder.scala b/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelaySchemaBuilder.scala new file mode 100644 index 0000000000..9eed57a860 --- /dev/null +++ b/server/backend-api-relay/src/main/scala/cool/graph/relay/schema/RelaySchemaBuilder.scala @@ -0,0 +1,90 @@ +package cool.graph.relay.schema + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.authProviders._ +import cool.graph.client._ +import cool.graph.client.database.DeferredTypes._ +import cool.graph.client.database.{DeferredResolverProvider, IdBasedConnection, RelayManyModelDeferredResolver, RelayToManyDeferredResolver} +import cool.graph.client.schema.SchemaBuilder +import cool.graph.client.schema.relay.RelaySchemaModelObjectTypeBuilder +import cool.graph.shared.models +import cool.graph.shared.models.Model +import sangria.schema._ +import scaldi._ + +// Todo: Decide if we really need UserContext instead of SimpleUserContext here. +// Or if we could use UserContext in the superclass. +class RelaySchemaBuilder(project: models.Project, modelPrefix: String = "")(implicit inj: Injector, actorSystem: ActorSystem, materializer: ActorMaterializer) + extends SchemaBuilder(project, modelPrefix)(inj, actorSystem, materializer) { + + type ManyDataItemType = RelayConnectionOutputType + + lazy val ViewerType: ObjectType[UserContext, Unit] = { + ObjectType( + "Viewer", + "This is the famous Relay viewer object", + fields[UserContext, Unit]( + includedModels.map(getAllItemsField) ++ userField.toList ++ includedModels + .map(getSingleItemField) ++ project.activeCustomQueryFunctions + .map(getCustomResolverField) :+ Field[UserContext, Unit, String, String](name = "id", + fieldType = IDType, + arguments = List(), + resolve = _ => s"viewer-fixed"): _* + ) + ) + } + + override val includeSubscription = false + override val modelObjectTypesBuilder = new RelaySchemaModelObjectTypeBuilder(project, Some(nodeInterface), modelPrefix) + override val modelObjectTypes = modelObjectTypesBuilder.modelObjectTypes + override val argumentSchema = RelayArgumentSchema + override val outputMapper = new RelayOutputMapper(ViewerType, edgeObjectTypes, modelObjectTypes, project) + override val deferredResolverProvider: DeferredResolverProvider[_, UserContext] = + new DeferredResolverProvider(new RelayToManyDeferredResolver, new RelayManyModelDeferredResolver) + + lazy val connectionObjectTypes = modelObjectTypesBuilder.modelConnectionTypes + lazy val edgeObjectTypes = modelObjectTypesBuilder.modelEdgeTypes + + override def getConnectionArguments(model: Model): List[Argument[Option[Any]]] = { + modelObjectTypesBuilder.mapToListConnectionArguments(model) + } + + override def resolveGetAllItemsQuery(model: Model, ctx: Context[UserContext, Unit]): sangria.schema.Action[UserContext, RelayConnectionOutputType] = { + val arguments = modelObjectTypesBuilder.extractQueryArgumentsFromContext(model, ctx) + + ManyModelDeferred[RelayConnectionOutputType](model, arguments) + } + + override def createManyFieldTypeForModel(model: Model): OutputType[IdBasedConnection[DataItem]] = { + connectionObjectTypes(model.name) + } + + def viewerField: Field[UserContext, Unit] = Field( + "viewer", + fieldType = ViewerType, + resolve = _ => () + ) + + override def buildQuery(): ObjectType[UserContext, Unit] = { + ObjectType( + "Query", + List(viewerField, nodeField) ++ Nil + ) + } + + override def getIntegrationFields: List[Field[UserContext, Unit]] = { + includedModels.find(_.name == "User") match { + case Some(_) => + AuthProviderManager.relayMutationFields(project, + includedModels.find(_.name == "User").get, + ViewerType, + modelObjectTypes("User"), + modelObjectTypesBuilder, + argumentSchema, + deferredResolverProvider) + case None => List() + } + } +} diff --git a/server/backend-api-schema-manager/build.sbt b/server/backend-api-schema-manager/build.sbt new file mode 100644 index 0000000000..0041a2521d --- /dev/null +++ b/server/backend-api-schema-manager/build.sbt @@ -0,0 +1 @@ +name := "backend-api-schema-manager" diff --git a/server/backend-api-schema-manager/project/build.properties b/server/backend-api-schema-manager/project/build.properties new file mode 100644 index 0000000000..27e88aa115 --- /dev/null +++ b/server/backend-api-schema-manager/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 diff --git a/server/backend-api-schema-manager/project/plugins.sbt b/server/backend-api-schema-manager/project/plugins.sbt new file mode 100644 index 0000000000..a86a46d973 --- /dev/null +++ b/server/backend-api-schema-manager/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.3") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.0") diff --git a/server/backend-api-schema-manager/src/main/resources/application.conf b/server/backend-api-schema-manager/src/main/resources/application.conf new file mode 100644 index 0000000000..b73eb04e12 --- /dev/null +++ b/server/backend-api-schema-manager/src/main/resources/application.conf @@ -0,0 +1,49 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + remote-address-header = on + request-timeout = 45s + } +} + +schemaManagerSecret = ${SCHEMA_MANAGER_SECRET} +awsRegion = ${AWS_REGION} + +internal { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = ${?SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +# Test DBs +internalTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +internalTestRoot { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} \ No newline at end of file diff --git a/server/backend-api-schema-manager/src/main/resources/logback.xml b/server/backend-api-schema-manager/src/main/resources/logback.xml new file mode 100644 index 0000000000..c1f586b1c6 --- /dev/null +++ b/server/backend-api-schema-manager/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/server/backend-api-schema-manager/src/main/scala/SchemaManagerMain.scala b/server/backend-api-schema-manager/src/main/scala/SchemaManagerMain.scala new file mode 100644 index 0000000000..a4e844adc1 --- /dev/null +++ b/server/backend-api-schema-manager/src/main/scala/SchemaManagerMain.scala @@ -0,0 +1,15 @@ +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnagger +import cool.graph.schemamanager.{SchemaManagerDependencies, SchemaManagerServer} +import scaldi.Injectable + +object SchemaManagerMain extends App with Injectable { + implicit val system = ActorSystem("sangria-server") + implicit val materializer = ActorMaterializer() + implicit val inj = SchemaManagerDependencies() + implicit val bugSnagger = inject[BugSnagger] + + ServerExecutor(port = 8087, SchemaManagerServer("schema-manager")).startBlocking() +} diff --git a/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerDependencies.scala b/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerDependencies.scala new file mode 100644 index 0000000000..bc7ad4e889 --- /dev/null +++ b/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerDependencies.scala @@ -0,0 +1,51 @@ +package cool.graph.schemamanager + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import com.typesafe.config.ConfigFactory +import cool.graph.bugsnag.{BugSnagger, BugSnaggerImpl} +import cool.graph.cloudwatch.CloudwatchImpl +import cool.graph.system.database.finder._ +import cool.graph.system.metrics.SystemMetrics +import scaldi.Module +import slick.jdbc.MySQLProfile +import slick.jdbc.MySQLProfile.api._ + +trait SchemaManagerApiDependencies extends Module { + implicit val system: ActorSystem + implicit val materializer: ActorMaterializer + + lazy val config = ConfigFactory.load() + + val internalDb: MySQLProfile.backend.DatabaseDef + val uncachedProjectResolver: UncachedProjectResolver + val cachedProjectResolver: CachedProjectResolver + val requestPrefix: String + + SystemMetrics.init() + + binding identifiedBy "cloudwatch" toNonLazy CloudwatchImpl() + binding identifiedBy "config" toNonLazy config + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + binding identifiedBy "actorSystem" toNonLazy system destroyWith (_.terminate()) + binding identifiedBy "dispatcher" toNonLazy system.dispatcher + binding identifiedBy "actorMaterializer" toNonLazy materializer + + bind[BugSnagger] toNonLazy BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) +} + +case class SchemaManagerDependencies()(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends SchemaManagerApiDependencies { + import system.dispatcher + + lazy val internalDb = Database.forConfig("internal", config) + lazy val uncachedProjectResolver = UncachedProjectResolver(internalDb) + lazy val cachedProjectResolver: CachedProjectResolver = CachedProjectResolverImpl(uncachedProjectResolver) + lazy val requestPrefix = sys.env.getOrElse("AWS_REGION", sys.error("AWS Region not found.")) + + bind[String] identifiedBy "request-prefix" toNonLazy requestPrefix + + binding identifiedBy "internal-db" toNonLazy internalDb + binding identifiedBy "cachedProjectResolver" toNonLazy cachedProjectResolver + binding identifiedBy "uncachedProjectResolver" toNonLazy uncachedProjectResolver +} diff --git a/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerServer.scala b/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerServer.scala new file mode 100644 index 0000000000..a41e5a7b40 --- /dev/null +++ b/server/backend-api-schema-manager/src/main/scala/cool/graph/schemamanager/SchemaManagerServer.scala @@ -0,0 +1,110 @@ +package cool.graph.schemamanager + +import akka.actor.ActorSystem +import akka.http.scaladsl.model.StatusCodes.{BadRequest, OK, Unauthorized} +import akka.http.scaladsl.server.Directives.{complete, get, handleExceptions, optionalHeaderValueByName, parameters, pathPrefix, _} +import akka.http.scaladsl.server.PathMatchers.Segment +import akka.stream.ActorMaterializer +import com.typesafe.config.Config +import com.typesafe.scalalogging.LazyLogging +import cool.graph.akkautil.http.Server +import cool.graph.bugsnag.BugSnagger +import cool.graph.shared.SchemaSerializer +import cool.graph.shared.errors.SystemErrors.InvalidProjectId +import cool.graph.shared.logging.RequestLogger +import cool.graph.shared.models.ProjectWithClientId +import cool.graph.system.database.finder.{CachedProjectResolver, ProjectResolver} +import cool.graph.util.ErrorHandlerFactory +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.Future + +case class SchemaManagerServer(prefix: String = "")( + implicit system: ActorSystem, + materializer: ActorMaterializer, + bugsnag: BugSnagger, + inj: Injector +) extends Server + with Injectable + with LazyLogging { + import system.dispatcher + + val config = inject[Config](identified by "config") + val internalDatabase = inject[DatabaseDef](identified by "internal-db") + val cachedProjectResolver = inject[CachedProjectResolver](identified by "cachedProjectResolver") + val uncachedProjectResolver = inject[ProjectResolver](identified by "uncachedProjectResolver") + val schemaManagerSecret = config.getString("schemaManagerSecret") + val log: (String) => Unit = (x: String) => logger.info(x) + val errorHandlerFactory = ErrorHandlerFactory(log) + val requestPrefix = inject[String](identified by "request-prefix") + + val innerRoutes = extractRequest { _ => + val requestLogger = new RequestLogger(requestPrefix + ":schema-manager", log = log) + val requestId = requestLogger.begin + + handleExceptions(errorHandlerFactory.akkaHttpHandler(requestId)) { + pathPrefix(Segment) { projectId => + get { + optionalHeaderValueByName("Authorization") { + case Some(authorizationHeader) if authorizationHeader == s"Bearer $schemaManagerSecret" => + parameters('forceRefresh ? false) { forceRefresh => + complete(performRequest(projectId, forceRefresh, requestLogger)) + } + + case Some(h) => + println(s"Wrong Authorization Header supplied: '$h'") + complete(Unauthorized -> "Wrong Authorization Header supplied") + + case None => + println("No Authorization Header supplied") + complete(Unauthorized -> "No Authorization Header supplied") + } + } + } + } + } + + def performRequest(projectId: String, forceRefresh: Boolean, requestLogger: RequestLogger) = { + getSchema(projectId, forceRefresh) + .map(res => OK -> res) + .andThen { + case _ => requestLogger.end(Some(projectId), None) + } + .recover { + case error: Throwable => + val unhandledErrorLogger = errorHandlerFactory.unhandledErrorHandler( + requestId = requestLogger.requestId, + projectId = Some(projectId) + ) + + BadRequest -> unhandledErrorLogger(error)._2.toString + } + } + + def getSchema(projectId: String, forceRefresh: Boolean): Future[String] = { + val project: Future[Option[ProjectWithClientId]] = forceRefresh match { + case true => + for { + projectWithClientId <- uncachedProjectResolver.resolveProjectWithClientId(projectId) + _ <- cachedProjectResolver.invalidate(projectId) + } yield { + projectWithClientId + } + + case false => + cachedProjectResolver.resolveProjectWithClientId(projectId) + } + + project map { + case None => throw InvalidProjectId(projectId) + case Some(schema) => SchemaSerializer.serialize(schema) + } + } + + def healthCheck = + for { + internalDb <- internalDatabase.run(sql"SELECT 1".as[Int]) + } yield internalDb +} diff --git a/server/backend-api-simple-subscriptions/README.md b/server/backend-api-simple-subscriptions/README.md new file mode 100644 index 0000000000..e9ed9b8b8e --- /dev/null +++ b/server/backend-api-simple-subscriptions/README.md @@ -0,0 +1,3 @@ +# Architecture Overview + +You can find the architecture overview [here](../backend-api-subscriptions-websocket/README.md). diff --git a/server/backend-api-simple-subscriptions/build.sbt b/server/backend-api-simple-subscriptions/build.sbt new file mode 100644 index 0000000000..1679302107 --- /dev/null +++ b/server/backend-api-simple-subscriptions/build.sbt @@ -0,0 +1 @@ +name := "backend-api-simple-subscriptions" diff --git a/server/backend-api-simple-subscriptions/project/build.properties b/server/backend-api-simple-subscriptions/project/build.properties new file mode 100644 index 0000000000..5f32afe7da --- /dev/null +++ b/server/backend-api-simple-subscriptions/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 \ No newline at end of file diff --git a/server/backend-api-simple-subscriptions/project/plugins.sbt b/server/backend-api-simple-subscriptions/project/plugins.sbt new file mode 100644 index 0000000000..a86a46d973 --- /dev/null +++ b/server/backend-api-simple-subscriptions/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.3") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.0") diff --git a/server/backend-api-simple-subscriptions/src/main/resources/application.conf b/server/backend-api-simple-subscriptions/src/main/resources/application.conf new file mode 100644 index 0000000000..47bca1d4f2 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/resources/application.conf @@ -0,0 +1,112 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + remote-address-header = on + } + actor.provider = "akka.cluster.ClusterActorRefProvider" + loglevel = WARNING + remote { + log-remote-lifecycle-events = off + netty.tcp { + hostname = "127.0.0.1" + port = 0 + port = ${?AKKA_CLUSTER_PORT} + } + } + + test { + single-expect-default = 6s + } +} + + +jwtSecret = ${?JWT_SECRET} +schemaManagerEndpoint = ${SCHEMA_MANAGER_ENDPOINT} +schemaManagerSecret = ${SCHEMA_MANAGER_SECRET} +awsAccessKeyId = ${AWS_ACCESS_KEY_ID} +awsSecretAccessKey = ${AWS_SECRET_ACCESS_KEY} +awsRegion = ${AWS_REGION} + +internal { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = ${?SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientDatabases { + client1 { + master { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + readonly { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_READONLY_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + readOnly = true + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + } +} + + +# test DBs +internalTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + maxConnections = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +internalTestRoot { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + maxConnections = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_CLIENT_HOST}":"${?TEST_SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_CLIENT_USER} + password = ${?TEST_SQL_CLIENT_PASSWORD} + } + numThreads = ${?TEST_SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +slick.dbs.default.db.connectionInitSql="set names utf8mb4" diff --git a/server/backend-api-simple-subscriptions/src/main/resources/logback.xml b/server/backend-api-simple-subscriptions/src/main/resources/logback.xml new file mode 100644 index 0000000000..ec842e3270 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionDependencies.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionDependencies.scala new file mode 100644 index 0000000000..6c61e59bd6 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionDependencies.scala @@ -0,0 +1,109 @@ +package cool.graph.subscriptions + +import akka.actor.{ActorSystem, Props} +import akka.stream.ActorMaterializer +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} +import com.typesafe.config.ConfigFactory +import cool.graph.bugsnag.{BugSnagger, BugSnaggerImpl} +import cool.graph.client.FeatureMetricActor +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.finder.ProjectFetcherImpl +import cool.graph.client.metrics.ApiMetricsMiddleware +import cool.graph.cloudwatch.CloudwatchImpl +import cool.graph.messagebus.pubsub.rabbit.RabbitAkkaPubSub +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.messagebus.{Conversions, PubSubPublisher, PubSubSubscriber, QueueConsumer} +import cool.graph.shared.{ApiMatrixFactory, DefaultApiMatrix} +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.externalServices.{KinesisPublisher, KinesisPublisherImplementation, TestableTime, TestableTimeImplementation} +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.SubscriptionSessionResponse +import cool.graph.subscriptions.protocol.SubscriptionRequest +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.{SchemaInvalidated, SchemaInvalidatedMessage} +import scaldi._ + +trait SimpleSubscriptionApiDependencies extends Module { + implicit val system: ActorSystem + implicit val materializer: ActorMaterializer + + val invalidationSubscriber: PubSubSubscriber[SchemaInvalidatedMessage] + val sssEventsSubscriber: PubSubSubscriber[String] + val responsePubSubPublisherV05: PubSubPublisher[SubscriptionSessionResponseV05] + val responsePubSubPublisherV07: PubSubPublisher[SubscriptionSessionResponse] + val requestsQueueConsumer: QueueConsumer[SubscriptionRequest] + + lazy val config = ConfigFactory.load() + lazy val testableTime = new TestableTimeImplementation + lazy val apiMetricsFlushInterval = 10 + lazy val kinesis = createKinesis() + lazy val apiMetricsPublisher = new KinesisPublisherImplementation(streamName = sys.env("KINESIS_STREAM_API_METRICS"), kinesis) + lazy val clientAuth = ClientAuthImpl() + lazy val featureMetricActor = system.actorOf(Props(new FeatureMetricActor(apiMetricsPublisher, apiMetricsFlushInterval))) + + implicit lazy val bugsnagger = BugSnaggerImpl(sys.env.getOrElse("BUGSNAG_API_KEY", "")) + + bind[GlobalDatabaseManager] toNonLazy GlobalDatabaseManager.initializeForSingleRegion(config) + bind[BugSnagger] toNonLazy bugsnagger + bind[TestableTime] toNonLazy new TestableTimeImplementation + bind[ClientAuth] toNonLazy clientAuth + bind[KinesisPublisher] identifiedBy "kinesisApiMetricsPublisher" toNonLazy new KinesisPublisherImplementation( + streamName = sys.env("KINESIS_STREAM_API_METRICS"), + kinesis + ) + + binding identifiedBy "kinesis" toNonLazy kinesis + binding identifiedBy "cloudwatch" toNonLazy CloudwatchImpl() + binding identifiedBy "config" toNonLazy ConfigFactory.load() + binding identifiedBy "actorSystem" toNonLazy system destroyWith (_.terminate()) + binding identifiedBy "dispatcher" toNonLazy system.dispatcher + binding identifiedBy "actorMaterializer" toNonLazy materializer + binding identifiedBy "featureMetricActor" to featureMetricActor + binding identifiedBy "api-metrics-middleware" toNonLazy new ApiMetricsMiddleware(testableTime, featureMetricActor) + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + + protected def createKinesis(): AmazonKinesis = { + val credentials = new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonKinesisClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("KINESIS_ENDPOINT"), sys.env("AWS_REGION"))) + .build() + } +} + +case class SimpleSubscriptionDependencies()(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends SimpleSubscriptionApiDependencies { + import SubscriptionRequest._ + import cool.graph.subscriptions.protocol.Converters._ + + val globalRabbitUri = sys.env("GLOBAL_RABBIT_URI") + implicit val unmarshaller = (_: Array[Byte]) => SchemaInvalidated + val apiMatrixFactory: ApiMatrixFactory = ApiMatrixFactory(DefaultApiMatrix(_)) + + val invalidationSubscriber: PubSubSubscriber[SchemaInvalidatedMessage] = + RabbitAkkaPubSub.subscriber[SchemaInvalidatedMessage](globalRabbitUri, "project-schema-invalidation", durable = true) + + val clusterLocalRabbitUri = sys.env("RABBITMQ_URI") + + val sssEventsSubscriber = + RabbitAkkaPubSub.subscriber[String](clusterLocalRabbitUri, "sss-events", durable = true)(bugsnagger, system, Conversions.Unmarshallers.ToString) + + val responsePubSubPublisher: PubSubPublisher[String] = + RabbitAkkaPubSub.publisher[String](clusterLocalRabbitUri, "subscription-responses", durable = false)(bugsnagger, Conversions.Marshallers.FromString) + + val responsePubSubPublisherV05 = responsePubSubPublisher.map[SubscriptionSessionResponseV05](converterResponse05ToString) + val responsePubSubPublisherV07 = responsePubSubPublisher.map[SubscriptionSessionResponse](converterResponse07ToString) + val requestsQueueConsumer = RabbitQueue.consumer[SubscriptionRequest](clusterLocalRabbitUri, "subscription-requests") + + bind[QueueConsumer[SubscriptionRequest]] identifiedBy "subscription-requests-consumer" toNonLazy requestsQueueConsumer + bind[PubSubPublisher[SubscriptionSessionResponseV05]] identifiedBy "subscription-responses-publisher-05" toNonLazy responsePubSubPublisherV05 + bind[PubSubPublisher[SubscriptionSessionResponse]] identifiedBy "subscription-responses-publisher-07" toNonLazy responsePubSubPublisherV07 + bind[PubSubSubscriber[SchemaInvalidatedMessage]] identifiedBy "schema-invalidation-subscriber" toNonLazy invalidationSubscriber + bind[PubSubSubscriber[String]] identifiedBy "sss-events-subscriber" toNonLazy sssEventsSubscriber + bind[ApiMatrixFactory] toNonLazy apiMatrixFactory + + binding identifiedBy "project-schema-fetcher" toNonLazy ProjectFetcherImpl(blockedProjectIds = Vector.empty, config) +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionsMain.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionsMain.scala new file mode 100644 index 0000000000..4c7cea5468 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/SubscriptionsMain.scala @@ -0,0 +1,93 @@ +package cool.graph.subscriptions + +import akka.actor.{ActorSystem, Props} +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.{Routes, Server, ServerExecutor} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.pubsub.Only +import cool.graph.messagebus.{PubSubPublisher, PubSubSubscriber, QueueConsumer} +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Requests.SubscriptionSessionRequestV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Requests.SubscriptionSessionRequest +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.{GqlError, SubscriptionSessionResponse} +import cool.graph.subscriptions.protocol.SubscriptionSessionManager.Requests.{EnrichedSubscriptionRequest, EnrichedSubscriptionRequestV05, StopSession} +import cool.graph.subscriptions.protocol.{StringOrInt, SubscriptionRequest, SubscriptionSessionManager} +import cool.graph.subscriptions.resolving.SubscriptionsManager +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.SchemaInvalidatedMessage +import cool.graph.subscriptions.util.PlayJson +import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport +import play.api.libs.json.{JsError, JsSuccess} +import scaldi.akka.AkkaInjectable +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future + +object SubscriptionsMain extends App with Injectable { + implicit val system = ActorSystem("graphql-subscriptions") + implicit val materializer = ActorMaterializer() + implicit val inj = SimpleSubscriptionDependencies() + + ServerExecutor(port = 8086, SimpleSubscriptionsServer()).startBlocking() +} + +case class SimpleSubscriptionsServer(prefix: String = "")( + implicit inj: Injector, + system: ActorSystem, + materializer: ActorMaterializer +) extends Server + with AkkaInjectable + with PlayJsonSupport { + import system.dispatcher + + implicit val bugSnag = inject[BugSnagger] + implicit val response05Publisher = inject[PubSubPublisher[SubscriptionSessionResponseV05]](identified by "subscription-responses-publisher-05") + implicit val response07Publisher = inject[PubSubPublisher[SubscriptionSessionResponse]](identified by "subscription-responses-publisher-07") + + val innerRoutes = Routes.emptyRoute + val subscriptionsManager = system.actorOf(Props(new SubscriptionsManager(bugSnag)), "subscriptions-manager") + val requestsConsumer = inject[QueueConsumer[SubscriptionRequest]](identified by "subscription-requests-consumer") + + val consumerRef = requestsConsumer.withConsumer { req: SubscriptionRequest => + Future { + if (req.body == "STOP") { + subscriptionSessionManager ! StopSession(req.sessionId) + } else { + handleProtocolMessage(req.projectId, req.sessionId, req.body) + } + } + } + + val subscriptionSessionManager = system.actorOf( + Props(new SubscriptionSessionManager(subscriptionsManager, bugSnag)), + "subscriptions-sessions-manager" + ) + + def handleProtocolMessage(projectId: String, sessionId: String, messageBody: String) = { + import cool.graph.subscriptions.protocol.ProtocolV05.SubscriptionRequestReaders._ + import cool.graph.subscriptions.protocol.ProtocolV07.SubscriptionRequestReaders._ + + val currentProtocol = PlayJson.parse(messageBody).flatMap(_.validate[SubscriptionSessionRequest]) + lazy val oldProtocol = PlayJson.parse(messageBody).flatMap(_.validate[SubscriptionSessionRequestV05]) + + currentProtocol match { + case JsSuccess(request, _) => + subscriptionSessionManager ! EnrichedSubscriptionRequest(sessionId = sessionId, projectId = projectId, request) + + case JsError(newError) => + oldProtocol match { + case JsSuccess(request, _) => + subscriptionSessionManager ! EnrichedSubscriptionRequestV05(sessionId = sessionId, projectId = projectId, request) + + case JsError(oldError) => + response07Publisher.publish(Only(sessionId), GqlError(StringOrInt(string = Some(""), int = None), "The message can't be parsed")) + } + } + } + + override def healthCheck: Future[_] = Future.successful(()) + + override def onStop = Future { + consumerRef.stop + inject[PubSubSubscriber[SchemaInvalidatedMessage]](identified by "schema-invalidation-subscriber").shutdown + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/Auth.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/Auth.scala new file mode 100644 index 0000000000..178227decd --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/Auth.scala @@ -0,0 +1,31 @@ +package cool.graph.subscriptions.helpers + +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.shared.models.{AuthenticatedRequest, Project} +import scaldi.{Injectable, Injector} +import cool.graph.utils.future.FutureUtils._ + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +object Auth extends Injectable { + def getAuthContext(project: Project, authHeader: Option[String])(implicit inj: Injector, ec: ExecutionContext): Future[Option[AuthenticatedRequest]] = { + val clientAuth = inject[ClientAuth] + val token = authHeader.flatMap { + case str if str.startsWith("Bearer ") => Some(str.stripPrefix("Bearer ")) + case _ => None + } + + token match { + case None => Future.successful(None) + case Some(sessionToken) => + clientAuth + .authenticateRequest(sessionToken, project) + .toFutureTry + .flatMap { + case Success(authedReq) => Future.successful(Some(authedReq)) + case Failure(_) => Future.successful(None) + } + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/ProjectHelper.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/ProjectHelper.scala new file mode 100644 index 0000000000..5c68015b87 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/helpers/ProjectHelper.scala @@ -0,0 +1,38 @@ +package cool.graph.subscriptions.helpers + +import akka.actor.{ActorRef, ActorSystem} +import cool.graph.client.finder.ProjectFetcher +import cool.graph.client.{ApiFeatureMetric, FeatureMetric} +import cool.graph.shared.models.ProjectWithClientId +import cool.graph.shared.externalServices.TestableTime +import scaldi.Injector +import scaldi.akka.AkkaInjectable + +import scala.concurrent.{ExecutionContext, Future} + +object ProjectHelper extends AkkaInjectable { + def resolveProject(projectId: String)(implicit inj: Injector, as: ActorSystem, ec: ExecutionContext): Future[ProjectWithClientId] = { + val schemaFetcher = inject[ProjectFetcher](identified by "project-schema-fetcher") + + schemaFetcher.fetch(projectId).map { + case None => + sys.error(s"ProjectHelper: Could not resolve project with id: $projectId") + + case Some(project: ProjectWithClientId) => { + val apiMetricActor = inject[ActorRef](identified by "featureMetricActor") + val testableTime = inject[TestableTime] + + apiMetricActor ! ApiFeatureMetric( + "", + testableTime.DateTime, + project.project.id, + project.clientId, + List(FeatureMetric.Subscriptions.toString), + isFromConsole = false + ) + + project + } + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/metrics/SubscriptionMetrics.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/metrics/SubscriptionMetrics.scala new file mode 100644 index 0000000000..ae57f86399 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/metrics/SubscriptionMetrics.scala @@ -0,0 +1,22 @@ +package cool.graph.subscriptions.metrics + +import cool.graph.metrics.{CustomTag, MetricsManager} +import cool.graph.profiling.MemoryProfiler + +object SubscriptionMetrics extends MetricsManager { + override def serviceName = "SimpleSubscriptionService" + + MemoryProfiler.schedule(this) + + // Actor Counts + val activeSubcriptionSessions = defineGauge("activeSubscriptionSessions") + val activeSubscriptionsManagerForProject = defineGauge("activeSubscriptionsManagerForProject") + val activeSubscriptionsManagerForModelAndMutation = defineGauge("activeSubscriptionsManagerForModelAndMutation") + + val activeSubscriptions = defineGauge("activeSubscriptions") + + val projectIdTag = CustomTag("projectId") + val databaseEventRate = defineCounter("databaseEventRate", projectIdTag) + val handleDatabaseEventRate = defineCounter("handleDatabaseEventRate", projectIdTag) + val handleDatabaseEventTimer = defineTimer("databaseEventTimer", projectIdTag) +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/Converters.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/Converters.scala new file mode 100644 index 0000000000..b1cccfc683 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/Converters.scala @@ -0,0 +1,17 @@ +package cool.graph.subscriptions.protocol + +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.SubscriptionSessionResponse +import play.api.libs.json.Json + +object Converters { + val converterResponse07ToString = (response: SubscriptionSessionResponse) => { + import cool.graph.subscriptions.protocol.ProtocolV07.SubscriptionResponseWriters._ + Json.toJson(response).toString + } + + val converterResponse05ToString = (response: SubscriptionSessionResponseV05) => { + import cool.graph.subscriptions.protocol.ProtocolV05.SubscriptionResponseWriters._ + Json.toJson(response).toString + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocol.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocol.scala new file mode 100644 index 0000000000..3685dfe4b9 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocol.scala @@ -0,0 +1,198 @@ +package cool.graph.subscriptions.protocol + +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.{InitConnectionFail, SubscriptionErrorPayload, SubscriptionFail} +import play.api.libs.json._ + +case class StringOrInt(string: Option[String], int: Option[Int]) { + def asString = string.orElse(int.map(_.toString)).get +} + +object StringOrInt { + implicit val writer = new Writes[StringOrInt] { + def writes(stringOrInt: StringOrInt): JsValue = { + stringOrInt match { + case StringOrInt(Some(id), _) => JsString(id) + case StringOrInt(_, Some(id)) => JsNumber(id) + case _ => sys.error("writes: this StringOrInt is neither") + } + } + } +} + +object SubscriptionProtocolV07 { + val protocolName = "graphql-ws" + + object MessageTypes { + val GQL_CONNECTION_INIT = "connection_init" // Client -> Server + val GQL_CONNECTION_TERMINATE = "connection_terminate" // Client -> Server + val GQL_CONNECTION_ACK = "connection_ack" // Server -> Client + val GQL_CONNECTION_ERROR = "connection_error" // Server -> Client + val GQL_CONNECTION_KEEP_ALIVE = "ka" // Server -> Client + + val GQL_START = "start" // Client -> Server + val GQL_STOP = "stop" // Client -> Server + val GQL_DATA = "data" // Server -> Client + val GQL_ERROR = "error" // Server -> Client + val GQL_COMPLETE = "complete" // Server -> Client + } + + /** + * REQUESTS + */ + object Requests { + sealed trait SubscriptionSessionRequest { + def `type`: String + } + + case class GqlConnectionInit(payload: Option[JsObject]) extends SubscriptionSessionRequest { + val `type` = MessageTypes.GQL_CONNECTION_INIT + } + + object GqlConnectionTerminate extends SubscriptionSessionRequest { + val `type` = MessageTypes.GQL_CONNECTION_TERMINATE + } + + case class GqlStart(id: StringOrInt, payload: GqlStartPayload) extends SubscriptionSessionRequest { + val `type` = MessageTypes.GQL_START + } + + case class GqlStartPayload(query: String, variables: Option[JsObject], operationName: Option[String]) + + case class GqlStop(id: StringOrInt) extends SubscriptionSessionRequest { + val `type` = MessageTypes.GQL_STOP + } + } + + /** + * RESPONSES + */ + object Responses { + sealed trait SubscriptionSessionResponse { + def `type`: String + } + + object GqlConnectionAck extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_CONNECTION_ACK + } + + case class GqlConnectionError(payload: ErrorMessage) extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_CONNECTION_ERROR + } + + object GqlConnectionKeepAlive extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_CONNECTION_KEEP_ALIVE + } + + case class GqlData(id: StringOrInt, payload: JsValue) extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_DATA + } + case class GqlDataPayload(data: JsValue, errors: Option[Seq[ErrorMessage]] = None) + + case class GqlError(id: StringOrInt, payload: ErrorMessage) extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_ERROR + } + + case class GqlComplete(id: StringOrInt) extends SubscriptionSessionResponse { + val `type` = MessageTypes.GQL_COMPLETE + } + + /** + * Companions for the Responses + */ + object GqlConnectionError { + def apply(errorMessage: String): GqlConnectionError = GqlConnectionError(ErrorMessage(errorMessage)) + } + object GqlError { + def apply(id: StringOrInt, errorMessage: String): GqlError = GqlError(id, ErrorMessage(errorMessage)) + } + } +} + +object SubscriptionProtocolV05 { + val protocolName = "graphql-subscriptions" + + object MessageTypes { + val INIT = "init" // Client -> Server + val INIT_FAIL = "init_fail" // Server -> Client + val INIT_SUCCESS = "init_success" // Server -> Client + val KEEPALIVE = "keepalive" // Server -> Client + + val SUBSCRIPTION_START = "subscription_start" // Client -> Server + val SUBSCRIPTION_END = "subscription_end" // Client -> Server + val SUBSCRIPTION_SUCCESS = "subscription_success" // Server -> Client + val SUBSCRIPTION_FAIL = "subscription_fail" // Server -> Client + val SUBSCRIPTION_DATA = "subscription_data" // Server -> Client + } + + /** + * REQUESTS + */ + object Requests { + sealed trait SubscriptionSessionRequestV05 { + def `type`: String + } + + case class InitConnection(payload: Option[JsObject]) extends SubscriptionSessionRequestV05 { + val `type` = MessageTypes.INIT + } + + case class SubscriptionStart(id: StringOrInt, query: String, variables: Option[JsObject], operationName: Option[String]) + extends SubscriptionSessionRequestV05 { + + val `type` = MessageTypes.SUBSCRIPTION_START + } + + case class SubscriptionEnd(id: Option[StringOrInt]) extends SubscriptionSessionRequestV05 { + val `type` = MessageTypes.SUBSCRIPTION_END + } + } + + /** + * RESPONSES + */ + object Responses { + sealed trait SubscriptionSessionResponseV05 { + def `type`: String + } + + object InitConnectionSuccess extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.INIT_SUCCESS + } + + case class InitConnectionFail(payload: ErrorMessage) extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.INIT_FAIL + } + + case class SubscriptionSuccess(id: StringOrInt) extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.SUBSCRIPTION_SUCCESS + } + + case class SubscriptionFail(id: StringOrInt, payload: SubscriptionErrorPayload) extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.SUBSCRIPTION_FAIL + } + + case class SubscriptionData(id: StringOrInt, payload: JsValue) extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.SUBSCRIPTION_DATA + } + + object SubscriptionKeepAlive extends SubscriptionSessionResponseV05 { + val `type` = MessageTypes.KEEPALIVE + } + + case class SubscriptionErrorPayload(errors: Seq[ErrorMessage]) + + /** + * Companions for the Responses + */ + object SubscriptionFail { + def apply(id: StringOrInt, errorMessage: String): SubscriptionFail = { + SubscriptionFail(id, SubscriptionErrorPayload(Seq(ErrorMessage(errorMessage)))) + } + } + object InitConnectionFail { + def apply(errorMessage: String): InitConnectionFail = InitConnectionFail(ErrorMessage(errorMessage)) + } + } +} + +case class ErrorMessage(message: String) diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocolSerializers.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocolSerializers.scala new file mode 100644 index 0000000000..95d898662a --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionProtocolSerializers.scala @@ -0,0 +1,147 @@ +package cool.graph.subscriptions.protocol + +import play.api.libs.json._ + +object ProtocolV07 { + + object SubscriptionResponseWriters { + import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses._ + val emptyJson = Json.obj() + + implicit lazy val subscriptionResponseWrites = new Writes[SubscriptionSessionResponse] { + implicit lazy val stringOrIntWrites = StringOrInt.writer + implicit lazy val errorWrites = Json.writes[ErrorMessage] + implicit lazy val gqlConnectionErrorWrites = Json.writes[GqlConnectionError] + implicit lazy val gqlDataPayloadWrites = Json.writes[GqlDataPayload] + implicit lazy val gqlDataWrites = Json.writes[GqlData] + implicit lazy val gqlErrorWrites = Json.writes[GqlError] + implicit lazy val gqlCompleteWrites = Json.writes[GqlComplete] + + override def writes(resp: SubscriptionSessionResponse): JsValue = { + val json = resp match { + case GqlConnectionAck => emptyJson + case x: GqlConnectionError => gqlConnectionErrorWrites.writes(x) + case GqlConnectionKeepAlive => emptyJson + case x: GqlData => gqlDataWrites.writes(x) + case x: GqlError => gqlErrorWrites.writes(x) + case x: GqlComplete => gqlCompleteWrites.writes(x) + } + json + ("type", JsString(resp.`type`)) + } + } + } + + object SubscriptionRequestReaders { + import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Requests._ + + implicit lazy val stringOrIntReads = CommonReaders.stringOrIntReads + implicit lazy val initReads = Json.reads[GqlConnectionInit] + implicit lazy val gqlStartPayloadReads = Json.reads[GqlStartPayload] + implicit lazy val gqlStartReads = Json.reads[GqlStart] + implicit lazy val gqlStopReads = Json.reads[GqlStop] + + implicit lazy val subscriptionRequestReadsV07 = new Reads[SubscriptionSessionRequest] { + import SubscriptionProtocolV07.MessageTypes + + override def reads(json: JsValue): JsResult[SubscriptionSessionRequest] = { + (json \ "type").validate[String] match { + case x: JsError => + x + case JsSuccess(value, _) => + value match { + case MessageTypes.GQL_CONNECTION_INIT => + initReads.reads(json) + case MessageTypes.GQL_CONNECTION_TERMINATE => + JsSuccess(GqlConnectionTerminate) + case MessageTypes.GQL_START => + gqlStartReads.reads(json) + case MessageTypes.GQL_STOP => + gqlStopReads.reads(json) + case _ => + JsError(error = s"Message could not be parsed. Message Type '$value' is not defined.") + } + } + } + } + } +} + +object ProtocolV05 { + object SubscriptionResponseWriters { + import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses._ + val emptyJson = Json.obj() + + implicit lazy val subscriptionResponseWrites = new Writes[SubscriptionSessionResponseV05] { + implicit val stringOrIntWrites = StringOrInt.writer + implicit lazy val errorWrites = Json.writes[ErrorMessage] + implicit lazy val subscriptionErrorPayloadWrites = Json.writes[SubscriptionErrorPayload] + implicit lazy val subscriptionFailWrites = Json.writes[SubscriptionFail] + implicit lazy val subscriptionSuccessWrites = Json.writes[SubscriptionSuccess] + implicit lazy val subscriptionDataWrites = Json.writes[SubscriptionData] + implicit lazy val initConnectionFailWrites = Json.writes[InitConnectionFail] + + override def writes(resp: SubscriptionSessionResponseV05): JsValue = { + val json = resp match { + case InitConnectionSuccess => emptyJson + case x: InitConnectionFail => initConnectionFailWrites.writes(x) + case x: SubscriptionSuccess => subscriptionSuccessWrites.writes(x) + case x: SubscriptionFail => subscriptionFailWrites.writes(x) + case x: SubscriptionData => subscriptionDataWrites.writes(x) + case SubscriptionKeepAlive => emptyJson + } + json + ("type", JsString(resp.`type`)) + } + } + } + + object SubscriptionRequestReaders { + import CommonReaders._ + import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Requests._ + import play.api.libs.functional.syntax._ + + implicit lazy val subscriptionStartReads = ( + (JsPath \ "id").read(stringOrIntReads) and + (JsPath \ "query").read[String] and + (JsPath \ "variables").readNullable[JsObject] and + (JsPath \ "operationName").readNullable[String] + )(SubscriptionStart.apply _) + + implicit lazy val subscriptionEndReads = + (JsPath \ "id").readNullable(stringOrIntReads).map(id => SubscriptionEnd(id)) + + implicit lazy val subscriptionInitReads = Json.reads[InitConnection] + + implicit lazy val subscriptionRequestReadsV05 = new Reads[SubscriptionSessionRequestV05] { + import SubscriptionProtocolV05.MessageTypes + + override def reads(json: JsValue): JsResult[SubscriptionSessionRequestV05] = { + (json \ "type").validate[String] match { + case x: JsError => + x + case JsSuccess(value, _) => + value match { + case MessageTypes.INIT => + subscriptionInitReads.reads(json) + case MessageTypes.SUBSCRIPTION_START => + subscriptionStartReads.reads(json) + case MessageTypes.SUBSCRIPTION_END => + subscriptionEndReads.reads(json) + case _ => + JsError(error = s"Message could not be parsed. Message Type '$value' is not defined.") + } + } + } + } + } +} + +object CommonReaders { + lazy val stringOrIntReads: Reads[StringOrInt] = Reads { + case JsNumber(x) => + JsSuccess(StringOrInt(string = None, int = Some(x.toInt))) + case JsString(x) => + JsSuccess(StringOrInt(string = Some(x), int = None)) + case _ => + JsError("Couldn't parse request id. Supply a number or a string.") + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionRequest.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionRequest.scala new file mode 100644 index 0000000000..f8a26bd1b4 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionRequest.scala @@ -0,0 +1,13 @@ +package cool.graph.subscriptions.protocol + +import cool.graph.messagebus.Conversions +import play.api.libs.json.Json + +object SubscriptionRequest { + implicit val requestFormat = Json.format[SubscriptionRequest] + + implicit val requestUnmarshaller = Conversions.Unmarshallers.ToJsonBackedType[SubscriptionRequest]() + implicit val requestMarshaller = Conversions.Marshallers.FromJsonBackedType[SubscriptionRequest]() +} + +case class SubscriptionRequest(sessionId: String, projectId: String, body: String) diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActor.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActor.scala new file mode 100644 index 0000000000..211e3390f6 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActor.scala @@ -0,0 +1,134 @@ +package cool.graph.subscriptions.protocol + +import akka.actor.{Actor, ActorRef} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubPublisher +import cool.graph.messagebus.pubsub.Only +import cool.graph.subscriptions.metrics.SubscriptionMetrics +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.SubscriptionSessionResponse +import cool.graph.subscriptions.protocol.SubscriptionSessionActorV05.Internal.Authorization +import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.EndSubscription +import cool.graph.subscriptions.resolving.SubscriptionsManager.Responses.{ + CreateSubscriptionFailed, + CreateSubscriptionSucceeded, + ProjectSchemaChanged, + SubscriptionEvent +} +import play.api.libs.json._ +import sangria.parser.QueryParser + +object SubscriptionSessionActor { + object Internal { + case class Authorization(token: Option[String]) + + // see https://github.com/apollographql/subscriptions-transport-ws/issues/174 + def extractOperationName(operationName: Option[String]): Option[String] = operationName match { + case Some("") => None + case x => x + } + } +} + +case class SubscriptionSessionActor( + sessionId: String, + projectId: String, + subscriptionsManager: ActorRef, + bugsnag: BugSnagger, + responsePublisher: PubSubPublisher[SubscriptionSessionResponse] +) extends Actor + with LogUnhandled + with LogUnhandledExceptions { + + import SubscriptionMetrics._ + import SubscriptionProtocolV07.Requests._ + import SubscriptionProtocolV07.Responses._ + import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.CreateSubscription + + override def preStart() = { + super.preStart() + activeSubcriptionSessions.inc + } + + override def postStop(): Unit = { + super.postStop() + activeSubcriptionSessions.dec + } + + override def receive: Receive = logUnhandled { + case GqlConnectionInit(payload) => + ParseAuthorization.parseAuthorization(payload.getOrElse(Json.obj())) match { + case Some(auth) => + publishToResponseQueue(GqlConnectionAck) + context.become(readyReceive(auth)) + + case None => + publishToResponseQueue(GqlConnectionError("No Authorization field was provided in payload.")) + } + + case _: SubscriptionSessionRequest => + publishToResponseQueue(GqlConnectionError("You have to send an init message before sending anything else.")) + } + + def readyReceive(auth: Authorization): Receive = logUnhandled { + case GqlStart(id, payload) => + handleStart(id, payload, auth) + + case GqlStop(id) => + subscriptionsManager ! EndSubscription(id, sessionId, projectId) + + case success: CreateSubscriptionSucceeded => + // FIXME: this is really a NO-OP now? + + case fail: CreateSubscriptionFailed => + publishToResponseQueue(GqlError(fail.request.id, fail.errors.head.getMessage)) + + case ProjectSchemaChanged(subscriptionId) => + publishToResponseQueue(GqlError(subscriptionId, "Schema changed")) + + case SubscriptionEvent(subscriptionId, payload) => + val response = GqlData(subscriptionId, payload) + publishToResponseQueue(response) + } + + private def handleStart(id: StringOrInt, payload: GqlStartPayload, auth: Authorization) = { + val query = QueryParser.parse(payload.query) + + if (query.isFailure) { + publishToResponseQueue(GqlError(id, s"""the GraphQL Query was not valid""")) + } else { + val createSubscription = CreateSubscription( + id = id, + projectId = projectId, + sessionId = sessionId, + query = query.get, + variables = payload.variables, + authHeader = auth.token, + operationName = SubscriptionSessionActor.Internal.extractOperationName(payload.operationName) + ) + subscriptionsManager ! createSubscription + } + } + + private def publishToResponseQueue(response: SubscriptionSessionResponse) = { + responsePublisher.publish(Only(sessionId), response) + } +} + +object ParseAuthorization { + def parseAuthorization(jsObject: JsObject): Option[Authorization] = { + + def parseLowerCaseAuthorization = { + (jsObject \ "authorization").validateOpt[String] match { + case JsSuccess(authField, _) => Some(Authorization(authField)) + case JsError(_) => None + } + } + + (jsObject \ "Authorization").validateOpt[String] match { + case JsSuccess(Some(auth), _) => Some(Authorization(Some(auth))) + case JsSuccess(None, _) => parseLowerCaseAuthorization + case JsError(_) => None + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActorV05.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActorV05.scala new file mode 100644 index 0000000000..1ac8bb46b8 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionActorV05.scala @@ -0,0 +1,104 @@ +package cool.graph.subscriptions.protocol + +import akka.actor.{Actor, ActorRef} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubPublisher +import cool.graph.messagebus.pubsub.Only +import cool.graph.subscriptions.metrics.SubscriptionMetrics +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionSessionActorV05.Internal.Authorization +import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.EndSubscription +import cool.graph.subscriptions.resolving.SubscriptionsManager.Responses.{ + CreateSubscriptionFailed, + CreateSubscriptionSucceeded, + ProjectSchemaChanged, + SubscriptionEvent +} +import play.api.libs.json.Json +import sangria.parser.QueryParser + +object SubscriptionSessionActorV05 { + object Internal { + case class Authorization(token: Option[String]) + } +} +case class SubscriptionSessionActorV05( + sessionId: String, + projectId: String, + subscriptionsManager: ActorRef, + bugsnag: BugSnagger, + responsePublisher: PubSubPublisher[SubscriptionSessionResponseV05] +) extends Actor + with LogUnhandled + with LogUnhandledExceptions { + + import SubscriptionMetrics._ + import SubscriptionProtocolV05.Requests._ + import SubscriptionProtocolV05.Responses._ + import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.CreateSubscription + + activeSubcriptionSessions.inc + + override def postStop(): Unit = { + super.postStop() + activeSubcriptionSessions.dec + } + + override def receive: Receive = logUnhandled { + case InitConnection(payload) => + ParseAuthorization.parseAuthorization(payload.getOrElse(Json.obj())) match { + case Some(auth) => + publishToResponseQueue(InitConnectionSuccess) + context.become(readyReceive(auth)) + + case None => + publishToResponseQueue(InitConnectionFail("No Authorization field was provided in payload.")) + } + + case _: SubscriptionSessionRequestV05 => + publishToResponseQueue(InitConnectionFail("You have to send an init message before sending anything else.")) + } + + def readyReceive(auth: Authorization): Receive = logUnhandled { + case start: SubscriptionStart => + val query = QueryParser.parse(start.query) + + if (query.isFailure) { + publishToResponseQueue(SubscriptionFail(start.id, s"""the GraphQL Query was not valid""")) + } else { + val createSubscription = CreateSubscription( + id = start.id, + projectId = projectId, + sessionId = sessionId, + query = query.get, + variables = start.variables, + authHeader = auth.token, + operationName = SubscriptionSessionActor.Internal.extractOperationName(start.operationName) + ) + subscriptionsManager ! createSubscription + } + + case SubscriptionEnd(id) => + if (id.isDefined) { + subscriptionsManager ! EndSubscription(id.get, sessionId, projectId) + } + + case success: CreateSubscriptionSucceeded => + publishToResponseQueue(SubscriptionSuccess(success.request.id)) + + case fail: CreateSubscriptionFailed => + publishToResponseQueue(SubscriptionFail(fail.request.id, fail.errors.head.getMessage)) + + case SubscriptionEvent(subscriptionId, payload) => + val response = SubscriptionData(subscriptionId, payload) + publishToResponseQueue(response) + + case ProjectSchemaChanged(subscriptionId) => + publishToResponseQueue(SubscriptionFail(subscriptionId, "Schema changed")) + } + + private def publishToResponseQueue(response: SubscriptionSessionResponseV05) = { + responsePublisher.publish(Only(sessionId), response) + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionManager.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionManager.scala new file mode 100644 index 0000000000..feb9d6d9af --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/protocol/SubscriptionSessionManager.scala @@ -0,0 +1,99 @@ +package cool.graph.subscriptions.protocol + +import akka.actor.{Actor, ActorRef, PoisonPill, Props, Terminated} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubPublisher +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Requests.{InitConnection, SubscriptionSessionRequestV05} +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Requests.{GqlConnectionInit, SubscriptionSessionRequest} +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.SubscriptionSessionResponse +import cool.graph.subscriptions.protocol.SubscriptionSessionManager.Requests.{EnrichedSubscriptionRequest, EnrichedSubscriptionRequestV05, StopSession} + +import scala.collection.mutable + +object SubscriptionSessionManager { + object Requests { + trait SubscriptionSessionManagerRequest + + case class EnrichedSubscriptionRequestV05( + sessionId: String, + projectId: String, + request: SubscriptionSessionRequestV05 + ) extends SubscriptionSessionManagerRequest + + case class EnrichedSubscriptionRequest( + sessionId: String, + projectId: String, + request: SubscriptionSessionRequest + ) extends SubscriptionSessionManagerRequest + + case class StopSession(sessionId: String) extends SubscriptionSessionManagerRequest + } +} + +case class SubscriptionSessionManager(subscriptionsManager: ActorRef, bugsnag: BugSnagger)( + implicit responsePublisher05: PubSubPublisher[SubscriptionSessionResponseV05], + responsePublisher07: PubSubPublisher[SubscriptionSessionResponse] +) extends Actor + with LogUnhandledExceptions + with LogUnhandled { + + val sessions: mutable.Map[String, ActorRef] = mutable.Map.empty + + override def receive: Receive = logUnhandled { + case EnrichedSubscriptionRequest(sessionId, projectId, request: GqlConnectionInit) => + val session = startSessionActorForCurrentProtocolVersion(sessionId, projectId) + session ! request + + case EnrichedSubscriptionRequest(sessionId, _, request: SubscriptionSessionRequest) => + // we might receive session requests that are not meant for this box. So we might not find an actor for this session. + sessions.get(sessionId).foreach { session => + session ! request + } + + case EnrichedSubscriptionRequestV05(sessionId, projectId, request: InitConnection) => + val session = startSessionActorForProtocolVersionV05(sessionId, projectId) + session ! request + + case EnrichedSubscriptionRequestV05(sessionId, _, request) => + // we might receive session requests that are not meant for this box. So we might not find an actor for this session. + sessions.get(sessionId).foreach { session => + session ! request + } + + case StopSession(sessionId) => + sessions.get(sessionId).foreach { session => + session ! PoisonPill + sessions.remove(sessionId) + } + + case Terminated(terminatedActor) => + sessions.find { _._2 == terminatedActor } match { + case Some((sessionId, _)) => sessions.remove(sessionId) + case None => // nothing to do; should not happen though + } + } + + private def startSessionActorForProtocolVersionV05(sessionId: String, projectId: String): ActorRef = { + val props = Props(SubscriptionSessionActorV05(sessionId, projectId, subscriptionsManager, bugsnag, responsePublisher05)) + startSessionActor(sessionId, props) + } + + private def startSessionActorForCurrentProtocolVersion(sessionId: String, projectId: String): ActorRef = { + val props = Props(SubscriptionSessionActor(sessionId, projectId, subscriptionsManager, bugsnag, responsePublisher07)) + startSessionActor(sessionId, props) + } + + private def startSessionActor(sessionId: String, props: Props): ActorRef = { + sessions.get(sessionId) match { + case None => + val ref = context.actorOf(props, sessionId) + sessions += sessionId -> ref + context.watch(ref) + + case Some(ref) => + ref + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/DatabaseEvents.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/DatabaseEvents.scala new file mode 100644 index 0000000000..168f5a0353 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/DatabaseEvents.scala @@ -0,0 +1,44 @@ +package cool.graph.subscriptions.resolving + +import play.api.libs.json._ + +object DatabaseEvents { + sealed trait DatabaseEvent { + def nodeId: String + def modelId: String + } + + case class DatabaseDeleteEvent(nodeId: String, modelId: String, node: JsObject) extends DatabaseEvent + case class DatabaseCreateEvent(nodeId: String, modelId: String) extends DatabaseEvent + case class DatabaseUpdateEvent(nodeId: String, modelId: String, changedFields: Seq[String], previousValues: JsObject) extends DatabaseEvent + + case class IntermediateUpdateEvent(nodeId: String, modelId: String, changedFields: Seq[String], previousValues: String) + + object DatabaseEventReaders { + implicit lazy val databaseDeleteEventReads = Json.reads[DatabaseDeleteEvent] + implicit lazy val databaseCreateEventReads = Json.reads[DatabaseCreateEvent] + implicit lazy val intermediateUpdateEventReads = Json.reads[IntermediateUpdateEvent] + + implicit lazy val databaseUpdateEventReads = new Reads[DatabaseUpdateEvent] { + override def reads(json: JsValue): JsResult[DatabaseUpdateEvent] = { + intermediateUpdateEventReads.reads(json) match { + case x: JsError => + x + case JsSuccess(intermediate, _) => + Json.parse(intermediate.previousValues).validate[JsObject] match { + case x: JsError => + x + case JsSuccess(previousValues, _) => + JsSuccess( + DatabaseUpdateEvent( + intermediate.nodeId, + intermediate.modelId, + intermediate.changedFields, + previousValues + )) + } + } + } + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/MutationChannelUtil.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/MutationChannelUtil.scala new file mode 100644 index 0000000000..4504c7a90c --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/MutationChannelUtil.scala @@ -0,0 +1,29 @@ +package cool.graph.subscriptions.resolving + +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Model, ModelMutationType} + +trait MutationChannelUtil { + protected def mutationChannelsForModel(projectId: String, model: Model): Vector[String] = { + Vector(createChannelName(model), updateChannelName(model), deleteChannelName(model)).map { mutationChannelName => + s"subscription:event:$projectId:$mutationChannelName" + } + } + + protected def extractMutationTypeFromChannel(channel: String, model: Model): ModelMutationType = { + val elements = channel.split(':') + require(elements.length == 4, "A channel name must consist of exactly 4 parts separated by colons") + val createChannelName = this.createChannelName(model) + val updateChannelName = this.updateChannelName(model) + val deleteChannelName = this.deleteChannelName(model) + elements.last match { + case `createChannelName` => ModelMutationType.Created + case `updateChannelName` => ModelMutationType.Updated + case `deleteChannelName` => ModelMutationType.Deleted + } + } + + private def createChannelName(model: Model) = "create" + model.name + private def updateChannelName(model: Model) = "update" + model.name + private def deleteChannelName(model: Model) = "delete" + model.name +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionResolver.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionResolver.scala new file mode 100644 index 0000000000..deb9165549 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionResolver.scala @@ -0,0 +1,112 @@ +package cool.graph.subscriptions.resolving + +import java.util.concurrent.TimeUnit + +import cool.graph.DataItem +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Model, ModelMutationType, ProjectWithClientId} +import cool.graph.subscriptions.SubscriptionExecutor +import cool.graph.subscriptions.metrics.SubscriptionMetrics.handleDatabaseEventTimer +import cool.graph.subscriptions.resolving.SubscriptionsManagerForModel.Requests.StartSubscription +import cool.graph.subscriptions.util.PlayJson +import play.api.libs.json._ +import scaldi.Injector + +import scala.concurrent.duration.Duration +import scala.concurrent.{ExecutionContext, Future} + +case class SubscriptionResolver( + project: ProjectWithClientId, + model: Model, + mutationType: ModelMutationType, + subscription: StartSubscription, + scheduler: akka.actor.Scheduler +)(implicit inj: Injector, ec: ExecutionContext) { + import DatabaseEvents._ + + def handleDatabaseMessage(event: String): Future[Option[JsValue]] = { + import DatabaseEventReaders._ + val dbEvent = PlayJson.parse(event).flatMap { json => + mutationType match { + case ModelMutationType.Created => json.validate[DatabaseCreateEvent] + case ModelMutationType.Updated => json.validate[DatabaseUpdateEvent] + case ModelMutationType.Deleted => json.validate[DatabaseDeleteEvent] + } + } + + dbEvent match { + case JsError(_) => + Future.successful(None) + + case JsSuccess(event, _) => + handleDatabaseEventTimer.timeFuture(project.project.id) { + delayed(handleDatabaseMessage(event)) + } + } + } + + // In production we read from db replicas that can be up to 20 ms behind master. We add 35 ms buffer + // Please do not remove this artificial delay! + def delayed[T](fn: => Future[T]): Future[T] = akka.pattern.after(Duration(35, TimeUnit.MILLISECONDS), using = scheduler)(fn) + + def handleDatabaseMessage(event: DatabaseEvent): Future[Option[JsValue]] = { + event match { + case e: DatabaseCreateEvent => handleDatabaseCreateEvent(e) + case e: DatabaseUpdateEvent => handleDatabaseUpdateEvent(e) + case e: DatabaseDeleteEvent => handleDatabaseDeleteEvent(e) + } + } + + def handleDatabaseCreateEvent(event: DatabaseCreateEvent): Future[Option[JsValue]] = { + executeQuery(event.nodeId, previousValues = None, updatedFields = None) + } + + def handleDatabaseUpdateEvent(event: DatabaseUpdateEvent): Future[Option[JsValue]] = { + val values = GraphcoolDataTypes.fromJson(event.previousValues, model.fields) + val previousValues = DataItem(event.nodeId, values) + + executeQuery(event.nodeId, Some(previousValues), updatedFields = Some(event.changedFields.toList)) + } + + def handleDatabaseDeleteEvent(event: DatabaseDeleteEvent): Future[Option[JsValue]] = { + val values = GraphcoolDataTypes.fromJson(event.node, model.fields) + val previousValues = DataItem(event.nodeId, values) + + executeQuery(event.nodeId, Some(previousValues), updatedFields = None) + } + + def executeQuery(nodeId: String, previousValues: Option[DataItem], updatedFields: Option[List[String]]): Future[Option[JsValue]] = { + val variables: spray.json.JsValue = subscription.variables match { + case None => + spray.json.JsObject.empty + + case Some(vars) => + val str = vars.toString + VariablesParser.parseVariables(str) + } + + SubscriptionExecutor + .execute( + project = project.project, + model = model, + mutationType = mutationType, + previousValues = previousValues, + updatedFields = updatedFields, + query = subscription.query, + variables = variables, + nodeId = nodeId, + clientId = project.clientId, + authenticatedRequest = subscription.authenticatedRequest, + requestId = s"subscription:${subscription.sessionId}:${subscription.id.asString}", + operationName = subscription.operationName, + skipPermissionCheck = false, + alwaysQueryMasterDatabase = false + ) + .map { x => + x.map { sprayJsonResult => + Json.parse(sprayJsonResult.toString) + } + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManager.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManager.scala new file mode 100644 index 0000000000..1caaa78303 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManager.scala @@ -0,0 +1,78 @@ +package cool.graph.subscriptions.resolving + +import java.util.concurrent.TimeUnit + +import akka.actor.{Actor, ActorRef, Props, Terminated} +import akka.util.Timeout +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubSubscriber +import cool.graph.messagebus.pubsub.Only +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.subscriptions.protocol.StringOrInt +import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.CreateSubscription +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.SchemaInvalidatedMessage +import play.api.libs.json._ +import scaldi.{Injectable, Injector} + +import scala.collection.mutable + +object SubscriptionsManager { + object Requests { + sealed trait SubscriptionsManagerRequest + + case class CreateSubscription( + id: StringOrInt, + projectId: String, + sessionId: String, + query: sangria.ast.Document, + variables: Option[JsObject], + authHeader: Option[String], + operationName: Option[String] + ) extends SubscriptionsManagerRequest + + case class EndSubscription( + id: StringOrInt, + sessionId: String, + projectId: String + ) extends SubscriptionsManagerRequest + } + + object Responses { + sealed trait CreateSubscriptionResponse + + case class CreateSubscriptionSucceeded(request: CreateSubscription) extends CreateSubscriptionResponse + case class CreateSubscriptionFailed(request: CreateSubscription, errors: Seq[Exception]) extends CreateSubscriptionResponse + case class SubscriptionEvent(subscriptionId: StringOrInt, payload: JsValue) + case class ProjectSchemaChanged(subscriptionId: StringOrInt) + } + + object Internal { + case class ResolverType(modelId: String, mutation: ModelMutationType) + } +} + +case class SubscriptionsManager(bugsnag: BugSnagger)(implicit inj: Injector) extends Actor with Injectable with LogUnhandled with LogUnhandledExceptions { + + import SubscriptionsManager.Requests._ + + val invalidationSubscriber = inject[PubSubSubscriber[SchemaInvalidatedMessage]](identified by "schema-invalidation-subscriber") + implicit val timeout = Timeout(10, TimeUnit.SECONDS) + private val projectManagers = mutable.HashMap.empty[String, ActorRef] + + override def receive: Receive = logUnhandled { + case create: CreateSubscription => projectActorFor(create.projectId).forward(create) + case end: EndSubscription => projectActorFor(end.projectId).forward(end) + case Terminated(ref) => projectManagers.retain { case (_, projectActor) => projectActor != ref } + } + + private def projectActorFor(projectId: String): ActorRef = { + projectManagers.getOrElseUpdate( + projectId, { + val ref = context.actorOf(Props(SubscriptionsManagerForProject(projectId, bugsnag)), projectId) + invalidationSubscriber.subscribe(Only(projectId), ref) + context.watch(ref) + } + ) + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForModel.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForModel.scala new file mode 100644 index 0000000000..dd9530a669 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForModel.scala @@ -0,0 +1,215 @@ +package cool.graph.subscriptions.resolving + +import java.util.concurrent.atomic.AtomicLong + +import akka.actor.{Actor, ActorRef, Stash, Terminated} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubSubscriber +import cool.graph.messagebus.pubsub.{Message, Only, Subscription} +import cool.graph.metrics.GaugeMetric +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models._ +import cool.graph.subscriptions.metrics.SubscriptionMetrics +import cool.graph.subscriptions.protocol.StringOrInt +import cool.graph.subscriptions.resolving.SubscriptionsManager.Requests.EndSubscription +import cool.graph.subscriptions.resolving.SubscriptionsManager.Responses.{ProjectSchemaChanged, SubscriptionEvent} +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.SchemaInvalidated +import play.api.libs.json._ +import sangria.ast.Document +import sangria.renderer.QueryRenderer +import scaldi.Injector +import scaldi.akka.AkkaInjectable + +import scala.collection.mutable +import scala.collection.mutable.ListBuffer +import scala.concurrent.Future +import scala.util.{Failure, Success} + +object SubscriptionsManagerForModel { + object Requests { + case class StartSubscription( + id: StringOrInt, + sessionId: String, + query: Document, + variables: Option[JsObject], + operationName: Option[String], + mutationTypes: Set[ModelMutationType], + authenticatedRequest: Option[AuthenticatedRequest], + subscriber: ActorRef + ) { + lazy val queryAsString: String = QueryRenderer.render(query) + } + } + + object Internal { + case class SubscriptionId( + id: StringOrInt, + sessionId: String + ) + } +} + +case class SubscriptionsManagerForModel( + project: ProjectWithClientId, + model: Model, + bugsnag: BugSnagger +)(implicit inj: Injector) + extends Actor + with Stash + with AkkaInjectable + with LogUnhandled + with LogUnhandledExceptions + with MutationChannelUtil { + + import SubscriptionMetrics._ + import SubscriptionsManagerForModel.Internal._ + import SubscriptionsManagerForModel.Requests._ + import context.dispatcher + + val projectId = project.project.id + val subscriptions = mutable.Map.empty[SubscriptionId, StartSubscription] + val smartActiveSubscriptions = SmartGaugeMetric(activeSubscriptions) + val pubSubSubscriptions = ListBuffer[Subscription]() + val sssEventsSubscriber = inject[PubSubSubscriber[String]](identified by "sss-events-subscriber") + + override def preStart() = { + super.preStart() + + activeSubscriptionsManagerForModelAndMutation.inc + smartActiveSubscriptions.set(0) + + pubSubSubscriptions ++= mutationChannelsForModel(projectId, model).map { channel => + sssEventsSubscriber.subscribe(Only(channel), self) + } + } + + override def postStop(): Unit = { + super.postStop() + + activeSubscriptionsManagerForModelAndMutation.dec + smartActiveSubscriptions.set(0) + pubSubSubscriptions.foreach(_.unsubscribe) + pubSubSubscriptions.clear() + } + + override def receive = logUnhandled { + case start: StartSubscription => + val subscriptionId = SubscriptionId(start.id, start.sessionId) + subscriptions += (subscriptionId -> start) + smartActiveSubscriptions.set(subscriptions.size) + context.watch(start.subscriber) + + case end: EndSubscription => + val subcriptionId = SubscriptionId(id = end.id, sessionId = end.sessionId) + subscriptions -= subcriptionId + smartActiveSubscriptions.set(subscriptions.size) + + case Message(topic: String, message: String) => + databaseEventRate.inc(projectId) + val mutationType = this.extractMutationTypeFromChannel(topic, model) + handleDatabaseMessage(message, mutationType) + + case SchemaInvalidated => + subscriptions.values.foreach { subscription => + subscription.subscriber ! ProjectSchemaChanged(subscription.id) + } + + case Terminated(subscriber) => + handleTerminatedSubscriber(subscriber) + } + + def handleDatabaseMessage(eventStr: String, mutationType: ModelMutationType): Unit = { + import cool.graph.utils.future.FutureUtils._ + + val subscriptionsForMutationType = subscriptions.values.filter(_.mutationTypes.contains(mutationType)) + + // We need to take query variables into consideration - group by query and variables + val groupedSubscriptions: Map[(String, String), Iterable[StartSubscription]] = + subscriptionsForMutationType.groupBy(sub => (sub.queryAsString, sub.variables.getOrElse("").toString)) + + val optimizedProcessEventFns = groupedSubscriptions.flatMap { + case (_, subscriptionsWithSameQuery) => + // only if the subscription has authentication and the model is actually using permissions queries we have to execute each subscription on its own + val (subscriptionsThatMustBeDoneEach, subscriptionsThatCanBeDoneOnlyOnce) = subscriptionsWithSameQuery.partition { subscription => + subscription.authenticatedRequest.isDefined && model.hasQueryPermissions + } + + val performEach: Iterable[() => Future[Unit]] = subscriptionsThatMustBeDoneEach.map { subscription => + processDatabaseAndNotifySubscribersEventFn( + eventStr = eventStr, + subscriptionToExecute = subscription, + subscriptionsToNotify = Vector(subscription), + mutationType = mutationType + ) + } + + val performOnlyTheFirstAndReuseResult: Option[() => Future[Unit]] = subscriptionsThatCanBeDoneOnlyOnce.headOption.map { subscription => + processDatabaseAndNotifySubscribersEventFn( + eventStr = eventStr, + subscriptionToExecute = subscription, + subscriptionsToNotify = subscriptionsThatCanBeDoneOnlyOnce, + mutationType = mutationType + ) + } + + performOnlyTheFirstAndReuseResult ++ performEach + } + + optimizedProcessEventFns.toList.runInChunksOf(maxParallelism = 10) + } + + def processDatabaseAndNotifySubscribersEventFn( + eventStr: String, + subscriptionToExecute: StartSubscription, + subscriptionsToNotify: Iterable[StartSubscription], + mutationType: ModelMutationType + ): () => Future[Unit] = { () => + handleDatabaseEventRate.inc(projectId) + + val result = processDatabaseEventForSubscription(eventStr, subscriptionToExecute, mutationType) + result.onComplete { + case Success(x) => subscriptionsToNotify.foreach(sendDataToSubscriber(_, x)) + case Failure(e) => e.printStackTrace() + } + + result.map(_ => ()) + } + + /** + * This is a separate method so it can be stubbed in tests. + */ + def processDatabaseEventForSubscription( + event: String, + subscription: StartSubscription, + mutationType: ModelMutationType + ): Future[Option[JsValue]] = { + SubscriptionResolver(project, model, mutationType, subscription, context.system.scheduler).handleDatabaseMessage(event) + } + + def sendDataToSubscriber(subscription: StartSubscription, value: Option[JsValue]): Unit = { + value.foreach { json => + val response = SubscriptionEvent(subscription.id, json) + subscription.subscriber ! response + } + } + + def handleTerminatedSubscriber(subscriber: ActorRef) = { + subscriptions.retain { case (_, job) => job.subscriber != subscriber } + smartActiveSubscriptions.set(subscriptions.size) + + if (subscriptions.isEmpty) { + context.stop(self) + } + } +} + +case class SmartGaugeMetric(gaugeMetric: GaugeMetric) { + val value = new AtomicLong(0) + + def set(newValue: Long): Unit = { + val delta = newValue - value.get() + gaugeMetric.add(delta) + value.set(newValue) + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForProject.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForProject.scala new file mode 100644 index 0000000000..aa75f56f08 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/SubscriptionsManagerForProject.scala @@ -0,0 +1,154 @@ +package cool.graph.subscriptions.resolving + +import akka.actor.{Actor, ActorRef, Props, Stash, Terminated} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.PubSubSubscriber +import cool.graph.messagebus.pubsub.Message +import cool.graph.shared.models._ +import cool.graph.subscriptions.helpers.{Auth, ProjectHelper} +import cool.graph.subscriptions.protocol.StringOrInt +import cool.graph.subscriptions.resolving.SubscriptionsManager.Responses.{CreateSubscriptionFailed, CreateSubscriptionResponse, CreateSubscriptionSucceeded} +import cool.graph.subscriptions.resolving.SubscriptionsManagerForModel.Requests.StartSubscription +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.{SchemaInvalidated, SchemaInvalidatedMessage} +import cool.graph.subscriptions.schemas.{QueryTransformer, SubscriptionQueryValidator} +import cool.graph.subscriptions.metrics.SubscriptionMetrics +import org.scalactic.{Bad, Good} +import scaldi.Injector +import scaldi.akka.AkkaInjectable +import cool.graph.utils.future.FutureUtils._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.collection.mutable +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +object SubscriptionsManagerForProject { + trait SchemaInvalidatedMessage + object SchemaInvalidated extends SchemaInvalidatedMessage +} + +case class SubscriptionsManagerForProject( + projectId: String, + bugsnag: BugSnagger +)(implicit inj: Injector) + extends Actor + with Stash + with AkkaInjectable + with LogUnhandled + with LogUnhandledExceptions { + + import SubscriptionsManager.Requests._ + import akka.pattern.pipe + import SubscriptionMetrics._ + + val resolversByModel = mutable.Map.empty[Model, ActorRef] + val resolversBySubscriptionId = mutable.Map.empty[StringOrInt, mutable.Set[ActorRef]] + + override def preStart() = { + super.preStart() + activeSubscriptionsManagerForProject.inc + pipe(ProjectHelper.resolveProject(projectId)(inj, context.system, context.dispatcher)) to self + } + + override def postStop(): Unit = { + super.postStop() + activeSubscriptionsManagerForProject.dec + } + + override def receive: Receive = logUnhandled { + case project: ProjectWithClientId => + context.become(ready(project)) + unstashAll() + + case akka.actor.Status.Failure(e) => + e.printStackTrace() + context.stop(self) + + case _ => + stash() + } + + def ready(project: ProjectWithClientId): Receive = logUnhandled { + case create: CreateSubscription => + val withAuthContext = enrichWithAuthContext(project, create) + pipe(withAuthContext) to (recipient = self, sender = sender) + + case (create: CreateSubscription, auth) => + val response = handleSubscriptionCreate(project, create, auth.asInstanceOf[AuthContext]) + sender ! response + + case end: EndSubscription => + resolversBySubscriptionId.getOrElse(end.id, Set.empty).foreach(_ ! end) + + case Terminated(ref) => + removeManagerForModel(ref) + + case Message(_, _: SchemaInvalidatedMessage) => + context.children.foreach { resolver => + resolver ! SchemaInvalidated + } + context.stop(self) + } + + type AuthContext = Try[Option[AuthenticatedRequest]] + + def enrichWithAuthContext(project: ProjectWithClientId, job: CreateSubscription): Future[(CreateSubscription, AuthContext)] = { + Auth.getAuthContext(project.project, job.authHeader).toFutureTry map { authContext => + (job, authContext) + } + } + + def handleSubscriptionCreate(project: ProjectWithClientId, job: CreateSubscription, authContext: AuthContext): CreateSubscriptionResponse = { + val model = SubscriptionQueryValidator(project.project).validate(job.query) match { + case Good(model) => model + case Bad(errors) => return CreateSubscriptionFailed(job, errors.map(violation => new Exception(violation.errorMessage))) + } + + authContext match { + case Success(userId) => + val mutations = QueryTransformer.getMutationTypesFromSubscription(job.query) + val resolverJob = StartSubscription( + id = job.id, + sessionId = job.sessionId, + query = job.query, + variables = job.variables, + operationName = job.operationName, + mutationTypes = mutations, + authenticatedRequest = userId, + subscriber = sender + ) + + managerForModel(project, model, job.id) ! resolverJob + CreateSubscriptionSucceeded(job) + + case Failure(_) => + CreateSubscriptionFailed(job, Seq(new Exception("Could not authenticate with the given auth token"))) + } + } + + def managerForModel(project: ProjectWithClientId, model: Model, subscriptionId: StringOrInt): ActorRef = { + val resolver = resolversByModel.getOrElseUpdate( + model, { + val actorName = model.name + val ref = context.actorOf(Props(SubscriptionsManagerForModel(project, model, bugsnag)), actorName) + context.watch(ref) + } + ) + + val resolversForSubscriptionId = resolversBySubscriptionId.getOrElseUpdate(subscriptionId, mutable.Set.empty) + + resolversForSubscriptionId.add(resolver) + resolver + } + + def removeManagerForModel(ref: ActorRef) = { + resolversByModel.retain { + case (_, resolver) => resolver != ref + } + + resolversBySubscriptionId.retain { + case (_, resolver) => resolver != ref + } + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/VariablesParser.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/VariablesParser.scala new file mode 100644 index 0000000000..7b975f620e --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/resolving/VariablesParser.scala @@ -0,0 +1,9 @@ +package cool.graph.subscriptions.resolving + +import spray.json._ + +object VariablesParser { + def parseVariables(str: String): JsObject = { + str.parseJson.asJsObject() + } +} diff --git a/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/util/PlayJson.scala b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/util/PlayJson.scala new file mode 100644 index 0000000000..315e1e7c65 --- /dev/null +++ b/server/backend-api-simple-subscriptions/src/main/scala/cool/graph/subscriptions/util/PlayJson.scala @@ -0,0 +1,23 @@ +package cool.graph.subscriptions.util + +import play.api.libs.json._ + +object PlayJson { + def parse(str: String): JsResult[JsValue] = { + try { + JsSuccess(Json.parse(str)) + } catch { + case _: Exception => + JsError(s"The provided string does not represent valid JSON. The string was: $str") + } + } + + def parse(bytes: Array[Byte]): JsResult[JsValue] = { + try { + JsSuccess(Json.parse(bytes)) + } catch { + case _: Exception => + JsError(s"The provided byte array does not represent valid JSON.") + } + } +} diff --git a/server/backend-api-simple/build.sbt b/server/backend-api-simple/build.sbt new file mode 100644 index 0000000000..5e2615500e --- /dev/null +++ b/server/backend-api-simple/build.sbt @@ -0,0 +1 @@ +name := "backend-api-simple" diff --git a/server/backend-api-simple/project/build.properties b/server/backend-api-simple/project/build.properties new file mode 100644 index 0000000000..27e88aa115 --- /dev/null +++ b/server/backend-api-simple/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 diff --git a/server/backend-api-simple/project/plugins.sbt b/server/backend-api-simple/project/plugins.sbt new file mode 100644 index 0000000000..a86a46d973 --- /dev/null +++ b/server/backend-api-simple/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.3") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.0") diff --git a/server/backend-api-simple/src/main/resources/application.conf b/server/backend-api-simple/src/main/resources/application.conf new file mode 100644 index 0000000000..8323b246a9 --- /dev/null +++ b/server/backend-api-simple/src/main/resources/application.conf @@ -0,0 +1,105 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + remote-address-header = on + request-timeout = 45s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } + http.client { + parsing.max-content-length = 50m + } +} + +jwtSecret = ${?JWT_SECRET} +schemaManagerEndpoint = ${SCHEMA_MANAGER_ENDPOINT} +schemaManagerSecret = ${SCHEMA_MANAGER_SECRET} +awsAccessKeyId = ${AWS_ACCESS_KEY_ID} +awsSecretAccessKey = ${AWS_SECRET_ACCESS_KEY} +awsRegion = ${AWS_REGION} + +internal { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = ${?SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientDatabases { + client1 { + master { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + readonly { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_READONLY_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + readOnly = true + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + } +} + +# test DBs +internalTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +internalTestRoot { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_CLIENT_HOST}":"${?TEST_SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_CLIENT_USER} + password = ${?TEST_SQL_CLIENT_PASSWORD} + } + numThreads = ${?TEST_SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +slick.dbs.default.db.connectionInitSql="set names utf8mb4" \ No newline at end of file diff --git a/server/backend-api-simple/src/main/resources/graphiql.html b/server/backend-api-simple/src/main/resources/graphiql.html new file mode 100644 index 0000000000..e788b78238 --- /dev/null +++ b/server/backend-api-simple/src/main/resources/graphiql.html @@ -0,0 +1 @@ + Graphcool Playground
Loading GraphQL Playground
\ No newline at end of file diff --git a/server/backend-api-simple/src/main/resources/logback.xml b/server/backend-api-simple/src/main/resources/logback.xml new file mode 100644 index 0000000000..d8b4b2fde1 --- /dev/null +++ b/server/backend-api-simple/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/backend-api-simple/src/main/scala/SimpleMain.scala b/server/backend-api-simple/src/main/scala/SimpleMain.scala new file mode 100644 index 0000000000..8455fcad99 --- /dev/null +++ b/server/backend-api-simple/src/main/scala/SimpleMain.scala @@ -0,0 +1,17 @@ +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnagger +import cool.graph.client.schema.simple.SimpleApiDependencies +import cool.graph.client.server.ClientServer +import scaldi.Injectable + +object SimpleMain extends App with Injectable { + implicit val system = ActorSystem("sangria-server") + implicit val materializer = ActorMaterializer() + implicit val inj = SimpleApiDependencies() + implicit val bugsnagger = inject[BugSnagger] + + ServerExecutor(port = 8080, ClientServer("simple")).startBlocking() + +} diff --git a/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleApiDependencies.scala b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleApiDependencies.scala new file mode 100644 index 0000000000..080327901c --- /dev/null +++ b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleApiDependencies.scala @@ -0,0 +1,77 @@ +package cool.graph.client.schema.simple + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.client.finder.{CachedProjectFetcherImpl, ProjectFetcherImpl, RefreshableProjectFetcher} +import cool.graph.client.server.{GraphQlRequestHandler, GraphQlRequestHandlerImpl, ProjectSchemaBuilder} +import cool.graph.client.{CommonClientDependencies, FeatureMetric, UserContext} +import cool.graph.messagebus.Conversions.{ByteUnmarshaller, Unmarshallers} +import cool.graph.messagebus.{Conversions, PubSubPublisher, QueuePublisher} +import cool.graph.messagebus.pubsub.rabbit.{RabbitAkkaPubSub, RabbitAkkaPubSubSubscriber} +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.shared.functions.{EndpointResolver, FunctionEnvironment, LiveEndpointResolver} +import cool.graph.shared.functions.lambda.LambdaFunctionEnvironment +import cool.graph.webhook.Webhook + +import scala.util.Try + +trait SimpleApiClientDependencies extends CommonClientDependencies { + import system.dispatcher + + val simpleDeferredResolver: DeferredResolverProvider[_, UserContext] = + new DeferredResolverProvider(new SimpleToManyDeferredResolver, new SimpleManyModelDeferredResolver) + + val simpleProjectSchemaBuilder = ProjectSchemaBuilder(project => new SimpleSchemaBuilder(project).build()) + + val simpleGraphQlRequestHandler = GraphQlRequestHandlerImpl( + errorHandlerFactory = errorHandlerFactory, + log = log, + apiVersionMetric = FeatureMetric.ApiSimple, + apiMetricsMiddleware = apiMetricsMiddleware, + deferredResolver = simpleDeferredResolver + ) + + bind[GraphQlRequestHandler] identifiedBy "simple-gql-request-handler" toNonLazy simpleGraphQlRequestHandler + bind[ProjectSchemaBuilder] identifiedBy "simple-schema-builder" toNonLazy simpleProjectSchemaBuilder +} + +case class SimpleApiDependencies(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends SimpleApiClientDependencies { + val projectSchemaInvalidationSubscriber: RabbitAkkaPubSubSubscriber[String] = { + val globalRabbitUri = sys.env("GLOBAL_RABBIT_URI") + implicit val unmarshaller: ByteUnmarshaller[String] = Unmarshallers.ToString + + RabbitAkkaPubSub.subscriber[String](globalRabbitUri, "project-schema-invalidation", durable = true) + } + + val functionEnvironment = LambdaFunctionEnvironment( + sys.env.getOrElse("LAMBDA_AWS_ACCESS_KEY_ID", "whatever"), + sys.env.getOrElse("LAMBDA_AWS_SECRET_ACCESS_KEY", "whatever") + ) + + val blockedProjectIds: Vector[String] = Try { + sys.env("BLOCKED_PROJECT_IDS").split(",").toVector + }.getOrElse(Vector.empty) + + val projectSchemaFetcher: RefreshableProjectFetcher = CachedProjectFetcherImpl( + projectFetcher = ProjectFetcherImpl(blockedProjectIds, config), + projectSchemaInvalidationSubscriber = projectSchemaInvalidationSubscriber + ) + + val fromStringMarshaller = Conversions.Marshallers.FromString + + val endpointResolver = LiveEndpointResolver() + val logsPublisher = RabbitQueue.publisher[String](sys.env("RABBITMQ_URI"), "function-logs")(bugSnagger, fromStringMarshaller) + val webhooksPublisher = RabbitQueue.publisher(sys.env("RABBITMQ_URI"), "webhooks")(bugSnagger, Webhook.marshaller) + val sssEventsPublisher = RabbitAkkaPubSub.publisher[String](sys.env("RABBITMQ_URI"), "sss-events", durable = true)(bugSnagger, fromStringMarshaller) + val requestPrefix = sys.env.getOrElse("AWS_REGION", sys.error("AWS Region not found.")) + + binding identifiedBy "project-schema-fetcher" toNonLazy projectSchemaFetcher + + bind[FunctionEnvironment] toNonLazy functionEnvironment + bind[EndpointResolver] identifiedBy "endpointResolver" toNonLazy endpointResolver + bind[QueuePublisher[String]] identifiedBy "logsPublisher" toNonLazy logsPublisher + bind[QueuePublisher[Webhook]] identifiedBy "webhookPublisher" toNonLazy webhooksPublisher + bind[PubSubPublisher[String]] identifiedBy "sss-events-publisher" toNonLazy sssEventsPublisher + bind[String] identifiedBy "request-prefix" toNonLazy requestPrefix +} diff --git a/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimplePermissionSchemaBuilder.scala b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimplePermissionSchemaBuilder.scala new file mode 100644 index 0000000000..032c57fa59 --- /dev/null +++ b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimplePermissionSchemaBuilder.scala @@ -0,0 +1,19 @@ +package cool.graph.client.schema.simple + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.shared.models +import scaldi.Injector + +class SimplePermissionSchemaBuilder(project: models.Project)(implicit inj: Injector, actorSystem: ActorSystem, materializer: ActorMaterializer) + extends SimpleSchemaBuilder(project)(inj, actorSystem, materializer) { + + override val generateCreate = false + override val generateUpdate = false + override val generateDelete = false + override val generateAddToRelation = false + override val generateRemoveFromRelation = false + override val generateSetRelation = false + override val generateUnsetRelation = false + override val generateIntegrationFields = false +} diff --git a/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaBuilder.scala b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaBuilder.scala new file mode 100644 index 0000000000..52aef1d275 --- /dev/null +++ b/server/backend-api-simple/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaBuilder.scala @@ -0,0 +1,72 @@ +package cool.graph.client.schema.simple + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.authProviders.AuthProviderManager +import cool.graph.client._ +import cool.graph.client.database.DeferredTypes.{CountManyModelDeferred, ManyModelDeferred, SimpleConnectionOutputType} +import cool.graph.client.database._ +import cool.graph.client.schema.{OutputMapper, SchemaBuilder} +import cool.graph.shared.models +import sangria.schema._ +import scaldi._ + +class SimpleSchemaBuilder(project: models.Project)(implicit inj: Injector, actorSystem: ActorSystem, materializer: ActorMaterializer) + extends SchemaBuilder(project)(inj, actorSystem, materializer) { + + type ManyDataItemType = SimpleConnectionOutputType + + override val includeSubscription = true + override val modelObjectTypesBuilder = new SimpleSchemaModelObjectTypeBuilder(project, Some(nodeInterface)) + override val modelObjectTypes = modelObjectTypesBuilder.modelObjectTypes + + override val argumentSchema = SimpleArgumentSchema + override val outputMapper: OutputMapper = SimpleOutputMapper(project, modelObjectTypes) + override val deferredResolverProvider: DeferredResolverProvider[_, UserContext] = + new DeferredResolverProvider(new SimpleToManyDeferredResolver, new SimpleManyModelDeferredResolver) + + override def getConnectionArguments(model: models.Model): List[Argument[Option[Any]]] = + modelObjectTypesBuilder.mapToListConnectionArguments(model) + + override def resolveGetAllItemsQuery(model: models.Model, ctx: Context[UserContext, Unit]): sangria.schema.Action[UserContext, SimpleConnectionOutputType] = { + val arguments = modelObjectTypesBuilder.extractQueryArgumentsFromContext(model, ctx) + + ManyModelDeferred[SimpleConnectionOutputType](model, arguments) + } + + override def createManyFieldTypeForModel(model: models.Model) = + ListType(modelObjectTypes(model.name)) + + override def getIntegrationFields: List[Field[UserContext, Unit]] = { + includedModels.find(_.name == "User") match { + case Some(userModel) => + AuthProviderManager.simpleMutationFields(project, + userModel, + modelObjectTypes("User"), + modelObjectTypesBuilder, + argumentSchema, + deferredResolverProvider) + case None => List() + } + } + + override def getAllItemsMetaField(model: models.Model): Option[Field[UserContext, Unit]] = { + Some( + Field( + s"_all${pluralsCache.pluralName(model)}Meta", + fieldType = modelObjectTypesBuilder.metaObjectType, + arguments = getConnectionArguments(model), + resolve = (ctx) => { + val queryArguments = + modelObjectTypesBuilder.extractQueryArgumentsFromContext(model, ctx) + + val countArgs = queryArguments.map(args => SangriaQueryArguments.createSimpleQueryArguments(None, None, None, None, None, args.filter, None)) + + val countDeferred = CountManyModelDeferred(model, countArgs) + + DataItem(id = "meta", userData = Map[String, Option[Any]]("count" -> Some(countDeferred))) + } + )) + } +} diff --git a/server/backend-api-simple/src/test/scala/cool/graph/auth2/Spec1.scala b/server/backend-api-simple/src/test/scala/cool/graph/auth2/Spec1.scala new file mode 100644 index 0000000000..600a471b29 --- /dev/null +++ b/server/backend-api-simple/src/test/scala/cool/graph/auth2/Spec1.scala @@ -0,0 +1,9 @@ +package cool.graph.auth2 + +import org.scalatest.{FlatSpec, Matchers} + +class Spec1 extends FlatSpec with Matchers { + "bla" should "be" in { + true should be(true) + } +} diff --git a/server/backend-api-subscriptions-websocket/README.md b/server/backend-api-subscriptions-websocket/README.md new file mode 100644 index 0000000000..f57ff9c022 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/README.md @@ -0,0 +1,78 @@ +# Architecture overview + +The implementation of subscriptions is split into 2 projects: + +The project *backend-api-subscriptions-websocket* is responsible for just maintaining the Websocket connections. It has exactly 2 responsibilities: +* Receive incoming messages from the connected clients and put them onto the Queue `subscriptions-requests`. +* Listen on the queue `subscriptions-responses` and send the contents to the connected clients. + +The project *backend-api-simple-subscriptions* is the actual backend. It has the following responsibilties: +* The `SubscriptionSession` actors are responsible for implementing the [Apollo Subscriptions Protocol](https://github.com/apollographql/subscriptions-transport-ws). It makes sure the protocol is followed correctly. If this is the case subscription start and end messages are forwarded to the *SubscriptionManagers*. +* The *SubscriptionsManager* actors are responsible for managing active susbcriptions. They receive a subscription start message and analyze the query and setup the right channels to listen for changes. The `backend-shared` project is responsible for publishing to those channels whenever changes are written to the Database. When a change is received, they execute the query to build the response payload for connected clients and then publish it to the Queue `subscriptions-responses`. Subscriptions are terminated when a Subscription End message is received. + + +
                                                                                                           
+                                                                                                           
+                                                                                                           
+     ┌────────────────────────────────────────────────────────────────────────────────────────────────┐    
+     │backend-api-subscriptions-websocket                                                             │    
+     │                                                                                                │    
+     │                                 ┌──────────────────────────┐                                   │    
+     │                                 │ WebsocketSessionManager  │                                   │    
+     │                                 └──────────────────────────┘                                   │    
+     │                                               ┼                                                │    
+     │                                               │                                                │    
+     │                                              ╱│╲                                               │    
+     │                                 ┌──────────────────────────┐                                   │    
+     │                    ┌────────────│     WebsocketSession     │◀────┐                             │    
+     │                    │            └──────────────────────────┘     │                             │    
+     └────────────────────┼─────────────────────────────────────────────┼─────────────────────────────┘    
+                          ▼                                             │                                  
+            .───────────────────────────.                 .───────────────────────────.                    
+           (  Q: subscriptions-requests  )               ( Q: subscriptions-responses  )◀──────────┐       
+            `───────────────────────────'                 `───────────────────────────'            │       
+                          │                                                                        │       
+     ┌────────────────────┼────────────────────────────────────────────────────────────────────────┼──┐    
+     │                    │                                                                        │  │    
+     │                    │                                                                        │  │    
+     │                    │                                                                        │  │    
+     │                    ▼                                                                        │  │    
+     │    ┌──────────────────────────────┐                 ┌──────────────────────────────┐        │  │    
+     │    │  SubscriptionSessionManager  │       ┌────────▶│     SubscriptionsManager     │        │  │    
+     │    └──────────────────────────────┘       │         └──────────────────────────────┘        │  │    
+     │                    ┼                      │                         ┼                       │  │    
+     │                    │                      │                         │                       │  │    
+     │                    │                      │                         │                       │  │    
+     │                   ╱│╲                     │                        ╱│╲                      │  │    
+     │      ┌──────────────────────────┐         │       ┌───────────────────────────────────┐     │  │    
+     │      │   SubscriptionSession    │─────────┘       │  SubscriptionsManagerForProject   │     │  │    
+     │      └──────────────────────────┘                 └───────────────────────────────────┘     │  │    
+     │                                                                     ┼                       │  │    
+     │                                                                     │                       │  │    
+     │                                                                     │                       │  │    
+     │                                                                    ╱│╲                      │  │    
+     │                                                   ┌───────────────────────────────────┐     │  │
+     │                                                   │   SubscriptionsManagerForModel    │─────┘  │
+     │                                                   └───────────────────────────────────┘        │
+     │backend-api-simple-subscriptions                                     ▲                          │    
+     └─────────────────────────────────────────────────────────────────────┼──────────────────────────┘    
+                                                                           │                               
+                                                                           │                               
+                                                             .───────────────────────────.                 
+                                                            (      MutationChannels       )                
+                                                             `───────────────────────────'                 
+                                                                           ▲                               
+     ┌─────────────────────────────────────────────────────────────────────┼──────────────────────────────┐
+     │                                                                     │                              │
+     │                                                                     │                              │
+     │                                                                                                    │
+     │                                                                                                    │
+     │                                                                                                    │
+     │client-shared  PublishSubscriptionEvent mutaction                                                   │
+     └────────────────────────────────────────────────────────────────────────────────────────────────────┘
+ + +## Current Problems + +* If a Websocket server crashes the corresponding subscriptions are not stopped in the backend. +* The subscriptions backend is currently not horizontally scalable. \ No newline at end of file diff --git a/server/backend-api-subscriptions-websocket/build.sbt b/server/backend-api-subscriptions-websocket/build.sbt new file mode 100644 index 0000000000..f5fb354be6 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/build.sbt @@ -0,0 +1 @@ +name := "backend-api-subscriptions-websocket" \ No newline at end of file diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketMain.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketMain.scala new file mode 100644 index 0000000000..ad9a19bd12 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketMain.scala @@ -0,0 +1,17 @@ +package cool.graph.websockets + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.subscriptions.websockets.services.WebsocketCloudServives + +object WebsocketMain extends App { + implicit val system = ActorSystem("graphql-subscriptions") + implicit val materializer = ActorMaterializer() + implicit val bugsnag = BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) + + val services = WebsocketCloudServives() + + ServerExecutor(port = 8085, WebsocketServer(services)).startBlocking() +} diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketServer.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketServer.scala new file mode 100644 index 0000000000..e4e9e337be --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketServer.scala @@ -0,0 +1,103 @@ +package cool.graph.websockets + +import java.util.concurrent.TimeUnit + +import akka.NotUsed +import akka.actor.{ActorSystem, Props} +import akka.http.scaladsl.model.ws.{Message, TextMessage} +import akka.http.scaladsl.server.Directives._ +import akka.stream.scaladsl.{Flow, Sink, Source} +import akka.stream.{ActorMaterializer, OverflowStrategy} +import cool.graph.akkautil.http.Server +import cool.graph.akkautil.stream.OnCompleteStage +import cool.graph.bugsnag.BugSnagger +import cool.graph.cuid.Cuid +import cool.graph.messagebus.pubsub.Everything +import cool.graph.subscriptions.websockets.services.WebsocketServices +import cool.graph.websockets.WebsocketSessionManager.Requests.IncomingQueueMessage +import metrics.SubscriptionWebsocketMetrics + +import scala.concurrent.Future +import scala.concurrent.duration._ + +case class WebsocketServer(services: WebsocketServices, prefix: String = "")( + implicit system: ActorSystem, + materializer: ActorMaterializer, + bugsnag: BugSnagger +) extends Server { + import SubscriptionWebsocketMetrics._ + import system.dispatcher + + val manager = system.actorOf(Props(WebsocketSessionManager(services.requestsQueuePublisher, bugsnag))) + val subProtocol1 = "graphql-subscriptions" + val subProtocol2 = "graphql-ws" + + val responseSubscription = services.responsePubSubSubscriber.subscribe(Everything, { strMsg => + incomingResponseQueueMessageRate.inc() + manager ! IncomingQueueMessage(strMsg.topic, strMsg.payload) + }) + + override def healthCheck: Future[_] = Future.successful(()) + override def onStop: Future[_] = Future { responseSubscription.unsubscribe } + + val innerRoutes = pathPrefix("v1") { + path(Segment) { projectId => + get { + handleWebSocketMessagesForProtocol(newSession(projectId, v7protocol = false), subProtocol1) ~ + handleWebSocketMessagesForProtocol(newSession(projectId, v7protocol = true), subProtocol2) + } + } + } + + def newSession(projectId: String, v7protocol: Boolean): Flow[Message, Message, NotUsed] = { + import WebsocketSessionManager.Requests._ + import WebsocketSessionManager.Responses._ + + val sessionId = Cuid.createCuid() + + val incomingMessages = + Flow[Message] + .collect { + case TextMessage.Strict(text) ⇒ Future.successful(text) + case TextMessage.Streamed(textStream) ⇒ + textStream + .limit(100) + .completionTimeout(5.seconds) + .runFold("")(_ + _) + } + .mapAsync(3)(identity) + .map(TextMessage.Strict) + .collect { + case TextMessage.Strict(text) => + incomingWebsocketMessageRate.inc() + IncomingWebsocketMessage(projectId = projectId, sessionId = sessionId, body = text) + } + .to(Sink.actorRef[IncomingWebsocketMessage](manager, CloseWebsocketSession(sessionId))) + + val outgoingMessage: Source[Message, NotUsed] = + Source + .actorRef[OutgoingMessage](5, OverflowStrategy.fail) + .mapMaterializedValue { outActor => + manager ! OpenWebsocketSession(projectId = projectId, sessionId = sessionId, outActor) + NotUsed + } + .map( + (outMsg: OutgoingMessage) => { + outgoingWebsocketMessageRate.inc() + TextMessage(outMsg.text) + } + ) + .via(OnCompleteStage(() => { + manager ! CloseWebsocketSession(sessionId) + })) + .keepAlive(FiniteDuration(10, TimeUnit.SECONDS), () => { + if (v7protocol) { + TextMessage.Strict("""{"type":"ka"}""") + } else { + TextMessage.Strict("""{"type":"keepalive"}""") + } + }) + + Flow.fromSinkAndSource(incomingMessages, outgoingMessage) + } +} diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketSession.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketSession.scala new file mode 100644 index 0000000000..163578c17c --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/WebsocketSession.scala @@ -0,0 +1,95 @@ +package cool.graph.websockets + +import java.util.concurrent.TimeUnit + +import akka.actor.{Actor, ActorRef, PoisonPill, Props, ReceiveTimeout, Stash, Terminated} +import cool.graph.akkautil.{LogUnhandled, LogUnhandledExceptions} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.QueuePublisher +import cool.graph.websockets.protocol.Request + +import scala.collection.mutable +import scala.concurrent.duration._ // if you don't supply your own Protocol (see below) + +object WebsocketSessionManager { + object Requests { + case class OpenWebsocketSession(projectId: String, sessionId: String, outgoing: ActorRef) + case class CloseWebsocketSession(sessionId: String) + + case class IncomingWebsocketMessage(projectId: String, sessionId: String, body: String) + case class IncomingQueueMessage(sessionId: String, body: String) + } + + object Responses { + case class OutgoingMessage(text: String) + } +} + +case class WebsocketSessionManager( + requestsPublisher: QueuePublisher[Request], + bugsnag: BugSnagger +) extends Actor + with LogUnhandled + with LogUnhandledExceptions { + import WebsocketSessionManager.Requests._ + + val websocketSessions = mutable.Map.empty[String, ActorRef] + + override def receive: Receive = logUnhandled { + case OpenWebsocketSession(projectId, sessionId, outgoing) => + val ref = context.actorOf(Props(WebsocketSession(projectId, sessionId, outgoing, requestsPublisher, bugsnag))) + context.watch(ref) + websocketSessions += sessionId -> ref + + case CloseWebsocketSession(sessionId) => + websocketSessions.get(sessionId).foreach(context.stop) + + case req: IncomingWebsocketMessage => + websocketSessions.get(req.sessionId) match { + case Some(session) => session ! req + case None => println(s"No session actor found for ${req.sessionId} when processing websocket message. This should only happen very rarely.") + } + + case req: IncomingQueueMessage => + websocketSessions.get(req.sessionId) match { + case Some(session) => session ! req + case None => println(s"No session actor found for ${req.sessionId} when processing queue message. This should only happen very rarely.") + } + + case Terminated(terminatedActor) => + websocketSessions.retain { + case (_, sessionActor) => sessionActor != terminatedActor + } + } +} + +case class WebsocketSession( + projectId: String, + sessionId: String, + outgoing: ActorRef, + requestsPublisher: QueuePublisher[Request], + bugsnag: BugSnagger +) extends Actor + with LogUnhandled + with LogUnhandledExceptions + with Stash { + import WebsocketSessionManager.Requests._ + import WebsocketSessionManager.Responses._ + import metrics.SubscriptionWebsocketMetrics._ + + activeWsConnections.inc + + context.setReceiveTimeout(FiniteDuration(60, TimeUnit.MINUTES)) + + def receive: Receive = logUnhandled { + case IncomingWebsocketMessage(_, _, body) => requestsPublisher.publish(Request(sessionId, projectId, body)) + case IncomingQueueMessage(_, body) => outgoing ! OutgoingMessage(body) + case ReceiveTimeout => context.stop(self) + } + + override def postStop = { + activeWsConnections.dec + outgoing ! PoisonPill + requestsPublisher.publish(Request(sessionId, projectId, "STOP")) + } +} diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/metrics/SubscriptionWebsocketMetrics.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/metrics/SubscriptionWebsocketMetrics.scala new file mode 100644 index 0000000000..366c3443a0 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/metrics/SubscriptionWebsocketMetrics.scala @@ -0,0 +1,15 @@ +package cool.graph.websockets.metrics + +import cool.graph.metrics.MetricsManager +import cool.graph.profiling.MemoryProfiler + +object SubscriptionWebsocketMetrics extends MetricsManager { + MemoryProfiler.schedule(this) + + override def serviceName = "SubscriptionWebsocketService" + + val activeWsConnections = defineGauge("activeWsConnections") + val incomingWebsocketMessageRate = defineCounter("incomingWebsocketMessageRate") + val outgoingWebsocketMessageRate = defineCounter("outgoingWebsocketMessageRate") + val incomingResponseQueueMessageRate = defineCounter("incomingResponseQueueMessageRate") +} diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/protocol/Request.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/protocol/Request.scala new file mode 100644 index 0000000000..bc49eed1b4 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/protocol/Request.scala @@ -0,0 +1,13 @@ +package cool.graph.websockets.protocol + +import cool.graph.messagebus.Conversions +import play.api.libs.json.Json + +object Request { + implicit val requestFormat = Json.format[Request] + + implicit val requestUnmarshaller = Conversions.Unmarshallers.ToJsonBackedType[Request]() + implicit val requestMarshaller = Conversions.Marshallers.FromJsonBackedType[Request]() +} + +case class Request(sessionId: String, projectId: String, body: String) diff --git a/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/services/WebsocketServices.scala b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/services/WebsocketServices.scala new file mode 100644 index 0000000000..1f8d6095d9 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/main/scala/cool/graph/websockets/services/WebsocketServices.scala @@ -0,0 +1,31 @@ +package cool.graph.subscriptions.websockets.services + +import akka.actor.ActorSystem +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.pubsub.rabbit.RabbitAkkaPubSub +import cool.graph.messagebus._ +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.websockets.protocol.Request + +trait WebsocketServices { + val requestsQueuePublisher: QueuePublisher[Request] + val responsePubSubSubscriber: PubSubSubscriber[String] +} + +case class WebsocketCloudServives()(implicit val bugsnagger: BugSnagger, system: ActorSystem) extends WebsocketServices { + import Request._ + + val clusterLocalRabbitUri = sys.env("RABBITMQ_URI") + + val requestsQueuePublisher: QueuePublisher[Request] = + RabbitQueue.publisher[Request](clusterLocalRabbitUri, "subscription-requests") + + val responsePubSubSubscriber: PubSubSubscriber[String] = + RabbitAkkaPubSub + .subscriber[String](clusterLocalRabbitUri, "subscription-responses", durable = false)(bugsnagger, system, Conversions.Unmarshallers.ToString) +} + +case class WebsocketDevDependencies( + requestsQueuePublisher: QueuePublisher[Request], + responsePubSubSubscriber: PubSub[String] +) extends WebsocketServices diff --git a/server/backend-api-subscriptions-websocket/src/test/scala/cool/graph/subscriptions/websockets/WebsocketSessionSpec.scala b/server/backend-api-subscriptions-websocket/src/test/scala/cool/graph/subscriptions/websockets/WebsocketSessionSpec.scala new file mode 100644 index 0000000000..d02a91def1 --- /dev/null +++ b/server/backend-api-subscriptions-websocket/src/test/scala/cool/graph/subscriptions/websockets/WebsocketSessionSpec.scala @@ -0,0 +1,43 @@ +package cool.graph.subscriptions.websockets + +import akka.actor.{ActorSystem, Props} +import akka.testkit.{TestKit, TestProbe} +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.messagebus.testkits.RabbitQueueTestKit +import cool.graph.websockets.WebsocketSession +import cool.graph.websockets.protocol.Request +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike} + +class WebsocketSessionSpec extends TestKit(ActorSystem("websocket-session-spec")) with WordSpecLike with Matchers with BeforeAndAfterAll with ScalaFutures { + + override def afterAll = shutdown() + + val ignoreProbe = TestProbe() + val ignoreRef = ignoreProbe.testActor + val amqpUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI env var required but not found.")) + + implicit val bugsnagger = BugSnaggerImpl("") + + "The WebsocketSession" should { + "send a message with the body STOP to the requests queue AND a Poison Pill to the outActor when it is stopped" in { + import cool.graph.websockets.protocol.Request._ + + val testKit = RabbitQueueTestKit[Request](amqpUri, "subscription-requests", exchangeDurable = true) + testKit.withTestConsumers() + + val projectId = "projectId" + val sessionId = "sessionId" + val outgoing = TestProbe().ref + val probe = TestProbe() + + probe.watch(outgoing) + + val session = system.actorOf(Props(WebsocketSession(projectId, sessionId, outgoing, testKit, bugsnag = null))) + + system.stop(session) + probe.expectTerminated(outgoing) + testKit.expectMsg(Request(sessionId, projectId, "STOP")) + } + } +} diff --git a/server/backend-api-system/.sbtopts b/server/backend-api-system/.sbtopts new file mode 100644 index 0000000000..07625e80ea --- /dev/null +++ b/server/backend-api-system/.sbtopts @@ -0,0 +1 @@ +-J-XX:MaxMetaspaceSize=512M \ No newline at end of file diff --git a/server/backend-api-system/build.sbt b/server/backend-api-system/build.sbt new file mode 100644 index 0000000000..eb0301b40a --- /dev/null +++ b/server/backend-api-system/build.sbt @@ -0,0 +1 @@ +name := "backend-api-system" diff --git a/server/backend-api-system/project/build.properties b/server/backend-api-system/project/build.properties new file mode 100644 index 0000000000..27e88aa115 --- /dev/null +++ b/server/backend-api-system/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 diff --git a/server/backend-api-system/project/plugins.sbt b/server/backend-api-system/project/plugins.sbt new file mode 100644 index 0000000000..a86a46d973 --- /dev/null +++ b/server/backend-api-system/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("io.spray" % "sbt-revolver" % "0.7.2") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.3") +addSbtPlugin("se.marcuslonnberg" % "sbt-docker" % "1.4.0") diff --git a/server/backend-api-system/src/main/resources/application.conf b/server/backend-api-system/src/main/resources/application.conf new file mode 100644 index 0000000000..fd02b9d1a6 --- /dev/null +++ b/server/backend-api-system/src/main/resources/application.conf @@ -0,0 +1,177 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + request-timeout = 120s // Deploy mutation is too slow for default 20s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } +} + +jwtSecret = ${?JWT_SECRET} +auth0jwtSecret = ${?AUTH0_CLIENT_SECRET} +auth0Domain = ${?AUTH0_DOMAIN} +auth0ApiToken = ${?AUTH0_API_TOKEN} +systemApiSecret = ${?SYSTEM_API_SECRET} +stripeApiKey = ${?STRIPE_API_KEY} +initialPricingPlan = ${?INITIAL_PRICING_PLAN} +awsAccessKeyId = ${AWS_ACCESS_KEY_ID} +awsSecretAccessKey = ${AWS_SECRET_ACCESS_KEY} +awsRegion = ${AWS_REGION} +clientApiAddress = ${CLIENT_API_ADDRESS} +privateClientApiSecret = ${PRIVATE_CLIENT_API_SECRET} + +logs { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"/"${?SQL_LOGS_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true&usePipelineAuth=false" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +logsRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true&usePipelineAuth=false" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +internal { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +internalRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +allClientDatabases { + eu-west-1 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_EU_WEST_1_CLIENT1}":"${?SQL_CLIENT_PORT_EU_WEST_1}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER_EU_WEST_1} + password = ${?SQL_CLIENT_PASSWORD_EU_WEST_1} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } + + us-west-2 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_US_WEST_2_CLIENT1}":"${?SQL_CLIENT_PORT_US_WEST_2}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER_US_WEST_2} + password = ${?SQL_CLIENT_PASSWORD_US_WEST_2} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } + + ap-northeast-1 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_AP_NORTHEAST_1_CLIENT1}":"${?SQL_CLIENT_PORT_AP_NORTHEAST_1}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&usePipelineAuth=false" + user = ${?SQL_CLIENT_USER_AP_NORTHEAST_1} + password = ${?SQL_CLIENT_PASSWORD_AP_NORTHEAST_1} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } +} + +# Test DBs +internalTest { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +internalTestRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +logsTest { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_LOGS_HOST}":"${?TEST_SQL_LOGS_PORT}"/"${?TEST_SQL_LOGS_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = ${?TEST_SQL_LOGS_USER} + password = ${?TEST_SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +logsTestRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_LOGS_HOST}":"${?TEST_SQL_LOGS_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +clientTest { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_CLIENT_HOST}":"${?TEST_SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&usePipelineAuth=false" + user = ${?TEST_SQL_CLIENT_USER} + password = ${?TEST_SQL_CLIENT_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} \ No newline at end of file diff --git a/server/backend-api-system/src/main/resources/graphiql.html b/server/backend-api-system/src/main/resources/graphiql.html new file mode 100644 index 0000000000..e788b78238 --- /dev/null +++ b/server/backend-api-system/src/main/resources/graphiql.html @@ -0,0 +1 @@ + Graphcool Playground
Loading GraphQL Playground
\ No newline at end of file diff --git a/server/backend-api-system/src/main/resources/logback.xml b/server/backend-api-system/src/main/resources/logback.xml new file mode 100644 index 0000000000..f640063cc1 --- /dev/null +++ b/server/backend-api-system/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/server/backend-api-system/src/main/scala/cool/graph/InternalMutactionRunner.scala b/server/backend-api-system/src/main/scala/cool/graph/InternalMutactionRunner.scala new file mode 100644 index 0000000000..dacc536e11 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/InternalMutactionRunner.scala @@ -0,0 +1,287 @@ +package cool.graph + +import com.github.tototoshi.slick.MySQLJodaSupport._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.{InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.models.MutationLogStatus +import cool.graph.shared.models.MutationLogStatus.MutationLogStatus +import cool.graph.system.database.tables.{MutationLog, MutationLogMutaction, Tables} +import cool.graph.utils.future.FutureUtils._ +import org.joda.time.DateTime +import slick.ast.BaseTypedType +import slick.jdbc.JdbcType + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.{Await, Awaitable, Future} +import scala.language.reflectiveCalls + +class InternalMutactionRunner(requestContext: Option[SystemRequestContextTrait], databases: InternalAndProjectDbs, logTiming: Function[Timing, Unit]) { + import InternalMutationMetrics._ + + val internalDatabase: InternalDatabase = databases.internal + lazy val clientDatabase = databases.client.getOrElse(sys.error("The client database must not be none here")).master + val internalDatabaseDef = internalDatabase.databaseDef + + // FIXME: instead of a tuple return an object with proper names + private def groupMutactions(mutactions: List[(Mutaction, Int)]) = + mutactions + .foldLeft(List[(ClientSqlMutaction, Int)](), List[(SystemSqlMutaction, Int)](), List[(Mutaction, Int)]()) { + case ((xs, ys, zs), x @ (x1: ClientSqlMutaction, _)) => + val casted = x.asInstanceOf[(ClientSqlMutaction, Int)] + (xs :+ casted, ys, zs) + + case ((xs, ys, zs), y @ (y1: SystemSqlMutaction, _)) => + val casted = y.asInstanceOf[(SystemSqlMutaction, Int)] + (xs, ys :+ casted, zs) + + case ((xs, ys, zs), z @ (z1: Mutaction, _)) => + (xs, ys, zs :+ z) + } + + def run(mutation: InternalMutation[_], mutactions: List[Mutaction]): Future[List[MutactionExecutionResult]] = { + + implicit val caseClassFormat: JsonFormats.CaseClassFormat.type = cool.graph.JsonFormats.CaseClassFormat + import slick.jdbc.MySQLProfile.api._ + import spray.json._ + + def defaultHandleErrors: PartialFunction[Throwable, MutactionExecutionResult] = { + case e: MutactionExecutionResult => e + } + + def extractTransactionMutactions(mutaction: Mutaction): List[Mutaction] = { + mutaction match { + case m: Transaction if m.isInstanceOf[Transaction] => m.clientSqlMutactions + case m => List(m) + } + } + + // make sure index is following execution order + val mutactionsWithIndex = groupMutactions(mutactions.flatMap(extractTransactionMutactions).map(m => (m, 0))) match { + case (clientSQLActions, systemSQLActions, otherActions) => + (clientSQLActions ++ systemSQLActions ++ otherActions).map(_._1).zipWithIndex + } + + val (clientSQLActions, systemSQLActions, otherActions) = groupMutactions(mutactionsWithIndex) + + val mutationLog = MutationLog( + id = Cuid.createCuid(), + name = mutation.getClass.getSimpleName, + status = MutationLogStatus.SCHEDULED, + failedMutaction = None, + input = mutation.args.toJson.toString, + startedAt = DateTime.now(), + finishedAt = None, + projectId = requestContext.flatMap(_.projectId), + clientId = requestContext.flatMap(_.client.map(_.id)) + ) + + val mutationLogMutactions = mutactionsWithIndex.map { + case (m, i) => + MutationLogMutaction( + id = Cuid.createCuid(), + name = m.getClass.getSimpleName, + index = i, + status = MutationLogStatus.SCHEDULED, + input = m.asInstanceOf[Product].toJson.toString, + finishedAt = None, + error = None, + rollbackError = None, + mutationLogId = mutationLog.id + ) + } + + def setRollbackStatus(index: Int, status: MutationLogStatus.MutationLogStatus, exception: Option[Throwable]) = { + implicit val mutationLogStatusMapper: JdbcType[MutationLogStatus] with BaseTypedType[MutationLogStatus] = MutationLog.mutationLogStatusMapper + + val indexed = mutationLogMutactions.find(_.index == index).get + + val mutactionSqlAction = status match { + case MutationLogStatus.ROLLEDBACK => + List((for { l <- Tables.MutationLogMutactions if l.id === indexed.id } yield l.status).update(status)) + + case MutationLogStatus.FAILURE => + List((for { l <- Tables.MutationLogMutactions if l.id === indexed.id } yield l.rollbackError).update(exception.map(formatException))) + + case _ => List() + } + + val mutationSqlAction = (index, status) match { + case (0, MutationLogStatus.ROLLEDBACK) => + List((for { m <- Tables.MutationLogs if m.id === mutationLog.id } yield (m.status, m.finishedAt)).update((status, Some(DateTime.now())))) + + case _ => + List() + } + + DBIO.seq(mutactionSqlAction ++ mutationSqlAction: _*).transactionally + } + + def formatException(exception: Throwable) = + s"${exception.getMessage} \n\n${exception.toString} \n\n${exception.getStackTrace + .map(_.toString) + .mkString(" \n")}" + + def setStatus(index: Int, status: MutationLogStatus.MutationLogStatus, exception: Option[Throwable]) = { + implicit val mutationLogStatusMapper: JdbcType[MutationLogStatus] with BaseTypedType[MutationLogStatus] = MutationLog.mutationLogStatusMapper + + val indexed = mutationLogMutactions.find(_.index == index).get + + val q = for { l <- Tables.MutationLogMutactions if l.id === indexed.id } yield (l.status, l.finishedAt, l.error) + + val mutactionSqlAction = List(q.update((status, Some(DateTime.now()), exception.map(formatException)))) + + val lastIndex = mutationLogMutactions.map(_.index).max + + val mutationSqlAction = (index, status) match { + case (lastIndex, MutationLogStatus.SUCCESS) => + // STATUS, finishedAt + List((for { m <- Tables.MutationLogs if m.id === mutationLog.id } yield (m.status, m.finishedAt)).update((status, Some(DateTime.now())))) + + case (0, MutationLogStatus.FAILURE) => + // FAILURE. No rollback needed + List( + (for { m <- Tables.MutationLogs if m.id === mutationLog.id } yield + (m.status, m.failedMutaction)).update((MutationLogStatus.ROLLEDBACK, Some(indexed.name)))) + + case (_, MutationLogStatus.FAILURE) => + // FAILURE. Begin rollback + List((for { m <- Tables.MutationLogs if m.id === mutationLog.id } yield (m.status, m.failedMutaction)).update((status, Some(indexed.name)))) + + case _ => + // noop + List() + } + + DBIO.seq(mutactionSqlAction ++ mutationSqlAction: _*) + } + + def logAndRollback[A](index: Int, f: Future[A]): Future[A] = { + f.andThenFuture( + handleSuccess = _ => internalDatabaseDef.run(setStatus(index, MutationLogStatus.SUCCESS, None)), + handleFailure = e => { + internalDatabaseDef + .run(setStatus(index, MutationLogStatus.FAILURE, Some(e))) + .flatMap(_ => { + val rollbackFutures = mutactionsWithIndex + .takeWhile(_._2 < index) + .reverse + .map(m => { + // rollback and log + val rollbackFuture = m._1 match { + case mutaction: SystemSqlMutaction => + mutaction.rollback match { + case None => Future.failed(new Exception(s"Rollback not implemented: ${mutaction.getClass.getSimpleName}")) + case Some(rollback) => internalDatabaseDef.run(await(rollback).sqlAction) + } + + case mutaction: ClientSqlMutaction => + mutaction.rollback match { + case None => Future.failed(new Exception(s"Rollback not implemented: ${mutaction.getClass.getSimpleName}")) + case Some(rollback) => clientDatabase.run(await(rollback).sqlAction) + } + + case mutaction => + Future.successful(()) // only rolling back sql mutactions + } + + rollbackFuture + .andThenFuture( + handleSuccess = _ => internalDatabaseDef.run(setRollbackStatus(m._2, MutationLogStatus.ROLLEDBACK, None)), + handleFailure = e => internalDatabaseDef.run(setRollbackStatus(m._2, MutationLogStatus.FAILURE, Some(e))) + ) + }) + + // Todo: this is absolutely useless, Futures are already running in parallel. Massive bug that just happens to work by chance. + rollbackFutures.map(() => _).runSequentially + }) + } + ) + } + + def createLogFuture = + mutationLogMutactions.length match { + case 0 => Future.successful(()) + case _ => + internalDatabaseDef.run( + DBIO.seq(List(Tables.MutationLogs += mutationLog) ++ + mutationLogMutactions.map(m => Tables.MutationLogMutactions += m): _*)) + } + + // todo: make this run in transaction + // todo decide how to handle execution results from runOnClientDatabase + // todo: when updating both internal and client database - how should we handle failures? + def clientSqlActionsResultFuture: Future[List[MutactionExecutionResult]] = + clientSQLActions.map { action => () => + def executeAction = mutactionTimer.timeFuture(customTagValues = action.getClass.getSimpleName) { + clientDatabase.run(await(action._1.execute).sqlAction) + } + + logAndRollback( + action._2, + InternalMutation.performWithTiming( + s"execute ${action.getClass.getSimpleName}", + performWithTiming("clientSqlAction", executeAction), + logTiming + ) + ).map(_ => MutactionExecutionSuccess()) + .recover( + action._1.handleErrors + .getOrElse(defaultHandleErrors) + .andThen({ case e: Throwable => throw e })) + }.runSequentially + + def systemSqlActionsResultFuture: Future[List[MutactionExecutionResult]] = + systemSQLActions.map { action => () => + def executeAction = mutactionTimer.timeFuture(customTagValues = action.getClass.getSimpleName) { + internalDatabaseDef.run( + await(InternalMutation.performWithTiming(s"execute ${action.getClass.getSimpleName}", action._1.execute, logTiming)).sqlAction) + } + + logAndRollback( + action._2, + executeAction + ).map(_ => MutactionExecutionSuccess()) + .recover( + action._1.handleErrors + .getOrElse(defaultHandleErrors) + .andThen({ case e: Throwable => throw e })) + }.runSequentially + + def otherExecutionResultFuture: Future[List[MutactionExecutionResult]] = + otherActions.map { action => () => + def executeAction = mutactionTimer.timeFuture(customTagValues = action.getClass.getSimpleName) { + action._1.execute + } + logAndRollback(action._2, + InternalMutation + .performWithTiming(s"execute ${action.getClass.getSimpleName}", executeAction, logTiming)) + .recover( + action._1.handleErrors + .getOrElse(defaultHandleErrors) + .andThen({ case e: Throwable => throw e })) + }.runSequentially + + for { + createLogResult <- createLogFuture + clientSqlActionsResult <- clientSqlActionsResultFuture + systemSqlActionsResult <- systemSqlActionsResultFuture + otherExecutionResult <- otherExecutionResultFuture + } yield { + clientSqlActionsResult ++ systemSqlActionsResult ++ otherExecutionResult + } + } + + private def await[T](awaitable: Awaitable[T]): T = { + import scala.concurrent.duration._ + Await.result(awaitable, 15.seconds) + } + + private def performWithTiming[A](name: String, f: Future[A]): Future[A] = { + val begin = System.currentTimeMillis() + f andThen { + case x => + requestContext.foreach(_.logSqlTiming(Timing(name, System.currentTimeMillis() - begin))) + x + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/InternalMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/InternalMutation.scala new file mode 100644 index 0000000000..c31aa0ab52 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/InternalMutation.scala @@ -0,0 +1,119 @@ +package cool.graph + +import cool.graph.metrics.CustomTag +import cool.graph.shared.database.{InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.errors.CommonErrors.MutationsNotAllowedForProject +import cool.graph.shared.models.Project +import sangria.relay.Mutation +import scaldi.Injector + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Success, Try} + +abstract class InternalProjectMutation[+ReturnValue <: Mutation] extends InternalMutation[ReturnValue] { + + val projectDbsFn: Project => InternalAndProjectDbs + val project: Project + + override val databases: InternalAndProjectDbs = projectDbsFn(project) + override val internalDatabase: InternalDatabase = databases.internal + + override def verifyActions(): Future[List[Try[MutactionVerificationSuccess]]] = { + if (actions.exists(_.isInstanceOf[ClientSqlMutaction]) && !project.allowMutations) { + Future.failed(MutationsNotAllowedForProject(project.id)) + } else { + super.verifyActions() + } + } +} + +abstract class InternalMutation[+ReturnValue <: Mutation] { + import InternalMutationMetrics._ + + val internalDatabase: InternalDatabase + val databases: InternalAndProjectDbs = InternalAndProjectDbs(internal = internalDatabase) + + def trusted(input: TrustedInternalMutationInput[Product])(implicit inj: Injector): TrustedInternalMutation[ReturnValue] = { + TrustedInternalMutation(this, input, this.internalDatabase) + } + + val args: Product + var actions: List[Mutaction] = List.empty[Mutaction] + var actionVerificationResults: List[Try[MutactionVerificationSuccess]] = List.empty[Try[MutactionVerificationSuccess]] + var actionExecutionResults: List[MutactionExecutionResult] = List.empty[MutactionExecutionResult] + var mutactionTimings: List[Timing] = List.empty + + def prepareActions(): List[Mutaction] + + def verifyActions(): Future[List[Try[MutactionVerificationSuccess]]] = { + val mutactionFutures = actions.map { action => + require(!action.isInstanceOf[ClientSqlDataChangeMutaction], "This must not be called with ClientSqlDataChangeMutatctions") + InternalMutation.performWithTiming(s"verify ${action.getClass.getSimpleName}", action.verify(), timing => mutactionTimings :+= timing) + } + + Future + .sequence(mutactionFutures) + .andThen { + case Success(res) => actionVerificationResults = res + } + } + + def performActions(requestContext: Option[SystemRequestContextTrait] = None): Future[List[MutactionExecutionResult]] = { + runningMutactionsCounter.incBy(actions.size) + new InternalMutactionRunner(requestContext, databases, timing => mutactionTimings :+= timing).run(this, actions) + } + + def getReturnValue: Option[ReturnValue] + + def run(requestContext: SystemRequestContextTrait): Future[ReturnValue] = run(Some(requestContext)) + + def run(requestContext: Option[SystemRequestContextTrait] = None): Future[ReturnValue] = { + runningMutationsCounter.inc() + def performAndLog = { + for { + mutactionResults <- performActions(requestContext) + _ = logTimings(mutactionResults) + } yield getReturnValue.get + } + + def logTimings(results: List[MutactionExecutionResult]): Unit = { + requestContext.foreach(ctx => mutactionTimings.foreach(ctx.logMutactionTiming)) + } + + prepareActions() + + mutationTimer.timeFuture(customTagValues = this.getClass.getSimpleName) { + for { + verifications <- verifyActions() + firstError = verifications.find(_.isFailure) + result <- if (firstError.isDefined) { + throw firstError.get.failed.get + } else { + performAndLog + } + } yield result + } + } +} + +object InternalMutation { + def performWithTiming[A](name: String, f: Future[A], log: Function[Timing, Unit]): Future[A] = { + val begin = System.currentTimeMillis() + f andThen { + case x => + log(Timing(name, System.currentTimeMillis() - begin)) + x + } + } +} + +object InternalMutationMetrics { + import cool.graph.system.metrics.SystemMetrics._ + + val runningMutationsCounter = defineCounter("runningMutations") + val runningMutactionsCounter = defineCounter("runningMutactions") + + val mutationTimer = defineTimer("mutationTime", CustomTag("name", recordingThreshold = 1000)) + val mutactionTimer = defineTimer("mutactionTime", CustomTag("name", recordingThreshold = 500)) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/TrustedInternalMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/TrustedInternalMutation.scala new file mode 100644 index 0000000000..145019d3f6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/TrustedInternalMutation.scala @@ -0,0 +1,32 @@ +package cool.graph + +import com.typesafe.config.Config +import cool.graph.shared.database.InternalDatabase +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.mutactions.InvalidInput +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class TrustedInternalMutation[+T <: Mutation]( + mutation: InternalMutation[T], + args: TrustedInternalMutationInput[Product], + internalDatabase: InternalDatabase +)(implicit inj: Injector) + extends InternalMutation[T]() + with Injectable { + + val config: Config = inject[Config](identified by "config") + + override def prepareActions(): List[Mutaction] = { + if (args.secret == config.getString("systemApiSecret")) { + actions = mutation.prepareActions() + } else { + actions = List(InvalidInput(SystemErrors.InvalidSecret())) + } + actions + } + + override def getReturnValue: Option[T] = mutation.getReturnValue +} + +case class TrustedInternalMutationInput[+T](secret: String, mutationInput: T) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/ActionSchemaResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/ActionSchemaResolver.scala new file mode 100644 index 0000000000..b4f1439a45 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/ActionSchemaResolver.scala @@ -0,0 +1,84 @@ +package cool.graph.system + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.DataItem +import cool.graph.Types.Id +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.schemas._ +import cool.graph.shared.{ApiMatrixFactory} +import cool.graph.shared.models.{ActionTriggerMutationModelMutationType, ActionTriggerMutationRelationMutationType, ActionTriggerType, Project} +import sangria.execution.Executor +import sangria.introspection.introspectionQuery +import sangria.marshalling.sprayJson._ +import sangria.schema.Schema +import scaldi.{Injectable, Injector} +import spray.json.JsObject + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ActionSchemaPayload( + triggerType: ActionTriggerType.Value, + mutationModel: Option[ActionSchemaPayloadMutationModel], + mutationRelation: Option[ActionSchemaPayloadMutationRelation] +) + +case class ActionSchemaPayloadMutationModel( + modelId: Id, + mutationType: ActionTriggerMutationModelMutationType.Value +) + +case class ActionSchemaPayloadMutationRelation( + relationId: Id, + mutationType: ActionTriggerMutationRelationMutationType.Value +) + +class ActionSchemaResolver(implicit inj: Injector) extends Injectable with LazyLogging { + + def resolve(project: Project, payload: ActionSchemaPayload): Future[String] = { + val apiMatrix = inject[ApiMatrixFactory].create(project) + + payload.triggerType match { + case ActionTriggerType.MutationModel => + val model = apiMatrix.filterModel(project.getModelById_!(payload.mutationModel.get.modelId)) + + model match { + case None => + Future.successful(JsObject.empty.prettyPrint) + case Some(model) => + val modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project) + + val schema: Schema[ActionUserContext, Unit] = + payload.mutationModel.get.mutationType match { + case ActionTriggerMutationModelMutationType.Create => + new CreateSchema(model = model, modelObjectTypes = modelObjectTypes, project = project).build() + case ActionTriggerMutationModelMutationType.Update => + new UpdateSchema(model = model, + modelObjectTypes = modelObjectTypes, + project = project, + updatedFields = List(), + previousValues = DataItem("dummy", Map())).build() + case ActionTriggerMutationModelMutationType.Delete => + new DeleteSchema(model = model, modelObjectTypes = modelObjectTypes, project = project).build() + } + + Executor + .execute( + schema = schema, + queryAst = introspectionQuery, + userContext = ActionUserContext( + requestId = "", + project = project, + nodeId = model.id, + mutation = MutationMetaData(id = "", _type = ""), + log = (x: String) => logger.info(x) + ) + ) + .map { response => + val JsObject(fields) = response + fields("data").compactPrint + } + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/RequestPipelineSchemaResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/RequestPipelineSchemaResolver.scala new file mode 100644 index 0000000000..014995155d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/RequestPipelineSchemaResolver.scala @@ -0,0 +1,36 @@ +package cool.graph.system + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models.{Model, Project, RequestPipelineOperation} +import cool.graph.system.migration.dataSchema.SchemaExport +import sangria.ast.ObjectTypeDefinition +import sangria.renderer.QueryRenderer + +class RequestPipelineSchemaResolver { + def resolve(project: Project, model: Model, binding: FunctionBinding, operation: RequestPipelineOperation): String = { + + val fields = operation match { + case RequestPipelineOperation.CREATE => model.scalarFields + case RequestPipelineOperation.UPDATE => + model.scalarFields.map(f => + f.name match { + case "id" => f + case _ => f.copy(isRequired = false) + }) + case RequestPipelineOperation.DELETE => model.scalarFields.filter(_.name == "id") + } + + val fieldDefinitions = fields + .map(field => { + SchemaExport.buildFieldDefinition(project, model, field) + }) + .map(definition => definition.copy(directives = Vector.empty)) + .toVector + + val res = + ObjectTypeDefinition(s"${model.name}Input", interfaces = Vector(), fields = fieldDefinitions.sortBy(_.name), directives = Vector(), comments = Vector()) + + QueryRenderer.render(res) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/SchemaBuilderImpl.scala b/server/backend-api-system/src/main/scala/cool/graph/system/SchemaBuilderImpl.scala new file mode 100644 index 0000000000..85c2e17538 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/SchemaBuilderImpl.scala @@ -0,0 +1,305 @@ +package cool.graph.system + +import akka.actor.ActorSystem +import cool.graph.InternalMutation +import cool.graph.shared.database.{GlobalDatabaseManager, InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.errors.SystemErrors.InvalidProjectId +import cool.graph.shared.functions.FunctionEnvironment +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Project, ProjectDatabase, ProjectWithClientId} +import cool.graph.system.authorization.SystemAuth +import cool.graph.system.database.client.{ClientDbQueries, ClientDbQueriesImpl} +import cool.graph.system.database.finder.{ProjectFinder, ProjectResolver} +import cool.graph.system.mutations._ +import cool.graph.system.schema.fields._ +import cool.graph.system.schema.types._ +import sangria.relay.{Connection, ConnectionDefinition, Edge, Mutation} +import sangria.schema.{Field, ObjectType, OptionType, Schema, StringType, UpdateCtx, fields} +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future + +trait SchemaBuilder { + def apply(userContext: SystemUserContext): Schema[SystemUserContext, Unit] +} + +object SchemaBuilder { + def apply(fn: SystemUserContext => Schema[SystemUserContext, Unit]): SchemaBuilder = new SchemaBuilder { + override def apply(userContext: SystemUserContext) = fn(userContext) + } +} + +class SchemaBuilderImpl( + userContext: SystemUserContext, + globalDatabaseManager: GlobalDatabaseManager, + internalDatabase: InternalDatabase +)( + implicit inj: Injector, + system: ActorSystem +) extends Injectable { + import system.dispatcher + + implicit val projectResolver: ProjectResolver = inject[ProjectResolver](identified by "projectResolver") + val functionEnvironment = inject[FunctionEnvironment] + lazy val clientType: ObjectType[SystemUserContext, Client] = Customer.getType(userContext.clientId) + lazy val viewerType: ObjectType[SystemUserContext, ViewerModel] = Viewer.getType(clientType, projectResolver) + lazy val ConnectionDefinition(clientEdge, _) = Connection.definition[SystemUserContext, Connection, models.Client]("Client", clientType) + + def build(): Schema[SystemUserContext, Unit] = { + val Query = ObjectType( + "Query", + viewerField() :: nodeField :: Nil + ) + + val Mutation = ObjectType( + "Mutation", + getFields.toList + ) + + Schema(Query, Some(Mutation)) + } + + def viewerField(): Field[SystemUserContext, Unit] = Field( + "viewer", + fieldType = viewerType, + resolve = _ => ViewerModel() + ) + + def getFields: Vector[Field[SystemUserContext, Unit]] = Vector( + getPushField, + getTemporaryDeployUrl, + getAddProjectField, + getAuthenticateCustomerField, + getExportDataField, + getGenerateNodeTokenMutationField + ) + + def getPushField: Field[SystemUserContext, Unit] = { + import Push.fromInput + + Mutation + .fieldWithClientMutationId[SystemUserContext, Unit, PushMutationPayload, PushInput]( + fieldName = "push", + typeName = "Push", + inputFields = Push.inputFields, + outputFields = fields( + Field("project", OptionType(ProjectType), resolve = ctx => ctx.value.project), + Field("migrationMessages", VerbalDescriptionType.TheListType, resolve = ctx => ctx.value.verbalDescriptions), + Field("errors", SchemaErrorType.TheListType, resolve = ctx => ctx.value.errors) + ), + mutateAndGetPayload = (input, ctx) => + UpdateCtx({ + + for { + project <- getProjectOrThrow(input.projectId) + mutator = PushMutation( + client = ctx.ctx.getClient, + project = project.project, + args = input, + dataResolver = ctx.ctx.dataResolver(project.project), + projectDbsFn = internalAndProjectDbsForProject, + clientDbQueries = clientDbQueries(project.project) + ) + payload <- mutator.run(ctx.ctx).flatMap { payload => + val clientId = ctx.ctx.getClient.id + ProjectFinder + .loadById(clientId, payload.project.id) + .map(project => payload.copy(project = project)) + } + } yield { + payload + } + }) { payload => + ctx.ctx.refresh() + } + ) + } + + def getTemporaryDeployUrl: Field[SystemUserContext, Unit] = { + import GetTemporaryDeploymentUrl.fromInput + + case class GetTemporaryDeployUrlPayload(url: String, clientMutationId: Option[String] = None) extends Mutation + + Mutation + .fieldWithClientMutationId[SystemUserContext, Unit, GetTemporaryDeployUrlPayload, GetTemporaryDeployUrlInput]( + fieldName = "getTemporaryDeployUrl", + typeName = "GetTemporaryDeployUrl", + inputFields = GetTemporaryDeploymentUrl.inputFields, + outputFields = fields( + Field("url", StringType, resolve = ctx => ctx.value.url) + ), + mutateAndGetPayload = (input, ctx) => + for { + project <- getProjectOrThrow(input.projectId) + temporaryUrl <- functionEnvironment.getTemporaryUploadUrl(project.project) + } yield { + GetTemporaryDeployUrlPayload(temporaryUrl, None) + } + ) + } + + def getAddProjectField: Field[SystemUserContext, Unit] = { + import AddProject.manual + + Mutation.fieldWithClientMutationId[SystemUserContext, Unit, AddProjectMutationPayload, AddProjectInput]( + fieldName = "addProject", + typeName = "AddProject", + inputFields = AddProject.inputFields, + outputFields = fields( + Field("viewer", viewerType, resolve = _ => ViewerModel()), + Field("project", ProjectType, resolve = ctx => ctx.value.project), + Field("user", clientType, resolve = ctx => ctx.ctx.getClient), + Field("projectEdge", projectEdge, resolve = ctx => Edge(node = ctx.value.project, cursor = Connection.offsetToCursor(0))), + Field("migrationMessages", VerbalDescriptionType.TheListType, resolve = ctx => ctx.value.verbalDescriptions), + Field("errors", SchemaErrorType.TheListType, resolve = ctx => ctx.value.errors) + ), + mutateAndGetPayload = (input, ctx) => + UpdateCtx({ + val mutator = new AddProjectMutation( + client = ctx.ctx.getClient, + args = input, + projectDbsFn = internalAndProjectDbsForProject, + internalDatabase = internalDatabase, + globalDatabaseManager = globalDatabaseManager + ) + + mutator + .run(ctx.ctx) + .flatMap(payload => { + val clientId = ctx.ctx.getClient.id + ProjectFinder + .loadById(clientId, payload.project.id) + .map(project => payload.copy(project = project)) + }) + + }) { payload => + ctx.ctx.refresh() + } + ) + } + + def getAuthenticateCustomerField: Field[SystemUserContext, Unit] = { + import AuthenticateCustomer.manual + + Mutation + .fieldWithClientMutationId[SystemUserContext, Unit, AuthenticateCustomerMutationPayload, AuthenticateCustomerInput]( + fieldName = "authenticateCustomer", + typeName = "AuthenticateCustomer", + inputFields = AuthenticateCustomer.inputFields, + outputFields = fields( + Field("viewer", viewerType, resolve = _ => ViewerModel()), + Field("user", clientType, resolve = ctx => ctx.ctx.getClient), + Field("userEdge", clientEdge, resolve = ctx => Edge(node = ctx.ctx.getClient, cursor = Connection.offsetToCursor(0))), + Field("token", StringType, resolve = ctx => { + val auth = new SystemAuth() + auth.generateSessionToken(ctx.value.client.id) + }) + ), + mutateAndGetPayload = (input, ctx) => + UpdateCtx({ + + ctx.ctx.auth + .loginByAuth0IdToken(input.auth0IdToken) + .flatMap { + case Some((sessionToken, id)) => + val userContext = ctx.ctx.refresh(Some(id)) + Future.successful(AuthenticateCustomerMutationPayload(input.clientMutationId, userContext.client.get)) + case None => + val mutator = createAuthenticateCustomerMutation(input) + mutator.run(ctx.ctx) + + } + }) { payload => + ctx.ctx.refresh(Some(payload.client.id)) + } + ) + } + + def createAuthenticateCustomerMutation(input: AuthenticateCustomerInput): InternalMutation[AuthenticateCustomerMutationPayload] = { + AuthenticateCustomerMutation( + args = input, + internalDatabase = internalDatabase, + projectDbsFn = internalAndProjectDbsForProjectDatabase + ) + } + + def getExportDataField: Field[SystemUserContext, Unit] = { + import ExportData.manual + + Mutation + .fieldWithClientMutationId[SystemUserContext, Unit, ExportDataMutationPayload, ExportDataInput]( + fieldName = "exportData", + typeName = "ExportData", + inputFields = ExportData.inputFields, + outputFields = fields( + Field("viewer", viewerType, resolve = _ => ViewerModel()), + Field("project", ProjectType, resolve = ctx => ctx.value.project), + Field("user", clientType, resolve = ctx => ctx.ctx.getClient), + Field("url", StringType, resolve = ctx => ctx.value.url) + ), + mutateAndGetPayload = (input, ctx) => { + val project = ProjectFinder.loadById(ctx.ctx.getClient.id, input.projectId) + project.flatMap { project => + val mutator = ExportDataMutation( + client = ctx.ctx.getClient, + project = project, + args = input, + projectDbsFn = internalAndProjectDbsForProject, + dataResolver = ctx.ctx.dataResolver(project) + ) + + mutator + .run(ctx.ctx) + .flatMap { payload => + val clientId = ctx.ctx.getClient.id + ProjectFinder + .loadById(clientId, payload.project.id) + .map(project => payload.copy(project = project)) + } + } + } + ) + } + + def getGenerateNodeTokenMutationField: Field[SystemUserContext, Unit] = { + import cool.graph.system.schema.fields.GenerateNodeToken.manual + + Mutation + .fieldWithClientMutationId[SystemUserContext, Unit, GenerateUserTokenPayload, GenerateUserTokenInput]( + fieldName = "generateNodeToken", + typeName = "GenerateNodeToken", + inputFields = cool.graph.system.schema.fields.GenerateNodeToken.inputFields, + outputFields = fields(Field("token", StringType, resolve = ctx => ctx.value.token)), + mutateAndGetPayload = (input, ctx) => { + projectResolver + .resolve(input.projectId) + .flatMap { + case Some(project) => + val mutation = mutations.GenerateUserToken(project = project, args = input, projectDbsFn = internalAndProjectDbsForProject) + mutation.run(ctx.ctx) + case _ => + throw SystemErrors.InvalidProjectId(projectId = input.projectId) + } + } + ) + } + + def internalAndProjectDbsForProjectDatabase(projectDatabase: ProjectDatabase): InternalAndProjectDbs = { + val clientDbs = globalDatabaseManager.getDbForProjectDatabase(projectDatabase) + InternalAndProjectDbs(internalDatabase, clientDbs) + } + + def internalAndProjectDbsForProject(project: Project): InternalAndProjectDbs = { + val clientDbs = globalDatabaseManager.getDbForProject(project) + InternalAndProjectDbs(internalDatabase, clientDbs) + } + + def clientDbQueries(project: Project): ClientDbQueries = ClientDbQueriesImpl(globalDatabaseManager)(project) + + def getProjectOrThrow(projectId: String): Future[ProjectWithClientId] = { + projectResolver.resolveProjectWithClientId(projectIdOrAlias = projectId).map { projectOpt => + projectOpt.getOrElse(throw InvalidProjectId(projectId)) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/SystemDependencies.scala b/server/backend-api-system/src/main/scala/cool/graph/system/SystemDependencies.scala new file mode 100644 index 0000000000..c3df60e2f4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/SystemDependencies.scala @@ -0,0 +1,169 @@ +package cool.graph.system + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} +import com.amazonaws.services.s3.{AmazonS3, AmazonS3ClientBuilder} +import com.amazonaws.services.sns.{AmazonSNS, AmazonSNSAsyncClientBuilder} +import com.typesafe.config.ConfigFactory +import cool.graph.bugsnag.{BugSnagger, BugSnaggerImpl} +import cool.graph.cloudwatch.CloudwatchImpl +import cool.graph.messagebus.pubsub.rabbit.RabbitAkkaPubSub +import cool.graph.messagebus.{Conversions, PubSubPublisher} +import cool.graph.shared.database.{GlobalDatabaseManager, InternalDatabase} +import cool.graph.shared.{ApiMatrixFactory, DefaultApiMatrix} +import cool.graph.shared.externalServices._ +import cool.graph.shared.functions.FunctionEnvironment +import cool.graph.shared.functions.lambda.LambdaFunctionEnvironment +import cool.graph.system.database.finder.client.ClientResolver +import cool.graph.system.database.finder.{CachedProjectResolver, CachedProjectResolverImpl, ProjectQueries, UncachedProjectResolver} +import cool.graph.system.database.schema.{InternalDatabaseSchema, LogDatabaseSchema} +import cool.graph.system.database.seed.InternalDatabaseSeedActions +import cool.graph.system.externalServices._ +import cool.graph.system.metrics.SystemMetrics +import scaldi.Module +import slick.jdbc.MySQLProfile +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +trait SystemApiDependencies extends Module { + implicit val system: ActorSystem + implicit val materializer: ActorMaterializer + + SystemMetrics.init() + + def config = ConfigFactory.load() + + val functionEnvironment: FunctionEnvironment + val uncachedProjectResolver: UncachedProjectResolver + val cachedProjectResolver: CachedProjectResolver + val invalidationPublisher: PubSubPublisher[String] + val requestPrefix: String + + lazy val internalDb = setupAndGetInternalDatabase() + lazy val logsDb = setupAndGetLogsDatabase() + lazy val clientResolver = ClientResolver(internalDb, cachedProjectResolver)(system.dispatcher) + lazy val kinesis = createKinesis() + lazy val globalDatabaseManager = GlobalDatabaseManager.initializeForMultipleRegions(config) + lazy val schemaBuilder = SchemaBuilder(userCtx => new SchemaBuilderImpl(userCtx, globalDatabaseManager, InternalDatabase(internalDb)).build()) + implicit lazy val bugsnagger = BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) + + bind[GlobalDatabaseManager] toNonLazy globalDatabaseManager + binding identifiedBy "internal-db" toNonLazy internalDb + binding identifiedBy "logs-db" toNonLazy logsDb + binding identifiedBy "kinesis" toNonLazy kinesis + binding identifiedBy "sns" toNonLazy createSns() + binding identifiedBy "cloudwatch" toNonLazy CloudwatchImpl() + binding identifiedBy "export-data-s3" toNonLazy createExportDataS3() + binding identifiedBy "config" toNonLazy config + binding identifiedBy "actorSystem" toNonLazy system destroyWith (_.terminate()) + binding identifiedBy "dispatcher" toNonLazy system.dispatcher + binding identifiedBy "actorMaterializer" toNonLazy materializer + binding identifiedBy "master-token" toNonLazy sys.env.get("MASTER_TOKEN") + binding identifiedBy "clientResolver" toNonLazy clientResolver + binding identifiedBy "projectQueries" toNonLazy ProjectQueries()(internalDb, cachedProjectResolver) + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + + bind[GlobalDatabaseManager] toNonLazy GlobalDatabaseManager.initializeForMultipleRegions(config) + bind[AlgoliaKeyChecker] identifiedBy "algoliaKeyChecker" toNonLazy new AlgoliaKeyCheckerImplementation() + bind[Auth0Api] toNonLazy new Auth0ApiImplementation + bind[Auth0Extend] toNonLazy new Auth0ExtendImplementation() + bind[BugSnagger] toNonLazy bugsnagger + bind[SnsPublisher] identifiedBy "seatSnsPublisher" toNonLazy new SnsPublisherImplementation(topic = sys.env("SNS_SEAT")) + bind[TestableTime] toNonLazy new TestableTimeImplementation + bind[KinesisPublisher] identifiedBy "kinesisAlgoliaSyncQueriesPublisher" toNonLazy new KinesisPublisherImplementation( + streamName = sys.env("KINESIS_STREAM_ALGOLIA_SYNC_QUERY"), + kinesis) + + protected def createKinesis(): AmazonKinesis = { + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonKinesisClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("KINESIS_ENDPOINT"), sys.env("AWS_REGION"))) + .build() + } + + protected def createSns(): AmazonSNS = { + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonSNSAsyncClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("SNS_ENDPOINT"), sys.env("AWS_REGION"))) + .build + } + + protected def createExportDataS3(): AmazonS3 = { + val credentials = new BasicAWSCredentials( + sys.env.getOrElse("AWS_ACCESS_KEY_ID", ""), + sys.env.getOrElse("AWS_SECRET_ACCESS_KEY", "") + ) + + AmazonS3ClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("DATA_EXPORT_S3_ENDPOINT"), sys.env("AWS_REGION"))) + .build + } + + protected def setupAndGetInternalDatabase(): MySQLProfile.backend.Database = { + Try { + val rootDb = Database.forConfig(s"internalRoot") + Await.result(rootDb.run(InternalDatabaseSchema.createSchemaActions(recreate = false)), 30.seconds) + rootDb.close() + + val db = Database.forConfig("internal") + Await.result(db.run(InternalDatabaseSeedActions.seedActions(sys.env.get("MASTER_TOKEN"))), 5.seconds) + db + } match { + case Success(database) => database + case Failure(e) => println(s"[FATAL] Unable to init database: $e"); sys.exit(-1) + } + } + + protected def setupAndGetLogsDatabase(): MySQLProfile.backend.Database = { + Try { + val rootDb = Database.forConfig(s"logsRoot") + Await.result(rootDb.run(LogDatabaseSchema.createSchemaActions(recreate = false)), 30.seconds) + rootDb.close() + + Database.forConfig("logs") + } match { + case Success(database) => database + case Failure(e) => println(s"[FATAL] Unable to init logs database: $e"); sys.exit(-1) + } + } +} + +case class SystemDependencies()(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends SystemApiDependencies { + implicit val marshaller = Conversions.Marshallers.FromString + + val globalRabbitUri = sys.env.getOrElse("GLOBAL_RABBIT_URI", sys.error("GLOBAL_RABBIT_URI required for schema invalidation")) + val invalidationPublisher = RabbitAkkaPubSub.publisher[String](globalRabbitUri, "project-schema-invalidation", durable = true) + val uncachedProjectResolver = UncachedProjectResolver(internalDb) + val cachedProjectResolver: CachedProjectResolver = CachedProjectResolverImpl(uncachedProjectResolver)(system.dispatcher) + val apiMatrixFactory: ApiMatrixFactory = ApiMatrixFactory(DefaultApiMatrix(_)) + val requestPrefix = sys.env.getOrElse("AWS_REGION", sys.error("AWS Region not found.")) + + val functionEnvironment = LambdaFunctionEnvironment( + sys.env.getOrElse("LAMBDA_AWS_ACCESS_KEY_ID", "whatever"), + sys.env.getOrElse("LAMBDA_AWS_SECRET_ACCESS_KEY", "whatever") + ) + + bind[PubSubPublisher[String]] identifiedBy "schema-invalidation-publisher" toNonLazy invalidationPublisher + bind[String] identifiedBy "request-prefix" toNonLazy requestPrefix + bind[FunctionEnvironment] toNonLazy functionEnvironment + bind[ApiMatrixFactory] toNonLazy apiMatrixFactory + + binding identifiedBy "projectResolver" toNonLazy cachedProjectResolver + binding identifiedBy "cachedProjectResolver" toNonLazy cachedProjectResolver + binding identifiedBy "uncachedProjectResolver" toNonLazy uncachedProjectResolver +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/SystemMain.scala b/server/backend-api-system/src/main/scala/cool/graph/system/SystemMain.scala new file mode 100644 index 0000000000..7f3c51bb5a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/SystemMain.scala @@ -0,0 +1,15 @@ +package cool.graph.system + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor + +import scala.language.postfixOps + +object SystemMain extends App { + implicit val system = ActorSystem("sangria-server") + implicit val materializer = ActorMaterializer() + implicit val inj = SystemDependencies() + + ServerExecutor(8081, SystemServer(inj.schemaBuilder, "system")).startBlocking() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/SystemServer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/SystemServer.scala new file mode 100644 index 0000000000..a6c10ad4af --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/SystemServer.scala @@ -0,0 +1,174 @@ +package cool.graph.system + +import akka.actor.ActorSystem +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.model.{HttpResponse, StatusCode} +import akka.http.scaladsl.server.Directives._ +import akka.stream.ActorMaterializer +import com.typesafe.scalalogging.LazyLogging +import cool.graph._ +import cool.graph.akkautil.http.Server +import cool.graph.cuid.Cuid.createCuid +import cool.graph.metrics.extensions.TimeResponseDirectiveImpl +import cool.graph.shared.database.{GlobalDatabaseManager, InternalDatabase} +import cool.graph.shared.errors.CommonErrors.TimeoutExceeded +import cool.graph.shared.logging.{LogData, LogKey} +import cool.graph.shared.schema.JsonMarshalling._ +import cool.graph.system.authorization.SystemAuth +import cool.graph.system.database.finder.client.ClientResolver +import cool.graph.system.metrics.SystemMetrics +import cool.graph.util.ErrorHandlerFactory +import sangria.execution.{ErrorWithResolver, Executor, QueryAnalysisError} +import sangria.parser.QueryParser +import scaldi._ +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import spray.json._ + +import scala.concurrent.Future +import scala.language.postfixOps +import scala.util.{Failure, Success} + +case class SystemServer( + schemaBuilder: SchemaBuilder, + prefix: String = "" +)(implicit inj: Injector, system: ActorSystem, materializer: ActorMaterializer) + extends Server + with Injectable + with LazyLogging { + import system.dispatcher + + implicit val internalDatabaseDef: DatabaseDef = inject[DatabaseDef](identified by "internal-db") + implicit val clientResolver = inject[ClientResolver](identified by "clientResolver") + + val globalDatabaseManager = inject[GlobalDatabaseManager] + val internalDatabase = InternalDatabase(internalDatabaseDef) + val log: String => Unit = (msg: String) => logger.info(msg) + val errorHandlerFactory = ErrorHandlerFactory(log) + val requestPrefix = inject[String](identified by "request-prefix") + + val innerRoutes = extractRequest { _ => + val requestId = requestPrefix + ":system:" + createCuid() + val requestBeginningTime = System.currentTimeMillis() + + def logRequestEnd(projectId: Option[String] = None, clientId: Option[String] = None) = { + logger.info( + LogData( + key = LogKey.RequestComplete, + requestId = requestId, + projectId = projectId, + clientId = clientId, + payload = Some(Map("request_duration" -> (System.currentTimeMillis() - requestBeginningTime))) + ).json) + } + + logger.info(LogData(LogKey.RequestNew, requestId).json) + + post { + TimeResponseDirectiveImpl(SystemMetrics).timeResponse { + optionalHeaderValueByName("Authorization") { authorizationHeader => + optionalCookie("session") { sessionCookie => + respondWithHeader(RawHeader("Request-Id", requestId)) { + entity(as[JsValue]) { requestJson => + withRequestTimeoutResponse { request => + val unhandledErrorLogger = errorHandlerFactory.unhandledErrorHandler(requestId = requestId) + val error = TimeoutExceeded() + val errorResponse = unhandledErrorLogger(error) + + HttpResponse(errorResponse._1, entity = errorResponse._2.prettyPrint) + } { + complete { + val JsObject(fields) = requestJson + val JsString(query) = fields("query") + + val operationName = + fields.get("operationName") collect { + case JsString(op) if !op.isEmpty ⇒ op + } + + val variables = fields.get("variables") match { + case Some(obj: JsObject) => obj + case Some(JsString(s)) if s.trim.nonEmpty => s.parseJson + case _ => JsObject.empty + } + + val auth = new SystemAuth() + + val sessionToken = authorizationHeader + .flatMap { + case str if str.startsWith("Bearer ") => Some(str.stripPrefix("Bearer ")) + case _ => None + } + .orElse(sessionCookie.map(_.value)) + + val f: Future[SystemUserContext] = + sessionToken.flatMap(auth.parseSessionToken) match { + case None => Future.successful(SystemUserContext(None, requestId, logger.info(_))) + case Some(clientId) => SystemUserContext.fetchClient(clientId = clientId, requestId = requestId, log = logger.info(_)) + } + + f map { userContext => + { + QueryParser.parse(query) match { + case Failure(error) => + Future.successful(BadRequest -> JsObject("error" -> JsString(error.getMessage))) + + case Success(queryAst) => + val sangriaErrorHandler = errorHandlerFactory + .sangriaHandler( + requestId = requestId, + query = query, + variables = variables, + clientId = userContext.client.map(_.id), + projectId = None + ) + + val result: Future[(StatusCode with Product with Serializable, JsValue)] = + Executor + .execute( + schema = schemaBuilder(userContext), + queryAst = queryAst, + userContext = userContext, + variables = variables, + exceptionHandler = sangriaErrorHandler, + operationName = operationName, + middleware = List(new FieldMetricsMiddleware) + ) + .map(node => OK -> node) + .recover { + case error: QueryAnalysisError => BadRequest -> error.resolveError + case error: ErrorWithResolver => InternalServerError -> error.resolveError + } + + result.onComplete(_ => logRequestEnd(None, Some(userContext.clientId))) + result + } + } + } + } + } + } + } + } + } + } + } ~ + get { + getFromResource("graphiql.html") + } + } + + def healthCheck: Future[_] = + for { + _ <- Future.sequence { + globalDatabaseManager.databases.values.map { db => + for { + _ <- db.master.run(sql"SELECT 1".as[Int]) + _ <- db.readOnly.run(sql"SELECT 1".as[Int]) + } yield () + } + } + } yield () +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/SystemUserContext.scala b/server/backend-api-system/src/main/scala/cool/graph/system/SystemUserContext.scala new file mode 100644 index 0000000000..f77e76840c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/SystemUserContext.scala @@ -0,0 +1,116 @@ +package cool.graph.system + +import java.util.concurrent.TimeUnit + +import cool.graph.SystemRequestContextTrait +import cool.graph.client.database.ProjectDataresolver +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.errors.UserInputErrors.InvalidSession +import cool.graph.shared.models.ModelOperation.ModelOperation +import cool.graph.shared.models._ +import cool.graph.shared.queryPermissions.PermissionSchemaResolver +import cool.graph.system.authorization.SystemAuth +import cool.graph.system.database.finder.client.ClientResolver +import cool.graph.system.database.finder.{CachedProjectResolver, LogsDataResolver, ProjectResolver} +import cool.graph.system.database.tables.Tables.RelayIds +import cool.graph.system.schema.types.{SearchProviderAlgoliaSchemaResolver, ViewerModel} +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, Future} + +case class SystemUserContext( + client: Option[Client], + requestId: String, + log: scala.Predef.Function[String, Unit] +)(implicit inj: Injector, val clientResolver: ClientResolver) + extends Injectable + with SystemRequestContextTrait { + + override val projectId: Option[String] = None + override val clientId = client.map(_.id).getOrElse("") + override val requestIp = "fake-ip" + + val cloudwatch = inject[Cloudwatch]("cloudwatch") + val internalDatabase = inject[DatabaseDef](identified by "internal-db") + val logsDatabase = inject[DatabaseDef](identified by "logs-db") + val projectResolver = inject[ProjectResolver](identified by "projectResolver") + val injector = inj + + val logsDataResolver = new LogsDataResolver() + + def dataResolver(project: Project) = new ProjectDataresolver(project = project, requestContext = Some(this)) + + val auth = new SystemAuth() + + def getTypeName(globalId: String): Future[Option[String]] = { + if (globalId == ViewerModel.globalId) { + return Future.successful(Some("Viewer")) + } + + internalDatabase.run( + RelayIds + .filter(_.id === globalId) + .map(_.typeName) + .take(1) + .result + .headOption) + } + + def getActionSchema(project: Project, payload: ActionSchemaPayload): Future[String] = { + new ActionSchemaResolver().resolve(project, payload) + } + + def getModelPermissionSchema(project: Project, modelId: String, operation: ModelOperation): Future[String] = { + new PermissionSchemaResolver().resolve(project) + } + + def getRelationPermissionSchema(project: Project, relationId: String): Future[String] = { + new PermissionSchemaResolver().resolve(project) + } + + def getSearchProviderAlgoliaSchema(project: Project, modelId: String): Future[String] = { + new SearchProviderAlgoliaSchemaResolver().resolve(project, modelId) + } + + def getClient: Client = client match { + case Some(client) => client + case None => throw new InvalidSession + } + + def refresh(clientId: String): SystemUserContext = refresh(Some(clientId)) + + def refresh(clientId: Option[String] = None): SystemUserContext = { + implicit val internalDatabase: DatabaseDef = inject[DatabaseDef](identified by "internal-db") + + (clientId match { + case Some(clientId) => Some(clientId) + case None => client.map(_.id) + }) match { + case Some(clientId) => + Await.result(SystemUserContext + .fetchClient(clientId, requestId, log = log), + Duration(5, TimeUnit.SECONDS)) + case None => + throw new Exception( + "Don't call refresh when client is None. Currently the UserContext is used both when there is a client and when there isn't. We should refactor that") + } + } +} + +object SystemUserContext { + + def fetchClient(clientId: String, requestId: String, log: scala.Predef.Function[String, Unit])(implicit inj: Injector, + clientResolver: ClientResolver): Future[SystemUserContext] = { + clientResolver.resolve(clientId = clientId) map { + case Some(client) => + SystemUserContext(client = Some(client), requestId = requestId, log = log) + case None => + throw SystemErrors.InvalidClientId(clientId) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth.scala b/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth.scala new file mode 100644 index 0000000000..0ebcf24186 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth.scala @@ -0,0 +1,206 @@ +package cool.graph.system.authorization + +import com.github.t3hnar.bcrypt._ +import com.typesafe.config.Config +import cool.graph.Types.Id +import cool.graph.shared.authorization.JwtCustomerData +import cool.graph.shared.errors.UserInputErrors.DuplicateEmailFromMultipleProviders +import cool.graph.system.database.tables.Client +import cool.graph.system.database.tables.Tables._ +import cool.graph.utils.future.FutureUtils._ +import pdi.jwt.{Jwt, JwtAlgorithm, JwtClaim} + +import scaldi._ +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import spray.json._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class SystemAuth()(implicit inj: Injector) extends Injectable { + + import cool.graph.shared.authorization.JwtClaimJsonProtocol._ + + val internalDatabase = inject[DatabaseDef](identified by "internal-db") + val config = inject[Config](identified by "config") + val masterToken = inject[Option[String]](identified by "master-token") + + type SessionToken = String + + val expiringSeconds = 60 * 60 * 24 * 30 + + def login(email: String, password: String): Future[Option[(SessionToken, Id)]] = { + internalDatabase + .run(Clients.filter(_.email === email).take(1).result.headOption) + .map { + case Some(client) if password.isBcrypted(client.password) => + val sessionToken = Jwt.encode(JwtClaim(JwtCustomerData(client.id).toJson.compactPrint).issuedNow, config.getString("jwtSecret"), JwtAlgorithm.HS256) + Some((sessionToken, client.id)) + + case _ => + None + } + } + + def trustedLogin(email: String, secret: String): Future[Option[(SessionToken, Id)]] = { + if (secret != config.getString("systemApiSecret")) { + return Future.successful(None) + } + + internalDatabase + .run(Clients.filter(_.email === email).take(1).result.headOption) + .map { + case Some(client) => + val sessionToken = Jwt.encode(JwtClaim(JwtCustomerData(client.id).toJson.compactPrint).issuedNow, config.getString("jwtSecret"), JwtAlgorithm.HS256) + Some((sessionToken, client.id)) + + case _ => + None + } + } + + def loginByAuth0IdToken(idToken: String): Future[Option[(SessionToken, Id)]] = { + // Check if we run in a local env with a master token and if not, use the usual flow + masterToken match { + case Some(token) => + if (idToken == token) { + masterTokenFlow(idToken) + } else { + throw new Exception("Invalid token for local env") + } + + case None => + loginByAuth0IdTokenFlow(idToken) + } + } + + def masterTokenFlow(idToken: String): Future[Option[(SessionToken, Id)]] = { + internalDatabase + .run(Clients.result) + .flatMap { (customers: Seq[Client]) => + if (customers.nonEmpty) { + generateSessionToken(customers.head.id).map(sessionToken => Some((sessionToken, customers.head.id))) + } else { + throw new Exception("Inconsistent local state: Master user was not initialized correctly.") + } + } + } + + /** + * Rules: + * existing auth0id - sign in + * no auth0Id, existing email that matches - sign in and add auth0Id + * no auth0Id, no email - create user and add auth0Id + * no auth0Id, existing email for other user - reject + */ + def loginByAuth0IdTokenFlow(idToken: String): Future[Option[(SessionToken, Id)]] = { + val idTokenData = parseAuth0IdToken(idToken) + + if (idTokenData.isEmpty) { + return Future.successful(None) + } + + val isAuth0IdentityProviderEmail = idTokenData.get.sub.split("\\|").head == "auth0" + + internalDatabase + .run( + Clients + .filter(c => c.email === idTokenData.get.email || c.auth0Id === idTokenData.get.sub) + .result) + .flatMap(customers => { + customers.length match { + case 0 => { + // create user and add auth0Id + Future.successful(None) + } + case 1 => { + (customers.head.auth0Id, customers.head.email) match { + case (None, email) => { + // sign in and add auth0Id + generateSessionToken(customers.head.id).andThenFuture( + handleSuccess = res => + internalDatabase.run((for { + c <- Clients if c.id === customers.head.id + } yield (c.auth0Id, c.isAuth0IdentityProviderEmail)) + .update((Some(idTokenData.get.sub), isAuth0IdentityProviderEmail))), + handleFailure = e => Future.successful(()) + ) map (sessionToken => Some((sessionToken, customers.head.id))) + } + case (Some(auth0Id), email) if auth0Id == idTokenData.get.sub => { + // sign in + generateSessionToken(customers.head.id).map(sessionToken => Some((sessionToken, customers.head.id))) + + } + case (Some(auth0Id), email) + // note: the isEmail check is disabled until we fix the Auth0 account linking issue + if (auth0Id != idTokenData.get.sub) /*&& !isAuth0IdentityProviderEmail*/ => { + // Auth0 returns wrong id first time for linked accounts. + // Let's just go ahead and match on email only as long as it is provided by a social provider + // that has already verified the email + generateSessionToken(customers.head.id).map(sessionToken => Some((sessionToken, customers.head.id))) + + } + case (Some(auth0Id), email) if auth0Id != idTokenData.get.sub => { + // reject + throw DuplicateEmailFromMultipleProviders(email) + } + } + } + case 2 => { + // we fucked up + throw new Exception("Two different users exist with the idToken and email") + } + } + }) + } + + def loginByResetPasswordToken(resetPasswordToken: String): Future[Option[(SessionToken, Id)]] = { + internalDatabase + .run( + Clients + .filter(_.resetPasswordToken === resetPasswordToken) + .take(1) + .result + .headOption) + .flatMap { + case Some(client) => generateSessionToken(client.id).map(sessionToken => Some((sessionToken, client.id))) + case _ => Future.successful(None) + } + } + + def generateSessionToken(clientId: String): Future[String] = Future.successful { + Jwt.encode(JwtClaim(JwtCustomerData(clientId).toJson.compactPrint).issuedNow, config.getString("jwtSecret"), JwtAlgorithm.HS256) + } + + def generateSessionTokenWithExpiration(clientId: String): String = { + Jwt.encode(JwtClaim(JwtCustomerData(clientId).toJson.compactPrint).issuedNow.expiresIn(expiringSeconds), config.getString("jwtSecret"), JwtAlgorithm.HS256) + } + + def parseAuth0IdToken(idToken: String): Option[Auth0IdTokenData] = { + implicit val a = Auth0IdTokenDataJsonProtocol.formatAuth0IdTokenData + + val decodedSecret = new String( + new sun.misc.BASE64Decoder() + .decodeBuffer(config.getString("auth0jwtSecret"))) + + Jwt + .decodeRaw(idToken, decodedSecret, Seq(JwtAlgorithm.HS256)) + .map(_.parseJson.convertTo[Auth0IdTokenData]) + .map(Some(_)) + .getOrElse(None) + } + + def parseSessionToken(sessionToken: SessionToken): Option[String] = { + SystemAuth2().clientId(sessionToken) + } +} + +case class Auth0IdTokenData(sub: String, email: String, name: String, exp: Option[Int], user_metadata: Option[UserMetaData]) +case class UserMetaData(name: String) + +object Auth0IdTokenDataJsonProtocol extends DefaultJsonProtocol { + implicit val formatUserMetaData = jsonFormat(UserMetaData, "name") + implicit val formatAuth0IdTokenData = + jsonFormat(Auth0IdTokenData, "sub", "email", "name", "exp", "user_metadata") +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth2.scala b/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth2.scala new file mode 100644 index 0000000000..05aa3cfa4e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/authorization/SystemAuth2.scala @@ -0,0 +1,36 @@ +package cool.graph.system.authorization + +import com.typesafe.config.Config +import cool.graph.shared.authorization.{JwtCustomerData, JwtPermanentAuthTokenData, JwtUserData, SharedAuth} +import cool.graph.shared.models.Project +import pdi.jwt +import pdi.jwt.{Jwt, JwtAlgorithm, JwtClaim} +import scaldi.{Injectable, Injector} + +case class SystemAuth2()(implicit inj: Injector) extends SharedAuth with Injectable { + import spray.json._ + import cool.graph.shared.authorization.JwtClaimJsonProtocol._ + + val config = inject[Config](identified by "config") + + // todo: should we include optional authData as string? + def generateNodeToken(project: Project, nodeId: String, modelName: String, expirationInSeconds: Option[Int]): String = { + val claimPayload = JwtUserData[String](projectId = project.id, userId = nodeId, authData = None, modelName = modelName).toJson.compactPrint + val finalExpiresIn: Int = expirationInSeconds.getOrElse(expiringSeconds) + val token = Jwt.encode(JwtClaim(claimPayload).issuedNow.expiresIn(finalExpiresIn), jwtSecret, JwtAlgorithm.HS256) + + token + } + + def clientId(sessionToken: String): Option[String] = { + if (isExpired(sessionToken)) { + None + } else { + parseTokenAsClientData(sessionToken).map(_.clientId) + } + } + + def generatePlatformTokenWithExpiration(clientId: String): String = { + Jwt.encode(JwtClaim(JwtCustomerData(clientId).toJson.compactPrint).issuedNow.expiresIn(expiringSeconds), config.getString("jwtSecret"), JwtAlgorithm.HS256) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/DbToModelMapper.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/DbToModelMapper.scala new file mode 100644 index 0000000000..318674fdc5 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/DbToModelMapper.scala @@ -0,0 +1,502 @@ +package cool.graph.system.database + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.models +import cool.graph.shared.models.{FieldConstraintType, FunctionBinding, IntegrationName, NumberConstraint, StringConstraint} +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.tables._ +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +case class AllDataForProject( + project: Project, + models: Seq[Model], + fields: Seq[Field], + relations: Seq[Relation], + relationFieldMirrors: Seq[RelationFieldMirror], + rootTokens: Seq[RootToken], + actions: Seq[Action], + actionTriggerMutationModels: Seq[ActionTriggerMutationModel], + actionTriggerMutationRelations: Seq[ActionTriggerMutationRelation], + actionHandlerWebhooks: Seq[ActionHandlerWebhook], + integrations: Seq[Integration], + modelPermissions: Seq[ModelPermission], + modelPermissionFields: Seq[ModelPermissionField], + relationPermissions: Seq[RelationPermission], + auth0s: Seq[IntegrationAuth0], + digits: Seq[IntegrationDigits], + algolias: Seq[SearchProviderAlgolia], + algoliaSyncQueries: Seq[AlgoliaSyncQuery], + seats: Seq[(Seat, Option[Client])], + packageDefinitions: Seq[PackageDefinition], + enums: Seq[Enum], + featureToggles: Seq[FeatureToggle], + functions: Seq[Function], + fieldConstraints: Seq[FieldConstraint], + projectDatabase: ProjectDatabase +) + +object DbToModelMapper { + def createClient(client: Client) = { + models.Client( + id = client.id, + auth0Id = client.auth0Id, + isAuth0IdentityProviderEmail = client.isAuth0IdentityProviderEmail, + name = client.name, + email = client.email, + hashedPassword = client.password, + resetPasswordSecret = client.resetPasswordToken, + source = client.source, + projects = List.empty, + createdAt = client.createdAt, + updatedAt = client.updatedAt + ) + } + + def createProject(allData: AllDataForProject): models.Project = { + + val projectModels = createModelList(allData).toList + val project = allData.project + + models.Project( + id = project.id, + ownerId = project.clientId, + alias = project.alias, + name = project.name, + webhookUrl = project.webhookUrl, + models = projectModels, + relations = createRelationList(allData).toList, + enums = createEnumList(allData), + actions = createActionList(allData).toList, + rootTokens = createRootTokenList(allData).toList, + integrations = createIntegrationList(allData, projectModels).toList, + seats = createSeatList(allData).toList, + allowQueries = project.allowQueries, + allowMutations = project.allowMutations, + revision = project.revision, + packageDefinitions = createPackageDefinitionList(allData).toList, + featureToggles = createFeatureToggleList(allData), + functions = createFunctionList(allData).toList, + typePositions = project.typePositions.toList, + projectDatabase = createProjectDatabase(allData.projectDatabase), + isEjected = project.isEjected, + hasGlobalStarPermission = project.hasGlobalStarPermission + ) + } + + def createProjectDatabase(projectDatabase: ProjectDatabase): models.ProjectDatabase = { + models.ProjectDatabase( + id = projectDatabase.id, + region = projectDatabase.region, + name = projectDatabase.name, + isDefaultForRegion = projectDatabase.isDefaultForRegion + ) + } + + def createSeatList(allData: AllDataForProject) = { + allData.seats.map { seat => + models.Seat( + id = seat._1.id, + status = seat._1.status, + isOwner = seat._1.clientId.contains(allData.project.clientId), + email = seat._1.email, + clientId = seat._1.clientId, + name = seat._2.map(_.name) + ) + } + } + + def createFunctionList(allData: AllDataForProject): Seq[models.Function] = { + allData.functions + .map { function => + val delivery = function.functionType match { + case models.FunctionType.CODE if function.inlineCode.nonEmpty => + models.Auth0Function( + code = function.inlineCode.get, + codeFilePath = function.inlineCodeFilePath, + auth0Id = function.auth0Id.get, + url = function.webhookUrl.get, + headers = HttpFunctionHeaders.read(function.webhookHeaders) + ) + + case models.FunctionType.CODE if function.inlineCode.isEmpty => + models.ManagedFunction() +// case models.FunctionType.LAMBDA => +// models.LambdaFunction( +// code = function.inlineCode.get, +// arn = function.lambdaArn.get +// ) + + case models.FunctionType.WEBHOOK => + models.WebhookFunction( + url = function.webhookUrl.get, + headers = HttpFunctionHeaders.read(function.webhookHeaders) + ) + } + + function.binding match { + case FunctionBinding.SERVERSIDE_SUBSCRIPTION => + models.ServerSideSubscriptionFunction( + id = function.id, + name = function.name, + isActive = function.isActive, + query = function.serversideSubscriptionQuery.get, + queryFilePath = function.serversideSubscriptionQueryFilePath, + delivery = delivery + ) + + case FunctionBinding.TRANSFORM_PAYLOAD | FunctionBinding.TRANSFORM_ARGUMENT | FunctionBinding.PRE_WRITE | FunctionBinding.TRANSFORM_REQUEST | + FunctionBinding.TRANSFORM_RESPONSE => + models.RequestPipelineFunction( + id = function.id, + name = function.name, + isActive = function.isActive, + binding = function.binding, + modelId = function.requestPipelineMutationModelId.get, + operation = function.requestPipelineMutationOperation.get, + delivery = delivery + ) + + case FunctionBinding.CUSTOM_MUTATION => + models.CustomMutationFunction( + id = function.id, + name = function.name, + isActive = function.isActive, + schema = function.schema.get, + schemaFilePath = function.schemaFilePath, + delivery = delivery + ) + + case FunctionBinding.CUSTOM_QUERY => + models.CustomQueryFunction( + id = function.id, + name = function.name, + isActive = function.isActive, + schema = function.schema.get, + schemaFilePath = function.schemaFilePath, + delivery = delivery + ) + } + } + } + + def createPackageDefinitionList(allData: AllDataForProject) = { + allData.packageDefinitions.map { definition => + models.PackageDefinition( + id = definition.id, + name = definition.name, + definition = definition.definition, + formatVersion = definition.formatVersion + ) + } + } + + def createModelList(allData: AllDataForProject) = { + allData.models.map { model => + models.Model( + id = model.id, + name = model.name, + description = model.description, + isSystem = model.isSystem, + fields = createFieldList(model, allData).toList, + permissions = createModelPermissionList(model, allData).toList, + fieldPositions = model.fieldPositions.toList + ) + } + } + + def createFieldList(model: Model, allData: AllDataForProject) = { + allData.fields + .filter(_.modelId == model.id) + .map { field => + val enum = for { + enumId <- field.enumId + enum <- allData.enums.find(_.id == enumId) + } yield createEnum(enum) + + val constraints = for { + fieldConstraint <- allData.fieldConstraints.filter(_.fieldId == field.id) + } yield createFieldConstraint(fieldConstraint) + + val typeIdentifier = CustomScalarTypes.parseTypeIdentifier(field.typeIdentifier) + models.Field( + id = field.id, + name = field.name, + typeIdentifier = typeIdentifier, + description = field.description, + isRequired = field.isRequired, + isList = field.isList, + isUnique = field.isUnique, + isSystem = field.isSystem, + isReadonly = field.isReadonly, + defaultValue = field.defaultValue.map(GCStringConverter(typeIdentifier, field.isList).toGCValue(_).get), + relation = field.relationId.map(id => createRelation(id, allData)), + relationSide = field.relationSide, + enum = enum, + constraints = constraints.toList + ) + } + } + + def createRelationList(allData: AllDataForProject): Seq[models.Relation] = { + allData.relations.map { relation => + createRelation(relation.id, allData) + } + } + + def createEnumList(allData: AllDataForProject): List[models.Enum] = { + allData.enums.map(createEnum).toList + } + + def createEnum(enum: Enum): models.Enum = { + models.Enum( + id = enum.id, + name = enum.name, + values = enum.values.parseJson.convertTo[List[String]] + ) + } + + def createFieldConstraint(constraint: FieldConstraint): models.FieldConstraint = { + constraint.constraintType match { + case FieldConstraintType.STRING => + StringConstraint( + id = constraint.id, + fieldId = constraint.fieldId, + equalsString = constraint.equalsString, + oneOfString = constraint.oneOfString.parseJson.convertTo[List[String]], + minLength = constraint.minLength, + maxLength = constraint.maxLength, + startsWith = constraint.startsWith, + endsWith = constraint.endsWith, + includes = constraint.includes, + regex = constraint.regex + ) + case FieldConstraintType.NUMBER => + NumberConstraint( + id = constraint.id, + fieldId = constraint.fieldId, + equalsNumber = constraint.equalsNumber, + oneOfNumber = constraint.oneOfNumber.parseJson.convertTo[List[Double]], + min = constraint.min, + max = constraint.max, + exclusiveMin = constraint.exclusiveMin, + exclusiveMax = constraint.exclusiveMax, + multipleOf = constraint.multipleOf + ) + + case FieldConstraintType.BOOLEAN => + models.BooleanConstraint(id = constraint.id, fieldId = constraint.fieldId, equalsBoolean = constraint.equalsBoolean) + + case FieldConstraintType.LIST => + models.ListConstraint(id = constraint.id, + fieldId = constraint.fieldId, + uniqueItems = constraint.uniqueItems, + minItems = constraint.minItems, + maxItems = constraint.maxItems) + } + } + + def createFeatureToggleList(allData: AllDataForProject): List[models.FeatureToggle] = { + allData.featureToggles.map { featureToggle => + models.FeatureToggle( + id = featureToggle.id, + name = featureToggle.name, + isEnabled = featureToggle.isEnabled + ) + }.toList + } + + def createRelation(relationId: String, allData: AllDataForProject) = { + val relation = allData.relations.find(_.id == relationId).get + + models.Relation( + id = relation.id, + name = relation.name, + description = relation.description, + modelAId = relation.modelAId, + modelBId = relation.modelBId, + fieldMirrors = createFieldMirrorList(relation, allData).toList, + permissions = createRelationPermissionList(relation, allData).toList + ) + } + + def createFieldMirrorList(relation: Relation, allData: AllDataForProject): Seq[models.RelationFieldMirror] = { + allData.relationFieldMirrors + .filter(_.relationId == relation.id) + .map { fieldMirror => + models.RelationFieldMirror( + id = fieldMirror.id, + relationId = fieldMirror.relationId, + fieldId = fieldMirror.fieldId + ) + } + } + + def createModelPermissionList(model: Model, allData: AllDataForProject) = { + allData.modelPermissions + .filter(_.modelId == model.id) + .map(permission => { + models.ModelPermission( + id = permission.id, + operation = permission.operation, + userType = permission.userType, + rule = permission.rule, + ruleName = permission.ruleName, + ruleGraphQuery = permission.ruleGraphQuery, + ruleGraphQueryFilePath = permission.ruleGraphQueryFilePath, + ruleWebhookUrl = permission.ruleWebhookUrl, + fieldIds = allData.modelPermissionFields + .filter(_.modelPermissionId == permission.id) + .toList + .map(_.fieldId) + .distinct, + applyToWholeModel = permission.applyToWholeModel, + isActive = permission.isActive, + description = permission.description + ) + }) + } + + def createRelationPermissionList(relation: Relation, allData: AllDataForProject) = { + allData.relationPermissions + .filter(_.relationId == relation.id) + .map(permission => { + + models.RelationPermission( + id = permission.id, + connect = permission.connect, + disconnect = permission.disconnect, + userType = permission.userType, + rule = permission.rule, + ruleName = permission.ruleName, + ruleGraphQuery = permission.ruleGraphQuery, + ruleGraphQueryFilePath = permission.ruleGraphQueryFilePath, + ruleWebhookUrl = permission.ruleWebhookUrl, + isActive = permission.isActive + ) + }) + } + + def createActionList(allData: AllDataForProject) = { + allData.actions.map { action => + val handlerWebhook = allData.actionHandlerWebhooks + .find(_.actionId == action.id) + .map { wh => + models.ActionHandlerWebhook(id = wh.id, url = wh.url, isAsync = wh.isAsync) + } + + val triggerModel = allData.actionTriggerMutationModels + .find(_.actionId == action.id) + .map { m => + models.ActionTriggerMutationModel( + id = m.id, + modelId = m.modelId, + mutationType = m.mutationType, + fragment = m.fragment + ) + } + + val triggerRelation = allData.actionTriggerMutationRelations + .find(_.actionId == action.id) + .map { m => + models.ActionTriggerMutationRelation( + id = m.id, + relationId = m.relationId, + mutationType = m.mutationType, + fragment = m.fragment + ) + } + + models.Action( + id = action.id, + isActive = action.isActive, + triggerType = action.triggerType, + handlerType = action.handlerType, + description = action.description, + handlerWebhook = handlerWebhook, + triggerMutationModel = triggerModel, + triggerMutationRelation = triggerRelation + ) + } + } + + def createRootTokenList(allData: AllDataForProject) = { + allData.rootTokens.map { token => + models.RootToken( + id = token.id, + token = token.token, + name = token.name, + created = token.created + ) + } + } + + def createIntegrationList(allData: AllDataForProject, projectModels: List[models.Model]): Seq[models.Integration] = { + allData.integrations + .map { integration => + integration.name match { + case IntegrationName.AuthProviderAuth0 => + val meta = + allData.auth0s + .find(_.integrationId == integration.id) + .map(auth0 => models.AuthProviderAuth0(id = auth0.id, domain = auth0.domain, clientId = auth0.clientId, clientSecret = auth0.clientSecret)) + + models.AuthProvider( + id = integration.id, + subTableId = meta.map(_.id).getOrElse(""), + isEnabled = integration.isEnabled, + name = integration.name, + metaInformation = meta + ) + + case IntegrationName.AuthProviderDigits => + val meta = + allData.digits + .find(_.integrationId == integration.id) + .map(digits => models.AuthProviderDigits(id = digits.id, consumerKey = digits.consumerKey, consumerSecret = digits.consumerSecret)) + + models.AuthProvider( + id = integration.id, + subTableId = meta.map(_.id).getOrElse(""), + isEnabled = integration.isEnabled, + name = integration.name, + metaInformation = meta + ) + + case IntegrationName.AuthProviderEmail => + models.AuthProvider( + id = integration.id, + subTableId = "", + isEnabled = integration.isEnabled, + name = integration.name, + metaInformation = None + ) + + case IntegrationName.SearchProviderAlgolia => + val algolia = allData.algolias.find(_.integrationId == integration.id).get + val syncQueries = allData.algoliaSyncQueries + .filter(_.searchProviderAlgoliaId == algolia.id) + .map { syncQuery => + models.AlgoliaSyncQuery( + id = syncQuery.id, + indexName = syncQuery.indexName, + fragment = syncQuery.query, + isEnabled = syncQuery.isEnabled, + model = projectModels.find(_.id == syncQuery.modelId).get + ) + } + + models.SearchProviderAlgolia( + id = integration.id, + subTableId = algolia.id, + applicationId = algolia.applicationId, + apiKey = algolia.apiKey, + algoliaSyncQueries = syncQueries.toList, + isEnabled = integration.isEnabled, + name = integration.name + ) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/ModelToDbMapper.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/ModelToDbMapper.scala new file mode 100644 index 0000000000..3cc5ca1597 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/ModelToDbMapper.scala @@ -0,0 +1,243 @@ +package cool.graph.system.database + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.JsonFormats +import cool.graph.Types.Id +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.models._ +import spray.json._ + +object ModelToDbMapper { + def convertProjectDatabase(projectDatabase: ProjectDatabase): cool.graph.system.database.tables.ProjectDatabase = { + cool.graph.system.database.tables.ProjectDatabase( + id = projectDatabase.id, + region = projectDatabase.region, + name = projectDatabase.name, + isDefaultForRegion = projectDatabase.isDefaultForRegion + ) + } + + def convertProject(project: Project): cool.graph.system.database.tables.Project = { + cool.graph.system.database.tables.Project( + id = project.id, + alias = project.alias, + name = project.name, + revision = project.revision, + webhookUrl = project.webhookUrl, + clientId = project.ownerId, + allowQueries = project.allowQueries, + allowMutations = project.allowMutations, + typePositions = project.typePositions, + projectDatabaseId = project.projectDatabase.id, + isEjected = project.isEjected, + hasGlobalStarPermission = project.hasGlobalStarPermission + ) + } + + def convertModel(project: Project, model: Model): cool.graph.system.database.tables.Model = { + cool.graph.system.database.tables.Model( + id = model.id, + name = model.name, + description = model.description, + isSystem = model.isSystem, + projectId = project.id, + fieldPositions = model.fieldPositions + ) + } + + def convertFunction(project: Project, function: Function): cool.graph.system.database.tables.Function = { + function match { + case ServerSideSubscriptionFunction(id, name, isActive, query, queryFilePath, delivery) => + val dbFunctionWithoutDelivery = cool.graph.system.database.tables.Function( + id = id, + projectId = project.id, + name = name, + binding = FunctionBinding.SERVERSIDE_SUBSCRIPTION, + functionType = delivery.functionType, + isActive = isActive, + requestPipelineMutationModelId = None, + requestPipelineMutationOperation = None, + serversideSubscriptionQuery = Some(query), + serversideSubscriptionQueryFilePath = queryFilePath, + lambdaArn = None, + webhookUrl = None, + webhookHeaders = None, + inlineCode = None, + inlineCodeFilePath = None, + auth0Id = None, + schema = None, + schemaFilePath = None + ) + mergeDeliveryIntoDbFunction(delivery, dbFunctionWithoutDelivery) + + case RequestPipelineFunction(id, name, isActive, binding, modelId, operation, delivery) => + val dbFunctionWithoutDelivery = cool.graph.system.database.tables.Function( + id = id, + projectId = project.id, + name = name, + binding = binding, + functionType = delivery.functionType, + isActive = isActive, + requestPipelineMutationModelId = Some(modelId), + requestPipelineMutationOperation = Some(operation), + serversideSubscriptionQuery = None, + serversideSubscriptionQueryFilePath = None, + lambdaArn = None, + webhookUrl = None, + webhookHeaders = None, + inlineCode = None, + inlineCodeFilePath = None, + auth0Id = None, + schema = None, + schemaFilePath = None + ) + mergeDeliveryIntoDbFunction(delivery, dbFunctionWithoutDelivery) + + case CustomMutationFunction(id, name, isActive, schema, schemaFilePath, delivery, _, _, _) => + val dbFunctionWithoutDelivery = cool.graph.system.database.tables.Function( + id = id, + projectId = project.id, + name = name, + binding = FunctionBinding.CUSTOM_MUTATION, + functionType = delivery.functionType, + isActive = isActive, + requestPipelineMutationModelId = None, + requestPipelineMutationOperation = None, + serversideSubscriptionQuery = None, + serversideSubscriptionQueryFilePath = None, + lambdaArn = None, + webhookUrl = None, + webhookHeaders = None, + inlineCode = None, + inlineCodeFilePath = None, + auth0Id = None, + schema = Some(schema), + schemaFilePath = schemaFilePath + ) + mergeDeliveryIntoDbFunction(delivery, dbFunctionWithoutDelivery) + + case CustomQueryFunction(id, name, isActive, schema, schemaFilePath, delivery, _, _, _) => + val dbFunctionWithoutDelivery = cool.graph.system.database.tables.Function( + id = id, + projectId = project.id, + name = name, + binding = FunctionBinding.CUSTOM_QUERY, + functionType = delivery.functionType, + isActive = isActive, + requestPipelineMutationModelId = None, + requestPipelineMutationOperation = None, + serversideSubscriptionQuery = None, + serversideSubscriptionQueryFilePath = None, + lambdaArn = None, + webhookUrl = None, + webhookHeaders = None, + inlineCode = None, + inlineCodeFilePath = None, + auth0Id = None, + schema = Some(schema), + schemaFilePath = schemaFilePath + ) + mergeDeliveryIntoDbFunction(delivery, dbFunctionWithoutDelivery) + } + } + + private def mergeDeliveryIntoDbFunction(delivery: FunctionDelivery, + dbFunction: cool.graph.system.database.tables.Function): cool.graph.system.database.tables.Function = { + delivery match { + case fn: WebhookFunction => + dbFunction.copy( + functionType = FunctionType.WEBHOOK, + webhookUrl = Some(fn.url), + webhookHeaders = Some(HttpFunctionHeaders.write(fn.headers).toString) + ) + case fn: Auth0Function => + dbFunction.copy( + functionType = FunctionType.CODE, + webhookUrl = Some(fn.url), + webhookHeaders = Some(HttpFunctionHeaders.write(fn.headers).toString), + auth0Id = Some(fn.auth0Id), + inlineCode = Some(fn.code), + inlineCodeFilePath = fn.codeFilePath + ) + case fn: ManagedFunction => + dbFunction.copy( + functionType = FunctionType.CODE, + inlineCodeFilePath = fn.codeFilePath + ) +// case fn: LambdaFunction => +// dbFunction.copy( +// functionType = FunctionType.LAMBDA, +// inlineCode = Some(fn.code), +// lambdaArn = Some(fn.arn) +// ) + } + } + + def convertField(modelId: Id, field: Field): cool.graph.system.database.tables.Field = { + cool.graph.system.database.tables.Field( + id = field.id, + name = field.name, + typeIdentifier = field.typeIdentifier.toString, + description = field.description, + isRequired = field.isRequired, + isList = field.isList, + isUnique = field.isUnique, + isSystem = field.isSystem, + isReadonly = field.isReadonly, + defaultValue = field.defaultValue.flatMap(GCStringConverter(field.typeIdentifier, field.isList).fromGCValueToOptionalString), + relationId = field.relation.map(_.id), + relationSide = field.relationSide, + modelId = modelId, + enumId = field.enum.map(_.id) + ) + } + + def convertFieldConstraint(constraint: FieldConstraint): cool.graph.system.database.tables.FieldConstraint = { + implicit val anyFormat = JsonFormats.AnyJsonFormat + constraint match { + case string: StringConstraint => + cool.graph.system.database.tables.FieldConstraint( + id = string.id, + constraintType = string.constraintType, + fieldId = string.fieldId, + equalsString = string.equalsString, + oneOfString = string.oneOfString.asInstanceOf[Any].toJson.compactPrint, + minLength = string.minLength, + maxLength = string.maxLength, + startsWith = string.startsWith, + endsWith = string.endsWith, + includes = string.includes, + regex = string.regex + ) + case number: NumberConstraint => + cool.graph.system.database.tables.FieldConstraint( + id = number.id, + constraintType = number.constraintType, + fieldId = number.fieldId, + equalsNumber = number.equalsNumber, + oneOfNumber = number.oneOfNumber.asInstanceOf[Any].toJson.compactPrint, + min = number.min, + max = number.max, + exclusiveMin = number.exclusiveMin, + exclusiveMax = number.exclusiveMax, + multipleOf = number.multipleOf + ) + + case boolean: BooleanConstraint => + cool.graph.system.database.tables.FieldConstraint( + id = boolean.id, + constraintType = boolean.constraintType, + fieldId = boolean.fieldId, + equalsBoolean = boolean.equalsBoolean + ) + + case list: ListConstraint => + cool.graph.system.database.tables.FieldConstraint(id = list.id, + constraintType = list.constraintType, + fieldId = list.fieldId, + uniqueItems = list.uniqueItems, + minItems = list.minItems, + maxItems = list.maxItems) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/SystemFields.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/SystemFields.scala new file mode 100644 index 0000000000..d33914bd5c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/SystemFields.scala @@ -0,0 +1,89 @@ +package cool.graph.system.database + +import cool.graph.cuid.Cuid +import cool.graph.shared.models.{Field, TypeIdentifier} +import scala.util.{Failure, Try} + +object SystemFields { + val idFieldName = "id" + val updatedAtFieldName = "updatedAt" + val createdAtFieldName = "createdAt" + val systemFieldNames = Vector(idFieldName, updatedAtFieldName, createdAtFieldName) + + def generateAll: List[Field] = { + List( + generateIdField(), + generateCreatedAtField(), + generateUpdatedAtField() + ) + } + + def generateCreatedAtField(id: String = Cuid.createCuid()): Field = { + Field( + id = id, + name = createdAtFieldName, + typeIdentifier = TypeIdentifier.DateTime, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ) + } + + def generateUpdatedAtField(id: String = Cuid.createCuid()): Field = { + Field( + id = id, + name = updatedAtFieldName, + typeIdentifier = TypeIdentifier.DateTime, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ) + } + + def generateIdField(id: String = Cuid.createCuid()): Field = { + Field( + id = id, + name = idFieldName, + typeIdentifier = TypeIdentifier.GraphQLID, + isRequired = true, + isList = false, + isUnique = true, + isSystem = true, + isReadonly = true + ) + } + + def generateSystemFieldFor(name: String): Field = { + name match { + case x if x == idFieldName => generateIdField() + case x if x == createdAtFieldName => generateCreatedAtField() + case x if x == updatedAtFieldName => generateUpdatedAtField() + case _ => throw new Exception(s"Unknown system field with name: $name") + } + } + + def isDeletableSystemField(name: String) = name == updatedAtFieldName || name == createdAtFieldName + def isReservedFieldName(name: String): Boolean = systemFieldNames.contains(name) + + /** + * Attempts to parse a given field from user input and maps it to the appropriate system field. + * This is used for "hiding" system fields in the schema initially, like createdAt and updatedAt, which are + * still in the client database and are recorded all the time, but not exposed for querying in the schema (missing in the project db). + * + * If the user chooses to create one of those fields manually, it is then added in the project database, which this util + * is providing the system fields and verification for. + */ + def generateSystemFieldFromInput(field: Field): Try[Field] = { + if (field.name == idFieldName) { + Failure(new Exception(s"$idFieldName is reserved and can't be created manually.")) + } else if (!field.isRequired || field.isUnique || field.isList || field.typeIdentifier != TypeIdentifier.DateTime) { + Failure(new Exception(s"Type is predefined and must be non-unique, required, a scalar field (a datetime) to be exposed.")) + } else { + Try { generateSystemFieldFor(field.name) } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/client/ClientDbQueriesImpl.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/client/ClientDbQueriesImpl.scala new file mode 100644 index 0000000000..0c07053f1e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/client/ClientDbQueriesImpl.scala @@ -0,0 +1,76 @@ +package cool.graph.system.database.client + +import cool.graph.client.database.DatabaseQueryBuilder +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.models.{Field, Model, Project, Relation} +import slick.dbio.Effect.Read +import slick.jdbc.SQLActionBuilder +import slick.sql.SqlStreamingAction + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +trait ClientDbQueries { + def itemCountForModel(model: Model): Future[Int] + def existsByModel(model: Model): Future[Boolean] + def existsNullByModelAndScalarField(model: Model, field: Field): Future[Boolean] + def existsNullByModelAndRelationField(model: Model, field: Field): Future[Boolean] + def itemCountForRelation(relation: Relation): Future[Int] + def itemCountForFieldValue(model: Model, field: Field, enumValue: String): Future[Int] +} + +case class ClientDbQueriesImpl(globalDatabaseManager: GlobalDatabaseManager)(project: Project) extends ClientDbQueries { + val clientDatabase = globalDatabaseManager.getDbForProject(project).readOnly + + def itemCountForModel(model: Model): Future[Int] = { + val query = DatabaseQueryBuilder.itemCountForTable(project.id, model.name) + clientDatabase.run(readOnlyInt(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => 0 } + } + + def existsByModel(model: Model): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsByModel(project.id, model.name) + clientDatabase.run(readOnlyBoolean(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => false } + } + + def existsNullByModelAndScalarField(model: Model, field: Field): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsNullByModelAndScalarField(project.id, model.name, field.name) + clientDatabase.run(readOnlyBoolean(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => false } + } + + def existsNullByModelAndRelationField(model: Model, field: Field): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsNullByModelAndRelationField(project.id, model.name, field) + clientDatabase.run(readOnlyBoolean(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => false } + } + + def itemCountForRelation(relation: Relation): Future[Int] = { + val query = DatabaseQueryBuilder.itemCountForTable(project.id, relation.id) + clientDatabase.run(readOnlyInt(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => 0 } + } + + def itemCountForFieldValue(model: Model, field: Field, enumValue: String): Future[Int] = { + val query = DatabaseQueryBuilder.valueCountForScalarField(project.id, model.name, field.name, enumValue) + clientDatabase.run(readOnlyInt(query)).map(_.head).recover { case _: java.sql.SQLSyntaxErrorException => 0 } + } + + private def readOnlyInt(query: SQLActionBuilder): SqlStreamingAction[Vector[Int], Int, Read] = { + val action: SqlStreamingAction[Vector[Int], Int, Read] = query.as[Int] + + action + } + + private def readOnlyBoolean(query: SQLActionBuilder): SqlStreamingAction[Vector[Boolean], Boolean, Read] = { + val action: SqlStreamingAction[Vector[Boolean], Boolean, Read] = query.as[Boolean] + + action + } +} + +object EmptyClientDbQueries extends ClientDbQueries { + override def existsByModel(model: Model): Future[Boolean] = Future.successful(false) + override def existsNullByModelAndScalarField(model: Model, field: Field): Future[Boolean] = Future.successful(false) + override def existsNullByModelAndRelationField(model: Model, field: Field): Future[Boolean] = Future.successful(false) + override def itemCountForModel(model: Model): Future[Int] = Future.successful(0) + override def itemCountForFieldValue(model: Model, field: Field, enumValue: String): Future[Int] = Future.successful(0) + override def itemCountForRelation(relation: Relation): Future[Int] = Future.successful(0) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/CachedProjectResolverImpl.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/CachedProjectResolverImpl.scala new file mode 100644 index 0000000000..65c25fb443 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/CachedProjectResolverImpl.scala @@ -0,0 +1,26 @@ +package cool.graph.system.database.finder + +import cool.graph.cache.Cache +import cool.graph.shared.models.{Project, ProjectWithClientId} + +import scala.concurrent.{ExecutionContext, Future} + +case class CachedProjectResolverImpl( + uncachedProjectResolver: UncachedProjectResolver +)(implicit ec: ExecutionContext) + extends CachedProjectResolver { + val cache = Cache.lfuAsync[String, ProjectWithClientId](initialCapacity = 5, maxCapacity = 5) + + override def resolve(projectIdOrAlias: String): Future[Option[Project]] = resolveProjectWithClientId(projectIdOrAlias).map(_.map(_.project)) + + override def resolveProjectWithClientId(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = { + cache.getOrUpdateOpt(projectIdOrAlias, () => { + uncachedProjectResolver.resolveProjectWithClientId(projectIdOrAlias) + }) + } + + override def invalidate(projectIdOrAlias: String): Future[Unit] = { + cache.remove(projectIdOrAlias) + Future.successful(()) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/LogsDataResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/LogsDataResolver.scala new file mode 100644 index 0000000000..d669c03ae9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/LogsDataResolver.scala @@ -0,0 +1,98 @@ +package cool.graph.system.database.finder + +import cool.graph.shared.externalServices.TestableTime +import cool.graph.shared.models.Log +import cool.graph.system.database.finder.HistogramPeriod.HistogramPeriod +import cool.graph.system.database.tables.Tables +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object HistogramPeriod extends Enumeration { + type HistogramPeriod = Value + val MONTH = Value("MONTH") + val WEEK = Value("WEEK") + val DAY = Value("DAY") + val HOUR = Value("HOUR") + val HALF_HOUR = Value("HALF_HOUR") +} + +class LogsDataResolver(implicit inj: Injector) extends Injectable { + + val logsDatabase = inject[DatabaseDef](identified by "logs-db") + val testableTime = inject[TestableTime] + + def load(functionId: String, count: Int = 1000, before: Option[String] = None): Future[Seq[Log]] = { + + val query = before match { + case Some(curosr) => + for { + log <- Tables.Logs + if log.functionId === functionId && log.id < before + } yield log + case None => + for { + log <- Tables.Logs + if log.functionId === functionId + } yield log + } + + logsDatabase + .run(query.sortBy(_.id.desc).take(count).result) + .map(_.map(l => Log(id = l.id, requestId = l.requestId, status = l.status, duration = l.duration, timestamp = l.timestamp, message = l.message))) + } + + def calculateHistogram(projectId: String, period: HistogramPeriod, functionId: Option[String] = None): Future[List[Int]] = { + val currentTimeStamp: Long = getCurrentUnixTimestamp + + val (fullDurationInMinutes, intervalInSeconds, sections) = period match { + case HistogramPeriod.HALF_HOUR => (30, 10, 180) + case HistogramPeriod.HOUR => (60, 20, 180) + case HistogramPeriod.DAY => (60 * 24, 20 * 24, 180) + case HistogramPeriod.WEEK => (60 * 24 * 7, 20 * 24 * 7, 180) + case HistogramPeriod.MONTH => (60 * 24 * 30, 20 * 24 * 30, 180) + } + + val functionIdCriteria = functionId.map(id => s"AND functionId = '$id'").getOrElse("") + + logsDatabase + .run(sql""" + SELECT COUNT(*), FLOOR(unix_timestamp(timestamp)/$intervalInSeconds) * $intervalInSeconds as ts FROM Log + WHERE timestamp > date_sub(FLOOR(from_unixtime($currentTimeStamp)), INTERVAL $fullDurationInMinutes MINUTE) + AND projectId = $projectId + #$functionIdCriteria + GROUP BY ts""".as[(Int, Int)]) + .map(res => fillInBlankSections(currentTimeStamp, res, fullDurationInMinutes, intervalInSeconds, sections)) + } + + private def fillInBlankSections(currentTimeStamp: Long, data: Seq[(Int, Int)], fullDurationInMinutes: Int, intervalInSeconds: Int, sections: Int) = { + val firstTimestamp = ((currentTimeStamp - fullDurationInMinutes * 60 + intervalInSeconds) / intervalInSeconds) * intervalInSeconds + List + .tabulate(sections)(n => firstTimestamp + n * intervalInSeconds) + .map(ts => data.find(_._2 == ts).map(_._1).getOrElse(0)) + } + + def countRequests(functionId: String): Future[Int] = { + logsDatabase + .run(sql""" + SELECT COUNT(*) FROM Log + WHERE timestamp > date_sub(from_unixtime($getCurrentUnixTimestamp), INTERVAL 24 HOUR) + AND functionId = ${functionId}""".as[Int]) + .map(_.head) + } + + def countErrors(functionId: String): Future[Int] = { + logsDatabase + .run(sql""" + SELECT COUNT(*) FROM Log + WHERE timestamp > date_sub(from_unixtime($getCurrentUnixTimestamp), INTERVAL 24 HOUR) + AND status = 'FAILURE' + AND functionId = ${functionId}""".as[Int]) + .map(_.head) + } + + private def getCurrentUnixTimestamp = testableTime.DateTime.getMillis / 1000 +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectDatabaseFinder.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectDatabaseFinder.scala new file mode 100644 index 0000000000..2e27f2b527 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectDatabaseFinder.scala @@ -0,0 +1,30 @@ +package cool.graph.system.database.finder + +import cool.graph.system.database.tables.Tables +import cool.graph.shared.models.ProjectDatabase +import cool.graph.shared.models.Region.Region +import cool.graph.system.database.DbToModelMapper +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.Future + +object ProjectDatabaseFinder { + import cool.graph.system.database.tables.ProjectTable.regionMapper + import scala.concurrent.ExecutionContext.Implicits.global + + def forId(id: String)(internalDatabase: DatabaseDef): Future[Option[ProjectDatabase]] = { + val query = Tables.ProjectDatabases.filter(_.id === id).result.headOption + internalDatabase.run(query).map { dbResult: Option[cool.graph.system.database.tables.ProjectDatabase] => + dbResult.map(DbToModelMapper.createProjectDatabase) + } + } + + def defaultForRegion(region: Region)(internalDatabase: DatabaseDef): Future[Option[ProjectDatabase]] = { + val query = + Tables.ProjectDatabases.filter(pdb => pdb.region === region && pdb.isDefaultForRegion).result.headOption + internalDatabase.run(query).map { dbResult: Option[cool.graph.system.database.tables.ProjectDatabase] => + dbResult.map(DbToModelMapper.createProjectDatabase) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectFinder.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectFinder.scala new file mode 100644 index 0000000000..6be3036d7a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectFinder.scala @@ -0,0 +1,128 @@ +package cool.graph.system.database.finder + +import cool.graph.Types.Id +import cool.graph.shared.errors.SystemErrors._ +import cool.graph.shared.errors.UserFacingError +import cool.graph.shared.errors.UserInputErrors.InvalidRootTokenId +import cool.graph.shared.models.Project +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.Future + +// all load functions take a clientId to enforce permission checks +object ProjectFinder { + import scala.concurrent.ExecutionContext.Implicits.global + + def loadById(clientId: Id, id: Id)(implicit projectResolver: ProjectResolver): Future[Project] = { + val projectFuture = projectResolver.resolve(projectIdOrAlias = id) + checkProject(clientId, InvalidProjectId(id), projectFuture) + } + + def loadByName(clientId: Id, name: String)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidProjectName(name), ProjectQueries().loadByName(clientId, name)) + } + + def loadByModelId(clientId: Id, modelId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidModelId(modelId), ProjectQueries().loadByModelId(modelId)) + } + + def loadByFieldId(clientId: Id, fieldId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidFieldId(fieldId), ProjectQueries().loadByFieldId(fieldId)) + } + + def loadByEnumId(clientId: Id, enumId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidEnumId(enumId), ProjectQueries().loadByEnumId(enumId)) + } + + def loadByFieldConstraintId(clientId: Id, fieldConstraintId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidFieldConstraintId(fieldConstraintId), ProjectQueries().loadByFieldConstraintId(fieldConstraintId)) + } + + def loadByActionId(clientId: Id, actionId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidActionId(actionId), ProjectQueries().loadByActionId(actionId)) + } + + def loadByFunctionId(clientId: Id, functionId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidFunctionId(functionId), ProjectQueries().loadByFunctionId(functionId)) + } + + def loadByRelationId(clientId: Id, relationId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidRelationId(relationId), ProjectQueries().loadByRelationId(relationId)) + } + + def loadByRelationFieldMirrorId(clientId: Id, relationFieldMirrorId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidRelationFieldMirrorId(relationFieldMirrorId), ProjectQueries().loadByRelationFieldMirrorId(relationFieldMirrorId)) + } + + def loadByActionTriggerMutationModelId(clientId: Id, actionTriggerModelId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject( + clientId, + InvalidActionTriggerMutationModelId(actionTriggerModelId), + ProjectQueries().loadByloadByActionTriggerMutationModelId(actionTriggerModelId) + ) + } + + def loadByActionTriggerMutationRelationId(clientId: Id, actionTriggerRelationId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject( + clientId, + InvalidActionTriggerMutationModelId(actionTriggerRelationId), + ProjectQueries().loadByloadByActionTriggerMutationModelId(actionTriggerRelationId) + ) + } + + def loadByActionHandlerWebhookId(clientId: Id, actionHandlerWebhookId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject( + clientId, + InvalidActionTriggerMutationModelId(actionHandlerWebhookId), + ProjectQueries().loadByloadByActionactionHandlerWebhookId(actionHandlerWebhookId) + ) + } + + def loadByModelPermissionId(clientId: Id, modelPermissionId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidModelPermissionId(modelPermissionId), ProjectQueries().loadByModelPermissionId(modelPermissionId)) + } + + def loadByRelationPermissionId(clientId: Id, relationPermissionId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidRelationPermissionId(relationPermissionId), ProjectQueries().loadByRelationPermissionId(relationPermissionId)) + } + + def loadByIntegrationId(clientId: Id, integrationId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidIntegrationId(integrationId), ProjectQueries().loadByIntegrationId(integrationId)) + } + + def loadByAlgoliaSyncQueryId(clientId: Id, algoliaSyncQueryId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidAlgoliaSyncQueryId(algoliaSyncQueryId), ProjectQueries().loadByAlgoliaSyncQueryId(algoliaSyncQueryId)) + } + + def loadBySeatId(clientId: Id, seatId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidSeatId(seatId), ProjectQueries().loadBySeatId(seatId)) + } + + def loadByPackageDefinitionId(clientId: Id, packageDefinitionId: Id)(implicit internalDatabase: DatabaseDef, + projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidPackageDefinitionId(packageDefinitionId), ProjectQueries().loadByPackageDefinitionId(packageDefinitionId)) + } + + def loadByRootTokenId(clientId: Id, patId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidRootTokenId(patId), ProjectQueries().loadByRootTokenId(patId)) + } + + def loadByAuthProviderId(clientId: Id, authProviderId: Id)(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver): Future[Project] = { + checkProject(clientId, InvalidAuthProviderId(authProviderId), ProjectQueries().loadByAuthProviderId(authProviderId)) + } + + private def checkProject(clientId: Id, error: UserFacingError, projectFuture: Future[Option[Project]]): Future[Project] = { + projectFuture.map { + case Some(project) => if (project.seats.exists(_.clientId.contains(clientId))) project else throw error + case None => throw error + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectQueries.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectQueries.scala new file mode 100644 index 0000000000..0fd6d374ca --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectQueries.scala @@ -0,0 +1,179 @@ +package cool.graph.system.database.finder + +import cool.graph.shared.models.Project +import cool.graph.system.database.tables._ +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.QueryBase + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ProjectQueries(implicit internalDatabase: DatabaseDef, projectResolver: ProjectResolver) { + import Tables._ + + def loadById(id: String): Future[Option[Project]] = { + // here we explicitly just want to load by id. We do not want the magic fallback to the alias. + val projectWithIdExists = Projects.filter(p => p.id === id).exists + + internalDatabase.run(projectWithIdExists.result).flatMap { + case true => loadByIdOrAlias(id) + case false => Future.successful(None) + } + } + + def loadByIdOrAlias(idOrAlias: String): Future[Option[Project]] = resolveProject(Some(idOrAlias)) + + def loadByModelId(modelId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + model <- Models if model.id === modelId + } yield model.projectId + } + + def loadByName(clientId: String, name: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + seat <- Seats if seat.clientId === clientId + project <- Projects if project.id === seat.projectId && project.name === name + } yield project.id + } + + def loadByFieldId(fieldId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + field <- Fields if field.id === fieldId + model <- Models if field.modelId === model.id + } yield model.projectId + } + + def loadByFunctionId(functionId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + function <- Functions if function.id === functionId + } yield function.projectId + } + + def loadByEnumId(enumId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + enum <- Enums if enum.id === enumId + } yield enum.projectId + } + + def loadByFieldConstraintId(fieldConstraintId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + constraint <- FieldConstraints if constraint.id === fieldConstraintId + field <- Fields if field.id === constraint.fieldId + model <- Models if field.modelId === model.id + } yield model.projectId + } + + def loadByModelPermissionId(modelPermissionId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + permission <- ModelPermissions if permission.id === modelPermissionId + model <- Models if model.id === permission.modelId + } yield model.projectId + } + + def loadByRelationPermissionId(relationPermissionId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + permission <- RelationPermissions if permission.id === relationPermissionId + relation <- Relations if relation.id === permission.relationId + } yield relation.projectId + } + + def loadByloadByActionTriggerMutationModelId(actionTriggerId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + mutationTrigger <- ActionTriggerMutationModels if mutationTrigger.id === actionTriggerId + action <- Actions if action.id === mutationTrigger.actionId + } yield action.projectId + } + + def loadByloadByActionTriggerMutationRelationId(actionTriggerId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + relationTrigger <- ActionTriggerMutationRelations if relationTrigger.id === actionTriggerId + action <- Actions if action.id === relationTrigger.actionId + } yield action.projectId + } + + def loadByloadByActionactionHandlerWebhookId(actionHandlerId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + webhookAction <- ActionHandlerWebhooks if webhookAction.id === actionHandlerId + action <- Actions if webhookAction.actionId === action.id + } yield action.projectId + } + + def loadByActionId(actionId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + action <- Actions if action.id === actionId + } yield action.projectId + } + + def loadByRelationId(relationId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + relation <- Relations if relation.id === relationId + } yield relation.projectId + } + + def loadByRelationFieldMirrorId(relationFieldMirrorId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + relationMirror <- RelationFieldMirrors if relationMirror.id === relationFieldMirrorId + relation <- Relations if relation.id === relationMirror.relationId + } yield relation.projectId + } + + def loadByIntegrationId(integrationId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + integration <- Integrations if integration.id === integrationId + } yield integration.projectId + } + + def loadByAlgoliaSyncQueryId(algoliaSyncQueryId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + algoliaSyncQuery <- AlgoliaSyncQueries if algoliaSyncQuery.id === algoliaSyncQueryId + model <- Models if model.id === algoliaSyncQuery.modelId + } yield model.projectId + } + + def loadBySeatId(seatId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + seat <- Seats if seat.id === seatId + } yield seat.projectId + } + + def loadByPackageDefinitionId(packageDefinitionId: String): Future[Option[Project]] = + resolveProjectByProjectIdsQuery { + for { + definition <- PackageDefinitions if definition.id === packageDefinitionId + } yield definition.projectId + } + + def loadByRootTokenId(patId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + rootToken <- RootTokens if rootToken.id === patId + } yield rootToken.projectId + } + + def loadByAuthProviderId(authProviderId: String): Future[Option[Project]] = resolveProjectByProjectIdsQuery { + for { + integration <- Integrations if integration.id === authProviderId + } yield integration.projectId + } + + private def resolveProjectByProjectIdsQuery(projectIdsQuery: QueryBase[Seq[String]]): Future[Option[Project]] = { + for { + projectIds <- internalDatabase.run(projectIdsQuery.result) + project <- resolveProject(projectIds.headOption) + } yield project + } + + private def resolveProject(projectId: Option[String]): Future[Option[Project]] = { + projectId match { + case Some(projectId) => + projectResolver.resolve(projectId) + case None => + Future.successful(None) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectResolver.scala new file mode 100644 index 0000000000..d46d2e1578 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/ProjectResolver.scala @@ -0,0 +1,18 @@ +package cool.graph.system.database.finder + +import cool.graph.shared.models.{Project, ProjectWithClientId} + +import scala.concurrent.Future + +trait ProjectResolver { + def resolve(projectIdOrAlias: String): Future[Option[Project]] + def resolveProjectWithClientId(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] +} + +trait CachedProjectResolver extends ProjectResolver { + + /** + * Invalidates the cache entry for the given project id or alias. + */ + def invalidate(projectIdOrAlias: String): Future[Unit] +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/UncachedProjectResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/UncachedProjectResolver.scala new file mode 100644 index 0000000000..f5e3e1afee --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/UncachedProjectResolver.scala @@ -0,0 +1,279 @@ +package cool.graph.system.database.finder + +import cool.graph.shared.models +import cool.graph.shared.models.ProjectWithClientId +import cool.graph.system.database.tables._ +import cool.graph.system.database.{AllDataForProject, DbToModelMapper} +import cool.graph.system.metrics.SystemMetrics +import cool.graph.{RequestContextTrait, Timing} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.QueryBase + +import scala.concurrent.Future + +object UncachedProjectResolverMetrics { + import SystemMetrics._ + + val readFromDatabaseTimer = defineTimer("readFromDatabaseTimer") +} + +object UncachedProjectResolver { + def apply(internalDatabase: DatabaseDef, requestContext: RequestContextTrait): UncachedProjectResolver = { + UncachedProjectResolver(internalDatabase, Some(requestContext)) + } +} + +case class UncachedProjectResolver( + internalDatabase: DatabaseDef, + requestContext: Option[RequestContextTrait] = None +) extends ProjectResolver { + import DbQueriesForUncachedProjectResolver._ + import UncachedProjectResolverMetrics._ + import scala.concurrent.ExecutionContext.Implicits.global + + override def resolve(projectIdOrAlias: String): Future[Option[models.Project]] = resolveProjectWithClientId(projectIdOrAlias).map(_.map(_.project)) + + override def resolveProjectWithClientId(projectIdOrAlias: String): Future[Option[models.ProjectWithClientId]] = { + val project: Future[Option[Project]] = runQuery(projectQuery(projectIdOrAlias)).map(_.headOption) + + val allDataForProject: Future[Option[AllDataForProject]] = project.flatMap { + case Some(project) => gatherAllDataForProject(project).map(Some(_)) + case None => Future.successful(Option.empty) + } + + val asModel: Future[Option[ProjectWithClientId]] = allDataForProject.map(_.map { allDataForProject => + val project = DbToModelMapper.createProject(allDataForProject) + models.ProjectWithClientId(project, allDataForProject.project.clientId) + }) + + asModel + } + + private def gatherAllDataForProject(project: Project): Future[AllDataForProject] = performWithTiming("resolveProjectWithClientId.gatherAllDataForProject") { + readFromDatabaseTimer.timeFuture() { + for { + _ <- Future.successful(()) + // execute all queries in parallel + fieldsFuture = runQuery(fieldsForProjectQuery(project.id)) + rootsFuture = runQuery(patQuery(project.id)) + actionsFuture = runQuery(actionQuery(project.id)) + seatsFuture = runQuery(seatQuery(project.id)) + packageDefinitionsFuture = runQuery(packageDefinitionQuery(project.id)) + enumsFuture = runQuery(enumQuery(project.id)) + featureTogglesFuture = runQuery(featureTogglesQuery(project.id)) + functionsFuture = runQuery(functionsQuery(project.id)) + projectDatabaseFuture = runQuery(projectDatabasesQuery(project.projectDatabaseId)).map(_.head) + fieldConstraintsFuture = runQuery(fieldConstraintsQuery(project.id)) + modelsFuture = runQuery(modelsForProjectQuery(project.id)) + relationsAndMirrorsFuture = runQuery(relationAndFieldMirrorQuery(project.id)) + integrationsFuture = runQuery(integrationQuery(project.id)) + + // gather the first results we need for the next queries + models <- modelsFuture + relationsAndMirrors <- relationsAndMirrorsFuture + integrations <- integrationsFuture + + // trigger next queries in parallel + modelIds = models.map(_.id) + relationIds = relationsAndMirrors.map(_._1.id).distinct + integrationIds = integrations.map(_.id).toList + modelPermissionsFuture = runQuery(modelAndPermissionQuery(modelIds)) + relationPermissionsFuture = runQuery(relationPermissionQuery(relationIds)) + auth0IntegrationsFuture = runQuery(auth0IntegrationQuery(integrationIds)) + digitsIntegrationsFuture = runQuery(digitsIntegrationQuery(integrationIds)) + algoliaIntegrationsFuture = runQuery(algoliaIntegrationQuery(integrationIds)) + + // then gather all results + fields <- fieldsFuture + roots <- rootsFuture + actions <- actionsFuture + seats <- seatsFuture + packageDefinitions <- packageDefinitionsFuture + enums <- enumsFuture + featureToggles <- featureTogglesFuture + functions <- functionsFuture + projectDatabase <- projectDatabaseFuture + fieldConstraints <- fieldConstraintsFuture + modelPermissions <- modelPermissionsFuture + relationPermissions <- relationPermissionsFuture + auth0Integrations <- auth0IntegrationsFuture + digitsIntegrations <- digitsIntegrationsFuture + algoliaIntegrations <- algoliaIntegrationsFuture + + } yield { + AllDataForProject( + project = project, + models = models, + fields = fields, + relations = relationsAndMirrors.map(_._1).distinct, + relationFieldMirrors = relationsAndMirrors.flatMap(_._2).distinct, + rootTokens = roots.distinct, + actions = actions.map(_._1).distinct, + actionTriggerMutationModels = actions.flatMap(_._2).distinct, + actionTriggerMutationRelations = actions.flatMap(_._3).distinct, + actionHandlerWebhooks = actions.flatMap(_._4).distinct, + integrations = integrations, + modelPermissions = modelPermissions.map(_._1).distinct, + modelPermissionFields = modelPermissions.flatMap(_._2).distinct, + relationPermissions = relationPermissions, + auth0s = auth0Integrations, + digits = digitsIntegrations, + algolias = algoliaIntegrations.map(_._1).distinct, + algoliaSyncQueries = algoliaIntegrations.flatMap(_._2), + seats = seats.distinct, + packageDefinitions = packageDefinitions.distinct, + enums = enums.distinct, + featureToggles = featureToggles.toList, + functions = functions.toList, + fieldConstraints = fieldConstraints, + projectDatabase = projectDatabase + ) + } + } + } + + private def runQuery[T](query: QueryBase[T]): Future[T] = internalDatabase.run(query.result) + + private def performWithTiming[A](name: String)(f: => Future[A]): Future[A] = { + val begin = System.currentTimeMillis() + val result = f + result onComplete { _ => + val timing = Timing(name, System.currentTimeMillis() - begin) + requestContext.foreach(_.logTimingWithoutCloudwatch(timing, _.RequestMetricsSql)) + } + result + } +} + +object DbQueriesForUncachedProjectResolver { + import Tables._ + + def projectQuery(projectIdOrAlias: String): Query[ProjectTable, Project, Seq] = { + val query = for { + project <- Projects if project.id === projectIdOrAlias || project.alias === projectIdOrAlias + } yield project + query.take(1) + } + + def modelsForProjectQuery(projectId: String): Query[ModelTable, Model, Seq] = { + for { + model <- Models if model.projectId === projectId + } yield model + } + + def fieldsForProjectQuery(projectId: String): Query[FieldTable, Field, Seq] = { + for { + model <- modelsForProjectQuery(projectId) + field <- Fields if field.modelId === model.id + } yield field + } + + def relationAndFieldMirrorQuery(projectId: String): QueryBase[Seq[(Relation, Option[RelationFieldMirror])]] = { + for { + ((r: RelationTable), frm) <- Relations joinLeft RelationFieldMirrors on (_.id === _.relationId) + if r.projectId === projectId + } yield (r, frm) + } + + def patQuery(projectId: String): QueryBase[Seq[RootToken]] = { + for { + pat <- RootTokens if pat.projectId === projectId + } yield pat + } + + def actionQuery( + projectId: String): QueryBase[Seq[(Action, Option[ActionTriggerMutationModel], Option[ActionTriggerMutationRelation], Option[ActionHandlerWebhook])]] = { + for { + ((((a: ActionTable), atmm), atrm), atwh) <- Actions joinLeft ActionTriggerMutationModels on (_.id === _.actionId) joinLeft ActionTriggerMutationRelations on (_._1.id === _.actionId) joinLeft ActionHandlerWebhooks on (_._1._1.id === _.actionId) + if a.projectId === projectId + } yield (a, atmm, atrm, atwh) + } + + def integrationQuery(projectId: String): QueryBase[Seq[Integration]] = { + for { + integration <- Integrations + if integration.projectId === projectId + } yield integration + } + + def modelAndPermissionQuery(modelIds: Seq[String]): QueryBase[Seq[(ModelPermission, Option[ModelPermissionField])]] = { + for { + ((mp: ModelPermissionTable), mpf) <- ModelPermissions joinLeft ModelPermissionFields on (_.id === _.modelPermissionId) + if mp.modelId.inSet(modelIds) + } yield (mp, mpf) + } + + def auth0IntegrationQuery(integrationIds: Seq[String]): QueryBase[Seq[IntegrationAuth0]] = { + for { + a <- IntegrationAuth0s if a.integrationId.inSet(integrationIds) + } yield a + } + + def digitsIntegrationQuery(integrationIds: Seq[String]): QueryBase[Seq[IntegrationDigits]] = { + for { + d <- IntegrationDigits if d.integrationId.inSet(integrationIds) + } yield d + } + + def algoliaIntegrationQuery(integrationIds: Seq[String]): QueryBase[Seq[(SearchProviderAlgolia, Option[AlgoliaSyncQuery])]] = { + for { + ((a: SearchProviderAlgoliaTable), as) <- SearchProviderAlgolias joinLeft AlgoliaSyncQueries on (_.id === _.searchProviderAlgoliaId) + if a.integrationId.inSet(integrationIds) + } yield (a, as) + } + + def seatQuery(projectId: String): QueryBase[Seq[(Seat, Option[Client])]] = { + for { + (s: SeatTable, c) <- Seats joinLeft Clients on (_.clientId === _.id) + if s.projectId === projectId + } yield (s, c) + } + + def fieldConstraintsQuery(projectId: String): QueryBase[Seq[FieldConstraint]] = { + for { + field <- fieldsForProjectQuery(projectId) + constraint <- FieldConstraints if constraint.fieldId === field.id + } yield constraint + } + + def relationPermissionQuery(relationIds: Seq[String]): QueryBase[Seq[RelationPermission]] = { + for { + relationPermission <- RelationPermissions if relationPermission.relationId.inSet(relationIds) + } yield relationPermission + } + + def packageDefinitionQuery(projectId: String): QueryBase[Seq[PackageDefinition]] = { + for { + packageDefinition <- PackageDefinitions if packageDefinition.projectId === projectId + } yield packageDefinition + } + + def enumQuery(projectId: String): QueryBase[Seq[Enum]] = { + for { + enum <- Enums + if enum.projectId === projectId + } yield enum + } + + def featureTogglesQuery(projectId: String): QueryBase[Seq[FeatureToggle]] = { + for { + featureToggle <- FeatureToggles + if featureToggle.projectId === projectId + } yield featureToggle + } + + def functionsQuery(projectId: String): QueryBase[Seq[Function]] = { + for { + function <- Functions + if function.projectId === projectId + } yield function + } + + def projectDatabasesQuery(projectDatabaseId: String): QueryBase[Seq[ProjectDatabase]] = { + for { + projectDatabase <- ProjectDatabases + if projectDatabase.id === projectDatabaseId + } yield projectDatabase + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/client/ClientResolver.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/client/ClientResolver.scala new file mode 100644 index 0000000000..3c356b2107 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/finder/client/ClientResolver.scala @@ -0,0 +1,77 @@ +package cool.graph.system.database.finder.client + +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.{CachedProjectResolver, ProjectResolver} +import cool.graph.system.database.tables.Tables.{Clients, Seats} +import cool.graph.system.database.{DbToModelMapper, tables} +import cool.graph.system.metrics.SystemMetrics +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.{ExecutionContext, Future} + +trait ClientResolver { + def resolve(clientId: String): Future[Option[Client]] + def resolveProjectsForClient(clientId: String): Future[Vector[Project]] +} + +object ClientResolver { + def apply(internalDatabase: DatabaseDef, projectResolver: ProjectResolver)(implicit ec: ExecutionContext): ClientResolver = { + ClientResolverImpl(internalDatabase, projectResolver) + } +} + +case class ClientResolverImpl( + internalDatabase: DatabaseDef, + projectResolver: ProjectResolver +)(implicit ec: ExecutionContext) + extends ClientResolver { + import ClientResolverMetrics._ + + override def resolve(clientId: String): Future[Option[Client]] = resolveClientTimer.timeFuture() { + clientForId(clientId).map { clientRowOpt => + clientRowOpt.map { clientRow => + DbToModelMapper.createClient(clientRow) + } + } + } + + private def clientForId(clientId: String): Future[Option[tables.Client]] = { + val query = for { + client <- Clients + if client.id === clientId + } yield client + + internalDatabase.run(query.result.headOption) + } + + override def resolveProjectsForClient(clientId: String): Future[Vector[Project]] = resolveAllProjectsForClientTimer.timeFuture() { + def resolveProjectIds(projectIds: Vector[String]): Future[Vector[Project]] = { + val tmp: Vector[Future[Option[Project]]] = projectIds.map(projectResolver.resolve) + val sequenced: Future[Vector[Option[Project]]] = Future.sequence(tmp) + sequenced.map(_.flatten) + } + + for { + projectIds <- projectIdsForClientId(clientId) + projects <- resolveProjectIds(projectIds) + } yield projects + } + + private def projectIdsForClientId(clientId: String): Future[Vector[String]] = readProjectIdsFromDatabaseTimer.timeFuture() { + val query = for { + seat <- Seats + if seat.clientId === clientId + } yield seat.projectId + + internalDatabase.run(query.result.map(_.toVector)) + } +} + +object ClientResolverMetrics { + import SystemMetrics._ + + val resolveClientTimer = defineTimer("readClientFromDatabaseTimer") + val readProjectIdsFromDatabaseTimer = defineTimer("readProjectIdsFromDatabaseTimer") + val resolveAllProjectsForClientTimer = defineTimer("resolveAllProjectsForClientTimer") +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/InternalDatabaseSchema.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/InternalDatabaseSchema.scala new file mode 100644 index 0000000000..6845e4448b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/InternalDatabaseSchema.scala @@ -0,0 +1,492 @@ +package cool.graph.system.database.schema + +import slick.jdbc.MySQLProfile.api._ + +object InternalDatabaseSchema { + + def createSchemaActions(recreate: Boolean): DBIOAction[Unit, NoStream, Effect] = { + if (recreate) { + DBIO.seq(dropAction, setupActions) + } else { + setupActions + } + } + + lazy val dropAction = DBIO.seq(sqlu"DROP SCHEMA IF EXISTS `graphcool`;") + + lazy val setupActions = DBIO.seq( + sqlu"CREATE SCHEMA IF NOT EXISTS `graphcool` DEFAULT CHARACTER SET latin1;", + sqlu"USE `graphcool`;", + // CLIENT + sqlu""" + CREATE TABLE IF NOT EXISTS `Client` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `gettingStartedStatus` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `password` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `createdAt` datetime(3) NOT NULL, + `updatedAt` datetime(3) NOT NULL, + `resetPasswordSecret` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `source` varchar(255) CHARACTER SET utf8 NOT NULL, + `auth0Id` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `Auth0IdentityProvider` enum('auth0','github','google-oauth2') COLLATE utf8_unicode_ci DEFAULT NULL, + `isAuth0IdentityProviderEmail` tinyint(4) NOT NULL DEFAULT '0', + `isBeta` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `client_auth0id_uniq` (`auth0Id`), + UNIQUE KEY `email_UNIQUE` (`email`(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // PROJECT DATABASE + sqlu""" + CREATE TABLE IF NOT EXISTS `ProjectDatabase` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `region` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT 'eu-west-1', + `name` varchar(255) CHARACTER SET utf8 DEFAULT NULL, + `isDefaultForRegion` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `region_name_uniq` (`region`,`name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // PROJECT + sqlu""" + CREATE TABLE IF NOT EXISTS `Project` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `clientId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `webhookUrl` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `oauthRedirectUrl` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `twitterConsumerKey` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `twitterConsumerSecret` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `alias` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `allowQueries` tinyint(1) NOT NULL DEFAULT '1', + `allowMutations` tinyint(1) NOT NULL DEFAULT '1', + `region` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT 'eu-west-1', + `revision` int(11) NOT NULL DEFAULT '1', + `typePositions` text CHARACTER SET utf8, + `projectDatabaseId` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT 'eu-west-1-legacy', + `isEjected` tinyint(1) NOT NULL DEFAULT '0', + `hasGlobalStarPermission` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `project_clientid_projectname_uniq` (`clientId`,`name`), + UNIQUE KEY `project_alias_uniq` (`alias`), + KEY `project_databaseid_foreign` (`projectDatabaseId`), + CONSTRAINT `project_clientid_foreign` FOREIGN KEY (`clientId`) REFERENCES `Client` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `project_databaseid_foreign` FOREIGN KEY (`projectDatabaseId`) REFERENCES `ProjectDatabase` (`id`) ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // PACKAGEDEFINITION + sqlu""" + CREATE TABLE IF NOT EXISTS `PackageDefinition` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8 NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `formatVersion` int(11) NOT NULL DEFAULT '1', + `definition` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `packagedefinition_projectid_foreign` (`projectId`), + CONSTRAINT `packagedefinition_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // SEAT + sqlu""" + CREATE TABLE IF NOT EXISTS `Seat` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL DEFAULT '', + `clientId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `status` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `seat_clientId_projectid_uniq` (`clientId`,`projectId`), + UNIQUE KEY `seat_projectid_email_uniq` (`projectId`,`email`), + KEY `seat_clientid_foreign` (`clientId`), + CONSTRAINT `seat_clientid_foreign` FOREIGN KEY (`clientId`) REFERENCES `Client` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `seat_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ACTION + sqlu""" + CREATE TABLE IF NOT EXISTS `Action` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `isActive` tinyint(1) NOT NULL, + `triggerType` enum('MUTATION_MODEL','MUTATION_RELATION') COLLATE utf8_unicode_ci NOT NULL, + `handlerType` enum('WEBHOOK') COLLATE utf8_unicode_ci NOT NULL, + `description` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_Action_Project_projectId` (`projectId`), + CONSTRAINT `fk_Action_Project_projectId` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ACTIONHANDLERWEBHOOK + sqlu""" + CREATE TABLE IF NOT EXISTS `ActionHandlerWebhook` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `actionId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `url` varchar(2048) CHARACTER SET utf8 NOT NULL DEFAULT '', + `isAsync` tinyint(1) NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + KEY `fk_ActionHandlerWebhook_Action_actionId` (`actionId`), + CONSTRAINT `fk_ActionHandlerWebhook_Action_actionId` FOREIGN KEY (`actionId`) REFERENCES `Action` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // MUTATIONLOG + sqlu""" + CREATE TABLE IF NOT EXISTS `MutationLog` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `clientId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL, + `startedAt` datetime(3) NOT NULL, + `finishedAt` datetime(3) DEFAULT NULL, + `status` enum('SCHEDULED','SUCCESS','FAILURE','ROLLEDBACK') COLLATE utf8_unicode_ci NOT NULL, + `failedMutaction` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `input` mediumtext COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `mutationlog_clientid_foreign` (`clientId`), + KEY `mutationlog_projectid_foreign` (`projectId`), + CONSTRAINT `mutationlog_clientid_foreign` FOREIGN KEY (`clientId`) REFERENCES `Client` (`id`) ON DELETE CASCADE, + CONSTRAINT `mutationlog_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // MUTATION LOG MUTACTION + sqlu""" + CREATE TABLE IF NOT EXISTS `MutationLogMutaction` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `mutationLogId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `index` smallint(6) NOT NULL, + `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL, + `finishedAt` datetime(3) DEFAULT NULL, + `status` enum('SCHEDULED','SUCCESS','FAILURE','ROLLEDBACK') COLLATE utf8_unicode_ci NOT NULL, + `error` text COLLATE utf8_unicode_ci, + `rollbackError` text COLLATE utf8_unicode_ci, + `input` mediumtext COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `mutationlogmutaction_mutationlogid_foreign` (`mutationLogId`), + CONSTRAINT `mutationlogmutaction_mutationlogid_foreign` FOREIGN KEY (`mutationLogId`) REFERENCES `MutationLog` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // MODEL + sqlu""" + CREATE TABLE IF NOT EXISTS `Model` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `projectId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `modelName` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `description` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `isSystem` tinyint(1) NOT NULL, + `fieldPositions` text CHARACTER SET utf8, + PRIMARY KEY (`id`), + UNIQUE KEY `model_projectid_modelname_uniq` (`projectId`,`modelName`), + CONSTRAINT `model_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // FUNCTION + sqlu""" + CREATE TABLE IF NOT EXISTS `Function` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8 NOT NULL, + `binding` enum('CUSTOM_MUTATION','CUSTOM_QUERY','SERVERSIDE_SUBSCRIPTION','TRANSFORM_REQUEST','TRANSFORM_ARGUMENT','PRE_WRITE','TRANSFORM_PAYLOAD','TRANSFORM_RESPONSE') COLLATE utf8_unicode_ci NOT NULL, + `type` enum('WEBHOOK','LAMBDA','AUTH0') COLLATE utf8_unicode_ci NOT NULL, + `requestPipelineMutationModelId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `serversideSubscriptionQuery` text CHARACTER SET utf8, + `serversideSubscriptionQueryFilePath` text CHARACTER SET utf8 DEFAULT NULL, + `lambdaArn` varchar(1000) COLLATE utf8_unicode_ci DEFAULT NULL, + `webhookUrl` text CHARACTER SET utf8, + `webhookHeaders` text CHARACTER SET utf8, + `inlineCode` mediumtext CHARACTER SET utf8, + `inlineCodeFilePath` text CHARACTER SET utf8 DEFAULT NULL, + `auth0Id` varchar(1000) COLLATE utf8_unicode_ci DEFAULT NULL, + `isActive` tinyint(1) NOT NULL DEFAULT '1', + `requestPipelineMutationOperation` enum('CREATE','UPDATE','DELETE') COLLATE utf8_unicode_ci DEFAULT NULL, + `schema` mediumtext CHARACTER SET utf8, + `schemaFilePath` text CHARACTER SET utf8 DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `function_projectid_name_uniq` (`projectId`,`name`), + KEY `function_requestPipelineMutationModelId_foreign` (`requestPipelineMutationModelId`), + CONSTRAINT `function_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `function_requestPipelineMutationModelId_foreign` FOREIGN KEY (`requestPipelineMutationModelId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // INTEGRATION + sqlu""" + CREATE TABLE IF NOT EXISTS `Integration` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8 NOT NULL, + `integrationType` varchar(255) CHARACTER SET utf8 NOT NULL, + `isEnabled` tinyint(1) NOT NULL DEFAULT '1', + PRIMARY KEY (`id`), + KEY `integration_projectid_foreign` (`projectId`), + CONSTRAINT `integration_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // AUTH PROVIDER DIGITS + sqlu""" + CREATE TABLE IF NOT EXISTS `AuthProviderDigits` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL, + `integrationId` varchar(25) CHARACTER SET utf8 NOT NULL, + `consumerKey` varchar(255) CHARACTER SET utf8 NOT NULL, + `consumerSecret` varchar(255) CHARACTER SET utf8 NOT NULL, + PRIMARY KEY (`id`), + KEY `authproviderdigits_integrationid_foreign` (`integrationId`), + CONSTRAINT `authproviderdigits_integrationid_foreign` FOREIGN KEY (`integrationId`) REFERENCES `Integration` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // AUTH PROVIDER AUTH0 + sqlu""" + CREATE TABLE IF NOT EXISTS `AuthProviderAuth0` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL, + `integrationId` varchar(25) CHARACTER SET utf8 NOT NULL, + `domain` varchar(255) CHARACTER SET utf8 NOT NULL, + `clientId` varchar(255) CHARACTER SET utf8 NOT NULL, + `clientSecret` varchar(255) CHARACTER SET utf8 NOT NULL, + PRIMARY KEY (`id`), + KEY `authproviderauth0_integrationid_foreign` (`integrationId`), + CONSTRAINT `authproviderauth0_integrationid_foreign` FOREIGN KEY (`integrationId`) REFERENCES `Integration` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // SEARCHPROVIDERALGOLIA + sqlu""" + CREATE TABLE IF NOT EXISTS `SearchProviderAlgolia` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL, + `integrationId` varchar(25) CHARACTER SET utf8 NOT NULL, + `applicationId` varchar(255) CHARACTER SET utf8 NOT NULL, + `apiKey` varchar(255) CHARACTER SET utf8 NOT NULL, + PRIMARY KEY (`id`), + KEY `searchprovideralgolia_integrationid_foreign` (`integrationId`), + CONSTRAINT `searchprovideralgolia_integrationid_foreign` FOREIGN KEY (`integrationId`) REFERENCES `Integration` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ALGOLIASYNCQUERY + sqlu""" + CREATE TABLE IF NOT EXISTS `AlgoliaSyncQuery` ( + `id` varchar(25) CHARACTER SET utf8 NOT NULL, + `modelId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `searchProviderAlgoliaId` varchar(25) CHARACTER SET utf8 NOT NULL, + `indexName` varchar(255) CHARACTER SET utf8 NOT NULL, + `query` text CHARACTER SET utf8 NOT NULL, + `isEnabled` tinyint(4) NOT NULL, + PRIMARY KEY (`id`), + KEY `algoliasyncquery_modelid_foreign` (`modelId`), + KEY `algoliasyncquery_searchprovideralgoliaid_foreign` (`searchProviderAlgoliaId`), + CONSTRAINT `algoliasyncquery_modelid_foreign` FOREIGN KEY (`modelId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `algoliasyncquery_searchprovideralgoliaid_foreign` FOREIGN KEY (`searchProviderAlgoliaId`) REFERENCES `SearchProviderAlgolia` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ACTIONTRIGGERMUTATIONMODEL + sqlu""" + CREATE TABLE IF NOT EXISTS `ActionTriggerMutationModel` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `actionId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `modelId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `mutationType` enum('CREATE','UPDATE','DELETE') COLLATE utf8_unicode_ci NOT NULL, + `fragment` text COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_ActionTriggerMutationModel_Action_actionId` (`actionId`), + KEY `fk_ActionTriggerMutationModel_Model_modelId` (`modelId`), + CONSTRAINT `fk_ActionTriggerMutationModel_Action_actionId` FOREIGN KEY (`actionId`) REFERENCES `Action` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_ActionTriggerMutationModel_Model_modelId` FOREIGN KEY (`modelId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // RELATION + sqlu""" + CREATE TABLE IF NOT EXISTS `Relation` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `projectId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `modelAId` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `modelBId` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `name` varchar(191) COLLATE utf8_unicode_ci NOT NULL, + `description` text COLLATE utf8_unicode_ci, + PRIMARY KEY (`id`), + UNIQUE KEY `projectId_name_UNIQUE` (`projectId`,`name`), + KEY `relation_modelaid_foreign` (`modelAId`), + KEY `relation_modelbid_foreign` (`modelBId`), + CONSTRAINT `relation_modelaid_foreign` FOREIGN KEY (`modelAId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `relation_modelbid_foreign` FOREIGN KEY (`modelBId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `relation_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ACTIONTRIGGERMUTATIONRELATION + sqlu""" + CREATE TABLE IF NOT EXISTS `ActionTriggerMutationRelation` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `actionId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `relationId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `mutationType` enum('ADD','REMOVE') COLLATE utf8_unicode_ci NOT NULL, + `fragment` text COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_ActionTriggerMutationRelation_Action_actionId` (`actionId`), + KEY `fk_ActionTriggerMutationRelation_Relation_relationId` (`relationId`), + CONSTRAINT `fk_ActionTriggerMutationRelation_Action_actionId` FOREIGN KEY (`actionId`) REFERENCES `Action` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_ActionTriggerMutationRelation_Relation_relationId` FOREIGN KEY (`relationId`) REFERENCES `Relation` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ENUM + sqlu""" + CREATE TABLE IF NOT EXISTS `Enum` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8 NOT NULL, + `values` text CHARACTER SET utf8, + PRIMARY KEY (`id`), + UNIQUE KEY `enum_projectid_name_uniq` (`projectId`,`name`), + CONSTRAINT `enum_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // FIELD + sqlu""" + CREATE TABLE IF NOT EXISTS `Field` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `modelId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + `fieldName` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `typeIdentifier` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `relationId` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `relationSide` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `enumValues` text COLLATE utf8_unicode_ci, + `isRequired` tinyint(1) DEFAULT NULL, + `isList` tinyint(1) DEFAULT NULL, + `isUnique` tinyint(1) DEFAULT NULL, + `isSystem` tinyint(1) DEFAULT NULL, + `defaultValue` text CHARACTER SET utf8, + `description` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `isReadonly` tinyint(1) NOT NULL DEFAULT '0', + `enumId` varchar(25) COLLATE utf8_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `field_modelid_fieldname` (`modelId`,`fieldName`), + KEY `field_relationid_foreign` (`relationId`), + KEY `field_enumid_foreign_2` (`enumId`), + CONSTRAINT `field_enumid_foreign_2` FOREIGN KEY (`enumId`) REFERENCES `Enum` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `field_modelid_foreign` FOREIGN KEY (`modelId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `field_relationid_foreign` FOREIGN KEY (`relationId`) REFERENCES `Relation` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + //FieldConstraint + sqlu""" + CREATE TABLE IF NOT EXISTS `FieldConstraint` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `fieldId` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `constraintType` enum('STRING','NUMBER','BOOLEAN','LIST') COLLATE utf8_unicode_ci NOT NULL, + `equalsNumber` decimal(65,30) DEFAULT NULL, + `oneOfNumber` text CHARACTER SET utf8, + `min` decimal(65,30) DEFAULT NULL, + `max` decimal(65,30) DEFAULT NULL, + `exclusiveMin` decimal(65,30) DEFAULT NULL, + `exclusiveMax` decimal(65,30) DEFAULT NULL, + `multipleOf` decimal(65,30) DEFAULT NULL, + `equalsString` text CHARACTER SET utf8mb4, + `oneOfString` text CHARACTER SET utf8mb4, + `minLength` int(11) DEFAULT NULL, + `maxLength` int(11) DEFAULT NULL, + `startsWith` text CHARACTER SET utf8mb4, + `endsWith` text CHARACTER SET utf8mb4, + `includes` text CHARACTER SET utf8mb4, + `regex` text CHARACTER SET utf8mb4, + `equalsBoolean` tinyint(1) DEFAULT NULL, + `uniqueItems` tinyint(1) DEFAULT NULL, + `minItems` int(11) DEFAULT NULL, + `maxItems` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `fieldconstraint_fieldid_uniq` (`fieldId`,`constraintType`), + CONSTRAINT `fieldconstraint_fieldid_foreign` FOREIGN KEY (`fieldId`) REFERENCES `Field` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // RELATIONFIELDMIRROR + sqlu""" + CREATE TABLE IF NOT EXISTS `RelationFieldMirror` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `relationId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `fieldId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `relationfieldmirror_relationid_foreign` (`relationId`), + KEY `relationfieldmirror_fieldid_foreign` (`fieldId`), + CONSTRAINT `relationfieldmirror_fieldid_foreign` FOREIGN KEY (`fieldId`) REFERENCES `Field` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `relationfieldmirror_relationid_foreign` FOREIGN KEY (`relationId`) REFERENCES `Relation` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // PERMISSION + sqlu""" + CREATE TABLE IF NOT EXISTS `Permission` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `fieldId` varchar(25) COLLATE utf8_unicode_ci DEFAULT '', + `userType` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `userPath` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `userRole` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `allowRead` tinyint(1) DEFAULT NULL, + `allowCreate` tinyint(1) DEFAULT NULL, + `allowUpdate` tinyint(1) DEFAULT NULL, + `allowDelete` tinyint(1) DEFAULT NULL, + `comment` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `permission_fieldid_foreign` (`fieldId`), + CONSTRAINT `permission_fieldid_foreign` FOREIGN KEY (`fieldId`) REFERENCES `Field` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // MODELPERMISSION + sqlu""" + CREATE TABLE IF NOT EXISTS `ModelPermission` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `modelId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `operation` enum('READ','CREATE','UPDATE','DELETE') COLLATE utf8_unicode_ci NOT NULL, + `userType` enum('EVERYONE','AUTHENTICATED') COLLATE utf8_unicode_ci NOT NULL, + `rule` enum('NONE','GRAPH','WEBHOOK') CHARACTER SET utf8 NOT NULL, + `ruleName` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `ruleGraphQuery` text COLLATE utf8_unicode_ci, + `ruleGraphQueryFilePath` text COLLATE utf8_unicode_ci DEFAULT NULL, + `ruleWebhookUrl` text COLLATE utf8_unicode_ci, + `applyToWholeModel` tinyint(1) NOT NULL DEFAULT '0', + `isActive` tinyint(1) NOT NULL DEFAULT '0', + `description` varchar(255) CHARACTER SET utf8 DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `modelpermission_modelid_foreign` (`modelId`), + CONSTRAINT `modelpermission_modelid_foreign` FOREIGN KEY (`modelId`) REFERENCES `Model` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // RELATIONPERMISSION + sqlu""" + CREATE TABLE IF NOT EXISTS `RelationPermission` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `relationId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `connect` tinyint(1) NOT NULL, + `disconnect` tinyint(1) NOT NULL, + `userType` enum('EVERYONE','AUTHENTICATED') COLLATE utf8_unicode_ci NOT NULL, + `rule` enum('NONE','GRAPH','WEBHOOK') CHARACTER SET utf8 NOT NULL, + `ruleName` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `ruleGraphQuery` text COLLATE utf8_unicode_ci, + `ruleGraphQueryFilePath` text COLLATE utf8_unicode_ci DEFAULT NULL, + `ruleWebhookUrl` text COLLATE utf8_unicode_ci, + `isActive` tinyint(4) NOT NULL DEFAULT '0', + `description` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `relationpermission_relationid_foreign` (`relationId`), + CONSTRAINT `relationpermission_relationid_foreign` FOREIGN KEY (`relationId`) REFERENCES `Relation` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // MODELPERMISSIONFIELD + sqlu""" + CREATE TABLE IF NOT EXISTS `ModelPermissionField` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `modelPermissionId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `fieldId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `modelpermissionfield_modelpermissionid_foreign` (`modelPermissionId`), + KEY `modelpermission_field_foreign` (`fieldId`), + CONSTRAINT `modelpermission_fieldid_foreign` FOREIGN KEY (`fieldId`) REFERENCES `Field` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `modelpermissionfield_modelpermisisonid_foreign` FOREIGN KEY (`modelPermissionId`) REFERENCES `ModelPermission` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // RELAYID + sqlu""" + CREATE TABLE IF NOT EXISTS `RelayId` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `typeName` varchar(100) COLLATE utf8_unicode_ci NOT NULL, + PRIMARY KEY (`id`), + KEY `relayid_typename` (`typeName`) USING BTREE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // ROOTTOKEN + sqlu""" + CREATE TABLE IF NOT EXISTS `PermanentAuthToken` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '', + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `token` text COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `description` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, + `created` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `systemtoken_projectid_foreign` (`projectId`), + CONSTRAINT `systemtoken_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // FEATURE TOGGLE + sqlu""" + CREATE TABLE IF NOT EXISTS `FeatureToggle` ( + `id` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `projectId` varchar(25) COLLATE utf8_unicode_ci NOT NULL, + `name` varchar(255) CHARACTER SET utf8 NOT NULL, + `isEnabled` tinyint(1) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `featuretoggle_projectid_name_uniq` (`projectId`,`name`), + KEY `featuretoggle_projectid_foreign` (`projectId`), + CONSTRAINT `featuretoggle_projectid_foreign` FOREIGN KEY (`projectId`) REFERENCES `Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""", + // _MIGRATION + sqlu""" + CREATE TABLE IF NOT EXISTS `_Migration` ( + `id` varchar(4) COLLATE utf8_unicode_ci NOT NULL, + `runAt` datetime NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;""" + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/LogDatabaseSchema.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/LogDatabaseSchema.scala new file mode 100644 index 0000000000..59ea7f7e62 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/schema/LogDatabaseSchema.scala @@ -0,0 +1,34 @@ +package cool.graph.system.database.schema + +import slick.jdbc.MySQLProfile.api._ + +object LogDatabaseSchema { + + def createSchemaActions(recreate: Boolean): DBIOAction[Unit, NoStream, Effect] = { + if (recreate) { + DBIO.seq(dropAction, setupActions) + } else { + setupActions + } + } + + lazy val dropAction = DBIO.seq(sqlu"DROP SCHEMA IF EXISTS `logs`;") + + lazy val setupActions = DBIO.seq( + sqlu"CREATE SCHEMA IF NOT EXISTS `logs` DEFAULT CHARACTER SET utf8mb4;", + sqlu"USE `logs`;", + sqlu""" + CREATE TABLE IF NOT EXISTS `Log` ( + `id` varchar(25) NOT NULL, + `projectId` varchar(25) NOT NULL, + `functionId` varchar(25) NOT NULL, + `requestId` varchar(25) NOT NULL, + `status` enum('SUCCESS','FAILURE') NOT NULL, + `duration` int(11) NOT NULL, + `timestamp` datetime(3) NOT NULL, + `message` mediumtext NOT NULL, + PRIMARY KEY (`id`), + KEY `functionId` (`functionId`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;""" + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/seed/InternalDatabaseSeedActions.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/seed/InternalDatabaseSeedActions.scala new file mode 100644 index 0000000000..79d178d551 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/seed/InternalDatabaseSeedActions.scala @@ -0,0 +1,53 @@ +package cool.graph.system.database.seed + +import cool.graph.cuid.Cuid +import slick.dbio.{Effect, NoStream} +import slick.jdbc.MySQLProfile.api._ + +object InternalDatabaseSeedActions { + + /** + * Returns a sequence of all sql statements that should be run in the current environment. + */ + def seedActions(masterToken: Option[String]): DBIOAction[Vector[Unit], NoStream, Effect] = { + var actions = Vector.empty[DBIOAction[Unit, NoStream, Effect]] + + if (masterToken.isDefined) { + actions = actions :+ createMasterConsumerSeedAction() + actions = actions :+ createProjectDatabaseSeedAction() + } + + DBIO.sequence(actions) + } + + /** + * Used to seed the master consumer for local Graphcool setup. Only creates a user if there is no data + * @return SQL action required to create the master user. + */ + private def createMasterConsumerSeedAction(): DBIOAction[Unit, NoStream, Effect] = { + val id = Cuid.createCuid() + val pw = java.util.UUID.randomUUID().toString + + DBIO.seq( + sqlu""" + INSERT INTO Client (id, name, email, gettingStartedStatus, password, createdAt, updatedAt, resetPasswordSecret, source, auth0Id, Auth0IdentityProvider, isAuth0IdentityProviderEmail, isBeta) + SELECT $id, 'Test', 'test@test.org', '', $pw, NOW(), NOW(), NULL, 'WAIT_LIST', NULL, NULL, 0, 0 FROM DUAL + WHERE NOT EXISTS (SELECT * FROM Client); + """ + ) + } + + /** + * Used to seed the basic ProjectDatabase for local Graphcool setup. + * @return SQL action required to create the ProjectDatabase. + */ + private def createProjectDatabaseSeedAction(): DBIOAction[Unit, NoStream, Effect] = { + DBIO.seq( + sqlu""" + INSERT INTO ProjectDatabase (id, region, name, isDefaultForRegion) + SELECT 'eu-west-1-client-1', 'eu-west-1', 'client1', 1 FROM DUAL + WHERE NOT EXISTS (SELECT * FROM ProjectDatabase); + """ + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Action.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Action.scala new file mode 100644 index 0000000000..abcc016637 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Action.scala @@ -0,0 +1,48 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +import cool.graph.shared.models.ActionTriggerType +import cool.graph.shared.models.ActionHandlerType + +case class Action( + id: String, + projectId: String, + isActive: Boolean, + triggerType: ActionTriggerType.Value, + handlerType: ActionHandlerType.Value, + description: Option[String] +) + +class ActionTable(tag: Tag) extends Table[Action](tag, "Action") { + + implicit val actionTriggerTypeMapper = + MappedColumnType.base[ActionTriggerType.Value, String]( + e => e.toString, + s => ActionTriggerType.withName(s) + ) + + implicit val actionHandlerTypeMapper = + MappedColumnType.base[ActionHandlerType.Value, String]( + e => e.toString, + s => ActionHandlerType.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + def isActive = column[Boolean]("isActive") + + def triggerType = + column[ActionTriggerType.Value]("triggerType") + + def handlerType = + column[ActionHandlerType.Value]("handlerType") + + def projectId = column[String]("projectId") + def project = + foreignKey("fk_Action_Project_projectId", projectId, Tables.Projects)(_.id) + + def description = column[Option[String]]("description") + + def * = + (id, projectId, isActive, triggerType, handlerType, description) <> ((Action.apply _).tupled, Action.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionHandlerWebhook.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionHandlerWebhook.scala new file mode 100644 index 0000000000..7f9e52a387 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionHandlerWebhook.scala @@ -0,0 +1,25 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class ActionHandlerWebhook( + id: String, + actionId: String, + url: String, + isAsync: Boolean +) + +class ActionHandlerWebhookTable(tag: Tag) extends Table[ActionHandlerWebhook](tag, "ActionHandlerWebhook") { + + def id = column[String]("id", O.PrimaryKey) + + def actionId = column[String]("actionId") + def action = + foreignKey("fk_ActionHandlerWebhook_Action_actionId", actionId, Tables.Actions)(_.id) + + def url = column[String]("url") + def isAsync = column[Boolean]("isAsync") + + def * = + (id, actionId, url, isAsync) <> ((ActionHandlerWebhook.apply _).tupled, ActionHandlerWebhook.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationModel.scala new file mode 100644 index 0000000000..a6b2567846 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationModel.scala @@ -0,0 +1,40 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +import cool.graph.shared.models.ActionTriggerMutationModelMutationType + +case class ActionTriggerMutationModel( + id: String, + actionId: String, + modelId: String, + mutationType: ActionTriggerMutationModelMutationType.Value, + fragment: String +) + +class ActionTriggerMutationModelTable(tag: Tag) extends Table[ActionTriggerMutationModel](tag, "ActionTriggerMutationModel") { + + implicit val actionTriggerMutationModelMutationTypeMapper = MappedColumnType + .base[ActionTriggerMutationModelMutationType.Value, String]( + e => e.toString, + s => ActionTriggerMutationModelMutationType.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + + def mutationType = + column[ActionTriggerMutationModelMutationType.Value]("mutationType") + + def actionId = column[String]("actionId") + def action = + foreignKey("fk_ActionTriggerMutationModelMutationType_Action_actionId", actionId, Tables.Actions)(_.id) + + def modelId = column[String]("modelId") + def model = + foreignKey("fk_ActionTriggerMutationModelMutationType_Model_modelId", modelId, Tables.Models)(_.id) + + def fragment = column[String]("fragment") + + def * = + (id, actionId, modelId, mutationType, fragment) <> ((ActionTriggerMutationModel.apply _).tupled, ActionTriggerMutationModel.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationRelation.scala new file mode 100644 index 0000000000..09a26d74ab --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ActionTriggerMutationRelation.scala @@ -0,0 +1,41 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +import cool.graph.shared.models.ActionTriggerMutationRelationMutationType + +case class ActionTriggerMutationRelation( + id: String, + actionId: String, + relationId: String, + mutationType: ActionTriggerMutationRelationMutationType.Value, + fragment: String +) + +class ActionTriggerMutationRelationTable(tag: Tag) extends Table[ActionTriggerMutationRelation](tag, "ActionTriggerMutationRelation") { + + implicit val actionTriggerMutationRelationMutationTypeMapper = + MappedColumnType + .base[ActionTriggerMutationRelationMutationType.Value, String]( + e => e.toString, + s => ActionTriggerMutationRelationMutationType.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + + def mutationType = + column[ActionTriggerMutationRelationMutationType.Value]("mutationType") + + def actionId = column[String]("actionId") + def action = + foreignKey("fk_ActionTriggerMutationRelationMutationType_Action_actionId", actionId, Tables.Actions)(_.id) + + def relationId = column[String]("relationId") + def relation = + foreignKey("fk_ActionTriggerMutationRelationMutationType_Relation_relationId", relationId, Tables.Relations)(_.id) + + def fragment = column[String]("fragment") + + def * = + (id, actionId, relationId, mutationType, fragment) <> ((ActionTriggerMutationRelation.apply _).tupled, ActionTriggerMutationRelation.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/AlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/AlgoliaSyncQuery.scala new file mode 100644 index 0000000000..51adeb8219 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/AlgoliaSyncQuery.scala @@ -0,0 +1,31 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class AlgoliaSyncQuery( + id: String, + indexName: String, + query: String, + modelId: String, + searchProviderAlgoliaId: String, + isEnabled: Boolean +) + +class AlgoliaSyncQueryTable(tag: Tag) extends Table[AlgoliaSyncQuery](tag, "AlgoliaSyncQuery") { + + def id = column[String]("id", O.PrimaryKey) + def modelId = column[String]("modelId") + def searchProviderAlgoliaId = column[String]("searchProviderAlgoliaId") + def indexName = column[String]("indexName") + def query = column[String]("query") + def isEnabled = column[Boolean]("isEnabled") + + def model = + foreignKey("algoliasyncquery_modelid_foreign", modelId, Tables.Models)(_.id) + + def searchProviderAlgolia = + foreignKey("algoliasyncquery_searchprovideralgoliaid_foreign", searchProviderAlgoliaId, Tables.SearchProviderAlgolias)(_.id) + + def * = + (id, indexName, query, modelId, searchProviderAlgoliaId, isEnabled) <> ((AlgoliaSyncQuery.apply _).tupled, AlgoliaSyncQuery.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Client.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Client.scala new file mode 100644 index 0000000000..f19a0a8122 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Client.scala @@ -0,0 +1,45 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.CustomerSource.CustomerSource +import slick.jdbc.MySQLProfile.api._ +import cool.graph.shared.models.CustomerSource +import org.joda.time.DateTime +import com.github.tototoshi.slick.MySQLJodaSupport._ + +case class Client( + id: String, + auth0Id: Option[String], + isAuth0IdentityProviderEmail: Boolean, + name: String, + email: String, + password: String, + resetPasswordToken: Option[String], + source: CustomerSource.Value, + createdAt: DateTime, + updatedAt: DateTime +) + +class ClientTable(tag: Tag) extends Table[Client](tag, "Client") { + implicit val sourceMapper = ClientTable.sourceMapper + + def id = column[String]("id", O.PrimaryKey) + def auth0Id = column[Option[String]]("auth0Id") + def isAuth0IdentityProviderEmail = column[Boolean]("isAuth0IdentityProviderEmail") + def name = column[String]("name") + def email = column[String]("email") + def password = column[String]("password") + def resetPasswordToken = column[Option[String]]("resetPasswordSecret") + def source = column[CustomerSource]("source") + def createdAt = column[DateTime]("createdAt") + def updatedAt = column[DateTime]("updatedAt") + + def * = + (id, auth0Id, isAuth0IdentityProviderEmail, name, email, password, resetPasswordToken, source, createdAt, updatedAt) <> ((Client.apply _).tupled, Client.unapply) +} + +object ClientTable { + implicit val sourceMapper = MappedColumnType.base[CustomerSource, String]( + e => e.toString, + s => CustomerSource.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Enum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Enum.scala new file mode 100644 index 0000000000..9be875cbd4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Enum.scala @@ -0,0 +1,20 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class Enum( + id: String, + projectId: String, + name: String, + values: String +) + +class EnumTable(tag: Tag) extends Table[Enum](tag, "Enum") { + def id = column[String]("id", O.PrimaryKey) + def name = column[String]("name") + def values = column[String]("values") + def projectId = column[String]("projectId") + def project = foreignKey("enum_projectid_foreign", projectId, Tables.Projects)(_.id) + + def * = (id, projectId, name, values) <> ((Enum.apply _).tupled, Enum.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FeatureToggle.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FeatureToggle.scala new file mode 100644 index 0000000000..73624c1fab --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FeatureToggle.scala @@ -0,0 +1,21 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class FeatureToggle( + id: String, + projectId: String, + name: String, + isEnabled: Boolean +) + +class FeatureToggleTable(tag: Tag) extends Table[FeatureToggle](tag, "FeatureToggle") { + def id = column[String]("id", O.PrimaryKey) + def projectId = column[String]("projectId") + def name = column[String]("name") + def isEnabled = column[Boolean]("isEnabled") + + def project = foreignKey("featuretoggle_enum_projectid_foreign", projectId, Tables.Projects)(_.id) + + def * = (id, projectId, name, isEnabled) <> ((FeatureToggle.apply _).tupled, FeatureToggle.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Field.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Field.scala new file mode 100644 index 0000000000..2ddb3d046d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Field.scala @@ -0,0 +1,57 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.RelationSide +import slick.ast.BaseTypedType +import slick.jdbc.JdbcType +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.ForeignKeyQuery + +case class Field( + id: String, + name: String, + typeIdentifier: String, + description: Option[String], + isRequired: Boolean, + isList: Boolean, + isUnique: Boolean, + isSystem: Boolean, + isReadonly: Boolean, + defaultValue: Option[String], + relationId: Option[String], + relationSide: Option[RelationSide.Value], + modelId: String, + enumId: Option[String] +) + +class FieldTable(tag: Tag) extends Table[Field](tag, "Field") { + + implicit val relationSideMapper: JdbcType[RelationSide.Value] with BaseTypedType[RelationSide.Value] = + MappedColumnType.base[RelationSide.Value, String]( + e => e.toString, + s => RelationSide.withName(s) + ) + + def id: Rep[String] = column[String]("id", O.PrimaryKey) + def name: Rep[String] = column[String]("fieldName") // TODO adjust db naming + def typeIdentifier: Rep[String] = column[String]("typeIdentifier") + def description: Rep[Option[String]] = column[Option[String]]("description") + def isRequired: Rep[Boolean] = column[Boolean]("isRequired") + def isList: Rep[Boolean] = column[Boolean]("isList") + def isUnique: Rep[Boolean] = column[Boolean]("isUnique") + def isSystem: Rep[Boolean] = column[Boolean]("isSystem") + def isReadonly: Rep[Boolean] = column[Boolean]("isReadonly") + def defaultValue: Rep[Option[String]] = column[Option[String]]("defaultValue") + def relationSide: Rep[Option[_root_.cool.graph.shared.models.RelationSide.Value]] = column[Option[RelationSide.Value]]("relationSide") + + def modelId: Rep[String] = column[String]("modelId") + def model: ForeignKeyQuery[ModelTable, Model] = foreignKey("field_modelid_fieldname", modelId, Tables.Models)(_.id) + + def relationId: Rep[Option[String]] = column[Option[String]]("relationId") + def relation: ForeignKeyQuery[RelationTable, Relation] = foreignKey("field_relationid_foreign", relationId, Tables.Relations)(_.id.?) + + def enumId: Rep[Option[String]] = column[Option[String]]("enumId") + def enum: ForeignKeyQuery[EnumTable, Enum] = foreignKey("field_enumid_foreign", relationId, Tables.Enums)(_.id.?) + + def * = + (id, name, typeIdentifier, description, isRequired, isList, isUnique, isSystem, isReadonly, defaultValue, relationId, relationSide, modelId, enumId) <> ((Field.apply _).tupled, Field.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FieldConstraint.scala new file mode 100644 index 0000000000..ba21535569 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/FieldConstraint.scala @@ -0,0 +1,91 @@ +package cool.graph.system.database.tables +import cool.graph.shared.models.FieldConstraintType +import cool.graph.shared.models.FieldConstraintType.FieldConstraintType +import slick.jdbc.MySQLProfile.api._ + +case class FieldConstraint( + id: String, + constraintType: FieldConstraintType, + equalsNumber: Option[Double] = None, + oneOfNumber: String = "[]", + min: Option[Double] = None, + max: Option[Double] = None, + exclusiveMin: Option[Double] = None, + exclusiveMax: Option[Double] = None, + multipleOf: Option[Double] = None, + equalsString: Option[String] = None, + oneOfString: String = "[]", + minLength: Option[Int] = None, + maxLength: Option[Int] = None, + startsWith: Option[String] = None, + endsWith: Option[String] = None, + includes: Option[String] = None, + regex: Option[String] = None, + equalsBoolean: Option[Boolean] = None, + uniqueItems: Option[Boolean] = None, + minItems: Option[Int] = None, + maxItems: Option[Int] = None, + fieldId: String +) + +class FieldConstraintTable(tag: Tag) extends Table[FieldConstraint](tag, "FieldConstraint") { + + implicit val FieldConstraintTypeMapper = FieldConstraintTable.FieldConstraintTypeMapper + + def id = column[String]("id", O.PrimaryKey) + def constraintType = column[FieldConstraintType]("constraintType") + def equalsNumber = column[Option[Double]]("equalsNumber") + def oneOfNumber = column[String]("oneOfNumber") + def min = column[Option[Double]]("min") + def max = column[Option[Double]]("max") + def exclusiveMin = column[Option[Double]]("exclusiveMin") + def exclusiveMax = column[Option[Double]]("exclusiveMax") + def multipleOf = column[Option[Double]]("multipleOf") + def equalsString = column[Option[String]]("equalsString") + def oneOfString = column[String]("oneOfString") + def minLength = column[Option[Int]]("minLength") + def maxLength = column[Option[Int]]("maxLength") + def startsWith = column[Option[String]]("startsWith") + def endsWith = column[Option[String]]("endsWith") + def includes = column[Option[String]]("includes") + def regex = column[Option[String]]("regex") + def equalsBoolean = column[Option[Boolean]]("equalsBoolean") + def uniqueItems = column[Option[Boolean]]("uniqueItems") + def minItems = column[Option[Int]]("minItems") + def maxItems = column[Option[Int]]("maxItems") + + def fieldId = column[String]("fieldId") + def field = foreignKey("fieldConstraint_fieldid_foreign", fieldId, Tables.Fields)(_.id) + + def * = + (id, + constraintType, + equalsNumber, + oneOfNumber, + min, + max, + exclusiveMin, + exclusiveMax, + multipleOf, + equalsString, + oneOfString, + minLength, + maxLength, + startsWith, + endsWith, + includes, + regex, + equalsBoolean, + uniqueItems, + minItems, + maxItems, + fieldId) <> ((FieldConstraint.apply _).tupled, FieldConstraint.unapply) +} + +object FieldConstraintTable { + implicit val FieldConstraintTypeMapper = + MappedColumnType.base[FieldConstraintType, String]( + e => e.toString, + s => FieldConstraintType.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Function.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Function.scala new file mode 100644 index 0000000000..bb5cd54367 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Function.scala @@ -0,0 +1,97 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models.{FunctionBinding, FunctionType, RequestPipelineOperation} +import slick.ast.BaseTypedType +import slick.jdbc.JdbcType +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.ProvenShape + +case class Function( + id: String, + projectId: String, + name: String, + binding: FunctionBinding, + functionType: FunctionType, + isActive: Boolean, + requestPipelineMutationModelId: Option[String], + requestPipelineMutationOperation: Option[RequestPipelineOperation], + serversideSubscriptionQuery: Option[String], + serversideSubscriptionQueryFilePath: Option[String], + lambdaArn: Option[String], + webhookUrl: Option[String], + webhookHeaders: Option[String], + inlineCode: Option[String], + inlineCodeFilePath: Option[String], + auth0Id: Option[String], + schema: Option[String], + schemaFilePath: Option[String] +) + +class FunctionTable(tag: Tag) extends Table[Function](tag, "Function") { + + implicit val FunctionBindingMapper = FunctionTable.FunctionBindingMapper + implicit val FunctionTypeMapper = FunctionTable.FunctionTypeMapper + implicit val RequestPipelineMutationOperationMapper = FunctionTable.RequestPipelineMutationOperationMapper + + def id: Rep[String] = column[String]("id", O.PrimaryKey) + def projectId: Rep[String] = column[String]("projectId") + def name: Rep[String] = column[String]("name") + def binding: Rep[FunctionBinding] = column[FunctionBinding]("binding") + def functionType: Rep[FunctionType] = column[FunctionType]("type") + def isActive: Rep[Boolean] = column[Boolean]("isActive") + def requestPipelineMutationModelId: Rep[Option[String]] = column[Option[String]]("requestPipelineMutationModelId") + def requestPipelineMutationOperation: Rep[Option[RequestPipelineOperation]] = column[Option[RequestPipelineOperation]]("requestPipelineMutationOperation") + def serversideSubscriptionQuery: Rep[Option[String]] = column[Option[String]]("serversideSubscriptionQuery") + def serversideSubscriptionQueryFilePath: Rep[Option[String]] = column[Option[String]]("serversideSubscriptionQueryFilePath") + def lambdaArn: Rep[Option[String]] = column[Option[String]]("lambdaArn") + def webhookUrl: Rep[Option[String]] = column[Option[String]]("webhookUrl") + def webhookHeaders: Rep[Option[String]] = column[Option[String]]("webhookHeaders") + def inlineCode: Rep[Option[String]] = column[Option[String]]("inlineCode") + def inlineCodeFilePath: Rep[Option[String]] = column[Option[String]]("inlineCodeFilePath") + def auth0Id: Rep[Option[String]] = column[Option[String]]("auth0Id") + def schema: Rep[Option[String]] = column[Option[String]]("schema") + def schemaFilePath: Rep[Option[String]] = column[Option[String]]("schemaFilePath") + + def * : ProvenShape[Function] = + (id, + projectId, + name, + binding, + functionType, + isActive, + requestPipelineMutationModelId, + requestPipelineMutationOperation, + serversideSubscriptionQuery, + serversideSubscriptionQueryFilePath, + lambdaArn, + webhookUrl, + webhookHeaders, + inlineCode, + inlineCodeFilePath, + auth0Id, + schema, + schemaFilePath) <> ((Function.apply _).tupled, Function.unapply) +} + +object FunctionTable { + implicit val FunctionBindingMapper: JdbcType[FunctionBinding] with BaseTypedType[FunctionBinding] = + MappedColumnType.base[FunctionBinding, String]( + e => e.toString, + s => FunctionBinding.withName(s) + ) + + implicit val FunctionTypeMapper: JdbcType[FunctionType] with BaseTypedType[FunctionType] = + MappedColumnType.base[FunctionType, String]( + e => e.toString, + s => FunctionType.withName(s) + ) + + implicit val RequestPipelineMutationOperationMapper: JdbcType[RequestPipelineOperation] with BaseTypedType[RequestPipelineOperation] = + MappedColumnType.base[RequestPipelineOperation, String]( + e => e.toString, + s => RequestPipelineOperation.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Integration.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Integration.scala new file mode 100644 index 0000000000..deb802c627 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Integration.scala @@ -0,0 +1,38 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.{IntegrationName, IntegrationType} +import slick.jdbc.MySQLProfile.api._ + +case class Integration( + id: String, + isEnabled: Boolean, + integrationType: IntegrationType.Value, + name: IntegrationName.Value, + projectId: String +) + +object IntegrationTable { + implicit val integrationTypeMapper = + MappedColumnType.base[IntegrationType.Value, String]( + e => e.toString, + s => IntegrationType.withName(s) + ) + + implicit val integrationNameMapper = + MappedColumnType.base[IntegrationName.Value, String]( + e => e.toString, + s => IntegrationName.withName(s) + ) +} + +class IntegrationTable(tag: Tag) extends Table[Integration](tag, "Integration") { + import IntegrationTable._ + + def id = column[String]("id", O.PrimaryKey) + def isEnabled = column[Boolean]("isEnabled") + def integrationType = column[IntegrationType.Value]("integrationType") // TODO adjust db naming + def name = column[IntegrationName.Value]("name") + def projectId = column[String]("projectId") + + def * = (id, isEnabled, integrationType, name, projectId) <> ((Integration.apply _).tupled, Integration.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationAuth0.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationAuth0.scala new file mode 100644 index 0000000000..8b9932363c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationAuth0.scala @@ -0,0 +1,26 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class IntegrationAuth0( + id: String, + integrationId: String, + clientId: String, + clientSecret: String, + domain: String +) + +class IntegrationAuth0Table(tag: Tag) extends Table[IntegrationAuth0](tag, "AuthProviderAuth0") { + + def id = column[String]("id", O.PrimaryKey) + def clientId = column[String]("clientId") + def clientSecret = column[String]("clientSecret") + def domain = column[String]("domain") + + def integrationId = column[String]("integrationId") + def integration = + foreignKey("authproviderauth0_integrationid_foreign", integrationId, Tables.Integrations)(_.id) + + def * = + (id, integrationId, clientId, clientSecret, domain) <> ((IntegrationAuth0.apply _).tupled, IntegrationAuth0.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationDigits.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationDigits.scala new file mode 100644 index 0000000000..8b593c972a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/IntegrationDigits.scala @@ -0,0 +1,24 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class IntegrationDigits( + id: String, + integrationId: String, + consumerKey: String, + consumerSecret: String +) + +class IntegrationDigitsTable(tag: Tag) extends Table[IntegrationDigits](tag, "AuthProviderDigits") { + + def id = column[String]("id", O.PrimaryKey) + def consumerKey = column[String]("consumerKey") + def consumerSecret = column[String]("consumerSecret") + + def integrationId = column[String]("integrationId") + def integration = + foreignKey("authproviderdigits_integrationid_foreign", integrationId, Tables.Integrations)(_.id) + + def * = + (id, integrationId, consumerKey, consumerSecret) <> ((IntegrationDigits.apply _).tupled, IntegrationDigits.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Log.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Log.scala new file mode 100644 index 0000000000..6ea37fc407 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Log.scala @@ -0,0 +1,38 @@ +package cool.graph.system.database.tables + +import com.github.tototoshi.slick.MySQLJodaSupport._ +import cool.graph.shared.models.LogStatus +import org.joda.time.DateTime +import slick.jdbc.MySQLProfile.api._ + +case class Log( + id: String, + projectId: String, + functionId: String, + requestId: Option[String], + status: LogStatus.Value, + duration: Int, + timestamp: DateTime, + message: String +) + +class LogTable(tag: Tag) extends Table[Log](tag, "Log") { + + implicit val statusMapper = + MappedColumnType.base[LogStatus.Value, String]( + e => e.toString, + s => LogStatus.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + def projectId = column[String]("projectId") + def functionId = column[String]("functionId") + def requestId = column[Option[String]]("requestId") + def status = column[LogStatus.Value]("status") + def duration = column[Int]("duration") + def timestamp = column[DateTime]("timestamp") + def message = column[String]("message") + + def * = + (id, projectId, functionId, requestId, status, duration, timestamp, message) <> ((Log.apply _).tupled, Log.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MappedColumns.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MappedColumns.scala new file mode 100644 index 0000000000..f618cfc96d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MappedColumns.scala @@ -0,0 +1,18 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ +import spray.json.{JsArray, JsString} + +import scala.util.Success + +object MappedColumns { + import cool.graph.util.json.Json._ + + implicit val stringListMapper = MappedColumnType.base[Seq[String], String]( + list => JsArray(list.map(JsString.apply _).toVector).toString, + _.tryParseJson match { + case Success(json: JsArray) => json.elements.collect { case x: JsString => x.value } + case _ => Seq.empty + } + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Model.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Model.scala new file mode 100644 index 0000000000..3759275e0d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Model.scala @@ -0,0 +1,28 @@ +package cool.graph.system.database.tables + +import cool.graph.Types.Id +import slick.jdbc.MySQLProfile.api._ + +case class Model( + id: String, + name: String, + description: Option[String], + isSystem: Boolean, + projectId: String, + fieldPositions: Seq[Id] +) + +class ModelTable(tag: Tag) extends Table[Model](tag, "Model") { + implicit val stringListMapper = MappedColumns.stringListMapper + + def id = column[String]("id", O.PrimaryKey) + def name = column[String]("modelName") + def description = column[Option[String]]("description") + def isSystem = column[Boolean]("isSystem") + def fieldPositions = column[Seq[String]]("fieldPositions") + + def projectId = column[String]("projectId") + def project = foreignKey("model_projectid_modelname_uniq", projectId, Tables.Projects)(_.id) + + def * = (id, name, description, isSystem, projectId, fieldPositions) <> ((Model.apply _).tupled, Model.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermission.scala new file mode 100644 index 0000000000..df2133c4f7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermission.scala @@ -0,0 +1,60 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.ModelOperation.ModelOperation +import cool.graph.shared.models.{CustomRule, ModelOperation, UserType} +import cool.graph.shared.models.UserType.UserType +import slick.jdbc.MySQLProfile.api._ + +case class ModelPermission( + id: String, + modelId: String, + operation: ModelOperation.Value, + userType: UserType.Value, + rule: CustomRule.Value, + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleGraphQueryFilePath: Option[String] = None, + ruleWebhookUrl: Option[String], + applyToWholeModel: Boolean, + description: Option[String], + isActive: Boolean +) + +class ModelPermissionTable(tag: Tag) extends Table[ModelPermission](tag, "ModelPermission") { + + implicit val userTypesMapper = MappedColumnType.base[UserType, String]( + e => e.toString, + s => UserType.withName(s) + ) + + implicit val operationTypesMapper = MappedColumnType.base[ModelOperation, String]( + e => e.toString, + s => ModelOperation.withName(s) + ) + + implicit val customRuleTypesMapper = + MappedColumnType.base[CustomRule, String]( + e => e.toString, + s => CustomRule.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + def operation = column[ModelOperation]("operation") + def userType = column[UserType]("userType") + def rule = column[CustomRule]("rule") + def ruleName = column[Option[String]]("ruleName") + def ruleGraphQuery = column[Option[String]]("ruleGraphQuery") + def ruleGraphQueryFilePath = column[Option[String]]("ruleGraphQueryFilePath") + def ruleWebhookUrl = column[Option[String]]("ruleWebhookUrl") + def applyToWholeModel = column[Boolean]("applyToWholeModel") + def description = column[Option[String]]("description") + def isActive = column[Boolean]("isActive") + + def modelId = column[String]("modelId") + def model = + foreignKey("modelpermission_modelid_foreign", modelId, Tables.Models)(_.id) + + def * = + (id, modelId, operation, userType, rule, ruleName, ruleGraphQuery, ruleGraphQueryFilePath, ruleWebhookUrl, applyToWholeModel, description, isActive) <> ((ModelPermission.apply _).tupled, ModelPermission.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermissionField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermissionField.scala new file mode 100644 index 0000000000..dc1c8117ba --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ModelPermissionField.scala @@ -0,0 +1,25 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class ModelPermissionField( + id: String, + modelPermissionId: String, + fieldId: String +) + +class ModelPermissionFieldTable(tag: Tag) extends Table[ModelPermissionField](tag, "ModelPermissionField") { + + def id = column[String]("id", O.PrimaryKey) + + def modelPermissionId = column[String]("modelPermissionId") + def modelPermission = + foreignKey("modelpermissionfield_modelpermissionid_foreign", modelPermissionId, Tables.ModelPermissions)(_.id) + + def fieldId = column[String]("fieldId") + def field = + foreignKey("modelpermissionfield_fieldid_foreign", fieldId, Tables.Fields)(_.id) + + def * = + (id, modelPermissionId, fieldId) <> ((ModelPermissionField.apply _).tupled, ModelPermissionField.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLog.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLog.scala new file mode 100644 index 0000000000..6272f01004 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLog.scala @@ -0,0 +1,49 @@ +package cool.graph.system.database.tables + +import com.github.tototoshi.slick.MySQLJodaSupport._ +import cool.graph.shared.models.MutationLogStatus +import cool.graph.shared.models.MutationLogStatus.MutationLogStatus +import org.joda.time.DateTime +import slick.jdbc.MySQLProfile.api._ + +case class MutationLog( + id: String, + name: String, + status: MutationLogStatus.Value, + failedMutaction: Option[String], + input: String, + startedAt: DateTime, + finishedAt: Option[DateTime], + projectId: Option[String], + clientId: Option[String] +) + +class MutationLogTable(tag: Tag) extends Table[MutationLog](tag, "MutationLog") { + implicit val mutationLogStatusMapper = MutationLog.mutationLogStatusMapper + + def id = column[String]("id", O.PrimaryKey) + def name = column[String]("name") + def status = column[MutationLogStatus]("status") + def failedMutaction = column[Option[String]]("failedMutaction") + def input = column[String]("input") + def startedAt = column[DateTime]("startedAt") + def finishedAt = column[Option[DateTime]]("finishedAt") + + def projectId = column[Option[String]]("projectId") + def project = + foreignKey("mutationlog_projectid_foreign", projectId, Tables.Projects)(_.id.?) + + def clientId = column[Option[String]]("clientId") + def client = + foreignKey("mutationlog_clientid_foreign", clientId, Tables.Clients)(_.id.?) + + def * = + (id, name, status, failedMutaction, input, startedAt, finishedAt, projectId, clientId) <> ((MutationLog.apply _).tupled, MutationLog.unapply) +} + +object MutationLog { + implicit val mutationLogStatusMapper = MappedColumnType.base[MutationLogStatus, String]( + e => e.toString, + s => MutationLogStatus.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLogMutaction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLogMutaction.scala new file mode 100644 index 0000000000..92b073fced --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/MutationLogMutaction.scala @@ -0,0 +1,39 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ +import cool.graph.shared.models.{MutationLogStatus} +import org.joda.time.DateTime +import com.github.tototoshi.slick.MySQLJodaSupport._ +import cool.graph.shared.models.MutationLogStatus.MutationLogStatus + +case class MutationLogMutaction( + id: String, + name: String, + index: Int, + status: MutationLogStatus.Value, + input: String, + finishedAt: Option[DateTime], + error: Option[String], + rollbackError: Option[String], + mutationLogId: String +) + +class MutationLogMutactionTable(tag: Tag) extends Table[MutationLogMutaction](tag, "MutationLogMutaction") { + implicit val mutationLogStatusMapper = MutationLog.mutationLogStatusMapper + + def id = column[String]("id", O.PrimaryKey) + def name = column[String]("name") + def index = column[Int]("index") + def status = column[MutationLogStatus.Value]("status") + def input = column[String]("input") + def finishedAt = column[Option[DateTime]]("finishedAt") + def error = column[Option[String]]("error") + def rollbackError = column[Option[String]]("rollbackError") + + def mutationLogId = column[String]("mutationLogId") + def mutationLog = + foreignKey("mutationlogmutaction_mutationlogid_foreign", mutationLogId, Tables.MutationLogs)(_.id) + + def * = + (id, name, index, status, input, finishedAt, error, rollbackError, mutationLogId) <> ((MutationLogMutaction.apply _).tupled, MutationLogMutaction.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/PackageDefinition.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/PackageDefinition.scala new file mode 100644 index 0000000000..b28055ba8f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/PackageDefinition.scala @@ -0,0 +1,25 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class PackageDefinition( + id: String, + name: String, + projectId: String, + definition: String, + formatVersion: Int +) + +class PackageDefinitionTable(tag: Tag) extends Table[PackageDefinition](tag, "PackageDefinition") { + def id = column[String]("id", O.PrimaryKey) + def name = column[String]("name") + def definition = column[String]("definition") + def formatVersion = column[Int]("formatVersion") + + def projectId = column[String]("projectId") + def project = + foreignKey("packagedefinition_projectid_foreign", projectId, Tables.Projects)(_.id) + + def * = + (id, name, projectId, definition, formatVersion) <> ((PackageDefinition.apply _).tupled, PackageDefinition.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Permission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Permission.scala new file mode 100644 index 0000000000..aa13478181 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Permission.scala @@ -0,0 +1,41 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.UserType +import cool.graph.shared.models.UserType.UserType +import slick.jdbc.MySQLProfile.api._ + +case class Permission( + id: String, + description: Option[String], + allowRead: Boolean, + allowCreate: Boolean, + allowUpdate: Boolean, + allowDelete: Boolean, + userType: UserType.Value, + userPath: Option[String], + fieldId: String +) + +class PermissionTable(tag: Tag) extends Table[Permission](tag, "Permission") { + + implicit val userTypesMapper = MappedColumnType.base[UserType, String]( + e => e.toString, + s => UserType.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + def description = column[Option[String]]("comment") // TODO adjust db naming + def allowRead = column[Boolean]("allowRead") + def allowCreate = column[Boolean]("allowCreate") + def allowUpdate = column[Boolean]("allowUpdate") + def allowDelete = column[Boolean]("allowDelete") + def userType = column[UserType]("userType") + def userPath = column[Option[String]]("userPath") + + def fieldId = column[String]("fieldId") + def field = + foreignKey("permission_fieldid_foreign", fieldId, Tables.Fields)(_.id) + + def * = + (id, description, allowRead, allowCreate, allowUpdate, allowDelete, userType, userPath, fieldId) <> ((Permission.apply _).tupled, Permission.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Project.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Project.scala new file mode 100644 index 0000000000..56db4326b7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Project.scala @@ -0,0 +1,53 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.Region +import cool.graph.shared.models.Region.Region +import slick.jdbc.MySQLProfile.api._ + +case class Project( + id: String, + alias: Option[String], + name: String, + revision: Int, + webhookUrl: Option[String], + clientId: String, + allowQueries: Boolean, + allowMutations: Boolean, + typePositions: Seq[String], + projectDatabaseId: String, + isEjected: Boolean, + hasGlobalStarPermission: Boolean +) + +class ProjectTable(tag: Tag) extends Table[Project](tag, "Project") { + implicit val RegionMapper = ProjectTable.regionMapper + implicit val stringListMapper = MappedColumns.stringListMapper + + def id = column[String]("id", O.PrimaryKey) + def alias = column[Option[String]]("alias") + def name = column[String]("name") + def revision = column[Int]("revision") + def webhookUrl = column[Option[String]]("webhookUrl") + def allowQueries = column[Boolean]("allowQueries") + def allowMutations = column[Boolean]("allowMutations") + def typePositions = column[Seq[String]]("typePositions") + def isEjected = column[Boolean]("isEjected") + def hasGlobalStarPermission = column[Boolean]("hasGlobalStarPermission") + + def clientId = column[String]("clientId") + def client = foreignKey("project_clientid_foreign", clientId, Tables.Clients)(_.id) + + def projectDatabaseId = column[String]("projectDatabaseId") + def projectDatabase = foreignKey("project_databaseid_foreign", projectDatabaseId, Tables.ProjectDatabases)(_.id) + + def * = + (id, alias, name, revision, webhookUrl, clientId, allowQueries, allowMutations, typePositions, projectDatabaseId, isEjected, hasGlobalStarPermission) <> + ((Project.apply _).tupled, Project.unapply) +} + +object ProjectTable { + implicit val regionMapper = MappedColumnType.base[Region, String]( + e => e.toString, + s => Region.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ProjectDatabase.scala new file mode 100644 index 0000000000..094d931538 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/ProjectDatabase.scala @@ -0,0 +1,17 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.Region.Region +import slick.jdbc.MySQLProfile.api._ + +case class ProjectDatabase(id: String, region: Region, name: String, isDefaultForRegion: Boolean) + +class ProjectDatabaseTable(tag: Tag) extends Table[ProjectDatabase](tag, "ProjectDatabase") { + implicit val RegionMapper = ProjectTable.regionMapper + + def id = column[String]("id", O.PrimaryKey) + def region = column[Region]("region") + def name = column[String]("name") + def isDefaultForRegion = column[Boolean]("isDefaultForRegion") + + def * = (id, region, name, isDefaultForRegion) <> ((ProjectDatabase.apply _).tupled, ProjectDatabase.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Relation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Relation.scala new file mode 100644 index 0000000000..ec42e27e88 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Relation.scala @@ -0,0 +1,35 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class Relation( + id: String, + projectId: String, + name: String, + description: Option[String], + modelAId: String, + modelBId: String +) + +class RelationTable(tag: Tag) extends Table[Relation](tag, "Relation") { + def id = column[String]("id", O.PrimaryKey) + + def projectId = column[String]("projectId") + def project = + foreignKey("relation_projectid_foreign", projectId, Tables.Projects)(_.id) + + def name = column[String]("name") + + def description = column[Option[String]]("description") + + def modelAId = column[String]("modelAId") + def modelA = + foreignKey("relation_modelaid_foreign", modelAId, Tables.Models)(_.id) + + def modelBId = column[String]("modelBId") + def modelB = + foreignKey("relation_modelbid_foreign", modelBId, Tables.Models)(_.id) + + def * = + (id, projectId, name, description, modelAId, modelBId) <> ((Relation.apply _).tupled, Relation.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationFieldMirror.scala new file mode 100644 index 0000000000..d7c3681470 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationFieldMirror.scala @@ -0,0 +1,25 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class RelationFieldMirror( + id: String, + relationId: String, + fieldId: String +) + +class RelationFieldMirrorTable(tag: Tag) extends Table[RelationFieldMirror](tag, "RelationFieldMirror") { + + def id = column[String]("id", O.PrimaryKey) + + def relationId = column[String]("relationId") + def relation = + foreignKey("relationfieldmirror_relationid_foreign", relationId, Tables.Relations)(_.id) + + def fieldId = column[String]("fieldId") + def field = + foreignKey("relationfieldmirror_fieldid_foreign", fieldId, Tables.Fields)(_.id) + + def * = + (id, relationId, fieldId) <> ((RelationFieldMirror.apply _).tupled, RelationFieldMirror.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationPermission.scala new file mode 100644 index 0000000000..dc23238452 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelationPermission.scala @@ -0,0 +1,54 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.UserType.UserType +import cool.graph.shared.models.{CustomRule, UserType} +import slick.jdbc.MySQLProfile.api._ + +case class RelationPermission( + id: String, + relationId: String, + connect: Boolean, + disconnect: Boolean, + userType: UserType.Value, + rule: CustomRule.Value, + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleGraphQueryFilePath: Option[String], + ruleWebhookUrl: Option[String], + description: Option[String], + isActive: Boolean +) + +class RelationPermissionTable(tag: Tag) extends Table[RelationPermission](tag, "RelationPermission") { + + implicit val userTypesMapper = MappedColumnType.base[UserType, String]( + e => e.toString, + s => UserType.withName(s) + ) + + implicit val customRuleTypesMapper = + MappedColumnType.base[CustomRule, String]( + e => e.toString, + s => CustomRule.withName(s) + ) + + def id = column[String]("id", O.PrimaryKey) + def connect = column[Boolean]("connect") + def disconnect = column[Boolean]("disconnect") + def userType = column[UserType]("userType") + def rule = column[CustomRule]("rule") + def ruleName = column[Option[String]]("ruleName") + def ruleGraphQuery = column[Option[String]]("ruleGraphQuery") + def ruleGraphQueryFilePath = column[Option[String]]("ruleGraphQueryFilePath") + def ruleWebhookUrl = column[Option[String]]("ruleWebhookUrl") + def description = column[Option[String]]("description") + def isActive = column[Boolean]("isActive") + + def relationId = column[String]("relationId") + def relation = + foreignKey("relationpermission_relationid_foreign", relationId, Tables.Relations)(_.id) + + def * = + (id, relationId, connect, disconnect, userType, rule, ruleName, ruleGraphQuery, ruleGraphQueryFilePath, ruleWebhookUrl, description, isActive) <> ((RelationPermission.apply _).tupled, RelationPermission.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelayId.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelayId.scala new file mode 100644 index 0000000000..ffeb88dd70 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RelayId.scala @@ -0,0 +1,13 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class RelayId(id: String, typeName: String) + +class RelayIdTable(tag: Tag) extends Table[RelayId](tag, "RelayId") { + + def id = column[String]("id", O.PrimaryKey) + def typeName = column[String]("typeName") + + def * = (id, typeName) <> ((RelayId.apply _).tupled, RelayId.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RootToken.scala new file mode 100644 index 0000000000..9ae937db4e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/RootToken.scala @@ -0,0 +1,27 @@ +package cool.graph.system.database.tables + +import org.joda.time.DateTime +import slick.jdbc.MySQLProfile.api._ +import com.github.tototoshi.slick.MySQLJodaSupport._ + +case class RootToken( + id: String, + projectId: String, + token: String, + name: String, + created: DateTime +) + +class RootTokenTable(tag: Tag) extends Table[RootToken](tag, "PermanentAuthToken") { + + def id = column[String]("id", O.PrimaryKey) + def token = column[String]("token") + def name = column[String]("name") + def created = column[DateTime]("created") + def projectId = column[String]("projectId") + def project = + foreignKey("systemtoken_projectid_foreign", projectId, Tables.Projects)(_.id) + + def * = + (id, projectId, token, name, created) <> ((RootToken.apply _).tupled, RootToken.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/SearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/SearchProviderAlgolia.scala new file mode 100644 index 0000000000..04fe8b3966 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/SearchProviderAlgolia.scala @@ -0,0 +1,24 @@ +package cool.graph.system.database.tables + +import slick.jdbc.MySQLProfile.api._ + +case class SearchProviderAlgolia( + id: String, + integrationId: String, + applicationId: String, + apiKey: String +) + +class SearchProviderAlgoliaTable(tag: Tag) extends Table[SearchProviderAlgolia](tag, "SearchProviderAlgolia") { + + def id = column[String]("id", O.PrimaryKey) + def applicationId = column[String]("applicationId") + def apiKey = column[String]("apiKey") + + def integrationId = column[String]("integrationId") + def integration = + foreignKey("searchprovideralgolia_integrationid_foreign", integrationId, Tables.Integrations)(_.id) + + def * = + (id, integrationId, applicationId, apiKey) <> ((SearchProviderAlgolia.apply _).tupled, SearchProviderAlgolia.unapply) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Seat.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Seat.scala new file mode 100644 index 0000000000..9de99f7208 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Seat.scala @@ -0,0 +1,42 @@ +package cool.graph.system.database.tables + +import cool.graph.shared.models.SeatStatus +import cool.graph.shared.models.SeatStatus.SeatStatus +import slick.jdbc.MySQLProfile.api._ + +case class Seat( + id: String, + status: SeatStatus, + email: String, + projectId: String, + clientId: Option[String] +) + +class SeatTable(tag: Tag) extends Table[Seat](tag, "Seat") { + + implicit val mapper = SeatTable.SeatStatusMapper + + def id = column[String]("id", O.PrimaryKey) + def status = column[SeatStatus]("status") + + def email = column[String]("email") + + def projectId = column[String]("projectId") + def project = + foreignKey("seat_projectid_foreign", projectId, Tables.Projects)(_.id) + + def clientId = column[Option[String]]("clientId") + def client = + foreignKey("seat_clientid_foreign", clientId, Tables.Clients)(_.id.?) + + def * = + (id, status, email, projectId, clientId) <> ((Seat.apply _).tupled, Seat.unapply) +} + +object SeatTable { + implicit val SeatStatusMapper = + MappedColumnType.base[SeatStatus.Value, String]( + e => e.toString, + s => SeatStatus.withName(s) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Tables.scala b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Tables.scala new file mode 100644 index 0000000000..f7a93694c3 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/database/tables/Tables.scala @@ -0,0 +1,37 @@ +package cool.graph.system.database.tables + +import slick.lifted.TableQuery + +object Tables { + val Clients = TableQuery[ClientTable] + val Projects = TableQuery[ProjectTable] + val Models = TableQuery[ModelTable] + val Fields = TableQuery[FieldTable] + val Enums = TableQuery[EnumTable] + val FeatureToggles = TableQuery[FeatureToggleTable] + val Functions = TableQuery[FunctionTable] + val ProjectDatabases = TableQuery[ProjectDatabaseTable] + val Permissions = TableQuery[PermissionTable] + val ModelPermissions = TableQuery[ModelPermissionTable] + val ModelPermissionFields = TableQuery[ModelPermissionFieldTable] + val RelationPermissions = TableQuery[RelationPermissionTable] + val Relations = TableQuery[RelationTable] + val RelationFieldMirrors = TableQuery[RelationFieldMirrorTable] + val RelayIds = TableQuery[RelayIdTable] + val RootTokens = TableQuery[RootTokenTable] + val Actions = TableQuery[ActionTable] + val ActionHandlerWebhooks = TableQuery[ActionHandlerWebhookTable] + val ActionTriggerMutationModels = TableQuery[ActionTriggerMutationModelTable] + val ActionTriggerMutationRelations = TableQuery[ActionTriggerMutationRelationTable] + val IntegrationDigits = TableQuery[IntegrationDigitsTable] + val IntegrationAuth0s = TableQuery[IntegrationAuth0Table] + val SearchProviderAlgolias = TableQuery[SearchProviderAlgoliaTable] + val AlgoliaSyncQueries = TableQuery[AlgoliaSyncQueryTable] + val Integrations = TableQuery[IntegrationTable] + val MutationLogs = TableQuery[MutationLogTable] + val MutationLogMutactions = TableQuery[MutationLogMutactionTable] + val Seats = TableQuery[SeatTable] + val PackageDefinitions = TableQuery[PackageDefinitionTable] + val Logs = TableQuery[LogTable] + val FieldConstraints = TableQuery[FieldConstraintTable] +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/AlgoliaKeyChecker.scala b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/AlgoliaKeyChecker.scala new file mode 100644 index 0000000000..abd6bcddb5 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/AlgoliaKeyChecker.scala @@ -0,0 +1,69 @@ +package cool.graph.system.externalServices + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.RawHeader +import akka.stream.{ActorMaterializer, StreamTcpException} +import scaldi.{Injectable, Injector} +import spray.json.DefaultJsonProtocol + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +trait AlgoliaKeyChecker { + def verifyAlgoliaCredentialValidity(appId: String, apiKey: String): Future[Boolean] +} + +class AlgoliaKeyCheckerMock() extends AlgoliaKeyChecker { + var returnValue: Boolean = true + override def verifyAlgoliaCredentialValidity(appId: String, apiKey: String): Future[Boolean] = { + Future.successful(returnValue) + } + + def setReturnValueToFalse() = { + returnValue = false + } +} + +class AlgoliaKeyCheckerImplementation(implicit inj: Injector) extends AlgoliaKeyChecker with Injectable { + implicit val system = inject[ActorSystem](identified by "actorSystem") + implicit val materializer = + inject[ActorMaterializer](identified by "actorMaterializer") + + case class AlgoliaResponse(acl: String) + object AlgoliaJsonProtocol extends DefaultJsonProtocol { + implicit val AlgoliaFormat = jsonFormat(AlgoliaResponse, "acl") + } + + // For documentation see: https://www.algolia.com/doc/rest-api/search#get-the-rights-of-a-global-api-key + override def verifyAlgoliaCredentialValidity(appId: String, apiKey: String): Future[Boolean] = { + + if (appId.isEmpty || apiKey.isEmpty) { + Future.successful(false) + } else { + + val algoliaUri = Uri(s"https://${appId}.algolia.net/1/keys/${apiKey}") + val algoliaAppIdHeader = RawHeader("X-Algolia-Application-Id", appId) + val algoliaApiKeyHeader = RawHeader("X-Algolia-API-Key", apiKey) + val algoliaHeaders = List(algoliaAppIdHeader, algoliaApiKeyHeader) + + val request = HttpRequest(method = HttpMethods.GET, uri = algoliaUri, headers = algoliaHeaders) + val response = Http().singleRequest(request) + response.map { + case HttpResponse(StatusCodes.OK, _, entity, _) => + val responseString = entity.toString() + val requiredPermissionsPresent = responseString.contains("addObject") && responseString + .contains("deleteObject") + + requiredPermissionsPresent + + case _ => + false + } recover { + // https://[INVALID].algolia.net/1/keys/[VALID] times out, so we simply report a timeout as a wrong appId + case _: StreamTcpException => false + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0.scala b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0.scala new file mode 100644 index 0000000000..631b449d26 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0.scala @@ -0,0 +1,55 @@ +package cool.graph.system.externalServices + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.OAuth2BearerToken +import akka.stream.ActorMaterializer +import com.typesafe.config.Config +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class Auth0ApiUpdateValues(email: Option[String]) + +trait Auth0Api { + def updateClient(auth0Id: String, values: Auth0ApiUpdateValues): Future[Boolean] +} + +class Auth0ApiMock extends Auth0Api { + var lastUpdate: Option[(String, Auth0ApiUpdateValues)] = None + + override def updateClient(auth0Id: String, values: Auth0ApiUpdateValues): Future[Boolean] = { + + lastUpdate = Some((auth0Id, values)) + + Future.successful(true) + } +} + +class Auth0ApiImplementation(implicit inj: Injector) extends Auth0Api with Injectable { + + override def updateClient(auth0Id: String, values: Auth0ApiUpdateValues): Future[Boolean] = { + + implicit val system = inject[ActorSystem](identified by "actorSystem") + implicit val materializer = + inject[ActorMaterializer](identified by "actorMaterializer") + + val config = inject[Config](identified by "config") + val auth0Domain = config.getString("auth0Domain") + val auth0ApiToken = config.getString("auth0ApiToken") + + Http() + .singleRequest( + HttpRequest( + uri = s"https://${auth0Domain}/api/v2/users/${auth0Id}", + method = HttpMethods.PATCH, + entity = HttpEntity(contentType = ContentTypes.`application/json`, string = s"""{"email":"${values.email.get}"}""") + ).addCredentials(OAuth2BearerToken(auth0ApiToken))) + .map(_.status.intValue match { + case 200 => true + case _ => false + }) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0Extend.scala b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0Extend.scala new file mode 100644 index 0000000000..3c38dc765b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/externalServices/Auth0Extend.scala @@ -0,0 +1,73 @@ +package cool.graph.system.externalServices + +import akka.http.scaladsl.model._ +import com.twitter.io.Buf +import cool.graph.shared.models.Client +import cool.graph.system.authorization.SystemAuth2 +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future + +case class Auth0FunctionData(url: String, auth0Id: String) + +trait Auth0Extend { + def createAuth0Function(client: Client, code: String): Future[Auth0FunctionData] +} + +class Auth0ExtendMock extends Auth0Extend { + var lastCode: Option[String] = None + var shouldFail: Boolean = false + + override def createAuth0Function(client: Client, code: String): Future[Auth0FunctionData] = { + lastCode = Some(code) + + if (shouldFail) { + sys.error("some error deploying Auth0 Extend function") + } + + Future.successful(Auth0FunctionData("http://some.url", auth0Id = "some-id")) + } +} + +class Auth0ExtendImplementation(implicit inj: Injector) extends Auth0Extend with Injectable { + + override def createAuth0Function(client: Client, code: String): Future[Auth0FunctionData] = { + + import com.twitter.conversions.time._ + import com.twitter.finagle + import cool.graph.twitterFutures.TwitterFutureImplicits._ + import spray.json.DefaultJsonProtocol._ + import spray.json._ + + import scala.concurrent.ExecutionContext.Implicits.global + + // todo: inject this + val extendEndpoint = "https://d0b5iw4041.execute-api.eu-west-1.amazonaws.com/prod/create/" + val clientToken = SystemAuth2().generatePlatformTokenWithExpiration(clientId = client.id) + + def toDest(s: String) = s"${Uri(s).authority.host}:${Uri(s).effectivePort}" + val extendService = + finagle.Http.client.withTls(Uri(extendEndpoint).authority.host.address()).withRequestTimeout(15.seconds).newService(toDest(extendEndpoint)) + + val body = Map("code" -> code, "authToken" -> clientToken).toJson.prettyPrint + + val request = com.twitter.finagle.http + .RequestBuilder() + .url(extendEndpoint) + .buildPost(Buf.Utf8(body)) + request.setContentTypeJson() + + for { + json <- extendService(request) + .map(res => { + res.getContentString().parseJson + }) + .asScala + } yield { + Auth0FunctionData( + url = json.asJsObject.fields("url").convertTo[String], + auth0Id = json.asJsObject.fields("fn").convertTo[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/metrics/SystemMetrics.scala b/server/backend-api-system/src/main/scala/cool/graph/system/metrics/SystemMetrics.scala new file mode 100644 index 0000000000..6a0aee9350 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/metrics/SystemMetrics.scala @@ -0,0 +1,22 @@ +package cool.graph.system.metrics + +import cool.graph.metrics.{CustomTag, MetricsManager} +import cool.graph.profiling.MemoryProfiler + +object SystemMetrics extends MetricsManager { + // this is intentionally empty. Since we don't define metrics here, we need to load the object once so the profiler kicks in. + // This way it does not look so ugly on the caller side. + def init(): Unit = {} + + // CamelCase the service name read from env + override def serviceName = + sys.env + .getOrElse("SERVICE_NAME", "SystemShared") + .split("-") + .map { x => + x.head.toUpper + x.tail + } + .mkString + + MemoryProfiler.schedule(this) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/Diff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/Diff.scala new file mode 100644 index 0000000000..4f40b5d775 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/Diff.scala @@ -0,0 +1,16 @@ +package cool.graph.system.migration + +object Diff { + + def diff[T](current: T, updated: T): Option[T] = { + diffOpt(Some(current), Some(updated)) + } + + def diffOpt[T](current: Option[T], updated: Option[T]): Option[T] = { + if (current == updated) { + None + } else { + updated + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigrator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigrator.scala new file mode 100644 index 0000000000..0482457051 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigrator.scala @@ -0,0 +1,477 @@ +package cool.graph.system.migration + +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.errors.SystemErrors.ProjectPushError +import cool.graph.shared.functions.{DeployFailure, DeployResponse, ExternalFile, FunctionEnvironment} +import cool.graph.shared.errors.SystemErrors.{ProjectPushError, SchemaError} +import cool.graph.shared.errors.UserInputErrors.SchemaExtensionParseError +import cool.graph.shared.functions._ +import cool.graph.shared.functions.{DeployFailure, DeployResponse, ExternalFile, FunctionEnvironment, _} +import cool.graph.shared.models._ +import cool.graph.system.externalServices.{Auth0Extend, Auth0FunctionData} +import cool.graph.system.migration.ProjectConfig.Ast.Permission +import cool.graph.system.migration.ProjectConfig.{Ast, AstPermissionWithAllInfos, FunctionWithFiles} +import cool.graph.system.migration.functions.FunctionDiff +import cool.graph.system.migration.permissions.PermissionDiff +import cool.graph.system.migration.permissions.QueryPermissionHelper._ +import cool.graph.system.migration.rootTokens.RootTokenDiff +import cool.graph.system.mutations._ +import scaldi.{Injectable, Injector} +import spray.json.{JsObject, JsString} + +import scala.collection.Seq +import scala.concurrent.Await +import scala.concurrent.duration.Duration + +object ModuleMigrator { + def apply(client: Client, + project: Project, + parsedModules: Seq[ProjectConfig.Ast.Module], + files: Map[String, String], + externalFiles: Option[Map[String, ExternalFile]], + afterSchemaMigration: Boolean = false, + isDryRun: Boolean)(implicit inj: Injector): ModuleMigrator = { + val oldModule = ProjectConfig.moduleFromProject(project) + + val schemas: Seq[String] = parsedModules.map(module => module.types.map(x => files.getOrElse(x, sys.error("path to types not correct"))).getOrElse("")) // todo: this is ugly + val combinedSchema = schemas.mkString(" ") + + val newPermissions: Vector[Permission] = parsedModules.flatMap(_.permissions).toVector + + val newPermissionsWithQueryFile: Vector[AstPermissionWithAllInfos] = newPermissions.map(permission => { + astPermissionWithAllInfosFromAstPermission(permission, files) + }) + + val newFunctionsMapList: Seq[Map[String, ProjectConfig.Ast.Function]] = parsedModules.map(_.functions) + val combinedFunctionsList: Map[String, ProjectConfig.Ast.Function] = + newFunctionsMapList.foldLeft(Map.empty: Map[String, ProjectConfig.Ast.Function])(_ ++ _) + + val newRootTokens: Vector[String] = parsedModules.flatMap(_.rootTokens).toVector + + val functionDiff: FunctionDiff = FunctionDiff(project, oldModule.module, combinedFunctionsList, files) + val permissionDiff: PermissionDiff = PermissionDiff(project, newPermissionsWithQueryFile, files, afterSchemaMigration) + val rootTokenDiff: RootTokenDiff = RootTokenDiff(project, newRootTokens) + + ModuleMigrator(functionDiff, permissionDiff, rootTokenDiff, client, project, files, externalFiles, combinedSchema, isDryRun) + } +} + +case class ModuleMigrator(functionDiff: FunctionDiff, + permissionDiff: PermissionDiff, + rootTokenDiff: RootTokenDiff, + client: Client, + project: Project, + files: Map[String, String], + externalFiles: Option[Map[String, ExternalFile]], + schemaContent: String, + isDryRun: Boolean)(implicit inj: Injector) + extends Injectable { + + val functionEnvironment: FunctionEnvironment = inject[FunctionEnvironment] + + def determineActionsForRemove: RemoveModuleActions = { + RemoveModuleActions( + subscriptionFunctionsToRemove = subscriptionFunctionsToRemove, + schemaExtensionFunctionsToRemove = schemaExtensionFunctionsToRemove, + operationFunctionsToRemove = operationFunctionsToRemove, + modelPermissionsToRemove = modelPermissionsToRemove, + relationPermissionsToRemove = relationPermissionsToRemove, + rootTokensToRemove = rootTokensToRemove + ) + } + + def determineActionsForAdd: AddModuleActions = { + AddModuleActions( + subscriptionFunctionsToAdd = subscriptionFunctionsToAdd, + schemaExtensionFunctionsToAdd = schemaExtensionFunctionsToAdd, + operationFunctionsToAdd = operationFunctionsToAdd, + modelPermissionsToAdd = modelPermissionsToAdd, + relationPermissionsToAdd = relationPermissionsToAdd, + rootTokensToCreate = rootTokensToCreate + ) + } + + def determineActionsForUpdate: UpdateModuleActions = { + UpdateModuleActions( + subscriptionFunctionsToUpdate = subscriptionFunctionsToUpdate, + schemaExtensionFunctionsToUpdate = schemaExtensionFunctionsToUpdate, + operationFunctionsToUpdate = operationFunctionsToUpdate + ) + } + + val auth0Extend: Auth0Extend = inject[Auth0Extend] + + private def getFileContent(filePath: String) = files.getOrElse(filePath, sys.error(s"File with path '$filePath' does not exist")) + + lazy val subscriptionFunctionsToAdd: Vector[AddServerSideSubscriptionFunctionAction] = + functionDiff.addedSubscriptionFunctions.map { + case FunctionWithFiles(name, function, fc) => + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val input = AddServerSideSubscriptionFunctionInput( + clientMutationId = None, + projectId = project.id, + name = name, + isActive = true, + query = getFileContent(function.query.getOrElse(sys.error("query file path expected"))), + functionType = function.handlerType, + url = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id), + codeFilePath = fc.codeContainer.map(_.path), + queryFilePath = fc.queryContainer.map(_.path) + ) + AddServerSideSubscriptionFunctionAction(input) + } + + lazy val subscriptionFunctionsToUpdate: Vector[UpdateServerSideSubscriptionFunctionAction] = + functionDiff.updatedSubscriptionFunctions.map { + case FunctionWithFiles(name, function, fc) => + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val functionId = project.getFunctionByName_!(name).id + + val input = UpdateServerSideSubscriptionFunctionInput( + clientMutationId = None, + functionId = functionId, + name = Some(name), + isActive = Some(true), + query = Some(getFileContent(function.query.getOrElse(sys.error("query file path expected")))), + functionType = Some(function.handlerType), + webhookUrl = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id) + ) + UpdateServerSideSubscriptionFunctionAction(input) + } + + lazy val schemaExtensionFunctionsToAdd: Vector[AddSchemaExtensionFunctionAction] = + functionDiff.addedSchemaExtensionFunctions.map { + case FunctionWithFiles(name, function, fc) => + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val input = AddSchemaExtensionFunctionInput( + clientMutationId = None, + projectId = project.id, + name = name, + isActive = true, + schema = getFileContent(function.schema.getOrElse(sys.error("schema file path expected"))), + functionType = function.handlerType, + url = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id), + codeFilePath = fc.codeContainer.map(_.path), + schemaFilePath = fc.schemaContainer.map(_.path) + ) + + AddSchemaExtensionFunctionAction(input) + } + + lazy val schemaExtensionFunctionsToUpdate: Vector[UpdateSchemaExtensionFunctionAction] = + functionDiff.updatedSchemaExtensionFunctions.map { + case FunctionWithFiles(name, function, fc) => + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val functionId = project.getFunctionByName_!(name).id + + val input = UpdateSchemaExtensionFunctionInput( + clientMutationId = None, + functionId = functionId, + name = Some(name), + isActive = Some(true), + schema = Some(getFileContent(function.schema.getOrElse(sys.error("schema file path expected")))), + functionType = Some(function.handlerType), + webhookUrl = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id), + codeFilePath = fc.codeContainer.map(_.path), + schemaFilePath = fc.schemaContainer.map(_.path) + ) + + UpdateSchemaExtensionFunctionAction(input) + } + + lazy val operationFunctionsToAdd: Vector[AddOperationFunctionAction] = + functionDiff.addedRequestPipelineFunctions.map { + case FunctionWithFiles(name, function, fc) => + val x = function.operation.getOrElse(sys.error("operation is required for subscription function")).split("\\.").toVector + val modelName = x(0) + val operation = x(1) + + val rpOperation = operation match { + case "create" => RequestPipelineOperation.CREATE + case "delete" => RequestPipelineOperation.DELETE + case "update" => RequestPipelineOperation.UPDATE + case invalid => throw SystemErrors.InvalidRequestPipelineOperation(invalid) + } + + val modelId = project.getModelByName(modelName) match { + case Some(existingModel) => existingModel.id + case None => sys.error(s"Error in ${function.`type`} function '$name': No model with name '$modelName' found. Please supply a valid model.") + } + + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val input = AddRequestPipelineMutationFunctionInput( + clientMutationId = None, + projectId = project.id, + name = name, + isActive = true, + functionType = function.handlerType, + binding = function.binding, + modelId = modelId, + operation = rpOperation, + webhookUrl = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id), + codeFilePath = fc.codeContainer.map(_.path) + ) + + AddOperationFunctionAction(input) + } + + lazy val operationFunctionsToUpdate: Vector[UpdateOperationFunctionAction] = + functionDiff.updatedRequestPipelineFunctions.map { + case FunctionWithFiles(name, function, fc) => + val x = function.operation.getOrElse(sys.error("operation is required for subscription function")).split("\\.").toVector + val modelName = x(0) + val operation = x(1) + val rpOperation = operation match { + case "create" => RequestPipelineOperation.CREATE + case "delete" => RequestPipelineOperation.DELETE + case "update" => RequestPipelineOperation.UPDATE + case invalid => throw SystemErrors.InvalidRequestPipelineOperation(invalid) + } + + val modelId = project.getModelByName(modelName) match { + case Some(existingModel) => existingModel.id + case None => sys.error(s"Error in ${function.`type`} function '$name': No model with name '$modelName' found. Please supply a valid model.") + } + + val functionId = project.getFunctionByName_!(name).id + + val (code, extendFunction, webhookUrl, headers) = setupFunction(name, function, client) + + val input = UpdateRequestPipelineMutationFunctionInput( + clientMutationId = None, + functionId = functionId, + name = Some(name), + isActive = Some(true), + functionType = Some(function.handlerType), + binding = Some(function.binding), + modelId = Some(modelId), + operation = Some(rpOperation), + webhookUrl = webhookUrl, + headers = headers, + inlineCode = code, + auth0Id = extendFunction.map(_.auth0Id) + ) + + UpdateOperationFunctionAction(input) + } + + /** + * + * Determine if the function is webhook, auth0Extend or a normal code handler. + * Return corresponding function details + */ + def setupFunction(name: String, function: Ast.Function, client: Client): (Option[String], Option[Auth0FunctionData], Option[String], Option[String]) = { + val code: Option[String] = function.handler.code.flatMap(x => files.get(x.src)) + val externalFile: Option[ExternalFile] = function.handler.code.flatMap(x => externalFiles.flatMap(_.get(x.src))) + + (code, externalFile) match { + + case (Some(codeContent), _) => // Auth0 Extend + val extendFunction: Auth0FunctionData = createAuth0Function(client = client, code = codeContent, functionName = name) + + (Some(codeContent), Some(extendFunction), Some(extendFunction.url), None) + case (None, Some(externalFileContent)) => // Normal Code Handler + deployFunctionToRuntime(project, externalFileContent, name) match { + case DeployFailure(e) => throw e + case _ => + } + + (None, None, None, None) + case _ => // Webhook + val webhookUrl: String = + function.handler.webhook.map(_.url).getOrElse(sys.error("webhook url or inline code required")) + + val headerMap = function.handler.webhook.map(_.headers) + val jsonHeader = headerMap.map(value => JsObject(value.map { case (key, other) => (key, JsString(other)) })) + val headers: Option[String] = jsonHeader.map(_.toString) + + (code, None, Some(webhookUrl), headers) + } + + } + + lazy val subscriptionFunctionsToRemove: Vector[RemoveSubscriptionFunctionAction] = + functionDiff.removedSubscriptionFunctions.map { + case FunctionWithFiles(name, function, _) => + val input = DeleteFunctionInput( + clientMutationId = None, + functionId = project.getFunctionByName_!(name).id + ) + + RemoveSubscriptionFunctionAction(input, name) + } + + lazy val schemaExtensionFunctionsToRemove: Vector[RemoveSchemaExtensionFunctionAction] = + functionDiff.removedSchemaExtensionFunctions.map { + case FunctionWithFiles(name, function, _) => + val input = DeleteFunctionInput( + clientMutationId = None, + functionId = project.getFunctionByName_!(name).id + ) + + RemoveSchemaExtensionFunctionAction(input, name) + } + + lazy val operationFunctionsToRemove: Vector[RemoveOperationFunctionAction] = + functionDiff.removedRequestPipelineFunctions.map { + case FunctionWithFiles(name, function, _) => + val input = DeleteFunctionInput( + clientMutationId = None, + functionId = project.getFunctionByName_!(name).id + ) + + RemoveOperationFunctionAction(input, name) + } + + lazy val modelPermissionsToAdd: Vector[AddModelPermissionAction] = permissionDiff.addedModelPermissions.map(permission => { + + val astPermission = permission.permission.permission + val x = astPermission.operation.split("\\.").toVector + val modelName = x(0) + val operation = x(1) + val modelOperation = operation match { + case "create" => ModelOperation.Create + case "read" => ModelOperation.Read + case "update" => ModelOperation.Update + case "delete" => ModelOperation.Delete + case _ => sys.error(s"Wrong operation defined for ModelPermission. You supplied: '${astPermission.operation}'") + } + + val userType = if (astPermission.authenticated) { UserType.Authenticated } else { UserType.Everyone } + val fileContainer = permission.permission.queryFile + val rule = if (fileContainer.isDefined) { CustomRule.Graph } else { CustomRule.None } + val fieldIds = astPermission.fields match { + case Some(fieldNames) => fieldNames.map(fieldName => permission.model.getFieldByName_!(fieldName).id) + case None => Vector.empty + } + + val input = AddModelPermissionInput( + clientMutationId = None, + modelId = permission.model.id, + operation = modelOperation, + userType = userType, + rule = rule, + ruleName = getRuleNameFromPath(astPermission.queryPath), + ruleGraphQuery = fileContainer.map(_.content), + ruleGraphQueryFilePath = astPermission.queryPath, + ruleWebhookUrl = None, + fieldIds = fieldIds.toList, + applyToWholeModel = astPermission.fields.isEmpty, + description = astPermission.description, + isActive = true + ) + val modelPermissionName = s"$modelName.$modelOperation" + AddModelPermissionAction(input, modelPermissionName) + }) + + lazy val relationPermissionsToAdd: Vector[AddRelationPermissionAction] = permissionDiff.addedRelationPermissions.map(permission => { + + val astPermission = permission.permission.permission + val x = astPermission.operation.split("\\.").toVector + val relationName = x(0) + val operation = x(1) + val (connect, disconnect) = operation match { + case "connect" => (true, false) + case "disconnect" => (false, true) + case "*" => (true, true) + case _ => sys.error(s"Wrong operation defined for RelationPermission. You supplied: '${astPermission.operation}'") + } + + val userType = if (astPermission.authenticated) { UserType.Authenticated } else { UserType.Everyone } + val fileContainer = permission.permission.queryFile + val rule = if (fileContainer.isDefined) { CustomRule.Graph } else { CustomRule.None } + + val input = AddRelationPermissionInput( + clientMutationId = None, + relationId = permission.relation.id, + connect = connect, + disconnect = disconnect, + userType = userType, + rule = rule, + ruleName = getRuleNameFromPath(astPermission.queryPath), + ruleGraphQuery = fileContainer.map(_.content), + ruleGraphQueryFilePath = astPermission.queryPath, + ruleWebhookUrl = None, + description = astPermission.description, + isActive = true + ) + + val relationPermissionName = s"$relationName.$operation" + AddRelationPermissionAction(input, relationPermissionName, operation) + }) + + lazy val modelPermissionsToRemove: Vector[RemoveModelPermissionAction] = permissionDiff.removedPermissionIds + .flatMap(project.getModelPermissionById) + .map(permission => { + val input = DeleteModelPermissionInput(clientMutationId = None, modelPermissionId = permission.id) + val operation = permission.operation + val modelPermissionName = project.getModelByModelPermissionId_!(permission.id).name + "." + operation + + RemoveModelPermissionAction(input, modelPermissionName, operation.toString) + }) + + lazy val relationPermissionsToRemove: Vector[RemoveRelationPermissionAction] = permissionDiff.removedPermissionIds + .flatMap(project.getRelationPermissionById) + .map(permission => { + val input = DeleteRelationPermissionInput(clientMutationId = None, relationPermissionId = permission.id) + val operation = if (permission.connect && permission.disconnect) "*" else if (permission.connect) "connect" else "disconnect" + val relationPermissionName = project.getRelationByRelationPermissionId_!(permission.id).name + "." + operation + RemoveRelationPermissionAction(input, relationPermissionName, operation) + }) + + lazy val rootTokensToRemove: Vector[RemoveRootTokenAction] = rootTokenDiff.removedRootTokensIds + .flatMap(project.getRootTokenById) + .map(rootToken => { + val input = DeleteRootTokenInput(clientMutationId = None, rootTokenId = rootToken.id) + val rootTokenName = rootToken.name + RemoveRootTokenAction(input, rootTokenName) + }) + + lazy val rootTokensToCreate: Vector[CreateRootTokenAction] = rootTokenDiff.addedRootTokens + .map(rootTokenName => { + val input = CreateRootTokenInput(clientMutationId = None, projectId = project.id, name = rootTokenName, description = None) + CreateRootTokenAction(input, rootTokenName) + }) + + // todo: move this around so we don't have to use Await.result + def createAuth0Function(client: Client, code: String, functionName: String): Auth0FunctionData = { + if (isDryRun) { + Auth0FunctionData("dryRun.url", "dryRun-id") + } + try { + val future = auth0Extend.createAuth0Function(client, code) + Await.result(future, Duration.Inf) + } catch { + case _: Throwable => throw ProjectPushError(description = s"Could not create serverless function for '$functionName'. Ensure that the code is valid") + } + + } + + def deployFunctionToRuntime(project: Project, externalFile: ExternalFile, functionName: String): DeployResponse = { + if (isDryRun) { + DeploySuccess() + } else { + Await.result(functionEnvironment.deploy(project, externalFile, functionName), Duration.Inf) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigratorActions.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigratorActions.scala new file mode 100644 index 0000000000..9b66e1b263 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ModuleMigratorActions.scala @@ -0,0 +1,338 @@ +package cool.graph.system.migration + +import _root_.akka.actor.ActorSystem +import _root_.akka.stream.ActorMaterializer +import cool.graph.InternalMutation +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.migration.dataSchema.VerbalDescription +import cool.graph.system.mutations._ +import scaldi.Injector + +import scala.collection.{Seq, mutable} + +trait ModuleActions { + def verbalDescriptions: Vector[VerbalDescription] + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs)( + implicit inj: Injector, + actorSystem: ActorSystem, + actorMaterializer: ActorMaterializer): (Seq[InternalMutation[_]], Project) +} + +case class RemoveModuleActions( + subscriptionFunctionsToRemove: Vector[RemoveSubscriptionFunctionAction], + schemaExtensionFunctionsToRemove: Vector[RemoveSchemaExtensionFunctionAction], + operationFunctionsToRemove: Vector[RemoveOperationFunctionAction], + modelPermissionsToRemove: Vector[RemoveModelPermissionAction], + relationPermissionsToRemove: Vector[RemoveRelationPermissionAction], + rootTokensToRemove: Vector[RemoveRootTokenAction] +) extends ModuleActions { + override def verbalDescriptions: Vector[VerbalDescription] = { + subscriptionFunctionsToRemove.map(_.verbalDescription) ++ + schemaExtensionFunctionsToRemove.map(_.verbalDescription) ++ + operationFunctionsToRemove.map(_.verbalDescription) ++ + modelPermissionsToRemove.map(_.verbalDescription) ++ + relationPermissionsToRemove.map(_.verbalDescription) ++ + rootTokensToRemove.map(_.verbalDescription) + } + + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs)( + implicit inj: Injector, + actorSystem: ActorSystem, + actorMaterializer: ActorMaterializer): (Seq[InternalMutation[_]], Project) = { + val mutations = mutable.Buffer.empty[InternalMutation[_]] + var currentProject = project + + // REMOVE FUNCTIONS + mutations ++= subscriptionFunctionsToRemove.map { x => + val mutation = DeleteFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= schemaExtensionFunctionsToRemove.map { x => + val mutation = DeleteFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= operationFunctionsToRemove.map { x => + val mutation = DeleteFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // REMOVE PERMISSIONS + mutations ++= modelPermissionsToRemove.map { x => + val model = project.getModelByModelPermissionId_!(x.input.modelPermissionId) + val permission = project.getModelPermissionById_!(x.input.modelPermissionId) + val mutation = DeleteModelPermissionMutation(client, currentProject, model, permission, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= relationPermissionsToRemove.map { x => + val relation = project.getRelationByRelationPermissionId_!(x.input.relationPermissionId) + val permission = project.getRelationPermissionById_!(x.input.relationPermissionId) + val mutation = DeleteRelationPermissionMutation(client, currentProject, relation, permission, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // REMOVE ROOTTOKENS + mutations ++= rootTokensToRemove.map { x => + val rootToken = project.getRootTokenById_!(x.input.rootTokenId) + val mutation = DeleteRootTokenMutation(client, currentProject, rootToken, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + (mutations, currentProject) + } +} + +case class AddModuleActions(subscriptionFunctionsToAdd: Vector[AddServerSideSubscriptionFunctionAction], + schemaExtensionFunctionsToAdd: Vector[AddSchemaExtensionFunctionAction], + operationFunctionsToAdd: Vector[AddOperationFunctionAction], + modelPermissionsToAdd: Vector[AddModelPermissionAction], + relationPermissionsToAdd: Vector[AddRelationPermissionAction], + rootTokensToCreate: Vector[CreateRootTokenAction]) + extends ModuleActions { + def verbalDescriptions: Vector[VerbalDescription] = { + subscriptionFunctionsToAdd.map(_.verbalDescription) ++ + schemaExtensionFunctionsToAdd.map(_.verbalDescription) ++ + operationFunctionsToAdd.map(_.verbalDescription) ++ + modelPermissionsToAdd.map(_.verbalDescription) ++ + relationPermissionsToAdd.map(_.verbalDescription) ++ + rootTokensToCreate.map(_.verbalDescription) + } + + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs)( + implicit inj: Injector, + actorSystem: ActorSystem, + actorMaterializer: ActorMaterializer): (Seq[InternalMutation[_]], Project) = { + val mutations = mutable.Buffer.empty[InternalMutation[_]] + var currentProject = project + + // ADD FUNCTIONS + mutations ++= subscriptionFunctionsToAdd.map { x => + val mutation = AddServerSideSubscriptionFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= schemaExtensionFunctionsToAdd.map { x => + val mutation = AddSchemaExtensionFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= operationFunctionsToAdd.map { x => + val mutation = AddRequestPipelineMutationFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // ADD PERMISSIONS + mutations ++= modelPermissionsToAdd.map { x => + val model = project.getModelById_!(x.input.modelId) + val mutation = AddModelPermissionMutation(client, currentProject, model, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= relationPermissionsToAdd.map { x => + val relation = project.getRelationById_!(x.input.relationId) + val mutation = AddRelationPermissionMutation(client, currentProject, relation, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // ADD ROOTTOKENS + mutations ++= rootTokensToCreate.map { x => + val mutation = CreateRootTokenMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + (mutations, currentProject) + } +} + +case class UpdateModuleActions(subscriptionFunctionsToUpdate: Vector[UpdateServerSideSubscriptionFunctionAction], + schemaExtensionFunctionsToUpdate: Vector[UpdateSchemaExtensionFunctionAction], + operationFunctionsToUpdate: Vector[UpdateOperationFunctionAction]) + extends ModuleActions { + def verbalDescriptions: Vector[VerbalDescription] = { + subscriptionFunctionsToUpdate.map(_.verbalDescription) ++ + schemaExtensionFunctionsToUpdate.map(_.verbalDescription) ++ + operationFunctionsToUpdate.map(_.verbalDescription) + } + + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs)( + implicit inj: Injector, + actorSystem: ActorSystem, + actorMaterializer: ActorMaterializer): (Seq[InternalMutation[_]], Project) = { + val mutations = mutable.Buffer.empty[InternalMutation[_]] + var currentProject = project + + // ADD FUNCTIONS + mutations ++= subscriptionFunctionsToUpdate.map { x => + val mutation = UpdateServerSideSubscriptionFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= schemaExtensionFunctionsToUpdate.map { x => + val mutation = UpdateSchemaExtensionFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + mutations ++= operationFunctionsToUpdate.map { x => + val mutation = UpdateRequestPipelineMutationFunctionMutation(client, currentProject, x.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + (mutations, currentProject) + } +} + +case class AddServerSideSubscriptionFunctionAction(input: AddServerSideSubscriptionFunctionInput) { + def verbalDescription = VerbalDescription( + `type` = "subscription function", + action = "Create", + name = input.name, + description = s"A new subscription with the name `${input.name}` is created." + ) +} + +case class AddOperationFunctionAction(input: AddRequestPipelineMutationFunctionInput) { + def verbalDescription = VerbalDescription( + `type` = "operation function", + action = "Create", + name = input.name, + description = s"A new operation function with the name `${input.name}` is created." + ) +} + +case class AddSchemaExtensionFunctionAction(input: AddSchemaExtensionFunctionInput) { + def verbalDescription = VerbalDescription( + `type` = "resolver function", + action = "Create", + name = input.name, + description = s"A new resolver function with the name `${input.name}` is created." + ) +} + +case class UpdateServerSideSubscriptionFunctionAction(input: UpdateServerSideSubscriptionFunctionInput) { + private val functionName = input.name.get + + def verbalDescription = VerbalDescription( + `type` = "subscription function", + action = "Update", + name = functionName, + description = s"A subscription with the name `$functionName` is updated." + ) +} + +case class UpdateOperationFunctionAction(input: UpdateRequestPipelineMutationFunctionInput) { + private val functionName = input.name.get + def verbalDescription = VerbalDescription( + `type` = "operation function", + action = "Update", + name = functionName, + description = s"An operation function with the name `$functionName` is updated." + ) +} + +case class UpdateSchemaExtensionFunctionAction(input: UpdateSchemaExtensionFunctionInput) { + private val functionName = input.name.get + def verbalDescription = VerbalDescription( + `type` = "resolver function", + action = "Update", + name = functionName, + description = s"A resolver function with the name `$functionName` is updated." + ) +} + +case class RemoveSubscriptionFunctionAction(input: DeleteFunctionInput, name: String) { + def verbalDescription = VerbalDescription( + `type` = "subscription function", + action = "Delete", + name = name, + description = s"A subscription with the name `$name` is deleted." + ) +} + +case class RemoveOperationFunctionAction(input: DeleteFunctionInput, name: String) { + def verbalDescription = VerbalDescription( + `type` = "operation function", + action = "Delete", + name = name, + description = s"An operation function with the name `$name` is deleted." + ) +} + +case class RemoveSchemaExtensionFunctionAction(input: DeleteFunctionInput, name: String) { + def verbalDescription = VerbalDescription( + `type` = "resolver function", + action = "Delete", + name = name, + description = s"A resolver function with the name `$name` is deleted." + ) +} + +case class AddModelPermissionAction(input: AddModelPermissionInput, modelPermissionName: String) { + def verbalDescription = VerbalDescription( + `type` = "model permission", + action = "Create", + name = modelPermissionName, + description = s"A permission for the operation `${input.operation}` is created." + ) +} + +case class AddRelationPermissionAction(input: AddRelationPermissionInput, relationPermissionName: String, operation: String) { + def verbalDescription = VerbalDescription( + `type` = "model permission", + action = "Create", + name = relationPermissionName, + description = s"A permission for the operation `$operation` is created." + ) +} + +case class RemoveModelPermissionAction(input: DeleteModelPermissionInput, modelPermissionName: String, operation: String) { + def verbalDescription = VerbalDescription( + `type` = "model permission", + action = "Delete", + name = modelPermissionName, + description = s"A permission for the operation `$operation` is deleted." + ) +} + +case class RemoveRelationPermissionAction(input: DeleteRelationPermissionInput, relationPermissionName: String, operation: String) { + def verbalDescription = VerbalDescription( + `type` = "model permission", + action = "Delete", + name = relationPermissionName, + description = s"A permission for the operation `$operation` is deleted." + ) +} + +case class RemoveRootTokenAction(input: DeleteRootTokenInput, rootTokenName: String) { + def verbalDescription = VerbalDescription( + `type` = "rootToken", + action = "Delete", + name = rootTokenName, + description = s"A rootToken with the name `$rootTokenName` is deleted." + ) +} + +case class CreateRootTokenAction(input: CreateRootTokenInput, rootTokenName: String) { + def verbalDescription = VerbalDescription( + `type` = "rootToken", + action = "Create", + name = rootTokenName, + description = s"A rootToken with the name `$rootTokenName` is created." + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/ProjectConfig.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ProjectConfig.scala new file mode 100644 index 0000000000..560dd5f953 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/ProjectConfig.scala @@ -0,0 +1,516 @@ +package cool.graph.system.migration + +import cool.graph.shared.errors.{SystemErrors, UserInputErrors} +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models._ +import cool.graph.system.migration.dataSchema.SchemaExport +import cool.graph.system.migration.permissions.QueryPermissionHelper +import cool.graph.system.migration.project.FileContainer +import net.jcazevedo.moultingyaml._ +import org.yaml.snakeyaml.scanner.ScannerException +import scaldi.Injector + +import scala.collection.immutable + +//Todo add error handling to parse and print + +object ProjectConfig { + def parse(config: String): Ast.Module = { + implicit val protocol = Ast.ConfigProtocol.ModuleFormat + try { + config.parseYaml.convertTo[Ast.Module] + } catch { + case e: DeserializationException => throw UserInputErrors.InvalidSchema(s"Parsing of the Yaml failed: ${e.msg}") + case e: ScannerException => throw UserInputErrors.InvalidSchema(s"Parsing of the Yaml failed: ${e.getMessage}") + } + } + + def print(module: Ast.Module): String = { + implicit val protocol = Ast.ConfigProtocol.ModuleFormat + + val printedYaml = module.toYaml.prettyPrint + + // Our Yaml library does not concern itself with comments and spacing + printedYaml + .replaceAllLiterally("functions: {}", emptyFunctionsRendering) + .replaceAllLiterally("permissions: []", "# permissions: []") + .replaceAllLiterally("rootTokens: []", "# rootTokens: []") + } + + def print(project: Project): String = { + moduleFromProject(project).module.print + } + + def moduleFromProject(project: Project): ModuleAndFiles = { + val types = typesFromProject(project) + val namedFunctions: immutable.Seq[FunctionWithFiles] = functionsFromProject(project) + val permissionBundlesWithId = permissionBundlesFromProject(project) + val permissions = permissionBundlesWithId.flatMap(_.permissions).map(_.astPermission).toVector + + // only create the output path here + + val module = Ast.Module( + types = Some(types.path), + functions = namedFunctions.map(x => (x.name, x.function)).toMap, + permissions = permissions, + rootTokens = project.rootTokens.map(_.name).toVector + ) + + val files = Vector(types) ++ namedFunctions.flatMap(f => + List(f.fileContainers.codeContainer, f.fileContainers.queryContainer, f.fileContainers.schemaContainer).flatten) ++ permissionBundlesWithId.flatMap( + _.fileContainer) + + ModuleAndFiles(module, files) + } + + private def typesFromProject(project: Project): FileContainer = { + FileContainer(path = "./types.graphql", SchemaExport.renderSchema(project)) + } + + def permissionBundleFromModel(model: Model, project: Project): PermissionBundle = { + + val permissions = model.permissions + .filter(_.isActive) + .map { permission => + val otherPermissionsWithSameOperationIds = model.permissions.filter(_.operation == permission.operation).map(_.id) + val alternativeRuleName: String = + QueryPermissionHelper.generateAlternativeRuleName(otherPermissionsWithSameOperationIds, permission.id, permission.operationString) + + val (queryPath, query) = QueryPermissionHelper.queryAndQueryPathFromModelPermission(model, permission, alternativeRuleName, project) + + val astPermission = Ast.Permission( + description = permission.description, + operation = s"${model.name}.${permission.operationString}", + authenticated = permission.userType == UserType.Authenticated, + queryPath = queryPath, + fields = if (permission.applyToWholeModel) { + None + } else { + Some(permission.fieldIds.toVector.map(id => model.getFieldById_!(id).name)) + } + ) + AstPermissionWithAllInfos(astPermission, query, queryPath, permission.id) + } + .toVector + + val containerPath = s"./src/permissions/${model.name}" + val fileContainer: Option[FileContainer] = QueryPermissionHelper.bundleQueriesInOneFile(queries = permissions.flatMap(_.query), containerPath) + + PermissionBundle(permissions, fileContainer) + } + + def permissionBundleFromRelation(relation: Relation, project: Project): PermissionBundle = { + val permissions = relation.permissions + .filter(_.isActive) + .map { permission => + val otherPermissionsWithSameOperationIds = relation.permissions.filter(_.operation == permission.operation).map(_.id) + val alternativeRuleName: String = + QueryPermissionHelper.generateAlternativeRuleName(otherPermissionsWithSameOperationIds, permission.id, permission.operationString) + + val (queryPath, query) = QueryPermissionHelper.queryAndQueryPathFromRelationPermission(relation, permission, alternativeRuleName, project) + + val astPermission = Ast.Permission( + description = permission.description, + operation = s"${relation.name}.${permission.operation}", + authenticated = permission.userType == UserType.Authenticated, + queryPath = queryPath + ) + + AstPermissionWithAllInfos(astPermission, query, queryPath, permission.id) + } + .toVector + + val containerPath = s"./src/permissions/${relation.name}" + val fileContainer: Option[FileContainer] = QueryPermissionHelper.bundleQueriesInOneFile(queries = permissions.flatMap(_.query), containerPath) + + PermissionBundle(permissions, fileContainer) + } + + // this should only be used in project config not in the permission diff + def permissionBundlesFromProject(project: Project): List[PermissionBundle] = { + val modelsWithPermissions = project.models.filter(_.permissions.nonEmpty) + val modelsWithActivePermissions = modelsWithPermissions.filter(model => model.permissions.exists(_.isActive == true)) + + val modelPermissionBundles = modelsWithActivePermissions.map(model => permissionBundleFromModel(model, project)) + + val relationsWithPermissions = project.relations.filter(_.permissions.nonEmpty) + val relationsWithActivePermissions = relationsWithPermissions.filter(relation => relation.permissions.exists(_.isActive == true)) + + val relationPermissionBundles = relationsWithActivePermissions.map(relation => permissionBundleFromRelation(relation, project)) + + modelPermissionBundles ++ relationPermissionBundles + } + + private def functionsFromProject(project: Project): Vector[FunctionWithFiles] = { + + def getHandler(function: Function): Ast.FunctionHandler = { + function.delivery match { + case x: WebhookFunction => + Ast.FunctionHandler(webhook = Some(Ast.FunctionHandlerWebhook(url = x.url, headers = x.headers.toMap))) + + case x: Auth0Function => + val path = x.codeFilePath match { + case Some(string) => string + case None => defaultPathForFunctionCode(function.name) + } + Ast.FunctionHandler(code = Some(Ast.FunctionHandlerCode(src = path))) + // todo: how do we check changes to the actual file + case x: ManagedFunction => + val path = x.codeFilePath match { + case Some(string) => string + case None => defaultPathForFunctionCode(function.name) + } + Ast.FunctionHandler(code = Some(Ast.FunctionHandlerCode(src = path))) + } + } + + def getHandlerFileContainer(function: Function) = { + function.delivery match { + case _: WebhookFunction => + None + + case x: Auth0Function => + Some(x.codeFilePath match { + case Some(path) => FileContainer(path = path, content = x.code) + case None => FileContainer(path = defaultPathForFunctionCode(function.name), content = x.code) + }) + + case x: ManagedFunction => None + } + } + + project.functions.filter(_.isActive).toVector collect { + case x: ServerSideSubscriptionFunction => + val queryFileContainer = Some(x.queryFilePath match { + case Some(path) => FileContainer(path = path, content = x.query) + case None => FileContainer(path = defaultPathForFunctionQuery(x.name), content = x.query) + }) + + FunctionWithFiles( + name = x.name, + function = Ast.Function( + description = None, + handler = getHandler(x), + `type` = "subscription", + query = queryFileContainer.map(_.path) // todo: how do we check changes to the actual file + ), + fileContainers = FileContainerBundle(queryContainer = queryFileContainer, codeContainer = getHandlerFileContainer(x)) + ) + case x: CustomMutationFunction => + val schemaFileContainer = Some(x.schemaFilePath match { + case Some(path) => FileContainer(path = path, content = x.schema) + case None => FileContainer(path = defaultPathForFunctionSchema(x.name), content = x.schema) + }) + + FunctionWithFiles( + name = x.name, + function = Ast.Function( + description = None, + handler = getHandler(x), + `type` = "resolver", + schema = schemaFileContainer.map(_.path) + ), + fileContainers = FileContainerBundle(schemaContainer = schemaFileContainer, codeContainer = getHandlerFileContainer(x)) + ) + case x: CustomQueryFunction => + val schemaFileContainer = Some(x.schemaFilePath match { + case Some(path) => FileContainer(path = path, content = x.schema) + case None => FileContainer(path = defaultPathForFunctionSchema(x.name), content = x.schema) + }) + + FunctionWithFiles( + name = x.name, + function = Ast.Function( + description = None, + handler = getHandler(x), + `type` = "resolver", + schema = schemaFileContainer.map(_.path) + ), + fileContainers = FileContainerBundle(schemaContainer = schemaFileContainer, codeContainer = getHandlerFileContainer(x)) + ) + case x: RequestPipelineFunction if x.binding == FunctionBinding.TRANSFORM_ARGUMENT => + FunctionWithFiles( + name = x.name, + function = Ast.Function( + description = None, + handler = getHandler(x), + `type` = "operationBefore", + operation = Some(project.getModelById_!(x.modelId).name + "." + x.operation.toString.toLowerCase) + ), + fileContainers = FileContainerBundle(codeContainer = getHandlerFileContainer(x)) + ) + case x: RequestPipelineFunction if x.binding == FunctionBinding.TRANSFORM_PAYLOAD => + FunctionWithFiles( + name = x.name, + function = Ast.Function( + description = None, + handler = getHandler(x), + `type` = "operationAfter", + operation = Some(project.getModelById_!(x.modelId).name + "." + x.operation.toString.toLowerCase) + ), + fileContainers = FileContainerBundle(codeContainer = getHandlerFileContainer(x)) + ) + } + } + + object Ast { + case class Module(types: Option[String] = None, + functions: Map[String, Function] = Map.empty, + modules: Option[Map[String, String]] = None, + permissions: Vector[Permission] = Vector.empty, + rootTokens: Vector[String] = Vector.empty) { + def functionNames: Vector[String] = functions.keys.toVector + def function_!(name: String) = functions(name) + def namedFunction_!(name: String, files: Map[String, String]): FunctionWithFiles = { + val function: Function = functions(name) + + val fileContainerBundle = { + def createFileContainer(codePath: Option[String]) = codePath.flatMap(path => files.get(path).map(content => FileContainer(path, content))) + + val codePath = function.handler.code.map(_.src) + val schemaPath = function.schema + val queryPath = function.query + + FileContainerBundle(codeContainer = createFileContainer(codePath), + schemaContainer = createFileContainer(schemaPath), + queryContainer = createFileContainer(queryPath)) + } + + FunctionWithFiles(name = name, function = function, fileContainers = fileContainerBundle) + } + + def print: String = { + implicit val protocol = Ast.ConfigProtocol.ModuleFormat + this.toYaml.prettyPrint + } + } + + case class Function(description: Option[String] = None, + handler: FunctionHandler, + `type`: String, + schema: Option[String] = None, + query: Option[String] = None, + operation: Option[String] = None) { + def binding: FunctionBinding = `type` match { + case "httpRequest" => FunctionBinding.TRANSFORM_REQUEST + case "httpResponse" => FunctionBinding.TRANSFORM_RESPONSE + case "resolver" => FunctionBinding.CUSTOM_MUTATION // todo: determine if mutation or query + //case "resolver" => FunctionBinding.CUSTOM_QUERY // todo: determine if mutation or query + case "subscription" => FunctionBinding.SERVERSIDE_SUBSCRIPTION + case "operationBefore" => FunctionBinding.TRANSFORM_ARGUMENT + case "operationAfter" => FunctionBinding.TRANSFORM_PAYLOAD + case invalid => throw SystemErrors.InvalidFunctionType(invalid) + } + + def handlerType: cool.graph.shared.models.FunctionType.FunctionType = handler match { + case x if x.webhook.isDefined => cool.graph.shared.models.FunctionType.WEBHOOK + case x if x.code.isDefined => cool.graph.shared.models.FunctionType.CODE + } + } + + case class FunctionHandler(webhook: Option[FunctionHandlerWebhook] = None, code: Option[FunctionHandlerCode] = None) + case class FunctionHandlerWebhook(url: String, headers: Map[String, String] = Map.empty) + case class FunctionHandlerCode(src: String) + case class FunctionType(subscription: Option[String] = None, + httpRequest: Option[HttpRequest] = None, + httpResponse: Option[HttpResponse] = None, + schemaExtension: Option[String] = None, + operationBefore: Option[String] = None, + operationAfter: Option[String] = None) + case class FunctionEventSchemaExtension(schema: FunctionEventSchemaExtensionSchema) + case class FunctionEventSchemaExtensionSchema(src: String) + case class HttpRequest(order: Int = 0) + case class HttpResponse(order: Int = 0) + case class Permission(description: Option[String] = None, + operation: String, + authenticated: Boolean = false, + queryPath: Option[String] = None, + fields: Option[Vector[String]] = None) + case class PermissionQuery(src: String) + + object ConfigProtocol extends DefaultYamlProtocol { + + implicit val PermissionQueryFormat = yamlFormat1(PermissionQuery) + + implicit object PermissionFormat extends YamlFormat[Permission] { + def write(c: Permission) = { + var fields: Seq[(YamlValue, YamlValue)] = Vector(YamlString("operation") -> YamlString(c.operation)) + + if (c.description.nonEmpty) { + fields :+= YamlString("description") -> YamlString(c.description.get) + } + if (c.authenticated) { + fields :+= YamlString("authenticated") -> YamlBoolean(true) + } + if (c.queryPath.nonEmpty) { + fields :+= YamlString("query") -> YamlString(c.queryPath.get) + } + if (c.fields.nonEmpty) { + fields :+= YamlString("fields") -> YamlArray(c.fields.get.map(YamlString)) + } + + YamlObject( + fields: _* + ) + } + def read(value: YamlValue) = { + val fields = value.asYamlObject.fields + + Permission( + description = fields.get(YamlString("description")).map(_.convertTo[String]), + operation = fields(YamlString("operation")).convertTo[String], + authenticated = fields.get(YamlString("authenticated")).map(_.convertTo[Boolean]).getOrElse(false), + queryPath = fields.get(YamlString("query")).map(_.convertTo[String]), + fields = fields.get(YamlString("fields")).map(_.convertTo[Vector[String]]) + ) + } + } + + implicit val HttpRequestFormat = yamlFormat1(HttpRequest) + implicit val HttpResponseFormat = yamlFormat1(HttpResponse) + implicit val FunctionEventSchemaExtensionSchemaFormat = yamlFormat1(FunctionEventSchemaExtensionSchema) + implicit val FunctionEventSchemaExtensionFormat = yamlFormat1(FunctionEventSchemaExtension) + implicit val FunctionEventFormat = yamlFormat6(FunctionType) + implicit val FunctionHandlerCodeFormat = yamlFormat1(FunctionHandlerCode) + + implicit object FunctionHandlerWebhookFormat extends YamlFormat[FunctionHandlerWebhook] { + def write(c: FunctionHandlerWebhook) = { + var fields: Seq[(YamlValue, YamlValue)] = Vector(YamlString("url") -> c.url.toYaml) + + if (c.headers.nonEmpty) { + fields :+= YamlString("headers") -> c.headers.toYaml + } + + YamlObject( + fields: _* + ) + } + def read(value: YamlValue) = { + val fields = value.asYamlObject.fields + + if (fields.get(YamlString("headers")).nonEmpty) { + FunctionHandlerWebhook(url = fields(YamlString("url")).convertTo[String], headers = fields(YamlString("headers")).convertTo[Map[String, String]]) + } else { + FunctionHandlerWebhook(url = fields(YamlString("url")).convertTo[String]) + } + } + } + + implicit val FunctionHandlerFormat = yamlFormat2(FunctionHandler) + + implicit object FunctionFormat extends YamlFormat[Function] { + def write(c: Function) = { + var fields: Seq[(YamlValue, YamlValue)] = Vector(YamlString("handler") -> c.handler.toYaml, YamlString("type") -> c.`type`.toYaml) + + if (c.description.nonEmpty) { + fields :+= YamlString("description") -> YamlString(c.description.get) + } + if (c.schema.nonEmpty) { + fields :+= YamlString("schema") -> YamlString(c.schema.get) + } + if (c.query.nonEmpty) { + fields :+= YamlString("query") -> YamlString(c.query.get) + } + if (c.operation.nonEmpty) { + fields :+= YamlString("operation") -> YamlString(c.operation.get) + } + + YamlObject( + fields: _* + ) + } + + def read(value: YamlValue) = { + val fields = value.asYamlObject.fields + + val handler = if (fields(YamlString("handler")).asYamlObject.fields.get(YamlString("code")).exists(_.isInstanceOf[YamlString])) { + FunctionHandler(code = Some(FunctionHandlerCode(src = fields(YamlString("handler")).asYamlObject.fields(YamlString("code")).convertTo[String]))) + } else { + fields(YamlString("handler")).convertTo[FunctionHandler] + } + + Function( + description = fields.get(YamlString("description")).map(_.convertTo[String]), + handler = handler, + `type` = fields(YamlString("type")).convertTo[String], + schema = fields.get(YamlString("schema")).map(_.convertTo[String]), + query = fields.get(YamlString("query")).map(_.convertTo[String]), + operation = fields.get(YamlString("operation")).map(_.convertTo[String]) + ) + } + } + + implicit object ModuleFormat extends YamlFormat[Module] { + def write(c: Module) = { + var fields: Seq[(YamlValue, YamlValue)] = Vector.empty + + if (c.types.nonEmpty) { + fields :+= YamlString("types") -> YamlString(c.types.get) + } + fields :+= YamlString("functions") -> c.functions.toYaml + + if (c.modules.nonEmpty) { + fields :+= YamlString("modules") -> c.modules.toYaml + } + + fields :+= YamlString("permissions") -> c.permissions.toYaml + + fields :+= YamlString("rootTokens") -> c.rootTokens.toYaml + + YamlObject( + fields: _* + ) + } + def read(value: YamlValue) = { + val fields = value.asYamlObject.fields + + Module( + types = fields.get(YamlString("types")).map(_.convertTo[String]), + functions = fields.get(YamlString("functions")).map(_.convertTo[Map[String, Function]]).getOrElse(Map.empty), + modules = fields.get(YamlString("modules")).map(_.convertTo[Map[String, String]]), + permissions = fields.get(YamlString("permissions")).map(_.convertTo[Vector[Permission]]).getOrElse(Vector.empty), + rootTokens = fields.get(YamlString("rootTokens")).map(_.convertTo[Vector[String]]).getOrElse(Vector.empty) + ) + } + } + } + } + + case class FunctionWithFiles(name: String, function: Ast.Function, fileContainers: FileContainerBundle) + + case class PermissionWithQueryFile(permission: Ast.Permission, queryFile: Option[FileContainer]) + case class PermissionWithId(permission: PermissionWithQueryFile, id: String) + + case class AstPermissionWithAllInfos(astPermission: Ast.Permission, query: Option[String], queryPath: Option[String], permissionId: String) + + case class PermissionBundle(permissions: Vector[AstPermissionWithAllInfos], fileContainer: Option[FileContainer]) + + case class ModuleAndFiles(module: Ast.Module, files: Vector[FileContainer]) + + case class FileContainerBundle(codeContainer: Option[FileContainer] = None, + schemaContainer: Option[FileContainer] = None, + queryContainer: Option[FileContainer] = None) + def defaultPathForFunctionCode(functionName: String) = s"./src/$functionName.js" + def defaultPathForFunctionQuery(functionName: String) = s"./src/$functionName.graphql" + def defaultPathForFunctionSchema(functionName: String) = s"./src/$functionName.graphql" + def defaultPathForPermissionQuery(generatedName: String) = s"./src/permissions/$generatedName" + + val emptyFunctionsRendering = """# functions: + |# helloWorld: + |# handler: + |# code: | + |# module.exports = function sum(event) { + |# const data = event.data + |# const message = `Hello World (${data.extraMessage})` + |# return {data: {message: message}} + |# } + |# type: resolver + |# schema: | + |# type HelloPayload { + |# message: String! + |# } + |# + |# extend type Query { + |# hello(extraMessage: String): HelloPayload + |# }""".stripMargin +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/DataSchemaAstExtensions.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/DataSchemaAstExtensions.scala new file mode 100644 index 0000000000..f659b7468b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/DataSchemaAstExtensions.scala @@ -0,0 +1,170 @@ +package cool.graph.system.migration.dataSchema + +import sangria.ast._ +import scala.collection.Seq + +object DataSchemaAstExtensions { + implicit class CoolDocument(val doc: Document) extends AnyVal { + def typeNames: Vector[String] = objectTypes.map(_.name) + def oldTypeNames: Vector[String] = objectTypes.map(_.oldName) + + def enumNames: Vector[String] = enumTypes.map(_.name) + def oldEnumNames: Vector[String] = enumTypes.map(_.oldName) + + def containsRelation(relationName: String): Boolean = { + val allFields = objectTypes.flatMap(_.fields) + allFields.exists(fieldDef => fieldDef.oldRelationName.contains(relationName)) + } + + def isObjectOrEnumType(name: String): Boolean = objectType(name).isDefined || enumType(name).isDefined + + def objectType_!(name: String): ObjectTypeDefinition = objectType(name).getOrElse(sys.error(s"Could not find the object type $name!")) + def objectType(name: String): Option[ObjectTypeDefinition] = objectTypes.find(_.name == name) + def objectTypes: Vector[ObjectTypeDefinition] = doc.definitions.collect { case x: ObjectTypeDefinition => x } + + def enumType(name: String): Option[EnumTypeDefinition] = enumTypes.find(_.name == name) + def enumTypes: Vector[EnumTypeDefinition] = doc.definitions collect { case x: EnumTypeDefinition => x } + } + + implicit class CoolObjectType(val objectType: ObjectTypeDefinition) extends AnyVal { + def hasNoIdField: Boolean = field("id").isEmpty + + def oldName: String = { + val nameBeforeRename = for { + directive <- objectType.directive("rename") + argument <- directive.arguments.headOption + } yield argument.value.asInstanceOf[StringValue].value + + nameBeforeRename.getOrElse(objectType.name) + } + + def field_!(name: String): FieldDefinition = field(name).getOrElse(sys.error(s"Could not find the field $name on the type ${objectType.name}")) + def field(name: String): Option[FieldDefinition] = objectType.fields.find(_.name == name) + + def nonRelationFields: Vector[FieldDefinition] = objectType.fields.filter(_.isNoRelation) + def relationFields: Vector[FieldDefinition] = objectType.fields.filter(_.hasRelationDirective) + + def description: Option[String] = objectType.directiveArgumentAsString("description", "text") + } + + implicit class CoolField(val fieldDefinition: FieldDefinition) extends AnyVal { + + def oldName: String = { + val nameBeforeRename = fieldDefinition.directiveArgumentAsString("rename", "oldName") + nameBeforeRename.getOrElse(fieldDefinition.name) + } + + def isIdField: Boolean = fieldDefinition.name == "id" + + def isNotSystemField = { + val name = fieldDefinition.name + name != "id" && name != "updatedAt" && name != "createdAt" + } + + def typeString: String = fieldDefinition.fieldType.renderPretty + + def typeName: String = fieldDefinition.fieldType.namedType.name + + def isUnique: Boolean = fieldDefinition.directive("isUnique").isDefined + + def isRequired: Boolean = fieldDefinition.fieldType.isRequired + + def isList: Boolean = fieldDefinition.fieldType match { + case ListType(_, _) => true + case NotNullType(ListType(__, _), _) => true + case _ => false + } + + def isValidRelationType: Boolean = fieldDefinition.fieldType match { + case NamedType(_,_) => true + case NotNullType(NamedType(_,_), _) => true + case NotNullType(ListType(NotNullType(NamedType(_,_),_), _), _) => true + case _ => false + } + + def isValidScalarType: Boolean = fieldDefinition.fieldType match { + case NamedType(_,_) => true + case NotNullType(NamedType(_,_), _) => true + case ListType(NotNullType(NamedType(_,_),_), _) => true + case NotNullType(ListType(NotNullType(NamedType(_,_),_), _), _) => true + case _ => false + } + + def isOneRelationField: Boolean = hasRelationDirective && !isList + def hasRelationDirective: Boolean = relationName.isDefined + def isNoRelation: Boolean = !hasRelationDirective + def description: Option[String] = fieldDefinition.directiveArgumentAsString("description", "text") + def defaultValue: Option[String] = fieldDefinition.directiveArgumentAsString("defaultValue", "value") + def migrationValue: Option[String] = fieldDefinition.directiveArgumentAsString("migrationValue", "value") + def relationName: Option[String] = fieldDefinition.directiveArgumentAsString("relation", "name") + def oldRelationName: Option[String] = fieldDefinition.directiveArgumentAsString("relation", "oldName").orElse(relationName) + } + + implicit class CoolEnumType(val enumType: EnumTypeDefinition) extends AnyVal { + def oldName: String = { + val nameBeforeRename = enumType.directiveArgumentAsString("rename", "oldName") + nameBeforeRename.getOrElse(enumType.name) + } + + def migrationValue: Option[String] = enumType.directiveArgumentAsString("migrationValue", "value") + def valuesAsStrings: Seq[String] = enumType.values.map(_.name) + } + + implicit class CoolWithDirectives(val withDirectives: WithDirectives) extends AnyVal { + def directiveArgumentAsString(directiveName: String, argumentName: String): Option[String] = { + for { + directive <- directive(directiveName) + argument <- directive.arguments.find { x => + val isScalarOrEnum = x.value.isInstanceOf[ScalarValue] || x.value.isInstanceOf[EnumValue] + x.name == argumentName && isScalarOrEnum + } + } yield { + argument.value match { + case value: EnumValue => value.value + case value: StringValue => value.value + case value: BigIntValue => value.value.toString + case value: BigDecimalValue => value.value.toString + case value: IntValue => value.value.toString + case value: FloatValue => value.value.toString + case value: BooleanValue => value.value.toString + case _ => sys.error("This clause is unreachable because of the instance checks above, but i did not know how to prove it to the compiler.") + } + } + } + + def directive(name: String): Option[Directive] = withDirectives.directives.find(_.name == name) + def directive_!(name: String): Directive = directive(name).getOrElse(sys.error(s"Could not find the directive with name: $name!")) + + } + + implicit class CoolDirective(val directive: Directive) extends AnyVal { + import shapeless._ + import syntax.typeable._ + + def containsArgument(name: String, mustBeAString: Boolean): Boolean = { + if (mustBeAString) { + directive.arguments.find(_.name == name).flatMap(_.value.cast[StringValue]).isDefined + } else { + directive.arguments.exists(_.name == name) + } + } + + def argument(name: String): Option[Argument] = directive.arguments.find(_.name == name) + def argument_!(name: String): Argument = argument(name).getOrElse(sys.error(s"Could not find the argument with name: $name!")) + } + + implicit class CoolType(val `type`: Type) extends AnyVal { + + /** Example + * type Todo { + * tag: Tag! <- we treat this as required; this is the only one we treat as required + * tags: [Tag!]! <- this is explicitly not required, because we don't allow many relation fields to be required + * } + */ + def isRequired = `type` match { + case NotNullType(NamedType(_, _), _) => true + case _ => false + } + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/RelationDiff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/RelationDiff.scala new file mode 100644 index 0000000000..3b8b74a610 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/RelationDiff.scala @@ -0,0 +1,68 @@ +package cool.graph.system.migration.dataSchema + +import cool.graph.Types.Id +import cool.graph.shared.models.{Project, Relation} +import sangria.ast.{Document, StringValue} + +object RelationDiff { + // a schema is said to contain a relation if a @relation directive exists with correct name, or + // a @relation with different name links the same fields + def schemaContainsRelation(project: Project, schema: Document, relation: Relation): Boolean = { + + import DataSchemaAstExtensions._ + + if (schema.containsRelation(relation.name)) { + true + } else { + try { + val leftModel = schema.objectType_!(relation.getModelA_!(project).name) + val leftFieldRelationDirectiveName = + leftModel + .field_!(relation.getModelAField_!(project).name) + .directive_!("relation") + .argument_!("name") + .value + + val rightModel = schema.objectType_!(relation.getModelB_!(project).name) + val rightFieldRelationDirectiveName = + rightModel + .field_!(relation.getModelBField_!(project).name) + .directive_!("relation") + .argument_!("name") + .value + + leftFieldRelationDirectiveName + .asInstanceOf[StringValue] + .value == rightFieldRelationDirectiveName.asInstanceOf[StringValue].value + } catch { + case e: Throwable => false + } + } + } + // project is said to contain relation if a relation with the name already exists + // or the two fields are already linked by a relation with other name + def projectContainsRelation(project: Project, addRelation: AddRelationAction): Boolean = { + project.relations.exists { relation => + if (relation.name == addRelation.input.name) { + true + } else { + try { + val leftModelRelationId: Option[Id] = project + .getModelById_!(addRelation.input.leftModelId) + .getFieldByName_!(addRelation.input.fieldOnLeftModelName) + .relation + .map(_.id) + val rightModelRelationId: Option[Id] = project + .getModelById_!(addRelation.input.rightModelId) + .getFieldByName_!(addRelation.input.fieldOnRightModelName) + .relation + .map(_.id) + + leftModelRelationId == rightModelRelationId + } catch { + case e: Throwable => false + } + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaActions.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaActions.scala new file mode 100644 index 0000000000..66293a62f8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaActions.scala @@ -0,0 +1,307 @@ +package cool.graph.system.migration.dataSchema + +import cool.graph.InternalMutation +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.client.{ClientDbQueries, EmptyClientDbQueries} +import cool.graph.system.mutations._ +import scaldi.Injector + +import scala.collection.{Seq, mutable} + +case class UpdateSchemaActions( + modelsToAdd: Seq[AddModelAction], + modelsToUpdate: Seq[UpdateModelAction], + modelsToRemove: Seq[DeleteModelAction], + enumsToAdd: Seq[AddEnumAction], + enumsToUpdate: Seq[UpdateEnumAction], + enumsToRemove: Seq[DeleteEnumAction], + relationsToAdd: Seq[AddRelationAction], + relationsToRemove: Seq[DeleteRelationAction], + relationsToUpdate: Seq[UpdateRelationAction] +) { + def verbalDescriptions: Seq[VerbalDescription] = { + modelsToAdd.map(_.verbalDescription) ++ + modelsToUpdate.map(_.verbalDescription) ++ + modelsToRemove.map(_.verbalDescription) ++ + enumsToAdd.map(_.verbalDescription) ++ + enumsToUpdate.map(_.verbalDescription) ++ + enumsToRemove.map(_.verbalDescription) ++ + relationsToAdd.map(_.verbalDescription) ++ + relationsToRemove.map(_.verbalDescription) ++ + relationsToUpdate.map(_.verbalDescription) + } + + // will any of the actions potentially delete data + def isDestructive: Boolean = { + modelsToRemove.nonEmpty || enumsToRemove.nonEmpty || relationsToRemove.nonEmpty || + modelsToUpdate.exists(_.removeFields.nonEmpty) + } + + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs, clientDbQueries: ClientDbQueries)( + implicit inj: Injector): (Seq[InternalMutation[_]], Project) = { + val mutations = mutable.Buffer.empty[InternalMutation[_]] + var currentProject = project + + // ADD ENUMS + mutations ++= enumsToAdd.map { addEnumAction => + val mutation = AddEnumMutation(client, currentProject, addEnumAction.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // REMOVE MODELS + mutations ++= modelsToRemove.map { deleteModelAction => + val mutation = DeleteModelMutation(client, currentProject, deleteModelAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + + // ADD MODELS + mutations ++= modelsToAdd.flatMap { addModelAction => + val mutation = AddModelMutation(client, currentProject, addModelAction.addModel, projectDbsFn) + currentProject = mutation.updatedProject + List(mutation) ++ addModelAction.addFields.map { addField => + val mutation = AddFieldMutation(client, currentProject, addField.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + } + + // UPDATE MODELS + mutations ++= modelsToUpdate.flatMap { updateModelAction => + val updateModelMutation = updateModelAction.updateModel.map { updateModelInput => + UpdateModelMutation(client, currentProject, updateModelInput, projectDbsFn) + } + currentProject = updateModelMutation.map(_.updatedProject).getOrElse(currentProject) + updateModelMutation.toSeq ++ + updateModelAction.addFields.map { addFieldAction => + val mutation = AddFieldMutation(client, currentProject, addFieldAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } ++ + updateModelAction.removeFields.map { deleteFieldAction => + val mutation = DeleteFieldMutation(client, currentProject, deleteFieldAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } ++ + updateModelAction.updateFields.map { updateFieldAction => + val mutation = UpdateFieldMutation(client, currentProject, updateFieldAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + } + + // REMOVE ENUMS + mutations ++= enumsToRemove.map { deleteEnumAction => + val mutation = DeleteEnumMutation(client, currentProject, deleteEnumAction.input, projectDbsFn) + currentProject = mutation.updatedProject + mutation + } + + // UPDATE ENUMS + mutations ++= enumsToUpdate.map { updateEnumAction => + val mutation = UpdateEnumMutation(client, currentProject, updateEnumAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + + // REMOVE RELATIONS + mutations ++= relationsToRemove.map { deleteRelationAction => + val mutation = DeleteRelationMutation(client, currentProject, deleteRelationAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + + // ADD RELATIONS + mutations ++= relationsToAdd.map { addRelationAction => + val mutation = AddRelationMutation(client, currentProject, addRelationAction.input, projectDbsFn, clientDbQueries) + currentProject = mutation.updatedProject + mutation + } + + // UPDATE RELATIONS + mutations ++= relationsToUpdate.map { updateRelationAction => + val mutation = UpdateRelationMutation(client, project, updateRelationAction.input, projectDbsFn, clientDbQueries) + val (_, _, _, cProject) = mutation.updatedProject + currentProject = cProject + mutation + } + + (mutations, currentProject) + } +} + +case class InitSchemaActions( + modelsToAdd: Seq[AddModelAction], + modelsToUpdate: Seq[UpdateModelAction], + enumsToAdd: Seq[AddEnumAction], + relationsToAdd: Seq[AddRelationAction] +) { + def verbalDescriptions: Seq[VerbalDescription] = { + modelsToAdd.map(_.verbalDescription) ++ + modelsToUpdate.map(_.verbalDescription) ++ + enumsToAdd.map(_.verbalDescription) ++ + relationsToAdd.map(_.verbalDescription) + } + + def determineMutations(client: Client, project: Project, projectDbsFn: Project => InternalAndProjectDbs)(implicit inj: Injector): Seq[InternalMutation[_]] = { + val updateActions = UpdateSchemaActions( + modelsToAdd = modelsToAdd, + modelsToUpdate = modelsToUpdate, + modelsToRemove = Seq.empty, + enumsToAdd = enumsToAdd, + enumsToUpdate = Seq.empty, + enumsToRemove = Seq.empty, + relationsToAdd = relationsToAdd, + relationsToRemove = Seq.empty, + relationsToUpdate = Seq.empty + ) + // because of all those empty sequences we know that the the DbQueries for an empty db won't be a problem in this call. But it's not nice this way. + val (mutations, currentProject) = updateActions.determineMutations(client, project, projectDbsFn, EmptyClientDbQueries) + mutations + } +} + +case class VerbalDescription(`type`: String, action: String, name: String, description: String, subDescriptions: Seq[VerbalSubDescription] = Seq.empty) + +case class VerbalSubDescription(`type`: String, action: String, name: String, description: String) + +/** + * Action Data Structures + */ +case class AddModelAction(addModel: AddModelInput, addFields: List[AddFieldAction]) { + def verbalDescription = VerbalDescription( + `type` = "Type", + action = "Create", + name = addModel.modelName, + description = s"A new type with the name `${addModel.modelName}` is created.", + subDescriptions = addFields.map(_.verbalDescription) + ) +} + +case class UpdateModelAction(newName: String, + id: String, + updateModel: Option[UpdateModelInput], + addFields: List[AddFieldAction], + removeFields: List[DeleteFieldAction], + updateFields: List[UpdateFieldAction]) { + + def hasChanges: Boolean = addFields.nonEmpty || removeFields.nonEmpty || updateFields.nonEmpty || updateModel.nonEmpty + + lazy val verbalDescription = VerbalDescription( + `type` = "Type", + action = "Update", + name = newName, + description = s"The type `$newName` is updated.", + subDescriptions = addFieldDescriptions ++ removeFieldDescriptions ++ updateFieldDescriptions + ) + + val addFieldDescriptions: List[VerbalSubDescription] = addFields.map(_.verbalDescription) + val removeFieldDescriptions: List[VerbalSubDescription] = removeFields.map(_.verbalDescription) + val updateFieldDescriptions: List[VerbalSubDescription] = updateFields.map(_.verbalDescription) +} + +case class DeleteModelAction( + modelName: String, + input: DeleteModelInput +) { + def verbalDescription = VerbalDescription( + `type` = "Type", + action = "Delete", + name = modelName, + description = s"The type `$modelName` is removed. This also removes all its fields and relations." + ) +} + +case class AddFieldAction(input: AddFieldInput) { + val verbalDescription = VerbalSubDescription( + `type` = "Field", + action = "Create", + name = input.name, + description = { + val typeString = VerbalDescriptionUtil.typeString(typeName = input.typeIdentifier.toString, isRequired = input.isRequired, isList = input.isList) + s"A new field with the name `${input.name}` and type `$typeString` is created." + } + ) +} + +case class UpdateFieldAction(input: UpdateFieldInput, fieldName: String) { + val verbalDescription = VerbalSubDescription( + `type` = "Field", + action = "Update", + name = fieldName, + description = s"The field `$fieldName` is updated." + ) +} + +case class DeleteFieldAction(input: DeleteFieldInput, fieldName: String) { + val verbalDescription = VerbalSubDescription( + `type` = "Field", + action = "Delete", + name = fieldName, + description = s"The field `$fieldName` is deleted." + ) +} + +case class AddRelationAction(input: AddRelationInput, leftModelName: String, rightModelName: String) { + def verbalDescription = + VerbalDescription( + `type` = "Relation", + action = "Create", + name = input.name, + description = s"The relation `${input.name}` is created. It connects the type `$leftModelName` with the type `$rightModelName`." + ) +} + +case class DeleteRelationAction(input: DeleteRelationInput, relationName: String, leftModelName: String, rightModelName: String) { + def verbalDescription = + VerbalDescription( + `type` = "Relation", + action = "Delete", + name = relationName, + description = s"The relation `$relationName` is deleted. It connected the type `$leftModelName` with the type `$rightModelName`." + ) +} + +case class UpdateRelationAction(input: UpdateRelationInput, oldRelationName: String, newRelationName: String, leftModelName: String, rightModelName: String) { + def verbalDescription = + VerbalDescription( + `type` = "Relation", + action = "Update", + name = oldRelationName, + description = s"The relation `$oldRelationName` is renamed to `$newRelationName`. It connects the type `$leftModelName` with the type `$rightModelName`." + ) +} + +case class AddEnumAction(input: AddEnumInput) { + def verbalDescription = + VerbalDescription(`type` = "Enum", + action = "Create", + name = input.name, + description = s"The enum `${input.name}` is created. It has the values: ${input.values.mkString(",")}.") +} + +case class UpdateEnumAction(input: UpdateEnumInput, newName: String, newValues: Seq[String]) { + def verbalDescription = + VerbalDescription(`type` = "Enum", + action = "Update", + name = newName, + description = s"The enum `$newName` is updated. It has the values: ${newValues.mkString(",")}.") +} + +case class DeleteEnumAction(input: DeleteEnumInput, name: String) { + def verbalDescription = + VerbalDescription(`type` = "Enum", action = "Delete", name = name, description = s"The enum `$name` is deleted.") +} + +object VerbalDescriptionUtil { + def typeString(typeName: String, isRequired: Boolean, isList: Boolean): String = { + (isList, isRequired) match { + case (false, false) => s"$typeName" + case (false, true) => s"$typeName!" + case (true, true) => s"[$typeName!]!" + case (true, false) => s"[$typeName!]" + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaDiff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaDiff.scala new file mode 100644 index 0000000000..69bdc9a565 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaDiff.scala @@ -0,0 +1,75 @@ +package cool.graph.system.migration.dataSchema + +import sangria.ast.Document + +import scala.util.Try + +object SchemaDiff { + def apply(oldSchema: String, newSchema: String): Try[SchemaDiff] = { + for { + oldDocParsed <- SdlSchemaParser.parse(oldSchema) + newDocParsed <- SdlSchemaParser.parse(newSchema) + } yield SchemaDiff(oldDocParsed, newDocParsed) + } +} +case class SchemaDiff( + oldSchema: Document, + newSchema: Document +) { + import DataSchemaAstExtensions._ + + val addedTypes: Vector[String] = newSchema.oldTypeNames diff oldSchema.typeNames + val removedTypes: Vector[String] = oldSchema.typeNames diff newSchema.oldTypeNames + + val updatedTypes: Vector[UpdatedType] = { + val x = for { + typeInNewSchema <- newSchema.objectTypes + typeInOldSchema <- oldSchema.objectTypes.find(_.name == typeInNewSchema.oldName) + } yield { + val addedFields = typeInNewSchema.fields.filter(fieldInNewType => typeInOldSchema.fields.forall(_.name != fieldInNewType.oldName)) + + val removedFields = typeInOldSchema.fields.filter(fieldInOldType => typeInNewSchema.fields.forall(_.oldName != fieldInOldType.name)) + + val updatedFields = (typeInNewSchema.fields diff addedFields).map { updatedField => + UpdatedField(updatedField.name, updatedField.oldName, updatedField.fieldType.namedType.name) + } + + UpdatedType( + name = typeInNewSchema.name, + oldName = typeInNewSchema.oldName, + addedFields = addedFields.map(_.name).toList, + removedFields = removedFields.map(_.name).toList, + updatedFields = updatedFields.toList + ) + } + x.filter(_.hasChanges) + } + + val addedEnums: Vector[String] = newSchema.oldEnumNames diff oldSchema.enumNames + val removedEnums: Vector[String] = oldSchema.enumNames diff newSchema.oldEnumNames + val updatedEnums: Vector[UpdatedEnum] = { + for { + typeInNewSchema <- newSchema.enumTypes + typeInOldSchema <- oldSchema.enumTypes.find(_.name == typeInNewSchema.oldName) + } yield UpdatedEnum(name = typeInNewSchema.name, oldName = typeInOldSchema.name) + } +} +case class UpdatedType( + name: String, + oldName: String, + addedFields: List[String], + removedFields: List[String], + updatedFields: List[UpdatedField] +) { + def hasChanges: Boolean = addedFields.nonEmpty || removedFields.nonEmpty || updatedFields.nonEmpty +} +case class UpdatedField( + name: String, + oldName: String, + newType: String +) + +case class UpdatedEnum( + name: String, + oldName: String +) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaExport.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaExport.scala new file mode 100644 index 0000000000..adddeaaa0f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaExport.scala @@ -0,0 +1,143 @@ +package cool.graph.system.migration.dataSchema + +import cool.graph.GCDataTypes.{GCSangriaValueConverter, GCStringConverter} +import cool.graph.shared.models +import cool.graph.shared.models.{Model, Project, TypeIdentifier} +import cool.graph.system.database.SystemFields +import cool.graph.system.migration.project.{DatabaseSchemaExport, FileContainer} +import sangria.ast._ +import sangria.renderer.QueryRenderer + +object SchemaExport { + + def renderSchema(project: Project): String = { + renderDefinitions(buildObjectTypeDefinitions(project) ++ buildEnumDefinitions(project), project) + } + + def renderTypeSchema(project: Project): String = { + renderDefinitions(buildObjectTypeDefinitions(project), project) + } + + def renderEnumSchema(project: Project): String = { + renderDefinitions(buildEnumDefinitions(project), project) + } + + def renderDefinitions(definitions: Vector[TypeDefinition], project: Project): String = { + def positionOfTypeDef(typeDef: TypeDefinition): Option[Long] = { + project.getModelByName(typeDef.name).orElse(project.getEnumByName(typeDef.name)).map(_.id) match { + case Some(id) => + val index = project.typePositions.indexOf(id) + if (index > -1) Some(index) else None + case None => + None // this can't happen unless this method receives a type definition which we can't lookup correctly, e.g. we introduce interfaces to the project + } + } + def positionOfFieldDef(modelName: String)(fieldDef: FieldDefinition): Option[Long] = { + for { + model <- project.getModelByName(modelName) + field <- model.getFieldByName(fieldDef.name) + tmp = model.fieldPositions.indexOf(field.id) + index <- if (tmp > -1) Some(tmp.toLong) else None + } yield index + } + def sortFn[T](index: T => Option[Long], name: T => String)(element1: T, element2: T): Boolean = { + (index(element1), index(element2)) match { + case (Some(index1), Some(index2)) => index1 < index2 + case (Some(_), None) => true + case (None, Some(_)) => false + case (None, None) => name(element1) < name(element2) + } + } + + val sortedDefinitions = definitions + .sortWith(sortFn(positionOfTypeDef, _.name)) + .map { + case obj: ObjectTypeDefinition => + val sortedFields = obj.fields.sortWith(sortFn(positionOfFieldDef(obj.name), _.name)) + obj.copy(fields = sortedFields) + case x => x + } + + QueryRenderer.render(new Document(definitions = sortedDefinitions)) + } + + def buildFieldDefinition(project: models.Project, model: models.Model, field: models.Field) = { + val typeName: String = field.typeIdentifier match { + case TypeIdentifier.Relation => field.relatedModel(project).get.name + case TypeIdentifier.Enum => field.enum.map(_.name).getOrElse(sys.error("Enum must be not null if the typeIdentifier is enum.")) + case t => TypeIdentifier.toSangriaScalarType(t).name + } + + val fieldType = (field.isList, field.isRequired, field.isRelation) match { + case (false, false, _) => NamedType(typeName) + case (false, true, _) => NotNullType(NamedType(typeName)) + case (true, false, false) => ListType(NotNullType(NamedType(typeName))) + case (true, _, _) => NotNullType(ListType(NotNullType(NamedType(typeName)))) + } + + val relationDirective = field.relation.map { relation => + Directive(name = "relation", arguments = Vector(Argument(name = "name", value = StringValue(relation.name)))) + } + + val isUniqueDirective = if (field.isUnique) Some(Directive(name = "isUnique", arguments = Vector.empty)) else None + + val defaultValueDirective = field.defaultValue.map { dV => + val defaultValue = GCStringConverter(field.typeIdentifier, field.isList).fromGCValue(dV) + val argumentValue = if (field.isList) { + StringValue(defaultValue) + } else { + field.typeIdentifier match { + case TypeIdentifier.Enum => EnumValue(defaultValue) + case TypeIdentifier.Boolean => BooleanValue(defaultValue.toBoolean) + case TypeIdentifier.Int => IntValue(defaultValue.toInt) + case TypeIdentifier.Float => FloatValue(defaultValue.toDouble) + case _ => StringValue(defaultValue) + } + } + + Directive(name = "defaultValue", arguments = Vector(Argument(name = "value", value = argumentValue))) + } + + FieldDefinition(name = field.name, + fieldType = fieldType, + arguments = Vector.empty, + directives = Vector(relationDirective, isUniqueDirective, defaultValueDirective).flatten) + } + + def buildObjectTypeDefinitions(project: Project): Vector[ObjectTypeDefinition] = { + project.models + .map { model => + val fields = model.fields + .map { field => + buildFieldDefinition(project, model, field) + } + .sortBy(_.name) + .toVector + val atModel = Directive(name = "model", arguments = Vector.empty) + val comments = Vector() + + // just add directive to all that implement node? + ObjectTypeDefinition(model.name, interfaces = Vector.empty, fields = fields, directives = Vector(atModel), comments = comments) + +// ObjectTypeDefinition(model.name, interfaces = Vector(NamedType("Node")), fields = fields, directives = directives, comments = comments) + } + // stable order is desirable + .sortBy(_.name) + .toVector + } + + def buildEnumDefinitions(project: Project): Vector[EnumTypeDefinition] = { + project.enums.map { enum => + EnumTypeDefinition( + name = enum.name, + values = enum.values.map(v => EnumValueDefinition(v)).toVector + ) + }.toVector + } + + def addSystemFields(model: Model): Model = { + val missingFields = SystemFields.generateAll.filter(f => !model.fields.exists(_.name == f.name)) + + model.copy(fields = model.fields ++ missingFields) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaFileHeader.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaFileHeader.scala new file mode 100644 index 0000000000..639161e59f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaFileHeader.scala @@ -0,0 +1,26 @@ +package cool.graph.system.migration.dataSchema + +import scala.util.Try + +case class SchemaFileHeader(projectId: String, version: Int) + +object SchemaFileHeader { + def parseFromSchema(schema: String): Option[SchemaFileHeader] = { + def strintToIntOpt(s: String): Option[Int] = Try(s.toInt).toOption + val frontMatterMap: Map[String, String] = (for { + line <- schema.lines.toSeq.map(_.trim) + if line.startsWith("#") + x = line.stripPrefix("#") + elements = x.split(':') + if elements.size == 2 + key = elements(0).trim + value = elements(1).trim + } yield (key, value)).toMap + + for { + projectId <- frontMatterMap.get("projectId").orElse(frontMatterMap.get("project")) + version <- frontMatterMap.get("version") + versionAsInt <- strintToIntOpt(version) + } yield SchemaFileHeader(projectId = projectId, version = versionAsInt) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaMigrator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaMigrator.scala new file mode 100644 index 0000000000..6aaecc30da --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SchemaMigrator.scala @@ -0,0 +1,356 @@ +package cool.graph.system.migration.dataSchema + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.Types.Id +import cool.graph.shared.TypeInfo +import cool.graph.shared.models.{Project, TypeIdentifier} +import cool.graph.system.database.SystemFields +import cool.graph.system.migration.Diff +import cool.graph.system.mutations._ +import sangria.ast.FieldDefinition + +import scala.collection.Seq + +object SchemaMigrator { + def apply(project: Project, newSchema: String, clientMutationId: Option[String]): SchemaMigrator = { + val oldSchema = SchemaExport.renderSchema(project) + val result = SchemaDiff(oldSchema, newSchema).get + SchemaMigrator(result, project, clientMutationId) + } +} + +case class SchemaMigrator(diffResult: SchemaDiff, project: Project, clientMutationId: Option[String]) { + import DataSchemaAstExtensions._ + + def determineActionsForUpdate(): UpdateSchemaActions = { + UpdateSchemaActions( + modelsToAdd = modelsToAdd, + modelsToUpdate = modelsToUpdate, + modelsToRemove = modelsToRemove, + enumsToAdd = enumsToAdd, + enumsToUpdate = enumsToUpdate, + enumsToRemove = enumsToRemove, + relationsToAdd = relationsToAdd, + relationsToRemove = relationsToRemove, + relationsToUpdate = relationsToRename + ) + } + + def determineActionsForInit(): InitSchemaActions = { + // as this is the case for initializing a schema, only system models can be updated at this point. We just ignore updated & removed fields here. + val systemModelsToUpdate = modelsToUpdate.map { updateModelAction => + updateModelAction.copy(removeFields = List.empty, updateFields = List.empty) + } + InitSchemaActions( + modelsToAdd = modelsToAdd, + modelsToUpdate = systemModelsToUpdate, + enumsToAdd = enumsToAdd, + relationsToAdd = relationsToAdd + ) + } + + lazy val modelsToRemove: Seq[DeleteModelAction] = { + diffResult.removedTypes + .map { removedType => + project + .getModelByName(removedType) + .getOrElse(sys.error(s"Did not find removedType $removedType in the project")) + } + .filter(model => !model.isSystem || project.isEjected) + .map(model =>DeleteModelAction(modelName = model.name, input = DeleteModelInput(clientMutationId, model.id))) + } + + lazy val modelsToAdd: List[AddModelAction] = diffResult.addedTypes.map { addedType => + val objectType = diffResult.newSchema.objectTypes.find(_.name == addedType).get + + val addModel = AddModelInput( + clientMutationId = clientMutationId, + projectId = project.id, + modelName = addedType, + description = None, + fieldPositions = None + ) + val addFields = objectType.nonRelationFields.filter(f => f.name != SystemFields.idFieldName).map { fieldDef => + AddFieldAction(getAddFieldInputForFieldDef(fieldDef, addModel.id)) + } + + AddModelAction(addModel, addFields.toList) + }.toList + + lazy val relationsToAdd: Seq[AddRelationAction] = { + val addRelationActions = + diffResult.newSchema.objectTypes.flatMap(objectType => objectType.relationFields.map(rf => objectType.name -> rf)).groupBy(_._2.oldRelationName.get).map { + case (relationName, modelNameAndFieldList) => + require( + modelNameAndFieldList.size == 2 || modelNameAndFieldList.size == 1, + s"There must be either 1 or 2 fields with same relation name directive. Relation was $relationName. There were ${modelNameAndFieldList.size} fields instead." + ) + val (leftModelName, leftField) = modelNameAndFieldList.head + val (rightModelName, rightField) = modelNameAndFieldList.last + val leftModelId = findModelIdForName(leftModelName) + val rightModelId = findModelIdForName(rightModelName) + + val input = AddRelationInput( + clientMutationId = clientMutationId, + projectId = project.id, + description = None, + name = relationName, + leftModelId = leftModelId, + rightModelId = rightModelId, + fieldOnLeftModelName = leftField.name, + fieldOnRightModelName = rightField.name, + fieldOnLeftModelIsList = leftField.isList, + fieldOnRightModelIsList = rightField.isList, + fieldOnLeftModelIsRequired = leftField.fieldType.isRequired, + fieldOnRightModelIsRequired = rightField.fieldType.isRequired + ) + AddRelationAction(input = input, leftModelName = leftModelName, rightModelName = rightModelName) + } + + val removedModelIds = modelsToRemove.map(_.input).map(_.modelId) + val removedRelationIds = + project.relations.filter(relation => removedModelIds.contains(relation.modelAId) || removedModelIds.contains(relation.modelBId)).map(_.id) + + val projectWithoutRemovedRelations = project.copy(relations = project.relations.filter(relation => !removedRelationIds.contains(relation.id))) + + val filteredAddRelationActions = addRelationActions + .filter(addRelation => !RelationDiff.projectContainsRelation(projectWithoutRemovedRelations, addRelation)) + .toSeq + + filteredAddRelationActions + } + + lazy val relationsToRemove: Seq[DeleteRelationAction] = { + for { + relation <- project.relations + if !RelationDiff.schemaContainsRelation(project, diffResult.newSchema, relation) + oneOfTheModelsWasRemoved = modelsToRemove.exists { remove => + remove.input.modelId == relation.modelAId || remove.input.modelId == relation.modelBId + } + if !oneOfTheModelsWasRemoved + } yield { + val input = DeleteRelationInput( + clientMutationId = clientMutationId, + relationId = relation.id + ) + val leftModel = relation.getModelA_!(project) + val rightModel = relation.getModelB_!(project) + DeleteRelationAction( + input = input, + relationName = relation.name, + leftModelName = leftModel.name, + rightModelName = rightModel.name + ) + } + } + + lazy val relationsToRename: Seq[UpdateRelationAction] = { + val tmp = for { + objectType <- diffResult.newSchema.objectTypes + field <- objectType.fields + newRelationName <- field.relationName + oldRelationName <- field.oldRelationName + if newRelationName != oldRelationName + relation <- project.getRelationByName(oldRelationName) + } yield { + val leftModel = relation.getModelA_!(project) + val rightModel = relation.getModelB_!(project) + UpdateRelationAction( + input = UpdateRelationInput( + clientMutationId = None, + id = relation.id, + description = None, + name = Some(newRelationName), + leftModelId = None, + rightModelId = None, + fieldOnLeftModelName = None, + fieldOnRightModelName = None, + fieldOnLeftModelIsList = None, + fieldOnRightModelIsList = None, + fieldOnLeftModelIsRequired = None, + fieldOnRightModelIsRequired = None + ), + oldRelationName = oldRelationName, + newRelationName = newRelationName, + leftModelName = leftModel.name, + rightModelName = rightModel.name + ) + } + val distinctRenameActions = tmp.groupBy(_.input.name).values.map(_.head).toSeq + distinctRenameActions + } + + lazy val modelsToUpdate: Seq[UpdateModelAction] = diffResult.updatedTypes + .map { updatedType => + val model = project.getModelByName_!(updatedType.oldName) + val objectType = diffResult.newSchema.objectType(updatedType.name).get + + // FIXME: description is not evaluated yet + val updateModelInput = { + val tmp = UpdateModelInput(clientMutationId = clientMutationId, + modelId = model.id, + description = None, + name = Diff.diff(model.name, updatedType.name), + fieldPositions = None) + + if (tmp.isAnyArgumentSet()) Some(tmp) else None + } + + val fieldsToAdd = updatedType.addedFields.flatMap { addedFieldName => + val fieldDef = objectType.field(addedFieldName).get + if (fieldDef.isNoRelation) { + val input = getAddFieldInputForFieldDef(fieldDef, model.id) + Some(AddFieldAction(input)) + } else { + None + } + } + + val fieldsToRemove = updatedType.removedFields + .map(model.getFieldByName_!) + .filter(field => (!field.isSystem || (field.isSystem && SystemFields.isDeletableSystemField(field.name))) && !field.isRelation) + .map { removedField => + val input = DeleteFieldInput(clientMutationId = clientMutationId, removedField.id) + DeleteFieldAction(input = input, fieldName = removedField.name) + } + + val fieldsToUpdate = updatedType.updatedFields + .map { updatedField => + val newFieldDef = diffResult.newSchema.objectType(updatedType.name).get.field(updatedField.name).get + val currentField = model.getFieldByName_!(updatedField.oldName) + val newEnumId = findEnumIdForNameOpt(updatedField.newType) + val newTypeIdentifier = TypeInfo.extract(newFieldDef, None, diffResult.newSchema.enumTypes, false).typeIdentifier + + val oldDefaultValue = currentField.defaultValue.map(GCStringConverter(currentField.typeIdentifier, currentField.isList).fromGCValue) + val newDefaultValue = newFieldDef.defaultValue + + val inputDefaultValue = (oldDefaultValue, newDefaultValue) match { + case (Some(oldDV), None) => Some(None) + case (Some(oldDV), Some(newDV)) if oldDV == newDV => None + case (_, Some(newDV)) => Some(Some(newDV)) + case (None, None) => None + } + + //description cant be reset to null at the moment. it would need a similar behavior to defaultValue + + import Diff._ + val input = UpdateFieldInput( + clientMutationId = clientMutationId, + fieldId = model.getFieldByName_!(updatedField.oldName).id, + defaultValue = inputDefaultValue, + migrationValue = newFieldDef.migrationValue, + description = diffOpt(currentField.description, newFieldDef.description), + name = diff(currentField.name, newFieldDef.name), + typeIdentifier = diff(currentField.typeIdentifier, newTypeIdentifier).map(_.toString), + isUnique = diff(currentField.isUnique, newFieldDef.isUnique), + isRequired = diff(currentField.isRequired, newFieldDef.fieldType.isRequired), + isList = diff(currentField.isList, newFieldDef.isList), + enumId = diffOpt(currentField.enum.map(_.id), newEnumId) + ) + UpdateFieldAction(input = input, fieldName = updatedField.oldName) + } + + val fieldsWithChangesToUpdate = fieldsToUpdate.filter(updateField => updateField.input.isAnyArgumentSet()) + + UpdateModelAction(updatedType.name, model.id, updateModelInput, fieldsToAdd, fieldsToRemove, fieldsWithChangesToUpdate) + } + .filter(_.hasChanges) + + lazy val enumsToAdd: Vector[AddEnumAction] = diffResult.addedEnums.map { addedEnum => + val enumType = diffResult.newSchema.enumType(addedEnum).get + val input = AddEnumInput( + clientMutationId = clientMutationId, + projectId = project.id, + name = enumType.name, + values = enumType.valuesAsStrings + ) + AddEnumAction(input) + } + + lazy val enumsToRemove: Vector[DeleteEnumAction] = diffResult.removedEnums.map { removedEnum => + val enumId = findEnumIdForName(removedEnum) + val input = DeleteEnumInput(clientMutationId = clientMutationId, enumId = enumId) + DeleteEnumAction(input, name = removedEnum) + } + + lazy val enumsToUpdate: Vector[UpdateEnumAction] = diffResult.updatedEnums + .map { updatedEnum => + val enumId = findEnumIdForUpdatedEnum(updatedEnum.oldName) + val newEnum = diffResult.newSchema.enumType(updatedEnum.name).get + newEnum.oldName + val newValues = diffResult.newSchema.enumType(updatedEnum.name).get.valuesAsStrings + val oldValues = diffResult.oldSchema.enumType(updatedEnum.oldName).get.valuesAsStrings + val input = UpdateEnumInput( + clientMutationId = clientMutationId, + enumId = enumId, + name = Diff.diff(updatedEnum.oldName, updatedEnum.name), + values = Diff.diff(oldValues, newValues), + migrationValue = newEnum.migrationValue + ) + UpdateEnumAction(input, newName = updatedEnum.name, newValues = newValues) //add migrationValue to output + } + .filter(_.input.isAnyArgumentSet()) + + def getAddFieldInputForFieldDef(fieldDef: FieldDefinition, modelId: String): AddFieldInput = { + val typeInfo = TypeInfo.extract(fieldDef, None, diffResult.newSchema.enumTypes, false) + val enumId = typeInfo.typeIdentifier match { + case TypeIdentifier.Enum => + Some(findEnumIdForName(typeInfo.typename)) + case _ => + None + } + val isRequired = if (fieldDef.isList && typeInfo.typeIdentifier == TypeIdentifier.Relation) { + false + } else { + typeInfo.isRequired + } + AddFieldInput( + clientMutationId = clientMutationId, + modelId = modelId, + name = fieldDef.name, + typeIdentifier = typeInfo.typeIdentifier, + isRequired = isRequired, + isList = typeInfo.isList, + isUnique = fieldDef.isUnique, + relationId = None, + enumId = enumId, + defaultValue = fieldDef.defaultValue, + migrationValue = fieldDef.migrationValue, + description = None + ) + } + + def findModelIdForName(modelName: String): Id = { + findModelIdForNameOpt(modelName) + .getOrElse(sys.error(s"The model $modelName was not found in current project, added models or updated models.")) + } + + def findModelIdForNameOpt(modelName: String): Option[Id] = { + val inProject: Option[Id] = project.getModelByName(modelName).map(_.id) + val inAddedModels: Option[Id] = modelsToAdd.find(_.addModel.modelName == modelName).map(_.addModel.id) + val inUpdatedModels: Option[String] = modelsToUpdate.find(_.newName == modelName).map(_.id) + inProject + .orElse(inAddedModels) + .orElse(inUpdatedModels) + } + + def findEnumIdForUpdatedEnum(enumName: String): Id = { + project + .getEnumByName(enumName) + .map(_.id) + .getOrElse(sys.error(s"The enum $enumName was not found in current project.")) + } + + def findEnumIdForName(enumName: String): Id = { + findEnumIdForNameOpt(enumName).getOrElse(sys.error(s"The enum $enumName was not found in current project, added enums or updated enums.")) + } + + def findEnumIdForNameOpt(enumName: String): Option[Id] = { + val inProject: Option[Id] = project.getEnumByName(enumName).map(_.id) + val inAddedEnums: Option[Id] = enumsToAdd.find(_.input.name == enumName).map(_.input.id) + val inUpdatedEnums = enumsToUpdate.find(_.input.name == enumName).map(_.input.enumId) + inProject + .orElse(inAddedEnums) + .orElse(inUpdatedEnums) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SdlSchemaParser.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SdlSchemaParser.scala new file mode 100644 index 0000000000..7bacf36737 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/SdlSchemaParser.scala @@ -0,0 +1,18 @@ +package cool.graph.system.migration.dataSchema + +import sangria.ast.Document +import sangria.parser.{QueryParser, SyntaxError} + +import scala.util.Try + +/** + * Parses SDL schema files. + * Accepts empty schemas + */ +object SdlSchemaParser { + def parse(schema: String): Try[Document] = { + QueryParser.parse(schema) recover { + case e: SyntaxError if e.getMessage().contains("Unexpected end of input") => Document(Vector.empty) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/Utils.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/Utils.scala new file mode 100644 index 0000000000..ec680a0849 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/Utils.scala @@ -0,0 +1,9 @@ +package cool.graph.system.migration.dataSchema + +import cool.graph.system.database.SystemFields + +object SystemUtil { + def isNotSystemField(field: String) = !generalSystemFields.contains(field) + + private val generalSystemFields = SystemFields.generateAll.map(_.name) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/DiffAwareSchemaValidator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/DiffAwareSchemaValidator.scala new file mode 100644 index 0000000000..4e3b2265db --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/DiffAwareSchemaValidator.scala @@ -0,0 +1,41 @@ +package cool.graph.system.migration.dataSchema.validation + +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.shared.models.Project +import cool.graph.system.database.SystemFields +import cool.graph.system.migration.dataSchema.SchemaDiff + +import scala.collection.immutable.Seq +import scala.util.{Failure, Success, Try} + +case class DiffAwareSchemaValidator(diffResultTry: Try[SchemaDiff], project: Project) { + + def validate(): Seq[SchemaError] = { + diffResultTry match { + case Success(schemaDiff) => validateInternal(schemaDiff) + case Failure(e) => List.empty // the Syntax Validator already returns an error for this case + } + } + + def validateInternal(schemaDiff: SchemaDiff): Seq[SchemaError] = { + validateRemovedFields(schemaDiff) ++ validateRemovedTypes(schemaDiff) + } + + def validateRemovedTypes(schemaDiff: SchemaDiff): Seq[SchemaError] = { + for { + removedType <- schemaDiff.removedTypes + model = project.getModelByName_!(removedType) + if model.isSystem && !project.isEjected + } yield SchemaErrors.systemTypeCannotBeRemoved(model.name) + } + + def validateRemovedFields(schemaDiff: SchemaDiff): Seq[SchemaError] = { + for { + updatedType <- schemaDiff.updatedTypes + model = project.getModelByName_!(updatedType.oldName) + removedField <- updatedType.removedFields + field = model.getFieldByName_!(removedField) + if field.isSystem && !SystemFields.isDeletableSystemField(field.name) + } yield SchemaErrors.systemFieldCannotBeRemoved(updatedType.name, field.name) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaErrors.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaErrors.scala new file mode 100644 index 0000000000..fe24c844a6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaErrors.scala @@ -0,0 +1,148 @@ +package cool.graph.system.migration.dataSchema.validation + +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.system.migration.dataSchema.DataSchemaAstExtensions +import sangria.ast.{EnumTypeDefinition, TypeDefinition} + +object SchemaErrors { + + import DataSchemaAstExtensions._ + + def missingIdField(typeDefinition: TypeDefinition): SchemaError = { + error(typeDefinition, "All models must specify the `id` field: `id: ID! @isUnique`") + } + + def missingUniqueDirective(fieldAndType: FieldAndType): SchemaError = { + error(fieldAndType, s"""All id fields must specify the `@isUnique` directive.""") + } + + def missingRelationDirective(fieldAndType: FieldAndType): SchemaError = { + error(fieldAndType, s"""The relation field `${fieldAndType.fieldDef.name}` must specify a `@relation` directive: `@relation(name: "MyRelation")`""") + } + + def relationDirectiveNotAllowedOnScalarFields(fieldAndType: FieldAndType): SchemaError = { + error(fieldAndType, s"""The field `${fieldAndType.fieldDef.name}` is a scalar field and cannot specify the `@relation` directive.""") + } + + def relationNameMustAppear2Times(fieldAndType: FieldAndType): SchemaError = { + error(fieldAndType, "A relation directive with a name must appear exactly 2 times.") + } + + def selfRelationMustAppearOneOrTwoTimes(fieldAndType: FieldAndType): SchemaError = { + error(fieldAndType, "A relation directive for a many to many or one to one self relation must appear either 1 or 2 times.") + } + + def typesForOppositeRelationFieldsDoNotMatch(fieldAndType: FieldAndType, other: FieldAndType): SchemaError = { + error( + fieldAndType, + s"The relation field `${fieldAndType.fieldDef.name}` has the type `${fieldAndType.fieldDef.typeString}`. But the other directive for this relation appeared on the type `${other.objectType.name}`" + ) + } + + def missingType(fieldAndType: FieldAndType) = { + error( + fieldAndType, + s"The field `${fieldAndType.fieldDef.name}` has the type `${fieldAndType.fieldDef.typeString}` but there's no type or enum declaration with that name." + ) + } + + def missingAtModelDirective(fieldAndType: FieldAndType) = { + error( + fieldAndType, + s"The model `${fieldAndType.objectType.name}` is missing the @model directive. Please add it. See: https://github.com/graphcool/graphcool/issues/817" + ) + } + + def atNodeIsDeprecated(fieldAndType: FieldAndType) = { + error( + fieldAndType, + s"The model `${fieldAndType.objectType.name}` has the implements Node annotation. This is deprecated. Please use '@model' instead. See: https://github.com/graphcool/graphcool/issues/817" + ) + } + + def duplicateFieldName(fieldAndType: FieldAndType) = { + error( + fieldAndType, + s"The type `${fieldAndType.objectType.name}` has a duplicate fieldName." + ) + } + + def duplicateTypeName(fieldAndType: FieldAndType) = { + error( + fieldAndType, + s"The name of the type `${fieldAndType.objectType.name}` occurs more than once." + ) + } + + def directiveMissesRequiredArgument(fieldAndType: FieldAndType, directive: String, argument: String) = { + error( + fieldAndType, + s"The field `${fieldAndType.fieldDef.name}` specifies the directive `@$directive` but it's missing the required argument `$argument`." + ) + } + + def directivesMustAppearExactlyOnce(fieldAndType: FieldAndType) = { + error(fieldAndType, s"The field `${fieldAndType.fieldDef.name}` specifies a directive more than once. Directives must appear exactly once on a field.") + } + + def manyRelationFieldsMustBeRequired(fieldAndType: FieldAndType) = { + error(fieldAndType, s"Many relation fields must be marked as required.") + } + + def relationFieldTypeWrong(fieldAndType: FieldAndType): SchemaError = { + val oppositeType = fieldAndType.fieldDef.fieldType.namedType.name + error(fieldAndType, s"""The relation field `${fieldAndType.fieldDef.name}` has the wrong format: `${fieldAndType.fieldDef.typeString}` Possible Formats: `$oppositeType`, `$oppositeType!`, `[$oppositeType!]!`""") //todo + } + + def scalarFieldTypeWrong(fieldAndType: FieldAndType): SchemaError = { + val scalarType = fieldAndType.fieldDef.fieldType.namedType.name + error(fieldAndType, s"""The scalar field `${fieldAndType.fieldDef.name}` has the wrong format: `${fieldAndType.fieldDef.typeString}` Possible Formats: `$scalarType`, `$scalarType!`, `[$scalarType!]` or `[$scalarType!]!`""") + } + + def enumValuesMustBeginUppercase(enumType: EnumTypeDefinition) = { + error(enumType, s"The enum type `${enumType.name}` contains invalid enum values. The first character of each value must be an uppercase letter.") + } + + def enumValuesMustBeValid(enumType: EnumTypeDefinition, enumValues: Seq[String]) = { + error(enumType, s"The enum type `${enumType.name}` contains invalid enum values. Those are invalid: ${enumValues.map(v => s"`$v`").mkString(", ")}.") + } + + def systemFieldCannotBeRemoved(theType: String, field: String) = { + SchemaError(theType, field, s"The field `$field` is a system field and cannot be removed.") + } + + def systemTypeCannotBeRemoved(theType: String) = { + SchemaError(theType, s"The type `$theType` is a system type and cannot be removed.") + } + + def schemaFileHeaderIsMissing() = { + SchemaError.global(s"""The schema must specify the project id and version as a front matter, e.g.: + |# projectId: your-project-id + |# version: 3 + |type MyType { + | myfield: String! + |} + """.stripMargin) + } + + def schemaFileHeaderIsReferencingWrongVersion(expected: Int) = { + SchemaError.global(s"The schema is referencing the wrong project version. Expected version $expected.") + } + + def error(fieldAndType: FieldAndType, description: String) = { + SchemaError(fieldAndType.objectType.name, fieldAndType.fieldDef.name, description) + } + + def error(typeDef: TypeDefinition, description: String) = { + SchemaError(typeDef.name, description) + } + + // note: the cli relies on the string "destructive changes" being present in this error message. Ugly but effective + def forceArgumentRequired: SchemaError = { + SchemaError.global("Your migration includes potentially destructive changes. Review using `graphcool diff` and continue using `graphcool deploy --force`.") + } + + def invalidEnv(message: String) = { + SchemaError.global(s"""the environment file is invalid: $message""") + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaSyntaxValidator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaSyntaxValidator.scala new file mode 100644 index 0000000000..76a73eb5a6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaSyntaxValidator.scala @@ -0,0 +1,251 @@ +package cool.graph.system.migration.dataSchema.validation + +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.shared.models.TypeIdentifier +import cool.graph.system.migration.dataSchema.{DataSchemaAstExtensions, SdlSchemaParser} +import sangria.ast.{Directive, FieldDefinition, ObjectTypeDefinition} + +import scala.collection.immutable.Seq +import scala.util.{Failure, Success} + +case class DirectiveRequirement(directiveName: String, arguments: Seq[RequiredArg]) +case class RequiredArg(name: String, mustBeAString: Boolean) + +case class FieldAndType(objectType: ObjectTypeDefinition, fieldDef: FieldDefinition) + +object SchemaSyntaxValidator { + def apply(schema: String): SchemaSyntaxValidator = { + SchemaSyntaxValidator(schema, directiveRequirements) + } + + val directiveRequirements = Seq( + DirectiveRequirement("model", Seq.empty), + DirectiveRequirement("relation", Seq(RequiredArg("name", mustBeAString = true))), + DirectiveRequirement("rename", Seq(RequiredArg("oldName", mustBeAString = true))), + DirectiveRequirement("defaultValue", Seq(RequiredArg("value", mustBeAString = false))), + DirectiveRequirement("migrationValue", Seq(RequiredArg("value", mustBeAString = false))), + DirectiveRequirement("isUnique", Seq.empty) + ) +} + +case class SchemaSyntaxValidator(schema: String, directiveRequirements: Seq[DirectiveRequirement]) { + import DataSchemaAstExtensions._ + val result = SdlSchemaParser.parse(schema) + lazy val doc = result.get + + def validate(): Seq[SchemaError] = { + result match { + case Success(x) => validateInternal() + case Failure(e) => List(SchemaError.global(s"There's a syntax error in the Schema Definition. ${e.getMessage}")) + } + } + + def validateInternal(): Seq[SchemaError] = { + val nonSystemFieldAndTypes: Seq[FieldAndType] = for { + objectType <- doc.objectTypes + field <- objectType.fields + if field.isNotSystemField + } yield FieldAndType(objectType, field) + + val allFieldAndTypes: Seq[FieldAndType] = for { + objectType <- doc.objectTypes + field <- objectType.fields + } yield FieldAndType(objectType, field) + + val missingModelDirectiveValidations = validateModelDirectiveOnTypes(doc.objectTypes, allFieldAndTypes) + val deprecatedImplementsNodeValidations = validateNodeInterfaceOnTypes(doc.objectTypes, allFieldAndTypes) + val duplicateTypeValidations = validateDuplicateTypes(doc.objectTypes, allFieldAndTypes) + val duplicateFieldValidations = validateDuplicateFields(allFieldAndTypes) + val missingTypeValidations = validateMissingTypes(nonSystemFieldAndTypes) + val relationFieldValidations = validateRelationFields(nonSystemFieldAndTypes) + val scalarFieldValidations = validateScalarFields(nonSystemFieldAndTypes) + val fieldDirectiveValidations = nonSystemFieldAndTypes.flatMap(validateFieldDirectives) + + missingModelDirectiveValidations ++ deprecatedImplementsNodeValidations ++ validateIdFields ++ duplicateTypeValidations ++ duplicateFieldValidations ++ missingTypeValidations ++ relationFieldValidations ++ scalarFieldValidations ++ fieldDirectiveValidations ++ validateEnumTypes + } + + def validateIdFields(): Seq[SchemaError] = { + val missingUniqueDirectives = for { + objectType <- doc.objectTypes + field <- objectType.fields + if field.isIdField && !field.isUnique + } yield { + val fieldAndType = FieldAndType(objectType, field) + SchemaErrors.missingUniqueDirective(fieldAndType) + } + + val missingIdFields = for { + objectType <- doc.objectTypes + if objectType.hasNoIdField + } yield { + SchemaErrors.missingIdField(objectType) + } + missingUniqueDirectives ++ missingIdFields + } + + def validateDuplicateTypes(objectTypes: Seq[ObjectTypeDefinition], fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + val typeNames = objectTypes.map(_.name) + val duplicateTypeNames = typeNames.filter(name => typeNames.count(_ == name) > 1) + duplicateTypeNames.map(name => SchemaErrors.duplicateTypeName(fieldAndTypes.find(_.objectType.name == name).head)).distinct + } + + def validateModelDirectiveOnTypes(objectTypes: Seq[ObjectTypeDefinition], fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + objectTypes.collect { + case x if !x.directives.exists(_.name == "model") => SchemaErrors.missingAtModelDirective(fieldAndTypes.find(_.objectType.name == x.name).get) + } + } + + def validateNodeInterfaceOnTypes(objectTypes: Seq[ObjectTypeDefinition], fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + objectTypes.collect { + case x if x.interfaces.exists(_.name == "Node") => SchemaErrors.atNodeIsDeprecated(fieldAndTypes.find(_.objectType.name == x.name).get) + } + } + + def validateDuplicateFields(fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + val objectTypes = fieldAndTypes.map(_.objectType) + val distinctObjectTypes = objectTypes.distinct + distinctObjectTypes + .flatMap(objectType => { + val fieldNames = objectType.fields.map(_.name) + fieldNames.map( + name => + if (fieldNames.count(_ == name) > 1) + Seq(SchemaErrors.duplicateFieldName(fieldAndTypes.find(ft => ft.objectType == objectType & ft.fieldDef.name == name).get)) + else Seq.empty) + }) + .flatten + .distinct + } + + def validateMissingTypes(fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + fieldAndTypes + .filter(!isScalarField(_)) + .collect { + case fieldAndType if !doc.isObjectOrEnumType(fieldAndType.fieldDef.typeName) => + SchemaErrors.missingType(fieldAndType) + } + } + + def validateRelationFields(fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + val relationFields = fieldAndTypes.filter(isRelationField) + + val wrongTypeDefinitions = relationFields.collect{case fieldAndType if !fieldAndType.fieldDef.isValidRelationType => SchemaErrors.relationFieldTypeWrong(fieldAndType)} + + val (schemaErrors, validRelationFields) = partition(relationFields) { + case fieldAndType if !fieldAndType.fieldDef.hasRelationDirective => + Left(SchemaErrors.missingRelationDirective(fieldAndType)) + + case fieldAndType if !isSelfRelation(fieldAndType) && relationCount(fieldAndType) != 2 => + Left(SchemaErrors.relationNameMustAppear2Times(fieldAndType)) + + case fieldAndType if isSelfRelation(fieldAndType) && relationCount(fieldAndType) != 1 && relationCount(fieldAndType) != 2 => + Left(SchemaErrors.selfRelationMustAppearOneOrTwoTimes(fieldAndType)) + + case fieldAndType => + Right(fieldAndType) + } + + val relationFieldsWithNonMatchingTypes = validRelationFields + .groupBy(_.fieldDef.oldRelationName.get) + .flatMap { + case (_, fieldAndTypes) => + val first = fieldAndTypes.head + val second = fieldAndTypes.last + val firstError = if (first.fieldDef.typeName != second.objectType.name) { + Option(SchemaErrors.typesForOppositeRelationFieldsDoNotMatch(first, second)) + } else { + None + } + val secondError = if (second.fieldDef.typeName != first.objectType.name) { + Option(SchemaErrors.typesForOppositeRelationFieldsDoNotMatch(second, first)) + } else { + None + } + firstError ++ secondError + } + + wrongTypeDefinitions ++ schemaErrors ++ relationFieldsWithNonMatchingTypes + } + + def validateScalarFields(fieldAndTypes: Seq[FieldAndType]): Seq[SchemaError] = { + val scalarFields = fieldAndTypes.filter(isScalarField) + scalarFields.collect{case fieldAndType if !fieldAndType.fieldDef.isValidScalarType => SchemaErrors.scalarFieldTypeWrong(fieldAndType)} + } + + def validateFieldDirectives(fieldAndType: FieldAndType): Seq[SchemaError] = { + def validateDirectiveRequirements(directive: Directive): Seq[SchemaError] = { + for { + requirement <- directiveRequirements if requirement.directiveName == directive.name + requiredArg <- requirement.arguments + schemaError <- if (!directive.containsArgument(requiredArg.name, requiredArg.mustBeAString)) { + Some(SchemaErrors.directiveMissesRequiredArgument(fieldAndType, requirement.directiveName, requiredArg.name)) + } else { + None + } + } yield schemaError + } + + def ensureDirectivesAreUnique(fieldAndType: FieldAndType): Option[SchemaError] = { + val directives = fieldAndType.fieldDef.directives + val uniqueDirectives = directives.map(_.name).toSet + if (uniqueDirectives.size != directives.size) { + Some(SchemaErrors.directivesMustAppearExactlyOnce(fieldAndType)) + } else { + None + } + } + + def ensureRelationDirectivesArePlacedCorrectly(fieldAndType: FieldAndType): Option[SchemaError] = { + if (!isRelationField(fieldAndType.fieldDef) && fieldAndType.fieldDef.hasRelationDirective) { + Some(SchemaErrors.relationDirectiveNotAllowedOnScalarFields(fieldAndType)) + } else { + None + } + } + + fieldAndType.fieldDef.directives.flatMap(validateDirectiveRequirements) ++ + ensureDirectivesAreUnique(fieldAndType) ++ + ensureRelationDirectivesArePlacedCorrectly(fieldAndType) + } + + def validateEnumTypes: Seq[SchemaError] = { + doc.enumTypes.flatMap { enumType => + val invalidEnumValues = enumType.valuesAsStrings.filter(!NameConstraints.isValidEnumValueName(_)) + + if (enumType.values.exists(value => value.name.head.isLower)) { + Some(SchemaErrors.enumValuesMustBeginUppercase(enumType)) + } else if (invalidEnumValues.nonEmpty) { + Some(SchemaErrors.enumValuesMustBeValid(enumType, invalidEnumValues)) + } else { + None + } + } + } + + def relationCount(fieldAndType: FieldAndType): Int = relationCount(fieldAndType.fieldDef.oldRelationName.get) + def relationCount(relationName: String): Int = { + val tmp = for { + objectType <- doc.objectTypes + field <- objectType.relationFields + if field.oldRelationName.contains(relationName) + } yield field + tmp.size + } + + def isSelfRelation(fieldAndType: FieldAndType): Boolean = fieldAndType.fieldDef.typeName == fieldAndType.objectType.name + def isRelationField(fieldAndType: FieldAndType): Boolean = isRelationField(fieldAndType.fieldDef) + def isRelationField(fieldDef: FieldDefinition): Boolean = !isScalarField(fieldDef) && !isEnumField(fieldDef) + + def isScalarField(fieldAndType: FieldAndType): Boolean = isScalarField(fieldAndType.fieldDef) + def isScalarField(fieldDef: FieldDefinition): Boolean = TypeIdentifier.withNameOpt(fieldDef.typeName).isDefined + + def isEnumField(fieldDef: FieldDefinition): Boolean = doc.enumType(fieldDef.typeName).isDefined + + def partition[A, B, C](seq: Seq[A])(parititionFn: A => Either[B, C]): (Seq[B], Seq[C]) = { + val mapped = seq.map(parititionFn) + val lefts = mapped.collect { case Left(x) => x } + val rights = mapped.collect { case Right(x) => x } + (lefts, rights) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaValidator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaValidator.scala new file mode 100644 index 0000000000..82be6d3f96 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/dataSchema/validation/SchemaValidator.scala @@ -0,0 +1,23 @@ +package cool.graph.system.migration.dataSchema.validation + +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.shared.models.Project +import cool.graph.system.migration.dataSchema.{SchemaDiff, SchemaExport, SchemaFileHeader} + +import scala.collection.immutable.Seq + +case class SchemaValidator(schemaSyntaxValidator: SchemaSyntaxValidator, diffAwareSchemaValidator: DiffAwareSchemaValidator) { + def validate(): Seq[SchemaError] = { + schemaSyntaxValidator.validate() ++ diffAwareSchemaValidator.validate() + } +} + +object SchemaValidator { + def apply(project: Project, newSchema: String, schemaFileHeader: SchemaFileHeader): SchemaValidator = { + val oldSchema = SchemaExport.renderSchema(project) + SchemaValidator( + SchemaSyntaxValidator(newSchema), + DiffAwareSchemaValidator(SchemaDiff(oldSchema, newSchema), project) + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/functions/FunctionDiff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/functions/FunctionDiff.scala new file mode 100644 index 0000000000..561840d562 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/functions/FunctionDiff.scala @@ -0,0 +1,144 @@ +package cool.graph.system.migration.functions + +import cool.graph.shared.models +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.{Auth0Function, Project, SchemaExtensionFunction, ServerSideSubscriptionFunction} +import cool.graph.system.migration.ProjectConfig +import cool.graph.system.migration.ProjectConfig.Ast.Function +import cool.graph.system.migration.ProjectConfig.{Ast, FileContainerBundle, FunctionWithFiles} +import cool.graph.system.migration.project.FileContainer + +case class FunctionDiff(oldProject: Project, oldModule: Ast.Module, functions: Map[String, Function], files: Map[String, String]) { + import cool.graph.system.migration.Diff + + def functionNames: Vector[String] = functions.keys.toVector + def function_!(name: String): Function = functions(name) + def namedFunction_!(name: String, files: Map[String, String]): FunctionWithFiles = { + val function: Function = functions(name) + + val fileContainerBundle = { + def createFileContainer(codePath: Option[String]) = codePath.flatMap(path => files.get(path).map(content => FileContainer(path, content))) + + val codePath = function.handler.code.map(_.src) + val schemaPath = function.schema + val queryPath = function.query + + FileContainerBundle(codeContainer = createFileContainer(codePath), + schemaContainer = createFileContainer(schemaPath), + queryContainer = createFileContainer(queryPath)) + } + + FunctionWithFiles(name = name, function = function, fileContainers = fileContainerBundle) + } + + private val addedFunctions: Vector[String] = functionNames diff oldModule.functionNames + + private val removedFunctions: Vector[String] = oldModule.functionNames diff functionNames + private val functionsWithSameName: Vector[UpdatedFunction] = { + val potentiallyUpdatedFunctions = functionNames diff addedFunctions + val functionsWithSameName = { + potentiallyUpdatedFunctions + .map { functionName => + val oldFunction: Ast.Function = oldModule.function_!(functionName) + val oldProjectFunction: models.Function = oldProject.getFunctionByName(functionName).getOrElse(sys.error("that is supposed to be there...")) + val newFunction: Ast.Function = function_!(functionName) + + val oldSchema = oldProjectFunction match { + case x: SchemaExtensionFunction => Some(x.schema) + case _ => None + } + val oldSchemaFilePath = oldProjectFunction match { + case x: SchemaExtensionFunction => x.schemaFilePath + case _ => None + } + + val oldCode = oldProjectFunction.delivery match { + case x: Auth0Function => Some(x.code) + case _ => None + } + val oldCodeFilePath = oldProjectFunction.delivery match { + case x: Auth0Function => x.codeFilePath + case _ => None + } + + val oldQuery = oldProjectFunction match { + case x: ServerSideSubscriptionFunction => Some(x.query) + case _ => None + } + val oldQueryFilePath = oldProjectFunction match { + case x: ServerSideSubscriptionFunction => x.queryFilePath + case _ => None + } + + val x = UpdatedFunction( + name = functionName, + description = Diff.diffOpt(oldFunction.description, newFunction.description), + handlerWebhookUrl = Diff.diffOpt(oldFunction.handler.webhook.map(_.url), newFunction.handler.webhook.map(_.url)), + handlerWebhookHeaders = Diff.diffOpt(oldFunction.handler.webhook.map(_.headers), newFunction.handler.webhook.map(_.headers)), + handlerCodeSrc = Diff.diffOpt(oldFunction.handler.code.map(_.src), newFunction.handler.code.map(_.src)), + binding = Diff.diff(oldFunction.binding, newFunction.binding), + schema = Diff.diffOpt(oldSchema, newFunction.schema.map(path => files.getOrElse(path, sys.error("The schema file path was not supplied")))), + schemaFilePath = Diff.diffOpt(oldSchemaFilePath, newFunction.schema), + code = Diff.diffOpt(oldCode, newFunction.handler.code.flatMap(x => files.get(x.src))), + codeFilePath = Diff.diffOpt(oldCodeFilePath, newFunction.handler.code.map(_.src)), // this triggers the diff for functions with external files , we need this to redeploy them on every push + query = Diff.diffOpt(oldQuery, newFunction.query.map(path => files.getOrElse(path, sys.error("The query file path was not supplied")))), + queryFilePath = Diff.diffOpt(oldQueryFilePath, newFunction.query), + operation = Diff.diffOpt(oldFunction.operation, newFunction.operation), + `type` = Diff.diff(oldFunction.`type`, newFunction.`type`) + ) + x + } + .filter(_.hasChanges) + } + functionsWithSameName + } + + // updated functions that have a binding change are not really updated. + // in this case it is the deletion of a function with the old binding and afterwards the creation of a function with the same name under the new binding + + // for a real update we should introduce a way to keep the logs once we have a migrationConcept + + val differentFunctionsUnderSameName = functionsWithSameName.filter(updatedFunction => updatedFunction.binding.nonEmpty) + val updatedFunctions = functionsWithSameName.filter(updatedFunction => updatedFunction.binding.isEmpty) + + val namedUpdatedFunctions = updatedFunctions.map(x => namedFunction_!(x.name, files)) + val namedAddedFunctions = (addedFunctions ++ differentFunctionsUnderSameName.map(_.name)).map(namedFunction_!(_, files)) + val namedRemovedFunctions = (removedFunctions ++ differentFunctionsUnderSameName.map(_.name)).map(oldModule.namedFunction_!(_, files)) + + def isRequestPipelineFunction(x: ProjectConfig.FunctionWithFiles) = x.function.`type` == "operationBefore" || x.function.`type` == "operationAfter" + + val updatedSubscriptionFunctions = namedUpdatedFunctions.filter(_.function.`type` == "subscription") + val updatedRequestPipelineFunctions = namedUpdatedFunctions.filter(isRequestPipelineFunction) + val updatedSchemaExtensionFunctions = namedUpdatedFunctions.filter(_.function.`type` == "resolver") + + val addedSubscriptionFunctions = namedAddedFunctions.filter(_.function.`type` == "subscription") + val addedRequestPipelineFunctions = namedAddedFunctions.filter(isRequestPipelineFunction) + val addedSchemaExtensionFunctions = namedAddedFunctions.filter(_.function.`type` == "resolver") + + val removedSubscriptionFunctions = namedRemovedFunctions.filter(_.function.`type` == "subscription") + val removedRequestPipelineFunctions = namedRemovedFunctions.filter(isRequestPipelineFunction) + val removedSchemaExtensionFunctions = namedRemovedFunctions.filter(_.function.`type` == "resolver") + +} + +case class UpdatedFunction( + name: String, + description: Option[String], + handlerWebhookUrl: Option[String], + handlerWebhookHeaders: Option[Map[String, String]], + handlerCodeSrc: Option[String], + binding: Option[FunctionBinding], + schema: Option[String], + schemaFilePath: Option[String], + code: Option[String], + codeFilePath: Option[String], + query: Option[String], + queryFilePath: Option[String], + operation: Option[String], + `type`: Option[String] +) { + def hasChanges: Boolean = { + description.nonEmpty || handlerWebhookUrl.nonEmpty || handlerWebhookHeaders.nonEmpty || handlerCodeSrc.nonEmpty || schema.nonEmpty || schemaFilePath.nonEmpty || code.nonEmpty || codeFilePath.nonEmpty || query.nonEmpty || queryFilePath.nonEmpty || operation.nonEmpty || `type`.nonEmpty + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/permissions/PermissionsDiff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/permissions/PermissionsDiff.scala new file mode 100644 index 0000000000..9bfcc10424 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/permissions/PermissionsDiff.scala @@ -0,0 +1,348 @@ +package cool.graph.system.migration.permissions + +import cool.graph.client.UserContext +import cool.graph.shared.errors.UserInputErrors.{ModelOrRelationForPermissionDoesNotExist, QueryPermissionParseError} +import cool.graph.shared.models._ +import cool.graph.shared.queryPermissions.PermissionSchemaResolver +import cool.graph.system.migration.ProjectConfig._ +import cool.graph.system.migration.permissions.QueryPermissionHelper._ +import cool.graph.system.migration.project.FileContainer +import sangria.ast.{Document, OperationDefinition} +import sangria.schema.Schema +import sangria.validation.{QueryValidator, Violation} +import scaldi.Injector + +case class PermissionDiff(project: Project, newPermissions: Vector[AstPermissionWithAllInfos], files: Map[String, String], afterSchemaMigration: Boolean)( + implicit inj: Injector) { + val containsGlobalStarPermission: Boolean = newPermissions.exists(_.astPermission.operation == "*") + + val oldPermissionsWithId: Vector[AstPermissionWithAllInfos] = astPermissionsWithAllInfosFromProject(project) + val oldPermissions: Vector[AstPermissionWithAllInfos] = oldPermissionsWithId.map(astPermissionWithoutId) + + val addedPermissions: Vector[AstPermissionWithAllInfos] = newPermissions diff oldPermissions + val removedPermissions: Vector[AstPermissionWithAllInfos] = oldPermissions diff newPermissions + + val removedPermissionIds: Vector[String] = getIdsOfRemovedPermissions + + private def getIdsOfRemovedPermissions = { + val distinctRemovedPermissions = removedPermissions.distinct + val distinctWithCount = distinctRemovedPermissions.map(perm => (perm, removedPermissions.count(_ == perm))) + + distinctWithCount.flatMap { + case (permission, removedCount) => + val oldPerms = oldPermissionsWithId.filter(_.astPermission == permission.astPermission) + oldPerms.take(removedCount).map(_.permissionId) + } + } + + val modelNames = project.models.map(_.name) + val relationNames = project.relations.map(_.name) + + val superflousPermissions = addedPermissions + .filter(p => !modelNames.contains(nameFromOperation(p)) && !relationNames.contains(nameFromOperation(p))) + .filter(_.astPermission.operation != "*") + + val superflousPermissionOperations = superflousPermissions.map(_.astPermission).map(_.operation) + + if (superflousPermissionOperations.nonEmpty && afterSchemaMigration) + throw ModelOrRelationForPermissionDoesNotExist(superflousPermissionOperations.mkString(", ")) + + val addedModelPermissions: Vector[PermissionWithModel] = addedPermissions.flatMap(permission => { + val modelName: String = permission.astPermission.operation.split("\\.")(0) + + val fileContainer: Option[FileContainer] = QueryPermissionHelper.fileContainerFromQueryPath(permission.queryPath, files) + + project + .getModelByName(modelName) + .map(model => PermissionWithModel(PermissionWithQueryFile(permission.astPermission, fileContainer), model)) + }) + + val addedRelationPermissions: Vector[PermissionWithRelation] = addedPermissions.flatMap(permission => { + val relationName: String = permission.astPermission.operation.split("\\.")(0) + + val fileContainer: Option[FileContainer] = QueryPermissionHelper.fileContainerFromQueryPath(permission.queryPath, files) + + project + .getRelationByName(relationName) + .map(relation => PermissionWithRelation(PermissionWithQueryFile(permission.astPermission, fileContainer), relation)) + }) + +} + +case class PermissionWithModel(permission: PermissionWithQueryFile, model: Model) +case class PermissionWithRelation(permission: PermissionWithQueryFile, relation: Relation) + +object QueryPermissionHelper { + import sangria.renderer.QueryRenderer + + def nameFromOperation(permission: AstPermissionWithAllInfos): String = permission.astPermission.operation.split("\\.").head + + def renderQueryForName(queryName: String, path: String, files: Map[String, String]): String = { + val (queries: String, operationDefinitions: Vector[OperationDefinition]) = operationDefinitionsAndQueriesFromPath(path, files) + + val queryToRender: OperationDefinition = + operationDefinitions.filter(operationDefinition => operationDefinition.name.contains(queryName)) match { + case x if x.length == 1 => x.head + case x if x.length > 1 => throw QueryPermissionParseError(queryName, s"There was more than one query with the name $queryName in the file. $queries") + case x if x.isEmpty => throw QueryPermissionParseError(queryName, s"There was no query with the name $queryName in the file. $queries") + } + val query = renderQueryWithoutComments(queryToRender) + query + } + + def renderQuery(path: String, files: Map[String, String]): String = { + val (queries: String, operationDefinitions: Vector[OperationDefinition]) = operationDefinitionsAndQueriesFromPath(path, files) + + val queryToRender: OperationDefinition = operationDefinitions match { + case x if x.length == 1 => x.head + case x if x.length > 1 => throw QueryPermissionParseError("NoName", s"There was more than one query and you did not provide a query name. $queries") + case x if x.isEmpty => throw QueryPermissionParseError("NoName", s"There was no query in the file. $queries") + } + val query = renderQueryWithoutComments(queryToRender) + query + } + + def renderQueryWithoutComments(input: OperationDefinition): String = QueryRenderer.render(input.copy(comments = Vector.empty)) + + def operationDefinitionsAndQueriesFromPath(path: String, files: Map[String, String]): (String, Vector[OperationDefinition]) = { + val queries = files.get(path) match { + case Some(string) => string + case None => throw QueryPermissionParseError("", s"There was no file for the path: $path provided") + } + + val doc = sangria.parser.QueryParser.parse(queries).toOption match { + case Some(document) => document + case None => throw QueryPermissionParseError("", s"Query could not be parsed. Please ensure it is valid GraphQL. $queries") + } + + val operationDefinitions = doc.definitions.collect { case x: OperationDefinition => x } + (queries, operationDefinitions) + } + + def splitPathInRuleNameAndPath(path: String): (Option[String], Option[String]) = { + path match { + case _ if path.contains(":") => + path.split(":") match { + case Array(one, two, three, _*) => throw QueryPermissionParseError(two, s"There was more than one colon in your filepath. $path") + case Array(pathPart, queryNamePart) => (Some(queryNamePart), Some(pathPart)) + } + case _ => (None, Some(path)) + } + } + + def getRuleNameFromPath(pathOption: Option[String]): Option[String] = { + pathOption match { + case Some(path) => splitPathInRuleNameAndPath(path)._1 + case None => None + } + } + + def astPermissionWithAllInfosFromAstPermission(astPermission: Ast.Permission, files: Map[String, String]): AstPermissionWithAllInfos = { + + astPermission.queryPath match { + case Some(path) => + splitPathInRuleNameAndPath(path) match { + case (Some(name), Some(pathPart)) => + AstPermissionWithAllInfos(astPermission = astPermission, + query = Some(renderQueryForName(name, pathPart, files)), + queryPath = astPermission.queryPath, + permissionId = "") + + case (None, Some(pathPart)) => + AstPermissionWithAllInfos(astPermission = astPermission, + query = Some(renderQuery(pathPart, files)), + queryPath = astPermission.queryPath, + permissionId = "") + case _ => + sys.error("This should not happen") + + } + case None => + AstPermissionWithAllInfos(astPermission = astPermission, query = None, queryPath = None, permissionId = "") + } + } + + def queryAndQueryPathFromModelPermission(model: Model, + modelPermission: ModelPermission, + alternativeRuleName: String, + project: Project): (Option[String], Option[String]) = { + modelPermission.rule match { + case CustomRule.Graph => + val args: List[(String, String)] = permissionQueryArgsFromModel(model) + queryAndQueryPathFromPermission(model.name, modelPermission.ruleName, args, modelPermission.ruleGraphQuery, alternativeRuleName) + + case _ => + (None, None) + } + } + + def permissionQueryArgsFromModel(model: Model): List[(String, String)] = { + model.scalarFields.map(field => (s"$$node_${field.name}", TypeIdentifier.toSangriaScalarType(field.typeIdentifier).name)) + } + + def queryAndQueryPathFromRelationPermission(relation: Relation, + relationPermission: RelationPermission, + alternativeRuleName: String, + project: Project): (Option[String], Option[String]) = { + relationPermission.rule match { + case CustomRule.Graph => + val args: List[(String, String)] = permissionQueryArgsFromRelation(relation, project) + queryAndQueryPathFromPermission(relation.name, relationPermission.ruleName, args, relationPermission.ruleGraphQuery, alternativeRuleName) + + case _ => + (None, None) + } + } + + def permissionQueryArgsFromRelation(relation: Relation, project: Project): List[(String, String)] = { + List(("$user_id", "ID"), (s"$$${relation.aName(project)}_id", "ID"), (s"$$${relation.bName(project)}_id", "ID")) + } + + def queryAndQueryPathFromPermission(modelOrRelationName: String, + ruleName: Option[String], + args: List[(String, String)], + ruleGraphQuery: Option[String], + alternativeRuleName: String): (Option[String], Option[String]) = { + + val queryName = ruleName match { + case None => alternativeRuleName + case Some(x) => x + } + + val generatedName = s"$modelOrRelationName.graphql:$queryName" + val queryPath = defaultPathForPermissionQuery(generatedName) + + val resultingQuery = ruleGraphQuery.map(query => prependNameAndRenderQuery(query, queryName, args)) + (Some(queryPath), resultingQuery) + } + + /** This is only a dumb printer + * it takes a query string and checks whether it is valid GraphQL after prepending + * it does not do a schema validation of the query + * it will however format the query using the Sangria Rendering and set the query name + * the queryName is either the ruleName or the alternative name ([operation][ 1,2...]) + * --- + * it will discard names on the queries that do not match the ruleName + * it will take the first query definition it finds and ignore the others + */ + def prependNameAndRenderQuery(query: String, queryName: String, args: List[(String, String)]): String = { + + def renderQueryWithCorrectNameWithSangria(doc: Document) = { + val firstDefinition: OperationDefinition = doc.definitions.collect { case x: OperationDefinition => x }.head + val definitionWithQueryName: _root_.sangria.ast.OperationDefinition = firstDefinition.copy(name = Some(queryName)) + renderQueryWithoutComments(definitionWithQueryName) + } + + def prependQueryWithHeader(query: String) = { + val usedVars = args.filter(field => query.contains(field._1)) + val vars = usedVars.map(field => s"${field._1}: ${field._2}").mkString(", ") + val queryHeader = if (usedVars.isEmpty) "query" else s"query ($vars) " + queryHeader + query + } + val prependedQuery = prependQueryWithHeader(query) + isQueryValidGraphQL(prependedQuery) match { + case None => + isQueryValidGraphQL(query) match { + case None => "# Could not parse the query. Please check that it is valid.\n" + query + case Some(doc) => renderQueryWithCorrectNameWithSangria(doc) + } + case Some(doc) => renderQueryWithCorrectNameWithSangria(doc) + } + } + + def isQueryValidGraphQL(query: String): Option[Document] = sangria.parser.QueryParser.parse(query).toOption + + def validatePermissionQuery(query: String, project: Project)(implicit inj: Injector): Vector[Violation] = { + + val permissionSchema: Schema[UserContext, Unit] = PermissionSchemaResolver.permissionSchema(project) + sangria.parser.QueryParser.parse(query).toOption match { + case None => sys.error("could not even parse the query") + case Some(doc) => QueryValidator.default.validateQuery(permissionSchema, doc) + } + } + + def bundleQueriesInOneFile(queries: Seq[String], name: String): Option[FileContainer] = { + val fileContainer = queries.isEmpty match { + case true => None + case false => Some(FileContainer(path = s"$name.graphql", content = queries.distinct.mkString("\n"))) + } + fileContainer + } + + /** Creates the fileContainer whose content will be stored in the backend + * Ensures that the query is valid GraphQL and will set the name to ruleName if one exists + * Will error on invalid GraphQL + */ + def fileContainerFromQueryPath(inputPath: Option[String], files: Map[String, String]): Option[FileContainer] = { + inputPath match { + case Some(path) => + splitPathInRuleNameAndPath(path) match { + case (Some(name), Some(pathPart)) => + Some(FileContainer(path, renderQueryForName(name, pathPart, files))) + + case (None, Some(pathPart)) => + isQueryValidGraphQL(files(pathPart)) match { + case None => throw QueryPermissionParseError("noName", s"Query could not be parsed. Please ensure it is valid GraphQL. ${files(pathPart)}") + case Some(doc) => Some(FileContainer(pathPart, QueryRenderer.render(doc))) // todo take out comments too here + } + case _ => sys.error("This should not happen.") + } + case None => + None + } + } + + def astPermissionWithoutId(permission: AstPermissionWithAllInfos): AstPermissionWithAllInfos = permission.copy(permissionId = "") + + def generateAlternativeRuleName(otherPermissionsWithSameOperationIds: List[String], permissionId: String, operation: String): String = { + val sortedOtherPermissions = otherPermissionsWithSameOperationIds.sorted + val ownIndex = sortedOtherPermissions.indexOf(permissionId) + alternativeNameFromOperationAndInt(operation, ownIndex) + } + + def alternativeNameFromOperationAndInt(operation: String, ownIndex: Int): String = { + ownIndex match { + case 0 => operation + case x => s"$operation${x + 1}" + } + } + + def astPermissionsWithAllInfosFromProject(project: Project): Vector[AstPermissionWithAllInfos] = { + + val modelPermissions = project.models.flatMap { model => + model.permissions.filter(_.isActive).map { permission => + val astPermission = Ast.Permission( + description = permission.description, + operation = s"${model.name}.${permission.operationString}", + authenticated = permission.userType == UserType.Authenticated, + queryPath = permission.ruleGraphQueryFilePath, + fields = if (permission.applyToWholeModel) { + None + } else { + Some(permission.fieldIds.toVector.map(id => model.getFieldById_!(id).name)) + } + ) + AstPermissionWithAllInfos(astPermission, permission.ruleGraphQuery, permission.ruleGraphQueryFilePath, permission.id) + } + }.toVector + + val relationPermissions = project.relations.flatMap { relation => + relation.permissions + .filter(_.isActive) + .map { permission => + val astPermission = Ast.Permission( + description = permission.description, + operation = s"${relation.name}.${permission.operation}", + authenticated = permission.userType == UserType.Authenticated, + queryPath = permission.ruleGraphQueryFilePath + ) + + AstPermissionWithAllInfos(astPermission, permission.ruleGraphQuery, permission.ruleGraphQueryFilePath, permission.id) + } + .toVector + + } + modelPermissions ++ relationPermissions + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/project/ClientInterchange.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/project/ClientInterchange.scala new file mode 100644 index 0000000000..2de186b1c7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/project/ClientInterchange.scala @@ -0,0 +1,56 @@ +package cool.graph.system.migration.project + +import cool.graph.shared.functions.ExternalFile +import cool.graph.shared.models.Project +import cool.graph.system.migration.ProjectConfig +import sangria.ast.ObjectValue +import scaldi.Injector +import spray.json._ + +case class ProjectExports(content: String, files: Vector[FileContainer]) +case class FileContainer(path: String, content: String) +case class PermissionsExport(modelPermissions: Vector[ObjectValue], relationPermissions: Vector[ObjectValue], files: Vector[FileContainer]) +case class DatabaseSchemaExport(databaseSchema: ObjectValue, files: Vector[FileContainer]) +case class FunctionsExport(functions: Vector[ObjectValue], files: Vector[FileContainer]) + +case class ClientInterchangeFormatTop(modules: Vector[ClientInterchangeFormatModule]) +case class ClientInterchangeFormatModule(name: String, content: String, files: Map[String, String], externalFiles: Option[Map[String, ExternalFile]]) + +object ClientInterchangeFormatFormats extends DefaultJsonProtocol { + implicit lazy val ExternalFileFormat = jsonFormat4(ExternalFile) + implicit lazy val ClientInterchangeFormatModuleFormat: RootJsonFormat[ClientInterchangeFormatModule] = jsonFormat4(ClientInterchangeFormatModule) + implicit lazy val ClientInterchangeFormatTopFormat: RootJsonFormat[ClientInterchangeFormatTop] = jsonFormat1(ClientInterchangeFormatTop) +} + +object ClientInterchange { + def export(project: Project)(implicit inj: Injector): ProjectExports = { + val x = ProjectConfig.moduleFromProject(project) //.print(project) + + val files = x.files + + ProjectExports(x.module.print, files) + } + + def render(project: Project)(implicit inj: Injector): String = { + import ClientInterchangeFormatFormats._ + + val exports: ProjectExports = export(project) + + ClientInterchangeFormatTop( + modules = Vector( + ClientInterchangeFormatModule( + name = "", + content = exports.content, + files = exports.files.map(x => (x.path, x.content)).toMap, + externalFiles = None + ) + ) + ).toJson.prettyPrint + } + + def parse(interchange: String): ClientInterchangeFormatTop = { + import ClientInterchangeFormatFormats._ + + interchange.parseJson.convertTo[ClientInterchangeFormatTop] + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/migration/rootTokens/RootTokenDiff.scala b/server/backend-api-system/src/main/scala/cool/graph/system/migration/rootTokens/RootTokenDiff.scala new file mode 100644 index 0000000000..2466da8396 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/migration/rootTokens/RootTokenDiff.scala @@ -0,0 +1,13 @@ +package cool.graph.system.migration.rootTokens + +import cool.graph.shared.models.Project + +case class RootTokenDiff(project: Project, newRootTokens: Vector[String]) { + val oldRootTokenNames: Vector[String] = project.rootTokens.map(_.name).toVector + + val addedRootTokens: Vector[String] = newRootTokens diff oldRootTokenNames + val removedRootTokens: Vector[String] = oldRootTokenNames diff newRootTokens + + val removedRootTokensIds: Vector[String] = + removedRootTokens.map(rootToken => project.rootTokens.find(_.name == rootToken).getOrElse(sys.error("Logic error in RootTokenDiff")).id) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyModelTableData.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyModelTableData.scala new file mode 100644 index 0000000000..f40cae00e6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyModelTableData.scala @@ -0,0 +1,20 @@ +package cool.graph.system.mutactions.client + +import cool.graph.{ClientMutactionNoop, _} +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.Model + +import scala.concurrent.Future + +case class CopyModelTableData(sourceProjectId: String, sourceModel: Model, targetProjectId: String, targetModel: Model) extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + val columns = sourceModel.scalarFields.map(_.name) + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.copyTableData(sourceProjectId, sourceModel.name, columns, targetProjectId, targetModel.name))) + } + + override def rollback = Some(ClientMutactionNoop().execute) // consider truncating table + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyRelationTableData.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyRelationTableData.scala new file mode 100644 index 0000000000..6ce6a53984 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CopyRelationTableData.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.client + +import cool.graph.{ClientMutactionNoop, _} +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.models.{Project, Relation} + +import scala.concurrent.Future + +case class CopyRelationTableData(sourceProject: Project, sourceRelation: Relation, targetProjectId: String, targetRelation: Relation) + extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + val columns = List[String]("id", "A", "B") ++ sourceRelation.fieldMirrors + .map(mirror => RelationFieldMirrorColumn.mirrorColumnName(sourceProject, sourceProject.getFieldById_!(mirror.fieldId), sourceRelation)) + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.copyTableData(sourceProject.id, sourceRelation.id, columns, targetProjectId, targetRelation.id))) + } + + override def rollback = Some(ClientMutactionNoop().execute) // consider truncating table + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateClientDatabaseForProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateClientDatabaseForProject.scala new file mode 100644 index 0000000000..7da1e6a21e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateClientDatabaseForProject.scala @@ -0,0 +1,14 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder + +import scala.concurrent.Future + +case class CreateClientDatabaseForProject(projectId: String) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.createClientDatabaseForProject(projectId = projectId))) + + override def rollback = Some(DeleteClientDatabaseForProject(projectId).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateColumn.scala new file mode 100644 index 0000000000..64aa4d58ec --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateColumn.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{Field, Model} + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateColumn(projectId: String, model: Model, field: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.createColumn( + projectId = projectId, + tableName = model.name, + columnName = field.name, + isRequired = field.isRequired, + isUnique = field.isUnique, + isList = field.isList, + typeIdentifier = field.typeIdentifier + ))) + } + + override def rollback = Some(DeleteColumn(projectId, model, field).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + NameConstraints.isValidFieldName(field.name) match { + case false => Future.successful(Failure(UserInputErrors.InvalidName(name = field.name))) + case true => Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateModelTable.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateModelTable.scala new file mode 100644 index 0000000000..1b03d81b45 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateModelTable.scala @@ -0,0 +1,28 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.Model + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateModelTable(projectId: String, model: Model) extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.createTable(projectId = projectId, name = model.name))) + } + + override def rollback = Some(DeleteModelTable(projectId, model).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val validationResult = if (NameConstraints.isValidModelName(model.name)) { + Success(MutactionVerificationSuccess()) + } else { + Failure(UserInputErrors.InvalidName(name = model.name)) + } + + Future.successful(validationResult) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationFieldMirrorColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationFieldMirrorColumn.scala new file mode 100644 index 0000000000..3b0ac9699d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationFieldMirrorColumn.scala @@ -0,0 +1,31 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.models.{Field, Project, Relation} + +import scala.concurrent.Future + +case class CreateRelationFieldMirrorColumn(project: Project, relation: Relation, field: Field) extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + + val mirrorColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, field, relation) + + // Note: we don't need unique index or null constraints on mirrored fields + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.createColumn( + projectId = project.id, + tableName = relation.id, + columnName = mirrorColumnName, + isRequired = false, + isUnique = false, + isList = field.isList, + typeIdentifier = field.typeIdentifier + ))) + } + + override def rollback = Some(DeleteRelationFieldMirrorColumn(project, relation, field).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationTable.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationTable.scala new file mode 100644 index 0000000000..77fb4558f0 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/CreateRelationTable.scala @@ -0,0 +1,23 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.{Project, Relation} + +import scala.concurrent.Future + +case class CreateRelationTable(project: Project, relation: Relation) extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + + val aModel = project.getModelById_!(relation.modelAId) + val bModel = project.getModelById_!(relation.modelBId) + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .createRelationTable(projectId = project.id, tableName = relation.id, aTableName = aModel.name, bTableName = bModel.name))) + } + + override def rollback = Some(DeleteRelationTable(project, relation).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllDataItems.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllDataItems.scala new file mode 100644 index 0000000000..b0f334b1dd --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllDataItems.scala @@ -0,0 +1,13 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.Model + +import scala.concurrent.Future + +case class DeleteAllDataItems(projectId: String, model: Model) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.deleteAllDataItems(projectId, model.name))) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllRelations.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllRelations.scala new file mode 100644 index 0000000000..071744fe99 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteAllRelations.scala @@ -0,0 +1,14 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.Relation + +import scala.concurrent.Future + +case class DeleteAllRelations(projectId: String, relation: Relation) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.deleteAllDataItems(projectId, relation.id))) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteClientDatabaseForProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteClientDatabaseForProject.scala new file mode 100644 index 0000000000..a63dd3221d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteClientDatabaseForProject.scala @@ -0,0 +1,17 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder + +import scala.concurrent.Future + +case class DeleteClientDatabaseForProject(projectId: String) extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .deleteProjectDatabase(projectId = projectId))) + } + + override def rollback = Some(CreateClientDatabaseForProject(projectId).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteColumn.scala new file mode 100644 index 0000000000..cee9f7de20 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteColumn.scala @@ -0,0 +1,17 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.{Field, Model} + +import scala.concurrent.Future + +case class DeleteColumn(projectId: String, model: Model, field: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful( + ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.deleteColumn(projectId = projectId, tableName = model.name, columnName = field.name))) + } + + override def rollback = Some(CreateColumn(projectId, model, field).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteModelTable.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteModelTable.scala new file mode 100644 index 0000000000..f21c12845f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteModelTable.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.{DatabaseMutationBuilder, ProjectRelayIdTable} +import cool.graph.shared.models.Model +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteModelTable(projectId: String, model: Model) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val relayIds = TableQuery(new ProjectRelayIdTable(_, projectId)) + + Future.successful( + ClientSqlStatementResult( + sqlAction = DBIO.seq(DatabaseMutationBuilder.dropTable(projectId = projectId, tableName = model.name), relayIds.filter(_.modelId === model.id).delete))) + } + + override def rollback = Some(CreateModelTable(projectId, model).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationFieldMirrorColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationFieldMirrorColumn.scala new file mode 100644 index 0000000000..b350033f83 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationFieldMirrorColumn.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.models.{Field, Project, Relation} + +import scala.concurrent.Future + +case class DeleteRelationFieldMirrorColumn(project: Project, relation: Relation, field: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + + val mirrorColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, field, relation) + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.deleteColumn(projectId = project.id, tableName = relation.id, columnName = mirrorColumnName))) + } + + override def rollback = Some(CreateRelationFieldMirrorColumn(project, relation, field).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationTable.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationTable.scala new file mode 100644 index 0000000000..235cce0eb5 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/DeleteRelationTable.scala @@ -0,0 +1,16 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.{Project, Relation} + +import scala.concurrent.Future + +case class DeleteRelationTable(project: Project, relation: Relation) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.dropTable(projectId = project.id, tableName = relation.id))) + + override def rollback = Some(CreateRelationTable(project, relation).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteAllRowsForColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteAllRowsForColumn.scala new file mode 100644 index 0000000000..d4ca3a40a4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteAllRowsForColumn.scala @@ -0,0 +1,27 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.ClientMutactionNoop +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Model} + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class OverwriteAllRowsForColumn(projectId: String, model: Model, field: Field, value: Option[Any]) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful(ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.overwriteAllRowsForColumn(projectId = projectId, modelName = model.name, fieldName = field.name, value = value.get))) + } + + override def rollback = Some(ClientMutactionNoop().execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + value.isEmpty match { + case true => Future.successful(Failure(UserAPIErrors.InvalidValue("OverrideValue"))) + case false => Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteInvalidEnumForColumnWithMigrationValue.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteInvalidEnumForColumnWithMigrationValue.scala new file mode 100644 index 0000000000..160097737b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/OverwriteInvalidEnumForColumnWithMigrationValue.scala @@ -0,0 +1,33 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.ClientMutactionNoop +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Model} + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class OverwriteInvalidEnumForColumnWithMigrationValue(projectId: String, model: Model, field: Field, oldValue: String, migrationValue: String) + extends ClientSqlSchemaChangeMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = { + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.overwriteInvalidEnumForColumnWithMigrationValue(projectId = projectId, + modelName = model.name, + fieldName = field.name, + oldValue = oldValue, + migrationValue = migrationValue))) + } + + override def rollback = Some(ClientMutactionNoop().execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + oldValue.isEmpty || migrationValue.isEmpty match { + case true => Future.successful(Failure(UserAPIErrors.InvalidValue("MigrationValue"))) + case false => Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateNullRowsForColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateNullRowsForColumn.scala new file mode 100644 index 0000000000..62738a2bba --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateNullRowsForColumn.scala @@ -0,0 +1,26 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.ClientMutactionNoop +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Model} + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class PopulateNullRowsForColumn(projectId: String, model: Model, field: Field, value: Option[Any]) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful(ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.populateNullRowsForColumn(projectId = projectId, modelName = model.name, fieldName = field.name, value = value.get))) + } + + override def rollback = Some(ClientMutactionNoop().execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful( + if (value.isEmpty) Failure(UserAPIErrors.InvalidValue("ValueForNullRows")) + else Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateRelationFieldMirrorColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateRelationFieldMirrorColumn.scala new file mode 100644 index 0000000000..f817e4dcfe --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/PopulateRelationFieldMirrorColumn.scala @@ -0,0 +1,29 @@ +package cool.graph.system.mutactions.client + +import cool.graph.{ClientMutactionNoop, _} +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.models.{Field, Project, Relation} + +import scala.concurrent.Future + +case class PopulateRelationFieldMirrorColumn(project: Project, relation: Relation, field: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + + val model = project.getModelByFieldId_!(field.id) + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder.populateRelationFieldMirror( + projectId = project.id, + modelTable = model.name, + mirrorColumn = RelationFieldMirrorColumn.mirrorColumnName(project, field, relation), + column = field.name, + relationSide = relation.fieldSide(project, field).toString, + relationTable = relation.id + ))) + } + + override def rollback = Some(ClientMutactionNoop().execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/RenameTable.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/RenameTable.scala new file mode 100644 index 0000000000..4cc8a02ad0 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/RenameTable.scala @@ -0,0 +1,17 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.Model + +import scala.concurrent.Future + +case class RenameTable(projectId: String, model: Model, name: String) extends ClientSqlSchemaChangeMutaction { + + def setName(oldName: String, newName: String): Future[ClientSqlStatementResult[Any]] = + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.renameTable(projectId = projectId, name = oldName, newName = newName))) + + override def execute: Future[ClientSqlStatementResult[Any]] = setName(model.name, name) + + override def rollback = Some(setName(name, model.name)) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/SyncModelToAlgoliaViaRequest.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/SyncModelToAlgoliaViaRequest.scala new file mode 100644 index 0000000000..0c0be6298e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/SyncModelToAlgoliaViaRequest.scala @@ -0,0 +1,46 @@ +package cool.graph.system.mutactions.client + +import com.typesafe.config.Config +import cool.graph.graphql.GraphQlClient +import cool.graph.shared.errors.SystemErrors.SystemApiError +import cool.graph.shared.models.{AlgoliaSyncQuery, Model, Project} +import cool.graph.{Mutaction, MutactionExecutionResult, MutactionExecutionSuccess, MutactionVerificationSuccess} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Success, Try} + +case class SyncModelToAlgoliaViaRequest(project: Project, model: Model, algoliaSyncQuery: AlgoliaSyncQuery, config: Config)(implicit ec: ExecutionContext) + extends Mutaction { + + val clientApiAddress: String = config.getString("clientApiAddress").stripSuffix("/") + val privateClientApiSecret: String = config.getString("privateClientApiSecret") + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(Success(MutactionVerificationSuccess())) + } + + override def execute: Future[MutactionExecutionResult] = { + val graphqlClient = GraphQlClient(s"$clientApiAddress/simple/private/${project.id}", Map("Authorization" -> privateClientApiSecret)) + val query = + s"""mutation { + | syncModelToAlgolia( + | input: { + | modelId: "${model.id}", + | syncQueryId: "${algoliaSyncQuery.id}" + | } + | ){ + | clientMutationId + | } + | } + """.stripMargin + + graphqlClient.sendQuery(query).map { response => + if (response.isSuccess) { + MutactionExecutionSuccess() + } else { + val error = response.firstError + new SystemApiError(error.message, error.code) {} + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateColumn.scala new file mode 100644 index 0000000000..b289750ce8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateColumn.scala @@ -0,0 +1,72 @@ +package cool.graph.system.mutactions.client + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.errors.UserInputErrors.ExistingDuplicateDataPreventsUniqueIndex +import cool.graph.shared.models.{Field, Model} +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class UpdateColumn(projectId: String, model: Model, oldField: Field, newField: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + + // when type changes to/from String we need to change the subpart + // when fieldName changes we need to update index name + // recreating an index is expensive, so we might need to make this smarter in the future + updateFromBeforeStateToAfterState(before = oldField, after = newField) + } + + override def rollback: Some[Future[ClientSqlStatementResult[Any]]] = Some(updateFromBeforeStateToAfterState(before = newField, after = oldField)) + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + ExistingDuplicateDataPreventsUniqueIndex(newField.name) + }) + + def updateFromBeforeStateToAfterState(before: Field, after: Field): Future[ClientSqlStatementResult[Any]] = { + + val hasIndex = before.isUnique + val indexIsDirty = before.isRequired != after.isRequired || before.name != after.name || before.typeIdentifier != after.typeIdentifier + + val updateColumnMutation = DatabaseMutationBuilder.updateColumn( + projectId = projectId, + tableName = model.name, + oldColumnName = before.name, + newColumnName = after.name, + newIsRequired = after.isRequired, + newIsUnique = after.isUnique, + newIsList = after.isList, + newTypeIdentifier = after.typeIdentifier + ) + + val removeUniqueConstraint = + Future.successful(DatabaseMutationBuilder.removeUniqueConstraint(projectId = projectId, tableName = model.name, columnName = before.name)) + + val addUniqueConstraint = Future.successful( + DatabaseMutationBuilder.addUniqueConstraint(projectId = projectId, + tableName = model.name, + columnName = after.name, + typeIdentifier = after.typeIdentifier, + isList = after.isList)) + + val updateColumn = Future.successful(updateColumnMutation) + + val updateColumnActions = (hasIndex, indexIsDirty, after.isUnique) match { + case (true, true, true) => List(removeUniqueConstraint, updateColumn, addUniqueConstraint) + case (true, _, false) => List(removeUniqueConstraint, updateColumn) + case (true, false, true) => List(updateColumn) + case (false, _, false) => List(updateColumn) + case (false, _, true) => List(updateColumn, addUniqueConstraint) + } + + Future.sequence(updateColumnActions).map(sqlActions => ClientSqlStatementResult(sqlAction = DBIO.seq(sqlActions: _*))) + + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateRelationFieldMirrorColumn.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateRelationFieldMirrorColumn.scala new file mode 100644 index 0000000000..ad6b4dadc8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/client/UpdateRelationFieldMirrorColumn.scala @@ -0,0 +1,42 @@ +package cool.graph.system.mutactions.client + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.models.{Field, Project, Relation} + +import scala.concurrent.Future + +case class UpdateRelationFieldMirrorColumn(project: Project, relation: Relation, oldField: Field, newField: Field) extends ClientSqlSchemaChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val updateColumn = DatabaseMutationBuilder.updateColumn( + projectId = project.id, + tableName = relation.id, + oldColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, oldField, relation), + newColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, oldField.copy(name = newField.name), relation), + newIsRequired = false, + newIsUnique = false, + newIsList = newField.isList, + newTypeIdentifier = newField.typeIdentifier + ) + + Future.successful(ClientSqlStatementResult(sqlAction = updateColumn)) + } + + override def rollback: Some[Future[ClientSqlStatementResult[Any]]] = { + val updateColumn = DatabaseMutationBuilder + .updateColumn( + projectId = project.id, + tableName = relation.id, + oldColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, oldField.copy(name = newField.name), relation), // use new name for rollback + newColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, oldField, relation), + newIsRequired = false, + newIsUnique = false, + newIsList = oldField.isList, + newTypeIdentifier = oldField.typeIdentifier + ) + + Some(Future.successful(ClientSqlStatementResult(sqlAction = updateColumn))) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/BumpProjectRevision.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/BumpProjectRevision.scala new file mode 100644 index 0000000000..4c582f08de --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/BumpProjectRevision.scala @@ -0,0 +1,34 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.Project +import cool.graph.system.database.tables.Tables +import scaldi.Injectable +import slick.dbio.Effect.Write +import slick.jdbc.MySQLProfile.api._ +import slick.sql.FixedSqlAction + +import scala.concurrent.Future + +// We increase the Project.revision number whenever the project structure is changed + +case class BumpProjectRevision(project: Project) extends SystemSqlMutaction with Injectable { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val bumpProjectRevision = setRevisionQuery(project.revision + 1) + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(bumpProjectRevision))) + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some { + val resetProjectRevision = setRevisionQuery(project.revision) + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(resetProjectRevision))) + } + + private def setRevisionQuery(revision: Int): FixedSqlAction[Int, NoStream, Write] = { + val query = for { + projectRow <- Tables.Projects + if projectRow.id === project.id + } yield projectRow.revision + query.update(revision) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAction.scala new file mode 100644 index 0000000000..4960d2150f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAction.scala @@ -0,0 +1,59 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.models.{Action, ActionHandlerWebhook, ActionTriggerMutationModel, Project} +import cool.graph.system.database.tables.{ActionTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateAction(project: Project, action: Action) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful({ + val actions = TableQuery[ActionTable] + val relayIds = TableQuery[RelayIdTable] + SystemSqlStatementResult( + sqlAction = DBIO.seq( + actions += + cool.graph.system.database.tables.Action(action.id, project.id, action.isActive, action.triggerType, action.handlerType, action.description), + relayIds += + cool.graph.system.database.tables.RelayId(action.id, "Action") + )) + }) + } + + override def rollback = Some(DeleteAction(project, action).execute) +} + +object CreateAction { + def generateAddActionMutactions(action: Action, project: Project): List[Mutaction] = { + def createAction = CreateAction(project = project, action = action) + + def createHandlerWebhook: Option[CreateActionHandlerWebhook] = + action.handlerWebhook.map( + h => + CreateActionHandlerWebhook( + project = project, + action = action, + actionHandlerWebhook = ActionHandlerWebhook(id = Cuid.createCuid(), url = h.url, h.isAsync) + )) + + def createActionTriggerMutationModel: Option[CreateActionTriggerMutationModel] = + action.triggerMutationModel.map( + t => + CreateActionTriggerMutationModel( + project = project, + action = action, + actionTriggerMutationModel = ActionTriggerMutationModel( + id = Cuid.createCuid(), + modelId = t.modelId, + mutationType = t.mutationType, + fragment = t.fragment + ) + )) + + List(Some(createAction), createHandlerWebhook, createActionTriggerMutationModel).flatten + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionHandlerWebhook.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionHandlerWebhook.scala new file mode 100644 index 0000000000..2045d462a6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionHandlerWebhook.scala @@ -0,0 +1,33 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Action, ActionHandlerWebhook, Project} +import cool.graph.system.database.tables.{ActionHandlerWebhookTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class CreateActionHandlerWebhook(project: Project, action: Action, actionHandlerWebhook: ActionHandlerWebhook) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val actionHandlerWebhooks = TableQuery[ActionHandlerWebhookTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + actionHandlerWebhooks += cool.graph.system.database.tables + .ActionHandlerWebhook(actionHandlerWebhook.id, action.id, actionHandlerWebhook.url, actionHandlerWebhook.isAsync), + relayIds += cool.graph.system.database.tables + .RelayId(actionHandlerWebhook.id, "ActionHandlerWebhook") + ))) + } + + override def rollback = Some(DeleteActionHandlerWebhook(project, action, actionHandlerWebhook).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + // todo: verify is valid url + Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionTriggerMutationModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionTriggerMutationModel.scala new file mode 100644 index 0000000000..0b59d62547 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateActionTriggerMutationModel.scala @@ -0,0 +1,56 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.errors.UserInputErrors.ActionInputIsInconsistent +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{ActionTriggerMutationModelTable, RelayIdTable} +import cool.graph.shared.models.{Action, ActionTriggerMutationModel, Project} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateActionTriggerMutationModel(project: Project, action: Action, actionTriggerMutationModel: ActionTriggerMutationModel) + extends SystemSqlMutaction { + override def execute = { + val actionTriggerMutationModels = + TableQuery[ActionTriggerMutationModelTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + actionTriggerMutationModels += + cool.graph.system.database.tables.ActionTriggerMutationModel( + actionTriggerMutationModel.id, + action.id, + actionTriggerMutationModel.modelId, + actionTriggerMutationModel.mutationType, + actionTriggerMutationModel.fragment + ), + relayIds += cool.graph.system.database.tables + .RelayId(actionTriggerMutationModel.id, "ActionTriggerMutationModel") + ))) + } + + override def handleErrors = + Some({ + case e => throw e + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry +// case e: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException +// if e.getErrorCode == 1452 => +// ActionInputIsInconsistent("Specified model does not exist") + }) + + override def rollback = Some(DeleteActionTriggerMutationModel(project, actionTriggerMutationModel).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + // todo: verify is valid url + + project.getModelById(actionTriggerMutationModel.modelId) match { + case Some(_) => Future.successful(Success(MutactionVerificationSuccess())) + case None => Future.successful(Failure(ActionInputIsInconsistent("Specified model does not exist"))) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..86696c6f4c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAlgoliaSyncQuery.scala @@ -0,0 +1,34 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{AlgoliaSyncQueryTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateAlgoliaSyncQuery(searchProviderAlgolia: SearchProviderAlgolia, algoliaSyncQuery: AlgoliaSyncQuery) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful({ + val algoliaSyncQueries = TableQuery[AlgoliaSyncQueryTable] + val relayIds = TableQuery[RelayIdTable] + SystemSqlStatementResult( + sqlAction = DBIO.seq( + algoliaSyncQueries += + cool.graph.system.database.tables.AlgoliaSyncQuery( + algoliaSyncQuery.id, + algoliaSyncQuery.indexName, + algoliaSyncQuery.fragment, + algoliaSyncQuery.model.id, + searchProviderAlgolia.subTableId, + algoliaSyncQuery.isEnabled + ), + relayIds += + cool.graph.system.database.tables.RelayId(algoliaSyncQuery.id, "AlgoliaSyncQuery") + )) + }) + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = Some(DeleteAlgoliaSyncQuery(searchProviderAlgolia, algoliaSyncQuery).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAuthProvider.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAuthProvider.scala new file mode 100644 index 0000000000..937437d2cf --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateAuthProvider.scala @@ -0,0 +1,59 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.cuid.Cuid +import cool.graph.system.database.tables.{IntegrationAuth0 => _, IntegrationDigits => _, Project => _, _} +import cool.graph.shared.models._ +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class CreateAuthProvider(project: Project, name: IntegrationName.Value, metaInformation: Option[AuthProviderMetaInformation], isEnabled: Boolean) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val integrations = TableQuery[IntegrationTable] + val digitsTable = TableQuery[IntegrationDigitsTable] + val auth0Table = TableQuery[IntegrationAuth0Table] + val relayIds = TableQuery[RelayIdTable] + + val id = Cuid.createCuid() + + val addIntegration = List( + integrations += cool.graph.system.database.tables + .Integration(id = id, isEnabled = isEnabled, integrationType = IntegrationType.AuthProvider, name = name, projectId = project.id), + relayIds += cool.graph.system.database.tables.RelayId(id, "Integration") + ) + + val addMeta = metaInformation match { + case Some(digits: AuthProviderDigits) if digits.isInstanceOf[AuthProviderDigits] => { + List( + digitsTable += cool.graph.system.database.tables.IntegrationDigits( + id = Cuid.createCuid(), + integrationId = id, + consumerKey = digits.consumerKey, + consumerSecret = digits.consumerSecret + )) + } + case Some(auth0: AuthProviderAuth0) if auth0.isInstanceOf[AuthProviderAuth0] => { + List( + auth0Table += cool.graph.system.database.tables.IntegrationAuth0( + id = Cuid.createCuid(), + integrationId = id, + clientId = auth0.clientId, + clientSecret = auth0.clientSecret, + domain = auth0.domain + )) + } + case _ => List() + } + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(addIntegration ++ addMeta: _*) + )) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateClient.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateClient.scala new file mode 100644 index 0000000000..84d961e41a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateClient.scala @@ -0,0 +1,54 @@ +package cool.graph.system.mutactions.internal + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph.shared.errors.UserInputErrors.ClientEmailInUse +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{ClientTable, RelayIdTable} +import cool.graph.shared.models.Client +import org.joda.time.DateTime +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class CreateClient(client: Client) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val clients = TableQuery[ClientTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + clients += cool.graph.system.database.tables.Client( + id = client.id, + auth0Id = client.auth0Id, + isAuth0IdentityProviderEmail = client.isAuth0IdentityProviderEmail, + name = client.name, + email = client.email, + password = client.hashedPassword, + resetPasswordToken = client.resetPasswordSecret, + source = client.source, + createdAt = DateTime.now(), + updatedAt = DateTime.now() + ), + relayIds += cool.graph.system.database.tables + .RelayId(client.id, "Client") + ))) + } + + override def rollback = Some(DeleteClient(client).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + // todo: check valid email, valid password + // todo: make email column in sql unique + + Future.successful(Success(MutactionVerificationSuccess())) + } + + override def handleErrors = + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + Some({ case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => ClientEmailInUse() }) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateEnum.scala new file mode 100644 index 0000000000..71356da8af --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateEnum.scala @@ -0,0 +1,40 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{EnumTable, RelayIdTable} +import cool.graph.shared.models.{Enum, Project} +import cool.graph.system.mutactions.internal.validations.{EnumValueValidation, TypeNameValidation} +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import scala.concurrent.Future +import scala.util.Try + +case class CreateEnum(project: Project, enum: Enum) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val enums = TableQuery[EnumTable] + val relayIds = TableQuery[RelayIdTable] + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + enums += cool.graph.system.database.tables.Enum(enum.id, project.id, enum.name, enum.values.toJson.compactPrint), + relayIds += cool.graph.system.database.tables.RelayId(enum.id, enums.baseTableRow.tableName) + ) + } + } + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = Some(DeleteEnum(project, enum).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful { + for { + _ <- TypeNameValidation.validateEnumName(project, enum.name) + _ <- EnumValueValidation.validateEnumValues(enum.values) + } yield MutactionVerificationSuccess() + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateField.scala new file mode 100644 index 0000000000..ed7f5d819c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateField.scala @@ -0,0 +1,94 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.{UserAPIErrors, UserInputErrors} +import cool.graph.shared.models.{Field, Model, Project} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.tables.{FieldTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateField( + project: Project, + model: Model, + field: Field, + migrationValue: Option[String], + clientDbQueries: ClientDbQueries +) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val fields = TableQuery[FieldTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + fields += ModelToDbMapper.convertField(model.id, field), + relayIds += cool.graph.system.database.tables.RelayId(field.id, "Field") + ))) + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = + Some(DeleteField(project, model, field, allowDeleteSystemField = true).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + lazy val itemCount = clientDbQueries.itemCountForModel(model) + + if (field.isScalar && field.isRequired && migrationValue.isEmpty) { + itemCount map { + case 0 => doVerify + case _ => Failure(UserInputErrors.RequiredAndNoMigrationValue(modelName = model.name, fieldName = field.name)) + } + } else if (field.isUnique && migrationValue.nonEmpty) { + itemCount map { + case 0 => + doVerify + + case 1 => + doVerify + + case _ => + Failure( + UserAPIErrors.UniqueConstraintViolation( + model.name, + s"${field.name} has more than one entry and can't be added as a unique field with a non-unique value." + )) + } + } else { + Future(doVerify) + } + } + + def doVerify: Try[MutactionVerificationSuccess] = { + lazy val fieldValidations = UpdateField.fieldValidations(field, migrationValue) + lazy val relationValidations = relationValidation + + () match { + case _ if fieldValidations.isFailure => fieldValidations + case _ if model.fields.exists(_.name.toLowerCase == field.name.toLowerCase) => Failure(UserInputErrors.FieldAreadyExists(field.name)) + case _ if field.relation.isDefined && relationValidations.isFailure => relationValidations + case _ => Success(MutactionVerificationSuccess()) + } + } + + private def relationValidation: Try[MutactionVerificationSuccess] = { + + val relation = field.relation.get + val otherFieldsInRelation = project.getFieldsByRelationId(relation.id) + + // todo: Asserts are preconditions in the code. + // Triggering one should make us reproduce the bug first thing in the morning. + // let's find a good way to handle this. + assert(otherFieldsInRelation.length <= 2) + + otherFieldsInRelation.length match { + case 2 => + Failure(UserAPIErrors.RelationAlreadyFull(relationId = relation.id, field1 = otherFieldsInRelation.head.name, field2 = otherFieldsInRelation(1).name)) + case _ => Success(MutactionVerificationSuccess()) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFieldConstraint.scala new file mode 100644 index 0000000000..e965e64070 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFieldConstraint.scala @@ -0,0 +1,31 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.{FieldConstraintTable, RelayIdTable} +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateFieldConstraint(project: Project, constraint: FieldConstraint, fieldId: String)(implicit inj: Injector) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val constraints = TableQuery[FieldConstraintTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + constraints += ModelToDbMapper.convertFieldConstraint(constraint), + relayIds += cool.graph.system.database.tables.RelayId(constraint.id, constraints.baseTableRow.tableName) + ) + } + } + } + + override def rollback = Some(DeleteFieldConstraint(project, constraint).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFunction.scala new file mode 100644 index 0000000000..ad550effe2 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateFunction.scala @@ -0,0 +1,30 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models._ +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.{FunctionTable, RelayIdTable} +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.Try + +case class CreateFunction(project: Project, function: Function) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val functions = TableQuery[FunctionTable] + val relayIds = TableQuery[RelayIdTable] + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + functions += ModelToDbMapper.convertFunction(project, function), + relayIds += cool.graph.system.database.tables.RelayId(function.id, "Function") + ) + } + } + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = Some(DeleteFunction(project, function).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = FunctionVerification.verifyFunction(function, project) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateIntegration.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateIntegration.scala new file mode 100644 index 0000000000..6b8643819f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateIntegration.scala @@ -0,0 +1,29 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{IntegrationTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateIntegration(project: Project, integration: Integration) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful({ + val integrations = TableQuery[IntegrationTable] + val relayIds = TableQuery[RelayIdTable] + SystemSqlStatementResult( + sqlAction = DBIO.seq( + integrations += + cool.graph.system.database.tables.Integration(integration.id, integration.isEnabled, integration.integrationType, integration.name, project.id), + relayIds += + cool.graph.system.database.tables + .RelayId(integration.id, "Integration") + )) + }) + } + + override def rollback = Some(DeleteIntegration(project, integration).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModel.scala new file mode 100644 index 0000000000..cb8bbf2d71 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModel.scala @@ -0,0 +1,58 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models._ +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.{FieldTable, ModelTable, RelayIdTable} +import cool.graph.system.mutactions.internal.validations.TypeNameValidation +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Try} + +case class CreateModel(project: Project, model: Model) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val models = TableQuery[ModelTable] + val fields = TableQuery[FieldTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO + .seq( + models += ModelToDbMapper.convertModel(project, model), + relayIds += cool.graph.system.database.tables.RelayId(model.id, "Model"), + fields ++= model.fields.map(f => ModelToDbMapper.convertField(model.id, f)), + relayIds ++= model.fields.map(f => cool.graph.system.database.tables.RelayId(f.id, "Field")) + ))) + } + + override def rollback = Some(DeleteModel(project, model).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + if (!NameConstraints.isValidModelName(model.name)) { + return Future.successful(Failure(UserInputErrors.InvalidName(name = model.name))) + } + + if (CustomScalarTypes.isScalar(model.name)) { + return Future.successful(Failure(UserInputErrors.InvalidName(name = model.name))) + } + + if (project.getModelByName(model.name).exists(_.id != model.id)) { + return Future.successful(Failure(UserInputErrors.ModelWithNameAlreadyExists(name = model.name))) + } + + Future.successful { + for { + _ <- TypeNameValidation.validateModelName(project, model.name) + } yield { + MutactionVerificationSuccess() + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermission.scala new file mode 100644 index 0000000000..d73259552f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermission.scala @@ -0,0 +1,44 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{ModelPermissionTable, RelayIdTable} +import cool.graph.shared.models._ +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class CreateModelPermission(project: Project, model: Model, permission: ModelPermission) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val permissions = TableQuery[ModelPermissionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + permissions += cool.graph.system.database.tables + .ModelPermission( + permission.id, + model.id, + permission.operation, + permission.userType, + permission.rule, + permission.ruleName, + permission.ruleGraphQuery, + permission.ruleGraphQueryFilePath, + permission.ruleWebhookUrl, + permission.applyToWholeModel, + permission.description, + permission.isActive + ), + relayIds += cool.graph.system.database.tables + .RelayId(permission.id, "ModelPermission") + ))) + } + + override def rollback = Some(DeleteModelPermission(project, model, permission).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermissionField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermissionField.scala new file mode 100644 index 0000000000..f835aa92bd --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelPermissionField.scala @@ -0,0 +1,38 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{ModelPermissionFieldTable, RelayIdTable} +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateModelPermissionField(project: Project, model: Model, permission: ModelPermission, fieldId: String)(implicit inj: Injector) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val newId = Cuid.createCuid() + + val permissionFields = TableQuery[ModelPermissionFieldTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + permissionFields += cool.graph.system.database.tables + .ModelPermissionField( + id = newId, + modelPermissionId = permission.id, + fieldId = fieldId + ), + relayIds += cool.graph.system.database.tables + .RelayId(newId, "ModelPermissionField") + ))) + } + + override def rollback = Some(DeleteModelPermissionField(project, model, permission, fieldId).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelWithoutSystemFields.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelWithoutSystemFields.scala new file mode 100644 index 0000000000..7f7bcf07d6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateModelWithoutSystemFields.scala @@ -0,0 +1,46 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.system.database.tables.{FieldTable, ModelTable, PermissionTable, RelayIdTable} +import cool.graph.shared.models._ +import cool.graph.shared.schema.CustomScalarTypes +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateModelWithoutSystemFields(project: Project, model: Model) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val models = TableQuery[ModelTable] + val fields = TableQuery[FieldTable] + val permissions = TableQuery[PermissionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + models += cool.graph.system.database.tables + .Model(model.id, model.name, model.description, model.isSystem, project.id, fieldPositions = Seq.empty), + relayIds += + cool.graph.system.database.tables.RelayId(model.id, "Model") + ))) + } + + override def rollback = Some(DeleteModel(project, model).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful( + () match { + case _ if !NameConstraints.isValidModelName(model.name) => Failure(UserInputErrors.InvalidName(name = model.name)) + case _ if CustomScalarTypes.isScalar(model.name) => Failure(UserInputErrors.InvalidName(name = model.name)) + case _ if project.getModelByName(model.name).exists(_.id != model.id) => Failure(UserInputErrors.ModelWithNameAlreadyExists(name = model.name)) + case _ => Success(MutactionVerificationSuccess()) + + } + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateOrUpdateProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateOrUpdateProjectDatabase.scala new file mode 100644 index 0000000000..09ea36eb2d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateOrUpdateProjectDatabase.scala @@ -0,0 +1,39 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models.ProjectDatabase +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.Tables.ProjectDatabases +import cool.graph.{SystemSqlMutaction, SystemSqlStatementResult} +import slick.dbio.DBIOAction +import slick.dbio.Effect.{Read, Transactional, Write} +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Future + +case class CreateOrUpdateProjectDatabase(projectDatabase: ProjectDatabase) extends SystemSqlMutaction { + import scala.concurrent.ExecutionContext.Implicits.global + + val insertProjectDatabaseIfNotExists: DBIOAction[Any, NoStream, Read with Write with Transactional] = + ProjectDatabases + .filter(_.id === projectDatabase.id) + .exists + .result + .flatMap { exists => + if (!exists) { + ProjectDatabases += ModelToDbMapper.convertProjectDatabase(projectDatabase) + } else { + DBIO.successful(None) // no-op + } + } + .transactionally + + override def execute: Future[SystemSqlStatementResult[Any]] = { + import cool.graph.system.database.tables.Tables._ + Future.successful( + SystemSqlStatementResult(sqlAction = + DBIO.seq(insertProjectDatabaseIfNotExists, RelayIds.insertOrUpdate(cool.graph.system.database.tables.RelayId(projectDatabase.id, "ProjectDatabase"))))) + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some(DeleteProjectDatabase(projectDatabase).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreatePackageDefinition.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreatePackageDefinition.scala new file mode 100644 index 0000000000..71336f1d66 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreatePackageDefinition.scala @@ -0,0 +1,41 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{PackageDefinitionTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreatePackageDefinition(project: Project, + packageDefinition: PackageDefinition, + internalDatabase: DatabaseDef, + ignoreDuplicateNameVerificationError: Boolean = false) + extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val packageDefinitions = TableQuery[PackageDefinitionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO + .seq( + packageDefinitions += cool.graph.system.database.tables.PackageDefinition( + id = packageDefinition.id, + name = packageDefinition.name, + definition = packageDefinition.definition, + formatVersion = packageDefinition.formatVersion, + projectId = project.id + ), + relayIds += + cool.graph.system.database.tables.RelayId(packageDefinition.id, "PackageDefinition") + ) + )) + } + + override def rollback = Some(DeletePackageDefinition(project, packageDefinition, internalDatabase).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateProject.scala new file mode 100644 index 0000000000..1cbaa9be9c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateProject.scala @@ -0,0 +1,54 @@ +package cool.graph.system.mutactions.internal + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph._ +import cool.graph.shared.errors.UserInputErrors.ProjectWithAliasAlreadyExists +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.database.tables.{ProjectTable, RelayIdTable} +import cool.graph.system.mutactions.internal.validations.ProjectValidations +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Try + +case class CreateProject( + client: Client, + project: Project, + internalDatabase: DatabaseDef, + projectQueries: ProjectQueries, + ignoreDuplicateNameVerificationError: Boolean = false +) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val projects = TableQuery[ProjectTable] + val relayIds = TableQuery[RelayIdTable] + val addProject = projects += ModelToDbMapper.convertProject(project.copy(ownerId = client.id)) + val addRelayId = relayIds += cool.graph.system.database.tables.RelayId(project.id, "Project") + + Future.successful { + SystemSqlStatementResult( + sqlAction = DBIO.seq(addProject, addRelayId) + ) + } + } + + override def rollback = Some(DeleteProject(client, project, projectQueries = projectQueries, internalDatabase = internalDatabase).execute) + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + ProjectWithAliasAlreadyExists(alias = project.alias.getOrElse("")) + }) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val projectValidations = ProjectValidations(client, project, projectQueries) + projectValidations.verify() + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelation.scala new file mode 100644 index 0000000000..b020d38349 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelation.scala @@ -0,0 +1,98 @@ +package cool.graph.system.mutactions.internal + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph._ +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.errors.UserInputErrors.ObjectDoesNotExistInCurrentProject +import cool.graph.shared.models.{Project, Relation} +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.tables.{RelationTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateRelation(project: Project, + relation: Relation, + fieldOnLeftModelIsRequired: Boolean = false, + fieldOnRightModelIsRequired: Boolean = false, + clientDbQueries: ClientDbQueries) + extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = + Future.successful({ + val relations = TableQuery[RelationTable] + val relayIds = TableQuery[RelayIdTable] + val addRelationRow = relations += cool.graph.system.database.tables + .Relation(relation.id, project.id, relation.name, relation.description, relation.modelAId, relation.modelBId) + val addRelayId = relayIds += cool.graph.system.database.tables.RelayId(relation.id, "Relation") + + SystemSqlStatementResult(sqlAction = DBIO.seq(addRelationRow, addRelayId)) + }) + + override def rollback = + Some( + DeleteRelation( + relation = relation, + project = project, + clientDbQueries = clientDbQueries + ).execute) + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + UserInputErrors.RelationNameAlreadyExists(relation.name) + }) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + () match { + case _ if !NameConstraints.isValidRelationName(relation.name) => + Future.successful(Failure(UserInputErrors.InvalidName(name = relation.name))) + + case _ if project.relations.exists(x => x.name.toLowerCase == relation.name.toLowerCase && x.id != relation.id) => + Future.successful(Failure(UserInputErrors.RelationNameAlreadyExists(relation.name))) + + case _ if project.getModelById(relation.modelAId).isEmpty => + Future.successful(Failure(ObjectDoesNotExistInCurrentProject("modelIdA does not correspond to an existing Model"))) + + case _ if project.getModelById(relation.modelBId).isEmpty => + Future.successful(Failure(ObjectDoesNotExistInCurrentProject("modelIdB does not correspond to an existing Model"))) + + case _ if fieldOnLeftModelIsRequired || fieldOnRightModelIsRequired => + checkCounts() + + case _ => + Future.successful(Success(MutactionVerificationSuccess())) + } + } + + def checkCounts(): Future[Try[MutactionVerificationSuccess]] = { + val modelA = relation.getModelA_!(project) + val modelB = relation.getModelB_!(project) + val fieldOnModelA = relation.getModelAField_!(project) + val fieldOnModelB = relation.getModelBField_!(project) + + def checkCountResultAgainstRequired(aExists: Boolean, bExists: Boolean): Try[MutactionVerificationSuccess] = { + (aExists, bExists) match { + case (true, _) if fieldOnLeftModelIsRequired => + Failure(UserInputErrors.AddingRequiredRelationButNodesExistForModel(modelA.name, fieldOnModelA.name)) + case (_, true) if fieldOnRightModelIsRequired => + Failure(UserInputErrors.AddingRequiredRelationButNodesExistForModel(modelB.name, fieldOnModelB.name)) + case _ => Success(MutactionVerificationSuccess()) + } + } + + val modelAExists = clientDbQueries.existsByModel(modelA).recover { case _: java.sql.SQLSyntaxErrorException => false } + val modelBExists = clientDbQueries.existsByModel(modelB).recover { case _: java.sql.SQLSyntaxErrorException => false } + + for { + aExists <- modelAExists + bExists <- modelBExists + } yield checkCountResultAgainstRequired(aExists, bExists) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationFieldMirror.scala new file mode 100644 index 0000000000..f8887b5711 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationFieldMirror.scala @@ -0,0 +1,41 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.errors.UserInputErrors.ObjectDoesNotExistInCurrentProject +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{RelationFieldMirrorTable, RelayIdTable} +import cool.graph.shared.models._ +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateRelationFieldMirror(project: Project, relationFieldMirror: RelationFieldMirror) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val mirrors = TableQuery[RelationFieldMirrorTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + mirrors += cool.graph.system.database.tables + .RelationFieldMirror(id = relationFieldMirror.id, relationId = relationFieldMirror.relationId, fieldId = relationFieldMirror.fieldId), + relayIds += cool.graph.system.database.tables + .RelayId(relationFieldMirror.id, "RelationFieldMirror") + ))) + } + + override def rollback = Some(DeleteRelationFieldMirror(project, relationFieldMirror).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + project.getRelationById(relationFieldMirror.relationId) match { + case None => Future.successful(Failure(ObjectDoesNotExistInCurrentProject("relationId does not correspond to an existing Relation"))) + case _ => + project.getFieldById(relationFieldMirror.fieldId) match { + case None => Future.successful(Failure(ObjectDoesNotExistInCurrentProject("fieldId does not correspond to an existing Field"))) + case _ => Future.successful(Success(MutactionVerificationSuccess())) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationPermission.scala new file mode 100644 index 0000000000..e708518718 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRelationPermission.scala @@ -0,0 +1,43 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{RelationPermissionTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateRelationPermission(project: Project, relation: Relation, permission: RelationPermission) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val permissions = TableQuery[RelationPermissionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + permissions += cool.graph.system.database.tables + .RelationPermission( + permission.id, + relation.id, + permission.connect, + permission.disconnect, + permission.userType, + permission.rule, + permission.ruleName, + permission.ruleGraphQuery, + permission.ruleGraphQueryFilePath, + permission.ruleWebhookUrl, + permission.description, + permission.isActive + ), + relayIds += cool.graph.system.database.tables + .RelayId(permission.id, "RelationPermission") + ))) + } + + override def rollback = Some(DeleteRelationPermission(project, relation, permission).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRootToken.scala new file mode 100644 index 0000000000..7b8cd2b818 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateRootToken.scala @@ -0,0 +1,27 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.RootToken +import cool.graph.system.database.tables.{RootTokenTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class CreateRootToken(projectId: String, rootToken: RootToken) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val rootTokens = TableQuery[RootTokenTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + rootTokens += cool.graph.system.database.tables + .RootToken(id = rootToken.id, projectId = projectId, name = rootToken.name, token = rootToken.token, created = rootToken.created), + relayIds += cool.graph.system.database.tables + .RelayId(rootToken.id, "PermanentAuthToken") + ))) + } + + override def rollback = Some(DeleteRootToken(rootToken).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSearchProviderAlgolia.scala new file mode 100644 index 0000000000..48f27f765a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSearchProviderAlgolia.scala @@ -0,0 +1,63 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.UserInputErrors +import cool.graph.system.database.tables.{RelayIdTable, SearchProviderAlgoliaTable} +import cool.graph.shared.models._ +import cool.graph.system.externalServices.AlgoliaKeyChecker +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateSearchProviderAlgolia(project: Project, searchProviderAlgolia: SearchProviderAlgolia)(implicit inj: Injector) + extends SystemSqlMutaction + with Injectable { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful({ + val searchProviderAlgolias = TableQuery[SearchProviderAlgoliaTable] + val relayIds = TableQuery[RelayIdTable] + SystemSqlStatementResult( + sqlAction = DBIO.seq( + searchProviderAlgolias += + cool.graph.system.database.tables.SearchProviderAlgolia(searchProviderAlgolia.subTableId, + searchProviderAlgolia.id, + searchProviderAlgolia.applicationId, + searchProviderAlgolia.apiKey), + relayIds += + cool.graph.system.database.tables.RelayId(searchProviderAlgolia.subTableId, "SearchProviderAlgolia") + )) + }) + } + + override def rollback = Some(DeleteSearchProviderAlgolia(project, searchProviderAlgolia).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + project.integrations + .collect { + case existingSearchProviderAlgolias: SearchProviderAlgolia => + existingSearchProviderAlgolias + } + .foreach(spa => { + if (spa.id != searchProviderAlgolia) // This comparison will always evaluate to true. Which results in the intended outcome but was probably not intentional. Leaving this in since there is no test coverage and the code will be removed soon. + return Future.successful(Failure(UserInputErrors.ProjectAlreadyHasSearchProviderAlgolia())) + }) + + if (searchProviderAlgolia.applicationId.isEmpty && searchProviderAlgolia.apiKey.isEmpty) { + Future.successful(Success(MutactionVerificationSuccess())) + } else { + val algoliaKeyChecker = inject[AlgoliaKeyChecker](identified by "algoliaKeyChecker") + + algoliaKeyChecker + .verifyAlgoliaCredentialValidity(searchProviderAlgolia.applicationId, searchProviderAlgolia.apiKey) + .map { + case true => Success(MutactionVerificationSuccess()) + case false => Failure(UserInputErrors.AlgoliaCredentialsDontHaveRequiredPermissions()) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSeat.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSeat.scala new file mode 100644 index 0000000000..396096b394 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSeat.scala @@ -0,0 +1,80 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.errors.UserInputErrors.CollaboratorProjectWithNameAlreadyExists +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.externalServices.SnsPublisher +import cool.graph.system.database.tables.{ProjectTable, RelayIdTable, SeatTable} +import cool.graph.shared.models._ +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery +import spray.json.{JsObject, JsString} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateSeat(client: Client, project: Project, seat: Seat, internalDatabase: DatabaseDef, ignoreDuplicateNameVerificationError: Boolean = false)( + implicit inj: Injector) + extends SystemSqlMutaction + with Injectable { + + val seatSnsPublisher: SnsPublisher = inject[SnsPublisher](identified by "seatSnsPublisher") + + if (!seat.clientId.contains(project.ownerId)) { + seatSnsPublisher.putRecord( + JsObject( + "action" -> JsString("ADD"), + "projectId" -> JsString(project.id), + "projectName" -> JsString(project.name), + "email" -> JsString(seat.email), + "status" -> JsString(seat.status.toString), + "byEmail" -> JsString(client.email), + "byName" -> JsString(client.name) + ).compactPrint) + } + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val seats = TableQuery[SeatTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO + .seq( + seats += cool.graph.system.database.tables + .Seat(id = seat.id, status = seat.status, email = seat.email, clientId = seat.clientId, projectId = project.id), + relayIds += + cool.graph.system.database.tables.RelayId(seat.id, "Seat") + ) + )) + } + + override def rollback = Some(DeleteSeat(client, project, seat, internalDatabase).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + + seat.clientId match { + case None => + // pending collaborators do not have projects yet. + Future.successful(Success(MutactionVerificationSuccess())) + + case Some(id) => + ignoreDuplicateNameVerificationError match { + case true => + Future.successful(Success(MutactionVerificationSuccess())) + + case false => + val projects = TableQuery[ProjectTable] + internalDatabase + .run(projects.filter(p => p.clientId === id && p.name === project.name).length.result) + .map { + case 0 => Success(MutactionVerificationSuccess()) + case _ => Failure(CollaboratorProjectWithNameAlreadyExists(name = project.name)) + } + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSystemFieldIfNotExists.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSystemFieldIfNotExists.scala new file mode 100644 index 0000000000..2c74e3b8e1 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/CreateSystemFieldIfNotExists.scala @@ -0,0 +1,47 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{Field, Model, Project} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.{FieldTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +/** + * Allows for insertion of system fields with minimal validation checks. + * Usually you want to use CreateField. + */ +case class CreateSystemFieldIfNotExists( + project: Project, + model: Model, + field: Field +) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val fields = TableQuery[FieldTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + fields += ModelToDbMapper.convertField(model.id, field), + relayIds += cool.graph.system.database.tables.RelayId(field.id, "Field") + ))) + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = + Some(DeleteField(project, model, field, allowDeleteSystemField = true).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val verifyResult = if (model.fields.exists(_.name.toLowerCase == field.name.toLowerCase)) { + Failure(UserInputErrors.FieldAreadyExists(field.name)) + } else { + Success(MutactionVerificationSuccess()) + } + + Future.successful(verifyResult) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAction.scala new file mode 100644 index 0000000000..9e0078de59 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAction.scala @@ -0,0 +1,20 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Action, Project} +import cool.graph.system.database.tables.{ActionTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteAction(project: Project, action: Action) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val actions = TableQuery[ActionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(actions.filter(_.id === action.id).delete, relayIds.filter(_.id === action.id).delete))) + } + + override def rollback = Some(new CreateAction(project, action).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionHandlerWebhook.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionHandlerWebhook.scala new file mode 100644 index 0000000000..3c1f7e25a9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionHandlerWebhook.scala @@ -0,0 +1,26 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{ActionHandlerWebhook, Project} +import cool.graph.system.database.tables.{ActionHandlerWebhookTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteActionHandlerWebhook(project: Project, action: cool.graph.shared.models.Action, actionHandlerWebhook: ActionHandlerWebhook) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val actionHandlerWebhooks = TableQuery[ActionHandlerWebhookTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(actionHandlerWebhooks + .filter(_.id === actionHandlerWebhook.id) + .delete, + relayIds.filter(_.id === actionHandlerWebhook.id).delete))) + } + + override def rollback = Some(CreateActionHandlerWebhook(project, action, actionHandlerWebhook).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionTriggerMutationModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionTriggerMutationModel.scala new file mode 100644 index 0000000000..0c3dcb7cc4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteActionTriggerMutationModel.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{ActionTriggerMutationModelTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteActionTriggerMutationModel(project: Project, actionTriggerMutationModel: ActionTriggerMutationModel) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val actionTriggerMutationModels = + TableQuery[ActionTriggerMutationModelTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(actionTriggerMutationModels.filter(_.id === actionTriggerMutationModel.id).delete, + relayIds.filter(_.id === actionTriggerMutationModel.id).delete))) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..75176fb5ae --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAlgoliaSyncQuery.scala @@ -0,0 +1,23 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{AlgoliaSyncQuery, SearchProviderAlgolia} +import cool.graph.system.database.tables.{AlgoliaSyncQueryTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteAlgoliaSyncQuery(searchProviderAlgolia: SearchProviderAlgolia, algoliaSyncQuery: AlgoliaSyncQuery) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val algoliaSyncQueries = TableQuery[AlgoliaSyncQueryTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(algoliaSyncQueries.filter(_.id === algoliaSyncQuery.id).delete, relayIds.filter(_.id === algoliaSyncQuery.id).delete))) + } + + override def rollback = Some(CreateAlgoliaSyncQuery(searchProviderAlgolia, algoliaSyncQuery).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAuthProvider.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAuthProvider.scala new file mode 100644 index 0000000000..c335e85b97 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteAuthProvider.scala @@ -0,0 +1,19 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.AuthProvider +import cool.graph.system.database.tables.{IntegrationTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteAuthProvider(integration: AuthProvider) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val integrations = TableQuery[IntegrationTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult(sqlAction = DBIO.seq(integrations.filter(_.id === integration.id).delete, relayIds.filter(_.id === integration.id).delete))) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteClient.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteClient.scala new file mode 100644 index 0000000000..cea2af7e0e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteClient.scala @@ -0,0 +1,21 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.Client +import cool.graph.system.database.tables.{ClientTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteClient(client: Client) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val clients = TableQuery[ClientTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(clients.filter(_.id === client.id).delete, relayIds.filter(_.id === client.id).delete))) + } + + override def rollback = Some(CreateClient(client).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteEnum.scala new file mode 100644 index 0000000000..a0dc2efb80 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteEnum.scala @@ -0,0 +1,51 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.errors.UserInputErrors.EnumIsReferencedByField +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.{EnumTable, RelayIdTable} +import cool.graph.shared.models.{Enum, Project} +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteEnum(project: Project, enum: Enum) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val enums = TableQuery[EnumTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + enums.filter(_.id === enum.id).delete, + relayIds.filter(_.id === enum.id).delete + ) + } + } + + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = { + Some(CreateEnum(project, enum).execute) + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val referencesToEnum = for { + model <- project.models + field <- model.fields + fieldEnum <- field.enum + if fieldEnum.id == enum.id + } yield (model.name, field.name) + + val checkIfEnumIsInUse = if (referencesToEnum.nonEmpty) { + val (modelName, fieldName) = referencesToEnum.head + Failure(EnumIsReferencedByField(fieldName = fieldName, typeName = modelName)) + } else { + Success(MutactionVerificationSuccess()) + } + + Future.successful(checkIfEnumIsInUse) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteField.scala new file mode 100644 index 0000000000..ed01b2b202 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteField.scala @@ -0,0 +1,45 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Field, Model, Project} +import cool.graph.system.database.client.EmptyClientDbQueries +import cool.graph.system.database.tables.{FieldTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteField( + project: Project, + model: Model, + field: Field, + allowDeleteSystemField: Boolean = false, + allowDeleteRelationField: Boolean = false +) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val fields = TableQuery[FieldTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(fields.filter(_.id === field.id).delete, relayIds.filter(_.id === field.id).delete))) + } + + override def rollback = Some(CreateField(project, model, field, None, EmptyClientDbQueries).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = + Future.successful(() match { + case _ if model.getFieldById(field.id).isEmpty => + Failure(SystemErrors.FieldNotInModel(fieldName = field.name, modelName = model.name)) + + case _ if field.isSystem && !allowDeleteSystemField => + Failure(SystemErrors.SystemFieldCannotBeRemoved(fieldName = field.name)) + + case _ if field.relation.isDefined && !allowDeleteRelationField => + Failure(SystemErrors.CantDeleteRelationField(fieldName = field.name)) + + case _ => + Success(MutactionVerificationSuccess()) + }) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFieldConstraint.scala new file mode 100644 index 0000000000..f68a8353bc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFieldConstraint.scala @@ -0,0 +1,29 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models.{FieldConstraint, Project} +import cool.graph.system.database.tables.{FieldConstraintTable, RelayIdTable} +import cool.graph.{SystemSqlMutaction, SystemSqlStatementResult} +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteFieldConstraint(project: Project, constraint: FieldConstraint)(implicit inj: Injector) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val constraints = TableQuery[FieldConstraintTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + constraints.filter(_.id === constraint.id).delete, + relayIds.filter(_.id === constraint.id).delete + ) + } + } + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some(CreateFieldConstraint(project, constraint, constraint.fieldId).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFunction.scala new file mode 100644 index 0000000000..66a69fada3 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteFunction.scala @@ -0,0 +1,29 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models.{Function, Project} +import cool.graph.system.database.tables.{FunctionTable, RelayIdTable} +import cool.graph.{SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteFunction(project: Project, function: Function) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val functions = TableQuery[FunctionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + functions.filter(_.id === function.id).delete, + relayIds.filter(_.id === function.id).delete + ) + } + } + + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some(CreateFunction(project, function).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteIntegration.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteIntegration.scala new file mode 100644 index 0000000000..97cd1eb39f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteIntegration.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Integration, Project} +import cool.graph.system.database.tables.{IntegrationTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteIntegration(project: Project, integration: Integration) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val integrations = TableQuery[IntegrationTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult(sqlAction = DBIO.seq(integrations.filter(_.id === integration.id).delete, relayIds.filter(_.id === integration.id).delete))) + } + + override def rollback = Some(CreateIntegration(project, integration).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModel.scala new file mode 100644 index 0000000000..468a6ec2b9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModel.scala @@ -0,0 +1,30 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Model, Project} +import cool.graph.system.database.tables.{ModelTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteModel(project: Project, model: Model) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val models = TableQuery[ModelTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(models.filter(_.id === model.id).delete, relayIds.filter(_.id === model.id).delete))) + } + + override def rollback = Some(CreateModel(project, model).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + if (model.isSystem && !project.isEjected) { + Future.successful(Failure(SystemErrors.SystemModelCannotBeRemoved(model.name))) + } else { + Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermission.scala new file mode 100644 index 0000000000..6ca1df2d2a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermission.scala @@ -0,0 +1,32 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.SystemErrors +import cool.graph.system.database.tables.{ModelPermissionTable, RelayIdTable} +import cool.graph.shared.models.{Model, ModelPermission, Project} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteModelPermission(project: Project, model: Model, permission: ModelPermission) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val permissions = TableQuery[ModelPermissionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult(sqlAction = DBIO.seq(permissions.filter(_.id === permission.id).delete, relayIds.filter(_.id === permission.id).delete))) + } + + override def rollback = Some(CreateModelPermission(project, model, permission).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(model.getPermissionById(permission.id) match { + case None => Failure(SystemErrors.ModelPermissionNotInModel(modelPermissionId = permission.id, modelName = model.name)) + case Some(x) => Success(MutactionVerificationSuccess()) + }) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermissionField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermissionField.scala new file mode 100644 index 0000000000..7c328e19f8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteModelPermissionField.scala @@ -0,0 +1,58 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Model, ModelPermission, Project} +import cool.graph.system.database.tables._ +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class DeleteModelPermissionField(project: Project, model: Model, permission: ModelPermission, fieldId: String)(implicit inj: Injector) + extends SystemSqlMutaction + with Injectable { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val internalDatabase = inject[DatabaseDef](identified by "internal-db") + val permissionFields = TableQuery[ModelPermissionFieldTable] + val relayIds = TableQuery[RelayIdTable] + + val permissionField = permissionFields.filter(pf => pf.modelPermissionId === permission.id && pf.fieldId === fieldId) + + val modelPermissionFields: Future[Seq[ModelPermissionField]] = internalDatabase.run(permissionField.result) + + val sqlStatementResults: Future[SystemSqlStatementResult[Any]] = modelPermissionFields.map { modelPermissionFieldList => + val firstModelPermissionField: Option[ModelPermissionField] = modelPermissionFieldList.headOption + + val result: Option[SystemSqlStatementResult[Any]] = firstModelPermissionField.map { existingModelPermissionField => + SystemSqlStatementResult[Any](sqlAction = + DBIO.seq(permissionFields.filter(_.id === existingModelPermissionField.id).delete, relayIds.filter(_.id === existingModelPermissionField.id).delete)) + } + + result match { + case Some(x) => + x + case None => + sys.error( + "DeleteModelPermissionField_None.get \n" + + "ModelId: " + model.id + "\n" + + "FieldId: " + fieldId + "\n" + + "Permission: " + permission + "\n" + + "-----------------------------\n" + + "permissionFields: " + permissionFields + "\n" + + "relayIds: " + relayIds + "\n" + + "permissionField: " + permissionField + "\n" + + "modelPermissionFields: " + modelPermissionFields + "\n" + + "ModelPermissionFieldList: " + modelPermissionFieldList + "\n" + + "result: " + result + "\n") + } + } + sqlStatementResults + } + + override def rollback = Some(CreateModelPermission(project, model, permission).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeletePackageDefinition.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeletePackageDefinition.scala new file mode 100644 index 0000000000..4cd2c74f44 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeletePackageDefinition.scala @@ -0,0 +1,24 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{PackageDefinition, Project} +import cool.graph.system.database.tables.{PackageDefinitionTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeletePackageDefinition(project: Project, packageDefinition: PackageDefinition, internalDatabase: DatabaseDef) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val packageDefinitions = TableQuery[PackageDefinitionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(packageDefinitions.filter(_.id === packageDefinition.id).delete, relayIds.filter(_.id === packageDefinition.id).delete))) + } + + override def rollback = Some(CreatePackageDefinition(project, packageDefinition, internalDatabase).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProject.scala new file mode 100644 index 0000000000..0e7ce7c068 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProject.scala @@ -0,0 +1,38 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.database.tables.{ProjectTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteProject(client: Client, project: Project, projectQueries: ProjectQueries, willBeRecreated: Boolean = false, internalDatabase: DatabaseDef) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val projects = TableQuery[ProjectTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(projects.filter(_.id === project.id).delete, relayIds.filter(_.id === project.id).delete))) + } + + override def rollback = Some(CreateProject(client, project, internalDatabase, projectQueries).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val numberOfProjects = TableQuery[ProjectTable].filter(p => p.clientId === client.id).length + + internalDatabase.run(numberOfProjects.result).map { remainingCount => + if (remainingCount == 1 && !willBeRecreated) { + Failure(SystemErrors.CantDeleteLastProject()) + } else { + Success(MutactionVerificationSuccess()) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProjectDatabase.scala new file mode 100644 index 0000000000..948aef0fad --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteProjectDatabase.scala @@ -0,0 +1,19 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models.ProjectDatabase +import cool.graph.{SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Future + +case class DeleteProjectDatabase(projectDatabase: ProjectDatabase) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + import cool.graph.system.database.tables.Tables._ + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(ProjectDatabases.filter(_.id === projectDatabase.id).delete, RelayIds.filter(_.id === projectDatabase.id).delete))) + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some(CreateOrUpdateProjectDatabase(projectDatabase).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelation.scala new file mode 100644 index 0000000000..7ef18d011b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelation.scala @@ -0,0 +1,27 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Project, Relation} +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.tables.{RelationTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteRelation( + relation: Relation, + project: Project, + clientDbQueries: ClientDbQueries +) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val relations = TableQuery[RelationTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult(sqlAction = DBIO.seq(relations.filter(_.id === relation.id).delete, relayIds.filter(_.id === relation.id).delete))) + } + + override def rollback = Some(CreateRelation(relation = relation, project = project, clientDbQueries = clientDbQueries).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationFieldMirror.scala new file mode 100644 index 0000000000..5a7541b2e1 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationFieldMirror.scala @@ -0,0 +1,23 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{RelationFieldMirrorTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteRelationFieldMirror(project: Project, relationFieldMirror: RelationFieldMirror) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val mirrors = TableQuery[RelationFieldMirrorTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(mirrors.filter(_.id === relationFieldMirror.id).delete, relayIds.filter(_.id === relationFieldMirror.id).delete))) + } + + override def rollback = Some(CreateRelationFieldMirror(project, relationFieldMirror).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationPermission.scala new file mode 100644 index 0000000000..b4892bda4b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRelationPermission.scala @@ -0,0 +1,32 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.SystemErrors +import cool.graph.system.database.tables.{RelationPermissionTable, RelayIdTable} +import cool.graph.shared.models._ +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteRelationPermission(project: Project, relation: Relation, permission: RelationPermission) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val permissions = TableQuery[RelationPermissionTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult(sqlAction = DBIO.seq(permissions.filter(_.id === permission.id).delete, relayIds.filter(_.id === permission.id).delete))) + } + + override def rollback = Some(CreateRelationPermission(project, relation, permission).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(relation.getPermissionById(permission.id) match { + case None => Failure(SystemErrors.RelationPermissionNotInModel(relationPermissionId = permission.id, relationName = relation.name)) + case Some(_) => Success(MutactionVerificationSuccess()) + }) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRootToken.scala new file mode 100644 index 0000000000..5ecd839886 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteRootToken.scala @@ -0,0 +1,23 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.RootToken +import cool.graph.system.database.tables.{RootTokenTable, RelayIdTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteRootToken(rootToken: RootToken) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val rootTokens = TableQuery[RootTokenTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq( + rootTokens.filter(_.id === rootToken.id).delete, + relayIds.filter(_.id === rootToken.id).delete + ))) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSearchProviderAlgolia.scala new file mode 100644 index 0000000000..e81403ae4b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSearchProviderAlgolia.scala @@ -0,0 +1,24 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Project, SearchProviderAlgolia} +import cool.graph.system.database.tables.{RelayIdTable, SearchProviderAlgoliaTable} +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteSearchProviderAlgolia(project: Project, integrationAlgolia: SearchProviderAlgolia)(implicit inj: Injector) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val integrationAlgolias = TableQuery[SearchProviderAlgoliaTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful( + SystemSqlStatementResult( + sqlAction = DBIO.seq(integrationAlgolias.filter(_.id === integrationAlgolia.id).delete, relayIds.filter(_.id === integrationAlgolia.id).delete))) + } + + override def rollback = Some(CreateSearchProviderAlgolia(project, integrationAlgolia).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSeat.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSeat.scala new file mode 100644 index 0000000000..768abbd1ec --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/DeleteSeat.scala @@ -0,0 +1,23 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.{Client, Project, Seat} +import cool.graph.system.database.tables.{RelayIdTable, SeatTable} +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class DeleteSeat(client: Client, project: Project, seat: Seat, internalDatabase: DatabaseDef)(implicit inj: Injector) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val seats = TableQuery[SeatTable] + val relayIds = TableQuery[RelayIdTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(seats.filter(_.id === seat.id).delete, relayIds.filter(_.id === seat.id).delete))) + } + + override def rollback = Some(CreateSeat(client, project, seat, internalDatabase).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/EjectProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/EjectProject.scala new file mode 100644 index 0000000000..d079ee7b76 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/EjectProject.scala @@ -0,0 +1,51 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{FunctionBinding, Project} +import cool.graph.system.database.tables.Tables +import scaldi.Injectable +import slick.dbio.Effect.Write +import slick.jdbc.MySQLProfile.api._ +import slick.sql.FixedSqlAction + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +/** + * Sets the project to the "ejected" state, in which it can't be modified from the console anymore - only the CLI. + */ +case class EjectProject(project: Project) extends SystemSqlMutaction with Injectable { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val ejectProjectAction = setEjectedQuery(true) + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(ejectProjectAction))) + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some { + val resetEjectProjectAction = setEjectedQuery(project.isEjected) + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(resetEjectProjectAction))) + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = Future.successful { + () match { + case _ if project.integrations.exists(_.isEnabled) => + Failure(UserInputErrors.ProjectEjectFailure("it has enabled integrations. Please migrate all integrations to resolvers first.")) + + case _ if project.functions.exists(_.binding == FunctionBinding.PRE_WRITE) => + Failure(UserInputErrors.ProjectEjectFailure("it has a Pre_Write RequestPipelineFunction. Please migrate it to Transform_Argument first.")) + + case _ => + Success(MutactionVerificationSuccess()) + } + } + + private def setEjectedQuery(isEjected: Boolean): FixedSqlAction[Int, NoStream, Write] = { + val query = for { + projectRow <- Tables.Projects + if projectRow.id === project.id + } yield projectRow.isEjected + + query.update(isEjected) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ExportData.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ExportData.scala new file mode 100644 index 0000000000..5359e07935 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ExportData.scala @@ -0,0 +1,139 @@ +package cool.graph.system.mutactions.internal + +import java.io.{BufferedWriter, ByteArrayInputStream, ByteArrayOutputStream, OutputStreamWriter} +import java.nio.charset.Charset +import java.util.zip.{ZipEntry, ZipOutputStream} +import akka.stream.ActorMaterializer +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.model.{CannedAccessControlList, ObjectMetadata, PutObjectRequest} +import cool.graph.JsonFormats._ +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.cuid.Cuid +import cool.graph.shared.errors.UserInputErrors.TooManyNodesToExportData +import cool.graph.shared.models._ +import scaldi.{Injectable, Injector} +import spray.json._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class ExportData(project: Project, resolver: DataResolver)(implicit inj: Injector) extends Mutaction with Injectable { + + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val MODEL_SIZE_LIMIT = 10000 + val key = s"${Cuid.createCuid()}.zip" + def getUrl = s"https://s3-eu-west-1.amazonaws.com/${sys.env.getOrElse("DATA_EXPORT_S3_BUCKET", "")}/$key" + + def generateJsonForModel(model: Model): Future[String] = { + val relationIds = model.fields.filter(_.isRelation).map(_.relation.get.id) + + Future + .sequence(relationIds.map(relationId => { + resolver + .resolveByModel(Model(id = relationId, name = relationId, isSystem = false)) + .map(x => (relationId, x.items.map(_.userData))) + })) + .flatMap((relations: Seq[(Id, Seq[Map[String, Option[Any]]])]) => { + resolver + .resolveByModel(model) + .map(resolverResult => { + resolverResult.items.map(dataItem => { + val relationValues = model.fields + .filter(_.isRelation) + .map(relationField => { + relationField.name -> relations + .find(_._1 == relationField.relation.get.id) + .get + ._2 + .flatMap(x => { + x match { + case y if y("A").contains(dataItem.id) => y("B") + case y if y("B").contains(dataItem.id) => y("A") + case _ => None + } + }) + }) + + val scalarValues: Map[String, Any] = + dataItem.userData.mapValues(_.orNull) + scalarValues + ("id" -> dataItem.id) ++ relationValues + }) + }) + .map((data: Seq[Map[String, Any]]) => { + data.toJson(writer = new SeqAnyJsonWriter()).prettyPrint + }) + }) + } + + def zipInMemory(modelNameAndJson: List[(String, String)]): Array[Byte] = { + val out = new ByteArrayOutputStream() + val zip = new ZipOutputStream(out) + + val writer = new BufferedWriter( + new OutputStreamWriter(zip, Charset.forName("utf-8")) + ) + + modelNameAndJson.foreach(nameAndJson => { + zip.putNextEntry(new ZipEntry(nameAndJson._1)) + + writer.write(nameAndJson._2.toCharArray) + writer.flush() + + zip.closeEntry() + }) + + writer.close() + zip.close() + out.toByteArray + } + + def uploadBytes(bytes: Array[Byte]) = { + val s3: AmazonS3 = inject[AmazonS3]("export-data-s3") + val bucketName: String = sys.env.getOrElse("DATA_EXPORT_S3_BUCKET", "") + val meta: ObjectMetadata = getObjectMetaData(key) + + meta.setContentLength(bytes.length.toLong) + + val request = new PutObjectRequest(bucketName, key, new ByteArrayInputStream(bytes), meta) + request.setCannedAcl(CannedAccessControlList.PublicRead) + + s3.putObject(request) + } + + def getObjectMetaData(fileName: String): ObjectMetadata = { + val contentType = "application/octet-stream" + val meta = new ObjectMetadata() + + meta.setHeader("content-disposition", s"""filename="$fileName"""") + meta.setContentType(contentType) + meta + } + + override def execute: Future[MutactionExecutionSuccess] = { + Future + .sequence(project.models.map(model => generateJsonForModel(model).map(json => (model, json)))) + .map(x => { + val modelNameAndJsonList = x.map(y => (s"${y._1.name}.json", y._2)) + val zipBytes = zipInMemory(modelNameAndJsonList) + + uploadBytes(zipBytes) + MutactionExecutionSuccess() + }) + } + + override def verify: Future[Try[MutactionVerificationSuccess]] = { + + def verifyResultSizeLimitIsNotExceeded(modelCounts: List[Int]) = { + modelCounts.map { modelCount => + if (modelCount > MODEL_SIZE_LIMIT) throw TooManyNodesToExportData(MODEL_SIZE_LIMIT) + } + } + + Future.sequence(project.models.map(model => { resolver.itemCountForModel(model) })).map(verifyResultSizeLimitIsNotExceeded) + + Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/InvalidateSchema.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/InvalidateSchema.scala new file mode 100644 index 0000000000..c221e76a59 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/InvalidateSchema.scala @@ -0,0 +1,88 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.messagebus.PubSubPublisher +import cool.graph.messagebus.pubsub.Only +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.CachedProjectResolver +import cool.graph.system.database.tables.{SeatTable, Tables} +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.collection.immutable.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +/** + * Project schemas are cached in a shared redis instance in the system cluster: + * - System Api + Schema Manager lives in one stack that is only deployed in ireland. + * - Other regions have all other apis and services deployed in a client stack. + * + * This mutaction is only invoked by the system api / admin service. + * It will invalidate the local redis in a blocking fashion before sending a message to the invalidation publisher. + * that other stacks will subscribe to. + * + * There are at least two consumers of the invalidation message: + * - The subscription manager that caches the schema in memory. + * - The AutoInvalidatingProjectCache that caches sangria schemas in memory. + */ +object InvalidateSchema { + def apply(project: Project)(implicit inj: Injector): InvalidateSchema = InvalidateSchema(project.id) +} + +case class InvalidateSchema(projectId: String)(implicit inj: Injector) extends InvalidateSchemaBase { + + def projectIds: Future[Vector[String]] = Future.successful(Vector(projectId)) +} + +case class InvalidateAllSchemas()(implicit inj: Injector) extends InvalidateSchemaBase { + import slick.jdbc.MySQLProfile.api._ + + var invalidationCount = 0 + + def projectIds: Future[Vector[String]] = { + val query = for { + project <- Tables.Projects + } yield { + project.id + } + internalDatabase.run(query.result).map { projectIds => + invalidationCount = projectIds.size + projectIds.toVector + } + } +} + +case class InvalidateSchemaForAllProjects(client: Client)(implicit inj: Injector) extends InvalidateSchemaBase { + + def projectIds: Future[Vector[String]] = { + import slick.jdbc.MySQLProfile.api._ + import slick.lifted.TableQuery + + val seatFuture = internalDatabase.run(TableQuery[SeatTable].filter(_.email === client.email).result) + seatFuture.map { seats => + seats.toVector.map(_.projectId) + } + } +} + +abstract class InvalidateSchemaBase()(implicit inj: Injector) extends Mutaction with Injectable { + val internalDatabase: DatabaseDef = inject[DatabaseDef](identified by "internal-db") + val cachedProjectResolver = inject[CachedProjectResolver](identified by "cachedProjectResolver") + val invalidationPublisher = inject[PubSubPublisher[String]](identified by "schema-invalidation-publisher") + + override def execute: Future[MutactionExecutionResult] = { + projectIds.flatMap { projectIdsOrAliases => + val invalidationFutures: Seq[Future[Unit]] = projectIdsOrAliases.map(cachedProjectResolver.invalidate) + + Future.sequence(invalidationFutures).map { _ => + invalidate(projectIds = projectIdsOrAliases) + MutactionExecutionSuccess() + } + } + } + + private def invalidate(projectIds: Seq[String]): Unit = projectIds.foreach(pid => invalidationPublisher.publish(Only(pid), pid)) + protected def projectIds: Future[Vector[String]] + override def rollback = Some(ClientMutactionNoop().execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/JoinPendingSeats.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/JoinPendingSeats.scala new file mode 100644 index 0000000000..4bd73b487b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/JoinPendingSeats.scala @@ -0,0 +1,25 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.SeatTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class JoinPendingSeats(client: Client) extends SystemSqlMutaction { + + implicit val mapper = SeatTable.SeatStatusMapper + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val seats = TableQuery[SeatTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { s <- seats if s.email === client.email } yield (s.status, s.clientId) + q.update(SeatStatus.JOINED, Some(client.id)) + }))) + } + + override def rollback = Some(SystemMutactionNoop().execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ResetClientPassword.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ResetClientPassword.scala new file mode 100644 index 0000000000..eb1e85a00b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/ResetClientPassword.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.ClientTable +import cool.graph.shared.models.Client +import cool.graph.util.crypto.Crypto +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class ResetClientPassword(client: Client, resetPasswordToken: String, newPassword: String) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val hashedPassword = Crypto.hash(password = newPassword) + + val clients = TableQuery[ClientTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { + c <- clients if c.id === client.id && + c.resetPasswordToken === resetPasswordToken + } yield (c.password, c.resetPasswordToken) + q.update(hashedPassword, None) + }))) + } + + override def rollback = Some(SystemMutactionNoop().execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + // todo: verify new password is valid (long / strong) + Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SetFeatureToggle.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SetFeatureToggle.scala new file mode 100644 index 0000000000..cb2d45d89f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SetFeatureToggle.scala @@ -0,0 +1,50 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.FeatureToggleTable +import cool.graph.shared.models.{FeatureToggle, Project} +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class SetFeatureToggle(project: Project, featureToggle: FeatureToggle) extends SystemSqlMutaction { + val featureToggles: TableQuery[FeatureToggleTable] = TableQuery[FeatureToggleTable] + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val insertOrUpdate = featureToggles + .filter(ft => ft.projectId === project.id && ft.name === featureToggle.name) + .result + .headOption + .flatMap { + case Some(featureToggleRow) => + featureToggles.update( + featureToggleRow.copy( + isEnabled = featureToggle.isEnabled + ) + ) + case None => + featureToggles += cool.graph.system.database.tables.FeatureToggle( + id = featureToggle.id, + projectId = project.id, + name = featureToggle.name, + isEnabled = featureToggle.isEnabled + ) + } + .transactionally + + Future.successful( + SystemSqlStatementResult( + DBIO.seq(insertOrUpdate) + ) + ) + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + // FIXME: just able to set toggles in projects one has access to? + Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SystemMutactionNoop.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SystemMutactionNoop.scala new file mode 100644 index 0000000000..3a410f8022 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/SystemMutactionNoop.scala @@ -0,0 +1,14 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Future + +case class SystemMutactionNoop() extends SystemSqlMutaction { + + override def execute = Future.successful(SystemSqlStatementResult(sqlAction = DBIO.successful(None))) + + override def rollback = Some(Future.successful(SystemSqlStatementResult(sqlAction = DBIO.successful(None)))) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAction.scala new file mode 100644 index 0000000000..0176c15317 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAction.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.ActionHandlerType._ +import cool.graph.shared.models.ActionTriggerType._ +import cool.graph.shared.models.{Action, ActionHandlerType, ActionTriggerType, Project} +import cool.graph.system.database.tables.ActionTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateAction(project: Project, oldAction: Action, action: Action) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + implicit val ActionHandlerTypeMapper = + MappedColumnType.base[ActionHandlerType, String]( + e => e.toString, + s => ActionHandlerType.withName(s) + ) + implicit val ActionTriggerTypeMapper = + MappedColumnType.base[ActionTriggerType, String]( + e => e.toString, + s => ActionTriggerType.withName(s) + ) + + val actions = TableQuery[ActionTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { a <- actions if a.id === action.id } yield (a.description, a.isActive, a.triggerType, a.handlerType) + + q.update((action.description, action.isActive, action.triggerType, action.handlerType)) + }))) + } + + override def rollback = Some(UpdateAction(project, oldAction, oldAction).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..a657e50e2b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAlgoliaSyncQuery.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.AlgoliaSyncQuery +import cool.graph.system.database.tables.AlgoliaSyncQueryTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateAlgoliaSyncQuery(oldAlgoliaSyncQuery: AlgoliaSyncQuery, newAlgoliaSyncQuery: AlgoliaSyncQuery) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val algoliaSyncQueries = TableQuery[AlgoliaSyncQueryTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { s <- algoliaSyncQueries if s.id === newAlgoliaSyncQuery.id } yield (s.indexName, s.query, s.isEnabled) + q.update(newAlgoliaSyncQuery.indexName, newAlgoliaSyncQuery.fragment, newAlgoliaSyncQuery.isEnabled) + }))) + } + + override def rollback = Some(UpdateAlgoliaSyncQuery(oldAlgoliaSyncQuery, oldAlgoliaSyncQuery).execute) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAuthProvider.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAuthProvider.scala new file mode 100644 index 0000000000..1da5a39c11 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateAuthProvider.scala @@ -0,0 +1,49 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.{IntegrationAuth0Table, IntegrationDigitsTable, IntegrationTable} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateAuthProvider(project: Project, + authProvider: AuthProvider, + metaInformation: Option[AuthProviderMetaInformation] = None, + oldMetaInformationId: Option[String] = None) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val authProviders = TableQuery[IntegrationTable] + val integrationDigits = TableQuery[IntegrationDigitsTable] + val integrationAuth0s = TableQuery[IntegrationAuth0Table] + + val updateIntegration = { + val q = for { a <- authProviders if a.id === authProvider.id } yield (a.isEnabled) + q.update(authProvider.isEnabled) + } + + val upsertIntegrationMeta = metaInformation match { + case Some(digits: AuthProviderDigits) if digits.isInstanceOf[AuthProviderDigits] => { + List( + integrationDigits.insertOrUpdate( + cool.graph.system.database.tables.IntegrationDigits(id = oldMetaInformationId.getOrElse(digits.id), + integrationId = authProvider.id, + consumerKey = digits.consumerKey, + consumerSecret = digits.consumerSecret))) + } + case Some(auth0: AuthProviderAuth0) if auth0.isInstanceOf[AuthProviderAuth0] => { + List( + integrationAuth0s.insertOrUpdate( + cool.graph.system.database.tables.IntegrationAuth0(id = oldMetaInformationId.getOrElse(auth0.id), + integrationId = authProvider.id, + clientId = auth0.clientId, + clientSecret = auth0.clientSecret, + domain = auth0.domain))) + } + case _ => List() + } + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(List(updateIntegration) ++ upsertIntegrationMeta: _*))) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClient.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClient.scala new file mode 100644 index 0000000000..f09d827953 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClient.scala @@ -0,0 +1,34 @@ +package cool.graph.system.mutactions.internal + +import java.sql.SQLIntegrityConstraintViolationException + +import com.github.tototoshi.slick.MySQLJodaSupport._ +import cool.graph._ +import cool.graph.shared.errors.UserInputErrors.ClientEmailInUse +import cool.graph.shared.models.Client +import cool.graph.system.database.tables.ClientTable +import org.joda.time.DateTime +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateClient(oldClient: Client, client: Client) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val clients = TableQuery[ClientTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { c <- clients if c.id === client.id } yield (c.name, c.email, c.updatedAt) + q.update((client.name, client.email, DateTime.now())) + }))) + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = Some(UpdateClient(oldClient, oldClient).execute) + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => ClientEmailInUse() + }) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClientPassword.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClientPassword.scala new file mode 100644 index 0000000000..386577ca5a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateClientPassword.scala @@ -0,0 +1,33 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.Client +import cool.graph.system.database.tables.ClientTable +import cool.graph.util.crypto.Crypto +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateClientPassword(client: Client, oldPassword: String, newPassword: String) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val hashedPassword = Crypto.hash(password = newPassword) + + val clients = TableQuery[ClientTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { c <- clients if c.id === client.id } yield (c.password) + q.update(hashedPassword) + }))) + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + if (!Crypto.verify(oldPassword, client.hashedPassword)) { + Future.successful(Failure(UserInputErrors.InvalidPassword())) + } else Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateCustomerInAuth0.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateCustomerInAuth0.scala new file mode 100644 index 0000000000..7241c93290 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateCustomerInAuth0.scala @@ -0,0 +1,44 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.models.Client +import cool.graph.system.externalServices.{Auth0Api, Auth0ApiUpdateValues} +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class UpdateCustomerInAuth0(oldClient: Client, client: Client)(implicit inj: Injector) extends Mutaction with Injectable { + override def execute: Future[MutactionExecutionSuccess] = { + val emailUpdate = oldClient.email == client.email match { + case true => None + case false => Some(client.email) + } + + emailUpdate match { + case None => + Future.successful(MutactionExecutionSuccess()) + + case Some(_) => + val values = Auth0ApiUpdateValues(email = emailUpdate) + + val auth0Api = inject[Auth0Api] + + auth0Api.updateClient(client.auth0Id.get, values).map { + case true => MutactionExecutionSuccess() + case false => throw new Exception("Updating Auth0 failed") + } + } + } + + override def rollback = Some(UpdateCustomerInAuth0(oldClient = client, client = oldClient).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + client.auth0Id match { + case None => throw new Exception(s"Client ${client.id} does not have a auth0Id") + case Some(_) => Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateEnum.scala new file mode 100644 index 0000000000..7b64bf4d5e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateEnum.scala @@ -0,0 +1,42 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.client.database.DataResolver +import cool.graph.system.database.tables.EnumTable +import cool.graph.shared.models.Enum +import cool.graph.system.mutactions.internal.validations.EnumValueValidation +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import scala.concurrent.Future +import scala.util.Try + +case class UpdateEnum(newEnum: Enum, oldEnum: Enum) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + val enums = TableQuery[EnumTable] + val query = for { + enum <- enums + if enum.id === oldEnum.id + } yield (enum.name, enum.values) + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + query.update(newEnum.name, newEnum.values.toJson.compactPrint) + ) + } + } + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = Some(UpdateEnum(newEnum = oldEnum, oldEnum = oldEnum).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful { + for { + _ <- EnumValueValidation.validateEnumValues(newEnum.values) + } yield MutactionVerificationSuccess() + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateField.scala new file mode 100644 index 0000000000..8b6f7fdf76 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateField.scala @@ -0,0 +1,184 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.{SystemErrors, UserInputErrors} +import cool.graph.shared.models.{Field, Model, TypeIdentifier} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.tables.FieldTable +import cool.graph.system.mutactions.internal.validations.{EnumValueValidation, MigrationAndDefaultValueValidation} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateField( + model: Model, + oldField: Field, + field: Field, + migrationValue: Option[String], + newModelId: Option[String] = None, + clientDbQueries: ClientDbQueries +) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val fields = TableQuery[FieldTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { f <- fields if f.id === field.id } yield f + q.update(ModelToDbMapper.convertField(newModelId.getOrElse(model.id), field)) + }))) + } + + override def rollback: Some[Future[SystemSqlStatementResult[Any]]] = { + Some( + UpdateField( + model = model, + oldField = oldField, + field = oldField, + migrationValue = None, + newModelId = Some(model.id), + clientDbQueries = clientDbQueries + ).execute + ) + } + + def isUpdatingIllegalProperty(oldField: Field, newField: Field): Boolean = { + oldField.typeIdentifier != newField.typeIdentifier || + oldField.name != newField.name || oldField.isList != newField.isList || + oldField.isUnique != newField.isUnique || + oldField.isRequired != newField.isRequired || + oldField.defaultValue != newField.defaultValue + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + //if a model gets renamed in a SchemaMigration the resolver uses the new table name although that transaction has not been performed yet. + + lazy val nodeExists = clientDbQueries.existsByModel(model) + lazy val nodeWithNullFieldExists = clientDbQueries.existsNullByModelAndScalarField(model, field) + lazy val nodeWithNullRelationExists = clientDbQueries.existsNullByModelAndRelationField(model, field) + lazy val nodeAndScalarExists = Future.sequence(List(nodeExists, nodeWithNullFieldExists)) + lazy val nodeAndRelationExists = Future.sequence(List(nodeExists, nodeWithNullRelationExists)) + + def relationChecks(nodeExistsAndRelationExists: List[Boolean]): Try[MutactionVerificationSuccess] = { + + if (nodeExistsAndRelationExists.head) Failure(UserInputErrors.RelationChangedFromListToSingleAndNodesPresent(field.name)) + else if (nodeExistsAndRelationExists(1)) Failure(UserInputErrors.SettingRelationRequiredButNodesExist(field.name)) + else doVerify + } + + def scalarChecks(nodeExistsAndScalarFieldExists: List[Boolean]): Try[MutactionVerificationSuccess] = { + if (nodeExistsAndScalarFieldExists.head) Failure(UserInputErrors.ChangedIsListAndNoMigrationValue(field.name)) + else if (nodeExistsAndScalarFieldExists(1)) Failure(UserInputErrors.RequiredAndNoMigrationValue(modelName = model.name, fieldName = field.name)) + else doVerify + } + + val listToSingle = oldField.isList && !field.isList + val optionalToRequired = !oldField.isRequired && field.isRequired + val changedListStatus = oldField.isList != field.isList + + if (field.relation.isDefined) { + (listToSingle, optionalToRequired) match { + case (true, false) => + nodeExists map { + case false => doVerify + case true => Failure(UserInputErrors.RelationChangedFromListToSingleAndNodesPresent(field.name)) + } + + case (false, true) => + nodeWithNullRelationExists map { + case false => doVerify + case true => Failure(UserInputErrors.SettingRelationRequiredButNodesExist(field.name)) + } + + case (false, false) => + Future(doVerify) + + case (true, true) => + nodeAndRelationExists map relationChecks + } + } else if (field.relation.isEmpty && migrationValue.isEmpty) { + (changedListStatus, optionalToRequired, UpdateField.typeChangeRequiresMigration(oldField, field)) match { + case (false, false, false) => + Future(doVerify) + + case (true, false, false) => + nodeExists map { + case false => doVerify + case true => Failure(UserInputErrors.ChangedIsListAndNoMigrationValue(field.name)) + } + + case (false, true, false) => + nodeWithNullFieldExists map { + case false => doVerify + case true => Failure(UserInputErrors.RequiredAndNoMigrationValue(modelName = model.name, fieldName = field.name)) + } + + case (true, true, false) => + nodeAndScalarExists map scalarChecks + + case (_, _, true) => + nodeExists map { + case false => doVerify //if there are no nodes, there can also be no scalarNullFields, + case true => Failure(UserInputErrors.TypeChangeRequiresMigrationValue(field.name)) //if there are nodes we always require migValue + } + } + } else Future(doVerify) + } + + def doVerify: Try[MutactionVerificationSuccess] = { + + lazy val fieldValidations = UpdateField.fieldValidations(field, migrationValue) + lazy val updateFieldFieldValidations = UpdateField.fieldValidations(field, migrationValue) + lazy val fieldWithSameNameAndDifferentIdExists = model.fields.exists(x => x.name.toLowerCase == field.name.toLowerCase && x.id != field.id) + + () match { + case _ if model.getFieldById(field.id).isEmpty => + Failure(SystemErrors.FieldNotInModel(fieldName = field.name, modelName = model.name)) + + case _ if field.isSystem && isUpdatingIllegalProperty(oldField = oldField, newField = field) => + Failure(SystemErrors.CannotUpdateSystemField(fieldName = field.name, modelName = model.name)) + + case _ if fieldValidations.isFailure => + fieldValidations + + case _ if updateFieldFieldValidations.isFailure => + updateFieldFieldValidations + + case _ if fieldWithSameNameAndDifferentIdExists => + Failure(UserInputErrors.FieldAreadyExists(field.name)) + + case _ => + Success(MutactionVerificationSuccess()) + } + } +} + +object UpdateField { + def typeChangeRequiresMigration(oldField: Field, updatedField: Field): Boolean = { + (oldField.typeIdentifier, updatedField.typeIdentifier) match { + case (_, TypeIdentifier.String) => false + case (oldType, updatedType) if oldType == updatedType => false + case _ => true + } + } + + def fieldValidations(field: Field, migrationValue: Option[String]): Try[MutactionVerificationSuccess] = { + lazy val isInvalidFieldName = !NameConstraints.isValidFieldName(field.name) + lazy val defaultAndMigrationValueValidation = MigrationAndDefaultValueValidation.validateMigrationAndDefaultValue(migrationValue, field) + lazy val enumValueValidation = EnumValueValidation.validateEnumField(migrationValue, field) + lazy val isRequiredManyRelation = field.relation.isDefined && field.isList && field.isRequired + + () match { + case _ if isInvalidFieldName => Failure(UserInputErrors.InvalidName(name = field.name)) + case _ if enumValueValidation.isFailure => enumValueValidation + case _ if defaultAndMigrationValueValidation.isFailure => defaultAndMigrationValueValidation + case _ if isRequiredManyRelation => Failure(UserInputErrors.ListRelationsCannotBeRequired(field.name)) + case _ => Success(MutactionVerificationSuccess()) + } + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFieldConstraint.scala new file mode 100644 index 0000000000..4fd35c680b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFieldConstraint.scala @@ -0,0 +1,25 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models._ +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.FieldConstraintTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateFieldConstraint(field: Field, oldConstraint: FieldConstraint, constraint: FieldConstraint) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + val constraints = TableQuery[FieldConstraintTable] + + val query = constraints.filter(_.id === constraint.id) + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq(query.update(ModelToDbMapper.convertFieldConstraint(constraint))))) + + } + + override def rollback = Some(UpdateFieldConstraint(field = field, oldConstraint = constraint, constraint = oldConstraint).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFunction.scala new file mode 100644 index 0000000000..46dcb3cf69 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateFunction.scala @@ -0,0 +1,88 @@ +package cool.graph.system.mutactions.internal + +import akka.http.scaladsl.model.Uri +import cool.graph.shared.errors.UserInputErrors._ +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors.{FunctionHasInvalidUrl, FunctionWithNameAlreadyExists, IllegalFunctionName, SchemaExtensionParseError} +import cool.graph.shared.models.{CustomMutationFunction, CustomQueryFunction, Function, FunctionDelivery, HttpFunction, Project} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.FunctionTable +import cool.graph.{MutactionVerificationSuccess, SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateFunction(project: Project, newFunction: Function, oldFunction: Function) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + + implicit val FunctionBindingMapper = FunctionTable.FunctionBindingMapper + implicit val FunctionTypeMapper = FunctionTable.FunctionTypeMapper + implicit val RequestPipelineMutationOperationMapper = FunctionTable.RequestPipelineMutationOperationMapper + + val functions = TableQuery[FunctionTable] + + Future.successful { + SystemSqlStatementResult { + DBIO.seq( + functions.filter(_.id === newFunction.id).update(ModelToDbMapper.convertFunction(project, newFunction)) + ) + } + } + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = FunctionVerification.verifyFunction(newFunction, project) + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = + Some(UpdateFunction(project = project, newFunction = oldFunction, oldFunction = newFunction).execute) + +} + +object FunctionVerification { + + def verifyFunction(function: Function, project: Project): Future[Try[MutactionVerificationSuccess] with Product with Serializable] = { + + def differentFunctionWithSameTypeName(name: String, id: String): Boolean = { + project.customMutationFunctions.exists(func => func.payloadType.name == name && func.id != id) || + project.customQueryFunctions.exists(func => func.payloadType.name == name && func.id != id) + + } + + def differentFunctionWithSameName: Boolean = { + project.functions.exists(func => func.name.toLowerCase == function.name.toLowerCase && func.id != function.id) + } + + val typeNameViolation = function match { + case f: CustomMutationFunction if project.models.map(_.name).contains(f.payloadType.name) => List(f.payloadType.name) + case f: CustomQueryFunction if project.models.map(_.name).contains(f.payloadType.name) => List(f.payloadType.name) + case f: CustomMutationFunction if differentFunctionWithSameTypeName(f.payloadType.name, f.id) => List(f.payloadType.name) + case f: CustomQueryFunction if differentFunctionWithSameTypeName(f.payloadType.name, f.id) => List(f.payloadType.name) + case _ => List.empty + } + + def hasInvalidUrl = function.delivery match { + case x: HttpFunction => Try(Uri(x.url)).isFailure + case _ => false + } + + def getInvalidUrl(delivery: FunctionDelivery) = delivery.asInstanceOf[HttpFunction].url + + def projectHasNameConflict = function match { + case x: CustomQueryFunction => project.hasSchemaNameConflict(x.queryName, function.id) + case x: CustomMutationFunction => project.hasSchemaNameConflict(x.mutationName, function.id) + case _ => false + } + + Future.successful(() match { + case _ if !NameConstraints.isValidFunctionName(function.name) => Failure(IllegalFunctionName(function.name)) + case _ if typeNameViolation.nonEmpty => Failure(FunctionHasInvalidPayloadName(name = function.name, payloadName = typeNameViolation.head)) + case _ if differentFunctionWithSameName => Failure(FunctionWithNameAlreadyExists(name = function.name)) + case _ if hasInvalidUrl => Failure(FunctionHasInvalidUrl(name = function.name, url = getInvalidUrl(function.delivery))) + case _ if projectHasNameConflict => Failure(SchemaExtensionParseError(function.name, "Operation name would conflict with existing schema")) + case _ => Success(MutactionVerificationSuccess()) + }) + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateIntegration.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateIntegration.scala new file mode 100644 index 0000000000..b373048222 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateIntegration.scala @@ -0,0 +1,28 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.shared.models.{Integration, Project} +import cool.graph.system.database.tables.{IntegrationTable, RelayIdTable} +import cool.graph.{SystemSqlMutaction, SystemSqlStatementResult} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateIntegration(project: Project, oldIntegration: Integration, integration: Integration) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful({ + val integrations = TableQuery[IntegrationTable] + val relayIds = TableQuery[RelayIdTable] + println("Updating isEnabled of integration " + integration.isEnabled.toString) + SystemSqlStatementResult( + sqlAction = DBIO.seq({ + val q = for { i <- integrations if i.id === integration.id } yield i.isEnabled + q.update(integration.isEnabled) + }) + ) + }) + } + + override def rollback = Some(UpdateIntegration(project, oldIntegration = oldIntegration, integration = oldIntegration).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModel.scala new file mode 100644 index 0000000000..ec1e882622 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModel.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{Model, Project} +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.tables.Tables +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateModel(project: Project, oldModel: Model, model: Model) extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + Future.successful { + SystemSqlStatementResult(sqlAction = DBIO.seq { + Tables.Models.filter(_.id === model.id).update(ModelToDbMapper.convertModel(project, model)) + }) + } + } + + override def rollback = Some(UpdateModel(project = project, oldModel = oldModel, model = oldModel).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(() match { + case _ if oldModel.isSystem && oldModel.name != model.name => Failure(UserInputErrors.CantRenameSystemModels(name = oldModel.name)) + case _ if !NameConstraints.isValidModelName(model.name) => Failure(UserInputErrors.InvalidName(name = model.name)) + case _ if CustomScalarTypes.isScalar(model.name) => Failure(UserInputErrors.InvalidName(name = model.name)) + case _ if project.getModelByName(model.name).exists(_.id != model.id) => Failure(UserInputErrors.ModelWithNameAlreadyExists(model.name)) + case _ => Success(MutactionVerificationSuccess()) + }) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModelPermission.scala new file mode 100644 index 0000000000..273b196200 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateModelPermission.scala @@ -0,0 +1,63 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.CustomRule.{apply => _, _} +import cool.graph.shared.models.ModelOperation.{apply => _, _} +import cool.graph.shared.models.UserType._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.ModelPermissionTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateModelPermission(model: Model, oldPermisison: ModelPermission, permission: ModelPermission) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + implicit val userTypesMapper = MappedColumnType.base[UserType, String]( + e => e.toString, + s => UserType.withName(s) + ) + + implicit val operationTypesMapper = + MappedColumnType.base[ModelOperation, String]( + e => e.toString, + s => ModelOperation.withName(s) + ) + + implicit val customRuleTypesMapper = + MappedColumnType.base[CustomRule, String]( + e => e.toString, + s => CustomRule.withName(s) + ) + + val permissions = TableQuery[ModelPermissionTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { p <- permissions if p.id === permission.id } yield + (p.userType, + p.operation, + p.applyToWholeModel, + p.rule, + p.ruleGraphQuery, + p.ruleGraphQueryFilePath, + p.ruleName, + p.ruleWebhookUrl, + p.description, + p.isActive) + q.update( + (permission.userType, + permission.operation, + permission.applyToWholeModel, + permission.rule, + permission.ruleGraphQuery, + permission.ruleGraphQueryFilePath, + permission.ruleName, + permission.ruleWebhookUrl, + permission.description, + permission.isActive)) + }))) + } + + override def rollback = Some(UpdateModelPermission(model = model, oldPermisison = permission, permission = oldPermisison).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateProject.scala new file mode 100644 index 0000000000..a5e33bf773 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateProject.scala @@ -0,0 +1,66 @@ +package cool.graph.system.mutactions.internal + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph._ +import cool.graph.shared.errors.UserInputErrors.ProjectWithAliasAlreadyExists +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.ModelToDbMapper +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.database.tables.ProjectTable +import cool.graph.system.mutactions.internal.validations.ProjectValidations +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Try + +case class UpdateProject( + client: Client, + oldProject: Project, + project: Project, + internalDatabase: DatabaseDef, + projectQueries: ProjectQueries, + bumpRevision: Boolean = true +) extends SystemSqlMutaction { + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val projectValidations = ProjectValidations(client, project, projectQueries) + projectValidations.verify() + } + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val projects = TableQuery[ProjectTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + + // todo: update sangria-relay and introduce proper null support in the system api + val nullableAlias: Option[String] = project.alias match { + case Some("") => null + case x => x + } + val newRevision = if (bumpRevision) oldProject.revision + 1 else oldProject.revision + val actualProject = project.copy(revision = newRevision, alias = nullableAlias) + + projects.filter(_.id === project.id).update(ModelToDbMapper.convertProject(actualProject)) + }))) + } + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + ProjectWithAliasAlreadyExists(alias = project.alias.getOrElse("")) + }) + + override def rollback = Some { + UpdateProject( + client = client, + oldProject = oldProject, + project = oldProject, + internalDatabase = internalDatabase, + projectQueries = projectQueries + ).execute + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelation.scala new file mode 100644 index 0000000000..c8ded6fc77 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelation.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{Project, Relation} +import cool.graph.system.database.tables.RelationTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateRelation(oldRelation: Relation, relation: Relation, project: Project) extends SystemSqlMutaction { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val relations = TableQuery[RelationTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { r <- relations if r.id === relation.id } yield (r.name, r.description, r.modelAId, r.modelBId) + q.update(relation.name, relation.description, relation.modelAId, relation.modelBId) + }))) + } + + override def rollback = Some(UpdateRelation(oldRelation = oldRelation, relation = oldRelation, project = project).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + def otherRelationWithNameExists = + project.relations.exists(existing => existing.name.toLowerCase == relation.name.toLowerCase && existing.id != relation.id) + + () match { + case _ if !NameConstraints.isValidRelationName(relation.name) => Future.successful(Failure(UserInputErrors.InvalidName(name = relation.name))) + case _ if otherRelationWithNameExists => Future.successful(Failure(UserInputErrors.RelationNameAlreadyExists(relation.name))) + case _ => Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelationPermission.scala new file mode 100644 index 0000000000..b640715e6b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateRelationPermission.scala @@ -0,0 +1,60 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.shared.models.CustomRule.{apply => _, _} +import cool.graph.shared.models.ModelOperation.{apply => _} +import cool.graph.shared.models.UserType._ +import cool.graph.shared.models._ +import cool.graph.system.database.tables.RelationPermissionTable +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.Future + +case class UpdateRelationPermission(relation: Relation, + oldPermission: RelationPermission, + permission: RelationPermission) + extends SystemSqlMutaction { + override def execute: Future[SystemSqlStatementResult[Any]] = { + + implicit val userTypesMapper = MappedColumnType.base[UserType, String]( + e => e.toString, + s => UserType.withName(s) + ) + + implicit val customRuleTypesMapper = + MappedColumnType.base[CustomRule, String]( + e => e.toString, + s => CustomRule.withName(s) + ) + + val permissions = TableQuery[RelationPermissionTable] + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { p <- permissions if p.id === permission.id } yield + (p.userType, + p.connect, + p.disconnect, + p.rule, + p.ruleGraphQuery, + p.ruleGraphQueryFilePath, + p.ruleName, + p.ruleWebhookUrl, + p.description, + p.isActive) + q.update( + (permission.userType, + permission.connect, + permission.disconnect, + permission.rule, + permission.ruleGraphQuery, + permission.ruleGraphQueryFilePath, + permission.ruleName, + permission.ruleWebhookUrl, + permission.description, + permission.isActive)) + }))) + } + + override def rollback = Some(UpdateRelationPermission(relation = relation, oldPermission = permission, permission = oldPermission).execute) + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateSearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateSearchProviderAlgolia.scala new file mode 100644 index 0000000000..fc12cbc715 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateSearchProviderAlgolia.scala @@ -0,0 +1,42 @@ +package cool.graph.system.mutactions.internal + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.UserInputErrors +import cool.graph.system.database.tables.SearchProviderAlgoliaTable +import cool.graph.shared.models.SearchProviderAlgolia +import cool.graph.system.externalServices.AlgoliaKeyChecker +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateSearchProviderAlgolia(oldSearchProviderAlgolia: SearchProviderAlgolia, newSearchProviderAlgolia: SearchProviderAlgolia)(implicit inj: Injector) + extends SystemSqlMutaction + with Injectable { + + override def execute: Future[SystemSqlStatementResult[Any]] = { + val searchProviderTableAlgolias = TableQuery[SearchProviderAlgoliaTable] + + Future.successful(SystemSqlStatementResult(sqlAction = DBIO.seq({ + val q = for { s <- searchProviderTableAlgolias if s.id === newSearchProviderAlgolia.subTableId } yield (s.applicationId, s.apiKey) + q.update(newSearchProviderAlgolia.applicationId, newSearchProviderAlgolia.apiKey) + }))) + } + + override def rollback = Some(UpdateSearchProviderAlgolia(oldSearchProviderAlgolia, oldSearchProviderAlgolia).execute) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val algoliaKeyChecker = inject[AlgoliaKeyChecker](identified by "algoliaKeyChecker") + + algoliaKeyChecker + .verifyAlgoliaCredentialValidity(newSearchProviderAlgolia.applicationId, newSearchProviderAlgolia.apiKey) + .map { + case true => Success(MutactionVerificationSuccess()) + case false => Failure(UserInputErrors.AlgoliaCredentialsDontHaveRequiredPermissions()) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateTypeAndFieldPositions.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateTypeAndFieldPositions.scala new file mode 100644 index 0000000000..d9d1692a02 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/UpdateTypeAndFieldPositions.scala @@ -0,0 +1,73 @@ +package cool.graph.system.mutactions.internal + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.{CachedProjectResolver, ProjectQueries, ProjectResolver} +import sangria.ast.{Document, ObjectTypeDefinition, TypeDefinition} +import scaldi.{Injectable, Injector} +import slick.dbio.DBIOAction +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.collection.immutable.Seq +import scala.collection.mutable +import scala.concurrent.Future + +case class UpdateTypeAndFieldPositions( + project: Project, + client: Client, + newSchema: Document, + internalDatabase: DatabaseDef, + projectQueries: ProjectQueries +)(implicit inj: Injector) + extends SystemSqlMutaction + with Injectable { + import scala.concurrent.ExecutionContext.Implicits.global + + implicit val projectResolver = inject[ProjectResolver](identified by "uncachedProjectResolver") + + val mutactions: mutable.Buffer[SystemSqlMutaction] = mutable.Buffer.empty + + override def execute: Future[SystemSqlStatementResult[Any]] = + refreshProject.flatMap { project => + val newTypePositions: Seq[Id] = newSchema.definitions.collect { + case typeDef: TypeDefinition => + project + .getModelByName(typeDef.name) + .orElse(project.getEnumByName(typeDef.name)) + .map(_.id) + }.flatten + + mutactions += UpdateProject( + client = client, + oldProject = project, + project = project.copy(typePositions = newTypePositions.toList), + internalDatabase = internalDatabase, + projectQueries = projectQueries, + bumpRevision = false + ) + + mutactions ++= newSchema.definitions.collect { + case typeDef: ObjectTypeDefinition => + project.getModelByName(typeDef.name).map { model => + val newFieldPositions = typeDef.fields.flatMap { fieldDef => + model.getFieldByName(fieldDef.name).map(_.id) + }.toList + UpdateModel(project = project, oldModel = model, model = model.copy(fieldPositions = newFieldPositions)) + } + }.flatten + + val y = mutactions.map(_.execute) + Future.sequence(y).map { statementResults => + val asSingleAction = DBIOAction.sequence(statementResults.toList.map(_.sqlAction)) + SystemSqlStatementResult(sqlAction = asSingleAction) + } + } + + def refreshProject: Future[Project] = { + projectResolver.resolve(project.id).map(_.get) + } + + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = mutactions.map(_.rollback).headOption.flatten + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/EnumValueValidation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/EnumValueValidation.scala new file mode 100644 index 0000000000..6e8216c900 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/EnumValueValidation.scala @@ -0,0 +1,74 @@ +package cool.graph.system.mutactions.internal.validations + +import cool.graph.GCDataTypes.{EnumGCValue, ListGCValue} +import cool.graph.MutactionVerificationSuccess +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.errors.UserInputErrors.{DefaultValueIsNotValidEnum, MigrationValueIsNotValidEnum, NoEnumSelectedAlthoughSetToEnumType} +import cool.graph.shared.models.{Field, TypeIdentifier} +import cool.graph.util.json.SprayJsonExtensions + +import scala.util.{Failure, Success, Try} + +object EnumValueValidation extends SprayJsonExtensions { + + def validateEnumField(migrationValue: Option[String], field: Field): Try[MutactionVerificationSuccess] = { + + field.typeIdentifier match { + case TypeIdentifier.Enum if field.enum.isEmpty => + Failure(NoEnumSelectedAlthoughSetToEnumType(field.name)) + + case TypeIdentifier.Enum => + val enum = field.enum.get + (field.isList, field.defaultValue, migrationValue) match { + case (false, Some(dV), _) if !dV.isInstanceOf[EnumGCValue] => + Failure(DefaultValueIsNotValidEnum(dV.toString)) + case (false, Some(dV: EnumGCValue), _) if !enum.values.contains(dV.value) => + Failure(DefaultValueIsNotValidEnum(dV.value)) + case (false, _, Some(mV)) if !enum.values.contains(mV) => + Failure(MigrationValueIsNotValidEnum(mV)) + case (true, Some(dV), _) if !dV.isInstanceOf[ListGCValue] => + Failure(DefaultValueIsNotValidEnum(dV.toString)) + case (true, Some(dV: ListGCValue), _) if newValidateEnumListInput(dV.getEnumVector, field).nonEmpty => + Failure(DefaultValueIsNotValidEnum(validateEnumListInput(dV.toString, field).mkString(","))) + + case (true, _, Some(mV)) if validateEnumListInput(mV, field).nonEmpty => + Failure(MigrationValueIsNotValidEnum(validateEnumListInput(mV, field).mkString(","))) + + case _ => + Success(MutactionVerificationSuccess()) + } + + case _ => + Success(MutactionVerificationSuccess()) + } + } + + def validateEnumValues(enumValues: Seq[String]): Try[MutactionVerificationSuccess] = { + lazy val invalidEnumValueNames = enumValues.filter(!NameConstraints.isValidEnumValueName(_)) + + () match { + case _ if enumValues.isEmpty => Failure(UserInputErrors.MissingEnumValues()) + case _ if invalidEnumValueNames.nonEmpty => Failure(UserInputErrors.InvalidNameMustStartUppercase(invalidEnumValueNames.mkString(","))) + case _ => Success(MutactionVerificationSuccess()) + } + } + + def validateEnumListInput(input: String, field: Field): Seq[String] = { + val inputWithoutWhitespace = input.replaceAll(" ", "") + + inputWithoutWhitespace match { + case "[]" => + Seq.empty + + case _ => + val values = inputWithoutWhitespace.stripPrefix("[").stripSuffix("]").split(",") + values.collect { case value if !field.enum.get.values.contains(value) => value } + } + } + + def newValidateEnumListInput(input: Vector[String], field: Field): Vector[String] = { + input.collect { case value if !field.enum.get.values.contains(value) => value } + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MigrationAndDefaultValueValidation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MigrationAndDefaultValueValidation.scala new file mode 100644 index 0000000000..6beed13712 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MigrationAndDefaultValueValidation.scala @@ -0,0 +1,47 @@ +package cool.graph.system.mutactions.internal.validations + +import cool.graph.GCDataTypes.{GCStringConverter, NullGCValue} +import cool.graph.MutactionVerificationSuccess +import cool.graph.shared.DatabaseConstraints +import cool.graph.shared.errors.UserAPIErrors.ValueTooLong +import cool.graph.shared.errors.UserInputErrors.InvalidValueForScalarType +import cool.graph.shared.models.Field +import cool.graph.shared.schema.CustomScalarTypes.isValidScalarType +import cool.graph.GCDataTypes.OtherGCStuff.isValidGCValueForField + +import scala.util.{Failure, Success, Try} + +object MigrationAndDefaultValueValidation { + + def validateDefaultValue(field: Field): Try[MutactionVerificationSuccess] = { + field.defaultValue match { + case Some(defValue) if !isValidGCValueForField(defValue, field) => Failure(InvalidValueForScalarType(defValue.toString, field.typeIdentifier)) + case Some(defValue) + if !defValue.isInstanceOf[NullGCValue] && !DatabaseConstraints + .isValueSizeValid(GCStringConverter(field.typeIdentifier, field.isList).fromGCValue(defValue), field) => + Failure(ValueTooLong("DefaultValue")) + case _ => Success(MutactionVerificationSuccess()) + } + } + + def validateMigrationValue(migrationValue: Option[String], field: Field): Try[MutactionVerificationSuccess] = { + migrationValue match { + case Some(migValue) if !isValidScalarType(migValue, field) => Failure(InvalidValueForScalarType(migValue, field.typeIdentifier)) + case Some(migValue) if !DatabaseConstraints.isValueSizeValid(migValue, field) => Failure(ValueTooLong("MigrationValue")) + case _ => Success(MutactionVerificationSuccess()) + } + } + + def validateMigrationAndDefaultValue(migrationValue: Option[String], field: Field): Try[MutactionVerificationSuccess] = { + + lazy val defaultValueValidationResult = validateDefaultValue(field) + lazy val migrationValueValidationResult = validateMigrationValue(migrationValue, field) + + field.isScalar match { + case true if defaultValueValidationResult.isFailure => defaultValueValidationResult + case true if migrationValueValidationResult.isFailure => migrationValueValidationResult + case _ => Success(MutactionVerificationSuccess()) + } + } + +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MutactionVerificationUtil.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MutactionVerificationUtil.scala new file mode 100644 index 0000000000..ca4ca8dcdd --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/MutactionVerificationUtil.scala @@ -0,0 +1,39 @@ +package cool.graph.system.mutactions.internal.validations + +import cool.graph.MutactionVerificationSuccess + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +trait MutactionVerificationUtil { + type VerificationFuture = Future[Try[MutactionVerificationSuccess]] + + private val initialResult = Future.successful(Success(MutactionVerificationSuccess())) + + /** + * Executes the verification functions in serial until: + * a. all of them result in a success + * OR + * b. the first verification fails + * + * The return value is the result of the last verification function. + */ + def serializeVerifications(verificationFns: List[() => VerificationFuture])(implicit ec: ExecutionContext): VerificationFuture = { + serializeVerifications(verificationFns, initialResult) + } + + private def serializeVerifications(verificationFns: List[() => VerificationFuture], lastResult: VerificationFuture)( + implicit ec: ExecutionContext): VerificationFuture = { + verificationFns match { + case Nil => + lastResult + case firstVerificationFn :: remainingVerifications => + firstVerificationFn().flatMap { + case result @ Success(_) => + serializeVerifications(remainingVerifications, Future.successful(result)) + case result @ Failure(_) => + Future.successful(result) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/ProjectValidations.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/ProjectValidations.scala new file mode 100644 index 0000000000..a0160f8973 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/ProjectValidations.scala @@ -0,0 +1,49 @@ +package cool.graph.system.mutactions.internal.validations + +import cool.graph.shared.errors.UserInputErrors.{ProjectAliasEqualsAnExistingId, ProjectWithNameAlreadyExists} +import cool.graph.client.database.DataResolver +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.MutactionVerificationSuccess +import cool.graph.shared.NameConstraints + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +case class ProjectValidations(client: Client, project: Project, projectQueries: ProjectQueries)(implicit ec: ExecutionContext) + extends MutactionVerificationUtil { + + def verify(): Future[Try[MutactionVerificationSuccess]] = { + () match { + case _ if !NameConstraints.isValidProjectName(project.name) => + Future.successful(Failure[MutactionVerificationSuccess](UserInputErrors.InvalidName(name = project.name))) + + case _ if project.alias.isDefined && !NameConstraints.isValidProjectAlias(project.alias.get) => + Future.successful(Failure(UserInputErrors.InvalidProjectAlias(alias = project.alias.get))) + + case _ => + serializeVerifications(List(verifyNameIsUnique, verifyAliasIsNotEqualToAProjectId)) + } + } + + def verifyNameIsUnique(): Future[Try[MutactionVerificationSuccess]] = { + projectQueries.loadByName(clientId = client.id, name = project.name).map { + case None => Success(MutactionVerificationSuccess()) + case Some(loadedProject) if loadedProject.id == project.id => Success(MutactionVerificationSuccess()) + case _ => Failure(ProjectWithNameAlreadyExists(name = project.name)) + } + } + + def verifyAliasIsNotEqualToAProjectId(): Future[Try[MutactionVerificationSuccess]] = { + project.alias match { + case Some(alias) => + projectQueries.loadById(alias).map { + case None => Success(MutactionVerificationSuccess()) + case Some(_) => Failure(ProjectAliasEqualsAnExistingId(alias = alias)) + } + case None => + Future.successful(Success(MutactionVerificationSuccess())) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/TypeNameValidation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/TypeNameValidation.scala new file mode 100644 index 0000000000..0a6678a0cb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/TypeNameValidation.scala @@ -0,0 +1,40 @@ +package cool.graph.system.mutactions.internal.validations + +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserInputErrors.{InvalidName, TypeAlreadyExists} +import cool.graph.shared.models.Project + +import scala.util.{Failure, Success, Try} + +object TypeNameValidation { + + def validateModelName(project: Project, modelName: String): Try[Unit] = { + // we intentionally just validate against the enum names because CreateModel needs the model name validation to not happen + validateAgainstEnumNames(project, modelName, NameConstraints.isValidModelName) + } + + def validateEnumName(project: Project, modelName: String): Try[Unit] = { + validateTypeName(project, modelName, NameConstraints.isValidEnumTypeName) + } + + def validateTypeName(project: Project, typeName: String, validateName: String => Boolean): Try[Unit] = { + val modelWithNameExists = project.getModelByName(typeName).isDefined + if (modelWithNameExists) { + Failure(TypeAlreadyExists(typeName)) + } else { + validateAgainstEnumNames(project, typeName, validateName) + } + } + + def validateAgainstEnumNames(project: Project, typeName: String, validateName: String => Boolean): Try[Unit] = { + val enumWithNameExists = project.getEnumByName(typeName).isDefined + val isValidTypeName = validateName(typeName) + if (!isValidTypeName) { + Failure(InvalidName(typeName)) + } else if (enumWithNameExists) { + Failure(TypeAlreadyExists(typeName)) + } else { + Success(()) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/URLValidation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/URLValidation.scala new file mode 100644 index 0000000000..a3df9b05aa --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutactions/internal/validations/URLValidation.scala @@ -0,0 +1,22 @@ +package cool.graph.system.mutactions.internal.validations + +import java.net.{MalformedURLException, URL} + +import cool.graph.shared.errors.{UserAPIErrors, UserInputErrors} + +object URLValidation { + def getAndValidateURL(functionName: String, input: Option[String]): String = { + input match { + case None => + throw UserAPIErrors.InvalidValue("Url") + case Some(url) => + try { + val trimmedString = url.trim + new URL(trimmedString) + trimmedString + } catch { + case _: MalformedURLException => throw UserInputErrors.FunctionHasInvalidUrl(functionName, url) + } + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddActionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddActionMutation.scala new file mode 100644 index 0000000000..750bc14f7c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddActionMutation.scala @@ -0,0 +1,88 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.ActionInputIsInconsistent +import cool.graph.shared.models +import cool.graph.shared.models.ActionHandlerType.ActionHandlerType +import cool.graph.shared.models.ActionTriggerMutationModelMutationType.ActionTriggerMutationModelMutationType +import cool.graph.shared.models.ActionTriggerType.ActionTriggerType +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateAction, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddActionMutation( + client: models.Client, + project: models.Project, + args: AddActionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[AddActionMutationPayload] { + + var newAction: Option[models.Action] = None + + def verifyArgs: Option[InvalidInput] = { + if (args.triggerType == ActionTriggerType.MutationModel && args.actionTriggerMutationModel.isEmpty) { + return Some(InvalidInput(ActionInputIsInconsistent(s"Specified triggerType '${ActionTriggerType.MutationModel}' requires 'triggerMutationModel'"))) + } + + if (args.handlerType == models.ActionHandlerType.Webhook && args.webhookUrl.isEmpty) { + return Some(InvalidInput(ActionInputIsInconsistent(s"Specified triggerType '${models.ActionHandlerType.Webhook}' requires 'handlerWebhook'"))) + } + + None + } + + override def prepareActions(): List[Mutaction] = { + + val argsValidationError = verifyArgs + if (argsValidationError.isDefined) { + actions = List(argsValidationError.get) + return actions + } + + newAction = Some( + models.Action( + id = Cuid.createCuid(), + isActive = args.isActive, + description = args.description, + triggerType = args.triggerType, + handlerType = args.handlerType, + triggerMutationModel = args.actionTriggerMutationModel.map(t => + ActionTriggerMutationModel(id = Cuid.createCuid(), modelId = t.modelId, mutationType = t.mutationType, fragment = t.fragment)), + handlerWebhook = args.webhookUrl.map(url => ActionHandlerWebhook(id = Cuid.createCuid(), url = url, isAsync = args.webhookIsAsync.getOrElse(true))) + )) + + actions ++= CreateAction.generateAddActionMutactions(newAction.get, project = project) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue(): Option[AddActionMutationPayload] = { + Some( + AddActionMutationPayload(clientMutationId = args.clientMutationId, + project = project.copy(actions = project.actions :+ newAction.get), + action = newAction.get)) + } +} + +case class AddActionMutationPayload(clientMutationId: Option[String], project: models.Project, action: models.Action) extends Mutation + +case class AddActionTriggerModelInput(modelId: String, mutationType: ActionTriggerMutationModelMutationType, fragment: String) + +case class AddActionInput(clientMutationId: Option[String], + projectId: String, + isActive: Boolean, + description: Option[String], + triggerType: ActionTriggerType, + handlerType: ActionHandlerType, + webhookUrl: Option[String], + webhookIsAsync: Option[Boolean], + actionTriggerMutationModel: Option[AddActionTriggerModelInput]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddAlgoliaSyncQueryMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddAlgoliaSyncQueryMutation.scala new file mode 100644 index 0000000000..1706653067 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddAlgoliaSyncQueryMutation.scala @@ -0,0 +1,93 @@ +package cool.graph.system.mutations + +import com.typesafe.config.Config +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.RequiredSearchProviderAlgoliaNotPresent +import cool.graph.shared.models +import cool.graph.shared.models.{IntegrationName, IntegrationType} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.client.SyncModelToAlgoliaViaRequest +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateAlgoliaSyncQuery, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +case class AddAlgoliaSyncQueryMutation(client: models.Client, + project: models.Project, + args: AddAlgoliaSyncQueryInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddAlgoliaSyncQueryPayload] + with Injectable { + + var newAlgoliaSyncQuery: Option[models.AlgoliaSyncQuery] = None + var searchProviderAlgolia: Option[models.SearchProviderAlgolia] = None + val config = inject[Config]("config") + + override def prepareActions(): List[Mutaction] = { + val integration = project.getIntegrationByTypeAndName(IntegrationType.SearchProvider, IntegrationName.SearchProviderAlgolia) + + val pendingMutactions: List[Mutaction] = integration match { + case Some(searchProvider) => + val existingSearchProviderAlgolia = searchProvider.asInstanceOf[models.SearchProviderAlgolia] + val model = project.getModelById_!(args.modelId) + searchProviderAlgolia = Some(existingSearchProviderAlgolia) + newAlgoliaSyncQuery = Some( + models.AlgoliaSyncQuery( + id = Cuid.createCuid(), + indexName = args.indexName, + fragment = args.fragment, + isEnabled = true, + model = model + ) + ) + + val addAlgoliaSyncQueryToProject = + CreateAlgoliaSyncQuery( + searchProviderAlgolia = searchProviderAlgolia.get, + algoliaSyncQuery = newAlgoliaSyncQuery.get + ) + + val syncModelToAlgolia = SyncModelToAlgoliaViaRequest(project = project, model = model, algoliaSyncQuery = newAlgoliaSyncQuery.get, config = config) + val bumpRevision = BumpProjectRevision(project = project) + + List(addAlgoliaSyncQueryToProject, syncModelToAlgolia, bumpRevision, InvalidateSchema(project = project)) + + case None => + List(InvalidInput(RequiredSearchProviderAlgoliaNotPresent())) + } + actions = pendingMutactions + actions + } + + override def getReturnValue(): Option[AddAlgoliaSyncQueryPayload] = { + val updatedSearchProviderAlgolia = searchProviderAlgolia.get.copy( + algoliaSyncQueries = + searchProviderAlgolia.get.algoliaSyncQueries + .filter(_.id != newAlgoliaSyncQuery.get.id) :+ newAlgoliaSyncQuery.get) + val updatedProject = project.copy( + integrations = + project.authProviders + .filter(_.id != searchProviderAlgolia.get.id) :+ updatedSearchProviderAlgolia) + + Some( + AddAlgoliaSyncQueryPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + model = project.getModelById_!(args.modelId), + algoliaSyncQuery = newAlgoliaSyncQuery.get, + searchProviderAlgolia = searchProviderAlgolia.get.copy(algoliaSyncQueries = searchProviderAlgolia.get.algoliaSyncQueries :+ newAlgoliaSyncQuery.get) + )) + } +} + +case class AddAlgoliaSyncQueryPayload(clientMutationId: Option[String], + project: models.Project, + model: models.Model, + algoliaSyncQuery: models.AlgoliaSyncQuery, + searchProviderAlgolia: models.SearchProviderAlgolia) + extends Mutation + +case class AddAlgoliaSyncQueryInput(clientMutationId: Option[String], modelId: String, indexName: String, fragment: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddEnumMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddEnumMutation.scala new file mode 100644 index 0000000000..444c12e004 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddEnumMutation.scala @@ -0,0 +1,33 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Enum, Project} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateEnum, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddEnumMutation(client: models.Client, project: models.Project, args: AddEnumInput, projectDbsFn: models.Project => InternalAndProjectDbs)( + implicit inj: Injector) + extends InternalProjectMutation[AddEnumMutationPayload] { + + val enum: Enum = Enum(args.id, name = args.name, values = args.values) + val updatedProject: Project = project.copy(enums = project.enums :+ enum) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(CreateEnum(project, enum), BumpProjectRevision(project = project), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue(): Option[AddEnumMutationPayload] = { + Some(AddEnumMutationPayload(args.clientMutationId, updatedProject, enum)) + } +} + +case class AddEnumMutationPayload(clientMutationId: Option[String], project: models.Project, enum: models.Enum) extends Mutation + +case class AddEnumInput(clientMutationId: Option[String], projectId: String, name: String, values: Seq[String]) { + val id: String = Cuid.createCuid() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldConstraint.scala new file mode 100644 index 0000000000..9d54422378 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldConstraint.scala @@ -0,0 +1,127 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models +import cool.graph.shared.models.FieldConstraintType.FieldConstraintType +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateFieldConstraint, InvalidateSchema} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddFieldConstraintMutation(client: models.Client, + project: models.Project, + args: AddFieldConstraintInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddFieldConstraintMutationPayload] { + + val newConstraint: FieldConstraint = args.constraintType match { + case FieldConstraintType.STRING => + val oneOfString = args.oneOfString.map(_.toList).getOrElse(List.empty) + StringConstraint( + id = Cuid.createCuid(), + fieldId = args.fieldId, + equalsString = args.equalsString, + oneOfString = oneOfString, + minLength = args.minLength, + maxLength = args.maxLength, + startsWith = args.startsWith, + endsWith = args.endsWith, + includes = args.includes, + regex = args.regex + ) + case FieldConstraintType.NUMBER => + val oneOfNumber = args.oneOfNumber.map(_.toList).getOrElse(List.empty) + NumberConstraint( + id = Cuid.createCuid(), + fieldId = args.fieldId, + equalsNumber = args.equalsNumber, + oneOfNumber = oneOfNumber, + min = args.min, + max = args.max, + exclusiveMin = args.exclusiveMin, + exclusiveMax = args.exclusiveMax, + multipleOf = args.multipleOf + ) + case FieldConstraintType.BOOLEAN => + BooleanConstraint(id = Cuid.createCuid(), fieldId = args.fieldId, equalsBoolean = args.equalsBoolean) + case FieldConstraintType.LIST => + ListConstraint(id = Cuid.createCuid(), fieldId = args.fieldId, uniqueItems = args.uniqueItems, minItems = args.minItems, maxItems = args.maxItems) + } + + val field = project.getFieldById_!(args.fieldId) + + val updatedField = field.copy(constraints = field.constraints :+ newConstraint) + val fieldType = field.typeIdentifier + + override def prepareActions(): List[Mutaction] = { + + newConstraint.constraintType match { + case _ if field.constraints.exists(_.constraintType == newConstraint.constraintType) => + actions = duplicateConstraint + case FieldConstraintType.STRING if fieldType != TypeIdentifier.String => actions = fieldConstraintTypeError + case FieldConstraintType.BOOLEAN if fieldType != TypeIdentifier.Boolean => actions = fieldConstraintTypeError + case FieldConstraintType.NUMBER if fieldType != TypeIdentifier.Float && fieldType != TypeIdentifier.Int => + actions = fieldConstraintTypeError + case FieldConstraintType.LIST if !field.isList => actions = fieldConstraintListError + case _ => + actions = List( + CreateFieldConstraint(project = project, fieldId = args.fieldId, constraint = newConstraint), + BumpProjectRevision(project = project), + InvalidateSchema(project = project) + ) + } + actions + } + + override def getReturnValue(): Option[AddFieldConstraintMutationPayload] = { + Some( + AddFieldConstraintMutationPayload(clientMutationId = args.clientMutationId, + project = project, + field = updatedField, + constraints = updatedField.constraints)) + } + + def duplicateConstraint = { + List(InvalidInput(SystemErrors.DuplicateFieldConstraint(constraintType = newConstraint.constraintType.toString, fieldId = field.id))) + } + + def fieldConstraintTypeError = { + List( + InvalidInput( + SystemErrors.FieldConstraintTypeNotCompatibleWithField(constraintType = newConstraint.constraintType.toString, + fieldId = field.id, + fieldType = field.typeIdentifier.toString))) + } + + def fieldConstraintListError = List(InvalidInput(SystemErrors.ListFieldConstraintOnlyOnListFields(field.id))) +} + +case class AddFieldConstraintMutationPayload(clientMutationId: Option[String], project: models.Project, field: models.Field, constraints: List[FieldConstraint]) + extends Mutation + +case class AddFieldConstraintInput(clientMutationId: Option[String], + fieldId: String, + constraintType: FieldConstraintType, + equalsString: Option[String] = None, + oneOfString: Option[Seq[String]] = None, + minLength: Option[Int] = None, + maxLength: Option[Int] = None, + startsWith: Option[String] = None, + endsWith: Option[String] = None, + includes: Option[String] = None, + regex: Option[String] = None, + equalsNumber: Option[Double] = None, + oneOfNumber: Option[Seq[Double]] = None, + min: Option[Double] = None, + max: Option[Double] = None, + exclusiveMin: Option[Double] = None, + exclusiveMax: Option[Double] = None, + multipleOf: Option[Double] = None, + equalsBoolean: Option[Boolean] = None, + uniqueItems: Option[Boolean] = None, + minItems: Option[Int] = None, + maxItems: Option[Int] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldMutation.scala new file mode 100644 index 0000000000..e61adfc996 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddFieldMutation.scala @@ -0,0 +1,134 @@ +package cool.graph.system.mutations + +import cool.graph.GCDataTypes.{GCStringConverter, GCValue} +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.{SystemErrors, UserInputErrors} +import cool.graph.shared.models +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Enum, Model, Project} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.SystemFields +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.{CreateColumn, OverwriteAllRowsForColumn} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateField, CreateSystemFieldIfNotExists, InvalidateSchema} +import org.scalactic.{Bad, Good} +import sangria.relay.Mutation +import scaldi.Injector + +import scala.util.{Failure, Success} + +case class AddFieldMutation( + client: models.Client, + project: models.Project, + args: AddFieldInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[AddFieldMutationPayload] { + + val model: Model = project.getModelById_!(args.modelId) + val enum: Option[Enum] = args.enumId.flatMap(project.getEnumById) + val gcStringConverter = GCStringConverter(args.typeIdentifier, args.isList) + + val defaultValue: Option[GCValue] = for { + defaultValue <- args.defaultValue + gcValue <- gcStringConverter.toGCValue(defaultValue).toOption + } yield gcValue + + val verifyDefaultValue: List[UserInputErrors.InvalidValueForScalarType] = { + args.defaultValue.map(dV => GCStringConverter(args.typeIdentifier, args.isList).toGCValue(dV)) match { + case Some(Good(_)) => List.empty + case Some(Bad(error)) => List(error) + case None => List.empty + } + } + + val newField: models.Field = models.Field( + id = args.id, + name = args.name, + typeIdentifier = args.typeIdentifier, + description = args.description, + isRequired = args.isRequired, + isList = args.isList, + isUnique = args.isUnique, + isSystem = false, + isReadonly = false, + enum = enum, + defaultValue = defaultValue, + relation = None, + relationSide = None + ) + + val updatedModel: Model = model.copy(fields = model.fields :+ newField) + val updatedProject: Project = project.copy(models = project.models.filter(_.id != model.id) :+ updatedModel) + + override def prepareActions(): List[Mutaction] = { + newField.isScalar match { + case _ if verifyDefaultValue.nonEmpty => + actions = List(InvalidInput(verifyDefaultValue.head)) + + case false => + actions = List(InvalidInput(SystemErrors.IsNotScalar(args.typeIdentifier.toString))) + + case true => + if (SystemFields.isReservedFieldName(newField.name)) { + val systemFieldAction = SystemFields.generateSystemFieldFromInput(newField) match { + case Success(field) => CreateSystemFieldIfNotExists(project, model, field.copy(id = newField.id)) + case Failure(err) => InvalidInput(SystemErrors.InvalidPredefinedFieldFormat(newField.name, err.getMessage)) + } + + actions = List(systemFieldAction) + } else { + actions = regularFieldCreationMutactions + } + + actions = actions ++ List(BumpProjectRevision(project = project), InvalidateSchema(project = project)) + } + + actions + } + + def regularFieldCreationMutactions: List[Mutaction] = { + val migrationAction = if (args.migrationValue.isDefined) { + List( + OverwriteAllRowsForColumn( + project.id, + model, + newField, + CustomScalarTypes.parseValueFromString(args.migrationValue.get, newField.typeIdentifier, newField.isList) + )) + } else { + Nil + } + + val createFieldClientDbAction = CreateColumn(project.id, model, newField) + val createFieldProjectDbAction = CreateField(project, model, newField, args.migrationValue, clientDbQueries) + + List(createFieldClientDbAction) ++ migrationAction ++ List(createFieldProjectDbAction) + } + + override def getReturnValue: Option[AddFieldMutationPayload] = + Some(AddFieldMutationPayload(clientMutationId = args.clientMutationId, project = updatedProject, model = updatedModel, field = newField)) +} + +case class AddFieldMutationPayload(clientMutationId: Option[String], project: models.Project, model: models.Model, field: models.Field) extends Mutation + +case class AddFieldInput( + clientMutationId: Option[String], + modelId: String, + name: String, + typeIdentifier: TypeIdentifier, + isRequired: Boolean, + isList: Boolean, + isUnique: Boolean, + relationId: Option[String], + defaultValue: Option[String], + migrationValue: Option[String], + description: Option[String], + enumId: Option[String] +) { + val id: String = Cuid.createCuid() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelMutation.scala new file mode 100644 index 0000000000..1b9da331db --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelMutation.scala @@ -0,0 +1,65 @@ +package cool.graph.system.mutations + +import cool.graph.Types.Id +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.Project +import cool.graph.system.database.SystemFields +import cool.graph.system.mutactions.client.CreateModelTable +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateModel, CreateModelPermission, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddModelMutation( + client: models.Client, + project: models.Project, + args: AddModelInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[AddModelMutationPayload] { + + val newModel: models.Model = models.Model( + id = args.id, + name = args.modelName, + description = args.description, + isSystem = false, + fields = List(SystemFields.generateIdField()), + fieldPositions = args.fieldPositions.getOrElse(List.empty) + ) + + val updatedProject: Project = project.copy(models = project.models :+ newModel) + + override def prepareActions(): List[Mutaction] = { + // The client DB table will still have all system fields, even if they're not visible in the schema at first + val clientTableModel = newModel.copy(fields = newModel.fields :+ SystemFields.generateCreatedAtField() :+ SystemFields.generateUpdatedAtField()) + val createClientTable = CreateModelTable(projectId = project.id, model = clientTableModel) + val addModelToProject = CreateModel(project = project, model = newModel) + + val createPublicPermissions: Seq[CreateModelPermission] = project.isEjected match { + case true => Seq.empty + case false => models.ModelPermission.publicPermissions.map(CreateModelPermission(project, newModel, _)) + } + + actions = List(createClientTable, addModelToProject) ++ createPublicPermissions ++ List(BumpProjectRevision(project = project), + InvalidateSchema(project = project)) + actions + } + + override def getReturnValue(): Option[AddModelMutationPayload] = { + Some(AddModelMutationPayload(clientMutationId = args.clientMutationId, project = updatedProject, model = newModel)) + } +} + +case class AddModelMutationPayload(clientMutationId: Option[String], project: models.Project, model: models.Model) extends Mutation + +case class AddModelInput( + clientMutationId: Option[String], + projectId: String, + modelName: String, + description: Option[String], + fieldPositions: Option[List[Id]] +) { + val id: Id = Cuid.createCuid +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelPermissionMutation.scala new file mode 100644 index 0000000000..2013b4dc90 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddModelPermissionMutation.scala @@ -0,0 +1,111 @@ +package cool.graph.system.mutations + +import _root_.akka.actor.ActorSystem +import _root_.akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.PermissionQueryIsInvalid +import cool.graph.shared.models +import cool.graph.shared.models.{Model, Project} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.migration.permissions.QueryPermissionHelper +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateModelPermission, CreateModelPermissionField, InvalidateSchema} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddModelPermissionMutation( + client: models.Client, + project: models.Project, + model: models.Model, + args: AddModelPermissionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)( + implicit inj: Injector, + actorSystem: ActorSystem +) extends InternalProjectMutation[AddModelPermissionMutationPayload] { + + //at the moment the console sends empty strings, these would cause problems for the rendering of the clientInterchange + val ruleName: Option[String] = args.ruleName match { + case Some("") => None + case x => x + } + + var newModelPermission = models.ModelPermission( + id = Cuid.createCuid(), + operation = args.operation, + userType = args.userType, + rule = args.rule, + ruleName = ruleName, + ruleGraphQuery = args.ruleGraphQuery, + ruleGraphQueryFilePath = args.ruleGraphQueryFilePath, + ruleWebhookUrl = args.ruleWebhookUrl, + fieldIds = args.fieldIds, + applyToWholeModel = args.applyToWholeModel, + description = args.description, + isActive = args.isActive + ) + + val newModel: Model = model.copy(permissions = model.permissions :+ newModelPermission) + + val updatedProject: Project = project.copy(models = project.models.filter(_.id != newModel.id) :+ newModel) + + override def prepareActions(): List[Mutaction] = { + +// newModelPermission.ruleGraphQuery.foreach { query => +// val queriesWithSameOpCount = model.permissions.count(_.operation == newModelPermission.operation) +// +// val queryName = newModelPermission.ruleName match { +// case Some(nameForRule) => nameForRule +// case None => QueryPermissionHelper.alternativeNameFromOperationAndInt(newModelPermission.operationString, queriesWithSameOpCount) +// } +// +// val args = QueryPermissionHelper.permissionQueryArgsFromModel(model) +// val treatedQuery = QueryPermissionHelper.prependNameAndRenderQuery(query, queryName: String, args: List[(String, String)]) +// +// val violations = QueryPermissionHelper.validatePermissionQuery(treatedQuery, project) +// if (violations.nonEmpty) +// actions ++= List(InvalidInput(PermissionQueryIsInvalid(violations.mkString(""), newModelPermission.ruleName.getOrElse(newModelPermission.id)))) +// } + + actions :+= CreateModelPermission(project = project, model = model, permission = newModelPermission) + + actions ++= newModelPermission.fieldIds.map(fieldId => CreateModelPermissionField(project, model, newModelPermission, fieldId)) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[AddModelPermissionMutationPayload] = { + Some( + AddModelPermissionMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + model = newModel, + modelPermission = newModelPermission + )) + } +} + +case class AddModelPermissionMutationPayload(clientMutationId: Option[String], + project: models.Project, + model: models.Model, + modelPermission: models.ModelPermission) + extends Mutation + +case class AddModelPermissionInput(clientMutationId: Option[String], + modelId: String, + operation: models.ModelOperation.Value, + userType: models.UserType.Value, + rule: models.CustomRule.Value, + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleWebhookUrl: Option[String], + fieldIds: List[String], + applyToWholeModel: Boolean, + description: Option[String], + isActive: Boolean, + ruleGraphQueryFilePath: Option[String] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddProjectMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddProjectMutation.scala new file mode 100644 index 0000000000..189721dedc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddProjectMutation.scala @@ -0,0 +1,198 @@ +package cool.graph.system.mutations + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.cuid.Cuid +import cool.graph.shared.database.{GlobalDatabaseManager, InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.errors.SystemErrors.{InvalidProjectDatabase, SchemaError, WithSchemaError} +import cool.graph.shared.errors.UserInputErrors.InvalidSchema +import cool.graph.shared.models +import cool.graph.shared.models.Region.Region +import cool.graph.shared.models._ +import cool.graph.system.database.client.ClientDbQueriesImpl +import cool.graph.system.database.finder.{ProjectDatabaseFinder, ProjectQueries} +import cool.graph.system.migration.dataSchema._ +import cool.graph.system.migration.dataSchema.validation.SchemaSyntaxValidator +import cool.graph.system.mutactions.internal.{InvalidateSchema, UpdateTypeAndFieldPositions} +import cool.graph.{InternalMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.collection.Seq +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} +import scala.util.{Failure, Success} + +case class AddProjectMutation( + client: Client, + args: AddProjectInput, + internalDatabase: InternalDatabase, + projectDbsFn: Project => InternalAndProjectDbs, + globalDatabaseManager: GlobalDatabaseManager +)(implicit inj: Injector) + extends InternalMutation[AddProjectMutationPayload] + with Injectable { + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + val projectDatabaseFuture: Future[Option[ProjectDatabase]] = args.projectDatabaseId match { + case Some(id) => ProjectDatabaseFinder.forId(id)(internalDatabase.databaseDef) + case None => ProjectDatabaseFinder.defaultForRegion(args.region)(internalDatabase.databaseDef) + } + + val projectDatabase: ProjectDatabase = Await.result(projectDatabaseFuture, 5.seconds) match { + case Some(db) => db + case None => throw InvalidProjectDatabase(args.projectDatabaseId.getOrElse(args.region.toString)) + } + + val newProject: Project = AddProjectMutation.base( + name = args.name, + alias = args.alias, + client = client, + projectDatabase = projectDatabase, + isEjected = args.config.nonEmpty + ) + + var verbalDescriptions: Seq[VerbalDescription] = Seq.empty + var errors: Seq[SchemaError] = Seq.empty + + override val databases: InternalAndProjectDbs = projectDbsFn(newProject) + + override def prepareActions(): List[Mutaction] = { + actions ++= AuthenticateCustomerMutation.createInternalStructureForNewProject(client, + newProject, + projectQueries = projectQueries, + internalDatabase.databaseDef) + actions ++= AuthenticateCustomerMutation.createClientDatabaseStructureForNewProject(client, newProject, internalDatabase.databaseDef) + actions ++= AuthenticateCustomerMutation.createIntegrationsForNewProject(newProject) + + val actionsForSchema: List[Mutaction] = args.schema match { + case Some(schema) => initActionsForSchemaFile(schema) + case None => List.empty + } + + actions ++= actionsForSchema + + args.config match { + case Some(config) => + val clientDbQueries = ClientDbQueriesImpl(globalDatabaseManager)(newProject) + val deployResult = DeployMutactions.generate( + config, + force = true, + isDryRun = false, + client = client, + project = newProject, + internalDatabase = internalDatabase, + clientDbQueries = clientDbQueries, + projectQueries = projectQueries + ) + + def extractErrors(exc: Throwable): SchemaError = exc match { + case sysError: WithSchemaError => + val fallbackError = SchemaError.global(sysError.getMessage) + sysError.schemaError.getOrElse(fallbackError) + case e: Throwable => + SchemaError.global(e.getMessage) + } + + deployResult match { + case Success(result) => + actions ++= result.mutactions.toList + verbalDescriptions ++= result.verbalDescriptions + errors ++= result.errors + + case Failure(error) => + actions = List.empty + verbalDescriptions = List.empty + errors = List(extractErrors(error)) + } + case None => () + } + + actions :+= InvalidateSchema(project = newProject) + actions + } + + def initActionsForSchemaFile(schema: String): List[Mutaction] = { + val errors = SchemaSyntaxValidator(schema).validate() + if (errors.nonEmpty) { + val message = errors.foldLeft("") { (acc, error) => + acc + "\n " + error.description + } + throw InvalidSchema(message) + } + + val migrator = SchemaMigrator(newProject, schema, args.clientMutationId) + val mutations = migrator.determineActionsForInit().determineMutations(client, newProject, _ => InternalAndProjectDbs(internalDatabase)) + + val updateTypeAndFieldPositions = UpdateTypeAndFieldPositions( + project = newProject, + client = client, + newSchema = migrator.diffResult.newSchema, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries + ) + + mutations.toList.flatMap(_.prepareActions()) :+ updateTypeAndFieldPositions + } + + override def getReturnValue: Option[AddProjectMutationPayload] = { + Some( + AddProjectMutationPayload( + clientMutationId = args.clientMutationId, + client = client.copy(projects = client.projects :+ newProject), + project = newProject, + verbalDescriptions = verbalDescriptions, + errors = errors + ) + ) + } +} + +case class AddProjectMutationPayload(clientMutationId: Option[String], + client: models.Client, + project: models.Project, + verbalDescriptions: Seq[VerbalDescription], + errors: Seq[SchemaError]) + extends Mutation + +case class AddProjectInput(clientMutationId: Option[String], + name: String, + alias: Option[String], + webhookUrl: Option[String], + schema: Option[String], + region: Region = Region.EU_WEST_1, + projectDatabaseId: Option[String], + config: Option[String]) + +object AddProjectMutation { + def base(name: String, alias: Option[String], client: Client, projectDatabase: ProjectDatabase, isEjected: Boolean): Project = { + val predefinedModels = if (isEjected) { + Vector.empty + } else { + val generatedUserFields = SignupCustomerMutation.generateUserFields + val userModel = SignupCustomerMutation.generateUserModel.copy(fields = generatedUserFields) + + val generatedFileFields = SignupCustomerMutation.generateFileFields + val fileModel = SignupCustomerMutation.generateFileModel.copy(fields = generatedFileFields) + + Vector(userModel, fileModel) + } + + models.Project( + id = Cuid.createCuid(), + alias = alias, + name = name, + webhookUrl = None, + models = predefinedModels.toList, + relations = List.empty, + actions = List.empty, + ownerId = client.id, + projectDatabase = projectDatabase, + isEjected = isEjected, + revision = if (isEjected) 0 else 1 + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationFieldMirrorMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationFieldMirrorMutation.scala new file mode 100644 index 0000000000..04a53a7ace --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationFieldMirrorMutation.scala @@ -0,0 +1,56 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.client.{CreateRelationFieldMirrorColumn, PopulateRelationFieldMirrorColumn} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateRelationFieldMirror, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddRelationFieldMirrorMutation(client: models.Client, + project: models.Project, + relation: models.Relation, + args: AddRelationFieldMirrorInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddRelationFieldMirrorPayload] { + + val newRelationFieldMirror = models.RelationFieldMirror(id = Cuid.createCuid(), fieldId = args.fieldId, relationId = args.relationId) + + override def prepareActions(): List[Mutaction] = { + + val addFieldMirror = CreateRelationFieldMirror(project = project, relationFieldMirror = newRelationFieldMirror) + + val field = project.getFieldById_!(args.fieldId) + + val addColumn = CreateRelationFieldMirrorColumn( + project = project, + relation = relation, + field = field + ) + + val populateColumn = PopulateRelationFieldMirrorColumn(project, relation, field) + + actions = List(addFieldMirror, addColumn, populateColumn, BumpProjectRevision(project = project), InvalidateSchema(project = project)) + actions + } + + override def getReturnValue(): Option[AddRelationFieldMirrorPayload] = { + Some( + AddRelationFieldMirrorPayload( + clientMutationId = args.clientMutationId, + project = project, + relationFieldMirror = newRelationFieldMirror, + relation = relation.copy(fieldMirrors = relation.fieldMirrors :+ newRelationFieldMirror) + )) + } +} + +case class AddRelationFieldMirrorPayload(clientMutationId: Option[String], + project: models.Project, + relationFieldMirror: models.RelationFieldMirror, + relation: models.Relation) + extends Mutation + +case class AddRelationFieldMirrorInput(clientMutationId: Option[String], fieldId: String, relationId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationMutation.scala new file mode 100644 index 0000000000..a91e997433 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationMutation.scala @@ -0,0 +1,136 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models.{Model, Project, RelationSide, TypeIdentifier} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.CreateRelationTable +import cool.graph.system.mutactions.internal._ +import sangria.relay.Mutation +import scaldi.Injector + +case class AddRelationMutation( + client: models.Client, + project: models.Project, + args: AddRelationInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[AddRelationMutationPayload] { + + val leftModel: Model = project.getModelById_!(args.leftModelId) + val rightModel: Model = project.getModelById_!(args.rightModelId) + + val newRelation: models.Relation = + models.Relation(id = Cuid.createCuid(), name = args.name, description = args.description, modelAId = leftModel.id, modelBId = rightModel.id) + + val fieldOnLeftModel: Option[models.Field] = Some( + models.Field( + id = Cuid.createCuid(), + name = args.fieldOnLeftModelName, + typeIdentifier = TypeIdentifier.Relation, + isRequired = if (args.fieldOnLeftModelIsList) false else args.fieldOnLeftModelIsRequired, + isList = args.fieldOnLeftModelIsList, + isUnique = false, + isSystem = false, + isReadonly = false, + relation = Some(newRelation), + relationSide = Some(RelationSide.A) + )) + + val fieldOnRightModel: Option[models.Field] = if (args.leftModelId != args.rightModelId || args.fieldOnLeftModelName != args.fieldOnRightModelName) { + Some( + models.Field( + id = Cuid.createCuid(), + name = args.fieldOnRightModelName, + typeIdentifier = TypeIdentifier.Relation, + isRequired = if (args.fieldOnRightModelIsList) false else args.fieldOnRightModelIsRequired, + isList = args.fieldOnRightModelIsList, + isUnique = false, + isSystem = false, + isReadonly = false, + relation = Some(newRelation), + relationSide = Some(RelationSide.B) + )) + } else None + + private def updatedLeftModel = leftModel.copy(fields = leftModel.fields ++ fieldOnLeftModel) + private def updatedRightModel = rightModel.copy(fields = rightModel.fields ++ fieldOnRightModel) + private def updatedSameModel = leftModel.copy(fields = leftModel.fields ++ fieldOnLeftModel ++ fieldOnRightModel) + + val updatedProject: Project = + project.copy( + models = project.models.map { + case x: models.Model if x.id == leftModel.id && x.id == rightModel.id => updatedSameModel + case x: models.Model if x.id == leftModel.id => updatedLeftModel + case x: models.Model if x.id == rightModel.id => updatedRightModel + case x => x + }, + relations = project.relations :+ newRelation + ) + + override def prepareActions(): List[Mutaction] = { + + if (args.leftModelId == args.rightModelId && + args.fieldOnLeftModelName == args.fieldOnRightModelName && + args.fieldOnLeftModelIsList != args.fieldOnRightModelIsList) { + actions = List(InvalidInput(UserInputErrors.OneToManyRelationSameModelSameField())) + return actions + } + + actions = { + + val createPublicPermissions: Vector[CreateRelationPermission] = project.isEjected match { + case true => Vector.empty + case false => models.RelationPermission.publicPermissions.map(CreateRelationPermission(project, newRelation, _)).toVector + } + + List( + CreateRelation(updatedProject, newRelation, args.fieldOnLeftModelIsRequired, args.fieldOnRightModelIsRequired, clientDbQueries), + CreateRelationTable(updatedProject, newRelation), + CreateField(project, leftModel, fieldOnLeftModel.get, None, clientDbQueries) + ) ++ + // note: fieldOnRightModel can be None for self relations + fieldOnRightModel.map(field => List(CreateField(project, rightModel, field, None, clientDbQueries))).getOrElse(List()) ++ + createPublicPermissions ++ + List(BumpProjectRevision(project = project), InvalidateSchema(project = project)) + } + actions + } + + override def getReturnValue: Option[AddRelationMutationPayload] = { + + Some( + AddRelationMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + leftModel = if (leftModel.id == rightModel.id) updatedSameModel else updatedLeftModel, + rightModel = if (leftModel.id == rightModel.id) updatedSameModel else updatedRightModel, + relation = newRelation + )) + } +} + +case class AddRelationMutationPayload(clientMutationId: Option[String], + project: models.Project, + leftModel: models.Model, + rightModel: models.Model, + relation: models.Relation) + extends Mutation + +case class AddRelationInput(clientMutationId: Option[String], + projectId: String, + description: Option[String], + name: String, + leftModelId: String, + rightModelId: String, + fieldOnLeftModelName: String, + fieldOnRightModelName: String, + fieldOnLeftModelIsList: Boolean, + fieldOnRightModelIsList: Boolean, + fieldOnLeftModelIsRequired: Boolean = false, + fieldOnRightModelIsRequired: Boolean = false) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationPermissionMutation.scala new file mode 100644 index 0000000000..48c62587d7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRelationPermissionMutation.scala @@ -0,0 +1,104 @@ +package cool.graph.system.mutations + +import _root_.akka.actor.ActorSystem +import _root_.akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Project, Relation} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateRelationPermission, InvalidateSchema} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddRelationPermissionMutation( + client: models.Client, + project: models.Project, + relation: models.Relation, + args: AddRelationPermissionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)( + implicit inj: Injector, + actorSystem: ActorSystem +) extends InternalProjectMutation[AddRelationPermissionMutationPayload] { + + //at the moment the console sends empty strings, these would cause problems for the rendering of the clientInterchange + val ruleName: Option[String] = args.ruleName match { + case Some("") => None + case x => x + } + + val newRelationPermission = models.RelationPermission( + id = Cuid.createCuid(), + connect = args.connect, + disconnect = args.disconnect, + userType = args.userType, + rule = args.rule, + ruleName = ruleName, + ruleGraphQuery = args.ruleGraphQuery, + ruleGraphQueryFilePath = args.ruleGraphQueryFilePath, + ruleWebhookUrl = args.ruleWebhookUrl, + description = args.description, + isActive = args.isActive + ) + + val updatedRelation: Relation = relation.copy(permissions = relation.permissions :+ newRelationPermission) + + val updatedProject: Project = project.copy(relations = project.relations.filter(_.id != updatedRelation.id) :+ updatedRelation) + + override def prepareActions(): List[Mutaction] = { + +// newRelationPermission.ruleGraphQuery.foreach { query => +// val queriesWithSameOpCount = relation.permissions.count(_.operation == newRelationPermission.operation) +// +// val queryName = newRelationPermission.ruleName match { +// case Some(nameForRule) => nameForRule +// case None => QueryPermissionHelper.alternativeNameFromOperationAndInt(newRelationPermission.operation, queriesWithSameOpCount) +// } +// +// val args = QueryPermissionHelper.permissionQueryArgsFromRelation(relation, project) +// val treatedQuery = QueryPermissionHelper.prependNameAndRenderQuery(query, queryName: String, args: List[(String, String)]) +// +// val violations = QueryPermissionHelper.validatePermissionQuery(treatedQuery, project) +// if (violations.nonEmpty) +// actions ++= List(InvalidInput(PermissionQueryIsInvalid(violations.mkString(""), newRelationPermission.ruleName.getOrElse(newRelationPermission.id)))) +// } + + actions :+= CreateRelationPermission(project = project, relation = relation, permission = newRelationPermission) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[AddRelationPermissionMutationPayload] = { + Some( + AddRelationPermissionMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + relation = updatedRelation, + relationPermission = newRelationPermission + )) + } +} + +case class AddRelationPermissionMutationPayload(clientMutationId: Option[String], + project: models.Project, + relation: models.Relation, + relationPermission: models.RelationPermission) + extends Mutation + +case class AddRelationPermissionInput(clientMutationId: Option[String], + relationId: String, + connect: Boolean, + disconnect: Boolean, + userType: models.UserType.Value, + rule: models.CustomRule.Value, + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleWebhookUrl: Option[String], + description: Option[String], + isActive: Boolean, + ruleGraphQueryFilePath: Option[String] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRequestPipelineMutationFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRequestPipelineMutationFunctionMutation.scala new file mode 100644 index 0000000000..900485fd0c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddRequestPipelineMutationFunctionMutation.scala @@ -0,0 +1,103 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.internal.validations.URLValidation +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateFunction, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddRequestPipelineMutationFunctionMutation(client: models.Client, + project: models.Project, + args: AddRequestPipelineMutationFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddRequestPipelineMutationFunctionMutationPayload] { + + val newDelivery: FunctionDelivery = args.functionType match { + case FunctionType.WEBHOOK => + WebhookFunction(url = URLValidation.getAndValidateURL(args.name, args.webhookUrl), headers = HttpFunctionHeaders.read(args.headers)) + + case FunctionType.CODE if args.inlineCode.nonEmpty => + Auth0Function( + code = args.inlineCode.get, + codeFilePath = args.codeFilePath, + url = URLValidation.getAndValidateURL(args.name, args.webhookUrl), + auth0Id = args.auth0Id.get, + headers = HttpFunctionHeaders.read(args.headers) + ) + + case FunctionType.CODE if args.inlineCode.isEmpty => + ManagedFunction(args.codeFilePath) + } + + val newFunction = RequestPipelineFunction( + id = args.id, + name = args.name, + isActive = args.isActive, + binding = args.binding, + modelId = args.modelId, + operation = args.operation, + delivery = newDelivery + ) + + val updatedProject: Project = project.copy(functions = project.functions :+ newFunction) + + override def prepareActions(): List[Mutaction] = { + + projectAlreadyHasSameRequestPipeLineFunction match { + case true => + actions = List( + InvalidInput( + UserInputErrors + .SameRequestPipeLineFunctionAlreadyExists(modelName = project.getModelById_!(args.modelId).name, + operation = args.operation.toString, + binding = args.binding.toString))) + case false => + actions = List(CreateFunction(project, newFunction), BumpProjectRevision(project = project), InvalidateSchema(project)) + } + actions + } + + private def projectAlreadyHasSameRequestPipeLineFunction: Boolean = { + def isSameRequestPipeLineFunction(function: RequestPipelineFunction) = { + function.modelId == args.modelId && + function.binding == args.binding && + function.operation == args.operation + } + project.functions.collect { case function: RequestPipelineFunction if isSameRequestPipeLineFunction(function) => function }.nonEmpty + } + + override def getReturnValue(): Option[AddRequestPipelineMutationFunctionMutationPayload] = { + Some(AddRequestPipelineMutationFunctionMutationPayload(args.clientMutationId, project, newFunction)) + } +} + +case class AddRequestPipelineMutationFunctionMutationPayload(clientMutationId: Option[String], + project: models.Project, + function: models.RequestPipelineFunction) + extends Mutation + +case class AddRequestPipelineMutationFunctionInput(clientMutationId: Option[String], + projectId: String, + name: String, + binding: FunctionBinding, + modelId: String, + isActive: Boolean, + operation: RequestPipelineOperation, + functionType: FunctionType, + webhookUrl: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None) { + val id: String = Cuid.createCuid() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddSchemaExtensionFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddSchemaExtensionFunctionMutation.scala new file mode 100644 index 0000000000..42c74e11d4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddSchemaExtensionFunctionMutation.scala @@ -0,0 +1,79 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateFunction, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class AddSchemaExtensionFunctionMutation(client: models.Client, + project: models.Project, + args: AddSchemaExtensionFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddSchemaExtensionFunctionMutationPayload] { + + val newDelivery: FunctionDelivery = args.functionType match { + case FunctionType.WEBHOOK => + WebhookFunction(url = args.url.get.trim, headers = HttpFunctionHeaders.read(args.headers)) + + case FunctionType.CODE if args.inlineCode.nonEmpty => + Auth0Function( + code = args.inlineCode.get, + codeFilePath = args.codeFilePath, + url = args.url.get.trim, + auth0Id = args.auth0Id.get, + headers = HttpFunctionHeaders.read(args.headers) + ) + + case FunctionType.CODE if args.inlineCode.isEmpty => + ManagedFunction(args.codeFilePath) + } + + val newFunction: SchemaExtensionFunction = SchemaExtensionFunction.createFunction( + id = args.id, + name = args.name, + isActive = args.isActive, + schema = args.schema, + delivery = newDelivery, + schemaFilePath = args.schemaFilePath + ) + + val updatedProject: Project = project.copy(functions = project.functions :+ newFunction) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(CreateFunction(project, newFunction), BumpProjectRevision(project = project), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue: Option[AddSchemaExtensionFunctionMutationPayload] = { + Some(AddSchemaExtensionFunctionMutationPayload(args.clientMutationId, project, newFunction)) + } +} + +case class AddSchemaExtensionFunctionMutationPayload( + clientMutationId: Option[String], + project: models.Project, + function: models.SchemaExtensionFunction +) extends Mutation + +case class AddSchemaExtensionFunctionInput( + clientMutationId: Option[String], + projectId: String, + isActive: Boolean, + name: String, + schema: String, + functionType: FunctionType, + url: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None, + schemaFilePath: Option[String] = None +) { + val id: String = Cuid.createCuid() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddServerSideSubscriptionFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddServerSideSubscriptionFunctionMutation.scala new file mode 100644 index 0000000000..f97d9afbb9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AddServerSideSubscriptionFunctionMutation.scala @@ -0,0 +1,92 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.ServerSideSubscriptionQueryIsInvalid +import cool.graph.shared.models +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.subscriptions.schemas.SubscriptionQueryValidator +import cool.graph.system.mutactions.internal.validations.URLValidation +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateFunction, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import org.scalactic.Bad +import sangria.relay.Mutation +import scaldi.Injector + +case class AddServerSideSubscriptionFunctionMutation(client: models.Client, + project: models.Project, + args: AddServerSideSubscriptionFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[AddServerSideSubscriptionFunctionMutationPayload] { + + val newDelivery: FunctionDelivery = args.functionType match { + case FunctionType.WEBHOOK => + WebhookFunction(url = URLValidation.getAndValidateURL(args.name, args.url), headers = HttpFunctionHeaders.read(args.headers)) + + case FunctionType.CODE if args.inlineCode.nonEmpty => + Auth0Function( + code = args.inlineCode.get, + codeFilePath = args.codeFilePath, + url = URLValidation.getAndValidateURL(args.name, args.url), + auth0Id = args.auth0Id.get, + headers = HttpFunctionHeaders.read(args.headers) + ) + + case FunctionType.CODE if args.inlineCode.isEmpty => + ManagedFunction(args.codeFilePath) + } + + val newFunction = ServerSideSubscriptionFunction( + id = args.id, + name = args.name, + isActive = args.isActive, + query = args.query, + queryFilePath = args.queryFilePath, + delivery = newDelivery + ) + + val updatedProject: Project = project.copy(functions = project.functions :+ newFunction) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(CreateFunction(project, newFunction), BumpProjectRevision(project = project), InvalidateSchema(project)) + + SubscriptionQueryValidator(project).validate(args.query) match { + case Bad(errors) => + val userError = ServerSideSubscriptionQueryIsInvalid(errors.head.errorMessage, newFunction.name) + this.actions :+= InvalidInput(userError) + case _ => // NO OP + } + + this.actions + } + + override def getReturnValue: Option[AddServerSideSubscriptionFunctionMutationPayload] = { + Some(AddServerSideSubscriptionFunctionMutationPayload(args.clientMutationId, project, newFunction)) + } +} + +case class AddServerSideSubscriptionFunctionMutationPayload( + clientMutationId: Option[String], + project: models.Project, + function: models.ServerSideSubscriptionFunction +) extends Mutation + +case class AddServerSideSubscriptionFunctionInput( + clientMutationId: Option[String], + projectId: String, + name: String, + isActive: Boolean, + query: String, + functionType: FunctionType, + url: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None, + queryFilePath: Option[String] = None +) { + val id: String = Cuid.createCuid() +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AuthenticateCustomerMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AuthenticateCustomerMutation.scala new file mode 100644 index 0000000000..3269e2eda9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/AuthenticateCustomerMutation.scala @@ -0,0 +1,279 @@ +package cool.graph.system.mutations + +import com.typesafe.config.Config +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.cuid.Cuid +import cool.graph.shared.database.{InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.models +import cool.graph.shared.models.CustomerSource.CustomerSource +import cool.graph.shared.models._ +import cool.graph.system.authorization.SystemAuth +import cool.graph.system.database.SystemFields +import cool.graph.system.database.client.EmptyClientDbQueries +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.client.{CreateClientDatabaseForProject, CreateColumn, CreateModelTable, CreateRelationTable} +import cool.graph.system.mutactions.internal._ +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.ExecutionContextExecutor + +case class AuthenticateCustomerMutation( + args: AuthenticateCustomerInput, + internalDatabase: InternalDatabase, + projectDbsFn: ProjectDatabase => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalMutation[AuthenticateCustomerMutationPayload] + with Injectable { + + val internalDatabaseDef = internalDatabase.databaseDef + implicit val config: Config = inject[Config](identified by "config") + val projectQueries = inject[ProjectQueries](identified by "projectQueries") + + var newClient: Option[models.Client] = None + var newProject: Option[models.Project] = None + + val projectDatabase: ProjectDatabase = DefaultProjectDatabase.blocking(internalDatabaseDef) + override val databases: InternalAndProjectDbs = projectDbsFn(projectDatabase) + + override def prepareActions(): List[Mutaction] = { + + val auth = new SystemAuth() + + val idTokenData = auth.parseAuth0IdToken(args.auth0IdToken).get + + val name = + idTokenData.user_metadata.map(_.name).getOrElse(idTokenData.name) + + val (actions, client, project) = AuthenticateCustomerMutation.generateActions( + name = name, + auth0Id = idTokenData.sub, + email = idTokenData.email, + source = CustomerSource.HOMEPAGE, + internalDatabase = internalDatabaseDef, + projectQueries = projectQueries, + projectDatabase = projectDatabase + ) + this.actions = actions + this.newClient = Some(client) + this.newProject = Some(project) + + actions + } + + override def getReturnValue(): Option[AuthenticateCustomerMutationPayload] = { + + val auth = new SystemAuth() + val sessionToken = auth.generateSessionToken(newClient.get.id) + + Some(AuthenticateCustomerMutationPayload(clientMutationId = args.clientMutationId, client = newClient.get)) + } +} + +case class AuthenticateCustomerMutationPayload(clientMutationId: Option[String], client: models.Client) extends Mutation + +case class AuthenticateCustomerInput(clientMutationId: Option[String], auth0IdToken: String) + +object AuthenticateCustomerMutation { + def generateUserModel = { + Model( + id = Cuid.createCuid(), + name = "User", + isSystem = true, + fields = List() + ) + } + + def generateUserFields = { + SystemFields.generateAll + } + + def generateFileModel = { + Model( + id = Cuid.createCuid(), + name = "File", + isSystem = true, + fields = List() + ) + } + + def generateFileFields = { + SystemFields.generateAll ++ + List( + Field( + id = Cuid.createCuid(), + name = "secret", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = true, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "url", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = true, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "name", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = false + ), + Field( + id = Cuid.createCuid(), + name = "contentType", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "size", + typeIdentifier = TypeIdentifier.Int, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ) + ) + } + + def generateExampleProject(projectDatabase: ProjectDatabase) = { + Project( + id = Cuid.createCuid(), + ownerId = "just-a-temporary-dummy-gets-set-to-real-client-id-later", + name = "Example Project", + models = List.empty, + projectDatabase = projectDatabase + ) + } + + def createInternalStructureForNewProject(client: Client, + project: Project, + projectQueries: ProjectQueries, + internalDatabase: DatabaseDef, + ignoreDuplicateNameVerificationError: Boolean = false)(implicit inj: Injector) = { + List( + CreateProject( + client = client, + project = project, + projectQueries = projectQueries, + internalDatabase = internalDatabase, + ignoreDuplicateNameVerificationError = ignoreDuplicateNameVerificationError + ), + CreateSeat( + client, + project, + Seat(id = Cuid.createCuid(), status = SeatStatus.JOINED, isOwner = true, email = client.email, clientId = Some(client.id), name = None), + internalDatabase, + ignoreDuplicateNameVerificationError = true + ) + ) ++ + project.models.map(model => CreateModel(project = project, model = model)) ++ + project.relations.map(relation => CreateRelation(project = project.copy(relations = List()), relation = relation, clientDbQueries = EmptyClientDbQueries)) ++ + project.models.flatMap( + model => + models.ModelPermission.publicPermissions + .map(CreateModelPermission(project, model, _))) ++ project.relations.flatMap(relation => + models.RelationPermission.publicPermissions.map(CreateRelationPermission(project, relation, _))) + + } + + def createClientDatabaseStructureForNewProject(client: Client, project: Project, internalDatabase: DatabaseDef) = { + List(CreateClientDatabaseForProject(projectId = project.id)) ++ + project.models.map(model => CreateModelTable(projectId = project.id, model = model)) ++ + project.models.flatMap(model => { + model.fields + .filter(f => !DatabaseMutationBuilder.implicitlyCreatedColumns.contains(f.name)) + .filter(_.isScalar) + .map(field => CreateColumn(projectId = project.id, model = model, field = field)) + }) ++ + project.relations.map(relation => CreateRelationTable(project = project, relation = relation)) + + } + + def createIntegrationsForNewProject(project: Project)(implicit inj: Injector) = { + val searchProviderAlgolia = models.SearchProviderAlgolia( + id = Cuid.createCuid(), + subTableId = Cuid.createCuid(), + applicationId = "", + apiKey = "", + algoliaSyncQueries = List(), + isEnabled = false, + name = IntegrationName.SearchProviderAlgolia + ) + + List( + CreateAuthProvider(project = project, name = IntegrationName.AuthProviderEmail, metaInformation = None, isEnabled = false), + CreateAuthProvider(project = project, name = IntegrationName.AuthProviderAuth0, metaInformation = None, isEnabled = false), + CreateAuthProvider(project = project, name = IntegrationName.AuthProviderDigits, metaInformation = None, isEnabled = false), + CreateIntegration(project, searchProviderAlgolia), + CreateSearchProviderAlgolia(project, searchProviderAlgolia) + ) + } + + def generateActions( + name: String, + auth0Id: String, + email: String, + source: CustomerSource, + internalDatabase: DatabaseDef, + projectQueries: ProjectQueries, + projectDatabase: ProjectDatabase + )(implicit inj: Injector, dispatcher: ExecutionContextExecutor, config: Config): (List[Mutaction], Client, Project) = { + + var actions: List[Mutaction] = List() + + val userFields = AuthenticateCustomerMutation.generateUserFields + val userModel = AuthenticateCustomerMutation.generateUserModel.copy(fields = userFields) + + val fileFields = AuthenticateCustomerMutation.generateFileFields + val fileModel = AuthenticateCustomerMutation.generateFileModel.copy(fields = fileFields) + + val exampleProject = generateExampleProject(projectDatabase).copy(models = List(userModel, fileModel)) + + val client = models.Client( + id = Cuid.createCuid(), + name = name, + auth0Id = Some(auth0Id), + isAuth0IdentityProviderEmail = auth0Id.split("\\|").head == "auth0", + email = email, + hashedPassword = Cuid.createCuid(), + resetPasswordSecret = Some(Cuid.createCuid()), + source = source, + projects = List(), + createdAt = org.joda.time.DateTime.now, + updatedAt = org.joda.time.DateTime.now + ) + + val newProject = exampleProject.copy(ownerId = client.id, models = List(userModel.copy(fields = userFields), fileModel.copy(fields = fileFields))) + val newClient = client.copy(projects = List(newProject)) + + actions :+= CreateClient(client = client) + actions :+= JoinPendingSeats(client) + actions :+= InvalidateSchemaForAllProjects(client) + actions ++= createInternalStructureForNewProject(client, newProject, projectQueries, internalDatabase) + actions ++= createClientDatabaseStructureForNewProject(client, newProject, internalDatabase) + actions ++= createIntegrationsForNewProject(newProject) + + (actions, newClient, newProject) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CloneProjectMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CloneProjectMutation.scala new file mode 100644 index 0000000000..6efcd9da7b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CloneProjectMutation.scala @@ -0,0 +1,269 @@ +package cool.graph.system.mutations + +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.database.client.EmptyClientDbQueries +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.client._ +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class CloneProjectMutation(client: Client, project: Project, args: CloneProjectInput, projectDbsFn: models.Project => InternalAndProjectDbs)( + implicit inj: Injector) + extends InternalProjectMutation[CloneProjectPayload] + with Injectable { + + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + var clonedProject: models.Project = Project( + id = Cuid.createCuid(), + name = args.name, + ownerId = client.id, + projectDatabase = project.projectDatabase + ) + + override def prepareActions(): List[Mutaction] = { + + // INTERNAL DATABASE + + var modelActions: List[Mutaction] = List() + var fieldActions: List[Mutaction] = List() + var modelPermissionActions: List[Mutaction] = List() + var modelPermissionFieldActions: List[Mutaction] = List() + var relationActions: List[Mutaction] = List() + var relationFieldMirrorActions: List[Mutaction] = List() + var relationPermissionActions: List[Mutaction] = List() + var actionActions: List[Mutaction] = List() + var authProviderActions: List[Mutaction] = List() + var rootTokenActions: List[Mutaction] = List() + var integrationActions: List[Mutaction] = List() + var searchProviderAlgoliaActions: List[Mutaction] = List() + var algoliaSyncQueriesActions: List[Mutaction] = List() + var seatActions: List[Mutaction] = List() + + val clientDbQueries = EmptyClientDbQueries + + seatActions :+= CreateSeat( + client, + project = clonedProject, + seat = + Seat(id = Cuid.createCuid(), status = SeatStatus.JOINED, isOwner = true, email = client.email, clientId = Some(client.id), name = Some(client.name)), + internalDatabase.databaseDef + ) + + var modelIdMap = Map.empty[String, String] + var fieldIdMap = Map.empty[String, String] + var fieldRelationMap = Map.empty[String, String] // new fieldId, old relationId + project.models.foreach(model => { + val newId = Cuid.createCuid() + modelIdMap += (model.id -> newId) + var clonedModel = + model.copy(id = newId, fields = List(), permissions = List()) + modelActions :+= CreateModelWithoutSystemFields(project = clonedProject, model = clonedModel) + + model.fields.foreach(field => { + val newId = Cuid.createCuid() + fieldIdMap += (field.id -> newId) + if (field.relation.isDefined) { + fieldRelationMap += (newId -> field.relation.get.id) + } + val clonedField = field.copy(id = newId, relation = None) // keep old relation so we can patch it up later + fieldActions :+= CreateField(project = clonedProject, model = clonedModel, field = clonedField, None, clientDbQueries) + + // RelationFieldMirror validation needs this + clonedModel = clonedModel.copy(fields = clonedModel.fields :+ clonedField) + }) + + model.permissions.foreach(permission => { + val clonedPermission = + permission.copy(id = Cuid.createCuid(), fieldIds = List()) + modelPermissionActions :+= CreateModelPermission(project = clonedProject, model = clonedModel, permission = clonedPermission) + + permission.fieldIds.foreach(fieldId => { + modelPermissionFieldActions :+= CreateModelPermissionField(project = clonedProject, + model = clonedModel, + permission = clonedPermission, + fieldId = fieldIdMap(fieldId)) + }) + }) + + // ActionTriggerMutationModel validation needs this + clonedProject = clonedProject.copy(models = clonedProject.models :+ clonedModel) + }) + + val enumsToCreate = project.enums.map { enum => + val newEnum = enum.copy(id = Cuid.createCuid()) + CreateEnum(clonedProject, newEnum) + } + + var relationIdMap = Map.empty[String, String] + project.relations.foreach(relation => { + val newId = Cuid.createCuid() + relationIdMap += (relation.id -> newId) + val clonedRelation = + relation.copy( + id = newId, + modelAId = modelIdMap(relation.modelAId), + modelBId = modelIdMap(relation.modelBId), + fieldMirrors = relation.fieldMirrors.map( + fieldMirror => + fieldMirror.copy( + id = Cuid.createCuid(), + relationId = newId, + fieldId = fieldIdMap(fieldMirror.fieldId) + )) + ) + relationActions :+= CreateRelation(project = clonedProject, relation = clonedRelation, clientDbQueries = clientDbQueries) + + clonedRelation.permissions.foreach(relationPermission => { + val newId = Cuid.createCuid() + val clonedRelationPermission = relationPermission.copy(id = newId) + + relationPermissionActions :+= CreateRelationPermission(project = clonedProject, relation = clonedRelation, permission = clonedRelationPermission) + }) + + // RelationFieldMirror validation needs this + clonedProject = clonedProject.copy(relations = clonedProject.relations :+ clonedRelation) + + clonedRelation.fieldMirrors.foreach(fieldMirror => { + relationFieldMirrorActions :+= CreateRelationFieldMirror(project = clonedProject, relationFieldMirror = fieldMirror) + }) + }) + + def findNewEnumForOldEnum(enum: Option[Enum]): Option[Enum] = { + for { + oldEnum <- enum + newEnumCreate <- enumsToCreate.find(_.enum.name == oldEnum.name) + } yield { + newEnumCreate.enum + } + } + + fieldActions = fieldActions.map { + case x: CreateField => + x.copy( + project = clonedProject, + field = x.field match { + case f if fieldRelationMap.get(x.field.id).isDefined => + f.copy(relation = Some(clonedProject.getRelationById_!(relationIdMap(fieldRelationMap(f.id))))) + + case f => + f.copy(enum = findNewEnumForOldEnum(f.enum)) + } + ) + } + + clonedProject = clonedProject.copy(models = clonedProject.models.map(model => + model.copy(fields = model.fields.map(field => { + val oldField = project.getModelByName_!(model.name).getFieldByName_!(field.name) + + field.copy(relation = oldField.relation.map(oldRelation => clonedProject.getRelationById_!(relationIdMap(oldRelation.id)))) + })))) + + if (args.includeMutationCallbacks) { + // TODO: relying on ActionTriggerMutationRelation to not get used, as not clean copying it + project.actions.foreach(action => { + val clonedAction = action.copy( + id = Cuid.createCuid(), + handlerWebhook = action.handlerWebhook.map(_.copy(id = Cuid.createCuid())), + triggerMutationModel = action.triggerMutationModel.map(_.copy(id = Cuid.createCuid(), modelId = modelIdMap(action.triggerMutationModel.get.modelId))), + triggerMutationRelation = None + ) + actionActions ++= CreateAction.generateAddActionMutactions(project = clonedProject, action = clonedAction) + }) + } + + project.authProviders.foreach(authProvider => { + // don't need to copy the metaInformation as a new Cuid is generated internally + val clonedAuthProvider = authProvider.copy(id = Cuid.createCuid()) + authProviderActions :+= CreateAuthProvider(project = clonedProject, + name = clonedAuthProvider.name, + metaInformation = authProvider.metaInformation, + isEnabled = authProvider.isEnabled) + }) + + project.integrations.foreach { + case searchProviderAlgolia: SearchProviderAlgolia => + val clonedSearchProviderAlgolia = searchProviderAlgolia.copy( + id = Cuid.createCuid(), + subTableId = Cuid.createCuid(), + algoliaSyncQueries = List() + ) + integrationActions :+= CreateIntegration(project = clonedProject, integration = clonedSearchProviderAlgolia) + searchProviderAlgoliaActions :+= CreateSearchProviderAlgolia(project = clonedProject, searchProviderAlgolia = clonedSearchProviderAlgolia) + + searchProviderAlgolia.algoliaSyncQueries.foreach(algoliaSyncQuery => { + val clonedAlgoliaSyncQuery = + algoliaSyncQuery.copy(id = Cuid.createCuid(), model = clonedProject.getModelById_!(modelIdMap(algoliaSyncQuery.model.id))) + algoliaSyncQueriesActions :+= CreateAlgoliaSyncQuery(searchProviderAlgolia = clonedSearchProviderAlgolia, algoliaSyncQuery = clonedAlgoliaSyncQuery) + }) + case _ => + } + + actions :+= CreateProject(client = client, project = clonedProject, projectQueries = projectQueries, internalDatabase = internalDatabase.databaseDef) + actions ++= seatActions + actions ++= enumsToCreate + actions ++= modelActions + actions ++= relationActions + actions ++= fieldActions + actions ++= modelPermissionActions + actions ++= modelPermissionFieldActions + actions ++= relationPermissionActions + actions ++= relationFieldMirrorActions + actions ++= actionActions + actions ++= authProviderActions + actions ++= rootTokenActions + actions ++= integrationActions + actions ++= searchProviderAlgoliaActions + actions ++= algoliaSyncQueriesActions + + // PROJECT DATABASE + + actions :+= CreateClientDatabaseForProject(clonedProject.id) + actions ++= clonedProject.models.map(model => CreateModelTable(clonedProject.id, model)) + actions ++= clonedProject.models.flatMap( + model => + model.scalarFields + .filter(f => !DatabaseMutationBuilder.implicitlyCreatedColumns.contains(f.name)) + .map(field => CreateColumn(clonedProject.id, model, field))) + + actions ++= clonedProject.relations.map(relation => CreateRelationTable(clonedProject, relation)) + actions ++= clonedProject.relations.flatMap(relation => + relation.fieldMirrors.map(fieldMirror => CreateRelationFieldMirrorColumn(clonedProject, relation, clonedProject.getFieldById_!(fieldMirror.fieldId)))) + + if (args.includeData) { + actions ++= clonedProject.models.map( + model => + CopyModelTableData(sourceProjectId = project.id, + sourceModel = project.getModelByName_!(model.name), + targetProjectId = clonedProject.id, + targetModel = model)) + + actions ++= project.relations.map( + oldRelation => + CopyRelationTableData( + sourceProject = project, + sourceRelation = oldRelation, + targetProjectId = clonedProject.id, + targetRelation = clonedProject.getRelationById_!(relationIdMap(oldRelation.id)) + )) + } + + actions + } + + override def getReturnValue: Option[CloneProjectPayload] = { + // note: we don't fully reconstruct the project (as we are cloning) since we just reload it in its + // entirety from the DB in the SchemaBuilder + Some(CloneProjectPayload(clientMutationId = args.clientMutationId, projectId = clonedProject.id, clonedProject = clonedProject)) + } +} + +case class CloneProjectPayload(clientMutationId: Option[String], projectId: String, clonedProject: Project) extends Mutation + +case class CloneProjectInput(clientMutationId: Option[String], projectId: String, name: String, includeData: Boolean, includeMutationCallbacks: Boolean) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CreateRootTokenMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CreateRootTokenMutation.scala new file mode 100644 index 0000000000..fec83c9e55 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/CreateRootTokenMutation.scala @@ -0,0 +1,60 @@ +package cool.graph.system.mutations + +import com.typesafe.config.Config +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models.{Project, RootToken} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.authorization.SystemAuth2 +import cool.graph.system.mutactions.internal.{BumpProjectRevision, CreateRootToken, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import org.joda.time.DateTime +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class CreateRootTokenMutation(client: models.Client, + project: models.Project, + args: CreateRootTokenInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit val inj: Injector) + extends InternalProjectMutation[CreateRootTokenMutationPayload] + with Injectable { + + val config: Config = inject[Config](identified by "config") + val newRootToken: RootToken = CreateRootTokenMutation.generate(clientId = client.id, projectId = project.id, name = args.name, expirationInSeconds = None) + val updatedProject: Project = project.copy(rootTokens = project.rootTokens :+ newRootToken) + + override def prepareActions(): List[Mutaction] = { + project.rootTokens.map(_.name).contains(newRootToken.name) match { + case true => actions = List(InvalidInput(UserInputErrors.RootTokenNameAlreadyInUse(newRootToken.name))) + case false => actions = List(CreateRootToken(project.id, newRootToken), BumpProjectRevision(project), InvalidateSchema(project)) + } + + actions + } + + override def getReturnValue: Option[CreateRootTokenMutationPayload] = { + Some( + CreateRootTokenMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + rootToken = newRootToken + )) + } +} + +case class CreateRootTokenMutationPayload(clientMutationId: Option[String], project: models.Project, rootToken: models.RootToken) extends Mutation +case class CreateRootTokenInput(clientMutationId: Option[String], projectId: String, name: String, description: Option[String]) + +object CreateRootTokenMutation { + private def generateRootToken(id: String, clientId: String, projectId: String, expirationInSeconds: Option[Long])(implicit inj: Injector): String = { + SystemAuth2().generateRootToken(clientId, projectId, id, expirationInSeconds) + } + + def generate(clientId: String, projectId: String, name: String, expirationInSeconds: Option[Long])(implicit inj: Injector): RootToken = { + val id = Cuid.createCuid() + + models.RootToken(id = id, token = generateRootToken(id, clientId, projectId, expirationInSeconds), name = name, created = DateTime.now()) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DefaultProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DefaultProjectDatabase.scala new file mode 100644 index 0000000000..da6ff11eea --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DefaultProjectDatabase.scala @@ -0,0 +1,27 @@ +package cool.graph.system.mutations + +import cool.graph.shared.models.{ProjectDatabase, Region} +import cool.graph.system.database.finder.ProjectDatabaseFinder +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +object DefaultProjectDatabase { + def blocking(internalDatabase: DatabaseDef): ProjectDatabase = { + Await.result(this(internalDatabase), 5.seconds) + } + + private def apply(internalDatabase: DatabaseDef): Future[ProjectDatabase] = { + import scala.concurrent.ExecutionContext.Implicits.global + + lazy val fallbackForTests: Future[ProjectDatabase] = { + val region = Region.EU_WEST_1 + ProjectDatabaseFinder + .defaultForRegion(region)(internalDatabase) + .map(_.getOrElse(sys.error(s"no default db found for region $region"))) + } + + fallbackForTests + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteActionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteActionMutation.scala new file mode 100644 index 0000000000..8e9f9ba53e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteActionMutation.scala @@ -0,0 +1,50 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteActionMutation( + client: models.Client, + project: models.Project, + args: DeleteActionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteActionMutationPayload] { + + var deletedAction: models.Action = project.getActionById_!(args.actionId) + + override def prepareActions(): List[Mutaction] = { + + // note: handlers and triggers does not cascade delete because we think it + // might make sense to model them as individual entities in the ui + + if (deletedAction.handlerWebhook.isDefined) + actions :+= DeleteActionHandlerWebhook(project, deletedAction, deletedAction.handlerWebhook.get) + + if (deletedAction.triggerMutationModel.isDefined) + actions :+= DeleteActionTriggerMutationModel(project, deletedAction.triggerMutationModel.get) + + actions :+= DeleteAction(project, deletedAction) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[DeleteActionMutationPayload] = { + Some( + DeleteActionMutationPayload(clientMutationId = args.clientMutationId, + project = project.copy(actions = project.actions.filter(_.id != deletedAction.id)), + action = deletedAction)) + } +} + +case class DeleteActionMutationPayload(clientMutationId: Option[String], project: models.Project, action: models.Action) extends Mutation + +case class DeleteActionInput(clientMutationId: Option[String], actionId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteAlgoliaSyncQueryMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteAlgoliaSyncQueryMutation.scala new file mode 100644 index 0000000000..0b60e6c9aa --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteAlgoliaSyncQueryMutation.scala @@ -0,0 +1,68 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.NotFoundException +import cool.graph.shared.models +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteAlgoliaSyncQuery, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteAlgoliaSyncQueryMutation(client: models.Client, + project: models.Project, + args: DeleteAlgoliaSyncQueryInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteAlgoliaSyncQueryPayload] { + + var algoliaSyncQuery: Option[models.AlgoliaSyncQuery] = None + var searchProviderAlgolia: Option[models.SearchProviderAlgolia] = None + + override def prepareActions(): List[Mutaction] = { + algoliaSyncQuery = project.getAlgoliaSyncQueryById(args.algoliaSyncQueryId) + + val pendingActions: List[Mutaction] = algoliaSyncQuery match { + case Some(algoliaSyncQueryToDelete: models.AlgoliaSyncQuery) => + searchProviderAlgolia = project.getSearchProviderAlgoliaByAlgoliaSyncQueryId(args.algoliaSyncQueryId) + + val removeAlgoliaSyncQueryFromProject = + DeleteAlgoliaSyncQuery( + searchProviderAlgolia = searchProviderAlgolia.get, + algoliaSyncQuery = algoliaSyncQueryToDelete + ) + List(removeAlgoliaSyncQueryFromProject, BumpProjectRevision(project = project), InvalidateSchema(project = project)) + + case None => + List(InvalidInput(NotFoundException("This algoliaSearchQueryId does not correspond to an existing AlgoliaSearchQuery"))) + } + + actions = pendingActions + actions + } + + override def getReturnValue: Option[DeleteAlgoliaSyncQueryPayload] = { + val updatedSearchProviderAlgolia = searchProviderAlgolia.get.copy( + algoliaSyncQueries = searchProviderAlgolia.get.algoliaSyncQueries + .filterNot(_.id == algoliaSyncQuery.get.id)) + val updatedProject = project.copy(integrations = project.authProviders.filter(_.id != searchProviderAlgolia.get.id) :+ updatedSearchProviderAlgolia) + + Some( + DeleteAlgoliaSyncQueryPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + algoliaSyncQuery = algoliaSyncQuery.get, + searchProviderAlgolia = searchProviderAlgolia.get.copy( + algoliaSyncQueries = searchProviderAlgolia.get.algoliaSyncQueries + .filter(_.id != algoliaSyncQuery.get.id) + ) + )) + } +} + +case class DeleteAlgoliaSyncQueryPayload(clientMutationId: Option[String], + project: models.Project, + algoliaSyncQuery: models.AlgoliaSyncQuery, + searchProviderAlgolia: models.SearchProviderAlgolia) + extends Mutation + +case class DeleteAlgoliaSyncQueryInput(clientMutationId: Option[String], algoliaSyncQueryId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteCustomer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteCustomer.scala new file mode 100644 index 0000000000..794974ebfb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteCustomer.scala @@ -0,0 +1,39 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalDatabase +import cool.graph.shared.errors.SystemErrors.InvalidClientId +import cool.graph.shared.models +import cool.graph.shared.models.Client +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.client.DeleteClientDatabaseForProject +import cool.graph.system.mutactions.internal.DeleteClient +import cool.graph.{InternalMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteCustomerMutation( + client: Client, + args: DeleteCustomerInput, + internalDatabase: InternalDatabase +)(implicit inj: Injector) + extends InternalMutation[DeleteCustomerMutationPayload] { + + override def prepareActions(): List[Mutaction] = { + + actions = if (client.id != args.customerId) { + List(InvalidInput(InvalidClientId(args.customerId))) + } else { + client.projects.map(project => DeleteClientDatabaseForProject(project.id)) ++ List(DeleteClient(client)) + } + + actions + } + + override def getReturnValue(): Option[DeleteCustomerMutationPayload] = { + Some(DeleteCustomerMutationPayload(clientMutationId = args.clientMutationId, customer = client)) + } +} + +case class DeleteCustomerMutationPayload(clientMutationId: Option[String], customer: models.Client) extends Mutation + +case class DeleteCustomerInput(clientMutationId: Option[String], customerId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteEnumMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteEnumMutation.scala new file mode 100644 index 0000000000..de75d5eff9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteEnumMutation.scala @@ -0,0 +1,32 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Enum, Project} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteEnum, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteEnumMutation( + client: models.Client, + project: models.Project, + args: DeleteEnumInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteEnumMutationPayload] { + + val enum: Enum = project.getEnumById_!(args.enumId) + val updatedProject: Project = project.copy(enums = project.enums.filter(_.id != args.enumId)) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(DeleteEnum(project, enum), BumpProjectRevision(project = project), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue: Option[DeleteEnumMutationPayload] = Some(DeleteEnumMutationPayload(args.clientMutationId, updatedProject, enum)) +} + +case class DeleteEnumMutationPayload(clientMutationId: Option[String], project: models.Project, enum: models.Enum) extends Mutation + +case class DeleteEnumInput(clientMutationId: Option[String], enumId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldConstraintMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldConstraintMutation.scala new file mode 100644 index 0000000000..de81d7d338 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldConstraintMutation.scala @@ -0,0 +1,39 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteFieldConstraint, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteFieldConstraintMutation(client: models.Client, + project: models.Project, + args: DeleteFieldConstraintInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteFieldConstraintMutationPayload] { + + val constraint: FieldConstraint = project.getFieldConstraintById_!(args.constraintId) + + val field: Field = project.getFieldById_!(constraint.fieldId) + + val fieldWithoutConstraint: Field = field.copy(constraints = field.constraints.filter(_.id != constraint.id)) + val model: Model = project.models.find(_.fields.contains(field)).get + val modelsWithoutConstraint: List[Model] = project.models.filter(_.id != model.id) :+ model.copy( + fields = model.fields.filter(_.id != field.id) :+ fieldWithoutConstraint) + val newProject: Project = project.copy(models = modelsWithoutConstraint) + + override def prepareActions(): List[Mutaction] = { + actions = List(DeleteFieldConstraint(project, constraint), BumpProjectRevision(project = project), InvalidateSchema(project)) + actions + } + + override def getReturnValue: Option[DeleteFieldConstraintMutationPayload] = { + Some(DeleteFieldConstraintMutationPayload(args.clientMutationId, newProject, constraint)) + } +} + +case class DeleteFieldConstraintMutationPayload(clientMutationId: Option[String], project: models.Project, constraint: FieldConstraint) extends Mutation + +case class DeleteFieldConstraintInput(clientMutationId: Option[String], constraintId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldMutation.scala new file mode 100644 index 0000000000..9e11fcdbde --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFieldMutation.scala @@ -0,0 +1,66 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Field, Model, Project} +import cool.graph.system.database.SystemFields +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.{DeleteColumn, DeleteRelationTable} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteField, DeleteRelation, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteFieldMutation( + client: Client, + project: Project, + args: DeleteFieldInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteFieldMutationPayload] { + + val field: Field = project.getFieldById_!(args.fieldId) + val model: Model = project.getModelByFieldId_!(args.fieldId) + val updatedModel: Model = model.copy(fields = model.fields.filter(_.id != field.id)) + val updatedProject: Project = project.copy(models = project.models.map { + case model if model.id == updatedModel.id => updatedModel + case model => model + }) + + override def prepareActions(): List[Mutaction] = { + if (field.isScalar) { + if (SystemFields.isDeletableSystemField(field.name)) { + // Only delete field in the project DB ("hiding" fields in the schema) + actions :+= DeleteField(project, model = model, field = field, allowDeleteSystemField = true) + } else { + // Delete field in both DBs + actions :+= DeleteField(project, model = model, field = field) + actions :+= DeleteColumn(projectId = project.id, model = model, field = field) + } + } else { + actions :+= DeleteField(project, model = model, field = field) + } + + if (field.relation.isDefined) { + val existingRelationFields = project.getFieldsByRelationId(field.relation.get.id) + + if (existingRelationFields.length == 1) { + actions :+= DeleteRelation(relation = field.relation.get, project = project, clientDbQueries = clientDbQueries) + actions :+= DeleteRelationTable(project = project, relation = field.relation.get) + } + } + + actions :+= BumpProjectRevision(project = project) + actions :+= InvalidateSchema(project = project) + actions + } + + override def getReturnValue: Option[DeleteFieldMutationPayload] = { + Some(DeleteFieldMutationPayload(clientMutationId = args.clientMutationId, model = updatedModel, field = field, project = updatedProject)) + } +} + +case class DeleteFieldMutationPayload(clientMutationId: Option[String], model: models.Model, field: models.Field, project: Project) extends Mutation + +case class DeleteFieldInput(clientMutationId: Option[String], fieldId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFunctionMutation.scala new file mode 100644 index 0000000000..7134eed124 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteFunctionMutation.scala @@ -0,0 +1,32 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Function, Project} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteFunction, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteFunctionMutation(client: models.Client, + project: models.Project, + args: DeleteFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteFunctionMutationPayload] { + + val function: Function = project.getFunctionById_!(args.functionId) + + val updatedProject: Project = project.copy(functions = project.functions.filter(_.id != args.functionId)) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(DeleteFunction(project, function), BumpProjectRevision(project = project), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue: Option[DeleteFunctionMutationPayload] = + Some(DeleteFunctionMutationPayload(args.clientMutationId, project, function)) +} + +case class DeleteFunctionMutationPayload(clientMutationId: Option[String], project: models.Project, function: models.Function) extends Mutation + +case class DeleteFunctionInput(clientMutationId: Option[String], functionId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelMutation.scala new file mode 100644 index 0000000000..e14b7aa371 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelMutation.scala @@ -0,0 +1,60 @@ +package cool.graph.system.mutations + +import cool.graph.Types.Id +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.{DeleteModelTable, DeleteRelationTable} +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteModelMutation( + client: Client, + project: Project, + args: DeleteModelInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteModelMutationPayload] { + + val model: Model = project.getModelById_!(args.modelId) + + val relations: List[Relation] = model.relations + val updatedProject: Project = { + val modelsWithoutThisOne = project.models.filter(_.id != model.id) + val modelsWithRelationFieldsToThisOneRemoved = modelsWithoutThisOne.map(_.withoutFieldsForRelations(relations)) + project.copy(models = modelsWithRelationFieldsToThisOneRemoved, relations = project.relations.filter(r => !model.relations.map(_.id).contains(r.id))) + } + + val relationFieldIds: List[Id] = for { + relation <- relations + field <- relation.fields(project) + } yield field.id + + override def prepareActions(): List[Mutaction] = { + actions ++= project.actions.collect { + case action @ Action(_, _, _, _, _, _, Some(trigger), _) if trigger.modelId == model.id => + DeleteAction(project, action) + } + actions ++= relations.map(relation => DeleteRelation(relation, project, clientDbQueries)) + actions ++= relations.map(relation => DeleteRelationTable(project = project, relation)) + actions :+= DeleteModel(project, model = model) + actions :+= DeleteModelTable(projectId = project.id, model = model) + actions :+= BumpProjectRevision(project = project) + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[DeleteModelMutationPayload] = { + Some(DeleteModelMutationPayload(clientMutationId = args.clientMutationId, model = model, deletedFieldIds = relationFieldIds, project = updatedProject)) + } +} + +case class DeleteModelMutationPayload(clientMutationId: Option[String], model: models.Model, deletedFieldIds: List[String], project: models.Project) + extends Mutation + +case class DeleteModelInput(clientMutationId: Option[String], modelId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelPermissionMutation.scala new file mode 100644 index 0000000000..4a05e8a841 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteModelPermissionMutation.scala @@ -0,0 +1,54 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteModelPermission, DeleteModelPermissionField, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteModelPermissionMutation(client: Client, + project: Project, + model: Model, + modelPermission: ModelPermission, + args: DeleteModelPermissionInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteModelPermissionMutationPayload] { + + val newModel: Model = model.copy(permissions = model.permissions.filter(_.id != modelPermission.id)) + val updatedProject: Project = project.copy(models = project.models.map { + case x if x.id == newModel.id => newModel + case x => x + }) + + override def prepareActions(): List[Mutaction] = { + + actions ++= modelPermission.fieldIds.map(fieldId => + DeleteModelPermissionField(project = project, model = model, permission = modelPermission, fieldId = fieldId)) + + actions :+= DeleteModelPermission(project, model = model, permission = modelPermission) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[DeleteModelPermissionMutationPayload] = { + + Some( + DeleteModelPermissionMutationPayload( + clientMutationId = args.clientMutationId, + model = newModel, + modelPermission = modelPermission, + project = updatedProject + )) + } +} + +case class DeleteModelPermissionMutationPayload(clientMutationId: Option[String], model: models.Model, modelPermission: ModelPermission, project: Project) + extends Mutation + +case class DeleteModelPermissionInput(clientMutationId: Option[String], modelPermissionId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteProjectMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteProjectMutation.scala new file mode 100644 index 0000000000..8e0b4e9615 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteProjectMutation.scala @@ -0,0 +1,50 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.client.DeleteClientDatabaseForProject +import cool.graph.system.mutactions.internal.{DeleteProject, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class DeleteProjectMutation( + client: Client, + project: Project, + args: DeleteProjectInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteProjectMutationPayload] + with Injectable { + + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + override def prepareActions(): List[Mutaction] = { + + actions :+= DeleteProject(client = client, project = project, projectQueries = projectQueries, internalDatabase = internalDatabase.databaseDef) + actions :+= DeleteClientDatabaseForProject(project.id) + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[DeleteProjectMutationPayload] = { + Some { + DeleteProjectMutationPayload( + clientMutationId = args.clientMutationId, + client = client.copy(projects = client.projects.filter(_.id != project.id)), + project = project + ) + } + } +} + +case class DeleteProjectMutationPayload( + clientMutationId: Option[String], + client: models.Client, + project: models.Project +) extends Mutation + +case class DeleteProjectInput(clientMutationId: Option[String], projectId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationFieldMirrorMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationFieldMirrorMutation.scala new file mode 100644 index 0000000000..e26b16f842 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationFieldMirrorMutation.scala @@ -0,0 +1,39 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteRelationFieldMirror, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteRelationFieldMirrorMutation(client: models.Client, + project: models.Project, + relation: models.Relation, + args: DeleteRelationFieldMirrorInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteRelationFieldMirrorPayload] { + + override def prepareActions(): List[Mutaction] = { + val deleteRelationFieldMirrorToField = + DeleteRelationFieldMirror(project = project, relationFieldMirror = relation.getRelationFieldMirrorById_!(args.relationFieldMirrorId)) + + actions = List(deleteRelationFieldMirrorToField, BumpProjectRevision(project = project), InvalidateSchema(project = project)) + actions + } + + override def getReturnValue: Option[DeleteRelationFieldMirrorPayload] = { + Some( + DeleteRelationFieldMirrorPayload( + clientMutationId = args.clientMutationId, + project = project, + deletedId = args.relationFieldMirrorId, + relation = relation.copy(fieldMirrors = relation.fieldMirrors.filter(_.id != args.relationFieldMirrorId)) + )) + } +} + +case class DeleteRelationFieldMirrorPayload(clientMutationId: Option[String], project: models.Project, deletedId: String, relation: models.Relation) + extends Mutation + +case class DeleteRelationFieldMirrorInput(clientMutationId: Option[String], relationFieldMirrorId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationMutation.scala new file mode 100644 index 0000000000..d78a6ed901 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationMutation.scala @@ -0,0 +1,55 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.DeleteRelationTable +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteField, DeleteRelation, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteRelationMutation( + client: Client, + project: Project, + args: DeleteRelationInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[DeleteRelationMutationPayload] { + + val relation: Relation = project.getRelationById_!(args.relationId) + val relationFields: List[Field] = project.getFieldsByRelationId(relation.id) + + val updatedModels: List[Model] = relationFields.map { field => + val model = project.getModelByFieldId_!(field.id) + model.copy(fields = model.fields.filter(_.id != field.id)) + } + + val updatedProject: Project = project.copy(relations = project.relations.filter(_.id != relation.id), + models = project.models.filter(model => !updatedModels.map(_.id).contains(model.id)) ++ updatedModels) + + override def prepareActions(): List[Mutaction] = { + + actions = relationFields.map { field => + DeleteField(project = project, model = project.getModelByFieldId_!(field.id), field = field, allowDeleteRelationField = true) + } ++ + List( + DeleteRelation(relation, project, clientDbQueries), + DeleteRelationTable(project = project, relation = relation), + BumpProjectRevision(project = project), + InvalidateSchema(project = project) + ) + + actions + } + + override def getReturnValue: Option[DeleteRelationMutationPayload] = { + Some(DeleteRelationMutationPayload(clientMutationId = args.clientMutationId, project = updatedProject, relation = relation)) + } +} + +case class DeleteRelationMutationPayload(clientMutationId: Option[String], project: models.Project, relation: models.Relation) extends Mutation + +case class DeleteRelationInput(clientMutationId: Option[String], relationId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationPermissionMutation.scala new file mode 100644 index 0000000000..af0cd938d6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRelationPermissionMutation.scala @@ -0,0 +1,54 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteRelationPermission, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteRelationPermissionMutation(client: Client, + project: Project, + relation: Relation, + relationPermission: RelationPermission, + args: DeleteRelationPermissionInput, + projectDbsFn: Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteRelationPermissionMutationPayload] { + + val newRelation: Relation = relation.copy(permissions = relation.permissions.filter(_.id != relationPermission.id)) + + val updatedProject: Project = project.copy(relations = project.relations.map { + case r if r.id == newRelation.id => newRelation + case r => r + }) + + override def prepareActions(): List[Mutaction] = { + + actions :+= DeleteRelationPermission(project, relation = relation, permission = relationPermission) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[DeleteRelationPermissionMutationPayload] = { + + Some( + DeleteRelationPermissionMutationPayload( + clientMutationId = args.clientMutationId, + relation = newRelation, + relationPermission = relationPermission, + project = updatedProject + )) + } +} + +case class DeleteRelationPermissionMutationPayload(clientMutationId: Option[String], + relation: Relation, + relationPermission: RelationPermission, + project: Project) + extends Mutation + +case class DeleteRelationPermissionInput(clientMutationId: Option[String], relationPermissionId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRootTokenMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRootTokenMutation.scala new file mode 100644 index 0000000000..5556dd4cc2 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/DeleteRootTokenMutation.scala @@ -0,0 +1,42 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteRootToken, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class DeleteRootTokenMutation(client: Client, + project: Project, + rootToken: RootToken, + args: DeleteRootTokenInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[DeleteRootTokenMutationPayload] { + + val updatedProject: Project = project.copy(rootTokens = project.rootTokens.filter(_.id != args.rootTokenId)) + + override def prepareActions(): List[Mutaction] = { + + actions = List( + DeleteRootToken(rootToken = rootToken), + BumpProjectRevision(project = project), + InvalidateSchema(project = project) + ) + actions + } + + override def getReturnValue: Option[DeleteRootTokenMutationPayload] = { + Some( + DeleteRootTokenMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + rootToken = rootToken + )) + } +} + +case class DeleteRootTokenMutationPayload(clientMutationId: Option[String], project: models.Project, rootToken: models.RootToken) extends Mutation + +case class DeleteRootTokenInput(clientMutationId: Option[String], rootTokenId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EjectProjectMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EjectProjectMutation.scala new file mode 100644 index 0000000000..32eb9ca295 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EjectProjectMutation.scala @@ -0,0 +1,33 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models.Project +import cool.graph.system.mutactions.internal.{BumpProjectRevision, EjectProject, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class EjectProjectMutation( + projectDbsFn: (Project) => InternalAndProjectDbs, + project: Project, + args: EjectProjectInput +)(implicit val inj: Injector) + extends InternalProjectMutation[EjectProjectMutationPayload] { + + override def prepareActions(): List[Mutaction] = { + val mutactions = List(EjectProject(project), InvalidateSchema(project), BumpProjectRevision(project)) + actions = actions ++ mutactions + actions + } + + override def getReturnValue: Option[EjectProjectMutationPayload] = + Some( + EjectProjectMutationPayload( + clientMutationId = args.clientMutationId, + project = project.copy(isEjected = true) + )) +} + +case class EjectProjectMutationPayload(clientMutationId: Option[String], project: Project) extends Mutation + +case class EjectProjectInput(clientMutationId: Option[String], projectId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EnableAuthProviderMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EnableAuthProviderMutation.scala new file mode 100644 index 0000000000..3a6e3b4bda --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/EnableAuthProviderMutation.scala @@ -0,0 +1,169 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.IntegrationName.IntegrationName +import cool.graph.shared.models.ManagedFields.ManagedField +import cool.graph.shared.models._ +import cool.graph.system.database.client.EmptyClientDbQueries +import cool.graph.system.mutactions.client.CreateColumn +import cool.graph.system.mutactions.internal._ +import sangria.relay.Mutation +import scaldi.Injector + +case class EnableAuthProviderMutation( + client: models.Client, + project: models.Project, + args: EnableAuthProviderInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[EnableAuthProviderPayload] { + + var integrationName: IntegrationName = project.getAuthProviderById_!(args.id).name + + override def prepareActions(): List[Mutaction] = { + + val meta: Option[AuthProviderMetaInformation] = integrationName match { + case IntegrationName.AuthProviderDigits if args.digitsConsumerKey.isDefined => + Some( + AuthProviderDigits( + id = Cuid.createCuid(), + consumerKey = args.digitsConsumerKey.get, + consumerSecret = args.digitsConsumerSecret.get + )) + case IntegrationName.AuthProviderAuth0 if args.auth0ClientId.isDefined => + Some( + models.AuthProviderAuth0( + id = Cuid.createCuid(), + clientId = args.auth0ClientId.get, + clientSecret = args.auth0ClientSecret.get, + domain = args.auth0Domain.get + )) + case _ => None + } + + actions ++= EnableAuthProviderMutation.getUpdateMutactions( + client = client, + project = project, + integrationName = integrationName, + metaInformation = meta, + isEnabled = args.isEnabled + ) + + actions + } + + override def getReturnValue: Option[EnableAuthProviderPayload] = { + Some(EnableAuthProviderPayload(clientMutationId = args.clientMutationId, project = project, authProvider = integrationName)) + } + +} + +object EnableAuthProviderMutation { + def getUpdateMutactions( + client: Client, + project: Project, + integrationName: IntegrationName.IntegrationName, + metaInformation: Option[AuthProviderMetaInformation], + isEnabled: Boolean + )(implicit inj: Injector): List[Mutaction] = { + + val managedFields = ManagedFields(integrationName) + + project.getModelByName("User") match { + case Some(user) => + val existingAuthProvider = + project.authProviders.find(_.name == integrationName).get + + def createManagedFields: List[Mutaction] = { + managedFields.flatMap(createFieldMutactions(_, userModel = user, client, project)) + } + + val newMeta = metaInformation match { + case Some(y) => metaInformation + case None => existingAuthProvider.metaInformation + } + + val updateAuthProvider = UpdateAuthProvider( + project = project, + authProvider = existingAuthProvider.copy(isEnabled = isEnabled), + metaInformation = newMeta, + oldMetaInformationId = existingAuthProvider.metaInformation.map(_.id) + ) + + val fieldActions = (existingAuthProvider.isEnabled, isEnabled) match { + case (true, false) => getMakeFieldsUnmanagedMutactions(project, managedFields) + case (false, true) => createManagedFields + case _ => List() + } + + fieldActions ++ List(updateAuthProvider, BumpProjectRevision(project = project), InvalidateSchema(project)) + + case None => + List() + } + } + + private def createFieldMutactions( + managedField: ManagedField, + userModel: Model, + client: Client, + project: Project + )(implicit inj: Injector) = { + val field = Field( + id = Cuid.createCuid(), + name = managedField.defaultName, + typeIdentifier = managedField.typeIdentifier, + description = managedField.description, + isRequired = false, + isList = false, + isUnique = managedField.isUnique, + isSystem = true, + isReadonly = managedField.isReadonly, + defaultValue = None, + relation = None, + relationSide = None + ) + + List( + CreateColumn(projectId = project.id, model = userModel, field = field), + CreateField(project = project, model = userModel, field = field, migrationValue = None, clientDbQueries = EmptyClientDbQueries) + ) + } + + private def getMakeFieldsUnmanagedMutactions( + project: Project, + managedFields: List[ManagedField] + )(implicit inj: Injector): List[Mutaction] = { + // We no longer remove managed fields + // Instead we change them to be non-managed + project.getModelByName("User") match { + case Some(user) => + managedFields.flatMap(managedField => { + user + .getFieldByName(managedField.defaultName) + .map(field => { + val updatedField = field.copy(isSystem = false, isReadonly = false) + List(UpdateField(user, field, updatedField, None, clientDbQueries = EmptyClientDbQueries)) + }) + .getOrElse(List()) + + }) + case None => List() + } + + } +} + +case class EnableAuthProviderPayload(clientMutationId: Option[String], project: models.Project, authProvider: IntegrationName) extends Mutation + +case class EnableAuthProviderInput(clientMutationId: Option[String], + id: String, + isEnabled: Boolean, + digitsConsumerKey: Option[String], + digitsConsumerSecret: Option[String], + auth0Domain: Option[String], + auth0ClientId: Option[String], + auth0ClientSecret: Option[String]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ExportDataMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ExportDataMutation.scala new file mode 100644 index 0000000000..dfd2ac5ab0 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ExportDataMutation.scala @@ -0,0 +1,45 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.Project +import cool.graph.system.mutactions.internal.ExportData +import sangria.relay.Mutation +import scaldi.Injector + +case class ExportDataMutation( + client: models.Client, + project: models.Project, + args: ExportDataInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + dataResolver: DataResolver +)(implicit inj: Injector) + extends InternalProjectMutation[ExportDataMutationPayload] { + + var url: String = "" + + override def prepareActions(): List[Mutaction] = { + + val exportData = ExportData(project, dataResolver) + + url = exportData.getUrl + + actions :+= exportData + + actions + } + + override def getReturnValue: Option[ExportDataMutationPayload] = { + Some( + ExportDataMutationPayload( + clientMutationId = args.clientMutationId, + project = project, + url = url + )) + } +} + +case class ExportDataMutationPayload(clientMutationId: Option[String], project: Project, url: String) extends Mutation +case class ExportDataInput(clientMutationId: Option[String], projectId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/GenerateUserToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/GenerateUserToken.scala new file mode 100644 index 0000000000..775d142feb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/GenerateUserToken.scala @@ -0,0 +1,53 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors.InvalidPatForProject +import cool.graph.shared.models.Project +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.authorization.SystemAuth2 +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class GenerateUserToken(project: Project, args: GenerateUserTokenInput, projectDbsFn: Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[GenerateUserTokenPayload] + with Injectable { + + val auth = SystemAuth2() + var token: Option[String] = None + + override def prepareActions(): List[Mutaction] = { + + // This is unconventional. Most system mutations rely on the caller being authenticated in system api + // This mutation is freely available but requires you to include a valid pat for the project + if (!isActiveRootToken && !isValidTemporaryRootToken && !isValidPlatformToken) { + actions :+= InvalidInput(InvalidPatForProject(project.id)) + } else { + token = Some(auth.generateNodeToken(project, args.userId, args.modelName, args.expirationInSeconds)) + } + + actions + } + + private def isActiveRootToken = project.rootTokens.exists(_.token == args.pat) + private def isValidTemporaryRootToken = auth.isValidTemporaryRootToken(project, args.pat) + private def isValidPlatformToken = { + auth.clientId(args.pat) match { + case Some(clientId) => project.seats.exists(_.clientId == Some(clientId)) + case None => false + } + } + + override def getReturnValue: Option[GenerateUserTokenPayload] = { + token.map(token => GenerateUserTokenPayload(clientMutationId = args.clientMutationId, token = token)) + } +} + +case class GenerateUserTokenPayload(clientMutationId: Option[String], token: String) extends Mutation + +case class GenerateUserTokenInput(clientMutationId: Option[String], + pat: String, + projectId: String, + userId: String, + modelName: String, + expirationInSeconds: Option[Int]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InstallPackageMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InstallPackageMutation.scala new file mode 100644 index 0000000000..b6235e73bc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InstallPackageMutation.scala @@ -0,0 +1,87 @@ +package cool.graph.system.mutations + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.cuid.Cuid +import cool.graph.deprecated.packageMocks.PackageParser +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.Field +import cool.graph.system.database.client.EmptyClientDbQueries +import cool.graph.system.mutactions.client.CreateColumn +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class InstallPackageMutation( + client: models.Client, + project: models.Project, + args: InstallPackageInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[InstallPackageMutationPayload] { + + var newPackage: Option[models.PackageDefinition] = None + + override def prepareActions(): List[Mutaction] = { + val parsed = PackageParser.parse(args.definition) + + newPackage = Some(models.PackageDefinition(id = Cuid.createCuid(), name = parsed.name, definition = args.definition, formatVersion = 1)) + + val addPackage = CreatePackageDefinition(project, newPackage.get, internalDatabase = internalDatabase.databaseDef) + val newPat = CreateRootTokenMutation.generate(clientId = client.id, projectId = project.id, name = newPackage.get.name, expirationInSeconds = None) + + val addPat = project.getRootTokenByName(newPackage.get.name) match { + case None => List(CreateRootToken(project.id, newPat)) + case _ => List() + } + + val addFields = PackageParser + .install(parsed, project.copy(rootTokens = project.rootTokens :+ newPat)) + .interfaces + .flatMap(i => { + i.fields.flatMap(f => { + // todo: this check should be more selective + if (i.model.fields.exists(_.name == f.name)) { + //sys.error("Cannot install interface on type that already has field with same name") + List() + } else { + val newField = Field( + id = Cuid.createCuid(), + name = f.name, + typeIdentifier = f.typeIdentifier, + description = Some(f.description), + isReadonly = false, + isRequired = f.isRequired, + isList = f.isList, + isUnique = f.isUnique, + isSystem = false, + defaultValue = f.defaultValue.map(GCStringConverter(f.typeIdentifier, f.isList).toGCValue(_).get) + ) + + List( + CreateColumn(projectId = project.id, model = i.model, field = newField), + CreateField(project = project, model = i.model, field = newField, migrationValue = f.defaultValue, EmptyClientDbQueries) + ) + } + }) + }) + + actions = List(addPackage, BumpProjectRevision(project = project), InvalidateSchema(project)) ++ addPat ++ addFields + actions + } + + override def getReturnValue: Option[InstallPackageMutationPayload] = { + Some( + InstallPackageMutationPayload( + clientMutationId = args.clientMutationId, + project = project.copy(packageDefinitions = project.packageDefinitions :+ newPackage.get), + packageDefinition = newPackage.get + )) + } +} + +case class InstallPackageMutationPayload(clientMutationId: Option[String], project: models.Project, packageDefinition: models.PackageDefinition) + extends Mutation + +case class InstallPackageInput(clientMutationId: Option[String], projectId: String, definition: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InviteCollaboratorMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InviteCollaboratorMutation.scala new file mode 100644 index 0000000000..fbc3056160 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/InviteCollaboratorMutation.scala @@ -0,0 +1,64 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.CollaboratorProjectWithNameAlreadyExists +import cool.graph.shared.models +import cool.graph.shared.models.{Client, SeatStatus} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.internal.{CreateSeat, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class InviteCollaboratorMutation(client: models.Client, + invitedClient: Option[Client], + project: models.Project, + args: InviteCollaboratorInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[InviteCollaboratorMutationPayload] { + + var newSeat: Option[models.Seat] = None + + // note: this mutation does not bump revision as collaborators are not part of the project structure + + override def prepareActions(): List[Mutaction] = { + + actions = invitedClient match { + case None => + newSeat = Some( + models.Seat(id = Cuid.createCuid(), + name = None, + status = SeatStatus.INVITED_TO_PROJECT, + isOwner = false, + email = args.email, + clientId = invitedClient.map(_.id))) + val addSeat = CreateSeat(client, project, newSeat.get, internalDatabase = internalDatabase.databaseDef) + + List(addSeat, InvalidateSchema(project = project)) + + case Some(invitedClient) if invitedClient.projects.map(_.name).contains(project.name) => + List(InvalidInput(error = CollaboratorProjectWithNameAlreadyExists(name = project.name))) + case Some(invitedClient) => + newSeat = Some( + models.Seat(id = Cuid.createCuid(), name = None, status = SeatStatus.JOINED, isOwner = false, email = args.email, clientId = Some(invitedClient.id))) + + val addSeat = CreateSeat(client, project, newSeat.get, internalDatabase = internalDatabase.databaseDef) + + List(addSeat, InvalidateSchema(project = project)) + } + + actions + } + + override def getReturnValue: Option[InviteCollaboratorMutationPayload] = { + Some( + InviteCollaboratorMutationPayload(clientMutationId = args.clientMutationId, + project = project.copy(seats = project.seats :+ newSeat.get), + seat = newSeat.get)) + } +} + +case class InviteCollaboratorMutationPayload(clientMutationId: Option[String], project: models.Project, seat: models.Seat) extends Mutation + +case class InviteCollaboratorInput(clientMutationId: Option[String], projectId: String, email: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateEnumValuesMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateEnumValuesMutation.scala new file mode 100644 index 0000000000..55494a5bee --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateEnumValuesMutation.scala @@ -0,0 +1,126 @@ +package cool.graph.system.mutations + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph._ +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.{OverwriteAllRowsForColumn, OverwriteInvalidEnumForColumnWithMigrationValue} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class MigrateEnumValuesMutation( + client: Client, + project: Project, + args: MigrateEnumValuesInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[MigrateEnumValuesMutationPayload] + with Injectable { + + val oldEnum: Enum = args.oldEnum + val updatedEnum: Enum = args.updatedEnum + val enumFields: List[Field] = project.allFields.filter(_.enum.contains(oldEnum)).toList + val removedEnumValues: List[String] = oldEnum.values.toList.filter(!updatedEnum.values.toList.contains(_)) + + def migrationValueIsList(value: String): Boolean = { + (value.startsWith("[") && value.endsWith("]")) || (value.startsWith("\"[") && value.endsWith("]\"")) + } + + def checkEnumValueUsageOnNodes(field: Field): List[InvalidInput] = { + val model = project.getModelByFieldId_!(field.id) + + field.isList match { + case true => + List( + InvalidInput(error = UserInputErrors.CantRemoveEnumValueWhenNodesExist(model.name, field.name), isInvalid = clientDbQueries.existsByModel(model)) + ) + case false => + List( + InvalidInput( + UserInputErrors.EnumValueInUse(), + isInvalid = Future + .sequence(removedEnumValues.map(enum => clientDbQueries.itemCountForFieldValue(model, field, enum))) + .map(_.exists(_ > 0)) + )) + } + } + + def changeEnumValuesInDB(field: Field): List[Mutaction with Product with Serializable] = { + val model = project.getModelByFieldId_!(field.id) + + field.isList match { + case true => + List( + OverwriteAllRowsForColumn( + projectId = project.id, + model = model, + field = field, + value = CustomScalarTypes.parseValueFromString(args.migrationValue.get, field.typeIdentifier, field.isList) + ) + ) + case false => + removedEnumValues.map { removedEnum => + OverwriteInvalidEnumForColumnWithMigrationValue( + project.id, + model = model, + field = field, + oldValue = removedEnum, + migrationValue = args.migrationValue.get + ) + } + } + } + + def validateOnFieldLevel(field: Field): List[Mutaction] = { + if (removedEnumValues.isEmpty) { + List.empty + } else { + (field.defaultValue, args.migrationValue) match { + case (Some(dV), _) if !updatedEnum.values.contains(GCStringConverter(field.typeIdentifier, field.isList).fromGCValue(dV)) => + List(InvalidInput(UserInputErrors.EnumValueUsedAsDefaultValue(GCStringConverter(field.typeIdentifier, field.isList).fromGCValue(dV), field.name))) + + case (_, Some(_)) => + changeEnumValuesInDB(field) + + case (_, None) => + checkEnumValueUsageOnNodes(field) + } + } + } + + override def prepareActions(): List[Mutaction] = { + args.migrationValue match { + case Some(migrationValue) => + enumFields.find(_.isList != migrationValueIsList(migrationValue)) match { + case Some(invalidField) => + List( + InvalidInput( + UserInputErrors + .InvalidMigrationValueForEnum(project.getModelByFieldId_!(invalidField.id).name, invalidField.name, migrationValue))) + + case None => + enumFields.flatMap(validateOnFieldLevel) + } + + case None => + enumFields.flatMap(validateOnFieldLevel) + } + } + + override def getReturnValue: Option[MigrateEnumValuesMutationPayload] = { + Some(MigrateEnumValuesMutationPayload(clientMutationId = args.clientMutationId, enum = updatedEnum, project = project)) + } +} + +case class MigrateEnumValuesMutationPayload(clientMutationId: Option[String], enum: Enum, project: models.Project) extends Mutation + +case class MigrateEnumValuesInput(clientMutationId: Option[String], oldEnum: Enum, updatedEnum: Enum, migrationValue: Option[String]) extends MutationInput diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateSchemaMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateSchemaMutation.scala new file mode 100644 index 0000000000..c4cf6cedf7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MigrateSchemaMutation.scala @@ -0,0 +1,124 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors.{SchemaError, SystemApiError, WithSchemaError} +import cool.graph.shared.models +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.metrics.SystemMetrics +import cool.graph.system.migration.dataSchema._ +import cool.graph.system.migration.dataSchema.SchemaFileHeader +import cool.graph.system.migration.dataSchema.validation.{SchemaErrors, SchemaValidator} +import cool.graph.system.mutactions.internal.UpdateTypeAndFieldPositions +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.collection.Seq +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class MigrateSchemaMutation(client: models.Client, + project: models.Project, + args: MigrateSchemaInput, + schemaFileHeader: SchemaFileHeader, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries)(implicit inj: Injector) + extends InternalProjectMutation[MigrateSchemaMutationPayload] + with Injectable { + import scala.concurrent.ExecutionContext.Implicits.global + + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + var verbalDescriptions: Seq[VerbalDescription] = Seq.empty + var errors: Seq[SchemaError] = Seq.empty + + override def prepareActions(): List[Mutaction] = { + errors = SchemaValidator(project, args.newSchema, schemaFileHeader).validate() + if (errors.nonEmpty) { + return List.empty + } + + val migrator = SchemaMigrator(project, args.newSchema, args.clientMutationId) + val actions: UpdateSchemaActions = migrator.determineActionsForUpdate + + verbalDescriptions = actions.verbalDescriptions + + if (actions.isDestructive && !args.force) { + errors = Seq[SchemaError](SchemaErrors.forceArgumentRequired) + + return List.empty + } + + val (mutations, _) = actions.determineMutations(client, project, _ => InternalAndProjectDbs(internalDatabase), clientDbQueries) + + // UPDATE PROJECT + val updateTypeAndFieldPositions = UpdateTypeAndFieldPositions( + project = project, + client = client, + newSchema = migrator.diffResult.newSchema, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries + ) + + this.actions = mutations.toList.flatMap(_.prepareActions()) ++ List(updateTypeAndFieldPositions) + + MigrateSchemaMutation.migrateSchemaCount.incBy(1) + MigrateSchemaMutation.migrateSchemaMutactionsCount.incBy(this.actions.length) + + this.actions + } + + override def verifyActions(): Future[List[Try[MutactionVerificationSuccess]]] = { + super.verifyActions().map { verifications => + verifications.map { + case Failure(sysError: WithSchemaError) => + val fallbackError = SchemaError.global(sysError.getMessage) + val schemaError = sysError.schemaError.getOrElse(fallbackError) + errors = errors :+ schemaError + verbalDescriptions = List.empty + this.actions = List.empty + Success(MutactionVerificationSuccess()) + + case verification => + verification + } + } + } + + override def performActions(requestContext: Option[SystemRequestContextTrait]): Future[List[MutactionExecutionResult]] = { + if (args.isDryRun) { + Future.successful(List(MutactionExecutionSuccess())) + } else { + super.performActions(requestContext) + } + } + + override def getReturnValue(): Option[MigrateSchemaMutationPayload] = { + Some( + MigrateSchemaMutationPayload( + clientMutationId = args.clientMutationId, + client = client, + project = project, + verbalDescriptions = verbalDescriptions, + errors = errors + ) + ) + } +} + +object MigrateSchemaMutation { + + val migrateSchemaMutactionsCount = SystemMetrics.defineCounter("migrateSchemaMutactionsCount") + val migrateSchemaCount = SystemMetrics.defineCounter("migrateSchemaCount") + +} + +case class MigrateSchemaMutationPayload(clientMutationId: Option[String], + client: models.Client, + project: models.Project, + verbalDescriptions: Seq[VerbalDescription], + errors: Seq[SchemaError]) + extends Mutation + +case class MigrateSchemaInput(clientMutationId: Option[String], newSchema: String, isDryRun: Boolean, force: Boolean) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MutationInput.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MutationInput.scala new file mode 100644 index 0000000000..0fbca8bc21 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/MutationInput.scala @@ -0,0 +1,24 @@ +package cool.graph.system.mutations + +trait MutationInput { this: Product => + import shapeless._ + import syntax.typeable._ + + def clientMutationId: Option[String] + + def isAnyArgumentSet(exclude: List[String] = List()): Boolean = { + getCaseClassParams(this) + .filter(x => !(exclude :+ "clientMutationId").contains(x._1)) + .map(_._2) + .map(_.cast[Option[Any]]) + .collect { + case Some(x: Option[Any]) => x.isDefined + } exists identity + } + + private def getCaseClassParams(cc: AnyRef): Seq[(String, Any)] = + (Seq[(String, Any)]() /: cc.getClass.getDeclaredFields) { (a, f) => + f.setAccessible(true) + a :+ (f.getName, f.get(cc)) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/PushMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/PushMutation.scala new file mode 100644 index 0000000000..2427d54ecf --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/PushMutation.scala @@ -0,0 +1,333 @@ +package cool.graph.system.mutations + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.metrics.CounterMetric +import cool.graph.shared.database.{InternalAndProjectDbs, InternalDatabase} +import cool.graph.shared.errors.SystemErrors.{SchemaError, WithSchemaError} +import cool.graph.shared.functions.ExternalFile +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.metrics.SystemMetrics +import cool.graph.system.migration.ProjectConfig.Ast +import cool.graph.system.migration.dataSchema._ +import cool.graph.system.migration.dataSchema.validation.{SchemaErrors, SchemaValidator} +import cool.graph.system.migration.project.{ClientInterchange, ClientInterchangeFormatModule} +import cool.graph.system.migration.{ModuleActions, ModuleMigrator, ProjectConfig} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateProject, UpdateTypeAndFieldPositions} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.collection.{Seq, immutable} +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class PushMutation( + client: Client, + project: Project, + args: PushInput, + dataResolver: DataResolver, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[PushMutationPayload] + with Injectable { + import scala.concurrent.ExecutionContext.Implicits.global + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + var verbalDescriptions: Seq[VerbalDescription] = Seq.empty + var errors: Seq[SchemaError] = Seq.empty + + override def prepareActions(): List[Mutaction] = { + PushMutation.pushMutationCount.incBy(1) + + project.isEjected match { + case false => + errors = List( + SchemaError( + "Global", + "Only projects that have been ejected can make use of the CLI's deploy function. More details: https://docs-next.graph.cool/reference/service-definition/legacy-console-projects-aemieb1aev" + )) + actions + + case true => + DeployMutactions.generate(args.config, args.force, args.isDryRun, client, project, internalDatabase, clientDbQueries, projectQueries) match { + case Success(result) => + actions = result.mutactions.toList + verbalDescriptions = result.verbalDescriptions + errors = result.errors + PushMutation.pushMutationMutactionsCount.incBy(this.actions.length) + + case Failure(error) => + actions = List.empty + verbalDescriptions = List.empty + errors = List(extractErrors(error)) + } + actions + } + } + + override def verifyActions(): Future[List[Try[MutactionVerificationSuccess]]] = { + super.verifyActions().map { verifications => + verifications.map { + case Failure(error: Throwable) => + errors = errors :+ extractErrors(error) + verbalDescriptions = List.empty + actions = List.empty + Success(MutactionVerificationSuccess()) + + case verification => + verification + } + } + } + + def extractErrors(exc: Throwable): SchemaError = exc match { + case sysError: WithSchemaError => + val fallbackError = SchemaError.global(sysError.getMessage) + sysError.schemaError.getOrElse(fallbackError) + case e: Throwable => + SchemaError.global(e.getMessage) + } + + override def performActions(requestContext: Option[SystemRequestContextTrait]): Future[List[MutactionExecutionResult]] = { + if (args.isDryRun) { + Future.successful(List(MutactionExecutionSuccess())) + } else { + super.performActions(requestContext) + } + } + + override def getReturnValue: Option[PushMutationPayload] = { + Some( + PushMutationPayload( + clientMutationId = args.clientMutationId, + client = client, //.copy(projects = client.projects :+ newProject), + project = project, + verbalDescriptions = verbalDescriptions, + errors = errors + ) + ) + } +} + +object PushMutation { + + val pushMutationMutactionsCount: CounterMetric = SystemMetrics.defineCounter("pushMutationMutactionsCount") + val pushMutationCount: CounterMetric = SystemMetrics.defineCounter("pushMutationCount") + +} + +object DeployMutactions { + + case class DeployResult(mutactions: Vector[Mutaction], verbalDescriptions: Vector[VerbalDescription], errors: Vector[SchemaError]) + + def generate(config: String, + force: Boolean, + isDryRun: Boolean, + client: Client, + project: Project, + internalDatabase: InternalDatabase, + clientDbQueries: ClientDbQueries, + projectQueries: ProjectQueries)(implicit inj: Injector, system: ActorSystem, materializer: ActorMaterializer): Try[DeployResult] = Try { + var verbalDescriptions: Vector[VerbalDescription] = Vector.empty + var errors: Vector[SchemaError] = Vector.empty + var mutactions: Vector[Mutaction] = Vector.empty[Mutaction] + var currentProject: Option[Project] = None + + val (combinedFileMap: Map[String, String], externalFilesMap: Option[Map[String, ExternalFile]], combinedParsedModules: Seq[Ast.Module]) = + combineAllModulesIntoOne(config) + + val moduleMigratorBeforeSchemaChanges: ModuleMigrator = + ModuleMigrator(client, project, combinedParsedModules, combinedFileMap, externalFilesMap, isDryRun = isDryRun) + val combinedSchema: String = moduleMigratorBeforeSchemaChanges.schemaContent + + val schemaFileHeader: SchemaFileHeader = SchemaFileHeader(projectId = project.id, version = project.revision) + + def getProject = currentProject.getOrElse(project) + + def runMigrator(function: => ModuleActions) = { + val moduleActions = function + val (mutations, cProject) = moduleActions.determineMutations(client, getProject, _ => InternalAndProjectDbs(internalDatabase)) + verbalDescriptions ++= moduleActions.verbalDescriptions + mutactions ++= mutations.toList.flatMap(_.prepareActions()) + currentProject = Some(cProject) + } + + //Delete Permissions, Functions and RootTokens + runMigrator(moduleMigratorBeforeSchemaChanges.determineActionsForRemove) + + // Update SCHEMA + errors ++= SchemaValidator(getProject, combinedSchema, schemaFileHeader).validate() + if (errors.nonEmpty) { + return Success(DeployResult(mutactions = Vector.empty, verbalDescriptions = Vector.empty, errors = errors)) + } + + val schemaMigrator = SchemaMigrator(getProject, combinedSchema, None) + val actions: UpdateSchemaActions = schemaMigrator.determineActionsForUpdate() + + verbalDescriptions ++= actions.verbalDescriptions + + if (actions.isDestructive && !force && !isDryRun) { + return Success( + DeployResult(mutactions = Vector.empty, verbalDescriptions = Vector.empty, errors = Vector[SchemaError](SchemaErrors.forceArgumentRequired))) + } + + val (mutations, cProject) = actions.determineMutations(client, getProject, _ => InternalAndProjectDbs(internalDatabase), clientDbQueries) + + currentProject = Some(cProject) + + // UPDATE PROJECT + val updateTypeAndFieldPositions = UpdateTypeAndFieldPositions( + project = getProject, + client = client, + newSchema = schemaMigrator.diffResult.newSchema, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries + ) + + mutactions ++= mutations.toVector.flatMap(_.prepareActions()) ++ Vector(updateTypeAndFieldPositions) + + // Add Functions, Permissions and RootTokens + val moduleMigratorAfterSchemaChanges = + ModuleMigrator(client, getProject, combinedParsedModules, combinedFileMap, externalFilesMap, isDryRun = isDryRun, afterSchemaMigration = true) + + runMigrator(moduleMigratorAfterSchemaChanges.determineActionsForAdd) + + //Update Functions + runMigrator(moduleMigratorAfterSchemaChanges.determineActionsForUpdate) + + if (errors.isEmpty) { + val shouldBump = mutactions.exists(_.isInstanceOf[BumpProjectRevision]) + val setsGlobalStarPermission = moduleMigratorAfterSchemaChanges.permissionDiff.containsGlobalStarPermission + val hasChanged = project.hasGlobalStarPermission != setsGlobalStarPermission + val setGlobalStarPermissionMutaction = UpdateProject( + client = client, + oldProject = project, + project = getProject.copy(hasGlobalStarPermission = setsGlobalStarPermission), + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries, + bumpRevision = shouldBump + ) + mutactions ++= Vector(setGlobalStarPermissionMutaction) + if (hasChanged) { + if (setsGlobalStarPermission) { + verbalDescriptions ++= Vector( + VerbalDescription( + `type` = "permission", + action = "Create", + name = "Wildcard Permission", + description = s"The wildcard permission for all types is added." + )) + } else { + verbalDescriptions ++= Vector( + VerbalDescription( + `type` = "permission", + action = "Delete", + name = "Wildcard Permission", + description = s"The wildcard permission for all types is removed." + )) + } + } + } + + val shouldBump = mutactions.exists(_.isInstanceOf[BumpProjectRevision]) + val shouldInvalidate = mutactions.exists(_.isInstanceOf[InvalidateSchema]) + val finalProject = currentProject.getOrElse(project) + val filteredMutactions = mutactions.filter(mutaction => !mutaction.isInstanceOf[BumpProjectRevision] && !mutaction.isInstanceOf[InvalidateSchema]) + + val invalidateSchemaAndBumpRevisionIfNecessary = + (errors.isEmpty, shouldBump, shouldInvalidate) match { + case (false, _, _) => List.empty + case (true, false, false) => List.empty + case (true, true, true) => List(BumpProjectRevision(finalProject), InvalidateSchema(finalProject)) + case (true, true, false) => List(BumpProjectRevision(finalProject)) + case (true, false, true) => List(InvalidateSchema(finalProject)) + } + val finalMutactions = filteredMutactions ++ invalidateSchemaAndBumpRevisionIfNecessary + + DeployResult(mutactions = finalMutactions, verbalDescriptions = verbalDescriptions, errors = errors) + } + + private def combineAllModulesIntoOne(config: String): (Map[String, String], Option[Map[String, ExternalFile]], Seq[Ast.Module]) = { + val modules: immutable.Seq[ClientInterchangeFormatModule] = ClientInterchange.parse(config).modules + val rootModule: ClientInterchangeFormatModule = modules.find(_.name == "").getOrElse(throw sys.error("There needs to be a root module with name \"\" ")) + val parsedRootModule: Ast.Module = ProjectConfig.parse(rootModule.content) + + val (prependedParsedNonRootModules, prependedNonRootModulesFiles) = parsedRootModule.modules match { + case Some(modulesMap) => + val nonRootModules: immutable.Seq[ClientInterchangeFormatModule] = modules.filter(_.name != "") + val parsedModuleAndFilesTuplesList = nonRootModules.map { module => + val pathFromRoot = createPathFromRoot(modulesMap, module) + + val parsedModule: Ast.Module = ProjectConfig.parse(module.content) + + val prependedTypes = parsedModule.types.map(path => pathFromRoot + path.drop(1)) + + val prependedPermissions: Seq[Ast.Permission] = + parsedModule.permissions.map(permission => permission.copy(queryPath = permission.queryPath.map(path => pathFromRoot + path.drop(1)))) + + val prependedSchemaPathFunctions: Map[String, Ast.Function] = parsedModule.functions.map { + case (x, function) => (x, function.copy(schema = function.schema.map(path => pathFromRoot + path.drop(1)))) + } + + val prependedQueryAndSchemaPathFunctions = prependedSchemaPathFunctions.map { + case (x, function) => (x, function.copy(query = function.query.map(path => pathFromRoot + path.drop(1)))) + } + + val prependedCodeAndQueryAndSchemaPathFunctions = prependedQueryAndSchemaPathFunctions.map { + case (x, function) => + (x, function.copy(handler = function.handler.copy(code = function.handler.code.map(code => code.copy(src = pathFromRoot + code.src.drop(1)))))) + } + + val prependedAndParsedModule: Ast.Module = + parsedModule.copy( + types = prependedTypes, + permissions = prependedPermissions.toVector, + functions = prependedCodeAndQueryAndSchemaPathFunctions, + rootTokens = parsedModule.rootTokens + ) + + val prependedFile: Map[String, String] = module.files.map { case (key, value) => (pathFromRoot ++ key.drop(1), value) } + + (prependedAndParsedModule, prependedFile) + } + + parsedModuleAndFilesTuplesList.unzip + + case None => + (Seq.empty, Seq.empty) + } + + val combinedFileMap: Map[String, String] = prependedNonRootModulesFiles.foldLeft(rootModule.files)(_ ++ _) + val combinedParsedModules = parsedRootModule +: prependedParsedNonRootModules + + val externalFilesMap = modules.headOption.flatMap(module => { + module.externalFiles + }) + + (combinedFileMap, externalFilesMap, combinedParsedModules) + } + + private def createPathFromRoot(modulesMap: Map[String, String], module: ClientInterchangeFormatModule) = { + val modulepath = modulesMap(module.name) + val lastSlash = modulepath.lastIndexOf("/") + modulepath.slice(0, lastSlash) + } +} + +case class PushMutationPayload(clientMutationId: Option[String], + client: models.Client, + project: models.Project, + verbalDescriptions: Seq[VerbalDescription], + errors: Seq[SchemaError]) + extends Mutation + +case class PushInput(clientMutationId: Option[String], config: String, projectId: String, version: Int, isDryRun: Boolean, force: Boolean) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/RemoveCollaboratorMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/RemoveCollaboratorMutation.scala new file mode 100644 index 0000000000..f3464290cf --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/RemoveCollaboratorMutation.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.internal.{DeleteSeat, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class RemoveCollaboratorMutation(client: models.Client, + project: models.Project, + seat: models.Seat, + args: RemoveCollaboratorInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[RemoveCollaboratorMutationPayload] { + + // note: this mutation does not bump revision as collaborators are not part of the project structure + + override def prepareActions(): List[Mutaction] = { + val deleteSeat = DeleteSeat(client, project = project, seat = seat, internalDatabase.databaseDef) + val invalidateSchema = InvalidateSchema(project = project) + actions = List(deleteSeat, invalidateSchema) + actions + } + + override def getReturnValue: Option[RemoveCollaboratorMutationPayload] = { + Some( + RemoveCollaboratorMutationPayload(clientMutationId = args.clientMutationId, + project = project.copy(seats = project.seats.filter(_.id != seat.id)), + seat = seat)) + } +} + +case class RemoveCollaboratorMutationPayload(clientMutationId: Option[String], project: models.Project, seat: models.Seat) extends Mutation + +case class RemoveCollaboratorInput(clientMutationId: Option[String], projectId: String, email: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetClientPasswordMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetClientPasswordMutation.scala new file mode 100644 index 0000000000..5d288aa9d3 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetClientPasswordMutation.scala @@ -0,0 +1,31 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalDatabase +import cool.graph.shared.models.Client +import cool.graph.system.mutactions.internal.ResetClientPassword +import cool.graph.{InternalMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class ResetClientPasswordMutation( + client: Client, + userToken: String, + args: ResetClientPasswordInput, + internalDatabase: InternalDatabase +)(implicit inj: Injector) + extends InternalMutation[ResetClientPasswordMutationPayload] { + + override def prepareActions(): List[Mutaction] = { + actions :+= ResetClientPassword(client = client, resetPasswordToken = args.resetPasswordToken, newPassword = args.newPassword) + + actions + } + + override def getReturnValue(): Option[ResetClientPasswordMutationPayload] = { + Some(new ResetClientPasswordMutationPayload(clientMutationId = args.clientMutationId, client = client, userToken = userToken)) + } +} + +case class ResetClientPasswordMutationPayload(clientMutationId: Option[String], client: Client, userToken: String) extends Mutation + +case class ResetClientPasswordInput(clientMutationId: Option[String], newPassword: String, resetPasswordToken: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectDataMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectDataMutation.scala new file mode 100644 index 0000000000..c07d7cb322 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectDataMutation.scala @@ -0,0 +1,40 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.mutactions.client.{DeleteAllDataItems, DeleteAllRelations} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class ResetProjectDataMutation( + client: Client, + project: Project, + args: ResetProjectDataInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[ResetProjectDataMutationPayload] { + + override def prepareActions(): List[Mutaction] = { + + val removeRelations = + project.relations.map(relation => DeleteAllRelations(projectId = project.id, relation = relation)) + + actions ++= removeRelations + + val removeDataItems = project.models.map(model => DeleteAllDataItems(projectId = project.id, model = model)) + + actions ++= removeDataItems + + actions + } + + override def getReturnValue: Option[ResetProjectDataMutationPayload] = { + Some(ResetProjectDataMutationPayload(clientMutationId = args.clientMutationId, client = client, project = project)) + } +} + +case class ResetProjectDataMutationPayload(clientMutationId: Option[String], client: models.Client, project: models.Project) extends Mutation + +case class ResetProjectDataInput(clientMutationId: Option[String], projectId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectSchemaMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectSchemaMutation.scala new file mode 100644 index 0000000000..ae32043595 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/ResetProjectSchemaMutation.scala @@ -0,0 +1,79 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.client.DeleteClientDatabaseForProject +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeleteProject, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class ResetProjectSchemaMutation( + client: Client, + project: Project, + args: ResetProjectSchemaInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[ResetProjectSchemaMutationPayload] + with Injectable { + + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + override def prepareActions(): List[Mutaction] = { + + // delete existing tables, data and internal schema + + // note: cascading deletes will delete models, relations etc. and these are not created by CreateProject + actions :+= DeleteProject( + client = client, + project = project, + willBeRecreated = true, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries + ) + + actions :+= DeleteClientDatabaseForProject(project.id) + + val userFields = AuthenticateCustomerMutation.generateUserFields + val userModel = AuthenticateCustomerMutation.generateUserModel.copy(fields = userFields) + + val fileFields = AuthenticateCustomerMutation.generateFileFields + val fileModel = AuthenticateCustomerMutation.generateFileModel.copy(fields = fileFields) + + val resetProject = Project( + id = project.id, + ownerId = client.id, + name = project.name, + alias = project.alias, + seats = project.seats.filter(_.isOwner == false), // owner added by createInternalStructureForNewProject + models = List(userModel, fileModel), + projectDatabase = project.projectDatabase + ) + + val resettedClient = client.copy(projects = client.projects.filter(_.id != project.id)) + + actions ++= AuthenticateCustomerMutation.createInternalStructureForNewProject( + client = resettedClient, + project = resetProject, + projectQueries = projectQueries, + internalDatabase = internalDatabase.databaseDef, + ignoreDuplicateNameVerificationError = true + ) + + actions ++= AuthenticateCustomerMutation.createClientDatabaseStructureForNewProject(resettedClient, resetProject, internalDatabase.databaseDef) + actions ++= AuthenticateCustomerMutation.createIntegrationsForNewProject(resetProject) + actions :+= BumpProjectRevision(project = project) + actions :+= InvalidateSchema(project = project) + actions + } + + override def getReturnValue: Option[ResetProjectSchemaMutationPayload] = { + Some(ResetProjectSchemaMutationPayload(clientMutationId = args.clientMutationId, client = client, project = project)) + } +} + +case class ResetProjectSchemaMutationPayload(clientMutationId: Option[String], client: models.Client, project: models.Project) extends Mutation + +case class ResetProjectSchemaInput(clientMutationId: Option[String], projectId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetFeatureToggleMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetFeatureToggleMutation.scala new file mode 100644 index 0000000000..488c542fda --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetFeatureToggleMutation.scala @@ -0,0 +1,36 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.FeatureToggle +import cool.graph.system.mutactions.internal.{InvalidateSchema, SetFeatureToggle} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class SetFeatureToggleMutation(client: models.Client, + project: models.Project, + args: SetFeatureToggleInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[SetFeatureToggleMutationPayload] { + + val featureToggle = FeatureToggle( + id = Cuid.createCuid(), + name = args.name, + isEnabled = args.isEnabled + ) + + override def prepareActions(): List[Mutaction] = { + this.actions = List(SetFeatureToggle(project, featureToggle), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue: Option[SetFeatureToggleMutationPayload] = { + Some(SetFeatureToggleMutationPayload(args.clientMutationId, project, featureToggle)) + } +} + +case class SetFeatureToggleMutationPayload(clientMutationId: Option[String], project: models.Project, featureToggle: models.FeatureToggle) extends Mutation + +case class SetFeatureToggleInput(clientMutationId: Option[String], projectId: String, name: String, isEnabled: Boolean) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetProjectDatabaseMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetProjectDatabaseMutation.scala new file mode 100644 index 0000000000..929f227139 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SetProjectDatabaseMutation.scala @@ -0,0 +1,65 @@ +package cool.graph.system.mutations + +import com.typesafe.config.Config +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors.InvalidProjectDatabase +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Project, ProjectDatabase} +import cool.graph.system.database.finder.{ProjectDatabaseFinder, ProjectQueries} +import cool.graph.system.mutactions.internal.{InvalidateSchema, UpdateProject} +import cool.graph.{InternalProjectMutation, Mutaction} + +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.Await + +case class SetProjectDatabaseMutation( + args: SetProjectDatabaseInput, + project: Project, + client: Client, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[SetProjectDatabaseMutationPayload] + with Injectable { + import scala.concurrent.duration._ + + val config: Config = inject[Config](identified by "config") + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + val newProjectDatabase: ProjectDatabase = + Await.result(ProjectDatabaseFinder.forId(args.projectDatabaseId)(internalDatabase.databaseDef), 5.seconds) match { + case Some(x) => x + case None => throw InvalidProjectDatabase(args.projectDatabaseId) + } + + val updatedProject: Project = project.copy(projectDatabase = newProjectDatabase) + + override def prepareActions(): List[Mutaction] = { + val updateProject = UpdateProject( + client = client, + oldProject = project, + project = updatedProject, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries, + bumpRevision = false + ) + val invalidateSchema = InvalidateSchema(project = project) + actions = List(updateProject, invalidateSchema) + + actions + } + + override def getReturnValue: Option[SetProjectDatabaseMutationPayload] = { + Some( + SetProjectDatabaseMutationPayload( + clientMutationId = args.clientMutationId, + client = client, + project = updatedProject + )) + } +} + +case class SetProjectDatabaseMutationPayload(clientMutationId: Option[String], client: models.Client, project: models.Project) extends Mutation + +case class SetProjectDatabaseInput(clientMutationId: Option[String], projectId: String, projectDatabaseId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SigninClientUserMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SigninClientUserMutation.scala new file mode 100644 index 0000000000..3d96e41fab --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SigninClientUserMutation.scala @@ -0,0 +1,41 @@ +package cool.graph.system.mutations + +import java.util.concurrent.TimeUnit + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.authorization.SystemAuth2 +import cool.graph.{DataItem, InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} +import spray.json.DefaultJsonProtocol._ + +import scala.concurrent.Await +import scala.concurrent.duration.Duration + +case class SigninClientUserMutation( + client: Client, + project: Project, + args: SigninClientUserInput, + projectDbsFn: Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[SigninClientUserMutationPayload] + with Injectable { + + override def prepareActions(): List[Mutaction] = { + actions + } + + override def getReturnValue: Option[SigninClientUserMutationPayload] = { + + val auth = SystemAuth2() + val token = Await.result(auth.loginUser(project, DataItem(id = args.clientUserId, userData = Map()), authData = Some("SigninClientUserMutation")), + Duration(5, TimeUnit.SECONDS)) + + Some(SigninClientUserMutationPayload(clientMutationId = args.clientMutationId, token = token)) + } +} + +case class SigninClientUserMutationPayload(clientMutationId: Option[String], token: String) extends Mutation + +case class SigninClientUserInput(clientMutationId: Option[String], projectId: String, clientUserId: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SignupCustomerMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SignupCustomerMutation.scala new file mode 100644 index 0000000000..a1f4573e80 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/SignupCustomerMutation.scala @@ -0,0 +1,96 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.models._ +import cool.graph.system.database.SystemFields +import scaldi.Injectable + +object SignupCustomerMutation extends Injectable { + def generateUserModel = { + Model( + id = Cuid.createCuid(), + name = "User", + isSystem = true, + fields = List() + ) + } + + def generateUserFields = { + SystemFields.generateAll + } + + def generateFileModel = { + Model( + id = Cuid.createCuid(), + name = "File", + isSystem = true, + fields = List() + ) + } + + def generateFileFields = { + SystemFields.generateAll ++ + List( + Field( + id = Cuid.createCuid(), + name = "secret", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = true, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "url", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = true, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "name", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = false + ), + Field( + id = Cuid.createCuid(), + name = "contentType", + typeIdentifier = TypeIdentifier.String, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ), + Field( + id = Cuid.createCuid(), + name = "size", + typeIdentifier = TypeIdentifier.Int, + isRequired = true, + isList = false, + isUnique = false, + isSystem = true, + isReadonly = true + ) + ) + } + + def generateExampleProject(projectDatabase: ProjectDatabase) = { + Project( + id = Cuid.createCuid(), + name = "Example Project", + ownerId = "just-a-temporary-dummy-gets-set-to-real-client-id-later", + models = List.empty, + projectDatabase = projectDatabase + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/TransferOwnershipMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/TransferOwnershipMutation.scala new file mode 100644 index 0000000000..17f3cba5ed --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/TransferOwnershipMutation.scala @@ -0,0 +1,75 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors.{EmailAlreadyIsTheProjectOwner, NewOwnerOfAProjectNeedsAClientId, OnlyOwnerOfProjectCanTransferOwnership} +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Project, Seat} +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.internal.{CreateSeat, DeleteSeat, InvalidateSchema, UpdateProject} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class TransferOwnershipMutation(client: models.Client, + project: models.Project, + args: TransferOwnershipInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[TransferOwnershipMutationPayload] + with Injectable { + + // note: this mutation does not bump revision as collaborators are not part of the project structure + + val projectQueries: ProjectQueries = inject[ProjectQueries](identified by "projectQueries") + + val oldOwnerSeat: models.Seat = if (project.ownerId == client.id) project.seatByClientId_!(client.id) else throw OnlyOwnerOfProjectCanTransferOwnership() + val newOwnerSeat: models.Seat = project.seatByEmail_!(args.email) + + if (newOwnerSeat.clientId.isEmpty) throw NewOwnerOfAProjectNeedsAClientId() + if (args.email == oldOwnerSeat.email) throw EmailAlreadyIsTheProjectOwner(args.email) + + val unchangedSeats: List[Seat] = project.seats.filter(seat => seat.id != oldOwnerSeat.id && seat.id != newOwnerSeat.id) + val projectWithOutSwitchedSeats: Project = project.copy(seats = unchangedSeats) + val updatedProject: Project = project.copy(seats = unchangedSeats :+ oldOwnerSeat.copy(isOwner = false) :+ newOwnerSeat.copy(isOwner = true)) + + override def prepareActions(): List[Mutaction] = { + + val deleteOldNewOwnerSeat = DeleteSeat(client, project, newOwnerSeat, internalDatabase = internalDatabase.databaseDef) + val deleteOldOldOwnerSeat = DeleteSeat(client, project, oldOwnerSeat, internalDatabase = internalDatabase.databaseDef) + + val addUpdatedNewOwnerSeat = + CreateSeat( + client, + projectWithOutSwitchedSeats, + newOwnerSeat.copy(isOwner = true), + internalDatabase = internalDatabase.databaseDef, + ignoreDuplicateNameVerificationError = true + ) + val addUpdatedOldOwnerSeat = + CreateSeat( + client, + projectWithOutSwitchedSeats, + oldOwnerSeat.copy(isOwner = false), + internalDatabase = internalDatabase.databaseDef, + ignoreDuplicateNameVerificationError = true + ) + + val updateProject = UpdateProject(client, + project, + updatedProject.copy(ownerId = newOwnerSeat.clientId.get), + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries) + + actions = + List(deleteOldNewOwnerSeat, deleteOldOldOwnerSeat, addUpdatedNewOwnerSeat, addUpdatedOldOwnerSeat, updateProject, InvalidateSchema(updatedProject)) + + actions + } + + override def getReturnValue: Option[TransferOwnershipMutationPayload] = + Some(TransferOwnershipMutationPayload(clientMutationId = args.clientMutationId, project = updatedProject, ownerEmail = newOwnerSeat.email)) + +} + +case class TransferOwnershipMutationPayload(clientMutationId: Option[String], project: models.Project, ownerEmail: String) extends Mutation + +case class TransferOwnershipInput(clientMutationId: Option[String], projectId: String, email: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UninstallPackageMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UninstallPackageMutation.scala new file mode 100644 index 0000000000..a54d1d2f11 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UninstallPackageMutation.scala @@ -0,0 +1,48 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models +import cool.graph.system.mutactions.internal.{BumpProjectRevision, DeletePackageDefinition, DeleteRootToken, InvalidateSchema} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UninstallPackageMutation(client: models.Client, + project: models.Project, + args: UninstallPackageInput, + projectDbsFn: models.Project => InternalAndProjectDbs)(implicit inj: Injector) + extends InternalProjectMutation[UninstallPackageMutationPayload] { + + var oldPackage: Option[models.PackageDefinition] = None + + override def prepareActions(): List[Mutaction] = { + + oldPackage = project.packageDefinitions.find(_.name == args.name) match { + case None => throw SystemErrors.InvalidPackageName(args.name) + case Some(x) => Some(x) + } + + val deletePackage = DeletePackageDefinition(project, oldPackage.get, internalDatabase = internalDatabase.databaseDef) + + val deletePat = project.rootTokens.filter(_.name == args.name).map(pat => DeleteRootToken(pat)) + + actions = List(deletePackage, BumpProjectRevision(project = project), InvalidateSchema(project)) ++ deletePat + + actions + } + + override def getReturnValue: Option[UninstallPackageMutationPayload] = { + Some( + UninstallPackageMutationPayload( + clientMutationId = args.clientMutationId, + project = project.copy(packageDefinitions = project.packageDefinitions :+ oldPackage.get), + packageDefinition = oldPackage.get + )) + } +} + +case class UninstallPackageMutationPayload(clientMutationId: Option[String], project: models.Project, packageDefinition: models.PackageDefinition) + extends Mutation + +case class UninstallPackageInput(clientMutationId: Option[String], projectId: String, name: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateActionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateActionMutation.scala new file mode 100644 index 0000000000..3bb7a5f380 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateActionMutation.scala @@ -0,0 +1,110 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.ActionHandlerType.ActionHandlerType +import cool.graph.shared.models.ActionTriggerMutationModelMutationType.ActionTriggerMutationModelMutationType +import cool.graph.shared.models.ActionTriggerType.ActionTriggerType +import cool.graph.shared.models.{Action, ActionHandlerWebhook, ActionTriggerMutationModel} +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateActionMutation( + client: models.Client, + project: models.Project, + args: UpdateActionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateActionMutationPayload] { + + val existingAction: Action = project.getActionById_!(args.actionId) + + var updatedAction: models.Action = mergeInputValuesToField(existingAction, args) + + def mergeInputValuesToField(existingAction: Action, updateValues: UpdateActionInput): Action = { + existingAction.copy( + isActive = updateValues.isActive.getOrElse(existingAction.isActive), + triggerType = updateValues.triggerType.getOrElse(existingAction.triggerType), + handlerType = updateValues.handlerType.getOrElse(existingAction.handlerType), + description = updateValues.description match { + case Some(x) => Some(x) + case None => existingAction.description + } + ) + } + + override def prepareActions(): List[Mutaction] = { + + actions :+= UpdateAction(project = project, oldAction = existingAction, action = updatedAction) + + if (args.webhookUrl.isDefined) { + if (existingAction.handlerWebhook.isDefined) { + actions :+= DeleteActionHandlerWebhook(project, existingAction, existingAction.handlerWebhook.get) + } + + val actionHandlerWebhook = + ActionHandlerWebhook(id = Cuid.createCuid(), url = args.webhookUrl.get, args.webhookIsAsync.getOrElse(true)) + + updatedAction = updatedAction.copy(handlerWebhook = Some(actionHandlerWebhook)) + + actions :+= CreateActionHandlerWebhook( + project = project, + action = updatedAction, + actionHandlerWebhook = actionHandlerWebhook + ) + } + + if (args.actionTriggerMutationModel.isDefined) { + if (existingAction.triggerMutationModel.isDefined) { + actions :+= DeleteActionTriggerMutationModel(project, existingAction.triggerMutationModel.get) + } + + val actionTriggerMutationModel = ActionTriggerMutationModel( + id = Cuid.createCuid(), + modelId = args.actionTriggerMutationModel.get.modelId, + mutationType = args.actionTriggerMutationModel.get.mutationType, + fragment = args.actionTriggerMutationModel.get.fragment + ) + + updatedAction = updatedAction.copy(triggerMutationModel = Some(actionTriggerMutationModel)) + + actions :+= CreateActionTriggerMutationModel( + project = project, + action = updatedAction, + actionTriggerMutationModel = actionTriggerMutationModel + ) + } + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[UpdateActionMutationPayload] = { + Some( + UpdateActionMutationPayload( + clientMutationId = args.clientMutationId, + project = project.copy(actions = project.actions.filter(_.id != updatedAction.id) :+ updatedAction), + action = updatedAction + )) + } +} + +case class UpdateActionMutationPayload(clientMutationId: Option[String], project: models.Project, action: models.Action) extends Mutation + +case class UpdateActionTriggerModelInput(modelId: String, mutationType: ActionTriggerMutationModelMutationType, fragment: String) + +case class UpdateActionInput(clientMutationId: Option[String], + actionId: String, + isActive: Option[Boolean], + description: Option[String], + triggerType: Option[ActionTriggerType], + handlerType: Option[ActionHandlerType], + webhookUrl: Option[String], + webhookIsAsync: Option[Boolean], + actionTriggerMutationModel: Option[AddActionTriggerModelInput]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateAlgoliaSyncQueryMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateAlgoliaSyncQueryMutation.scala new file mode 100644 index 0000000000..511bf24c4d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateAlgoliaSyncQueryMutation.scala @@ -0,0 +1,106 @@ +package cool.graph.system.mutations + +import com.typesafe.config.Config +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.NotFoundException +import cool.graph.shared.models +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.mutactions.client.SyncModelToAlgoliaViaRequest +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateAlgoliaSyncQuery} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +case class UpdateAlgoliaSyncQueryMutation( + client: models.Client, + project: models.Project, + args: UpdateAlgoliaSyncQueryInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateAlgoliaSyncQueryPayload] + with Injectable { + + var algoliaSyncQuery: Option[models.AlgoliaSyncQuery] = None + var searchProviderAlgolia: Option[models.SearchProviderAlgolia] = None + val config: Config = inject[Config]("config") + + override def prepareActions(): List[Mutaction] = { + algoliaSyncQuery = project.getAlgoliaSyncQueryById(args.algoliaSyncQueryId) + + val pendingActions: List[Mutaction] = algoliaSyncQuery match { + case Some(algoliaSyncQueryToUpdate: models.AlgoliaSyncQuery) => + searchProviderAlgolia = project.getSearchProviderAlgoliaByAlgoliaSyncQueryId(args.algoliaSyncQueryId) + val oldAlgoliaSyncQuery = algoliaSyncQueryToUpdate + algoliaSyncQuery = mergeInputValuesToAlgoliaSyncQuery(oldAlgoliaSyncQuery, args) + + val updateAlgoliaSyncQueryInProject = + UpdateAlgoliaSyncQuery( + oldAlgoliaSyncQuery = oldAlgoliaSyncQuery, + newAlgoliaSyncQuery = algoliaSyncQuery.get + ) + + val reSyncModelToAlgolia = algoliaSyncQuery.get.isEnabled match { + case false => + List.empty + case true => + List( + SyncModelToAlgoliaViaRequest( + project = project, + model = project.getModelById_!(algoliaSyncQuery.get.model.id), + algoliaSyncQuery = algoliaSyncQuery.get, + config = config + ) + ) + } + + List(updateAlgoliaSyncQueryInProject, BumpProjectRevision(project = project), InvalidateSchema(project = project)) ++ reSyncModelToAlgolia + + case None => + List(InvalidInput(NotFoundException("This algoliaSearchQueryId does not correspond to an existing AlgoliaSearchQuery"))) + + } + + actions = pendingActions + actions + } + + private def mergeInputValuesToAlgoliaSyncQuery(existingAlgoliaSyncQuery: models.AlgoliaSyncQuery, + updateValues: UpdateAlgoliaSyncQueryInput): Option[models.AlgoliaSyncQuery] = { + Some( + existingAlgoliaSyncQuery.copy( + indexName = updateValues.indexName, + fragment = updateValues.fragment, + isEnabled = updateValues.isEnabled + ) + ) + } + + override def getReturnValue: Option[UpdateAlgoliaSyncQueryPayload] = { + val updatedSearchProviderAlgolia = searchProviderAlgolia.get.copy( + algoliaSyncQueries = + searchProviderAlgolia.get.algoliaSyncQueries + .filterNot(_.id == algoliaSyncQuery.get.id) :+ algoliaSyncQuery.get) + val updatedProject = project.copy( + integrations = + project.authProviders + .filterNot(_.id == searchProviderAlgolia.get.id) :+ updatedSearchProviderAlgolia) + + Some( + UpdateAlgoliaSyncQueryPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + algoliaSyncQuery = algoliaSyncQuery.get, + searchProviderAlgolia = searchProviderAlgolia.get + )) + } +} + +case class UpdateAlgoliaSyncQueryPayload(clientMutationId: Option[String], + project: models.Project, + algoliaSyncQuery: models.AlgoliaSyncQuery, + searchProviderAlgolia: models.SearchProviderAlgolia) + extends Mutation + +case class UpdateAlgoliaSyncQueryInput(clientMutationId: Option[String], algoliaSyncQueryId: String, indexName: String, fragment: String, isEnabled: Boolean) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateClientPasswordMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateClientPasswordMutation.scala new file mode 100644 index 0000000000..49f0715f77 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateClientPasswordMutation.scala @@ -0,0 +1,35 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalDatabase +import cool.graph.shared.models +import cool.graph.shared.models.Client +import cool.graph.system.mutactions.internal.UpdateClientPassword +import cool.graph.{InternalMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateClientPasswordMutation( + client: Client, + args: UpdateClientPasswordInput, + internalDatabase: InternalDatabase +)(implicit inj: Injector) + extends InternalMutation[UpdateClientPasswordMutationPayload] { + + var updatedClient: Option[models.Client] = None + + override def prepareActions(): List[Mutaction] = { + val updateClientPassword = UpdateClientPassword(client = client, oldPassword = args.oldPassword, newPassword = args.newPassword) + + updatedClient = Some(client) + actions = List(updateClientPassword) + actions + } + + override def getReturnValue(): Option[UpdateClientPasswordMutationPayload] = { + Some(new UpdateClientPasswordMutationPayload(clientMutationId = args.clientMutationId, client = updatedClient.get)) + } +} + +case class UpdateClientPasswordMutationPayload(clientMutationId: Option[String], client: Client) extends Mutation + +case class UpdateClientPasswordInput(clientMutationId: Option[String], newPassword: String, oldPassword: String) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateCustomerMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateCustomerMutation.scala new file mode 100644 index 0000000000..c0908d892f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateCustomerMutation.scala @@ -0,0 +1,50 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalDatabase +import cool.graph.shared.models +import cool.graph.shared.models.Client +import cool.graph.system.mutactions.internal.{UpdateClient, UpdateCustomerInAuth0} +import cool.graph.{InternalMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +case class UpdateCustomerMutation( + client: Client, + args: UpdateClientInput, + internalDatabase: InternalDatabase +)(implicit inj: Injector) + extends InternalMutation[UpdateClientMutationPayload] + with Injectable { + + var updatedClient: Option[models.Client] = None + + def mergeInputValuesToClient(existingClient: Client, updateValues: UpdateClientInput): Client = { + existingClient.copy( + name = updateValues.name.getOrElse(existingClient.name), + email = updateValues.email.getOrElse(existingClient.email) + ) + } + + override def prepareActions(): List[Mutaction] = { + + updatedClient = Some(mergeInputValuesToClient(client, args)) + + val updateModel = UpdateClient(oldClient = client, client = updatedClient.get) + + val updateAuth0 = client.isAuth0IdentityProviderEmail match { + case true => List(UpdateCustomerInAuth0(oldClient = client, client = updatedClient.get)) + case false => List() + } + + actions = List(updateModel) ++ updateAuth0 + actions + } + + override def getReturnValue(): Option[UpdateClientMutationPayload] = { + Some(new UpdateClientMutationPayload(clientMutationId = args.clientMutationId, client = updatedClient.get)) + } +} + +case class UpdateClientMutationPayload(clientMutationId: Option[String], client: Client) extends Mutation + +case class UpdateClientInput(clientMutationId: Option[String], name: Option[String], email: Option[String]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateEnumMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateEnumMutation.scala new file mode 100644 index 0000000000..53fc35942e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateEnumMutation.scala @@ -0,0 +1,47 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models +import cool.graph.shared.models.{Enum, Project} +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateEnum} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateEnumMutation( + client: models.Client, + project: models.Project, + args: UpdateEnumInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateEnumMutationPayload] { + + val enum: Enum = project.getEnumById_!(args.enumId) + val updatedEnum: Enum = enum.copy(name = args.name.getOrElse(enum.name), values = args.values.getOrElse(enum.values)) + val updatedProject: Project = project.copy(enums = project.enums.filter(_.id != args.enumId) :+ updatedEnum) + + checkIfEnumWithNameAlreadyExists + + private def checkIfEnumWithNameAlreadyExists = args.name.foreach(name => if (enumWithSameName(name)) throw SystemErrors.InvalidEnumName(name)) + private def enumWithSameName(name: String) = project.enums.exists(enum => enum.name == name && enum.id != args.enumId) + + override def prepareActions(): List[Mutaction] = { + val migrationArgs = MigrateEnumValuesInput(args.clientMutationId, enum, updatedEnum, args.migrationValue) + val migrateFieldsUsingEnumValuesMutactions = MigrateEnumValuesMutation(client, project, migrationArgs, projectDbsFn, clientDbQueries).prepareActions() + + val updateEnumMutaction = List(UpdateEnum(newEnum = updatedEnum, oldEnum = enum), BumpProjectRevision(project = project), InvalidateSchema(project)) + + this.actions ++= migrateFieldsUsingEnumValuesMutactions ++ updateEnumMutaction + this.actions + } + + override def getReturnValue: Option[UpdateEnumMutationPayload] = Some(UpdateEnumMutationPayload(args.clientMutationId, updatedProject, updatedEnum)) +} + +case class UpdateEnumMutationPayload(clientMutationId: Option[String], project: models.Project, enum: models.Enum) extends Mutation + +case class UpdateEnumInput(clientMutationId: Option[String], enumId: String, name: Option[String], values: Option[Seq[String]], migrationValue: Option[String]) + extends MutationInput diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldConstraintMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldConstraintMutation.scala new file mode 100644 index 0000000000..69ed94f779 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldConstraintMutation.scala @@ -0,0 +1,113 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateFieldConstraint} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateFieldConstraintMutation( + client: models.Client, + project: models.Project, + args: UpdateFieldConstraintInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateFieldConstraintMutationPayload] { + + val constraint: FieldConstraint = project.getFieldConstraintById_!(args.constraintId) + + val updatedConstraint: FieldConstraint = constraint match { + case x: StringConstraint => + x.copy( + equalsString = newValue(x.equalsString, args.equalsString), + oneOfString = newOneOfValue(x.oneOfString, args.oneOfString), + minLength = newValue(x.minLength, args.minLength), + maxLength = newValue(x.maxLength, args.maxLength), + startsWith = newValue(x.startsWith, args.startsWith), + endsWith = newValue(x.endsWith, args.endsWith), + includes = newValue(x.includes, args.includes), + regex = newValue(x.regex, args.regex) + ) + case x: NumberConstraint => + x.copy( + equalsNumber = newValue(x.equalsNumber, args.oneOfNumber), + oneOfNumber = newOneOfValue(x.oneOfNumber, args.oneOfNumber), + min = newValue(x.min, args.min), + max = newValue(x.max, args.max), + exclusiveMin = newValue(x.exclusiveMin, args.exclusiveMin), + exclusiveMax = newValue(x.exclusiveMax, args.exclusiveMax), + multipleOf = newValue(x.multipleOf, args.multipleOf) + ) + case x: BooleanConstraint => + x.copy(equalsBoolean = newValue(x.equalsBoolean, args.equalsBoolean)) + case x: ListConstraint => + x.copy(uniqueItems = newValue(x.uniqueItems, args.uniqueItems), + minItems = newValue(x.minItems, args.minItems), + maxItems = newValue(x.maxItems, args.maxItems)) + } + + private def newValue[A](oldValue: Option[A], input: Any): Option[A] = { + input match { + case None => oldValue + case Some(Some(valid)) => Some(valid.asInstanceOf[A]) + case Some(None) => None + } + } + + private def newOneOfValue[A](oldValue: List[A], input: Any): List[A] = { + input match { + case None => oldValue + case Some(Some(valid)) => valid.asInstanceOf[List[A]] + case Some(None) => List.empty + } + } + + val field: Field = project.getFieldById_!(constraint.fieldId) + val updatedFieldConstraintList: List[FieldConstraint] = field.constraints.filter(_.id != updatedConstraint.id) :+ updatedConstraint + val fieldWithUpdatedFieldConstraint: Field = field.copy(constraints = updatedFieldConstraintList) + val model: Model = project.getModelByFieldId_!(field.id) + val modelsWithUpdatedFieldConstraint: List[Model] = project.models.filter(_.id != model.id) :+ model.copy( + fields = model.fields.filter(_.id != field.id) :+ fieldWithUpdatedFieldConstraint) + val newProject: Project = project.copy(models = modelsWithUpdatedFieldConstraint) + + override def prepareActions(): List[Mutaction] = { + actions = List( + UpdateFieldConstraint(field = field, oldConstraint = constraint, constraint = updatedConstraint), + BumpProjectRevision(project = project), + InvalidateSchema(project) + ) + actions + } + + override def getReturnValue: Option[UpdateFieldConstraintMutationPayload] = { + Some(UpdateFieldConstraintMutationPayload(args.clientMutationId, newProject, fieldWithUpdatedFieldConstraint, fieldWithUpdatedFieldConstraint.constraints)) + } +} + +case class UpdateFieldConstraintMutationPayload(clientMutationId: Option[String], project: models.Project, field: Field, constraints: List[FieldConstraint]) + extends Mutation + +case class UpdateFieldConstraintInput(clientMutationId: Option[String], + constraintId: String, + equalsString: Option[Option[Any]] = None, + oneOfString: Option[Option[Any]] = None, + minLength: Option[Option[Int]] = None, + maxLength: Option[Option[Int]] = None, + startsWith: Option[Option[Any]] = None, + endsWith: Option[Option[Any]] = None, + includes: Option[Option[Any]] = None, + regex: Option[Option[Any]] = None, + equalsNumber: Option[Option[Any]] = None, + oneOfNumber: Option[Option[Any]] = None, + min: Option[Option[Any]] = None, + max: Option[Option[Any]] = None, + exclusiveMin: Option[Option[Any]] = None, + exclusiveMax: Option[Option[Any]] = None, + multipleOf: Option[Option[Any]] = None, + equalsBoolean: Option[Option[Any]] = None, + uniqueItems: Option[Option[Any]] = None, + minItems: Option[Option[Int]] = None, + maxItems: Option[Option[Int]] = None) + extends MutationInput diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldMutation.scala new file mode 100644 index 0000000000..ad5fb9930e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateFieldMutation.scala @@ -0,0 +1,327 @@ +package cool.graph.system.mutations + +import cool.graph.GCDataTypes.{GCStringConverter, GCValue, NullGCValue} +import cool.graph._ +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.{SystemErrors, UserAPIErrors, UserInputErrors} +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateField} +import org.scalactic.{Bad, Good, Or} +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Try} + +case class UpdateFieldMutation( + client: Client, + project: Project, + args: UpdateFieldInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateFieldMutationPayload] + with Injectable { + + val oldField: Field = project.getFieldById_!(args.fieldId) + + val model: Model = project.getModelByFieldId_!(args.fieldId) + + val updatedField: Field = mergeInputValuesToField(oldField) + val newModel: Model = model.copy(fields = model.fields.filter(_.id != oldField.id) :+ updatedField) + val updatedProject: Project = project.copy(models = project.models.map { + case oldModel if oldModel.id == newModel.id => newModel + case oldModel => oldModel + }) + + def mergeInputValuesToField(existingField: Field): Field = { + val newTypeIdentifier = args.typeIdentifier.map(CustomScalarTypes.parseTypeIdentifier).getOrElse(existingField.typeIdentifier) + val newIsList = args.isList.getOrElse(existingField.isList) + + val oldDefaultValue: Option[GCValue] = + (newTypeIdentifier != oldField.typeIdentifier) || args.isList.exists(_ != oldField.isList) match { + case true => None + case false => oldField.defaultValue + } + + val newDefaultValue: Option[GCValue] = args.defaultValue match { + case None => None + case Some(None) => Some(NullGCValue()) + case Some(Some(x)) => GCStringConverter(newTypeIdentifier, newIsList).toGCValue(x).toOption + } + + val defaultValueMerged = newDefaultValue.orElse(oldDefaultValue) + + val newEnum = if (newTypeIdentifier == TypeIdentifier.Enum) { + args.enumId match { + case Some(enumId) => Some(project.getEnumById_!(enumId)) + case None => existingField.enum + } + } else None + + existingField.copy( + defaultValue = defaultValueMerged, + description = args.description.orElse(existingField.description), + name = args.name.getOrElse(existingField.name), + typeIdentifier = newTypeIdentifier, + isUnique = args.isUnique.getOrElse(existingField.isUnique), + isRequired = args.isRequired.getOrElse(existingField.isRequired), + isList = newIsList, + enum = newEnum + ) + } + + def removedEnumValues: List[String] = { + oldField.enum match { + case Some(oldEnum) => + updatedField.enum match { + case Some(newEnum) => oldEnum.values.filter(!newEnum.values.contains(_)).toList + case None => List.empty + } + + case None => List.empty + } + } + + def shouldUpdateClientDbColumn(oldField: Field, updatedField: Field): Boolean = { + if (oldField.isScalar) + oldField.isRequired != updatedField.isRequired || + oldField.name != updatedField.name || + oldField.typeIdentifier != updatedField.typeIdentifier || + oldField.isList != updatedField.isList || + oldField.isUnique != updatedField.isUnique + else false + } + + object MigrationType extends Enumeration { + type MigrationType = Value + val UniqueViolation = Value("UNIQUE_VIOLATION") + val AllFields = Value("ALL_FIELDS") + val RemovedEnumFieldsAndNullFields = Value("REMOVED_ENUM_FIELDS_AND_NULL_FIELDS") + val RemovedEnumFields = Value("REMOVED_ENUM_FIELDS") + val NullFields = Value("NULL_FIELDS") + val NoMigrationValue = Value("NO_MIGRATION_VALUE") + val VoluntaryMigrationValue = Value("UNNECESSARY_MIGRATION_VALUE") + } + + def scalarValueMigrationType(): MigrationType.Value = { + if (args.migrationValue.isEmpty) + MigrationType.NoMigrationValue + else if (updatedField.isUnique) + MigrationType.UniqueViolation + else if (UpdateField.typeChangeRequiresMigration(oldField, updatedField)) + MigrationType.AllFields + else if (oldField.isList != updatedField.isList) + MigrationType.AllFields + else if (updatedField.isList && removedEnumValues.nonEmpty) + MigrationType.AllFields + else if (!updatedField.isList && removedEnumValues.nonEmpty && updatedField.isRequired && !oldField.isRequired) + MigrationType.RemovedEnumFieldsAndNullFields + else if (!updatedField.isList && removedEnumValues.nonEmpty) + MigrationType.RemovedEnumFields + else if (updatedField.isRequired && !oldField.isRequired) + MigrationType.NullFields + else + MigrationType.VoluntaryMigrationValue + } + + def violatedFieldConstraints: List[FieldConstraint] = { + val listConstraints = oldField.constraints.filter(_.constraintType == FieldConstraintType.LIST) + val otherConstraints = oldField.constraints.filter(_.constraintType != FieldConstraintType.LIST) + val newType = updatedField.typeIdentifier + + () match { + case _ if listConstraints.nonEmpty && !updatedField.isList => + listConstraints + + case _ if otherConstraints.nonEmpty && !oldField.isList && updatedField.isList => + otherConstraints + + case _ if otherConstraints.nonEmpty => + otherConstraints.head.constraintType match { + case FieldConstraintType.STRING if newType != TypeIdentifier.String => otherConstraints + case FieldConstraintType.BOOLEAN if newType != TypeIdentifier.Boolean => otherConstraints + case FieldConstraintType.NUMBER if newType != TypeIdentifier.Float && newType != TypeIdentifier.Int => otherConstraints + case _ => List.empty + } + + case _ => + List.empty + } + } + + override def prepareActions(): List[Mutaction] = { + + () match { + case _ if verifyDefaultValue.nonEmpty => + actions = List(InvalidInput(verifyDefaultValue.head)) + + case _ if (!oldField.isScalar || !updatedField.isScalar) && args.isAnyArgumentSet(List("isRequired", "name")) => + actions = List(InvalidInput(SystemErrors.IsNotScalar(args.typeIdentifier.getOrElse(oldField.relatedModel(project).get.name)))) + + case _ if violatedFieldConstraints.nonEmpty => + actions = List( + InvalidInput(SystemErrors.UpdatingTheFieldWouldViolateConstraint(fieldId = oldField.id, constraintId = violatedFieldConstraints.head.id))) + + case _ if scalarValueMigrationType == MigrationType.UniqueViolation => + actions = List(InvalidInput(UserAPIErrors.UniqueConstraintViolation(model.name, "Field = " + oldField.name + " Value = " + args.migrationValue.get))) + + case _ => + createActions + + } + actions + } + + private def createActions = { + if (removedEnumValues.nonEmpty && args.migrationValue.isEmpty) { + if (oldField.isList) { + actions :+= InvalidInput(UserInputErrors.CantRemoveEnumValueWhenNodesExist(model.name, updatedField.name), + isInvalid = clientDbQueries.itemCountForModel(model).map(_ > 0)) + } else { + actions :+= InvalidInput( + UserInputErrors.EnumValueInUse(), + isInvalid = Future + .sequence(removedEnumValues.map(enum => clientDbQueries.itemCountForFieldValue(model, oldField, enum))) + .map(_.exists(_ > 0)) + ) + } + } + + actions :+= UpdateField( + model = model, + oldField = oldField, + field = updatedField, + migrationValue = args.migrationValue, + clientDbQueries = clientDbQueries + ) + + actions ++= (scalarValueMigrationType() match { + case MigrationType.AllFields => + replaceAllRowsWithMigValue + + case MigrationType.VoluntaryMigrationValue => + replaceAllRowsWithMigValue + + case MigrationType.RemovedEnumFieldsAndNullFields => + removedEnumValues.map(removedEnum => overWriteInvalidEnumsForColumn(removedEnum)) :+ populateNullRowsForColumn(args.migrationValue) + + case MigrationType.RemovedEnumFields => + removedEnumValues.map(removedEnum => overWriteInvalidEnumsForColumn(removedEnum)) + + case MigrationType.NullFields => + List(populateNullRowsForColumn(CustomScalarTypes.parseValueFromString(args.migrationValue.get, updatedField.typeIdentifier, updatedField.isList))) + + case _ => + List.empty + }) + + if (shouldUpdateClientDbColumn(oldField, updatedField)) { + actions :+= UpdateColumn(projectId = project.id, model = model, oldField = oldField, newField = updatedField) + actions ++= project + .getRelationFieldMirrorsByFieldId(oldField.id) + .map(mirror => UpdateRelationFieldMirrorColumn(project, project.getRelationById_!(mirror.relationId), oldField, updatedField)) + } + + actions ++= (scalarValueMigrationType() match { + case MigrationType.NoMigrationValue => + List.empty + + case _ => + project + .getRelationFieldMirrorsByFieldId(oldField.id) + .map(mirror => PopulateRelationFieldMirrorColumn(project, project.getRelationById_!(mirror.relationId), oldField)) + }) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + private def populateNullRowsForColumn(value: Option[Any]) = { + PopulateNullRowsForColumn( + projectId = project.id, + model = model, + field = updatedField, + value = value + ) + } + + private def overWriteInvalidEnumsForColumn(removedEnum: String) = { + OverwriteInvalidEnumForColumnWithMigrationValue(projectId = project.id, + model = model, + field = updatedField, + oldValue = removedEnum, + migrationValue = args.migrationValue.get) + } + + private def replaceAllRowsWithMigValue = { + val createColumnField = updatedField.copy(name = oldField.name, isRequired = false) + List( + DeleteColumn(projectId = project.id, model = model, field = oldField), + CreateColumn(projectId = project.id, model = model, field = createColumnField), + OverwriteAllRowsForColumn( + projectId = project.id, + model = model, + field = createColumnField, + value = CustomScalarTypes.parseValueFromString(args.migrationValue.get, createColumnField.typeIdentifier, createColumnField.isList) + ) + ) ++ + project + .getRelationFieldMirrorsByFieldId(oldField.id) + .flatMap(mirror => + List( + DeleteRelationFieldMirrorColumn(project, project.getRelationById_!(mirror.relationId), oldField), + CreateRelationFieldMirrorColumn(project, project.getRelationById_!(mirror.relationId), createColumnField) + )) + } + + override def getReturnValue: Option[UpdateFieldMutationPayload] = { + + Some( + UpdateFieldMutationPayload( + clientMutationId = args.clientMutationId, + field = updatedField, + model = newModel, + project = updatedProject + )) + } + + val verifyDefaultValue: List[UserInputErrors.InvalidValueForScalarType] = { + val x = args.defaultValue match { + case None => None + case Some(None) => Some(Good(NullGCValue())) + case Some(Some(value)) => Some(GCStringConverter(updatedField.typeIdentifier, updatedField.isList).toGCValue(value)) + } + + x match { + case Some(Good(_)) => List.empty + case Some(Bad(error)) => List(error) + case None => List.empty + } + } + +} + +case class UpdateFieldMutationPayload(clientMutationId: Option[String], model: models.Model, field: models.Field, project: models.Project) extends Mutation + +case class UpdateFieldInput(clientMutationId: Option[String], + fieldId: String, + defaultValue: Option[Option[String]], + migrationValue: Option[String], + description: Option[String], + name: Option[String], + typeIdentifier: Option[String], + isUnique: Option[Boolean], + isRequired: Option[Boolean], + isList: Option[Boolean], + enumId: Option[String]) + extends MutationInput diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelMutation.scala new file mode 100644 index 0000000000..86f506cce7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelMutation.scala @@ -0,0 +1,61 @@ +package cool.graph.system.mutations + +import cool.graph.Types.Id +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Model, Project} +import cool.graph.system.mutactions.client.RenameTable +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateModel} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateModelMutation( + client: Client, + project: Project, + args: UpdateModelInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateModelMutationPayload] { + + val model: Model = project.getModelById_!(args.modelId) + + var updatedModel: models.Model = mergeInputValuesToModel(model, args) + val updatedProject: models.Project = project.copy(models = project.models.filter(_.id != model.id) :+ updatedModel) + + def mergeInputValuesToModel(existingModel: Model, updateValues: UpdateModelInput): Model = { + existingModel.copy( + description = updateValues.description.orElse(existingModel.description), + name = updateValues.name.getOrElse(existingModel.name), + fieldPositions = args.fieldPositions.getOrElse(existingModel.fieldPositions) + ) + } + + override def prepareActions(): List[Mutaction] = { + val updateModel = UpdateModel(project = project, oldModel = model, model = updatedModel) + val updateTable = if (args.name.contains(model.name)) { + None + } else { + args.name.map(RenameTable(project.id, model, _)) + } + + actions = updateTable match { + case Some(updateTable) => List(updateModel, updateTable, InvalidateSchema(project), BumpProjectRevision(project)) + case None => List(updateModel, InvalidateSchema(project), BumpProjectRevision(project)) + } + actions + } + + override def getReturnValue: Option[UpdateModelMutationPayload] = { + Some(UpdateModelMutationPayload(clientMutationId = args.clientMutationId, project = updatedProject, model = updatedModel)) + } +} + +case class UpdateModelMutationPayload(clientMutationId: Option[String], model: models.Model, project: models.Project) extends Mutation + +case class UpdateModelInput(clientMutationId: Option[String], + modelId: String, + description: Option[String], + name: Option[String], + fieldPositions: Option[List[Id]]) + extends MutationInput diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelPermissionMutation.scala new file mode 100644 index 0000000000..e9e1f0b9d4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateModelPermissionMutation.scala @@ -0,0 +1,115 @@ +package cool.graph.system.mutations + +import _root_.akka.actor.ActorSystem +import _root_.akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.internal._ +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateModelPermissionMutation( + client: models.Client, + project: models.Project, + model: models.Model, + modelPermission: models.ModelPermission, + args: UpdateModelPermissionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector, actorSystem: ActorSystem) + extends InternalProjectMutation[UpdateModelPermissionMutationPayload] { + + val updatedModelPermission = models.ModelPermission( + id = modelPermission.id, + operation = args.operation.getOrElse(modelPermission.operation), + userType = args.userType.getOrElse(modelPermission.userType), + rule = args.rule.getOrElse(modelPermission.rule), + ruleName = args.ruleName match { + case None => modelPermission.ruleName + case x => x + }, + ruleGraphQuery = args.ruleGraphQuery match { + case None => modelPermission.ruleGraphQuery + case x => x + }, + ruleGraphQueryFilePath = args.ruleGraphQueryFilePath match { + case None => modelPermission.ruleGraphQueryFilePath + case x => x + }, + ruleWebhookUrl = args.ruleWebhookUrl match { + case None => modelPermission.ruleWebhookUrl + case x => x + }, + fieldIds = args.fieldIds.getOrElse(modelPermission.fieldIds), + applyToWholeModel = args.applyToWholeModel.getOrElse(modelPermission.applyToWholeModel), + description = args.description match { + case None => modelPermission.description + case x => x + }, + isActive = args.isActive.getOrElse(modelPermission.isActive) + ) + + override def prepareActions(): List[Mutaction] = { + +// updatedModelPermission.ruleGraphQuery.foreach { query => +// val queriesWithSameOpCount = model.permissions.count(_.operation == updatedModelPermission.operation) // Todo this count may be wrong +// +// val queryName = updatedModelPermission.ruleName match { +// case Some(nameForRule) => nameForRule +// case None => QueryPermissionHelper.alternativeNameFromOperationAndInt(updatedModelPermission.operationString, queriesWithSameOpCount) +// } +// +// val args = QueryPermissionHelper.permissionQueryArgsFromModel(model) +// val treatedQuery = QueryPermissionHelper.prependNameAndRenderQuery(query, queryName: String, args: List[(String, String)]) +// +// val violations = QueryPermissionHelper.validatePermissionQuery(treatedQuery, project) +// if (violations.nonEmpty) +// actions ++= List(InvalidInput(PermissionQueryIsInvalid(violations.mkString(""), updatedModelPermission.ruleName.getOrElse(updatedModelPermission.id)))) +// } + + actions :+= UpdateModelPermission(model = model, oldPermisison = modelPermission, permission = updatedModelPermission) + + val addPermissionFields = updatedModelPermission.fieldIds.filter(id => !modelPermission.fieldIds.contains(id)) + val removePermissionFields = modelPermission.fieldIds.filter(id => !updatedModelPermission.fieldIds.contains(id)) + + actions ++= addPermissionFields.map(fieldId => CreateModelPermissionField(project, model, updatedModelPermission, fieldId)) + + actions ++= removePermissionFields.map(fieldId => DeleteModelPermissionField(project, model, updatedModelPermission, fieldId)) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[UpdateModelPermissionMutationPayload] = { + Some( + UpdateModelPermissionMutationPayload( + clientMutationId = args.clientMutationId, + project = project, + model = model.copy(permissions = model.permissions :+ updatedModelPermission), + modelPermission = updatedModelPermission + )) + } +} + +case class UpdateModelPermissionMutationPayload(clientMutationId: Option[String], + project: models.Project, + model: models.Model, + modelPermission: models.ModelPermission) + extends Mutation + +case class UpdateModelPermissionInput(clientMutationId: Option[String], + id: String, + operation: Option[models.ModelOperation.Value], + userType: Option[models.UserType.Value], + rule: Option[models.CustomRule.Value], + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleWebhookUrl: Option[String], + fieldIds: Option[List[String]], + applyToWholeModel: Option[Boolean], + description: Option[String], + isActive: Option[Boolean], + ruleGraphQueryFilePath: Option[String] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateProjectMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateProjectMutation.scala new file mode 100644 index 0000000000..bf9a52da32 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateProjectMutation.scala @@ -0,0 +1,65 @@ +package cool.graph.system.mutations + +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{Client, Project} +import cool.graph.system.database.finder.ProjectQueries +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateProject} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateProjectMutation( + client: Client, + project: Project, + args: UpdateProjectInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + projectQueries: ProjectQueries +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateProjectMutationPayload] { + + var updatedProject: models.Project = mergeInputValuesToProject(project, args) + + def mergeInputValuesToProject(existingProject: Project, updateValues: UpdateProjectInput): Project = { + existingProject.copy( + name = updateValues.name.getOrElse(existingProject.name), + alias = updateValues.alias.orElse(existingProject.alias), + webhookUrl = updateValues.webhookUrl.orElse(existingProject.webhookUrl), + allowQueries = updateValues.allowQueries.getOrElse(existingProject.allowQueries), + allowMutations = updateValues.allowMutations.getOrElse(existingProject.allowMutations) + ) + } + + override def prepareActions(): List[Mutaction] = { + val updateProject = UpdateProject( + client = client, + oldProject = project, + project = updatedProject, + internalDatabase = internalDatabase.databaseDef, + projectQueries = projectQueries + ) + + actions = List(updateProject, BumpProjectRevision(project = project), InvalidateSchema(project = project)) + actions + } + + override def getReturnValue: Option[UpdateProjectMutationPayload] = { + Some( + UpdateProjectMutationPayload( + clientMutationId = args.clientMutationId, + client = client.copy(projects = client.projects.filter(_.id != project.id) :+ updatedProject), + project = updatedProject + ) + ) + } +} + +case class UpdateProjectMutationPayload(clientMutationId: Option[String], client: models.Client, project: models.Project) extends Mutation + +case class UpdateProjectInput(clientMutationId: Option[String], + projectId: String, + name: Option[String], + alias: Option[String], + webhookUrl: Option[String], + allowQueries: Option[Boolean], + allowMutations: Option[Boolean]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationMutation.scala new file mode 100644 index 0000000000..1b7ef87648 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationMutation.scala @@ -0,0 +1,248 @@ +package cool.graph.system.mutations + +import cool.graph._ +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.system.database.client.ClientDbQueries +import cool.graph.system.mutactions.client.{CreateRelationTable, DeleteRelationTable} +import cool.graph.system.mutactions.internal._ +import sangria.relay.Mutation +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +case class UpdateRelationMutation( + client: models.Client, + project: models.Project, + args: UpdateRelationInput, + projectDbsFn: models.Project => InternalAndProjectDbs, + clientDbQueries: ClientDbQueries +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateRelationMutationPayload] + with Injectable { + + val relation: Relation = project.getRelationById_!(args.id) + + val leftModel: Model = project.getModelById_!(relation.modelAId) + val rightModel: Model = project.getModelById_!(relation.modelBId) + + val fieldOnLeftModel: Field = relation.getModelAField_!(project) + val fieldOnRightModel: Field = relation.getModelBField_!(project) + + val updatedFieldOnLeftModel: Option[models.Field] = + updateField(args.fieldOnLeftModelName, args.fieldOnLeftModelIsList, args.fieldOnLeftModelIsRequired, args.leftModelId.isDefined, fieldOnLeftModel) + + var updatedFieldOnRightModel: Option[models.Field] = + updateField(args.fieldOnRightModelName, args.fieldOnRightModelIsList, args.fieldOnRightModelIsRequired, args.rightModelId.isDefined, fieldOnRightModel) + + val updatedRelation: Option[models.Relation] = updateRelation(args.name, args.description, args.leftModelId, args.rightModelId) + + val updatedProject: (Model, Model, Relation, Project) = getUpdatedProject + var migrationActions: List[Mutaction] = List() + + def isSameFieldOnSameModel: Boolean = { + updatedFieldOnLeftModel + .getOrElse(fieldOnLeftModel) + .name == updatedFieldOnRightModel + .getOrElse(fieldOnRightModel) + .name && args.leftModelId.getOrElse(leftModel.id) == args.rightModelId + .getOrElse(rightModel.id) + } + + def wasSameFieldOnSameModel: Boolean = { + fieldOnLeftModel.name == fieldOnRightModel.name && + leftModel.id == rightModel.id + } + + override def prepareActions(): List[Mutaction] = { + + if (args.leftModelId.getOrElse(leftModel.id) == args.rightModelId + .getOrElse(rightModel.id) && args.fieldOnLeftModelName.getOrElse(fieldOnLeftModel.name) == args.fieldOnRightModelName + .getOrElse(fieldOnRightModel.name) && args.fieldOnLeftModelIsList.getOrElse(fieldOnLeftModel.isList) != args.fieldOnRightModelIsList + .getOrElse(fieldOnRightModel.isList)) { + actions = List(InvalidInput(UserInputErrors.OneToManyRelationSameModelSameField())) + return actions + } + + if (modifiesModels) { + migrationActions :+= InvalidInput(UserInputErrors.EdgesAlreadyExist(), edgesExist) + + migrationActions :+= DeleteRelationTable(project = project, relation = relation) + + val newRelation = relation.copy(modelAId = args.leftModelId.getOrElse(leftModel.id), modelBId = args.rightModelId.getOrElse(rightModel.id)) + + migrationActions :+= CreateRelationTable(project = project, relation = newRelation) + } + + if (updatedFieldOnLeftModel.isDefined || updatedFieldOnRightModel.isDefined) { + + if (isSameFieldOnSameModel) { + if (!wasSameFieldOnSameModel) + migrationActions :+= DeleteField( + project = project, + model = rightModel, + field = fieldOnRightModel, + allowDeleteRelationField = true + ) + + migrationActions :+= + UpdateField( + model = leftModel, + oldField = fieldOnLeftModel, + field = updatedFieldOnLeftModel.getOrElse(fieldOnLeftModel), + migrationValue = None, + newModelId = args.leftModelId, + clientDbQueries = clientDbQueries + ) + } else { + migrationActions :+= + UpdateField( + model = leftModel, + oldField = fieldOnLeftModel, + field = updatedFieldOnLeftModel.getOrElse(fieldOnLeftModel), + migrationValue = None, + newModelId = args.leftModelId, + clientDbQueries = clientDbQueries + ) + + if (wasSameFieldOnSameModel) { + updatedFieldOnRightModel = Some( + models.Field( + id = Cuid.createCuid(), + name = args.fieldOnRightModelName.getOrElse(fieldOnRightModel.name), + typeIdentifier = TypeIdentifier.Relation, + isRequired = false, + isList = args.fieldOnRightModelIsList.getOrElse(fieldOnRightModel.isList), + isUnique = false, + isSystem = false, + isReadonly = false, + relation = Some(relation), + relationSide = Some(RelationSide.B) + )) + migrationActions :+= CreateField(project, rightModel, updatedFieldOnRightModel.get, None, clientDbQueries) + } else { + migrationActions :+= + UpdateField( + model = rightModel, + oldField = fieldOnRightModel, + field = updatedFieldOnRightModel.getOrElse(fieldOnRightModel), + migrationValue = None, + newModelId = args.rightModelId, + clientDbQueries = clientDbQueries + ) + } + } + } + + updatedRelation.foreach(relation => migrationActions :+= UpdateRelation(oldRelation = relation, relation = relation, project = project)) + + actions = migrationActions :+ BumpProjectRevision(project = project) :+ InvalidateSchema(project = project) + actions + } + + override def getReturnValue: Option[UpdateRelationMutationPayload] = { + val (updatedLeftModel: Model, updatedRightModel: Model, finalRelation: Relation, updatedProject: Project) = getUpdatedProject + + Some( + UpdateRelationMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + leftModel = updatedLeftModel, + rightModel = updatedRightModel, + relation = finalRelation + )) + } + + def updateField(fieldNameArg: Option[String], + fieldListArg: Option[Boolean], + fieldRequiredArg: Option[Boolean], + modelChanged: Boolean, + existingField: models.Field): Option[models.Field] = { + + if (modelChanged || fieldNameArg.isDefined || fieldListArg.isDefined || fieldRequiredArg.isDefined) { + Some( + existingField.copy( + name = fieldNameArg.getOrElse(existingField.name), + isList = fieldListArg.getOrElse(existingField.isList), + isRequired = fieldRequiredArg.getOrElse(existingField.isRequired) + )) + } else + None + } + + def updateRelation(nameArg: Option[String], + descriptionArg: Option[String], + leftModelIdArg: Option[String], + rightModelIdArg: Option[String]): Option[models.Relation] = { + + if (nameArg.isDefined || descriptionArg.isDefined || leftModelIdArg.isDefined || rightModelIdArg.isDefined) { + Some( + relation.copy( + name = nameArg.getOrElse(relation.name), + description = descriptionArg match { + case Some(description) => Some(description) + case None => relation.description + }, + modelAId = leftModelIdArg.getOrElse(relation.modelAId), + modelBId = rightModelIdArg.getOrElse(relation.modelBId) + )) + } else None + } + + def isDifferent(arg: Option[Any], existing: Any) = arg.getOrElse(existing) != existing + + def modifiesModels = isDifferent(args.rightModelId, rightModel.id) || isDifferent(args.leftModelId, leftModel.id) + + def edgesExist = clientDbQueries.itemCountForRelation(relation).map(_ != 0) + + def getUpdatedProject: (Model, Model, Relation, Project) = { + val updatedLeftModel = leftModel.copy( + fields = + leftModel.fields + .filter(_.id != fieldOnLeftModel.id) :+ updatedFieldOnLeftModel + .getOrElse(fieldOnLeftModel)) + val updatedRightModel = rightModel.copy( + fields = + rightModel.fields + .filter(_.id != fieldOnRightModel.id) :+ updatedFieldOnRightModel + .getOrElse(fieldOnRightModel)) + val finalRelation = updatedRelation.getOrElse(relation) + + val updatedProject = project.copy( + models = project.models.map { + case x: Model if x.id == leftModel.id => updatedLeftModel + case x: Model if x.id == rightModel.id => updatedRightModel + case x => x + }, + relations = project.relations.map { + case x if x.id == finalRelation.id => finalRelation + case x => x + } + ) + (updatedLeftModel, updatedRightModel, finalRelation, updatedProject) + } +} + +case class UpdateRelationMutationPayload(clientMutationId: Option[String], + project: models.Project, + leftModel: models.Model, + rightModel: models.Model, + relation: models.Relation) + extends Mutation + +case class UpdateRelationInput(clientMutationId: Option[String], + id: String, + description: Option[String], + name: Option[String], + leftModelId: Option[String], + rightModelId: Option[String], + fieldOnLeftModelName: Option[String], + fieldOnRightModelName: Option[String], + fieldOnLeftModelIsList: Option[Boolean], + fieldOnRightModelIsList: Option[Boolean], + fieldOnLeftModelIsRequired: Option[Boolean], + fieldOnRightModelIsRequired: Option[Boolean]) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationPermissionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationPermissionMutation.scala new file mode 100644 index 0000000000..adef6c119d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRelationPermissionMutation.scala @@ -0,0 +1,109 @@ +package cool.graph.system.mutations + +import _root_.akka.actor.ActorSystem +import cool.graph._ +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateRelationPermission} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateRelationPermissionMutation( + client: models.Client, + project: models.Project, + relation: models.Relation, + relationPermission: models.RelationPermission, + args: UpdateRelationPermissionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)( + implicit inj: Injector, + actorSystem: ActorSystem +) extends InternalProjectMutation[UpdateRelationPermissionMutationPayload] { + + val updatedRelationPermission = + models.RelationPermission( + id = relationPermission.id, + connect = args.connect.getOrElse(relationPermission.connect), + disconnect = args.disconnect.getOrElse(relationPermission.disconnect), + userType = args.userType.getOrElse(relationPermission.userType), + rule = args.rule.getOrElse(relationPermission.rule), + ruleName = args.ruleName match { + case None => relationPermission.ruleName + case x => x + }, + ruleGraphQuery = args.ruleGraphQuery match { + case None => relationPermission.ruleGraphQuery + case x => x + }, + ruleGraphQueryFilePath = args.ruleGraphQueryFilePath match { + case None => relationPermission.ruleGraphQueryFilePath + case x => x + }, + ruleWebhookUrl = args.ruleWebhookUrl match { + case None => relationPermission.ruleWebhookUrl + case x => x + }, + description = args.description match { + case None => relationPermission.description + case x => x + }, + isActive = args.isActive.getOrElse(relationPermission.isActive) + ) + + override def prepareActions(): List[Mutaction] = { + +// updatedRelationPermission.ruleGraphQuery.foreach { query => +// val queriesWithSameOpCount = relation.permissions.count(_.operation == updatedRelationPermission.operation) // Todo this count may be wrong +// +// val queryName = updatedRelationPermission.ruleName match { +// case Some(nameForRule) => nameForRule +// case None => QueryPermissionHelper.alternativeNameFromOperationAndInt(updatedRelationPermission.operation, queriesWithSameOpCount) +// } +// +// val args = QueryPermissionHelper.permissionQueryArgsFromRelation(relation, project) +// val treatedQuery = QueryPermissionHelper.prependNameAndRenderQuery(query, queryName: String, args: List[(String, String)]) +// +// val violations = QueryPermissionHelper.validatePermissionQuery(treatedQuery, project) +// if (violations.nonEmpty) +// actions ++= List( +// InvalidInput(PermissionQueryIsInvalid(violations.mkString(""), updatedRelationPermission.ruleName.getOrElse(updatedRelationPermission.id)))) +// } + + actions :+= UpdateRelationPermission(relation = relation, oldPermission = relationPermission, permission = updatedRelationPermission) + + actions :+= BumpProjectRevision(project = project) + + actions :+= InvalidateSchema(project = project) + + actions + } + + override def getReturnValue: Option[UpdateRelationPermissionMutationPayload] = { + Some( + UpdateRelationPermissionMutationPayload( + clientMutationId = args.clientMutationId, + project = project, + relation = relation.copy(permissions = relation.permissions :+ updatedRelationPermission), + relationPermission = updatedRelationPermission + )) + } +} + +case class UpdateRelationPermissionMutationPayload(clientMutationId: Option[String], + project: models.Project, + relation: models.Relation, + relationPermission: models.RelationPermission) + extends Mutation + +case class UpdateRelationPermissionInput(clientMutationId: Option[String], + id: String, + connect: Option[Boolean], + disconnect: Option[Boolean], + userType: Option[models.UserType.Value], + rule: Option[models.CustomRule.Value], + ruleName: Option[String], + ruleGraphQuery: Option[String], + ruleWebhookUrl: Option[String], + description: Option[String], + isActive: Option[Boolean], + ruleGraphQueryFilePath: Option[String] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRequestPipelineMutationFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRequestPipelineMutationFunctionMutation.scala new file mode 100644 index 0000000000..1659f85ad0 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateRequestPipelineMutationFunctionMutation.scala @@ -0,0 +1,70 @@ +package cool.graph.system.mutations + +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models._ +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateFunction} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateRequestPipelineMutationFunctionMutation( + client: models.Client, + project: models.Project, + args: UpdateRequestPipelineMutationFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateRequestPipelineMutationFunctionMutationPayload] { + + val function: RequestPipelineFunction = project.getRequestPipelineFunction_!(args.functionId) + + val headers: Option[Seq[(String, String)]] = HttpFunctionHeaders.readOpt(args.headers) + + val updatedDelivery: FunctionDelivery = + function.delivery.update(headers, args.functionType, args.webhookUrl.map(_.trim), args.inlineCode, args.auth0Id, args.codeFilePath) + + val updatedFunction: RequestPipelineFunction = function.copy( + name = args.name.getOrElse(function.name), + isActive = args.isActive.getOrElse(function.isActive), + binding = args.binding.getOrElse(function.binding), + modelId = args.modelId.getOrElse(function.modelId), + operation = args.operation.getOrElse(function.operation), + delivery = updatedDelivery + ) + + val updatedProject = project.copy(functions = project.functions.filter(_.id != function.id) :+ updatedFunction) + + override def prepareActions(): List[Mutaction] = { + + this.actions = + List(UpdateFunction(project, newFunction = updatedFunction, oldFunction = function), BumpProjectRevision(project = project), InvalidateSchema(project)) + this.actions + } + + override def getReturnValue: Option[UpdateRequestPipelineMutationFunctionMutationPayload] = { + Some(UpdateRequestPipelineMutationFunctionMutationPayload(args.clientMutationId, updatedProject, updatedFunction)) + } +} + +case class UpdateRequestPipelineMutationFunctionMutationPayload(clientMutationId: Option[String], + project: models.Project, + function: models.RequestPipelineFunction) + extends Mutation + +case class UpdateRequestPipelineMutationFunctionInput(clientMutationId: Option[String], + functionId: String, + name: Option[String], + isActive: Option[Boolean], + binding: Option[FunctionBinding], + modelId: Option[String], + functionType: Option[FunctionType], + operation: Option[RequestPipelineOperation], + webhookUrl: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSchemaExtensionFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSchemaExtensionFunctionMutation.scala new file mode 100644 index 0000000000..54a73f9d3e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSchemaExtensionFunctionMutation.scala @@ -0,0 +1,71 @@ +package cool.graph.system.mutations + +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.{FunctionDelivery, SchemaExtensionFunction} +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateFunction} +import cool.graph.{InternalProjectMutation, Mutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateSchemaExtensionFunctionMutation( + client: models.Client, + project: models.Project, + args: UpdateSchemaExtensionFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateSchemaExtensionFunctionMutationPayload] { + + val function: SchemaExtensionFunction = project.getSchemaExtensionFunction_!(args.functionId) + val headers: Option[Seq[(String, String)]] = HttpFunctionHeaders.readOpt(args.headers) + val updatedDelivery: FunctionDelivery = + function.delivery.update(headers, args.functionType, args.webhookUrl.map(_.trim), args.inlineCode, args.auth0Id, args.codeFilePath) + + val updatedFunction: SchemaExtensionFunction = SchemaExtensionFunction.createFunction( + id = function.id, + name = args.name.getOrElse(function.name), + isActive = args.isActive.getOrElse(function.isActive), + schema = args.schema.getOrElse(function.schema), + delivery = updatedDelivery, + schemaFilePath = args.schemaFilePath + ) + + val updatedProject = project.copy(functions = project.functions.filter(_.id != function.id) :+ updatedFunction) + + override def prepareActions(): List[Mutaction] = { + this.actions = List( + UpdateFunction(project, newFunction = updatedFunction, oldFunction = function), + BumpProjectRevision(project = project), + InvalidateSchema(project) + ) + + this.actions + } + + override def getReturnValue: Option[UpdateSchemaExtensionFunctionMutationPayload] = { + Some(UpdateSchemaExtensionFunctionMutationPayload(args.clientMutationId, updatedProject, updatedFunction)) + } +} + +case class UpdateSchemaExtensionFunctionMutationPayload( + clientMutationId: Option[String], + project: models.Project, + function: models.SchemaExtensionFunction +) extends Mutation + +case class UpdateSchemaExtensionFunctionInput( + clientMutationId: Option[String], + functionId: String, + isActive: Option[Boolean], + name: Option[String], + schema: Option[String], + functionType: Option[FunctionType], + webhookUrl: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None, + schemaFilePath: Option[String] = None +) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSearchProviderAlgoliaMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSearchProviderAlgoliaMutation.scala new file mode 100644 index 0000000000..d29eb31410 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateSearchProviderAlgoliaMutation.scala @@ -0,0 +1,112 @@ +package cool.graph.system.mutations + +import cool.graph.cuid.Cuid +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.models +import cool.graph.shared.models.{IntegrationName, IntegrationType, SearchProviderAlgolia} +import cool.graph.system.mutactions.internal._ +import cool.graph.{InternalProjectMutation, Mutaction, SystemSqlMutaction} +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateSearchProviderAlgoliaMutation( + client: models.Client, + project: models.Project, + args: UpdateSearchProviderAlgoliaInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateSearchProviderAlgoliaPayload] { + + var searchProviderAlgolia: Option[SearchProviderAlgolia] = None + + override def prepareActions(): List[Mutaction] = { + val integration = + project.getIntegrationByTypeAndName(IntegrationType.SearchProvider, IntegrationName.SearchProviderAlgolia) + + val pendingMutactions: List[Mutaction] = integration match { + case Some(searchProvider) => + val existingSearchProviderAlgolia = + searchProvider.asInstanceOf[models.SearchProviderAlgolia] + var mutactions: List[SystemSqlMutaction] = List() + + searchProviderAlgolia = mergeInputValuesToSearchProviderAlgolia(existingSearchProviderAlgolia, args) + + mutactions :+= UpdateSearchProviderAlgolia(existingSearchProviderAlgolia, searchProviderAlgolia.get) + + if (existingSearchProviderAlgolia.isEnabled != args.isEnabled) { + mutactions :+= UpdateIntegration(project, existingSearchProviderAlgolia, searchProviderAlgolia.get) + } + + mutactions + + case None => + searchProviderAlgolia = generateNewSearchProviderAlgolia() + + // Need to add both separately, as on DB level these are two tables + List(addIntegrationToProject(searchProviderAlgolia.get), addSearchProviderAlgoliaToProject(searchProviderAlgolia.get)) + } + + actions = pendingMutactions :+ BumpProjectRevision(project = project) :+ InvalidateSchema(project = project) + actions + } + + override def getReturnValue: Option[UpdateSearchProviderAlgoliaPayload] = { + Some( + UpdateSearchProviderAlgoliaPayload( + clientMutationId = args.clientMutationId, + project = project.copy(integrations = + project.authProviders.filter(_.id != searchProviderAlgolia.get.id) :+ searchProviderAlgolia.get), + searchProviderAlgolia = searchProviderAlgolia.get + )) + } + + private def mergeInputValuesToSearchProviderAlgolia(existingAlgoliaSearchProvider: models.SearchProviderAlgolia, + updateValues: UpdateSearchProviderAlgoliaInput): Option[models.SearchProviderAlgolia] = { + Some( + existingAlgoliaSearchProvider.copy( + applicationId = updateValues.applicationId, + apiKey = updateValues.apiKey, + isEnabled = updateValues.isEnabled + ) + ) + } + + private def generateNewSearchProviderAlgolia(): Option[models.SearchProviderAlgolia] = { + Some( + models.SearchProviderAlgolia( + id = Cuid.createCuid(), + subTableId = Cuid.createCuid(), + applicationId = args.applicationId, + apiKey = args.apiKey, + algoliaSyncQueries = List(), + isEnabled = true, + name = IntegrationName.SearchProviderAlgolia + ) + ) + } + + private def addSearchProviderAlgoliaToProject(searchProviderAlgolia: models.SearchProviderAlgolia): Mutaction = { + CreateSearchProviderAlgolia( + project = project, + searchProviderAlgolia = searchProviderAlgolia + ) + } + + private def addIntegrationToProject(integration: models.Integration): Mutaction = { + CreateIntegration( + project = project, + integration = integration + ) + } +} + +case class UpdateSearchProviderAlgoliaPayload(clientMutationId: Option[String], project: models.Project, searchProviderAlgolia: models.SearchProviderAlgolia) + extends Mutation + +case class UpdateSearchProviderAlgoliaInput( + clientMutationId: Option[String], + projectId: String, + applicationId: String, + apiKey: String, + isEnabled: Boolean +) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateServerSideSubscriptionFunctionMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateServerSideSubscriptionFunctionMutation.scala new file mode 100644 index 0000000000..bcad9a3b92 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/mutations/UpdateServerSideSubscriptionFunctionMutation.scala @@ -0,0 +1,88 @@ +package cool.graph.system.mutations + +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.database.InternalAndProjectDbs +import cool.graph.shared.errors.UserInputErrors.ServerSideSubscriptionQueryIsInvalid +import cool.graph.shared.models +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.{FunctionDelivery, HttpFunction, ServerSideSubscriptionFunction} +import cool.graph.shared.mutactions.InvalidInput +import cool.graph.subscriptions.schemas.SubscriptionQueryValidator +import cool.graph.system.mutactions.internal.{BumpProjectRevision, InvalidateSchema, UpdateFunction} +import cool.graph.{InternalProjectMutation, Mutaction} +import org.scalactic.Bad +import sangria.relay.Mutation +import scaldi.Injector + +case class UpdateServerSideSubscriptionFunctionMutation( + client: models.Client, + project: models.Project, + args: UpdateServerSideSubscriptionFunctionInput, + projectDbsFn: models.Project => InternalAndProjectDbs +)(implicit inj: Injector) + extends InternalProjectMutation[UpdateServerSideSubscriptionFunctionMutationPayload] { + + val function: ServerSideSubscriptionFunction = project.getServerSideSubscriptionFunction_!(args.functionId) + + val headers: Option[Seq[(String, String)]] = HttpFunctionHeaders.readOpt(args.headers) + val updatedDelivery: FunctionDelivery = + function.delivery.update(headers, args.functionType, args.webhookUrl, args.inlineCode, args.auth0Id, args.codeFilePath) + + val updatedFunction: ServerSideSubscriptionFunction = function.copy( + name = args.name.getOrElse(function.name), + isActive = args.isActive.getOrElse(function.isActive), + query = args.query.getOrElse(function.query), + queryFilePath = args.queryFilePath, + delivery = updatedDelivery + ) + + val updatedProject = project.copy(functions = project.functions.filter(_.id != function.id) :+ updatedFunction) + + override def prepareActions(): List[Mutaction] = { + this.actions = List( + UpdateFunction(project, newFunction = updatedFunction, oldFunction = function), + BumpProjectRevision(project = project), + InvalidateSchema(project) + ) + if (args.query.isDefined) { + SubscriptionQueryValidator(project).validate(args.query.get) match { + case Bad(errors) => + val userError = ServerSideSubscriptionQueryIsInvalid(errors.head.errorMessage, updatedFunction.name) + this.actions :+= InvalidInput(userError) + case _ => // NO OP + } + } + + this.actions + } + + override def getReturnValue: Option[UpdateServerSideSubscriptionFunctionMutationPayload] = { + Some( + UpdateServerSideSubscriptionFunctionMutationPayload( + clientMutationId = args.clientMutationId, + project = updatedProject, + function = updatedFunction + )) + } +} + +case class UpdateServerSideSubscriptionFunctionMutationPayload( + clientMutationId: Option[String], + project: models.Project, + function: models.ServerSideSubscriptionFunction +) extends Mutation + +case class UpdateServerSideSubscriptionFunctionInput( + clientMutationId: Option[String], + functionId: String, + name: Option[String], + isActive: Option[Boolean], + query: Option[String], + functionType: Option[FunctionType], + webhookUrl: Option[String], + headers: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None, + queryFilePath: Option[String] = None +) diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAction.scala new file mode 100644 index 0000000000..99b07bcfc2 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAction.scala @@ -0,0 +1,74 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.ActionHandlerType.ActionHandlerType +import cool.graph.shared.models.ActionTriggerMutationModelMutationType._ +import cool.graph.shared.models.ActionTriggerType.ActionTriggerType +import cool.graph.system.mutations._ +import cool.graph.system.schema.types.{HandlerType, ModelMutationType, TriggerType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema +import sangria.schema.{OptionInputType, _} + +object AddAction { + + val handlerWebhook = InputObjectType( + name = "ActionHandlerWebhookEmbed", + fields = List( + InputField("url", StringType), + InputField("isAsync", OptionInputType(BooleanType)) + ) + ) + + val triggerMutationModel = InputObjectType( + name = "ActionTriggerModelMutationEmbed", + fields = List( + InputField("fragment", StringType), + InputField("modelId", IDType), + InputField("mutationType", ModelMutationType.Type) + ) + ) + + val inputFields = List( + InputField("projectId", IDType, description = ""), + InputField("isActive", BooleanType, description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("triggerType", TriggerType.Type, description = ""), + InputField("handlerType", HandlerType.Type, description = ""), + InputField("handlerWebhook", OptionInputType(handlerWebhook), description = ""), + InputField("triggerMutationModel", OptionInputType(triggerMutationModel), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddActionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddActionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + isActive = ad("isActive").asInstanceOf[Boolean], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + triggerType = ad("triggerType").asInstanceOf[ActionTriggerType], + handlerType = ad("handlerType").asInstanceOf[ActionHandlerType], + webhookUrl = ad + .get("handlerWebhook") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .map(_("url").asInstanceOf[String]), + webhookIsAsync = ad + .get("handlerWebhook") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .flatMap(_.get("isAsync").flatMap(_.asInstanceOf[Option[Boolean]])), + actionTriggerMutationModel = ad + .get("triggerMutationModel") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .map(x => + AddActionTriggerModelInput( + modelId = x("modelId").asInstanceOf[String], + mutationType = x("mutationType") + .asInstanceOf[ActionTriggerMutationModelMutationType], + fragment = x("fragment").asInstanceOf[String] + )) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..6e069c7d6b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddAlgoliaSyncQuery.scala @@ -0,0 +1,27 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.AddAlgoliaSyncQueryInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object AddAlgoliaSyncQuery { + val inputFields = List( + InputField("modelId", StringType, description = ""), + InputField("indexName", StringType, description = ""), + InputField("fragment", StringType, description = "") + ) + + implicit val manual = new FromInput[AddAlgoliaSyncQueryInput] { + val marshaller: CoercedScalaResultMarshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node): AddAlgoliaSyncQueryInput = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddAlgoliaSyncQueryInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelId = ad("modelId").asInstanceOf[String], + indexName = ad("indexName").asInstanceOf[String], + fragment = ad("fragment").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddEnum.scala new file mode 100644 index 0000000000..3dbac598db --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddEnum.scala @@ -0,0 +1,28 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{AddEnumInput, AddModelInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField, ListInputType, OptionInputType, StringType} + +object AddEnum { + val inputFields = + List( + InputField("projectId", IDType, description = ""), + InputField("name", StringType, description = ""), + InputField("values", ListInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddEnumInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + AddEnumInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + name = node.requiredArgAsString("name"), + values = node.requiredArgAs[Seq[String]]("values") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddField.scala new file mode 100644 index 0000000000..630929e4bc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddField.scala @@ -0,0 +1,44 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.TypeIdentifier +import cool.graph.system.mutations.AddFieldInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object AddField { + val inputFields = List( + InputField("modelId", IDType, description = ""), + InputField("name", StringType, description = ""), + InputField("typeIdentifier", StringType, description = ""), + InputField("isRequired", BooleanType, description = ""), + InputField("isList", BooleanType, description = ""), + InputField("isUnique", BooleanType, description = ""), + InputField("relationId", OptionInputType(StringType), description = ""), + InputField("enumId", OptionInputType(IDType), description = ""), + InputField("defaultValue", OptionInputType(StringType), description = ""), + InputField("migrationValue", OptionInputType(StringType), description = ""), + InputField("description", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddFieldInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node): AddFieldInput = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddFieldInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelId = ad("modelId").asInstanceOf[String], + name = ad("name").asInstanceOf[String], + typeIdentifier = TypeIdentifier.withName(ad("typeIdentifier").asInstanceOf[String]), + isRequired = ad("isRequired").asInstanceOf[Boolean], + isList = ad("isList").asInstanceOf[Boolean], + isUnique = ad("isUnique").asInstanceOf[Boolean], + relationId = ad.get("relationId").flatMap(_.asInstanceOf[Option[String]]), + enumId = ad.get("enumId").flatMap(_.asInstanceOf[Option[String]]), + defaultValue = ad.get("defaultValue").flatMap(_.asInstanceOf[Option[String]]), + migrationValue = ad.get("migrationValue").flatMap(_.asInstanceOf[Option[String]]), + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddFieldConstraint.scala new file mode 100644 index 0000000000..8d4009fb9b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddFieldConstraint.scala @@ -0,0 +1,66 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FieldConstraintType.FieldConstraintType +import cool.graph.system.mutations.AddFieldConstraintInput +import cool.graph.system.schema.types.FieldConstraintTypeType +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{BooleanType, FloatType, IDType, InputField, IntType, ListInputType, OptionInputType, StringType} + +object AddFieldConstraint { + val inputFields = + List( + InputField("fieldId", IDType, description = ""), + InputField("constraintType", FieldConstraintTypeType.Type, description = ""), + InputField("equalsString", OptionInputType(StringType), description = ""), + InputField("oneOfString", OptionInputType(ListInputType(StringType)), description = ""), + InputField("minLength", OptionInputType(IntType), description = ""), + InputField("maxLength", OptionInputType(IntType), description = ""), + InputField("startsWith", OptionInputType(StringType), description = ""), + InputField("endsWith", OptionInputType(StringType), description = ""), + InputField("includes", OptionInputType(StringType), description = ""), + InputField("regex", OptionInputType(StringType), description = ""), + InputField("equalsNumber", OptionInputType(FloatType), description = ""), + InputField("oneOfNumber", OptionInputType(ListInputType(FloatType)), description = ""), + InputField("min", OptionInputType(FloatType), description = ""), + InputField("max", OptionInputType(FloatType), description = ""), + InputField("exclusiveMin", OptionInputType(FloatType), description = ""), + InputField("exclusiveMax", OptionInputType(FloatType), description = ""), + InputField("multipleOf", OptionInputType(FloatType), description = ""), + InputField("equalsBoolean", OptionInputType(BooleanType), description = ""), + InputField("uniqueItems", OptionInputType(BooleanType), description = ""), + InputField("minItems", OptionInputType(IntType), description = ""), + InputField("maxItems", OptionInputType(IntType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddFieldConstraintInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + AddFieldConstraintInput( + clientMutationId = node.clientMutationId, + fieldId = node.requiredArgAsString("fieldId"), + constraintType = node.requiredArgAs[FieldConstraintType]("constraintType"), + equalsString = node.optionalArgAs[String]("equalsString"), + oneOfString = node.optionalArgAs[Seq[String]]("oneOfString"), + minLength = node.optionalArgAs[Int]("minLength"), + maxLength = node.optionalArgAs[Int]("maxLength"), + startsWith = node.optionalArgAs[String]("startsWith"), + endsWith = node.optionalArgAs[String]("endsWith"), + includes = node.optionalArgAs[String]("includes"), + regex = node.optionalArgAs[String]("regex"), + equalsNumber = node.optionalArgAs[Double]("equalsNumber"), + oneOfNumber = node.optionalArgAs[Seq[Double]]("oneOfNumber"), + min = node.optionalArgAs[Double]("min"), + max = node.optionalArgAs[Double]("max"), + exclusiveMin = node.optionalArgAs[Double]("exclusiveMin"), + exclusiveMax = node.optionalArgAs[Double]("exclusiveMax"), + multipleOf = node.optionalArgAs[Double]("multipleOf"), + equalsBoolean = node.optionalArgAs[Boolean]("equalsBoolean"), + uniqueItems = node.optionalArgAs[Boolean]("uniqueItems"), + minItems = node.optionalArgAs[Int]("minItems"), + maxItems = node.optionalArgAs[Int]("maxItems") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModel.scala new file mode 100644 index 0000000000..828c297387 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModel.scala @@ -0,0 +1,28 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.AddModelInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object AddModel { + val inputFields = List( + InputField("projectId", IDType, description = ""), + InputField("modelName", StringType, description = ""), + InputField("description", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddModelInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddModelInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + modelName = ad("modelName").asInstanceOf[String], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + fieldPositions = None + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModelPermission.scala new file mode 100644 index 0000000000..b281d79f05 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddModelPermission.scala @@ -0,0 +1,47 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.ModelOperation.ModelOperation +import cool.graph.shared.models.UserType.UserType +import cool.graph.system.mutations.AddModelPermissionInput +import cool.graph.system.schema.types.{Operation, Rule, UserType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{ListInputType, OptionInputType, _} + +object AddModelPermission { + val inputFields = List( + InputField("modelId", IDType, description = ""), + InputField("operation", Operation.Type, description = ""), + InputField("userType", UserType.Type, description = ""), + InputField("rule", Rule.Type, description = ""), + InputField("ruleName", OptionInputType(StringType), description = ""), + InputField("ruleGraphQuery", OptionInputType(StringType), description = ""), + InputField("ruleWebhookUrl", OptionInputType(StringType), description = ""), + InputField("fieldIds", ListInputType(StringType), description = ""), + InputField("applyToWholeModel", BooleanType, description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("isActive", BooleanType, description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddModelPermissionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddModelPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelId = ad("modelId").asInstanceOf[String], + operation = ad("operation").asInstanceOf[ModelOperation], + userType = ad("userType").asInstanceOf[UserType], + rule = ad("rule").asInstanceOf[CustomRule], + ruleName = ad.get("ruleName").flatMap(_.asInstanceOf[Option[String]]), + ruleGraphQuery = ad.get("ruleGraphQuery").flatMap(_.asInstanceOf[Option[String]]), + ruleWebhookUrl = ad.get("ruleWebhookUrl").flatMap(_.asInstanceOf[Option[String]]), + fieldIds = ad("fieldIds").asInstanceOf[Vector[String]].toList, + applyToWholeModel = ad("applyToWholeModel").asInstanceOf[Boolean], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + isActive = ad("isActive").asInstanceOf[Boolean] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddProject.scala new file mode 100644 index 0000000000..7c4a836ee9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddProject.scala @@ -0,0 +1,36 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models +import cool.graph.system.mutations.AddProjectInput +import cool.graph.system.schema.types.Region +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object AddProject { + val inputFields = List( + InputField("name", StringType, description = ""), + InputField("alias", OptionInputType(StringType), description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("schema", OptionInputType(StringType), description = ""), + InputField("region", OptionInputType(Region.Type), description = ""), + InputField("config", OptionInputType(StringType), description = "") + ) + + implicit val manual = new FromInput[AddProjectInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddProjectInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + name = ad("name").asInstanceOf[String], + alias = ad.get("alias").flatMap(_.asInstanceOf[Option[String]]), + webhookUrl = ad.get("webhookUrl").flatMap(_.asInstanceOf[Option[String]]), + schema = ad.get("schema").flatMap(_.asInstanceOf[Option[String]]), + region = ad.get("region").flatMap(_.asInstanceOf[Option[models.Region.Region]]).getOrElse(models.Region.EU_WEST_1), + projectDatabaseId = None, + config = ad.get("config").flatMap(_.asInstanceOf[Option[String]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelation.scala new file mode 100644 index 0000000000..ca7f7e1db3 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelation.scala @@ -0,0 +1,43 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.AddRelationInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object AddRelation { + val inputFields = List( + InputField("projectId", IDType, description = ""), + InputField("leftModelId", IDType, description = ""), + InputField("rightModelId", IDType, description = ""), + InputField("fieldOnLeftModelName", StringType, description = ""), + InputField("fieldOnRightModelName", StringType, description = ""), + InputField("fieldOnLeftModelIsList", BooleanType, description = ""), + InputField("fieldOnRightModelIsList", BooleanType, description = ""), + InputField("fieldOnLeftModelIsRequired", OptionInputType(BooleanType), description = "Defaults to false. Can only be true for non-list relation fields"), + InputField("fieldOnRightModelIsRequired", OptionInputType(BooleanType), description = "Defaults to false. Can only be true for non-list relation fields"), + InputField("name", StringType, description = ""), + InputField("description", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddRelationInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddRelationInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + leftModelId = ad("leftModelId").asInstanceOf[String], + rightModelId = ad("rightModelId").asInstanceOf[String], + fieldOnLeftModelName = ad("fieldOnLeftModelName").asInstanceOf[String], + fieldOnRightModelName = ad("fieldOnRightModelName").asInstanceOf[String], + fieldOnLeftModelIsList = ad("fieldOnLeftModelIsList").asInstanceOf[Boolean], + fieldOnRightModelIsList = ad("fieldOnRightModelIsList").asInstanceOf[Boolean], + fieldOnLeftModelIsRequired = ad.get("fieldOnLeftModelIsRequired").flatMap(_.asInstanceOf[Option[Boolean]]).getOrElse(false), + fieldOnRightModelIsRequired = ad.get("fieldOnRightModelIsRequired").flatMap(_.asInstanceOf[Option[Boolean]]).getOrElse(false), + name = ad("name").asInstanceOf[String], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationFieldMirror.scala new file mode 100644 index 0000000000..01f996ef22 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationFieldMirror.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.AddRelationFieldMirrorInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object AddRelationFieldMirror { + + val inputFields = + List(InputField("fieldId", IDType, description = ""), InputField("relationId", IDType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddRelationFieldMirrorInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddRelationFieldMirrorInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + fieldId = ad("fieldId").asInstanceOf[String], + relationId = ad("relationId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationPermission.scala new file mode 100644 index 0000000000..85e249029d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRelationPermission.scala @@ -0,0 +1,44 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.UserType.UserType +import cool.graph.system.mutations.AddRelationPermissionInput +import cool.graph.system.schema.types.{Rule, UserType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object AddRelationPermission { + val inputFields = List( + InputField("relationId", IDType, description = ""), + InputField("connect", BooleanType, description = ""), + InputField("disconnect", BooleanType, description = ""), + InputField("userType", UserType.Type, description = ""), + InputField("rule", Rule.Type, description = ""), + InputField("ruleName", OptionInputType(StringType), description = ""), + InputField("ruleGraphQuery", OptionInputType(StringType), description = ""), + InputField("ruleWebhookUrl", OptionInputType(StringType), description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("isActive", BooleanType, description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddRelationPermissionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AddRelationPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + relationId = ad("relationId").asInstanceOf[String], + connect = ad("connect").asInstanceOf[Boolean], + disconnect = ad("disconnect").asInstanceOf[Boolean], + userType = ad("userType").asInstanceOf[UserType], + rule = ad("rule").asInstanceOf[CustomRule], + ruleName = ad.get("ruleName").flatMap(_.asInstanceOf[Option[String]]), + ruleGraphQuery = ad.get("ruleGraphQuery").flatMap(_.asInstanceOf[Option[String]]), + ruleWebhookUrl = ad.get("ruleWebhookUrl").flatMap(_.asInstanceOf[Option[String]]), + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + isActive = ad("isActive").asInstanceOf[Boolean] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRequestPipelineMutationFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRequestPipelineMutationFunction.scala new file mode 100644 index 0000000000..c2a7900f85 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddRequestPipelineMutationFunction.scala @@ -0,0 +1,48 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.system.mutations.AddRequestPipelineMutationFunctionInput +import cool.graph.system.schema.types.{FunctionBinding, FunctionType, RequestPipelineMutationOperation} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField, OptionInputType, StringType} + +object AddRequestPipelineMutationFunction { + val inputFields: List[InputField[Any]] = + List( + InputField("projectId", IDType, description = ""), + InputField("name", StringType, description = ""), + InputField("isActive", sangria.schema.BooleanType, description = ""), + InputField("binding", FunctionBinding.Type, description = ""), + InputField("modelId", StringType, description = ""), + InputField("operation", RequestPipelineMutationOperation.Type, description = ""), + InputField("type", FunctionType.Type, description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddRequestPipelineMutationFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller: CoercedScalaResultMarshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + AddRequestPipelineMutationFunctionInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + name = node.requiredArgAsString("name"), + isActive = node.requiredArgAs[Boolean]("isActive"), + binding = node.requiredArgAs[FunctionBinding]("binding"), + modelId = node.requiredArgAs[String]("modelId"), + operation = node.requiredArgAs[RequestPipelineOperation]("operation"), + functionType = node.requiredArgAs[FunctionType]("type"), + webhookUrl = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddSchemaExtensionFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddSchemaExtensionFunction.scala new file mode 100644 index 0000000000..637ab42242 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddSchemaExtensionFunction.scala @@ -0,0 +1,42 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.system.mutations.AddSchemaExtensionFunctionInput +import cool.graph.system.schema.types.FunctionType +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object AddSchemaExtensionFunction { + + val inputFields = + List( + InputField("projectId", IDType, description = ""), + InputField("isActive", BooleanType, description = ""), + InputField("name", StringType, description = ""), + InputField("schema", StringType, description = ""), + InputField("type", FunctionType.Type, description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddSchemaExtensionFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + AddSchemaExtensionFunctionInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + name = node.requiredArgAsString("name"), + isActive = node.requiredArgAs[Boolean]("isActive"), + schema = node.requiredArgAsString("schema"), + functionType = node.requiredArgAs[FunctionType]("type"), + url = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddServerSideSubscriptionFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddServerSideSubscriptionFunction.scala new file mode 100644 index 0000000000..e7c18eb5cc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AddServerSideSubscriptionFunction.scala @@ -0,0 +1,43 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.system.mutations.AddServerSideSubscriptionFunctionInput +import cool.graph.system.schema.types.FunctionType +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object AddServerSideSubscriptionFunction { + + val inputFields: List[InputField[Any]] = + List( + InputField("projectId", IDType, description = ""), + InputField("name", StringType, description = ""), + InputField("isActive", BooleanType, description = ""), + InputField("query", StringType, description = ""), + InputField("type", FunctionType.Type, description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[AddServerSideSubscriptionFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + AddServerSideSubscriptionFunctionInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + name = node.requiredArgAsString("name"), + isActive = node.requiredArgAs[Boolean]("isActive"), + query = node.requiredArgAsString("query"), + functionType = node.requiredArgAs[FunctionType]("type"), + url = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AuthenticateCustomer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AuthenticateCustomer.scala new file mode 100644 index 0000000000..697bbcb9da --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/AuthenticateCustomer.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.AuthenticateCustomerInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object AuthenticateCustomer { + val inputFields = List( + InputField("auth0IdToken", StringType, description = "") + ) + + implicit val manual = new FromInput[AuthenticateCustomerInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + AuthenticateCustomerInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + auth0IdToken = ad("auth0IdToken").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CloneProjectQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CloneProjectQuery.scala new file mode 100644 index 0000000000..7ef816be84 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CloneProjectQuery.scala @@ -0,0 +1,29 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{CloneProjectInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object CloneProjectQuery { + val inputFields = List( + InputField("projectId", StringType, description = ""), + InputField("name", StringType, description = ""), + InputField("includeData", BooleanType, description = ""), + InputField("includeMutationCallbacks", BooleanType, description = "") + ) + + implicit val manual = new FromInput[CloneProjectInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + CloneProjectInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + name = ad("name").asInstanceOf[String], + includeData = ad("includeData").asInstanceOf[Boolean], + includeMutationCallbacks = ad("includeMutationCallbacks").asInstanceOf[Boolean] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CreateRootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CreateRootToken.scala new file mode 100644 index 0000000000..1d23c6ef17 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/CreateRootToken.scala @@ -0,0 +1,28 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{CreateRootTokenInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object CreateRootToken { + + val inputFields = List( + InputField("projectId", IDType, description = ""), + InputField("name", StringType, description = ""), + InputField("description", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[CreateRootTokenInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + CreateRootTokenInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + name = ad("name").asInstanceOf[String], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAction.scala new file mode 100644 index 0000000000..54f75845e5 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAction.scala @@ -0,0 +1,27 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.ActionHandlerType.ActionHandlerType +import cool.graph.shared.models.ActionTriggerMutationModelMutationType._ +import cool.graph.shared.models.ActionTriggerType.ActionTriggerType +import cool.graph.system.mutations._ +import cool.graph.system.schema.types.{HandlerType, ModelMutationType, TriggerType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema +import sangria.schema.{OptionInputType, _} + +object DeleteAction { + + val inputFields = List(InputField("actionId", IDType, description = "")).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[DeleteActionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteActionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + actionId = ad("actionId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..84f4c386b9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteAlgoliaSyncQuery.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteAlgoliaSyncQueryInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteAlgoliaSyncQuery { + val inputFields = List( + InputField("algoliaSyncQueryId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteAlgoliaSyncQueryInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteAlgoliaSyncQueryInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + algoliaSyncQueryId = ad("algoliaSyncQueryId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteCustomer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteCustomer.scala new file mode 100644 index 0000000000..d9c9c97383 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteCustomer.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteCustomerInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteCustomer { + val inputFields = List( + InputField("customerId", StringType, description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[DeleteCustomerInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteCustomerInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + customerId = ad("customerId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteEnum.scala new file mode 100644 index 0000000000..f47858f363 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteEnum.scala @@ -0,0 +1,22 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteEnumInput, UpdateEnumInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField, ListInputType, OptionInputType, StringType} + +object DeleteEnum { + val inputFields = + List(InputField("enumId", IDType, description = "")).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[DeleteEnumInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + DeleteEnumInput( + clientMutationId = node.clientMutationId, + enumId = node.requiredArgAsString("enumId") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteField.scala new file mode 100644 index 0000000000..e448c08a65 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteField.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteFieldInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteField { + val inputFields = List( + InputField("fieldId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteFieldInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteFieldInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + fieldId = ad("fieldId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFieldConstraint.scala new file mode 100644 index 0000000000..070e94750f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFieldConstraint.scala @@ -0,0 +1,21 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteFieldConstraintInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField} + +object DeleteFieldConstraint { + val inputFields = List(InputField("constraintId", IDType, description = "")).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[DeleteFieldConstraintInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + DeleteFieldConstraintInput( + clientMutationId = node.clientMutationId, + constraintId = node.requiredArgAsString("constraintId") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFunction.scala new file mode 100644 index 0000000000..519d4fdd67 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteFunction.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteFunctionInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteFunction { + val inputFields = List( + InputField("functionId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteFunctionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteFunctionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + functionId = ad("functionId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModel.scala new file mode 100644 index 0000000000..4ef7746df4 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModel.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteModelInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteModel { + val inputFields = List( + InputField("modelId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteModelInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteModelInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelId = ad("modelId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModelPermission.scala new file mode 100644 index 0000000000..b3b6fde676 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteModelPermission.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteModelPermissionInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteModelPermission { + val inputFields = List( + InputField("modelPermissionId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteModelPermissionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteModelPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelPermissionId = ad("modelPermissionId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteProject.scala new file mode 100644 index 0000000000..b32fe1ba21 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteProject.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteProjectInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteProject { + val inputFields = List( + InputField("projectId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteProjectInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteProjectInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelation.scala new file mode 100644 index 0000000000..813d779458 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelation.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteModelInput, DeleteRelationInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteRelation { + val inputFields = List( + InputField("relationId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteRelationInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteRelationInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + relationId = ad("relationId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationFieldMirror.scala new file mode 100644 index 0000000000..5743c7b0da --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationFieldMirror.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteModelInput, DeleteRelationFieldMirrorInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteRelationFieldMirror { + val inputFields = List( + InputField("relationFieldMirrorId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteRelationFieldMirrorInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteRelationFieldMirrorInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + relationFieldMirrorId = ad("relationFieldMirrorId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationPermission.scala new file mode 100644 index 0000000000..ae7c7cda04 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRelationPermission.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteModelPermissionInput, DeleteRelationPermissionInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteRelationPermission { + val inputFields = List( + InputField("relationPermissionId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteRelationPermissionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteRelationPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + relationPermissionId = ad("relationPermissionId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRootToken.scala new file mode 100644 index 0000000000..14a820ef63 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/DeleteRootToken.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.DeleteRootTokenInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object DeleteRootToken { + val inputFields = List( + InputField("permanentAuthTokenId", StringType, description = "") + ) + + implicit val manual = new FromInput[DeleteRootTokenInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + DeleteRootTokenInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + rootTokenId = ad("permanentAuthTokenId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EjectProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EjectProject.scala new file mode 100644 index 0000000000..1e0ea37300 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EjectProject.scala @@ -0,0 +1,19 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.EjectProjectInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField} + +object EjectProject { + val inputFields = List(InputField("projectId", IDType, description = "")).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[EjectProjectInput] { + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + EjectProjectInput(ad.get("clientMutationId").map(_.asInstanceOf[String]), ad("projectId").asInstanceOf[String]) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EnableAuthProvider.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EnableAuthProvider.scala new file mode 100644 index 0000000000..80a07ce183 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/EnableAuthProvider.scala @@ -0,0 +1,64 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.EnableAuthProviderInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object EnableAuthProvider { + lazy val DigitsType = InputObjectType( + name = "AuthProviderDigitsMetaInput", + fields = List( + InputField("consumerKey", StringType), + InputField("consumerSecret", StringType) + ) + ) + lazy val Auth0Type = InputObjectType( + name = "AuthProviderAuth0MetaInput", + fields = List( + InputField("clientId", StringType), + InputField("clientSecret", StringType), + InputField("domain", StringType) + ) + ) + + val inputFields = List( + InputField("id", IDType, description = ""), + InputField("isEnabled", BooleanType, description = ""), + InputField("digits", OptionInputType(DigitsType)), + InputField("auth0", OptionInputType(Auth0Type)) + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[EnableAuthProviderInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + EnableAuthProviderInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + id = ad("id").asInstanceOf[String], + isEnabled = ad("isEnabled").asInstanceOf[Boolean], +// authProvider = ad("type").asInstanceOf[IntegrationName], + digitsConsumerKey = ad + .get("digits") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]].map(_("consumerKey"))) + .map(_.asInstanceOf[String]), + digitsConsumerSecret = ad + .get("digits") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]].map(_("consumerSecret"))) + .map(_.asInstanceOf[String]), + auth0ClientId = ad + .get("auth0") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]].map(_("clientId"))) + .map(_.asInstanceOf[String]), + auth0ClientSecret = ad + .get("auth0") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]].map(_("clientSecret"))) + .map(_.asInstanceOf[String]), + auth0Domain = ad + .get("auth0") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]].map(_("domain"))) + .map(_.asInstanceOf[String]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ExportData.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ExportData.scala new file mode 100644 index 0000000000..d3d9f6e230 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ExportData.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.ExportDataInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object ExportData { + val inputFields = List( + InputField("projectId", StringType, description = "") + ) + + implicit val manual = new FromInput[ExportDataInput] { + val marshaller: CoercedScalaResultMarshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node): ExportDataInput = { + val ad = node.asInstanceOf[Map[String, Any]] + + ExportDataInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GenerateUserToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GenerateUserToken.scala new file mode 100644 index 0000000000..7b5fa0476c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GenerateUserToken.scala @@ -0,0 +1,61 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations._ +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object GenerateUserToken { + + val inputFields = + List( + InputField("pat", StringType, description = ""), + InputField("projectId", IDType, description = ""), + InputField("userId", IDType, description = ""), + InputField("modelName", IDType, description = ""), + InputField("expirationInSeconds", OptionInputType(IntType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[GenerateUserTokenInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + GenerateUserTokenInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + pat = ad("pat").asInstanceOf[String], + projectId = ad("projectId").asInstanceOf[String], + userId = ad("userId").asInstanceOf[String], + modelName = ad("modelName").asInstanceOf[String], + expirationInSeconds = ad.get("expirationInSeconds").flatMap(_.asInstanceOf[Option[Int]]) + ) + } + } +} + +object GenerateNodeToken { + + val inputFields = + List( + InputField("rootToken", StringType, description = ""), + InputField("serviceId", IDType, description = ""), + InputField("nodeId", IDType, description = ""), + InputField("modelName", IDType, description = ""), + InputField("expirationInSeconds", OptionInputType(IntType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[GenerateUserTokenInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + GenerateUserTokenInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + pat = ad("rootToken").asInstanceOf[String], + projectId = ad("serviceId").asInstanceOf[String], + userId = ad("nodeId").asInstanceOf[String], + modelName = ad("modelName").asInstanceOf[String], + expirationInSeconds = ad.get("expirationInSeconds").flatMap(_.asInstanceOf[Option[Int]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GetTemporaryDeploymentUrl.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GetTemporaryDeploymentUrl.scala new file mode 100644 index 0000000000..546e437f14 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/GetTemporaryDeploymentUrl.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{MigrateSchemaInput, PushInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +case class GetTemporaryDeployUrlInput(projectId: String) + +object GetTemporaryDeploymentUrl { + val inputFields = List( + InputField("projectId", StringType, description = "") + ) + + implicit val fromInput = new FromInput[GetTemporaryDeployUrlInput] { + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + GetTemporaryDeployUrlInput( + projectId = ad("projectId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InstallPackage.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InstallPackage.scala new file mode 100644 index 0000000000..a837b64fa5 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InstallPackage.scala @@ -0,0 +1,24 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.InstallPackageInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object InstallPackage { + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("definition", StringType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[InstallPackageInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + InstallPackageInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + definition = ad("definition").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InviteCollaborator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InviteCollaborator.scala new file mode 100644 index 0000000000..47f553fa44 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/InviteCollaborator.scala @@ -0,0 +1,24 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.InviteCollaboratorInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object InviteCollaborator { + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("email", StringType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[InviteCollaboratorInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + InviteCollaboratorInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + email = ad("email").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/MigrateSchema.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/MigrateSchema.scala new file mode 100644 index 0000000000..01dfb4b9c9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/MigrateSchema.scala @@ -0,0 +1,28 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.MigrateSchemaInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object MigrateSchema { + val inputFields = List( + InputField("newSchema", StringType, description = ""), + InputField("isDryRun", BooleanType, description = "If set to false the migration is not performed."), + InputField("force", OptionInputType(BooleanType), description = "If set to false the migration will fail if data would be lost. Defaults to false.") + ) + + implicit val fromInput = new FromInput[MigrateSchemaInput] { + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + MigrateSchemaInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + newSchema = ad("newSchema").asInstanceOf[String], + isDryRun = ad("isDryRun").asInstanceOf[Boolean], + force = ad.get("force").flatMap(_.asInstanceOf[Option[Boolean]]).getOrElse(false) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/Push.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/Push.scala new file mode 100644 index 0000000000..2c5338725a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/Push.scala @@ -0,0 +1,32 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{MigrateSchemaInput, PushInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object Push { + val inputFields = List( + InputField("projectId", StringType, description = ""), + InputField("version", IntType, description = ""), + InputField("config", StringType, description = ""), + InputField("isDryRun", BooleanType, description = "If set to false the migration is not performed."), + InputField("force", OptionInputType(BooleanType), description = "If set to false the migration will fail if data would be lost. Defaults to false.") + ) + + implicit val fromInput = new FromInput[PushInput] { + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + PushInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + version = ad("version").asInstanceOf[Int], + config = ad("config").asInstanceOf[String], + isDryRun = ad("isDryRun").asInstanceOf[Boolean], + force = ad.get("force").flatMap(_.asInstanceOf[Option[Boolean]]).getOrElse(false) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/RemoveCollaborator.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/RemoveCollaborator.scala new file mode 100644 index 0000000000..1d1dd978c8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/RemoveCollaborator.scala @@ -0,0 +1,24 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{RemoveCollaboratorInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object RemoveCollaborator { + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("email", StringType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[RemoveCollaboratorInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + RemoveCollaboratorInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + email = ad("email").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetClientPassword.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetClientPassword.scala new file mode 100644 index 0000000000..a172b92642 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetClientPassword.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{ResetClientPasswordInput, UpdateClientPasswordInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object ResetClientPassword { + val inputFields = List( + InputField("resetPasswordToken", StringType, description = ""), + InputField("newPassword", StringType, description = "") + ) + + implicit val manual = new FromInput[ResetClientPasswordInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + ResetClientPasswordInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + resetPasswordToken = ad("resetPasswordToken").asInstanceOf[String], + newPassword = ad("newPassword").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectData.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectData.scala new file mode 100644 index 0000000000..0d489a3958 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectData.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{ResetProjectDataInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object ResetProjectData { + val inputFields = List( + InputField("projectId", StringType, description = "") + ) + + implicit val manual = new FromInput[ResetProjectDataInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + ResetProjectDataInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectSchema.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectSchema.scala new file mode 100644 index 0000000000..c677acfffe --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/ResetProjectSchema.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.ResetProjectSchemaInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object ResetProjectSchema { + val inputFields = List( + InputField("projectId", StringType, description = "") + ) + + implicit val manual = new FromInput[ResetProjectSchemaInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + ResetProjectSchemaInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetFeatureToggle.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetFeatureToggle.scala new file mode 100644 index 0000000000..108e8e0173 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetFeatureToggle.scala @@ -0,0 +1,30 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{SetFeatureToggleInput, UpdateProjectInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{BooleanType, InputField, OptionInputType, StringType} + +object SetFeatureToggle { + val inputFields = List( + InputField("projectId", StringType, description = ""), + InputField("name", StringType, description = ""), + InputField("isEnabled", BooleanType, description = "") + ) + + implicit val manual = new FromInput[SetFeatureToggleInput] { + val marshaller = CoercedScalaResultMarshaller.default + + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + def fromResult(node: marshaller.Node) = { + SetFeatureToggleInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + name = node.requiredArgAsString("name"), + isEnabled = node.requiredArgAs[Boolean]("isEnabled") + ) + } + } + + val trusted = TrustedMutation(inputFields, manual) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetProjectDatabase.scala new file mode 100644 index 0000000000..d4f9e03f6a --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SetProjectDatabase.scala @@ -0,0 +1,27 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.SetProjectDatabaseInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{InputField, StringType} + +object SetProjectDatabase { + val inputFields = List( + InputField("projectId", StringType, description = ""), + InputField("projectDatabaseId", StringType, description = "") + ) + + implicit val manual = new FromInput[SetProjectDatabaseInput] { + val marshaller = CoercedScalaResultMarshaller.default + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + def fromResult(node: marshaller.Node) = { + SetProjectDatabaseInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("projectId"), + projectDatabaseId = node.requiredArgAsString("projectDatabaseId") + ) + } + } + + val trusted = TrustedMutation(inputFields, manual) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SigninClientUser.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SigninClientUser.scala new file mode 100644 index 0000000000..0c73801c2b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/SigninClientUser.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations._ +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object SigninClientUser { + + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("clientUserId", IDType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[SigninClientUserInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + SigninClientUserInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + clientUserId = ad("clientUserId").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TransferOwnership.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TransferOwnership.scala new file mode 100644 index 0000000000..b34fa337d2 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TransferOwnership.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.TransferOwnershipInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object TransferOwnership { + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("email", StringType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[TransferOwnershipInput] { + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + TransferOwnershipInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + email = ad("email").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TrustedMutation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TrustedMutation.scala new file mode 100644 index 0000000000..1a70709746 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/TrustedMutation.scala @@ -0,0 +1,22 @@ +package cool.graph.system.schema.fields + +import cool.graph.TrustedInternalMutationInput +import cool.graph.system.mutations.SetProjectDatabaseInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{InputField, StringType} + +case class TrustedMutation[T](originalInputFields: List[InputField[_]], fromInput: FromInput[T]) { + val inputFields = originalInputFields :+ InputField("secret", StringType, description = "") + + implicit val manual = new FromInput[TrustedInternalMutationInput[T]] { + val marshaller = fromInput.marshaller + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + def fromResult(node: marshaller.Node) = { + TrustedInternalMutationInput( + secret = node.requiredArgAsString("secret"), + mutationInput = fromInput.fromResult(node.asInstanceOf[fromInput.marshaller.Node]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UninstallPackage.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UninstallPackage.scala new file mode 100644 index 0000000000..2ba6f0e01b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UninstallPackage.scala @@ -0,0 +1,24 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UninstallPackageInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UninstallPackage { + val inputFields = + List(InputField("projectId", IDType, description = ""), InputField("name", StringType, description = "")) + .asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UninstallPackageInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UninstallPackageInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + name = ad("name").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAction.scala new file mode 100644 index 0000000000..8655c6006f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAction.scala @@ -0,0 +1,61 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.ActionHandlerType.ActionHandlerType +import cool.graph.shared.models.ActionTriggerMutationModelMutationType._ +import cool.graph.shared.models.ActionTriggerType.ActionTriggerType +import cool.graph.system.mutations._ +import cool.graph.system.schema.types.{HandlerType, ModelMutationType, TriggerType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema +import sangria.schema.{OptionInputType, _} + +object UpdateAction { + + val inputFields = List( + InputField("actionId", IDType, description = ""), + InputField("isActive", OptionInputType(BooleanType), description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("triggerType", OptionInputType(TriggerType.Type), description = ""), + InputField("handlerType", OptionInputType(HandlerType.Type), description = ""), + InputField("handlerWebhook", OptionInputType(AddAction.handlerWebhook), description = ""), + InputField("triggerMutationModel", OptionInputType(AddAction.triggerMutationModel), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateActionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateActionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + actionId = ad("actionId").asInstanceOf[String], + isActive = ad.get("isActive").flatMap(_.asInstanceOf[Option[Boolean]]), + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + triggerType = ad + .get("triggerType") + .flatMap(_.asInstanceOf[Option[ActionTriggerType]]), + handlerType = ad + .get("handlerType") + .flatMap(_.asInstanceOf[Option[ActionHandlerType]]), + webhookUrl = ad + .get("handlerWebhook") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .map(_("url").asInstanceOf[String]), + webhookIsAsync = ad + .get("handlerWebhook") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .flatMap(_.get("isAsync").flatMap(_.asInstanceOf[Option[Boolean]])), + actionTriggerMutationModel = ad + .get("triggerMutationModel") + .flatMap(_.asInstanceOf[Option[Map[String, Any]]]) + .map(x => + AddActionTriggerModelInput( + modelId = x("modelId").asInstanceOf[String], + mutationType = x("mutationType") + .asInstanceOf[ActionTriggerMutationModelMutationType], + fragment = x("fragment").asInstanceOf[String] + )) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAlgoliaSyncQuery.scala new file mode 100644 index 0000000000..9aa2adb254 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateAlgoliaSyncQuery.scala @@ -0,0 +1,29 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{UpdateAlgoliaSyncQueryInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateAlgoliaSyncQuery { + val inputFields = List( + InputField("algoliaSyncQueryId", StringType, description = ""), + InputField("indexName", StringType, description = ""), + InputField("fragment", StringType, description = ""), + InputField("isEnabled", BooleanType, description = "") + ) + + implicit val manual = new FromInput[UpdateAlgoliaSyncQueryInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateAlgoliaSyncQueryInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + algoliaSyncQueryId = ad("algoliaSyncQueryId").asInstanceOf[String], + indexName = ad("indexName").asInstanceOf[String], + fragment = ad("fragment").asInstanceOf[String], + isEnabled = ad("isEnabled").asInstanceOf[Boolean] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClient.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClient.scala new file mode 100644 index 0000000000..7e182f5c4c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClient.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateClientInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateClient { + val inputFields = List( + InputField("name", OptionInputType(StringType), description = ""), + InputField("email", OptionInputType(StringType), description = "") + ) + + implicit val manual = new FromInput[UpdateClientInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateClientInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + name = ad.get("name").flatMap(_.asInstanceOf[Option[String]]), + email = ad.get("email").flatMap(_.asInstanceOf[Option[String]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClientPassword.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClientPassword.scala new file mode 100644 index 0000000000..bc6bd71842 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateClientPassword.scala @@ -0,0 +1,25 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateClientPasswordInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateClientPassword { + val inputFields = List( + InputField("oldPassword", StringType, description = ""), + InputField("newPassword", StringType, description = "") + ) + + implicit val manual = new FromInput[UpdateClientPasswordInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateClientPasswordInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + oldPassword = ad("oldPassword").asInstanceOf[String], + newPassword = ad("newPassword").asInstanceOf[String] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateEnum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateEnum.scala new file mode 100644 index 0000000000..78d7e82b21 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateEnum.scala @@ -0,0 +1,30 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateEnumInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField, ListInputType, OptionInputType, StringType} + +object UpdateEnum { + val inputFields = + List( + InputField("enumId", IDType, description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("values", OptionInputType(ListInputType(StringType)), description = ""), + InputField("migrationValue", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateEnumInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + UpdateEnumInput( + clientMutationId = node.clientMutationId, + enumId = node.requiredArgAsString("enumId"), + name = node.optionalArgAsString("name"), + values = node.optionalArgAs[Seq[String]]("values"), + migrationValue = node.optionalArgAsString("migrationValue") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateField.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateField.scala new file mode 100644 index 0000000000..1b29f1acea --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateField.scala @@ -0,0 +1,41 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateFieldInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateField { + val inputFields = List( + InputField("id", StringType, description = ""), + InputField("defaultValue", OptionInputType(StringType), description = ""), + InputField("migrationValue", OptionInputType(StringType), description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("typeIdentifier", OptionInputType(StringType), description = ""), + InputField("isUnique", OptionInputType(BooleanType), description = ""), + InputField("isRequired", OptionInputType(BooleanType), description = ""), + InputField("isList", OptionInputType(BooleanType), description = ""), + InputField("enumId", OptionInputType(IDType), description = "") + ) + + implicit val manual = new FromInput[UpdateFieldInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + UpdateFieldInput( + clientMutationId = node.clientMutationId, + fieldId = node.requiredArgAsString("id"), + defaultValue = node.optionalOptionalArgAsString("defaultValue"), + migrationValue = node.optionalArgAsString("migrationValue"), + description = node.optionalArgAsString("description"), + name = node.optionalArgAsString("name"), + typeIdentifier = node.optionalArgAsString("typeIdentifier"), + isUnique = node.optionalArgAs[Boolean]("isUnique"), + isRequired = node.optionalArgAs[Boolean]("isRequired"), + isList = node.optionalArgAs[Boolean]("isList"), + enumId = node.optionalArgAs[String]("enumId") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateFieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateFieldConstraint.scala new file mode 100644 index 0000000000..daf714a7bb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateFieldConstraint.scala @@ -0,0 +1,103 @@ +package cool.graph.system.schema.fields + +import cool.graph.JsonFormats +import cool.graph.shared.schema.JsonMarshalling.CustomSprayJsonResultMarshaller +import cool.graph.system.mutations.UpdateFieldConstraintInput +import sangria.marshalling.FromInput +import sangria.schema.{BooleanType, FloatType, IDType, InputField, IntType, ListInputType, OptionInputType, ScalarType, StringType} +import spray.json.DefaultJsonProtocol._ +import spray.json.{JsBoolean, JsNull, _} + +object UpdateFieldConstraint { + val inputFields = + List( + InputField("constraintId", IDType, description = ""), + InputField("equalsString", OptionInputType(StringType), description = ""), + InputField("oneOfString", OptionInputType(ListInputType(StringType)), description = ""), + InputField("minLength", OptionInputType(IntType), description = ""), + InputField("maxLength", OptionInputType(IntType), description = ""), + InputField("startsWith", OptionInputType(StringType), description = ""), + InputField("endsWith", OptionInputType(StringType), description = ""), + InputField("includes", OptionInputType(StringType), description = ""), + InputField("regex", OptionInputType(StringType), description = ""), + InputField("equalsNumber", OptionInputType(FloatType), description = ""), + InputField("oneOfNumber", OptionInputType(ListInputType(FloatType)), description = ""), + InputField("min", OptionInputType(FloatType), description = ""), + InputField("max", OptionInputType(FloatType), description = ""), + InputField("exclusiveMin", OptionInputType(FloatType), description = ""), + InputField("exclusiveMax", OptionInputType(FloatType), description = ""), + InputField("multipleOf", OptionInputType(FloatType), description = ""), + InputField("equalsBoolean", OptionInputType(BooleanType), description = ""), + InputField("uniqueItems", OptionInputType(BooleanType), description = ""), + InputField("minItems", OptionInputType(IntType), description = ""), + InputField("maxItems", OptionInputType(IntType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateFieldConstraintInput] { + implicit val anyFormat = JsonFormats.AnyJsonFormat + val marshaller = CustomSprayJsonResultMarshaller + def fromResult(node: marshaller.Node): UpdateFieldConstraintInput = { + + def tripleOption(name: String): Option[Option[Any]] = { + + if (node.asJsObject.getFields(name).nonEmpty) { + node.asJsObject.getFields(name).head match { + case JsNull => Some(None) + case b: JsBoolean => Some(Some(b.value)) + case n: JsNumber => Some(Some(n.convertTo[Double])) + case s: JsString => Some(Some(s.convertTo[String])) + case a: JsArray => + Some( + Some( + a.convertTo[List[JsValue]] + .map { + case b: JsBoolean => b.convertTo[Boolean] + case n: JsNumber => n.convertTo[Double] + case s: JsString => s.convertTo[String] + case _ => + } + )) + case _ => None + } + } else None + } + + def tripleOptionInt(name: String): Option[Option[Int]] = { + + if (node.asJsObject.getFields(name).nonEmpty) { + node.asJsObject.getFields(name).head match { + case JsNull => Some(None) + case n: JsNumber => Some(Some(n.convertTo[Int])) + case _ => None + } + } else None + } + + def getAsString(name: String) = node.asJsObject.getFields(name).head.asInstanceOf[JsString].convertTo[String] + + UpdateFieldConstraintInput( + clientMutationId = tripleOption("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + constraintId = getAsString("constraintId"), + equalsString = tripleOption("equalsString"), + oneOfString = tripleOption("oneOfString"), + minLength = tripleOptionInt("minLength"), + maxLength = tripleOptionInt("maxLength"), + startsWith = tripleOption("startsWith"), + endsWith = tripleOption("endsWith"), + includes = tripleOption("includes"), + regex = tripleOption("regex"), + equalsNumber = tripleOption("equalsNumber"), + oneOfNumber = tripleOption("oneOfNumber"), + min = tripleOption("min"), + max = tripleOption("max"), + exclusiveMin = tripleOption("exclusiveMin"), + exclusiveMax = tripleOption("exclusiveMax"), + multipleOf = tripleOption("multipleOf"), + equalsBoolean = tripleOption("equalsBoolean"), + uniqueItems = tripleOption("uniqueItems"), + minItems = tripleOptionInt("minItems"), + maxItems = tripleOptionInt("maxItems") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModel.scala new file mode 100644 index 0000000000..6aea8d567c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModel.scala @@ -0,0 +1,28 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.{DeleteProjectInput, UpdateModelInput, UpdateProjectInput} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateModel { + val inputFields = List( + InputField("id", StringType, description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("name", OptionInputType(StringType), description = "") + ) + + implicit val manual = new FromInput[UpdateModelInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateModelInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + modelId = ad("id").asInstanceOf[String], + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + name = ad.get("name").flatMap(_.asInstanceOf[Option[String]]), + fieldPositions = None + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModelPermission.scala new file mode 100644 index 0000000000..13f68c8183 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateModelPermission.scala @@ -0,0 +1,47 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.ModelOperation.ModelOperation +import cool.graph.shared.models.UserType.UserType +import cool.graph.system.mutations.{UpdateModelPermissionInput} +import cool.graph.system.schema.types.{Operation, Rule, UserType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{ListInputType, OptionInputType, _} + +object UpdateModelPermission { + val inputFields = List( + InputField("id", IDType, description = ""), + InputField("operation", OptionInputType(Operation.Type), description = ""), + InputField("userType", OptionInputType(UserType.Type), description = ""), + InputField("rule", OptionInputType(Rule.Type), description = ""), + InputField("ruleName", OptionInputType(StringType), description = ""), + InputField("ruleGraphQuery", OptionInputType(StringType), description = ""), + InputField("ruleWebhookUrl", OptionInputType(StringType), description = ""), + InputField("fieldIds", OptionInputType(ListInputType(StringType)), description = ""), + InputField("applyToWholeModel", OptionInputType(BooleanType), description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("isActive", OptionInputType(BooleanType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateModelPermissionInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateModelPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + id = ad("id").asInstanceOf[String], + operation = ad.get("operation").flatMap(_.asInstanceOf[Option[ModelOperation]]), + userType = ad.get("userType").flatMap(_.asInstanceOf[Option[UserType]]), + rule = ad.get("rule").flatMap(_.asInstanceOf[Option[CustomRule]]), + ruleName = ad.get("ruleName").flatMap(_.asInstanceOf[Option[String]]), + ruleGraphQuery = ad.get("ruleGraphQuery").flatMap(_.asInstanceOf[Option[String]]), + ruleWebhookUrl = ad.get("ruleWebhookUrl").flatMap(_.asInstanceOf[Option[String]]), + fieldIds = ad.get("fieldIds").flatMap(_.asInstanceOf[Option[Vector[String]]].map(_.toList)), + applyToWholeModel = ad.get("applyToWholeModel").flatMap(_.asInstanceOf[Option[Boolean]]), + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + isActive = ad.get("isActive").flatMap(_.asInstanceOf[Option[Boolean]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateProject.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateProject.scala new file mode 100644 index 0000000000..0e3908a481 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateProject.scala @@ -0,0 +1,36 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateProjectInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateProject { + val inputFields = List( + InputField("id", StringType, description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("alias", OptionInputType(StringType), description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("allowQueries", OptionInputType(BooleanType), description = ""), + InputField("allowMutations", OptionInputType(BooleanType), description = "") + ) + + implicit val manual = new FromInput[UpdateProjectInput] { + val marshaller = CoercedScalaResultMarshaller.default + + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + def fromResult(node: marshaller.Node) = { + UpdateProjectInput( + clientMutationId = node.clientMutationId, + projectId = node.requiredArgAsString("id"), + name = node.optionalArgAsString("name"), + alias = node.optionalArgAsString("alias"), + webhookUrl = node.optionalArgAsString("webhookUrl"), + allowQueries = node.optionalArgAs[Boolean]("allowQueries"), + allowMutations = node.optionalArgAs[Boolean]("allowMutations") + ) + } + } + + val trusted = TrustedMutation(inputFields, manual) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelation.scala new file mode 100644 index 0000000000..8793c9a918 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelation.scala @@ -0,0 +1,43 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateRelationInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object UpdateRelation { + val inputFields = List( + InputField("id", IDType, description = ""), + InputField("leftModelId", OptionInputType(IDType), description = ""), + InputField("rightModelId", OptionInputType(IDType), description = ""), + InputField("fieldOnLeftModelName", OptionInputType(StringType), description = ""), + InputField("fieldOnRightModelName", OptionInputType(StringType), description = ""), + InputField("fieldOnLeftModelIsList", OptionInputType(BooleanType), description = ""), + InputField("fieldOnRightModelIsList", OptionInputType(BooleanType), description = ""), + InputField("fieldOnLeftModelIsRequired", OptionInputType(BooleanType), description = ""), + InputField("fieldOnRightModelIsRequired", OptionInputType(BooleanType), description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("description", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateRelationInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + + UpdateRelationInput( + clientMutationId = node.optionalArgAsString("clientMutationId"), + id = node.requiredArgAsString("id"), + leftModelId = node.optionalArgAsString("leftModelId"), + rightModelId = node.optionalArgAsString("rightModelId"), + fieldOnLeftModelName = node.optionalArgAsString("fieldOnLeftModelName"), + fieldOnRightModelName = node.optionalArgAsString("fieldOnRightModelName"), + fieldOnLeftModelIsList = node.optionalArgAsBoolean("fieldOnLeftModelIsList"), + fieldOnRightModelIsList = node.optionalArgAsBoolean("fieldOnRightModelIsList"), + fieldOnLeftModelIsRequired = node.optionalArgAsBoolean("fieldOnLeftModelIsRequired"), + fieldOnRightModelIsRequired = node.optionalArgAsBoolean("fieldOnRightModelIsRequired"), + name = node.optionalArgAsString("name"), + description = node.optionalArgAsString("description") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelationPermission.scala new file mode 100644 index 0000000000..c9ebce4699 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRelationPermission.scala @@ -0,0 +1,44 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.UserType.UserType +import cool.graph.system.mutations.UpdateRelationPermissionInput +import cool.graph.system.schema.types.{Rule, UserType} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{OptionInputType, _} + +object UpdateRelationPermission { + val inputFields: List[InputField[Any]] = List( + InputField("id", IDType, description = ""), + InputField("connect", OptionInputType(BooleanType), description = ""), + InputField("disconnect", OptionInputType(BooleanType), description = ""), + InputField("userType", OptionInputType(UserType.Type), description = ""), + InputField("rule", OptionInputType(Rule.Type), description = ""), + InputField("ruleName", OptionInputType(StringType), description = ""), + InputField("ruleGraphQuery", OptionInputType(StringType), description = ""), + InputField("ruleWebhookUrl", OptionInputType(StringType), description = ""), + InputField("description", OptionInputType(StringType), description = ""), + InputField("isActive", OptionInputType(BooleanType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateRelationPermissionInput] { + val marshaller: CoercedScalaResultMarshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node): UpdateRelationPermissionInput = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateRelationPermissionInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + id = ad("id").asInstanceOf[String], + connect = ad.get("connect").flatMap(_.asInstanceOf[Option[Boolean]]), + disconnect = ad.get("disconnect").flatMap(_.asInstanceOf[Option[Boolean]]), + userType = ad.get("userType").flatMap(_.asInstanceOf[Option[UserType]]), + rule = ad.get("rule").flatMap(_.asInstanceOf[Option[CustomRule]]), + ruleName = ad.get("ruleName").flatMap(_.asInstanceOf[Option[String]]), + ruleGraphQuery = ad.get("ruleGraphQuery").flatMap(_.asInstanceOf[Option[String]]), + ruleWebhookUrl = ad.get("ruleWebhookUrl").flatMap(_.asInstanceOf[Option[String]]), + description = ad.get("description").flatMap(_.asInstanceOf[Option[String]]), + isActive = ad.get("isActive").flatMap(_.asInstanceOf[Option[Boolean]]) + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRequestPipelineMutationFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRequestPipelineMutationFunction.scala new file mode 100644 index 0000000000..e98f454e07 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateRequestPipelineMutationFunction.scala @@ -0,0 +1,48 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.system.mutations.UpdateRequestPipelineMutationFunctionInput +import cool.graph.system.schema.types.{FunctionBinding, FunctionType, RequestPipelineMutationOperation} +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField, OptionInputType, StringType} + +object UpdateRequestPipelineMutationFunction { + val inputFields = + List( + InputField("functionId", IDType, description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("isActive", OptionInputType(sangria.schema.BooleanType), description = ""), + InputField("operation", OptionInputType(RequestPipelineMutationOperation.Type), description = ""), + InputField("binding", OptionInputType(FunctionBinding.Type), description = ""), + InputField("modelId", OptionInputType(StringType), description = ""), + InputField("type", OptionInputType(FunctionType.Type), description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateRequestPipelineMutationFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + UpdateRequestPipelineMutationFunctionInput( + clientMutationId = node.clientMutationId, + functionId = node.requiredArgAsString("functionId"), + name = node.optionalArgAsString("name"), + binding = node.optionalArgAs[FunctionBinding]("binding"), + modelId = node.optionalArgAs[String]("modelId"), + isActive = node.optionalArgAs[Boolean]("isActive"), + operation = node.optionalArgAs[RequestPipelineOperation]("operation"), + functionType = node.optionalArgAs[FunctionType]("type"), + webhookUrl = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSchemaExtensionFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSchemaExtensionFunction.scala new file mode 100644 index 0000000000..d2d1b96364 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSchemaExtensionFunction.scala @@ -0,0 +1,41 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.system.mutations.UpdateSchemaExtensionFunctionInput +import cool.graph.system.schema.types.FunctionType +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{BooleanType, IDType, InputField, OptionInputType, StringType} + +object UpdateSchemaExtensionFunction { + val inputFields = + List( + InputField("functionId", IDType, description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("isActive", OptionInputType(BooleanType), description = ""), + InputField("schema", OptionInputType(StringType), description = ""), + InputField("type", OptionInputType(FunctionType.Type), description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateSchemaExtensionFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + UpdateSchemaExtensionFunctionInput( + clientMutationId = node.clientMutationId, + functionId = node.requiredArgAsString("functionId"), + name = node.optionalArgAsString("name"), + isActive = node.optionalArgAs[Boolean]("isActive"), + schema = node.optionalArgAsString("schema"), + functionType = node.optionalArgAs[FunctionType]("type"), + webhookUrl = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSearchProviderAlgolia.scala new file mode 100644 index 0000000000..0f7cd82546 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateSearchProviderAlgolia.scala @@ -0,0 +1,30 @@ +package cool.graph.system.schema.fields + +import cool.graph.system.mutations.UpdateSearchProviderAlgoliaInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema._ + +object UpdateSearchProviderAlgolia { + val inputFields = List( + // Can probably remove projectId + InputField("projectId", StringType, description = ""), + InputField("applicationId", StringType, description = ""), + InputField("apiKey", StringType, description = ""), + InputField("isEnabled", BooleanType, description = "") + ) + + implicit val manual = new FromInput[UpdateSearchProviderAlgoliaInput] { + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + val ad = node.asInstanceOf[Map[String, Any]] + + UpdateSearchProviderAlgoliaInput( + clientMutationId = ad.get("clientMutationId").flatMap(_.asInstanceOf[Option[String]]), + projectId = ad("projectId").asInstanceOf[String], + applicationId = ad("applicationId").asInstanceOf[String], + apiKey = ad("apiKey").asInstanceOf[String], + isEnabled = ad("isEnabled").asInstanceOf[Boolean] + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateServerSideSubscriptionFunction.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateServerSideSubscriptionFunction.scala new file mode 100644 index 0000000000..970439e9eb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/fields/UpdateServerSideSubscriptionFunction.scala @@ -0,0 +1,41 @@ +package cool.graph.system.schema.fields + +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.system.mutations.UpdateServerSideSubscriptionFunctionInput +import cool.graph.system.schema.types.FunctionType +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{BooleanType, IDType, InputField, OptionInputType, StringType} + +object UpdateServerSideSubscriptionFunction { + val inputFields = + List( + InputField("functionId", IDType, description = ""), + InputField("name", OptionInputType(StringType), description = ""), + InputField("isActive", OptionInputType(BooleanType), description = ""), + InputField("query", OptionInputType(StringType), description = ""), + InputField("type", OptionInputType(FunctionType.Type), description = ""), + InputField("webhookUrl", OptionInputType(StringType), description = ""), + InputField("webhookHeaders", OptionInputType(StringType), description = ""), + InputField("inlineCode", OptionInputType(StringType), description = ""), + InputField("auth0Id", OptionInputType(StringType), description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[UpdateServerSideSubscriptionFunctionInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + def fromResult(node: marshaller.Node) = { + UpdateServerSideSubscriptionFunctionInput( + clientMutationId = node.clientMutationId, + functionId = node.requiredArgAsString("functionId"), + name = node.optionalArgAsString("name"), + isActive = node.optionalArgAs[Boolean]("isActive"), + query = node.optionalArgAsString("query"), + functionType = node.optionalArgAs[FunctionType]("type"), + webhookUrl = node.optionalArgAsString("webhookUrl"), + headers = node.optionalArgAsString("webhookHeaders"), + inlineCode = node.optionalArgAsString("inlineCode"), + auth0Id = node.optionalArgAsString("auth0Id") + ) + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Action.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Action.scala new file mode 100644 index 0000000000..ac16408c3c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Action.scala @@ -0,0 +1,55 @@ +package cool.graph.system.schema.types + +import sangria.schema._ +import cool.graph.shared.models +import cool.graph.system.schema.types.ActionTriggerMutationModel.ActionTriggerMutationModelContext +import sangria.relay.Node + +object _Action { + case class ActionContext(project: models.Project, action: models.Action) extends Node { + override val id = action.id + + } + lazy val Type: ObjectType[Unit, ActionContext] = ObjectType( + "Action", + "This is an action", + interfaces[Unit, ActionContext](nodeInterface), + idField[Unit, ActionContext] :: + fields[Unit, ActionContext]( + Field("isActive", BooleanType, resolve = _.value.action.isActive), + Field("description", OptionType(StringType), resolve = _.value.action.description), + Field("triggerType", TriggerType.Type, resolve = _.value.action.triggerType), + Field("handlerType", HandlerType.Type, resolve = _.value.action.handlerType), + Field( + "triggerMutationModel", + OptionType(ActionTriggerMutationModelType), + resolve = ctx => ctx.value.action.triggerMutationModel.map(ActionTriggerMutationModelContext(ctx.value.project, _)) + ), + Field("triggerMutationRelation", OptionType(ActionTriggerMutationRelationType), resolve = _.value.action.triggerMutationRelation), + Field("handlerWebhook", OptionType(ActionHandlerWebhookType), resolve = _.value.action.handlerWebhook) + ) + ) +} + +object TriggerType { + lazy val Type = { + EnumType( + "ActionTriggerType", + None, + List( + EnumValue(models.ActionTriggerType.MutationModel.toString, value = models.ActionTriggerType.MutationModel), + EnumValue(models.ActionTriggerType.MutationRelation.toString, value = models.ActionTriggerType.MutationRelation) + ) + ) + } +} + +object HandlerType { + lazy val Type = { + EnumType("ActionHandlerType", + None, + List( + EnumValue(models.ActionHandlerType.Webhook.toString, value = models.ActionHandlerType.Webhook) + )) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionHandlerWebhook.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionHandlerWebhook.scala new file mode 100644 index 0000000000..c177536fd0 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionHandlerWebhook.scala @@ -0,0 +1,18 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object ActionHandlerWebhook { + lazy val Type: ObjectType[Unit, models.ActionHandlerWebhook] = ObjectType( + "ActionHandlerWebhook", + "This is an ActionHandlerWebhook", + interfaces[Unit, models.ActionHandlerWebhook](nodeInterface), + idField[Unit, models.ActionHandlerWebhook] :: + fields[Unit, models.ActionHandlerWebhook]( + Field("url", StringType, resolve = _.value.url), + Field("isAsync", BooleanType, resolve = _.value.isAsync) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationModel.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationModel.scala new file mode 100644 index 0000000000..f54981b525 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationModel.scala @@ -0,0 +1,48 @@ +package cool.graph.system.schema.types + +import cool.graph.Types.Id +import sangria.schema._ +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import sangria.relay.Node + +object ActionTriggerMutationModel { + case class ActionTriggerMutationModelContext(project: models.Project, actionTrigger: models.ActionTriggerMutationModel) extends Node { + override val id: Id = actionTrigger.id + + } + lazy val Type: ObjectType[SystemUserContext, ActionTriggerMutationModelContext] = + ObjectType( + "ActionTriggerMutationModel", + "This is an ActionTriggerMutationModel", + interfaces[SystemUserContext, ActionTriggerMutationModelContext](nodeInterface), + idField[SystemUserContext, ActionTriggerMutationModelContext] :: + fields[SystemUserContext, ActionTriggerMutationModelContext]( + Field("fragment", StringType, resolve = _.value.actionTrigger.fragment), + Field( + "model", + ModelType, + resolve = ctx => { + val project = ctx.value.project + val model = project.getModelById_!(ctx.value.actionTrigger.modelId) + + ModelContext(project, model) + } + ), + Field("mutationType", ModelMutationType.Type, resolve = _.value.actionTrigger.mutationType) + ) + ) +} + +object ModelMutationType { + lazy val Type = EnumType( + "ActionTriggerMutationModelMutationType", + None, + List( + EnumValue(models.ActionTriggerMutationModelMutationType.Create.toString, value = models.ActionTriggerMutationModelMutationType.Create), + EnumValue(models.ActionTriggerMutationModelMutationType.Update.toString, value = models.ActionTriggerMutationModelMutationType.Update), + EnumValue(models.ActionTriggerMutationModelMutationType.Delete.toString, value = models.ActionTriggerMutationModelMutationType.Delete) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationRelation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationRelation.scala new file mode 100644 index 0000000000..dc0b505ab9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ActionTriggerMutationRelation.scala @@ -0,0 +1,55 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.errors.UserInputErrors +import sangria.schema._ +import cool.graph.shared.models +import cool.graph.shared.models.ModelParser +import cool.graph.system.SystemUserContext +import cool.graph.system.database.finder.ProjectFinder +import cool.graph.system.schema.types.Relation.RelationContext + +import scala.concurrent.Future + +object ActionTriggerMutationRelation { + import scala.concurrent.ExecutionContext.Implicits.global + + def throwNotFound(item: String) = throw new UserInputErrors.NotFoundException(s"${item} not found") + + lazy val Type: ObjectType[SystemUserContext, models.ActionTriggerMutationRelation] = + ObjectType( + "ActionTriggerMutationRelation", + "This is an ActionTriggerMutationRelation", + interfaces[SystemUserContext, models.ActionTriggerMutationRelation](nodeInterface), + idField[SystemUserContext, models.ActionTriggerMutationRelation] :: + fields[SystemUserContext, models.ActionTriggerMutationRelation]( + Field("fragment", StringType, resolve = _.value.fragment), + Field( + "relation", + RelationType, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val relationId = ctx.value.relationId + val project: Future[models.Project] = ProjectFinder.loadByRelationId(clientId, relationId)(ctx.ctx.internalDatabase, ctx.ctx.projectResolver) + project.map { project => + ModelParser + .relation(project, relationId, ctx.ctx.injector) + .map(rel => RelationContext(project, rel)) + .getOrElse(throwNotFound("Relation")) + } + } + ), + Field("mutationType", RelationMutationType.Type, resolve = _.value.mutationType) + ) + ) +} + +object RelationMutationType { + lazy val Type = EnumType( + "ActionTriggerMutationModelRelationType", + None, + List( + EnumValue(models.ActionTriggerMutationRelationMutationType.Add.toString, value = models.ActionTriggerMutationRelationMutationType.Add), + EnumValue(models.ActionTriggerMutationRelationMutationType.Remove.toString, value = models.ActionTriggerMutationRelationMutationType.Remove) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AlgoliaSyncQuery.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AlgoliaSyncQuery.scala new file mode 100644 index 0000000000..7a4ab1188f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AlgoliaSyncQuery.scala @@ -0,0 +1,31 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import sangria.relay.Node +import sangria.schema._ + +object AlgoliaSyncQuery { + case class AlgoliaSyncQueryContext(project: models.Project, algoliaSyncQuery: models.AlgoliaSyncQuery) extends Node { + def id = algoliaSyncQuery.id + } + lazy val Type: ObjectType[SystemUserContext, AlgoliaSyncQueryContext] = + ObjectType( + "AlgoliaSyncQuery", + "This is an AlgoliaSyncQuery", + interfaces[SystemUserContext, AlgoliaSyncQueryContext](nodeInterface), + idField[SystemUserContext, AlgoliaSyncQueryContext] :: + fields[SystemUserContext, AlgoliaSyncQueryContext]( + Field("indexName", StringType, resolve = _.value.algoliaSyncQuery.indexName), + Field("fragment", StringType, resolve = _.value.algoliaSyncQuery.fragment), + Field("isEnabled", BooleanType, resolve = _.value.algoliaSyncQuery.isEnabled), + Field("model", ModelType, resolve = ctx => { + val project = ctx.value.project + val model = ctx.value.algoliaSyncQuery.model + + ModelContext(project, model) + }) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AuthProvider.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AuthProvider.scala new file mode 100644 index 0000000000..b0e25196d2 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/AuthProvider.scala @@ -0,0 +1,76 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models._ +import cool.graph.system.{SystemUserContext} +import sangria.schema.{Field, _} +import sangria.relay._ + +object AuthProvider { + lazy val NameType = EnumType( + "AuthProviderType", + values = List(IntegrationName.AuthProviderEmail, IntegrationName.AuthProviderDigits, IntegrationName.AuthProviderAuth0).map(authProvider => + EnumValue(authProvider.toString, value = authProvider)) + ) + + lazy val Type: ObjectType[SystemUserContext, AuthProvider] = ObjectType( + "AuthProvider", + "This is a AuthProvider", + interfaces[SystemUserContext, AuthProvider](nodeInterface), + idField[SystemUserContext, AuthProvider] :: + fields[SystemUserContext, AuthProvider]( + Field("type", NameType, resolve = _.value.name), + Field("isEnabled", BooleanType, resolve = _.value.isEnabled), + Field( + "digits", + OptionType(DigitsType), + resolve = ctx => + ctx.value.metaInformation match { + case Some(meta: AuthProviderDigits) if meta.isInstanceOf[AuthProviderDigits] => + Some(meta) + case _ => + ctx.value.name match { + case IntegrationName.AuthProviderDigits => + Some(AuthProviderDigits(id = "dummy-id", consumerKey = "", consumerSecret = "")) + case _ => None + } + } + ), + Field( + "auth0", + OptionType(Auth0Type), + resolve = ctx => + ctx.value.metaInformation match { + case Some(meta: AuthProviderAuth0) if meta.isInstanceOf[AuthProviderAuth0] => + Some(meta) + case _ => + ctx.value.name match { + case IntegrationName.AuthProviderAuth0 => + Some(AuthProviderAuth0(id = "dummy-id", clientId = "", clientSecret = "", domain = "")) + case _ => None + } + } + ) + ) + ) + + lazy val DigitsType: ObjectType[SystemUserContext, AuthProviderDigits] = + ObjectType( + "AuthProviderDigitsMeta", + "Digits Meta Information", + fields[SystemUserContext, AuthProviderDigits]( + Field("consumerKey", OptionType(StringType), resolve = _.value.consumerKey), + Field("consumerSecret", OptionType(StringType), resolve = _.value.consumerSecret) + ) + ) + + lazy val Auth0Type: ObjectType[SystemUserContext, AuthProviderAuth0] = + ObjectType( + "AuthProviderAuth0Meta", + "Auth0 Meta Information", + fields[SystemUserContext, AuthProviderAuth0]( + Field("clientId", OptionType(StringType), resolve = _.value.clientId), + Field("clientSecret", OptionType(StringType), resolve = _.value.clientSecret), + Field("domain", OptionType(StringType), resolve = _.value.domain) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Customer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Customer.scala new file mode 100644 index 0000000000..0cdca6fdfa --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Customer.scala @@ -0,0 +1,35 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.SystemUserContext +import sangria.relay._ +import sangria.schema.{ObjectType, _} +import scaldi.Injector + +import scala.concurrent.ExecutionContext.Implicits.global + +object Customer { + def getType(customerId: String)(implicit inj: Injector): ObjectType[SystemUserContext, models.Client] = ObjectType( + "Customer", + "This is a Customer", + interfaces[SystemUserContext, models.Client](nodeInterface), + fields[SystemUserContext, models.Client]( + idField[SystemUserContext, models.Client], + Field("name", StringType, resolve = _.value.name), + Field("email", StringType, resolve = _.value.email), + Field("source", CustomerSourceType, resolve = _.value.source), + Field("createdAt", CustomScalarTypes.DateTimeType, resolve = _.value.createdAt), + Field("updatedAt", CustomScalarTypes.DateTimeType, resolve = _.value.updatedAt), + Field( + "projects", + projectConnection, + resolve = ctx => + ctx.ctx.clientResolver.resolveProjectsForClient(ctx.ctx.getClient.id).map { projects => + Connection.connectionFromSeq(projects.sortBy(_.id), ConnectionArgs(ctx)) + }, + arguments = Connection.Args.All + ) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/CustomerSource.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/CustomerSource.scala new file mode 100644 index 0000000000..3e896853c8 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/CustomerSource.scala @@ -0,0 +1,17 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object CustomerSource { + lazy val Type = EnumType( + "CustomerSourceType", + values = List( + EnumValue(models.CustomerSource.LEARN_RELAY.toString, value = models.CustomerSource.LEARN_RELAY), + EnumValue(models.CustomerSource.LEARN_APOLLO.toString, value = models.CustomerSource.LEARN_APOLLO), + EnumValue(models.CustomerSource.DOCS.toString, value = models.CustomerSource.DOCS), + EnumValue(models.CustomerSource.WAIT_LIST.toString, value = models.CustomerSource.WAIT_LIST) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Enum.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Enum.scala new file mode 100644 index 0000000000..72e28bd678 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Enum.scala @@ -0,0 +1,21 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema.{Field, ListType, ObjectType, StringType, fields, interfaces} + +object Enum { + + lazy val Type: ObjectType[SystemUserContext, models.Enum] = { + ObjectType( + "Enum", + "This is an enum", + interfaces[SystemUserContext, models.Enum](nodeInterface), + idField[SystemUserContext, models.Enum] :: + fields[SystemUserContext, models.Enum]( + Field("name", StringType, resolve = _.value.name), + Field("values", ListType(StringType), resolve = _.value.values) + ) + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FeatureToggle.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FeatureToggle.scala new file mode 100644 index 0000000000..849f5b9c43 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FeatureToggle.scala @@ -0,0 +1,18 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object FeatureToggle { + lazy val Type: ObjectType[SystemUserContext, models.FeatureToggle] = ObjectType( + "FeatureToggle", + "The feature toggles of a project.", + interfaces[SystemUserContext, models.FeatureToggle](nodeInterface), + idField[SystemUserContext, models.FeatureToggle] :: + fields[SystemUserContext, models.FeatureToggle]( + Field("name", StringType, resolve = _.value.name), + Field("isEnabled", BooleanType, resolve = _.value.isEnabled) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Field.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Field.scala new file mode 100644 index 0000000000..626fdaf19f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Field.scala @@ -0,0 +1,83 @@ +package cool.graph.system.schema.types + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.Types.Id +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import cool.graph.system.schema.types.Relation.RelationContext +import sangria.relay.Node +import sangria.schema._ + +object _Field { + case class FieldContext(project: models.Project, field: models.Field) extends Node { + def id: Id = field.id + } + + lazy val Type: ObjectType[SystemUserContext, FieldContext] = ObjectType( + "Field", + "This is a field", + interfaces[SystemUserContext, FieldContext](nodeInterface), + () => + idField[SystemUserContext, FieldContext] :: + fields[SystemUserContext, FieldContext]( + Field("name", StringType, resolve = _.value.field.name), + Field("typeIdentifier", StringType, resolve = _.value.field.typeIdentifier.toString), + Field("description", OptionType(StringType), resolve = _.value.field.description), + Field("isRequired", BooleanType, resolve = _.value.field.isRequired), + Field("isList", BooleanType, resolve = _.value.field.isList), + Field("isUnique", BooleanType, resolve = _.value.field.isUnique), + Field("isSystem", BooleanType, resolve = _.value.field.isSystem), + Field("isReadonly", BooleanType, resolve = _.value.field.isReadonly), + Field("enum", OptionType(OurEnumType), resolve = _.value.field.enum), + Field("constraints", ListType(FieldConstraintType), resolve = _.value.field.constraints), + Field( + "defaultValue", + OptionType(StringType), + resolve = + x => x.value.field.defaultValue.flatMap(dV => GCStringConverter(x.value.field.typeIdentifier, x.value.field.isList).fromGCValueToOptionalString(dV)) + ), + Field("relation", OptionType(RelationType), resolve = ctx => { + ctx.value.field.relation + .map(relation => RelationContext(ctx.value.project, relation)) + }), + Field( + "model", + OptionType(ModelType), + resolve = ctx => { + val project = ctx.value.project + project.getModelByFieldId(ctx.value.id).map(model => ModelContext(project, model)) + } + ), + Field( + "relatedModel", + OptionType(ModelType), + resolve = ctx => { + val project = ctx.value.project + project.getRelatedModelForField(ctx.value.field).map(model => ModelContext(project, model)) + } + ), + Field( + "relationSide", + OptionType( + EnumType( + "RelationSide", + None, + List( + EnumValue(models.RelationSide.A.toString, value = models.RelationSide.A), + EnumValue(models.RelationSide.B.toString, value = models.RelationSide.B) + ) + )), + resolve = _.value.field.relationSide + ), + Field( + "reverseRelationField", + OptionType(FieldType), + resolve = ctx => { + val project = ctx.value.project + project.getReverseRelationField(ctx.value.field).map(field => FieldContext(project, field)) + } + ) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraint.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraint.scala new file mode 100644 index 0000000000..4bad9bc16b --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraint.scala @@ -0,0 +1,85 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema.{Field, _} + +object FieldConstraint { + + lazy val Type: InterfaceType[SystemUserContext, models.FieldConstraint] = InterfaceType( + "FieldConstraint", + "This is a FieldConstraint", + fields[SystemUserContext, models.FieldConstraint]( + Field("id", IDType, resolve = _.value.id), + Field("constraintType", FieldConstraintTypeType.Type, resolve = _.value.constraintType), + Field("fieldId", IDType, resolve = _.value.fieldId) + ) + ) +} + +object StringConstraint { + + lazy val Type: ObjectType[SystemUserContext, models.StringConstraint] = + ObjectType[SystemUserContext, models.StringConstraint]( + "StringConstraint", + "This is a StringConstraint", + interfaces[SystemUserContext, models.StringConstraint](nodeInterface, FieldConstraint.Type), + fields[SystemUserContext, models.StringConstraint]( + Field("equalsString", OptionType(StringType), resolve = _.value.equalsString), + Field("oneOfString", OptionType(ListType(StringType)), resolve = _.value.oneOfString), + Field("minLength", OptionType(IntType), resolve = _.value.minLength), + Field("maxLength", OptionType(IntType), resolve = _.value.maxLength), + Field("startsWith", OptionType(StringType), resolve = _.value.startsWith), + Field("endsWith", OptionType(StringType), resolve = _.value.endsWith), + Field("includes", OptionType(StringType), resolve = _.value.includes), + Field("regex", OptionType(StringType), resolve = _.value.regex) + ) + ) +} + +object NumberConstraint { + + lazy val Type: ObjectType[SystemUserContext, models.NumberConstraint] = + ObjectType[SystemUserContext, models.NumberConstraint]( + "NumberConstraint", + "This is a NumberConstraint", + interfaces[SystemUserContext, models.NumberConstraint](nodeInterface, FieldConstraint.Type), + fields[SystemUserContext, models.NumberConstraint]( + Field("equalsNumber", OptionType(FloatType), resolve = _.value.equalsNumber), + Field("oneOfNumber", OptionType(ListType(FloatType)), resolve = _.value.oneOfNumber), + Field("min", OptionType(FloatType), resolve = _.value.min), + Field("max", OptionType(FloatType), resolve = _.value.max), + Field("exclusiveMin", OptionType(FloatType), resolve = _.value.exclusiveMin), + Field("exclusiveMax", OptionType(FloatType), resolve = _.value.exclusiveMax), + Field("multipleOf", OptionType(FloatType), resolve = _.value.multipleOf) + ) + ) +} + +object BooleanConstraint { + + lazy val Type: ObjectType[SystemUserContext, models.BooleanConstraint] = + ObjectType[SystemUserContext, models.BooleanConstraint]( + "BooleanConstraint", + "This is a BooleanConstraint", + interfaces[SystemUserContext, models.BooleanConstraint](nodeInterface, FieldConstraint.Type), + fields[SystemUserContext, models.BooleanConstraint]( + Field("equalsBoolean", OptionType(BooleanType), resolve = _.value.equalsBoolean) + ) + ) +} + +object ListConstraint { + + lazy val Type: ObjectType[SystemUserContext, models.ListConstraint] = + ObjectType[SystemUserContext, models.ListConstraint]( + "ListConstraint", + "This is a ListConstraint", + interfaces[SystemUserContext, models.ListConstraint](nodeInterface, FieldConstraint.Type), + fields[SystemUserContext, models.ListConstraint]( + Field("uniqueItems", OptionType(BooleanType), resolve = _.value.uniqueItems), + Field("minItems", OptionType(IntType), resolve = _.value.minItems), + Field("maxItems", OptionType(IntType), resolve = _.value.maxItems) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraintTypeType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraintTypeType.scala new file mode 100644 index 0000000000..579d88a979 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FieldConstraintTypeType.scala @@ -0,0 +1,17 @@ +package cool.graph.system.schema.types + +import sangria.schema.{EnumType, EnumValue} + +object FieldConstraintTypeType { + val enum = cool.graph.shared.models.FieldConstraintType + + lazy val Type = EnumType( + "FieldConstraintTypeType", + values = List( + EnumValue("STRING", value = enum.STRING), + EnumValue("NUMBER", value = enum.NUMBER), + EnumValue("BOOLEAN", value = enum.BOOLEAN), + EnumValue("LIST", value = enum.LIST) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Function.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Function.scala new file mode 100644 index 0000000000..c27a6478ff --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Function.scala @@ -0,0 +1,181 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.adapters.HttpFunctionHeaders +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Function._ +import cool.graph.system.schema.types.Model.ModelContext +import sangria.relay._ +import sangria.schema.{Field, _} + +import scala.concurrent.ExecutionContext.Implicits.global + +object Function { + trait FunctionInterface { + val project: models.Project + val function: models.Function + } + + case class FunctionContextRp(project: models.Project, function: RequestPipelineFunction) extends Node with FunctionInterface { + override def id: String = function.id + } + + case class FunctionContextSss(project: models.Project, function: ServerSideSubscriptionFunction) extends Node with FunctionInterface { + override def id: String = function.id + } + + case class FunctionContextSchemaExtension(project: models.Project, function: SchemaExtensionFunction) extends Node with FunctionInterface { + override def id: String = function.id + } + + def mapToContext(project: Project, function: models.Function): FunctionInterface = { + function match { + case rp: RequestPipelineFunction => FunctionContextRp(project, rp) + case sss: models.ServerSideSubscriptionFunction => FunctionContextSss(project, sss) + case cm: models.CustomMutationFunction => FunctionContextSchemaExtension(project, cm) + case cq: models.CustomQueryFunction => FunctionContextSchemaExtension(project, cq) + } + } + + lazy val Type: InterfaceType[SystemUserContext, FunctionInterface] = InterfaceType( + "Function", + "This is a Function", + fields[SystemUserContext, FunctionInterface]( + Field("id", IDType, resolve = _.value.function.id), + Field( + "logs", + logConnection, + arguments = Connection.Args.All, + resolve = ctx => { + ctx.ctx.logsDataResolver + .load(ctx.value.function.id) + .map(logs => { + // todo: don't rely on in-mem connections generation + Connection.connectionFromSeq(logs, ConnectionArgs(ctx)) + }) + } + ), + Field("stats", FunctionStats.Type, arguments = Connection.Args.All, resolve = ctx => { + ctx.value + }), + Field("name", StringType, resolve = ctx => ctx.value.function.name), + Field("type", FunctionType.Type, resolve = ctx => ctx.value.function.delivery.functionType), + Field("isActive", BooleanType, resolve = ctx => ctx.value.function.isActive), + Field("webhookUrl", OptionType(StringType), resolve = _.value.function.delivery match { + case x: HttpFunction => Some(x.url) + case _ => None + }), + Field( + "webhookHeaders", + OptionType(StringType), + resolve = _.value.function.delivery match { + case x: HttpFunction => Some(HttpFunctionHeaders.write(x.headers).toString) + case _ => None + } + ), + Field("inlineCode", OptionType(StringType), resolve = _.value.function.delivery match { + case x: CodeFunction => Some(x.code) + case _ => None + }), + Field("auth0Id", OptionType(StringType), resolve = _.value.function.delivery match { + case x: Auth0Function => Some(x.auth0Id) + case _ => None + }) + ) + ) +} + +object RequestPipelineMutationFunction { + lazy val Type: ObjectType[SystemUserContext, FunctionContextRp] = + ObjectType[SystemUserContext, FunctionContextRp]( + "RequestPipelineMutationFunction", + "This is a RequestPipelineMutationFunction", + interfaces[SystemUserContext, FunctionContextRp](nodeInterface, Function.Type), + fields[SystemUserContext, FunctionContextRp]( + Field( + "model", + ModelType, + resolve = ctx => { + val modelId = ctx.value.function.modelId + val model = ctx.value.project.getModelById_!(modelId) + ModelContext(ctx.value.project, model) + } + ), + Field("binding", FunctionBinding.Type, resolve = ctx => { ctx.value.function.binding }), + Field("operation", RequestPipelineMutationOperation.Type, resolve = _.value.function.operation) + ) + ) +} + +object RequestPipelineMutationOperation { + val Type = EnumType( + "RequestPipelineMutationOperation", + values = List( + EnumValue("CREATE", value = models.RequestPipelineOperation.CREATE), + EnumValue("UPDATE", value = models.RequestPipelineOperation.UPDATE), + EnumValue("DELETE", value = models.RequestPipelineOperation.DELETE) + ) + ) +} + +object FunctionStats { + lazy val Type: ObjectType[SystemUserContext, FunctionInterface] = ObjectType[SystemUserContext, FunctionInterface]( + "FunctionStats", + "This is statistics for a Function", + fields[SystemUserContext, FunctionInterface]( + Field( + "requestHistogram", + ListType(IntType), + resolve = ctx => { + + ctx.ctx.logsDataResolver.calculateHistogram( + projectId = ctx.value.project.id, + period = cool.graph.system.database.finder.HistogramPeriod.HALF_HOUR, + functionId = Some(ctx.value.function.id) + ) + } + ), + Field("requestCount", IntType, resolve = ctx => { + ctx.ctx.logsDataResolver.countRequests(ctx.value.function.id) + }), + Field("errorCount", IntType, resolve = ctx => { + ctx.ctx.logsDataResolver.countErrors(ctx.value.function.id) + }), + Field( + "lastRequest", + OptionType(CustomScalarTypes.DateTimeType), + resolve = ctx => { + ctx.ctx.logsDataResolver + .load(ctx.value.function.id, 1) + .map(_.headOption.map(_.timestamp)) + } + ) + ) + ) +} + +object ServerSideSubscriptionFunction { + lazy val Type: ObjectType[SystemUserContext, FunctionContextSss] = + ObjectType[SystemUserContext, FunctionContextSss]( + "ServerSideSubscriptionFunction", + "This is a ServerSideSubscriptionFunction", + interfaces[SystemUserContext, FunctionContextSss](nodeInterface, Function.Type), + fields[SystemUserContext, FunctionContextSss]( + Field("query", StringType, resolve = _.value.function.query) + ) + ) +} + +object SchemaExtensionFunction { + lazy val Type: ObjectType[SystemUserContext, FunctionContextSchemaExtension] = + ObjectType[SystemUserContext, FunctionContextSchemaExtension]( + "SchemaExtensionFunction", + "This is a SchemaExtensionFunction", + interfaces[SystemUserContext, FunctionContextSchemaExtension](nodeInterface, Function.Type), + fields[SystemUserContext, FunctionContextSchemaExtension]( + Field("schema", StringType, resolve = _.value.function.schema) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionBinding.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionBinding.scala new file mode 100644 index 0000000000..8af1f2ff6e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionBinding.scala @@ -0,0 +1,16 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object FunctionBinding { + lazy val Type = EnumType( + "FunctionBinding", + values = List( + EnumValue("TRANSFORM_ARGUMENT", value = models.FunctionBinding.TRANSFORM_ARGUMENT), + EnumValue("PRE_WRITE", value = models.FunctionBinding.PRE_WRITE), + EnumValue("TRANSFORM_PAYLOAD", value = models.FunctionBinding.TRANSFORM_PAYLOAD) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionType.scala new file mode 100644 index 0000000000..132b39af62 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/FunctionType.scala @@ -0,0 +1,13 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object FunctionType { + lazy val Type = EnumType("FunctionType", + values = List( + EnumValue("WEBHOOK", value = models.FunctionType.WEBHOOK), + EnumValue("AUTH0", value = models.FunctionType.CODE) + )) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/HistogramPeriod.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/HistogramPeriod.scala new file mode 100644 index 0000000000..03e6cb1411 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/HistogramPeriod.scala @@ -0,0 +1,17 @@ +package cool.graph.system.schema.types + +import cool.graph.system.database.finder +import sangria.schema._ + +object HistogramPeriod { + lazy val Type = EnumType( + "HistogramPeriod", + values = List( + EnumValue("MONTH", value = finder.HistogramPeriod.MONTH), + EnumValue("WEEK", value = finder.HistogramPeriod.WEEK), + EnumValue("DAY", value = finder.HistogramPeriod.DAY), + EnumValue("HOUR", value = finder.HistogramPeriod.HOUR), + EnumValue("HALF_HOUR", value = finder.HistogramPeriod.HALF_HOUR) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Integration.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Integration.scala new file mode 100644 index 0000000000..73b0b533db --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Integration.scala @@ -0,0 +1,20 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object Integration { + lazy val Type: InterfaceType[SystemUserContext, models.Integration] = + InterfaceType( + "Integration", + "This is an integration. Use inline fragment to get values from the concrete type: `{id ... on SearchProviderAlgolia { algoliaSchema }}`", + () => + fields[SystemUserContext, models.Integration]( + Field("id", IDType, resolve = _.value.id), + Field("isEnabled", BooleanType, resolve = _.value.isEnabled), + Field("name", IntegrationNameType.Type, resolve = _.value.name), + Field("type", IntegrationTypeType.Type, resolve = _.value.integrationType) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationNameType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationNameType.scala new file mode 100644 index 0000000000..e388bb9006 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationNameType.scala @@ -0,0 +1,17 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object IntegrationNameType { + val Type = EnumType( + "IntegrationNameType", + values = List( + EnumValue("AUTH_PROVIDER_AUTH0", value = models.IntegrationName.AuthProviderAuth0), + EnumValue("AUTH_PROVIDER_DIGITS", value = models.IntegrationName.AuthProviderDigits), + EnumValue("AUTH_PROVIDER_EMAIL", value = models.IntegrationName.AuthProviderEmail), + EnumValue("SEARCH_PROVIDER_ALGOLIA", value = models.IntegrationName.SearchProviderAlgolia) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationTypeType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationTypeType.scala new file mode 100644 index 0000000000..ed54092ab7 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/IntegrationTypeType.scala @@ -0,0 +1,15 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object IntegrationTypeType { + val Type = EnumType( + "IntegrationTypeType", + values = List( + EnumValue("AUTH_PROVIDER", value = models.IntegrationType.AuthProvider), + EnumValue("SEARCH_PROVIDER", value = models.IntegrationType.SearchProvider) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Log.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Log.scala new file mode 100644 index 0000000000..20ad2aeedb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Log.scala @@ -0,0 +1,23 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.system.SystemUserContext +import sangria.schema.{Field, _} + +object Log { + + lazy val Type: ObjectType[SystemUserContext, models.Log] = ObjectType[SystemUserContext, models.Log]( + "Log", + "A log is a log is a log", + interfaces[SystemUserContext, models.Log](nodeInterface), + idField[SystemUserContext, models.Log] :: + fields[SystemUserContext, models.Log]( + Field("requestId", OptionType(StringType), resolve = ctx => ctx.value.requestId), + Field("duration", IntType, resolve = ctx => ctx.value.duration), + Field("status", LogStatusType, resolve = ctx => ctx.value.status), + Field("timestamp", CustomScalarTypes.DateTimeType, resolve = ctx => ctx.value.timestamp), + Field("message", StringType, resolve = ctx => ctx.value.message) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/LogStatus.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/LogStatus.scala new file mode 100644 index 0000000000..e33006864c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/LogStatus.scala @@ -0,0 +1,13 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object LogStatus { + lazy val Type = EnumType("LogStatus", + values = List( + EnumValue("SUCCESS", value = models.LogStatus.SUCCESS), + EnumValue("FAILURE", value = models.LogStatus.FAILURE) + )) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Model.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Model.scala new file mode 100644 index 0000000000..68ac493f7f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Model.scala @@ -0,0 +1,113 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.{ApiMatrixFactory, models} +import cool.graph.system.schema.types.ModelPermission.ModelPermissionContext +import cool.graph.system.schema.types._Field.FieldContext +import cool.graph.system.{RequestPipelineSchemaResolver, SystemUserContext} +import org.atteo.evo.inflector.English.plural +import sangria.relay._ +import sangria.schema._ +import scaldi.Injectable + +object Model extends Injectable { + + val operationTypeArgument = + Argument("operation", Operation.Type) + + val requestPipelineOperationTypeArgument = + Argument("operation", RequestPipelineMutationOperation.Type) + + val bindingArgument = + Argument("binding", FunctionBinding.Type) + + case class ModelContext(project: models.Project, model: models.Model) extends Node { + def id = model.id + } + lazy val Type: ObjectType[SystemUserContext, ModelContext] = { + val relatedModelNameArg = Argument("relatedModelName", StringType) + ObjectType( + "Model", + "This is a model", + interfaces[SystemUserContext, ModelContext](nodeInterface), + idField[SystemUserContext, ModelContext] :: + fields[SystemUserContext, ModelContext]( + Field("name", StringType, resolve = _.value.model.name), + Field("namePlural", StringType, resolve = ctx => plural(ctx.value.model.name)), + Field("description", OptionType(StringType), resolve = _.value.model.description), + Field("isSystem", BooleanType, resolve = _.value.model.isSystem), + Field( + "fields", + fieldConnection, + arguments = Connection.Args.All, + resolve = ctx => { + implicit val inj = ctx.ctx.injector + val apiMatrix = inject[ApiMatrixFactory].create(ctx.value.project) + val fields = + apiMatrix + .filterFields(ctx.value.model.fields) + .sortBy(_.id) + .map(field => FieldContext(ctx.value.project, field)) + + Connection + .connectionFromSeq(fields, ConnectionArgs(ctx)) + } + ), + Field( + "permissions", + modelPermissionConnection, + arguments = Connection.Args.All, + resolve = ctx => { + val permissions = ctx.value.model.permissions + .sortBy(_.id) + .map(modelPermission => ModelPermissionContext(ctx.value.project, modelPermission)) + + Connection.connectionFromSeq(permissions, ConnectionArgs(ctx)) + } + ), + Field("itemCount", + IntType, + resolve = ctx => + ctx.ctx + .dataResolver(project = ctx.value.project) + .itemCountForModel(ctx.value.model)), + Field( + "permissionSchema", + StringType, + arguments = List(operationTypeArgument), + resolve = ctx => { + ctx.ctx.getModelPermissionSchema(ctx.value.project, ctx.value.id, ctx.arg(operationTypeArgument)) + } + ), + Field( + "requestPipelineFunctionSchema", + StringType, + arguments = List(requestPipelineOperationTypeArgument, bindingArgument), + resolve = ctx => { + + val schemaResolver = new RequestPipelineSchemaResolver() + val schema = schemaResolver.resolve(ctx.value.project, ctx.value.model, ctx.arg(bindingArgument), ctx.arg(requestPipelineOperationTypeArgument)) + + schema + } + ), + Field( + "permissionQueryArguments", + ListType(PermissionQueryArgument.Type), + arguments = List(operationTypeArgument), + resolve = ctx => { + ctx.arg(operationTypeArgument) match { + case models.ModelOperation.Read => + PermissionQueryArguments.getReadArguments(ctx.value.model) + case models.ModelOperation.Create => + PermissionQueryArguments.getCreateArguments(ctx.value.model) + case models.ModelOperation.Update => + PermissionQueryArguments.getUpdateArguments(ctx.value.model) + case models.ModelOperation.Delete => + PermissionQueryArguments.getDeleteArguments(ctx.value.model) + } + } + ) + ) + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ModelPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ModelPermission.scala new file mode 100644 index 0000000000..162a27dbcc --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ModelPermission.scala @@ -0,0 +1,44 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import sangria.relay.Node +import sangria.schema._ + +object ModelPermission { + + case class ModelPermissionContext(project: models.Project, modelPermission: models.ModelPermission) extends Node { + def id = modelPermission.id + } + lazy val Type: ObjectType[SystemUserContext, ModelPermissionContext] = + ObjectType( + "ModelPermission", + "This is a model permission", + interfaces[SystemUserContext, ModelPermissionContext](nodeInterface), + () => + idField[SystemUserContext, ModelPermissionContext] :: + fields[SystemUserContext, ModelPermissionContext]( + Field("fieldIds", ListType(StringType), resolve = _.value.modelPermission.fieldIds), + Field("ruleWebhookUrl", OptionType(StringType), resolve = _.value.modelPermission.ruleWebhookUrl), + Field("rule", Rule.Type, resolve = _.value.modelPermission.rule), + Field("ruleName", OptionType(StringType), resolve = _.value.modelPermission.ruleName), + Field("ruleGraphQuery", OptionType(StringType), resolve = _.value.modelPermission.ruleGraphQuery), + Field("applyToWholeModel", BooleanType, resolve = _.value.modelPermission.applyToWholeModel), + Field("isActive", BooleanType, resolve = _.value.modelPermission.isActive), + Field("operation", Operation.Type, resolve = _.value.modelPermission.operation), + Field("userType", UserType.Type, resolve = _.value.modelPermission.userType), + Field("description", OptionType(StringType), resolve = _.value.modelPermission.description), + Field( + "model", + ModelType, + resolve = ctx => { + val project = ctx.value.project + val model = project.getModelByModelPermissionId(ctx.value.id).get + + ModelContext(project, model) + } + ) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Operation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Operation.scala new file mode 100644 index 0000000000..49676a19bb --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Operation.scala @@ -0,0 +1,17 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object Operation { + val Type = EnumType( + "Operation", + values = List( + EnumValue("READ", value = models.ModelOperation.Read), + EnumValue("CREATE", value = models.ModelOperation.Create), + EnumValue("UPDATE", value = models.ModelOperation.Update), + EnumValue("DELETE", value = models.ModelOperation.Delete) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PackageDefinition.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PackageDefinition.scala new file mode 100644 index 0000000000..72f1c8f64c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PackageDefinition.scala @@ -0,0 +1,18 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object PackageDefinition { + lazy val Type: ObjectType[SystemUserContext, models.PackageDefinition] = ObjectType( + "PackageDefinition", + "this is a beta feature. Expect breaking changes.", + interfaces[SystemUserContext, models.PackageDefinition](nodeInterface), + idField[SystemUserContext, models.PackageDefinition] :: + fields[SystemUserContext, models.PackageDefinition]( + Field("definition", StringType, resolve = _.value.definition), + Field("name", OptionType(StringType), resolve = _.value.name) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArgument.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArgument.scala new file mode 100644 index 0000000000..abef3033ef --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArgument.scala @@ -0,0 +1,19 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models.TypeIdentifier +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object PermissionQueryArgument { + lazy val Type: ObjectType[SystemUserContext, PermissionQueryArguments.PermissionQueryArgument] = + ObjectType( + "PermissionQueryArgument", + "PermissionQueryArgument", + () => + fields[SystemUserContext, PermissionQueryArguments.PermissionQueryArgument]( + Field("name", StringType, resolve = _.value.name), + Field("typeName", StringType, resolve = ctx => TypeIdentifier.toSangriaScalarType(ctx.value.typeIdentifier).name), + Field("group", StringType, resolve = _.value.group) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArguments.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArguments.scala new file mode 100644 index 0000000000..50414cb631 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/PermissionQueryArguments.scala @@ -0,0 +1,68 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models + +object PermissionQueryArguments { + + case class PermissionQueryArgument(group: String, name: String, typeIdentifier: models.TypeIdentifier.TypeIdentifier) + + private def defaultArguments = { + List( + PermissionQueryArgument("Authenticated User", "$user_id", models.TypeIdentifier.GraphQLID), + PermissionQueryArgument("Current Node", "$node_id", models.TypeIdentifier.GraphQLID) + ) + } + + def getCreateArguments(model: models.Model) = { + val scalarPermissionQueryArgs = model.scalarFields + .filter(_.name != "id") + .map(scalarField => PermissionQueryArgument("Scalar Values", s"$$input_${scalarField.name}", scalarField.typeIdentifier)) + + val singleRelationPermissionQueryArgs = model.singleRelationFields.map(singleRelationField => + PermissionQueryArgument("Relations", s"$$input_${singleRelationField.name}Id", models.TypeIdentifier.GraphQLID)) + + scalarPermissionQueryArgs ++ singleRelationPermissionQueryArgs ++ defaultArguments + } + + def getUpdateArguments(model: models.Model) = { + val scalarPermissionQueryArgs = model.scalarFields + .filter(_.name != "id") + .map(scalarField => PermissionQueryArgument("Scalar Values", s"$$input_${scalarField.name}", scalarField.typeIdentifier)) + + val singleRelationPermissionQueryArgs = model.singleRelationFields.map(singleRelationField => + PermissionQueryArgument("Relations", s"$$input_${singleRelationField.name}Id", models.TypeIdentifier.GraphQLID)) + + val oldScalarPermissionQueryArgs = model.scalarFields + .filter(_.name != "id") + .map(scalarField => PermissionQueryArgument("Existing Scalar Values", s"$$node_${scalarField.name}", scalarField.typeIdentifier)) + + scalarPermissionQueryArgs ++ oldScalarPermissionQueryArgs ++ singleRelationPermissionQueryArgs ++ defaultArguments + } + + def getDeleteArguments(model: models.Model) = { + val scalarPermissionQueryArgs = model.scalarFields + .filter(_.name != "id") + .map(scalarField => PermissionQueryArgument("Scalar Values", s"$$node_${scalarField.name}", scalarField.typeIdentifier)) + + scalarPermissionQueryArgs ++ defaultArguments + } + + def getReadArguments(model: models.Model) = { + + val scalarPermissionQueryArgs = model.scalarFields + .filter(_.name != "id") + .map(scalarField => PermissionQueryArgument("Scalar Values", s"$$node_${scalarField.name}", scalarField.typeIdentifier)) + + scalarPermissionQueryArgs ++ defaultArguments + } + + def getRelationArguments(relation: models.Relation, project: models.Project) = { + + List( + PermissionQueryArgument("Authenticated User", "$user_id", models.TypeIdentifier.GraphQLID), + PermissionQueryArgument("Relation", s"$$${relation.aName(project)}_id", models.TypeIdentifier.GraphQLID), + PermissionQueryArgument("Relation", s"$$${relation.bName(project)}_id", models.TypeIdentifier.GraphQLID) + ) + + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Project.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Project.scala new file mode 100644 index 0000000000..b2f621c732 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Project.scala @@ -0,0 +1,222 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.{ApiMatrixFactory, models} +import cool.graph.shared.models.{ActionTriggerType, IntegrationType} +import cool.graph.system.migration.dataSchema.SchemaExport +import cool.graph.system.migration.project.ClientInterchange +import cool.graph.system.schema.types.Model.{ModelContext, inject} +import cool.graph.system.schema.types.Relation.RelationContext +import cool.graph.system.schema.types.SearchProviderAlgolia.SearchProviderAlgoliaContext +import cool.graph.system.schema.types._Action.ActionContext +import cool.graph.system.schema.types._Field.FieldContext +import cool.graph.system.{ActionSchemaPayload, ActionSchemaPayloadMutationModel, SystemUserContext} +import sangria.relay._ +import sangria.schema.{Field, _} +import scaldi.Injectable + +object Project extends Injectable { + lazy val Type: ObjectType[SystemUserContext, models.Project] = ObjectType( + "Project", + "This is a project", + interfaces[SystemUserContext, models.Project](nodeInterface), + idField[SystemUserContext, models.Project] :: + fields[SystemUserContext, models.Project]( + Field("name", StringType, resolve = _.value.name), + Field("alias", OptionType(StringType), resolve = _.value.alias), + Field("version", IntType, resolve = _.value.revision), + Field("region", RegionType, resolve = _.value.region), + Field("projectDatabase", ProjectDatabaseType, resolve = _.value.projectDatabase), + Field("schema", StringType, resolve = x => { + SchemaExport.renderSchema(x.value) + }), + Field("typeSchema", StringType, resolve = x => { + SchemaExport.renderTypeSchema(x.value) + + }), + Field("enumSchema", StringType, resolve = x => { + SchemaExport.renderEnumSchema(x.value) + + }), + Field("projectDefinition", StringType, resolve = x => { // todo: reenable + val z = ClientInterchange.export(x.value)(x.ctx.injector) + z.content + + }), + Field("projectDefinitionWithFileContent", StringType, resolve = x => { + ClientInterchange.render(x.value)(x.ctx.injector) + + }), + Field("isGlobalEnumsEnabled", BooleanType, resolve = _.value.isGlobalEnumsEnabled), + Field("webhookUrl", OptionType(StringType), resolve = _.value.webhookUrl), + Field("seats", + seatConnection, + arguments = Connection.Args.All, + resolve = ctx => + Connection.connectionFromSeq(ctx.value.seats + .sortBy(_.id), + ConnectionArgs(ctx))), + Field( + "integrations", + integrationConnection, + arguments = Connection.Args.All, + resolve = ctx => { + val integrations: Seq[models.Integration] = ctx.value.integrations + .filter(_.integrationType == IntegrationType.SearchProvider) + .sortBy(_.id.toString) + .map { + case x: models.SearchProviderAlgolia => SearchProviderAlgoliaContext(ctx.value, x) + case x => x + } + + Connection.connectionFromSeq( + // todo: integrations should return all integrations, but we need to find a way to make it work with fragments + // and adjust `IntegrationsSpec` + integrations, + ConnectionArgs(ctx) + ) + } + ), + Field( + "authProviders", + authProviderConnection, + arguments = Connection.Args.All, + resolve = ctx => + Connection + .connectionFromSeq(ctx.value.authProviders + .sortBy(_.name.toString), + ConnectionArgs(ctx)) + ), + Field( + "fields", + projectFieldConnection, + arguments = Connection.Args.All, + resolve = ctx => { + val project = ctx.value + implicit val inj = ctx.ctx.injector + val apiMatrix = inject[ApiMatrixFactory].create(project) + val fields = apiMatrix + .filterFields( + apiMatrix + .filterModels(project.models) + .sortBy(_.id) + .flatMap(model => model.fields)) + .map(field => FieldContext(project, field)) + + Connection + .connectionFromSeq(fields, ConnectionArgs(ctx)) + } + ), + Field( + "models", + modelConnection, + arguments = Connection.Args.All, + resolve = ctx => { + implicit val inj = ctx.ctx.injector + val apiMatrix = inject[ApiMatrixFactory].create(ctx.value) + Connection + .connectionFromSeq(apiMatrix + .filterModels(ctx.value.models) + .sortBy(_.id) + .map(model => ModelContext(ctx.value, model)), + ConnectionArgs(ctx)) + } + ), + Field("enums", enumConnection, arguments = Connection.Args.All, resolve = ctx => { + Connection.connectionFromSeq(ctx.value.enums, ConnectionArgs(ctx)) + }), + Field( + "packageDefinitions", + packageDefinitionConnection, + arguments = Connection.Args.All, + resolve = ctx => { + Connection + .connectionFromSeq(ctx.value.packageDefinitions + .sortBy(_.id) + .map(packageDefinition => packageDefinition), + ConnectionArgs(ctx)) + } + ), + Field( + "relations", + relationConnection, + arguments = Connection.Args.All, + resolve = ctx => { + implicit val inj = ctx.ctx.injector + val apiMatrix = inject[ApiMatrixFactory].create(ctx.value) + val relations = apiMatrix.filterRelations(ctx.value.relations).sortBy(_.id) + val relationContexts = relations.map(rel => RelationContext(ctx.value, rel)) + Connection.connectionFromSeq(relationContexts, ConnectionArgs(ctx)) + } + ), + Field( + "permanentAuthTokens", + rootTokenConnection, + arguments = Connection.Args.All, + resolve = ctx => + Connection.connectionFromSeq(ctx.value.rootTokens + .sortBy(_.id), + ConnectionArgs(ctx)) + ), + Field( + "functions", + functionConnection, + arguments = Connection.Args.All, + resolve = ctx => { + val functions: Seq[Function.FunctionInterface] = + ctx.value.functions.sortBy(_.id).map(Function.mapToContext(ctx.value, _)) + Connection.connectionFromSeq(functions, ConnectionArgs(ctx)) + } + ), + Field( + "featureToggles", + featureToggleConnection, + arguments = Connection.Args.All, + resolve = ctx => { + Connection.connectionFromSeq(ctx.value.featureToggles, ConnectionArgs(ctx)) + } + ), + Field( + "actions", + actionConnection, + arguments = Connection.Args.All, + resolve = ctx => Connection.connectionFromSeq(ctx.value.actions.sortBy(_.id).map(a => ActionContext(ctx.value, a)), ConnectionArgs(ctx)) + ), { + val modelIdArgument = Argument("modelId", IDType) + val modelMutationTypeArgument = + Argument("modelMutationType", ModelMutationTypeType) + + Field( + "actionSchema", + StringType, + arguments = List(modelIdArgument, modelMutationTypeArgument), + resolve = ctx => { + val payload = ActionSchemaPayload( + triggerType = ActionTriggerType.MutationModel, + mutationModel = Some( + ActionSchemaPayloadMutationModel( + modelId = ctx arg modelIdArgument, + mutationType = + ctx arg modelMutationTypeArgument + )), + mutationRelation = None + ) + + ctx.ctx.getActionSchema(ctx.value, payload) + } + ) + }, + Field("allowMutations", BooleanType, resolve = _.value.allowMutations), + Field("availableUserRoles", ListType(StringType), resolve = _ => List()), + Field( + "functionRequestHistogram", + ListType(IntType), + arguments = List(Argument("period", HistogramPeriodType)), + resolve = ctx => { + + ctx.ctx.logsDataResolver.calculateHistogram(ctx.value.id, ctx.arg("period")) + } + ), + Field("isEjected", BooleanType, resolve = _.value.isEjected) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ProjectDatabase.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ProjectDatabase.scala new file mode 100644 index 0000000000..51b8e33912 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/ProjectDatabase.scala @@ -0,0 +1,18 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema.{Field, ObjectType, StringType, fields, interfaces} + +object ProjectDatabase { + lazy val Type: ObjectType[SystemUserContext, models.ProjectDatabase] = ObjectType( + "ProjectDatabase", + "This is the database for a project", + interfaces[SystemUserContext, models.ProjectDatabase](nodeInterface), + idField[SystemUserContext, models.ProjectDatabase] :: + fields[SystemUserContext, models.ProjectDatabase]( + Field("name", StringType, resolve = _.value.name), + Field("region", RegionType, resolve = _.value.region) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Region.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Region.scala new file mode 100644 index 0000000000..cd0db33f47 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Region.scala @@ -0,0 +1,16 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object Region { + lazy val Type = EnumType( + "Region", + values = List( + EnumValue("EU_WEST_1", value = models.Region.EU_WEST_1), + EnumValue("AP_NORTHEAST_1", value = models.Region.AP_NORTHEAST_1), + EnumValue("US_WEST_2", value = models.Region.US_WEST_2) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Relation.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Relation.scala new file mode 100644 index 0000000000..2a46b58bfa --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Relation.scala @@ -0,0 +1,90 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import cool.graph.system.schema.types.RelationPermission.RelationPermissionContext +import cool.graph.system.schema.types._Field.FieldContext +import sangria.relay._ +import sangria.schema._ + +object Relation { + case class RelationContext(project: models.Project, relation: models.Relation) extends Node { + override def id: String = relation.id + } + + lazy val Type: ObjectType[SystemUserContext, RelationContext] = ObjectType[SystemUserContext, RelationContext]( + "Relation", + "This is a relation", + interfaces[SystemUserContext, RelationContext](nodeInterface), + idField[SystemUserContext, RelationContext] :: + fields[SystemUserContext, RelationContext]( + Field( + "leftModel", + ModelType, + resolve = ctx => { + val project = ctx.value.project + val model = project.getModelById_!(ctx.value.relation.modelAId) + + ModelContext(project, model) + } + ), + Field( + "fieldOnLeftModel", + FieldType, + resolve = ctx => { + val project = ctx.value.project + val field = ctx.value.relation.getModelAField_!(project) + + FieldContext(project, field) + } + ), + Field( + "rightModel", + ModelType, + resolve = ctx => { + val project = ctx.value.project + val model = project.getModelById_!(ctx.value.relation.modelBId) + + ModelContext(project, model) + } + ), + Field( + "fieldOnRightModel", + FieldType, + resolve = ctx => { + val project = ctx.value.project + val relation = ctx.value.relation + val fieldOnRightModel = relation.getModelBField_!(project) + + FieldContext(project, fieldOnRightModel) + } + ), + Field( + "permissions", + relationPermissionConnection, + arguments = Connection.Args.All, + resolve = ctx => { + val permissions = ctx.value.relation.permissions + .sortBy(_.id) + .map(relationPermission => RelationPermissionContext(ctx.value.project, relationPermission)) + + Connection.connectionFromSeq(permissions, ConnectionArgs(ctx)) + } + ), + Field("name", StringType, resolve = ctx => ctx.value.relation.name), + Field("description", OptionType(StringType), resolve = ctx => ctx.value.relation.description), + Field("fieldMirrors", ListType(RelationFieldMirrorType), resolve = ctx => ctx.value.relation.fieldMirrors), + Field("permissionSchema", StringType, resolve = ctx => { + ctx.ctx.getRelationPermissionSchema(ctx.value.project, ctx.value.id) + }), + Field( + "permissionQueryArguments", + ListType(PermissionQueryArgument.Type), + resolve = ctx => { + PermissionQueryArguments.getRelationArguments(ctx.value.relation, project = ctx.value.project) + } + ) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationFieldMirror.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationFieldMirror.scala new file mode 100644 index 0000000000..0699886f93 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationFieldMirror.scala @@ -0,0 +1,21 @@ +package cool.graph.system.schema.types + +import sangria.schema._ +import sangria.relay._ + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext + +object RelationFieldMirror { + lazy val Type: ObjectType[SystemUserContext, models.RelationFieldMirror] = + ObjectType( + "RelationFieldMirror", + "This is a relation field mirror", + interfaces[SystemUserContext, models.RelationFieldMirror](nodeInterface), + idField[SystemUserContext, models.RelationFieldMirror] :: + fields[SystemUserContext, models.RelationFieldMirror]( + Field("fieldId", IDType, resolve = ctx => ctx.value.fieldId), + Field("relationId", IDType, resolve = ctx => ctx.value.relationId) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationPermission.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationPermission.scala new file mode 100644 index 0000000000..8b1f2f71a6 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/RelationPermission.scala @@ -0,0 +1,44 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.Model.ModelContext +import cool.graph.system.schema.types.Relation.RelationContext +import sangria.relay.Node +import sangria.schema._ + +object RelationPermission { + + case class RelationPermissionContext(project: models.Project, relationPermission: models.RelationPermission) extends Node { + def id = relationPermission.id + } + lazy val Type: ObjectType[SystemUserContext, RelationPermissionContext] = + ObjectType( + "RelationPermission", + "This is a relation permission", + interfaces[SystemUserContext, RelationPermissionContext](nodeInterface), + () => + idField[SystemUserContext, RelationPermissionContext] :: + fields[SystemUserContext, RelationPermissionContext]( + Field("ruleWebhookUrl", OptionType(StringType), resolve = _.value.relationPermission.ruleWebhookUrl), + Field("rule", Rule.Type, resolve = _.value.relationPermission.rule), + Field("ruleName", OptionType(StringType), resolve = _.value.relationPermission.ruleName), + Field("ruleGraphQuery", OptionType(StringType), resolve = _.value.relationPermission.ruleGraphQuery), + Field("isActive", BooleanType, resolve = _.value.relationPermission.isActive), + Field("connect", BooleanType, resolve = _.value.relationPermission.connect), + Field("disconnect", BooleanType, resolve = _.value.relationPermission.disconnect), + Field("userType", UserType.Type, resolve = _.value.relationPermission.userType), + Field("description", OptionType(StringType), resolve = _.value.relationPermission.description), + Field( + "relation", + RelationType, + resolve = ctx => { + val project = ctx.value.project + val relation = project.getRelationByRelationPermissionId(ctx.value.id).get + + RelationContext(project, relation) + } + ) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Rule.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Rule.scala new file mode 100644 index 0000000000..3207899f6e --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Rule.scala @@ -0,0 +1,14 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object Rule { + val Type = EnumType( + "Rule", + values = List(EnumValue("NONE", value = models.CustomRule.None), + EnumValue("GRAPH", value = models.CustomRule.Graph), + EnumValue("WEBHOOK", value = models.CustomRule.Webhook)) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SchemaErrorType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SchemaErrorType.scala new file mode 100644 index 0000000000..73cd3d9c85 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SchemaErrorType.scala @@ -0,0 +1,20 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object SchemaErrorType { + lazy val TheListType = ListType(Type) + + lazy val Type: ObjectType[SystemUserContext, SchemaError] = ObjectType( + "SchemaError", + "An error that occurred while validating the schema.", + List.empty, + fields[SystemUserContext, SchemaError]( + Field("type", StringType, resolve = _.value.`type`), + Field("field", OptionType(StringType), resolve = _.value.field), + Field("description", StringType, resolve = _.value.description) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SearchProviderAlgolia.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SearchProviderAlgolia.scala new file mode 100644 index 0000000000..66d61dc5bf --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SearchProviderAlgolia.scala @@ -0,0 +1,86 @@ +package cool.graph.system.schema.types + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.shared.algolia.schemas.AlgoliaSchema +import cool.graph.shared.algolia.AlgoliaContext +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import cool.graph.system.schema.types.AlgoliaSyncQuery.AlgoliaSyncQueryContext +import sangria.execution.Executor +import sangria.introspection.introspectionQuery +import sangria.marshalling.sprayJson._ +import sangria.relay.{Connection, ConnectionArgs, Node} +import sangria.schema._ +import scaldi.{Injectable, Injector} +import spray.json.JsObject + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object SearchProviderAlgolia { + case class SearchProviderAlgoliaContext(project: models.Project, algolia: models.SearchProviderAlgolia) extends Node with models.Integration { + override val id = algolia.id + override val subTableId = algolia.subTableId + override val isEnabled = algolia.isEnabled + override val name = algolia.name + override val integrationType = algolia.integrationType + } + lazy val Type: ObjectType[SystemUserContext, SearchProviderAlgoliaContext] = + ObjectType( + "SearchProviderAlgolia", + "This is a SearchProviderAlgolia", + interfaces[SystemUserContext, SearchProviderAlgoliaContext](nodeInterface, Integration.Type), + () => + idField[SystemUserContext, SearchProviderAlgoliaContext] :: + fields[SystemUserContext, SearchProviderAlgoliaContext]( + Field("applicationId", StringType, resolve = _.value.algolia.applicationId), + Field("apiKey", StringType, resolve = _.value.algolia.apiKey), + Field( + "algoliaSyncQueries", + algoliaSyncQueryConnection, + arguments = Connection.Args.All, + resolve = ctx => + Connection.connectionFromSeq(ctx.value.algolia.algoliaSyncQueries + .sortBy(_.id.toString) + .map(s => AlgoliaSyncQueryContext(ctx.value.project, s)), + ConnectionArgs(ctx)) + ), + Field( + "algoliaSchema", + StringType, + arguments = List(Argument("modelId", IDType)), + resolve = ctx => { + val modelId = + ctx.args.raw.get("modelId").get.asInstanceOf[String] + ctx.ctx.getSearchProviderAlgoliaSchema(ctx.value.project, modelId) + } + ) + ) + ) +} + +class SearchProviderAlgoliaSchemaResolver(implicit inj: Injector) extends Injectable with LazyLogging { + def resolve(project: models.Project, modelId: String): Future[String] = { + val model = project.getModelById_!(modelId) + Executor + .execute( + schema = new AlgoliaSchema( + project = project, + model = model, + modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project) + ).build(), + queryAst = introspectionQuery, + userContext = AlgoliaContext( + project = project, + requestId = "", + nodeId = "", + log = (x: String) => logger.info(x) + ) + ) + .map { response => + val JsObject(fields) = response + fields("data").compactPrint + } + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Seat.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Seat.scala new file mode 100644 index 0000000000..9464adc27f --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Seat.scala @@ -0,0 +1,20 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.models +import cool.graph.system.SystemUserContext +import sangria.schema._ + +object Seat { + lazy val Type: ObjectType[SystemUserContext, models.Seat] = ObjectType( + "Seat", + "This is a seat", + interfaces[SystemUserContext, models.Seat](nodeInterface), + idField[SystemUserContext, models.Seat] :: + fields[SystemUserContext, models.Seat]( + Field("isOwner", BooleanType, resolve = _.value.isOwner), + Field("email", StringType, resolve = _.value.email), + Field("name", OptionType(StringType), resolve = _.value.name), + Field("status", SeatStatusType, resolve = _.value.status) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SeatStatus.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SeatStatus.scala new file mode 100644 index 0000000000..90c965110d --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/SeatStatus.scala @@ -0,0 +1,16 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object SeatStatus { + val Type = EnumType( + "SeatStatus", + values = List( + EnumValue("JOINED", value = models.SeatStatus.JOINED), + EnumValue("INVITED_TO_PROJECT", value = models.SeatStatus.INVITED_TO_PROJECT), + EnumValue("INVITED_TO_GRAPHCOOL", value = models.SeatStatus.INVITED_TO_GRAPHCOOL) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/UserType.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/UserType.scala new file mode 100644 index 0000000000..2a03f62638 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/UserType.scala @@ -0,0 +1,10 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object UserType { + val Type = EnumType("UserType", + values = List(EnumValue("EVERYONE", value = models.UserType.Everyone), EnumValue("AUTHENTICATED", value = models.UserType.Authenticated))) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/VerbalDescription.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/VerbalDescription.scala new file mode 100644 index 0000000000..a9edef59f9 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/VerbalDescription.scala @@ -0,0 +1,34 @@ +package cool.graph.system.schema.types + +import cool.graph.system.SystemUserContext +import cool.graph.system.migration.dataSchema.{VerbalDescription, VerbalSubDescription} +import sangria.schema._ + +object VerbalDescriptionType { + lazy val TheListType = ListType(Type) + + lazy val Type: ObjectType[SystemUserContext, VerbalDescription] = ObjectType( + "MigrationMessage", + "verbal descriptions of actions taken during a schema migration", + List.empty, + fields[SystemUserContext, VerbalDescription]( + Field("type", StringType, resolve = _.value.`type`), + Field("action", StringType, resolve = _.value.action), + Field("name", StringType, resolve = _.value.name), + Field("description", StringType, resolve = _.value.description), + Field("subDescriptions", ListType(SubDescriptionType), resolve = _.value.subDescriptions) + ) + ) + + lazy val SubDescriptionType: ObjectType[SystemUserContext, VerbalSubDescription] = ObjectType( + "MigrationSubMessage", + "verbal descriptions of actions taken during a schema migration", + List.empty, + fields[SystemUserContext, VerbalSubDescription]( + Field("type", StringType, resolve = _.value.`type`), + Field("action", StringType, resolve = _.value.action), + Field("name", StringType, resolve = _.value.name), + Field("description", StringType, resolve = _.value.description) + ) + ) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Viewer.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Viewer.scala new file mode 100644 index 0000000000..706e26dcdd --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/Viewer.scala @@ -0,0 +1,168 @@ +package cool.graph.system.schema.types + +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.models +import cool.graph.shared.models.{Client, ModelParser} +import cool.graph.system.SystemUserContext +import cool.graph.system.database.finder.{ProjectFinder, ProjectResolver} +import cool.graph.system.schema.types.Model.ModelContext +import cool.graph.system.schema.types.Relation.RelationContext +import cool.graph.system.schema.types._Field.FieldContext +import sangria.relay.Node +import sangria.schema._ +import scaldi.Injector + +import scala.concurrent.Future + +case class ViewerModel(id: String) extends Node + +object ViewerModel { + val globalId = "static-viewer-id" + + def apply(): ViewerModel = new ViewerModel(ViewerModel.globalId) +} + +object Viewer { + import scala.concurrent.ExecutionContext.Implicits.global + + def getType(clientType: ObjectType[SystemUserContext, Client], projectResolver: ProjectResolver)( + implicit inj: Injector): ObjectType[SystemUserContext, ViewerModel] = { + + val idArgument = Argument("id", IDType) + val projectNameArgument = Argument("projectName", StringType) + val modelNameArgument = Argument("modelName", StringType) + val relationNameArgument = Argument("relationName", StringType) + val fieldNameArgument = Argument("fieldName", StringType) + + def throwNotFound(item: String) = throw UserInputErrors.NotFoundException(s"$item not found") + + ObjectType( + "Viewer", + "This is the famous Relay viewer object", + interfaces[SystemUserContext, ViewerModel](nodeInterface), + idField[SystemUserContext, ViewerModel] :: + fields[SystemUserContext, ViewerModel]( + Field("user", OptionType(clientType), resolve = ctx => { + val client = ctx.ctx.getClient + client + }), + Field( + "project", + OptionType(ProjectType), + arguments = idArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val id = ctx.arg(idArgument) + val project: Future[models.Project] = ProjectFinder.loadById(clientId, id)(projectResolver) + project + } + ), + Field( + "projectByName", + OptionType(ProjectType), + arguments = projectNameArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val projectName = ctx.arg(projectNameArgument) + val project: Future[models.Project] = ProjectFinder.loadByName(clientId, projectName)(ctx.ctx.internalDatabase, projectResolver) + project + } + ), + Field( + "model", + OptionType(ModelType), + arguments = idArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val modelId = ctx.arg(idArgument) + val project: Future[models.Project] = ProjectFinder.loadByModelId(clientId, modelId)(ctx.ctx.internalDatabase, projectResolver) + project.map { project => + val model = project.getModelById_!(modelId) + ModelContext(project, model) + } + } + ), + Field( + "modelByName", + OptionType(ModelType), + arguments = projectNameArgument :: modelNameArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val modelName = ctx.arg(modelNameArgument) + val projectName = ctx.arg(projectNameArgument) + val project: Future[models.Project] = ProjectFinder.loadByName(clientId, projectName)(ctx.ctx.internalDatabase, projectResolver) + project.map { project => + val model = ModelParser.modelByName(project, modelName, ctx.ctx.injector).getOrElse(throwNotFound("Model")) + ModelContext(project, model) + } + } + ), + Field( + "relation", + OptionType(RelationType), + arguments = idArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val id = ctx.arg(idArgument) + val project: Future[models.Project] = ProjectFinder.loadByRelationId(clientId, id)(ctx.ctx.internalDatabase, projectResolver) + project.map { project => + ModelParser + .relation(project, id, ctx.ctx.injector) + .map(rel => RelationContext(project, rel)) + .getOrElse(throwNotFound("Relation")) + } + } + ), + Field( + "relationByName", + OptionType(RelationType), + arguments = projectNameArgument :: relationNameArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val projectName = ctx.arg(projectNameArgument) + val project: Future[models.Project] = ProjectFinder.loadByName(clientId, projectName)(ctx.ctx.internalDatabase, projectResolver) + + project.map { project => + ModelParser + .relationByName(project, ctx.arg(relationNameArgument), ctx.ctx.injector) + .map(rel => RelationContext(project, rel)) + .getOrElse(throwNotFound("Relation by name")) + } + } + ), + Field( + "field", + OptionType(FieldType), + arguments = idArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val fieldId = ctx.arg(idArgument) + val project: Future[models.Project] = ProjectFinder.loadByFieldId(clientId, fieldId)(ctx.ctx.internalDatabase, projectResolver) + project.map { project => + val field = project.getFieldById_!(fieldId) + FieldContext(project, field) + } + } + ), + Field( + "fieldByName", + OptionType(FieldType), + arguments = + projectNameArgument :: modelNameArgument :: fieldNameArgument :: Nil, + resolve = ctx => { + val clientId = ctx.ctx.getClient.id + val fieldName = ctx.arg(fieldNameArgument) + val modelName = ctx.arg(modelNameArgument) + val projectName = ctx.arg(projectNameArgument) + val project: Future[models.Project] = ProjectFinder.loadByName(clientId, projectName)(ctx.ctx.internalDatabase, projectResolver) + project.map { project => + val field = + ModelParser.fieldByName(project, modelName, fieldName, ctx.ctx.injector).getOrElse(throwNotFound("Field by name")) + FieldContext(project, field) + } + } + ) + ) + ) + } +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/package.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/package.scala new file mode 100644 index 0000000000..16bf31780c --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/package.scala @@ -0,0 +1,292 @@ +package cool.graph.system.schema + +import cool.graph.shared.models +import cool.graph.shared.models.ModelParser +import cool.graph.system.SystemUserContext +import cool.graph.system.database.finder.ProjectFinder +import cool.graph.system.schema.types.ActionTriggerMutationModel.ActionTriggerMutationModelContext +import cool.graph.system.schema.types.AlgoliaSyncQuery.AlgoliaSyncQueryContext +import cool.graph.system.schema.types.Function.FunctionInterface +import cool.graph.system.schema.types.Model.ModelContext +import cool.graph.system.schema.types.ModelPermission.ModelPermissionContext +import cool.graph.system.schema.types.Relation.RelationContext +import cool.graph.system.schema.types.RelationPermission.RelationPermissionContext +import cool.graph.system.schema.types.SearchProviderAlgolia.SearchProviderAlgoliaContext +import cool.graph.system.schema.types._Action.ActionContext +import cool.graph.system.schema.types._Field.FieldContext +import sangria.relay._ +import sangria.schema._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +package object types { + + val NodeDefinition(nodeInterface, nodeField, nodeRes) = Node.definitionById( + resolve = (id: String, ctx: Context[SystemUserContext, Unit]) => { + val clientId = ctx.ctx.getClient.id + + implicit val internalDatabase = ctx.ctx.internalDatabase + implicit val projectResolver = ctx.ctx.projectResolver + + ctx.ctx.getTypeName(id).flatMap { + case Some("Client") if ctx.ctx.getClient.id == id => + Future.successful(Some(ctx.ctx.getClient)) + case Some("Project") => { + val project: Future[models.Project] = ProjectFinder.loadById(clientId, id) + project.map(Some(_)) + } + case Some("Model") => { + val project: Future[models.Project] = ProjectFinder.loadByModelId(clientId, id) + project.map { project => + ModelParser.model(project, id, ctx.ctx.injector) + } + } + case Some("Field") => { + val project: Future[models.Project] = ProjectFinder.loadByFieldId(clientId, id) + project.map { project => + ModelParser + .field(project, id, ctx.ctx.injector) + .map(FieldContext(project, _)) + } + } + case Some("Action") => { + val project: Future[models.Project] = ProjectFinder.loadByActionId(clientId, id) + project.map { project => + ModelParser + .action(project, id) + .map(ActionContext(project, _)) + } + } + case Some("Relation") => { + val project: Future[models.Project] = ProjectFinder.loadByRelationId(clientId, id) + project.map { project => + ModelParser + .relation(project, id, ctx.ctx.injector) + .map(rel => RelationContext(project, rel)) + } + } + case Some("ActionTriggerMutationModel") => { + val project: Future[models.Project] = ProjectFinder.loadByActionTriggerMutationModelId(clientId, id) + project.map { project => + ModelParser + .actionTriggerMutationModel(project, id) + .map(ActionTriggerMutationModelContext(project, _)) + } + } + case Some("ActionTriggerMutationRelation") => { + val project: Future[models.Project] = ProjectFinder.loadByActionTriggerMutationRelationId(clientId, id) + project.map { project => + ModelParser.actionTriggerMutationRelation(project, id) + } + } + case Some("ActionHandlerWebhook") => { + val project: Future[models.Project] = ProjectFinder.loadByActionHandlerWebhookId(clientId, id) + project.map { project => + ModelParser.actionHandlerWebhook(project, id) + } + } + case Some("Function") => { + val project: Future[models.Project] = ProjectFinder.loadByFunctionId(clientId, id) + project.map { project => + ModelParser + .function(project, id) + .map(Function.mapToContext(project, _)) + } + } + case Some("ModelPermission") => { + val project: Future[models.Project] = ProjectFinder.loadByModelPermissionId(clientId, id) + project.map { project => + ModelParser + .modelPermission(project, id) + .map(ModelPermissionContext(project, _)) + } + } + case Some("RelationPermission") => { + val project: Future[models.Project] = ProjectFinder.loadByRelationPermissionId(clientId, id) + project.map { project => + ModelParser + .relationPermission(project, id, ctx.ctx.injector) + .map(RelationPermissionContext(project, _)) + } + } + case Some("Integration") => { + val project: Future[models.Project] = ProjectFinder.loadByIntegrationId(clientId, id) + project.map { project => + ModelParser + .integration(project, id) + .map { + case x: models.SearchProviderAlgolia => SearchProviderAlgoliaContext(project, x) + case x => x + } + } + } + case Some("AlgoliaSyncQuery") => { + val project: Future[models.Project] = ProjectFinder.loadByAlgoliaSyncQueryId(clientId, id) + project.map { project => + { + ModelParser + .algoliaSyncQuery(project, id) + .map(sync => AlgoliaSyncQueryContext(project, sync)) + } + } + } + case Some("Seat") => { + val project: Future[models.Project] = ProjectFinder.loadBySeatId(clientId, id) + project.map { project => + ModelParser.seat(project, id) + } + } + case Some("PackageDefinition") => { + val project: Future[models.Project] = ProjectFinder.loadByPackageDefinitionId(clientId, id) + project.map { project => + ModelParser.packageDefinition(project, id) + } + } + case Some("Viewer") => + Future.successful(Some(ViewerModel())) + case x => + println(x) + Future.successful(None) + } + }, + possibleTypes = Node.possibleNodeTypes[SystemUserContext, Node]( +// ClientType, + ProjectType, + ModelType, + FieldType, + ActionType, + ActionTriggerMutationModelType, + ActionTriggerMutationRelationType, + ActionHandlerWebhookType, + RelationType, + AuthProviderType, + ModelPermissionType, + RelationPermissionType, + SearchProviderAlgoliaType, + AlgoliaSyncQueryType, + RequestPipelineMutationFunctionType, + ServerSideSubscriptionFunctionType, + SchemaExtensionFunctionType, + StringConstraintType, + BooleanConstraintType, + NumberConstraintType, + ListConstraintType + ) + ) + + lazy val CustomerSourceType = CustomerSource.Type + lazy val UserTypeType = UserType.Type +// lazy val ClientType = Customer.Type + lazy val rootTokenType = rootToken.Type + lazy val ProjectType = Project.Type + lazy val ProjectDatabaseType = ProjectDatabase.Type + lazy val RegionType = Region.Type + lazy val ModelType = Model.Type + lazy val OurEnumType = Enum.Type + lazy val FieldType = _Field.Type + lazy val ModelPermissionType = ModelPermission.Type + lazy val RelationPermissionType = RelationPermission.Type + lazy val RelationType = Relation.Type + lazy val FunctionInterfaceType = Function.Type + lazy val RequestPipelineMutationFunctionType = RequestPipelineMutationFunction.Type + lazy val ServerSideSubscriptionFunctionType = ServerSideSubscriptionFunction.Type + lazy val SchemaExtensionFunctionType = SchemaExtensionFunction.Type + lazy val LogType = Log.Type + lazy val LogStatusType = LogStatus.Type + lazy val RelationFieldMirrorType = RelationFieldMirror.Type + lazy val AuthProviderType = AuthProvider.Type + lazy val ActionType = _Action.Type + lazy val TriggerTypeType = TriggerType.Type + lazy val HandlerTypeType = HandlerType.Type + lazy val ActionTriggerMutationModelType = ActionTriggerMutationModel.Type + lazy val ModelMutationTypeType = ModelMutationType.Type + lazy val RelationMutationTypeType = RelationMutationType.Type + lazy val ActionTriggerMutationRelationType = ActionTriggerMutationRelation.Type + lazy val ActionHandlerWebhookType = ActionHandlerWebhook.Type + lazy val SearchProviderAlgoliaType = SearchProviderAlgolia.Type + lazy val AlgoliaSyncQueryType = AlgoliaSyncQuery.Type + lazy val IntegrationInterfaceType = Integration.Type + lazy val SeatStatusType = SeatStatus.Type + lazy val SeatType = Seat.Type + lazy val PackageDefinitionType = PackageDefinition.Type + lazy val FeatureToggleType = FeatureToggle.Type + lazy val FieldConstraintType = FieldConstraint.Type + lazy val StringConstraintType = StringConstraint.Type + lazy val NumberConstraintType = NumberConstraint.Type + lazy val BooleanConstraintType = BooleanConstraint.Type + lazy val ListConstraintType = ListConstraint.Type + lazy val HistogramPeriodType = HistogramPeriod.Type + + // lazy val ViewerType = Viewer.Type + +// lazy val ConnectionDefinition(clientEdge, clientConnection) = Connection +// .definition[UserContext, Connection, models.Client]("Client", ClientType) + + lazy val ConnectionDefinition(projectEdge, projectConnection) = + Connection.definition[SystemUserContext, Connection, models.Project]("Project", ProjectType) + + lazy val ConnectionDefinition(modelEdge, modelConnection) = Connection + .definition[SystemUserContext, Connection, ModelContext]("Model", ModelType) + + lazy val ConnectionDefinition(enumEdge, enumConnection) = Connection + .definition[SystemUserContext, Connection, models.Enum]("Enum", OurEnumType) + + lazy val ConnectionDefinition(packageDefinitionEdge, packageDefinitionConnection) = Connection + .definition[SystemUserContext, Connection, models.PackageDefinition]("PackageDefinition", PackageDefinitionType) + + lazy val ConnectionDefinition(algoliaSyncQueryEdge, algoliaSyncQueryConnection) = Connection + .definition[SystemUserContext, Connection, AlgoliaSyncQueryContext]("AlgoliaSyncQuery", AlgoliaSyncQueryType) + + lazy val ConnectionDefinition(projectFieldEdge, projectFieldConnection) = + Connection + .definition[SystemUserContext, Connection, FieldContext]("Field", FieldType) + + lazy val ConnectionDefinition(relationEdge, relationConnection) = + Connection.definition[SystemUserContext, Connection, RelationContext]("Relation", RelationType) + + lazy val ConnectionDefinition(functionEdge, functionConnection) = + Connection.definition[SystemUserContext, Connection, FunctionInterface]("Function", FunctionInterfaceType) + + lazy val ConnectionDefinition(logEdge, logConnection) = + Connection.definition[SystemUserContext, Connection, models.Log]("Log", LogType) + + lazy val ConnectionDefinition(relationFieldMirrorEdge, relationFieldMirrorConnection) = + Connection + .definition[SystemUserContext, Connection, models.RelationFieldMirror]("RelationFieldMirror", RelationFieldMirrorType) + + lazy val ConnectionDefinition(actionEdge, actionConnection) = Connection + .definition[SystemUserContext, Connection, ActionContext]("Action", ActionType) + + lazy val ConnectionDefinition(authProviderEdge, authProviderConnection) = + Connection + .definition[SystemUserContext, Connection, models.AuthProvider]("AuthProvider", AuthProviderType) + + lazy val ConnectionDefinition(fieldEdge, fieldConnection) = Connection + .definition[SystemUserContext, Connection, FieldContext]("Field", FieldType) + + lazy val ConnectionDefinition(modelPermissionEdge, modelPermissionConnection) = + Connection + .definition[SystemUserContext, Connection, ModelPermissionContext]("ModelPermission", ModelPermissionType) + + lazy val ConnectionDefinition(relationPermissionEdge, relationPermissionConnection) = + Connection + .definition[SystemUserContext, Connection, RelationPermissionContext]("RelationPermission", RelationPermissionType) + + lazy val ConnectionDefinition(rootTokenEdge, rootTokenConnection) = + Connection + .definition[SystemUserContext, Connection, models.RootToken]("PermanentAuthToken", rootTokenType) + + lazy val ConnectionDefinition(integrationEdge, integrationConnection) = + Connection + .definition[SystemUserContext, Connection, models.Integration]("Integration", IntegrationInterfaceType) + + lazy val ConnectionDefinition(seatEdge, seatConnection) = + Connection.definition[SystemUserContext, Connection, models.Seat]("Seat", SeatType) + + lazy val ConnectionDefinition(featureToggleEdge, featureToggleConnection) = + Connection.definition[SystemUserContext, Connection, models.FeatureToggle]("FeatureToggle", FeatureToggleType) + + def idField[Ctx, T: Identifiable]: Field[Ctx, T] = + Field("id", IDType, resolve = ctx => implicitly[Identifiable[T]].id(ctx.value)) +} diff --git a/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/rootToken.scala b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/rootToken.scala new file mode 100644 index 0000000000..c206417b41 --- /dev/null +++ b/server/backend-api-system/src/main/scala/cool/graph/system/schema/types/rootToken.scala @@ -0,0 +1,18 @@ +package cool.graph.system.schema.types + +import sangria.schema._ + +import cool.graph.shared.models + +object rootToken { + lazy val Type: ObjectType[Unit, models.RootToken] = ObjectType( + "PermanentAuthToken", + "Used to grant permanent access to your applications and services", + interfaces[Unit, models.RootToken](nodeInterface), + idField[Unit, models.RootToken] :: + fields[Unit, models.RootToken]( + Field("name", StringType, resolve = _.value.name), + Field("token", StringType, resolve = _.value.token) + ) + ) +} diff --git a/server/backend-shared/build.sbt b/server/backend-shared/build.sbt new file mode 100644 index 0000000000..024ef9061e --- /dev/null +++ b/server/backend-shared/build.sbt @@ -0,0 +1 @@ +name := "backend-shared" \ No newline at end of file diff --git a/server/backend-shared/project/build.properties b/server/backend-shared/project/build.properties new file mode 100644 index 0000000000..27e88aa115 --- /dev/null +++ b/server/backend-shared/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.13 diff --git a/server/backend-shared/project/plugins.sbt b/server/backend-shared/project/plugins.sbt new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/server/backend-shared/project/plugins.sbt @@ -0,0 +1 @@ + diff --git a/server/backend-shared/src/main/resources/application.conf b/server/backend-shared/src/main/resources/application.conf new file mode 100644 index 0000000000..fc66dd5d50 --- /dev/null +++ b/server/backend-shared/src/main/resources/application.conf @@ -0,0 +1,39 @@ + +# Test DBs +internalTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/"${?TEST_SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_INTERNAL_USER} + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +internalTestRoot { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_INTERNAL_HOST}":"${?TEST_SQL_INTERNAL_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = "root" + password = ${?TEST_SQL_INTERNAL_PASSWORD} + } + numThreads = ${?TEST_SQL_INTERNAL_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +clientTest { + connectionInitSql="set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?TEST_SQL_CLIENT_HOST}":"${?TEST_SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?TEST_SQL_CLIENT_USER} + password = ${?TEST_SQL_CLIENT_PASSWORD} + } + numThreads = ${?TEST_SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 +} + +slick.dbs.default.db.connectionInitSql="set names utf8mb4" \ No newline at end of file diff --git a/server/backend-shared/src/main/resources/logback.xml b/server/backend-shared/src/main/resources/logback.xml new file mode 100644 index 0000000000..d8b4b2fde1 --- /dev/null +++ b/server/backend-shared/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/backend-shared/src/main/scala/cool/graph/FieldMetrics.scala b/server/backend-shared/src/main/scala/cool/graph/FieldMetrics.scala new file mode 100644 index 0000000000..ed889f5556 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/FieldMetrics.scala @@ -0,0 +1,66 @@ +package cool.graph + +import sangria.execution._ +import sangria.schema._ +import spray.json.DefaultJsonProtocol._ +import spray.json._ +import com.typesafe.scalalogging.LazyLogging +import cool.graph.shared.logging.{LogData, LogKey} + +import scala.collection.concurrent.TrieMap + +class FieldMetricsMiddleware + extends Middleware[RequestContextTrait] + with MiddlewareAfterField[RequestContextTrait] + with MiddlewareErrorField[RequestContextTrait] + with LazyLogging { + + type QueryVal = TrieMap[String, List[Int]] + type FieldVal = Long + + def beforeQuery(context: MiddlewareQueryContext[RequestContextTrait, _, _]) = + TrieMap() + def afterQuery(queryVal: QueryVal, context: MiddlewareQueryContext[RequestContextTrait, _, _]) = { + + import TimingProtocol._ + + val total = queryVal.foldLeft(0)(_ + _._2.sum) + val sumMap = queryVal.toMap.mapValues(_.sum) + ("__total" -> total) +// logger.info( +// LogData( +// key = LogKey.RequestMetricsFields, +// requestId = context.ctx.requestId, +// clientId = Some(context.ctx.clientId), +// projectId = context.ctx.projectId, +// payload = Some(sumMap) +// ).json) + } + + def beforeField(queryVal: QueryVal, mctx: MiddlewareQueryContext[RequestContextTrait, _, _], ctx: Context[RequestContextTrait, _]) = + continue(System.currentTimeMillis()) + + def afterField(queryVal: QueryVal, + fieldVal: FieldVal, + value: Any, + mctx: MiddlewareQueryContext[RequestContextTrait, _, _], + ctx: Context[RequestContextTrait, _]) = { + val key = ctx.parentType.name + "." + ctx.field.name + val list = queryVal.getOrElse(key, Nil) + + queryVal.update(key, list :+ (System.currentTimeMillis() - fieldVal).toInt) + None + } + + def fieldError(queryVal: QueryVal, + fieldVal: FieldVal, + error: Throwable, + mctx: MiddlewareQueryContext[RequestContextTrait, _, _], + ctx: Context[RequestContextTrait, _]) = { + val key = ctx.parentType.name + "." + ctx.field.name + val list = queryVal.getOrElse(key, Nil) + val errors = queryVal.getOrElse("ERROR", Nil) + + queryVal.update(key, list :+ (System.currentTimeMillis() - fieldVal).toInt) + queryVal.update("ERROR", errors :+ 1) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/FilteredResolver.scala b/server/backend-shared/src/main/scala/cool/graph/FilteredResolver.scala new file mode 100644 index 0000000000..1c1cdf2d52 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/FilteredResolver.scala @@ -0,0 +1,39 @@ +package cool.graph + +import cool.graph.Types.DataItemFilterCollection +import cool.graph.client.database.{DataResolver, QueryArguments} +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.models.Model +import sangria.schema.Context + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object FilteredResolver { + def resolve[ManyDataItemType, C <: RequestContextTrait](modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType], + model: Model, + id: String, + ctx: Context[C, Unit], + dataResolver: DataResolver): Future[Option[DataItem]] = { + + val filterInput: DataItemFilterCollection = modelObjectTypes + .extractQueryArgumentsFromContext(model = model, ctx = ctx) + .flatMap(_.filter) + .getOrElse(List()) + + def removeTopLevelIdFilter(element: Any) = + element match { + case e: FilterElement => e.key != "id" + case _ => true + } + + val filter = filterInput.filter(removeTopLevelIdFilter(_)) ++ List(FilterElement(key = "id", value = id, field = Some(model.getFieldByName_!("id")))) + + dataResolver + .resolveByModel( + model, + Some(QueryArguments(filter = Some(filter), skip = None, after = None, first = None, before = None, last = None, orderBy = None)) + ) + .map(_.items.headOption) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/GCDataTypes/GCValues.scala b/server/backend-shared/src/main/scala/cool/graph/GCDataTypes/GCValues.scala new file mode 100644 index 0000000000..64841cf872 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/GCDataTypes/GCValues.scala @@ -0,0 +1,364 @@ +package cool.graph.GCDataTypes + +import cool.graph.GCDataTypes.OtherGCStuff.sequence +import cool.graph.shared.errors.UserInputErrors +import cool.graph.shared.errors.UserInputErrors.InvalidValueForScalarType +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Field, TypeIdentifier} +import org.apache.commons.lang.StringEscapeUtils +import org.joda.time.format.ISODateTimeFormat +import org.joda.time.{DateTime, DateTimeZone} +import org.parboiled2.{Parser, ParserInput} +import org.scalactic.{Bad, Good, Or} +import sangria.ast.{Field => SangriaField, Value => SangriaValue, _} +import sangria.parser.{Document => _, _} +import spray.json.DefaultJsonProtocol._ +import spray.json.JsonParser.ParsingException +import spray.json.{JsArray, JsValue, _} + +import scala.util.control.NonFatal +import scala.util.{Failure, Success} + +/** + * GCValues should be the sole way to represent data within our system. + * We will try to use them to get rid of the Any, and get better type safety. + * + * thoughts: + * - move the spot where we do the validations further back? out of the AddFieldMutation to AddField Input already? + * - Where do we need Good/Bad Error handling, where can we call get? + */ +sealed trait GCValue + +case class RootGCValue(map: Map[String, GCValue]) extends GCValue + +case class ListGCValue(values: Vector[GCValue]) extends GCValue { + def getStringVector: Vector[String] = values.asInstanceOf[Vector[StringGCValue]].map(_.value) + def getEnumVector: Vector[String] = values.asInstanceOf[Vector[EnumGCValue]].map(_.value) +} + +sealed trait LeafGCValue extends GCValue +case class NullGCValue() extends LeafGCValue +case class StringGCValue(value: String) extends LeafGCValue +case class IntGCValue(value: Int) extends LeafGCValue +case class FloatGCValue(value: Double) extends LeafGCValue +case class BooleanGCValue(value: Boolean) extends LeafGCValue +case class PasswordGCValue(value: String) extends LeafGCValue +case class GraphQLIdGCValue(value: String) extends LeafGCValue +case class DateTimeGCValue(value: DateTime) extends LeafGCValue +case class EnumGCValue(value: String) extends LeafGCValue +case class JsonGCValue(value: JsValue) extends LeafGCValue + +/** + * We need a bunch of different converters from / to GC values + * + * 1. DBValue <-> GCValue for writing into typed value fields in the Client-DB + * 2. SangriaValue <-> GCValue for transforming the Any we get from Sangria per field back and forth + * 3. DBString <-> GCValue for writing defaultValues in the System-DB since they are always a String, and JSArray for Lists + * 4. Json <-> GCValue for SchemaSerialization + * 5. SangriaValue <-> String for reading and writing default and migrationValues + * 6. InputString <-> GCValue chains String -> SangriaValue -> GCValue and back + */ +trait GCConverter[T] { + def toGCValue(t: T): Or[GCValue, InvalidValueForScalarType] + def fromGCValue(gcValue: GCValue): T +} + +/** + * 1. DBValue <-> GCValue - This is used write and read GCValues to typed Db fields in the ClientDB + */ +case class GCDBValueConverter(typeIdentifier: TypeIdentifier, isList: Boolean) extends GCConverter[Any] { + + override def toGCValue(t: Any): Or[GCValue, InvalidValueForScalarType] = { + ??? + } + + override def fromGCValue(t: GCValue): Any = { + t match { + case _: NullGCValue => None + case x: StringGCValue => x.value + case x: PasswordGCValue => x.value + case x: EnumGCValue => x.value + case x: GraphQLIdGCValue => x.value + case x: DateTimeGCValue => x.value + case x: IntGCValue => x.value + case x: FloatGCValue => x.value + case x: BooleanGCValue => x.value + case x: JsonGCValue => x.value + case x: ListGCValue => x.values.map(this.fromGCValue) + case x: RootGCValue => sys.error("RootGCValues not implemented yet in GCDBValueConverter") + } + } +} + +/** + * 2. SangriaAST <-> GCValue - This is used to transform Sangria parsed values into GCValue and back + */ +case class GCSangriaValueConverter(typeIdentifier: TypeIdentifier, isList: Boolean) extends GCConverter[SangriaValue] { + + override def toGCValue(t: SangriaValue): Or[GCValue, InvalidValueForScalarType] = { + try { + val result = (t, typeIdentifier) match { + case (_: NullValue, _) => NullGCValue() + case (x: StringValue, _) if x.value == "null" && typeIdentifier != TypeIdentifier.String => NullGCValue() + case (x: StringValue, TypeIdentifier.String) => StringGCValue(x.value) + case (x: BigIntValue, TypeIdentifier.Int) => IntGCValue(x.value.toInt) + case (x: BigIntValue, TypeIdentifier.Float) => FloatGCValue(x.value.toDouble) + case (x: BigDecimalValue, TypeIdentifier.Float) => FloatGCValue(x.value.toDouble) + case (x: FloatValue, TypeIdentifier.Float) => FloatGCValue(x.value) + case (x: BooleanValue, TypeIdentifier.Boolean) => BooleanGCValue(x.value) + case (x: StringValue, TypeIdentifier.Password) => PasswordGCValue(x.value) + case (x: StringValue, TypeIdentifier.DateTime) => DateTimeGCValue(new DateTime(x.value, DateTimeZone.UTC)) + case (x: StringValue, TypeIdentifier.GraphQLID) => GraphQLIdGCValue(x.value) + case (x: EnumValue, TypeIdentifier.Enum) => EnumGCValue(x.value) + case (x: StringValue, TypeIdentifier.Json) => JsonGCValue(x.value.parseJson) + case (x: ListValue, _) if isList => sequence(x.values.map(this.toGCValue)).map(seq => ListGCValue(seq)).get + case _ => sys.error("Error in GCSangriaASTConverter. Value: " + t.renderCompact) + } + + Good(result) + } catch { + case NonFatal(_) => Bad(UserInputErrors.InvalidValueForScalarType(t.renderCompact, typeIdentifier)) + } + } + + override def fromGCValue(gcValue: GCValue): SangriaValue = { + + val formatter = ISODateTimeFormat.dateHourMinuteSecondFraction() + + gcValue match { + case _: NullGCValue => NullValue() + case x: StringGCValue => StringValue(value = x.value) + case x: IntGCValue => BigIntValue(x.value) + case x: FloatGCValue => FloatValue(x.value) + case x: BooleanGCValue => BooleanValue(x.value) + case x: PasswordGCValue => StringValue(x.value) + case x: GraphQLIdGCValue => StringValue(x.value) + case x: DateTimeGCValue => StringValue(formatter.print(x.value)) + case x: EnumGCValue => EnumValue(x.value) + case x: JsonGCValue => StringValue(x.value.compactPrint) + case x: ListGCValue => ListValue(values = x.values.map(this.fromGCValue)) + case x: RootGCValue => sys.error("Default Value cannot be a RootGCValue. Value " + x.toString) + } + } +} + +/** + * 3. DBString <-> GCValue - This is used write the defaultValue as a String to the SystemDB and read it from there + */ +case class GCStringDBConverter(typeIdentifier: TypeIdentifier, isList: Boolean) extends GCConverter[String] { + override def toGCValue(t: String): Or[GCValue, InvalidValueForScalarType] = { + try { + val result = (typeIdentifier, isList) match { + case (_, _) if t == "null" => NullGCValue() + case (TypeIdentifier.String, false) => StringGCValue(t) + case (TypeIdentifier.Int, false) => IntGCValue(Integer.parseInt(t)) + case (TypeIdentifier.Float, false) => FloatGCValue(t.toDouble) + case (TypeIdentifier.Boolean, false) => BooleanGCValue(t.toBoolean) + case (TypeIdentifier.Password, false) => PasswordGCValue(t) + case (TypeIdentifier.DateTime, false) => DateTimeGCValue(new DateTime(t, DateTimeZone.UTC)) + case (TypeIdentifier.GraphQLID, false) => GraphQLIdGCValue(t) + case (TypeIdentifier.Enum, false) => EnumGCValue(t) + case (TypeIdentifier.Json, false) => JsonGCValue(t.parseJson) + case (_, true) => GCJsonConverter(typeIdentifier, isList).toGCValue(t.parseJson).get + } + + Good(result) + } catch { + case NonFatal(_) => Bad(UserInputErrors.InvalidValueForScalarType(t, typeIdentifier)) + } + } + + // this is temporarily used since we still have old string formats in the db + def toGCValueCanReadOldAndNewFormat(t: String): Or[GCValue, InvalidValueForScalarType] = { + toGCValue(t) match { + case Good(x) => Good(x) + case Bad(_) => GCStringConverter(typeIdentifier, isList).toGCValue(t) + } + } + + override def fromGCValue(gcValue: GCValue): String = { + + val formatter = ISODateTimeFormat.dateHourMinuteSecondFraction() + + gcValue match { + case _: NullGCValue => "null" + case x: StringGCValue => x.value + case x: IntGCValue => x.value.toString + case x: FloatGCValue => x.value.toString + case x: BooleanGCValue => x.value.toString + case x: PasswordGCValue => x.value + case x: GraphQLIdGCValue => x.value + case x: DateTimeGCValue => formatter.print(x.value) + case x: EnumGCValue => x.value + case x: JsonGCValue => x.value.compactPrint + case x: ListGCValue => GCJsonConverter(typeIdentifier, isList).fromGCValue(x).compactPrint + case x: RootGCValue => sys.error("This should not be a RootGCValue. Value " + x) + } + } +} + +/** + * 4. Json <-> GC Value - This is used to encode and decode the Schema in the SchemaSerializer. + */ +case class GCJsonConverter(typeIdentifier: TypeIdentifier, isList: Boolean) extends GCConverter[JsValue] { + + override def toGCValue(t: JsValue): Or[GCValue, InvalidValueForScalarType] = { + + (t, typeIdentifier) match { + case (JsNull, _) => Good(NullGCValue()) + case (x: JsString, TypeIdentifier.String) => Good(StringGCValue(x.convertTo[String])) + case (x: JsNumber, TypeIdentifier.Int) => Good(IntGCValue(x.convertTo[Int])) + case (x: JsNumber, TypeIdentifier.Float) => Good(FloatGCValue(x.convertTo[Double])) + case (x: JsBoolean, TypeIdentifier.Boolean) => Good(BooleanGCValue(x.convertTo[Boolean])) + case (x: JsString, TypeIdentifier.Password) => Good(PasswordGCValue(x.convertTo[String])) + case (x: JsString, TypeIdentifier.DateTime) => Good(DateTimeGCValue(new DateTime(x.convertTo[String], DateTimeZone.UTC))) + case (x: JsString, TypeIdentifier.GraphQLID) => Good(GraphQLIdGCValue(x.convertTo[String])) + case (x: JsString, TypeIdentifier.Enum) => Good(EnumGCValue(x.convertTo[String])) + case (x: JsArray, _) if isList => sequence(x.elements.map(this.toGCValue)).map(seq => ListGCValue(seq)) + case (x: JsValue, TypeIdentifier.Json) => Good(JsonGCValue(x)) + case (x, _) => Bad(UserInputErrors.InvalidValueForScalarType(x.toString, typeIdentifier)) + } + } + + override def fromGCValue(gcValue: GCValue): JsValue = { + val formatter = ISODateTimeFormat.dateHourMinuteSecondFraction() + + gcValue match { + case _: NullGCValue => JsNull + case x: StringGCValue => JsString(x.value) + case x: PasswordGCValue => JsString(x.value) + case x: EnumGCValue => JsString(x.value) + case x: GraphQLIdGCValue => JsString(x.value) + case x: DateTimeGCValue => JsString(formatter.print(x.value)) + case x: IntGCValue => JsNumber(x.value) + case x: FloatGCValue => JsNumber(x.value) + case x: BooleanGCValue => JsBoolean(x.value) + case x: JsonGCValue => x.value + case x: ListGCValue => JsArray(x.values.map(this.fromGCValue)) + case x: RootGCValue => JsObject(x.map.mapValues(this.fromGCValue)) + } + } +} + +/** + * 5. String <-> SangriaAST - This is reads and writes Default and MigrationValues we get/need as String. + */ +class MyQueryParser(val input: ParserInput) extends Parser with Tokens with Ignored with Operations with Fragments with Values with Directives with Types + +case class StringSangriaValueConverter(typeIdentifier: TypeIdentifier, isList: Boolean) { + + def from(string: String): Or[SangriaValue, InvalidValueForScalarType] = { + + val escapedIfNecessary = typeIdentifier match { + case _ if string == "null" => string + case TypeIdentifier.DateTime if !isList => escape(string) + case TypeIdentifier.String if !isList => escape(string) + case TypeIdentifier.Password if !isList => escape(string) + case TypeIdentifier.GraphQLID if !isList => escape(string) + case TypeIdentifier.Json => escape(string) + case _ => string + } + + val parser = new MyQueryParser(ParserInput(escapedIfNecessary)) + + parser.Value.run() match { + case Failure(e) => e.printStackTrace(); Bad(InvalidValueForScalarType(string, typeIdentifier)) + case Success(x) => Good(x) + } + } + + def fromAbleToHandleJsonLists(string: String): Or[SangriaValue, InvalidValueForScalarType] = { + + if (isList && typeIdentifier == TypeIdentifier.Json) { + try { + string.parseJson match { + case JsNull => Good(NullValue()) + case x: JsArray => sequence(x.elements.map(x => from(x.toString))).map(seq => ListValue(seq)) + case _ => Bad(InvalidValueForScalarType(string, typeIdentifier)) + } + } catch { + case e: ParsingException => Bad(InvalidValueForScalarType(string, typeIdentifier)) + } + } else { + from(string) + } + } + + def to(sangriaValue: SangriaValue): String = { + sangriaValue match { + case _: NullValue => sangriaValue.renderCompact + case x: StringValue if !isList => unescape(sangriaValue.renderCompact) + case x: ListValue if typeIdentifier == TypeIdentifier.Json => "[" + x.values.map(y => unescape(y.renderCompact)).mkString(",") + "]" + case _ => sangriaValue.renderCompact + } + } + + private def escape(str: String): String = "\"" + StringEscapeUtils.escapeJava(str) + "\"" + private def unescape(str: String): String = StringEscapeUtils.unescapeJava(str).stripPrefix("\"").stripSuffix("\"") +} + +/** + * 6. String <-> GC Value - This combines the StringSangriaConverter and GCSangriaValueConverter for convenience. + */ +case class GCStringConverter(typeIdentifier: TypeIdentifier, isList: Boolean) extends GCConverter[String] { + + override def toGCValue(t: String): Or[GCValue, InvalidValueForScalarType] = { + + for { + sangriaValue <- StringSangriaValueConverter(typeIdentifier, isList).fromAbleToHandleJsonLists(t) + result <- GCSangriaValueConverter(typeIdentifier, isList).toGCValue(sangriaValue) + } yield result + } + + override def fromGCValue(t: GCValue): String = { + val sangriaValue = GCSangriaValueConverter(typeIdentifier, isList).fromGCValue(t) + StringSangriaValueConverter(typeIdentifier, isList).to(sangriaValue) + } + + def fromGCValueToOptionalString(t: GCValue): Option[String] = { + t match { + case _: NullGCValue => None + case value => Some(fromGCValue(value)) + } + } +} + +/** + * This validates a GCValue against the field it is being used on, for example after an UpdateFieldMutation + */ +object OtherGCStuff { + def isValidGCValueForField(value: GCValue, field: Field): Boolean = { + (value, field.typeIdentifier) match { + case (_: NullGCValue, _) => true + case (_: StringGCValue, TypeIdentifier.String) => true + case (_: PasswordGCValue, TypeIdentifier.Password) => true + case (_: GraphQLIdGCValue, TypeIdentifier.GraphQLID) => true + case (_: EnumGCValue, TypeIdentifier.Enum) => true + case (_: JsonGCValue, TypeIdentifier.Json) => true + case (_: DateTimeGCValue, TypeIdentifier.DateTime) => true + case (_: IntGCValue, TypeIdentifier.Int) => true + case (_: FloatGCValue, TypeIdentifier.Float) => true + case (_: BooleanGCValue, TypeIdentifier.Boolean) => true + case (x: ListGCValue, _) if field.isList => x.values.map(isValidGCValueForField(_, field)).forall(identity) + case (_: RootGCValue, _) => false + case (_, _) => false + } + } + + /** + * This helps convert Or listvalues. + */ + def sequence[A, B](seq: Vector[Or[A, B]]): Or[Vector[A], B] = { + def recurse(seq: Vector[Or[A, B]])(acc: Vector[A]): Or[Vector[A], B] = { + if (seq.isEmpty) { + Good(acc) + } else { + seq.head match { + case Good(x) => recurse(seq.tail)(acc :+ x) + case Bad(error) => Bad(error) + } + } + } + recurse(seq)(Vector.empty) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/Mutaction.scala b/server/backend-shared/src/main/scala/cool/graph/Mutaction.scala new file mode 100644 index 0000000000..aaebc722a0 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/Mutaction.scala @@ -0,0 +1,43 @@ +package cool.graph + +import cool.graph.client.database.DataResolver +import slick.dbio.{DBIOAction, Effect, NoStream} +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.Future +import scala.util.{Success, Try} + +abstract class Mutaction { + def verify(): Future[Try[MutactionVerificationSuccess]] = Future.successful(Success(MutactionVerificationSuccess())) + def execute: Future[MutactionExecutionResult] + def handleErrors: Option[PartialFunction[Throwable, MutactionExecutionResult]] = None + def rollback: Option[Future[MutactionExecutionResult]] = None + def postExecute: Future[Boolean] = Future.successful(true) +} + +abstract class ClientSqlMutaction extends Mutaction { + override def execute: Future[ClientSqlStatementResult[Any]] + override def rollback: Option[Future[ClientSqlStatementResult[Any]]] = None +} + +trait ClientSqlSchemaChangeMutaction extends ClientSqlMutaction +trait ClientSqlDataChangeMutaction extends ClientSqlMutaction { + def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess]] = Future.successful(Success(MutactionVerificationSuccess())) +} + +abstract class SystemSqlMutaction extends Mutaction { + override def execute: Future[SystemSqlStatementResult[Any]] + override def rollback: Option[Future[SystemSqlStatementResult[Any]]] = None +} + +case class MutactionVerificationSuccess() + +trait MutactionExecutionResult +case class MutactionExecutionSuccess() extends MutactionExecutionResult +case class ClientSqlStatementResult[A <: Any](sqlAction: DBIOAction[A, NoStream, Effect.All]) extends MutactionExecutionResult +case class SystemSqlStatementResult[A <: Any](sqlAction: DBIOAction[A, NoStream, Effect.All]) extends MutactionExecutionResult + +case class ClientMutactionNoop() extends ClientSqlMutaction { + override def execute: Future[ClientSqlStatementResult[Any]] = Future.successful(ClientSqlStatementResult(sqlAction = DBIO.successful(None))) + override def rollback: Option[Future[ClientSqlStatementResult[Any]]] = Some(Future.successful(ClientSqlStatementResult(sqlAction = DBIO.successful(None)))) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/RequestContext.scala b/server/backend-shared/src/main/scala/cool/graph/RequestContext.scala new file mode 100644 index 0000000000..05196618d5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/RequestContext.scala @@ -0,0 +1,65 @@ +package cool.graph + +import cool.graph.client.FeatureMetric.FeatureMetric +import cool.graph.client.{MutactionMetric, MutationQueryWhitelist, SqlQueryMetric} +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.models.Client +import cool.graph.shared.logging.{LogData, LogKey} +import scaldi.{Injectable, Injector} + +import scala.collection.concurrent.TrieMap + +trait RequestContextTrait { + val requestId: String + val requestIp: String + val clientId: String + val projectId: Option[String] + val log: Function[String, Unit] + val cloudwatch: Cloudwatch + var graphcoolHeader: Option[String] = None + + // The console always includes the header `X-GraphCool-Source` with the value `dashboard:[sub section]` + def isFromConsole = graphcoolHeader.exists(header => header.contains("dashboard") || header.contains("console")) + + val isSubscription: Boolean = false + val mutationQueryWhitelist = new MutationQueryWhitelist() + + private var featureMetrics: TrieMap[String, Unit] = TrieMap() + + def addFeatureMetric(featureMetric: FeatureMetric): Unit = featureMetrics += (featureMetric.toString -> Unit) + def listFeatureMetrics: List[String] = featureMetrics.keys.toList + + def logMutactionTiming(timing: Timing): Unit = { + cloudwatch.measure(MutactionMetric(dimensionValue = timing.name, value = timing.duration)) + logTimingWithoutCloudwatch(timing, _.RequestMetricsMutactions) + } + + def logSqlTiming(timing: Timing): Unit = { + cloudwatch.measure(SqlQueryMetric(dimensionValue = timing.name, value = timing.duration)) + logTimingWithoutCloudwatch(timing, _.RequestMetricsSql) + } + + def logTimingWithoutCloudwatch(timing: Timing, logKeyFn: LogKey.type => LogKey.Value): Unit = { + // Temporarily disable request logging +// log( +// LogData( +// key = logKeyFn(LogKey), +// requestId = requestId, +// clientId = Some(clientId), +// projectId = projectId, +// payload = Some(Map("name" -> timing.name, "duration" -> timing.duration)) +// ).json) + } +} + +trait SystemRequestContextTrait extends RequestContextTrait { + override val clientId: String = client.map(_.id).getOrElse("") + val client: Option[Client] +} + +case class RequestContext(clientId: String, requestId: String, requestIp: String, log: Function[String, Unit], projectId: Option[String] = None)( + implicit inj: Injector) + extends RequestContextTrait + with Injectable { + val cloudwatch: Cloudwatch = inject[Cloudwatch]("cloudwatch") +} diff --git a/server/backend-shared/src/main/scala/cool/graph/TransactionMutaction.scala b/server/backend-shared/src/main/scala/cool/graph/TransactionMutaction.scala new file mode 100644 index 0000000000..6035f22c3b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/TransactionMutaction.scala @@ -0,0 +1,42 @@ +package cool.graph + +import cool.graph.client.database.DataResolver +import slick.dbio.DBIO + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class Transaction(clientSqlMutactions: List[ClientSqlMutaction], dataResolver: DataResolver) extends Mutaction { + + override def execute: Future[MutactionExecutionResult] = { + Future + .sequence(clientSqlMutactions.map(_.execute)) + .map(_.collect { + case ClientSqlStatementResult(sqlAction) => sqlAction + }) + .flatMap( + sqlActions => + dataResolver + .runOnClientDatabase("Transaction", DBIO.seq(sqlActions: _*)) //.transactionally # Due to https://github.com/slick/slick/pull/1461 not being in a stable release yet + ) + .map(_ => MutactionExecutionSuccess()) + } + + override def handleErrors: Option[PartialFunction[Throwable, MutactionExecutionResult]] = { + clientSqlMutactions.flatMap(_.handleErrors) match { + case errorHandlers if errorHandlers.isEmpty => None + case errorHandlers => Some(errorHandlers reduceLeft (_ orElse _)) + } + } + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + val results: Seq[Future[Try[MutactionVerificationSuccess]]] = clientSqlMutactions.map { + case action: ClientSqlDataChangeMutaction => action.verify(dataResolver) + case action => action.verify() + } + val sequenced: Future[Seq[Try[MutactionVerificationSuccess]]] = Future.sequence(results) + + sequenced.map(results => results.find(_.isFailure).getOrElse(Success(MutactionVerificationSuccess()))) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/Types.scala b/server/backend-shared/src/main/scala/cool/graph/Types.scala new file mode 100644 index 0000000000..7fe32ed8f1 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/Types.scala @@ -0,0 +1,48 @@ +package cool.graph + +import cool.graph +import cool.graph.Types.{DataItemFilterCollection, UserData} +import cool.graph.shared.models.{Field, Model, Relation} +import sangria.relay.Node + +object Types { + type DataItemFilterCollection = Seq[_ >: Seq[Any] <: Any] + type Id = String + type UserData = Map[String, Option[Any]] +} + +case class FilterElement(key: String, + value: Any, + field: Option[Field] = None, + filterName: String = "", + relatedFilterElement: Option[FilterElementRelation] = None) + +case class FilterElementRelation(fromModel: Model, toModel: Model, relation: Relation, filter: DataItemFilterCollection) + +case class DataItem(id: Types.Id, userData: UserData = Map.empty, typeName: Option[String] = None) extends Node { + def apply(key: String): Option[Any] = userData(key) + def get[T](key: String): T = userData(key).get.asInstanceOf[T] + def getOption[T](key: String): Option[T] = userData.get(key).flatten.map(_.asInstanceOf[T]) +} + +object SortOrder extends Enumeration { + type SortOrder = Value + val Asc: graph.SortOrder.Value = Value("asc") + val Desc: graph.SortOrder.Value = Value("desc") +} + +case class OrderBy( + field: Field, + sortOrder: SortOrder.Value +) + +object DataItem { + def fromMap(map: UserData): DataItem = { + val id: String = map.getOrElse("id", None) match { + case Some(value) => value.asInstanceOf[String] + case None => "" + } + + DataItem(id = id, userData = map) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/Utils.scala b/server/backend-shared/src/main/scala/cool/graph/Utils.scala new file mode 100644 index 0000000000..53793c7efa --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/Utils.scala @@ -0,0 +1,85 @@ +package cool.graph + +import com.google.common.base.CaseFormat +import spray.json.{DefaultJsonProtocol, _} + +object Utils { + + def camelToUpperUnderscore(str: String): String = + CaseFormat.UPPER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, str) +} + +case class Timing(name: String, duration: Long) +object TimingProtocol extends DefaultJsonProtocol { + implicit val timingFormat: RootJsonFormat[Timing] = jsonFormat2(Timing) +} + +object JsonFormats { + + implicit object CaseClassFormat extends JsonFormat[Product] { + def write(x: Product): JsValue = { + val values = x.productIterator.toList + val fields = x.getClass.getDeclaredFields + + def getIdValue(p: Product): Option[Any] = { + val values = p.productIterator.toList + val fields = p.getClass.getDeclaredFields + + fields.zipWithIndex.find(_._1.getName == "id").map(z => values(z._2)) + } + + val map: Map[String, Any] = values.zipWithIndex.map { + case (v, i) => + val key = fields(i).getName + val value = v match { + case v: Product if !v.isInstanceOf[Option[_]] => + getIdValue(v).getOrElse("...") + case Some(v: Product) => + getIdValue(v).getOrElse("...") + case v => v + } + + key -> value + }.toMap + + AnyJsonFormat.write(map) + } + + def read(value: JsValue) = throw new UnsupportedOperationException() + } + + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any): JsValue = x match { + case m: Map[_, _] => + JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case n: Double => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(value: JsValue) = throw new UnsupportedOperationException() + } + + class AnyJsonWriter extends JsonWriter[Map[String, Any]] { + override def write(obj: Map[String, Any]): JsValue = + AnyJsonFormat.write(obj) + } + + class SeqAnyJsonWriter[T <: Any] extends JsonWriter[Seq[Map[String, T]]] { + override def write(objs: Seq[Map[String, T]]): JsValue = + new JsArray( + objs + .map(obj => { + AnyJsonFormat.write(obj) + }) + .toVector) + } + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/Metrics.scala b/server/backend-shared/src/main/scala/cool/graph/client/Metrics.scala new file mode 100644 index 0000000000..164647b88c --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/Metrics.scala @@ -0,0 +1,127 @@ +package cool.graph.client + +import java.util.concurrent.TimeUnit + +import akka.actor.Actor +import com.amazonaws.services.cloudwatch.model._ +import cool.graph.cloudwatch.CloudwatchMetric +import cool.graph.cuid.Cuid +import cool.graph.shared.errors.UserFacingError +import cool.graph.shared.externalServices.KinesisPublisher +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat +import scaldi.Injector +import spray.json.{JsArray, JsBoolean, JsNumber, JsObject, JsString} + +import scala.collection.mutable +import scala.concurrent.duration.FiniteDuration +import scala.util.control.NonFatal + +object FeatureMetric extends Enumeration { + type FeatureMetric = Value + val Subscriptions = Value("backend/api/subscriptions") + val Filter = Value("backend/feature/filter") + val NestedMutations = Value("backend/feature/nested-mutation") + val ApiSimple = Value("backend/api/simple") + val ApiRelay = Value("backend/api/relay") + val ApiFiles = Value("backend/api/files") + val ServersideSubscriptions = Value("backend/feature/sss") + val RequestPipeline = Value("backend/feature/rp") // add this! + val PermissionQuery = Value("backend/feature/permission-queries") // add this! + val Authentication = Value("backend/feature/authentication") + val Algolia = Value("backend/feature/algolia") // add this! + val Auth0 = Value("backend/feature/integration-auth0") + val Digits = Value("backend/feature/integration-digits") +} + +case class ApiFeatureMetric(ip: String, + date: DateTime, + projectId: String, + clientId: String, + usedFeatures: List[String], + // Should be false when we can't determine. This is the case for subscriptions. + // Is always false for File api. + isFromConsole: Boolean) + +class FeatureMetricActor( + metricsPublisher: KinesisPublisher, + interval: Int +) extends Actor { + import context.dispatcher + + val metrics = mutable.Buffer.empty[ApiFeatureMetric] + val FLUSH = "FLUSH" + val tick = context.system.scheduler.schedule( + initialDelay = FiniteDuration(interval, TimeUnit.SECONDS), + interval = FiniteDuration(interval, TimeUnit.SECONDS), + receiver = self, + message = FLUSH + ) + + override def postStop() = tick.cancel() + + def receive = { + case metric: ApiFeatureMetric => + metrics += metric + + case FLUSH => + flushMetrics() + } + + def flushMetrics() = { + val byProject = metrics.groupBy(_.projectId) map { + case (projectId, metrics) => + JsObject( + "requestCount" -> JsNumber(metrics.length), + "projectId" -> JsString(projectId), + "usedIps" -> JsArray(metrics.map(_.ip).distinct.take(10).toVector.map(JsString(_))), + "features" -> JsArray(metrics.flatMap(_.usedFeatures).distinct.toVector.map(JsString(_))), + "date" -> JsString(metrics.head.date.toString(DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss'Z").withZoneUTC())), + "version" -> JsString("1"), + "justConsoleRequests" -> JsBoolean(metrics.forall(_.isFromConsole)) + ) + } + + byProject.foreach { json => + try { + metricsPublisher.putRecord(json.toString, shardId = Cuid.createCuid()) + } catch { + case NonFatal(e) => println(s"Putting kinesis FeatureMetric failed: ${e.getMessage} ${e.toString}") + } + } + metrics.clear() + } +} +case class SqlQueryMetric(value: Double, dimensionValue: String) extends CloudwatchMetric() { + override val name: String = "Duration" + override val namespacePostfix = "SqlQueries" + override val unit: StandardUnit = StandardUnit.Milliseconds + override val dimensionName = "By Query Name" +} + +case class MutactionMetric(value: Double, dimensionValue: String) extends CloudwatchMetric() { + override val name: String = "Duration" + override val namespacePostfix = "Mutactions" + override val unit: StandardUnit = StandardUnit.Milliseconds + override val dimensionName = "By Mutaction Name" +} + +case class HandledError(error: UserFacingError) extends CloudwatchMetric() { + override val name: String = "Count" + override val namespacePostfix = "HandledError" + override val unit: StandardUnit = StandardUnit.Count + override val dimensionName = "By Error" + override val dimensionValue = + s"${error.code} - ${error.getClass.getSimpleName}" + override val value = 1.0 +} + +case class UnhandledError(error: Throwable) extends CloudwatchMetric() { + override val name: String = "Count" + override val namespacePostfix = "UnhandledError" + override val unit: StandardUnit = StandardUnit.Count + override val dimensionName = "By Error" + override val dimensionValue = + s"${error.getClass.getSimpleName}" + override val value = 1.0 +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/MutationQueryWhitelist.scala b/server/backend-shared/src/main/scala/cool/graph/client/MutationQueryWhitelist.scala new file mode 100644 index 0000000000..6b06b991cf --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/MutationQueryWhitelist.scala @@ -0,0 +1,31 @@ +package cool.graph.client + +import cool.graph.RequestContextTrait +import sangria.schema.Context + +class MutationQueryWhitelist { + private var fields: Set[String] = Set() + private var paths: List[List[String]] = List(List()) + private var _isMutationQuery = false + + def registerWhitelist[C <: RequestContextTrait](mutationName: String, pathsToNode: List[List[String]], inputWrapper: Option[String], ctx: Context[C, _]) = { + _isMutationQuery = true + + fields = inputWrapper match { + case Some(wrapper) => ctx.args.raw(wrapper).asInstanceOf[Map[String, Any]].keys.toSet + case None => ctx.args.raw.keys.toSet + } + + this.paths = pathsToNode.map(mutationName +: _) + } + + def isMutationQuery = _isMutationQuery + + def isWhitelisted(path: Vector[Any]) = { + path.reverse.toList match { + case (field: String) :: pathToNode if paths.contains(pathToNode.reverse) => + fields.contains(field) || field == "id" + case _ => false + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/SangriaQueryArguments.scala b/server/backend-shared/src/main/scala/cool/graph/client/SangriaQueryArguments.scala new file mode 100644 index 0000000000..20c8d75e52 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/SangriaQueryArguments.scala @@ -0,0 +1,52 @@ +package cool.graph.client + +import cool.graph.Types.DataItemFilterCollection +import cool.graph.client.database.QueryArguments +import cool.graph.shared.models +import cool.graph.shared.models.Model +import cool.graph.util.coolSangria.FromInputImplicit +import cool.graph.{OrderBy, SortOrder} +import sangria.schema.{EnumType, EnumValue, _} + +object SangriaQueryArguments { + + import FromInputImplicit.DefaultScalaResultMarshaller + + def orderByArgument(model: Model, name: String = "orderBy") = { + val values = for { + field <- model.scalarFields.filter(!_.isList) + sortOrder <- List("ASC", "DESC") + } yield EnumValue(field.name + "_" + sortOrder, description = None, OrderBy(field, SortOrder.withName(sortOrder.toLowerCase()))) + + Argument(name, OptionInputType(EnumType(s"${model.name}OrderBy", None, values))) + } + + def filterArgument(model: models.Model, project: models.Project, name: String = "filter"): Argument[Option[Any]] = { + val utils = new FilterObjectTypeBuilder(model, project) + val filterObject: InputObjectType[Any] = utils.filterObjectType + Argument(name, OptionInputType(filterObject), description = "") + } + + def filterSubscriptionArgument(model: models.Model, project: models.Project, name: String = "filter") = { + val utils = new FilterObjectTypeBuilder(model, project) + val filterObject: InputObjectType[Any] = utils.subscriptionFilterObjectType + Argument(name, OptionInputType(filterObject), description = "") + } + + def internalFilterSubscriptionArgument(model: models.Model, project: models.Project, name: String = "filter") = { + val utils = new FilterObjectTypeBuilder(model, project) + val filterObject: InputObjectType[Any] = utils.internalSubscriptionFilterObjectType + Argument(name, OptionInputType(filterObject), description = "") + } + + // use given arguments if they exist or use sensible default values + def createSimpleQueryArguments(skipOpt: Option[Int], + after: Option[String], + first: Option[Int], + before: Option[String], + last: Option[Int], + filterOpt: Option[DataItemFilterCollection], + orderByOpt: Option[OrderBy]) = { + QueryArguments(skipOpt, after, first, before, last, filterOpt, orderByOpt) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/SchemaBuilderUtils.scala b/server/backend-shared/src/main/scala/cool/graph/client/SchemaBuilderUtils.scala new file mode 100644 index 0000000000..cde36855e5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/SchemaBuilderUtils.scala @@ -0,0 +1,167 @@ +package cool.graph.client + +import cool.graph.client.database.{FilterArgument, FilterArguments} +import cool.graph.client.schema.ModelMutationType +import cool.graph.shared.models +import cool.graph.shared.models.{Model, Project, TypeIdentifier} +import cool.graph.shared.schema.CustomScalarTypes.{DateTimeType, JsonType, PasswordType} +import sangria.schema._ + +object SchemaBuilderUtils { + def mapToOptionalInputType(field: models.Field): InputType[Any] = { + OptionInputType(mapToRequiredInputType(field)) + } + + def mapToRequiredInputType(field: models.Field): InputType[Any] = { + assert(field.isScalar) + + val inputType: InputType[Any] = field.typeIdentifier match { + case TypeIdentifier.String => StringType + case TypeIdentifier.Int => IntType + case TypeIdentifier.Float => FloatType + case TypeIdentifier.Boolean => BooleanType + case TypeIdentifier.GraphQLID => IDType + case TypeIdentifier.Password => PasswordType + case TypeIdentifier.DateTime => DateTimeType + case TypeIdentifier.Json => JsonType + case TypeIdentifier.Enum => mapEnumFieldToInputType(field) + } + + if (field.isList) { + ListInputType(inputType) + } else { + inputType + } + } + + def mapEnumFieldToInputType(field: models.Field): EnumType[Any] = { + require(field.typeIdentifier == TypeIdentifier.Enum, "This function must be called with Enum fields only!") + val enum = field.enum.getOrElse(sys.error("A field with TypeIdentifier Enum must always have an enum.")) + EnumType( + enum.name, + field.description, + enum.values.map(enumValue => EnumValue(enumValue, value = enumValue, description = None)).toList + ) + } + + def mapToInputField(field: models.Field): List[InputField[_ >: Option[Seq[Any]] <: Option[Any]]] = { + FilterArguments + .getFieldFilters(field) + .map({ + case FilterArgument(filterName, desc, true) => + InputField(field.name + filterName, OptionInputType(ListInputType(mapToRequiredInputType(field))), description = desc) + + case FilterArgument(filterName, desc, false) => + InputField(field.name + filterName, OptionInputType(mapToRequiredInputType(field)), description = desc) + }) + } +} + +class FilterObjectTypeBuilder(model: Model, project: Project) { + def mapToRelationFilterInputField(field: models.Field): List[InputField[_ >: Option[Seq[Any]] <: Option[Any]]] = { + assert(!field.isScalar) + val relatedModelInputType = new FilterObjectTypeBuilder(field.relatedModel(project).get, project).filterObjectType + + field.isList match { + case false => + List(InputField(field.name, OptionInputType(relatedModelInputType))) + case true => + FilterArguments + .getFieldFilters(field) + .map { filter => + InputField(field.name + filter.name, OptionInputType(relatedModelInputType)) + } + } + } + + lazy val filterObjectType: InputObjectType[Any] = + InputObjectType[Any]( + s"${model.name}Filter", + fieldsFn = () => { + List( + InputField("AND", OptionInputType(ListInputType(filterObjectType)), description = FilterArguments.ANDFilter.description), + InputField("OR", OptionInputType(ListInputType(filterObjectType)), description = FilterArguments.ORFilter.description) + ) ++ model.fields + .filter(_.isScalar) + .flatMap(SchemaBuilderUtils.mapToInputField) ++ model.fields + .filter(!_.isScalar) + .flatMap(mapToRelationFilterInputField) + } + ) + + // this is just a dummy schema as it is only used by graphiql to validate the subscription input + lazy val subscriptionFilterObjectType: InputObjectType[Any] = + InputObjectType[Any]( + s"${model.name}SubscriptionFilter", + () => { + List( + InputField("AND", OptionInputType(ListInputType(subscriptionFilterObjectType)), description = FilterArguments.ANDFilter.description), + InputField("OR", OptionInputType(ListInputType(subscriptionFilterObjectType)), description = FilterArguments.ORFilter.description), + InputField( + "mutation_in", + OptionInputType(ListInputType(ModelMutationType.Type)), + description = "The subscription event gets dispatched when it's listed in mutation_in" + ), + InputField( + "updatedFields_contains", + OptionInputType(StringType), + description = "The subscription event gets only dispatched when one of the updated fields names is included in this list" + ), + InputField( + "updatedFields_contains_every", + OptionInputType(ListInputType(StringType)), + description = "The subscription event gets only dispatched when all of the field names included in this list have been updated" + ), + InputField( + "updatedFields_contains_some", + OptionInputType(ListInputType(StringType)), + description = "The subscription event gets only dispatched when some of the field names included in this list have been updated" + ), + InputField( + "node", + OptionInputType( + InputObjectType[Any]( + s"${model.name}SubscriptionFilterNode", + () => { + model.fields + .filter(_.isScalar) + .flatMap(SchemaBuilderUtils.mapToInputField) ++ model.fields + .filter(!_.isScalar) + .flatMap(mapToRelationFilterInputField) + } + ) + ) + ) + ) + } + ) + + lazy val internalSubscriptionFilterObjectType: InputObjectType[Any] = + InputObjectType[Any]( + s"${model.name}SubscriptionFilter", + () => { + List( + InputField("AND", OptionInputType(ListInputType(internalSubscriptionFilterObjectType)), description = FilterArguments.ANDFilter.description), + InputField("OR", OptionInputType(ListInputType(internalSubscriptionFilterObjectType)), description = FilterArguments.ORFilter.description), + InputField("boolean", + OptionInputType(BooleanType), + description = "Placeholder boolean type that will be replaced with the according boolean in the schema"), + InputField( + "node", + OptionInputType( + InputObjectType[Any]( + s"${model.name}SubscriptionFilterNode", + () => { + model.fields + .filter(_.isScalar) + .flatMap(SchemaBuilderUtils.mapToInputField) ++ model.fields + .filter(!_.isScalar) + .flatMap(mapToRelationFilterInputField) + } + ) + ) + ) + ) + } + ) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/UserContext.scala b/server/backend-shared/src/main/scala/cool/graph/client/UserContext.scala new file mode 100644 index 0000000000..3fd6f6e31f --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/UserContext.scala @@ -0,0 +1,95 @@ +package cool.graph.client + +import cool.graph.client.database.ProjectDataresolver +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.models.{AuthenticatedRequest, AuthenticatedUser, Project, ProjectWithClientId} +import cool.graph.RequestContextTrait +import sangria.ast.Document +import scaldi.{Injectable, Injector} + +case class UserContext(project: Project, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + requestIp: String, + clientId: String, + log: Function[String, Unit], + override val queryAst: Option[Document] = None, + alwaysQueryMasterDatabase: Boolean = false)(implicit inj: Injector) + extends RequestContextTrait + with UserContextTrait + with Injectable { + override val projectId: Option[String] = Some(project.id) + + val userId = authenticatedRequest.map(_.id) + + val cloudwatch = inject[Cloudwatch]("cloudwatch") + + val queryDataResolver = + new ProjectDataresolver(project = project, requestContext = this) + + val mutationDataresolver = { + val resolver = new ProjectDataresolver(project = project, requestContext = this) + resolver.enableMasterDatabaseOnlyMode + resolver + } + + def dataResolver = + if (alwaysQueryMasterDatabase) { + mutationDataresolver + } else { + queryDataResolver + } +} + +object UserContext { + + def load( + project: Project, + requestId: String, + requestIp: String, + clientId: String, + log: Function[String, Unit], + queryAst: Option[Document] = None + )(implicit inj: Injector): UserContext = { + + UserContext(project, None, requestId, requestIp, clientId, log, queryAst = queryAst) + } + + def fetchUserProjectWithClientId( + project: ProjectWithClientId, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + requestIp: String, + log: Function[String, Unit], + queryAst: Option[Document] + )(implicit inj: Injector): UserContext = { + fetchUser(project.project, authenticatedRequest, requestId, requestIp, project.clientId, log, queryAst) + } + + def fetchUser( + project: Project, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + requestIp: String, + clientId: String, + log: Function[String, Unit], + queryAst: Option[Document] = None + )(implicit inj: Injector): UserContext = { + val userContext = UserContext(project, authenticatedRequest, requestId, requestIp, clientId, log, queryAst = queryAst) + + if (authenticatedRequest.isDefined && authenticatedRequest.get.isInstanceOf[AuthenticatedUser]) { + userContext.addFeatureMetric(FeatureMetric.Authentication) + } + + userContext + } +} + +trait UserContextTrait { + val project: Project + val authenticatedRequest: Option[AuthenticatedRequest] + val requestId: String + val clientId: String + val log: Function[String, Unit] + val queryAst: Option[Document] = None +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/DataResolver.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/DataResolver.scala new file mode 100644 index 0000000000..64cc93e2c2 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/DataResolver.scala @@ -0,0 +1,198 @@ +package cool.graph.client.database + +import cool.graph.Types.Id +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models._ +import cool.graph.{DataItem, RequestContextTrait, Timing} +import scaldi._ +import slick.dbio.{DBIOAction, Effect, NoStream} +import spray.json._ + +import scala.collection.immutable.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +abstract class DataResolver(val project: Project, val requestContext: Option[RequestContextTrait])(implicit inj: Injector) extends Injectable with Cloneable { + + def this(project: Project, requestContext: RequestContextTrait)(implicit inj: Injector) = + this(project: Project, Some(requestContext)) + + def copy(project: Project = project, requestContext: Option[RequestContextTrait] = requestContext): DataResolver = + this match { + case _: ProjectDataresolver => new ProjectDataresolver(project, requestContext) + } + + // todo: find a better pattern for this + private var useMasterDatabaseOnly = false + def enableMasterDatabaseOnlyMode = useMasterDatabaseOnly = true + + val globalDatabaseManager = inject[GlobalDatabaseManager] + def masterClientDatabase = globalDatabaseManager.getDbForProject(project).master + def readonlyClientDatabase = + if (useMasterDatabaseOnly) globalDatabaseManager.getDbForProject(project).master + else globalDatabaseManager.getDbForProject(project).readOnly + + protected def performWithTiming[A](name: String, f: Future[A]): Future[A] = { + val begin = System.currentTimeMillis() + f andThen { + case x => + requestContext.foreach(_.logSqlTiming(Timing(name, System.currentTimeMillis() - begin))) + x + } + } + def resolveByModel(model: Model, args: Option[QueryArguments] = None): Future[ResolverResult] + + def countByModel(model: Model, args: Option[QueryArguments] = None): Future[Int] + + def existsByModel(model: Model): Future[Boolean] + + def existsByModelAndId(model: Model, id: String): Future[Boolean] + + def resolveByUnique(model: Model, key: String, value: Any): Future[Option[DataItem]] + def resolveByUniqueWithoutValidation(model: Model, key: String, value: Any): Future[Option[DataItem]] + + def batchResolveByUnique(model: Model, key: String, values: List[Any]): Future[List[DataItem]] + + /** + * Resolves a DataItem by its global id. As this method has no knowledge about which model table to query it has to do an additional + * lookup from the id to the actual model table. This is stored in the _relayId table. Therefore this needs one more lookup. + * So if possible rather use resolveByModelAndId which does not have this cost.. + */ + def resolveByGlobalId(id: String): Future[Option[DataItem]] + + def resolveByModelAndId(model: Model, id: Id): Future[Option[DataItem]] = resolveByUnique(model, "id", id) + def resolveByModelAndIdWithoutValidation(model: Model, id: Id): Future[Option[DataItem]] = resolveByUniqueWithoutValidation(model, "id", id) + + def resolveRelation(relationId: String, aId: String, bId: String): Future[ResolverResult] + + def resolveByRelation(fromField: Field, fromModelId: String, args: Option[QueryArguments]): Future[ResolverResult] + + def resolveByRelationManyModels(fromField: Field, fromModelIds: List[String], args: Option[QueryArguments]): Future[Seq[ResolverResult]] + + def countByRelationManyModels(fromField: Field, fromModelIds: List[String], args: Option[QueryArguments]): Future[List[(String, Int)]] + + def itemCountForModel(model: Model): Future[Int] + + def existsNullByModelAndScalarField(model: Model, field: Field): Future[Boolean] + + def existsNullByModelAndRelationField(model: Model, field: Field): Future[Boolean] + + def itemCountsForAllModels(project: Project): Future[ModelCounts] = { + val x: Seq[Future[(Model, Int)]] = project.models.map { model => + itemCountForModel(model).map { count => + model -> count + } + } + Future.sequence(x).map(counts => ModelCounts(counts.toMap)) + } + + def itemCountForRelation(relation: Relation): Future[Int] + + def runOnClientDatabase[A](name: String, sqlAction: DBIOAction[A, NoStream, Effect.All]): Future[A] = + performWithTiming(name, masterClientDatabase.run(sqlAction)) + + protected def mapDataItem(model: Model)(dataItem: DataItem): DataItem = { + mapDataItemHelper(model, dataItem) + } + protected def mapDataItemWithoutValidation(model: Model)(dataItem: DataItem): DataItem = { + mapDataItemHelper(model, dataItem, validate = false) + } + + private def mapDataItemHelper(model: Model, dataItem: DataItem, validate: Boolean = true): DataItem = { + + def isType(fieldName: String, typeIdentifier: TypeIdentifier) = model.fields.exists(f => f.name == fieldName && f.typeIdentifier == typeIdentifier) + def isList(fieldName: String) = model.fields.exists(f => f.name == fieldName && f.isList) + + val res = dataItem.copy(userData = dataItem.userData.map { + case (f, Some(value: java.math.BigDecimal)) if isType(f, TypeIdentifier.Float) && !isList(f) => + (f, Some(value.doubleValue())) + + case (f, Some(value: String)) if isType(f, TypeIdentifier.Json) && !isList(f) => + DataResolverValidations(f, Some(value), model, validate).validateSingleJson(value) + + case (f, v) if isType(f, TypeIdentifier.Boolean) && !isList(f) => + DataResolverValidations(f, v, model, validate).validateSingleBoolean + + case (f, v) if isType(f, TypeIdentifier.Enum) && !isList(f) => + DataResolverValidations(f, v, model, validate).validateSingleEnum + + case (f, v) if isType(f, TypeIdentifier.Enum) => + DataResolverValidations(f, v, model, validate).validateListEnum + + case (f, v) => + (f, v) + }) + + res + } +} + +case class ModelCounts(countsMap: Map[Model, Int]) { + def countForName(name: String): Int = { + val model = countsMap.keySet.find(_.name == name).getOrElse(sys.error(s"No count found for model $name")) + countsMap(model) + } +} + +case class ResolverResult(items: Seq[DataItem], hasNextPage: Boolean = false, hasPreviousPage: Boolean = false, parentModelId: Option[String] = None) + +case class DataResolverValidations(f: String, v: Option[Any], model: Model, validate: Boolean) { + + private val field: Field = model.getFieldByName_!(f) + + private def enumOnFieldContainsValue(field: Field, value: Any): Boolean = { + val enum = field.enum.getOrElse(sys.error("Field should have an Enum")) + enum.values.contains(value) + } + + def validateSingleJson(value: String) = { + def parseJson = Try(value.parseJson) match { + case Success(json) ⇒ Some(json) + case Failure(_) ⇒ if (validate) throw UserAPIErrors.ValueNotAValidJson(f, value) else None + } + (f, parseJson) + } + + def validateSingleBoolean = { + (f, v.map { + case v: Boolean => v + case v: Integer => v == 1 + case v: String => v.toBoolean + }) + } + + def validateSingleEnum = { + val validatedEnum = v match { + case Some(value) if enumOnFieldContainsValue(field, value) => Some(value) + case Some(_) => if (validate) throw UserAPIErrors.StoredValueForFieldNotValid(field.name, model.name) else None + case _ => None + } + (f, validatedEnum) + } + + def validateListEnum = { + def enumListValueValid(input: Any): Boolean = { + val inputWithoutWhitespace = input.asInstanceOf[String].replaceAll(" ", "") + + inputWithoutWhitespace match { + case "[]" => + true + + case _ => + val values = inputWithoutWhitespace.stripPrefix("[").stripSuffix("]").split(",") + val invalidValues = values.collect { case value if !enumOnFieldContainsValue(field, value.stripPrefix("\"").stripSuffix("\"")) => value } + invalidValues.isEmpty + } + } + + val validatedEnumList = v match { + case Some(x) if enumListValueValid(x) => Some(x) + case Some(_) => if (validate) throw UserAPIErrors.StoredValueForFieldNotValid(field.name, model.name) else None + case _ => None + } + (f, validatedEnumList) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseMutationBuilder.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseMutationBuilder.scala new file mode 100644 index 0000000000..6167808ec4 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseMutationBuilder.scala @@ -0,0 +1,306 @@ +package cool.graph.client.database + +import cool.graph.shared.models.RelationSide.RelationSide +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Model, TypeIdentifier} +import slick.dbio.DBIOAction +import slick.jdbc.MySQLProfile.api._ +import slick.sql.SqlStreamingAction + +object DatabaseMutationBuilder { + + import SlickExtensions._ + + val implicitlyCreatedColumns = List("id", "createdAt", "updatedAt") + + def createDataItem(projectId: String, + modelName: String, + values: Map[String, Any]): SqlStreamingAction[Vector[Int], Int, Effect]#ResultAction[Int, NoStream, Effect] = { + + val escapedKeyValueTuples = values.toList.map(x => (escapeKey(x._1), escapeUnsafeParam(x._2))) + val escapedKeys = combineByComma(escapedKeyValueTuples.map(_._1)) + val escapedValues = combineByComma(escapedKeyValueTuples.map(_._2)) + + // Concat query as sql, but then convert it to Update, since is an insert query. + (sql"insert into `#$projectId`.`#$modelName` (" concat escapedKeys concat sql") values (" concat escapedValues concat sql")").asUpdate + } + + case class MirrorFieldDbValues(relationColumnName: String, modelColumnName: String, modelTableName: String, modelId: String) + + def createRelationRow(projectId: String, + relationTableName: String, + id: String, + a: String, + b: String, + fieldMirrors: List[MirrorFieldDbValues]): SqlStreamingAction[Vector[Int], Int, Effect]#ResultAction[Int, NoStream, Effect] = { + + val fieldMirrorColumns = fieldMirrors.map(_.relationColumnName).map(escapeKey) + + val fieldMirrorValues = + fieldMirrors.map(mirror => sql"(SELECT `#${mirror.modelColumnName}` FROM `#$projectId`.`#${mirror.modelTableName}` WHERE id = ${mirror.modelId})") + + // Concat query as sql, but then convert it to Update, since is an insert query. + (sql"insert into `#$projectId`.`#$relationTableName` (" concat combineByComma(List(sql"`id`, `A`, `B`") ++ fieldMirrorColumns) concat sql") values (" concat combineByComma( + List(sql"$id, $a, $b") ++ fieldMirrorValues) concat sql") on duplicate key update id=id").asUpdate + } + + def updateDataItem(projectId: String, modelName: String, id: String, values: Map[String, Any]) = { + val escapedValues = combineByComma(values.map { + case (k, v) => + escapeKey(k) concat sql" = " concat escapeUnsafeParam(v) + }) + + (sql"update `#$projectId`.`#$modelName` set" concat escapedValues concat sql"where id = $id").asUpdate + } + + def updateRelationRow(projectId: String, relationTable: String, relationSide: String, nodeId: String, values: Map[String, Any]) = { + val escapedValues = combineByComma(values.map { + case (k, v) => + escapeKey(k) concat sql" = " concat escapeUnsafeParam(v) + }) + + (sql"update `#$projectId`.`#$relationTable` set" concat escapedValues concat sql"where `#$relationSide` = $nodeId").asUpdate + } + + def populateNullRowsForColumn(projectId: String, modelName: String, fieldName: String, value: Any) = { + val escapedValues = + escapeKey(fieldName) concat sql" = " concat escapeUnsafeParam(value) + + (sql"update `#$projectId`.`#$modelName` set" concat escapedValues concat sql"where `#$projectId`.`#$modelName`.`#$fieldName` IS NULL").asUpdate + } + + def overwriteInvalidEnumForColumnWithMigrationValue(projectId: String, modelName: String, fieldName: String, oldValue: String, migrationValue: String) = { + val escapedValues = + escapeKey(fieldName) concat sql" = " concat escapeUnsafeParam(migrationValue) + val escapedWhereClause = + escapeKey(fieldName) concat sql" = " concat escapeUnsafeParam(oldValue) + + (sql"update `#$projectId`.`#$modelName` set" concat escapedValues concat sql"where" concat escapedWhereClause).asUpdate + } + + def overwriteAllRowsForColumn(projectId: String, modelName: String, fieldName: String, value: Any) = { + val escapedValues = + escapeKey(fieldName) concat sql" = " concat escapeUnsafeParam(value) + + (sql"update `#$projectId`.`#$modelName` set" concat escapedValues).asUpdate + } + + def deleteDataItemById(projectId: String, modelName: String, id: String) = sqlu"delete from `#$projectId`.`#$modelName` where id = $id" + + def deleteRelationRowById(projectId: String, relationId: String, id: String) = sqlu"delete from `#$projectId`.`#$relationId` where A = $id or B = $id" + + def deleteRelationRowBySideAndId(projectId: String, relationId: String, relationSide: RelationSide, id: String) = { + sqlu"delete from `#$projectId`.`#$relationId` where `#${relationSide.toString}` = $id" + } + + def deleteRelationRowByToAndFromSideAndId(projectId: String, + relationId: String, + aRelationSide: RelationSide, + aId: String, + bRelationSide: RelationSide, + bId: String) = { + sqlu"delete from `#$projectId`.`#$relationId` where `#${aRelationSide.toString}` = $aId and `#${bRelationSide.toString}` = $bId" + } + + def deleteAllDataItems(projectId: String, modelName: String) = sqlu"delete from `#$projectId`.`#$modelName`" + + def deleteDataItemByValues(projectId: String, modelName: String, values: Map[String, Any]) = { + val whereClause = + if (values.isEmpty) { + None + } else { + val escapedKeys = values.keys.map(escapeKey) + val escapedValues = values.values.map(escapeUnsafeParam) + + val keyValueTuples = escapedKeys zip escapedValues + combineByAnd(keyValueTuples.map({ + case (k, v) => k concat sql" = " concat v + })) + } + + val whereClauseWithWhere = + if (whereClause.isEmpty) None else Some(sql"where " concat whereClause) + + (sql"delete from `#$projectId`.`#$modelName`" concat whereClauseWithWhere).asUpdate + } + + def createClientDatabaseForProject(projectId: String) = { + val idCharset = + charsetTypeForScalarTypeIdentifier(isList = false, TypeIdentifier.GraphQLID) + + DBIO.seq( + sqlu"""CREATE SCHEMA `#$projectId` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; """, + sqlu"""CREATE TABLE `#$projectId`.`_RelayId` (`id` CHAR(25) #$idCharset NOT NULL, `modelId` CHAR(25) #$idCharset NOT NULL, PRIMARY KEY (`id`), UNIQUE INDEX `id_UNIQUE` (`id` ASC)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci""" + ) + } + + def copyTableData(sourceProjectId: String, sourceTableName: String, columns: List[String], targetProjectId: String, targetTableName: String) = { + val columnString = combineByComma(columns.map(c => escapeKey(c))) + (sql"INSERT INTO `#$targetProjectId`.`#$targetTableName` (" concat columnString concat sql") SELECT " concat columnString concat sql" FROM `#$sourceProjectId`.`#$sourceTableName`").asUpdate + } + + def deleteProjectDatabase(projectId: String) = sqlu"DROP DATABASE IF EXISTS `#$projectId`" + + def createTable(projectId: String, name: String) = { + val idCharset = charsetTypeForScalarTypeIdentifier(isList = false, TypeIdentifier.GraphQLID) + + sqlu"""CREATE TABLE `#$projectId`.`#$name` + (`id` CHAR(25) #$idCharset NOT NULL, + `createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE INDEX `id_UNIQUE` (`id` ASC)) + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci""" + } + + def dangerouslyTruncateTable(tableNames: List[String]): DBIOAction[Unit, NoStream, Effect] = { + DBIO.seq( + List(sqlu"""SET FOREIGN_KEY_CHECKS=0""") ++ + tableNames.map(name => sqlu"TRUNCATE TABLE `#$name`") ++ + List(sqlu"""SET FOREIGN_KEY_CHECKS=1"""): _* + ) + } + + def renameTable(projectId: String, name: String, newName: String) = sqlu"""RENAME TABLE `#$projectId`.`#$name` TO `#$projectId`.`#$newName`;""" + + def createRelationTable(projectId: String, tableName: String, aTableName: String, bTableName: String) = { + val idCharset = charsetTypeForScalarTypeIdentifier(isList = false, TypeIdentifier.GraphQLID) + + sqlu"""CREATE TABLE `#$projectId`.`#$tableName` (`id` CHAR(25) #$idCharset NOT NULL, + PRIMARY KEY (`id`), UNIQUE INDEX `id_UNIQUE` (`id` ASC), + `A` CHAR(25) #$idCharset NOT NULL, INDEX `A` (`A` ASC), + `B` CHAR(25) #$idCharset NOT NULL, INDEX `B` (`B` ASC), + UNIQUE INDEX `AB_unique` (`A` ASC, `B` ASC), + FOREIGN KEY (A) REFERENCES `#$projectId`.`#$aTableName`(id) ON DELETE CASCADE, + FOREIGN KEY (B) REFERENCES `#$projectId`.`#$bTableName`(id) ON DELETE CASCADE) + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;""" + } + + def dropTable(projectId: String, tableName: String) = sqlu"DROP TABLE `#$projectId`.`#$tableName`" + + def createColumn(projectId: String, + tableName: String, + columnName: String, + isRequired: Boolean, + isUnique: Boolean, + isList: Boolean, + typeIdentifier: TypeIdentifier.TypeIdentifier) = { + + val sqlType = sqlTypeForScalarTypeIdentifier(isList, typeIdentifier) + val charsetString = charsetTypeForScalarTypeIdentifier(isList, typeIdentifier) + val nullString = if (isRequired) "NOT NULL" else "NULL" + val uniqueString = + if (isUnique) { + val indexSize = sqlType match { + case "text" | "mediumtext" => "(191)" + case _ => "" + } + + s", ADD UNIQUE INDEX `${columnName}_UNIQUE` (`$columnName`$indexSize ASC)" + } else { "" } + + sqlu"""ALTER TABLE `#$projectId`.`#$tableName` ADD COLUMN `#$columnName` + #$sqlType #$charsetString #$nullString #$uniqueString, ALGORITHM = INPLACE""" + } + + def updateColumn(projectId: String, + tableName: String, + oldColumnName: String, + newColumnName: String, + newIsRequired: Boolean, + newIsUnique: Boolean, + newIsList: Boolean, + newTypeIdentifier: TypeIdentifier) = { + val nulls = if (newIsRequired) { "NOT NULL" } else { "NULL" } + val sqlType = + sqlTypeForScalarTypeIdentifier(newIsList, newTypeIdentifier) + + sqlu"ALTER TABLE `#$projectId`.`#$tableName` CHANGE COLUMN `#$oldColumnName` `#$newColumnName` #$sqlType #$nulls" + } + + def addUniqueConstraint(projectId: String, tableName: String, columnName: String, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val sqlType = sqlTypeForScalarTypeIdentifier(isList = isList, typeIdentifier = typeIdentifier) + + val indexSize = sqlType match { + case "text" | "mediumtext" => "(191)" + case _ => "" + } + + sqlu"ALTER TABLE `#$projectId`.`#$tableName` ADD UNIQUE INDEX `#${columnName}_UNIQUE` (`#$columnName`#$indexSize ASC)" + } + + def removeUniqueConstraint(projectId: String, tableName: String, columnName: String) = { + sqlu"ALTER TABLE `#$projectId`.`#$tableName` DROP INDEX `#${columnName}_UNIQUE`" + } + + def deleteColumn(projectId: String, tableName: String, columnName: String) = { + sqlu"ALTER TABLE `#$projectId`.`#$tableName` DROP COLUMN `#$columnName`, ALGORITHM = INPLACE" + } + + def populateRelationFieldMirror(projectId: String, relationTable: String, modelTable: String, mirrorColumn: String, column: String, relationSide: String) = { + sqlu"UPDATE `#$projectId`.`#$relationTable` R, `#$projectId`.`#$modelTable` M SET R.`#$mirrorColumn` = M.`#$column` WHERE R.`#$relationSide` = M.id;" + } + + // note: utf8mb4 requires up to 4 bytes per character and includes full utf8 support, including emoticons + // utf8 requires up to 3 bytes per character and does not have full utf8 support. + // mysql indexes have a max size of 767 bytes or 191 utf8mb4 characters. + // We limit enums to 191, and create text indexes over the first 191 characters of the string, but + // allow the actual content to be much larger. + // Key columns are utf8_general_ci as this collation is ~10% faster when sorting and requires less memory + def sqlTypeForScalarTypeIdentifier(isList: Boolean, typeIdentifier: TypeIdentifier): String = { + if (isList) { + return "mediumtext" + } + + typeIdentifier match { + case TypeIdentifier.String => "mediumtext" + case TypeIdentifier.Boolean => "boolean" + case TypeIdentifier.Int => "int" + case TypeIdentifier.Float => "Decimal(65,30)" + case TypeIdentifier.GraphQLID => "char(25)" + case TypeIdentifier.Password => "text" + case TypeIdentifier.Enum => "varchar(191)" + case TypeIdentifier.Json => "mediumtext" + case TypeIdentifier.DateTime => "datetime(3)" + case TypeIdentifier.Relation => sys.error("Relation is not a scalar type. Are you trying to create a db column for a relation?") + } + } + + def charsetTypeForScalarTypeIdentifier(isList: Boolean, typeIdentifier: TypeIdentifier): String = { + if (isList) { + return "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + } + + typeIdentifier match { + case TypeIdentifier.String => "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + case TypeIdentifier.Boolean => "" + case TypeIdentifier.Int => "" + case TypeIdentifier.Float => "" + case TypeIdentifier.GraphQLID => "CHARACTER SET utf8 COLLATE utf8_general_ci" + case TypeIdentifier.Password => "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + case TypeIdentifier.Enum => "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + case TypeIdentifier.Json => "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + case TypeIdentifier.DateTime => "" + } + } + + def createTableForModel(projectId: String, model: Model) = { + DBIO.seq( + DBIO.seq(createTable(projectId, model.name)), + DBIO.seq( + model.scalarFields + .filter(f => !DatabaseMutationBuilder.implicitlyCreatedColumns.contains(f.name)) + .map { (field) => + createColumn( + projectId = projectId, + tableName = model.name, + columnName = field.name, + isRequired = field.isRequired, + isUnique = field.isUnique, + isList = field.isList, + typeIdentifier = field.typeIdentifier + ) + }: _*) + ) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseQueryBuilder.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseQueryBuilder.scala new file mode 100644 index 0000000000..794e71fbda --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/DatabaseQueryBuilder.scala @@ -0,0 +1,254 @@ +package cool.graph.client.database + +import cool.graph.DataItem +import cool.graph.shared.models.{Field, Project} +import slick.dbio.DBIOAction +import slick.dbio.Effect.Read +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.meta.{DatabaseMeta, MTable} +import slick.jdbc.{SQLActionBuilder, _} + +import scala.concurrent.ExecutionContext.Implicits.global + +object DatabaseQueryBuilder { + + import SlickExtensions._ + + implicit object GetDataItem extends GetResult[DataItem] { + def apply(ps: PositionedResult): DataItem = { + val rs = ps.rs + val md = rs.getMetaData + val colNames = for (i <- 1 to md.getColumnCount) + yield md.getColumnName(i) + + val userData = (for (n <- colNames.filter(_ != "id")) + // note: getObject(string) is case insensitive, so we get the index in scala land instead + yield n -> Option(rs.getObject(colNames.indexOf(n) + 1))).toMap + + DataItem(id = rs.getString("id"), userData = userData) + } + } + + def selectAllFromModel(projectId: String, + modelName: String, + args: Option[QueryArguments], + overrideMaxNodeCount: Option[Int] = None): (SQLActionBuilder, ResultTransform) = { + + val (conditionCommand, orderByCommand, limitCommand, resultTransform) = + extractQueryArgs(projectId, modelName, args, overrideMaxNodeCount = overrideMaxNodeCount) + + val query = + sql"select * from `#$projectId`.`#$modelName`" concat + prefixIfNotNone("where", conditionCommand) concat + prefixIfNotNone("order by", orderByCommand) concat + prefixIfNotNone("limit", limitCommand) + + (query, resultTransform) + } + + def selectAllFromModels(projectId: String, modelName: String, args: Option[QueryArguments]): (SQLActionBuilder, ResultTransform) = { + + val (conditionCommand, orderByCommand, limitCommand, resultTransform) = + extractQueryArgs(projectId, modelName, args) + + val query = + sql"select * from `#$projectId`.`#$modelName`" concat + prefixIfNotNone("where", conditionCommand) concat + prefixIfNotNone("order by", orderByCommand) concat + prefixIfNotNone("limit", limitCommand) + + (query, resultTransform) + } + + def countAllFromModel(projectId: String, modelName: String, args: Option[QueryArguments]): SQLActionBuilder = { + + val (conditionCommand, orderByCommand, _, _) = + extractQueryArgs(projectId, modelName, args) + + sql"select count(*) from `#$projectId`.`#$modelName`" concat + prefixIfNotNone("where", conditionCommand) concat + prefixIfNotNone("order by", orderByCommand) + } + + def extractQueryArgs( + projectId: String, + modelName: String, + args: Option[QueryArguments], + defaultOrderShortcut: Option[String] = None, + overrideMaxNodeCount: Option[Int] = None): (Option[SQLActionBuilder], Option[SQLActionBuilder], Option[SQLActionBuilder], ResultTransform) = { + args match { + case None => (None, None, None, x => ResolverResult(x)) + case Some(givenArgs: QueryArguments) => + ( + givenArgs.extractWhereConditionCommand(projectId, modelName), + givenArgs.extractOrderByCommand(projectId, modelName, defaultOrderShortcut), + overrideMaxNodeCount match { + case None => givenArgs.extractLimitCommand(projectId, modelName) + case Some(maxCount: Int) => + givenArgs.extractLimitCommand(projectId, modelName, maxCount) + }, + givenArgs.extractResultTransform(projectId, modelName) + ) + } + } + + def itemCountForTable(projectId: String, modelName: String) = { + sql"SELECT COUNT(*) AS Count FROM `#$projectId`.`#$modelName`" + } + + def existsNullByModelAndScalarField(projectId: String, modelName: String, fieldName: String) = { + sql"""SELECT EXISTS(Select `id` FROM `#$projectId`.`#$modelName` + WHERE `#$projectId`.`#$modelName`.#$fieldName IS NULL)""" + } + + def valueCountForScalarField(projectId: String, modelName: String, fieldName: String, value: String) = { + sql"""SELECT COUNT(*) AS Count FROM `#$projectId`.`#$modelName` + WHERE `#$projectId`.`#$modelName`.#$fieldName = $value""" + } + + def existsNullByModelAndRelationField(projectId: String, modelName: String, field: Field) = { + val relationId = field.relation.get.id + val relationSide = field.relationSide.get.toString + sql"""(select EXISTS (select `id`from `#$projectId`.`#$modelName` + where `#$projectId`.`#$modelName`.id Not IN + (Select `#$projectId`.`#$relationId`.#$relationSide from `#$projectId`.`#$relationId`)))""" + } + + def existsByModelAndId(projectId: String, modelName: String, id: String) = { + sql"select exists (select `id` from `#$projectId`.`#$modelName` where `id` = '#$id')" + } + + def existsByModel(projectId: String, modelName: String) = { + sql"select exists (select `id` from `#$projectId`.`#$modelName`)" + } + + def batchSelectFromModelByUnique(projectId: String, modelName: String, key: String, values: List[Any]): SQLActionBuilder = { + sql"select * from `#$projectId`.`#$modelName` where `#$key` in (" concat combineByComma(values.map(escapeUnsafeParam)) concat sql")" + } + + def batchSelectAllFromRelatedModel(project: Project, + relationField: Field, + parentNodeIds: List[String], + args: Option[QueryArguments]): (SQLActionBuilder, ResultTransform) = { + + val fieldTable = relationField.relatedModel(project).get.name + val unsafeRelationId = relationField.relation.get.id + val modelRelationSide = relationField.relationSide.get.toString + val fieldRelationSide = relationField.oppositeRelationSide.get.toString + + val (conditionCommand, orderByCommand, limitCommand, resultTransform) = + extractQueryArgs(project.id, fieldTable, args, defaultOrderShortcut = Some(s"""`${project.id}`.`$unsafeRelationId`.$fieldRelationSide""")) + + def createQuery(id: String, modelRelationSide: String, fieldRelationSide: String) = { + sql"""(select * from `#${project.id}`.`#$fieldTable` + inner join `#${project.id}`.`#$unsafeRelationId` + on `#${project.id}`.`#$fieldTable`.id = `#${project.id}`.`#$unsafeRelationId`.#$fieldRelationSide + where `#${project.id}`.`#$unsafeRelationId`.#$modelRelationSide = '#$id' """ concat + prefixIfNotNone("and", conditionCommand) concat + prefixIfNotNone("order by", orderByCommand) concat + prefixIfNotNone("limit", limitCommand) concat sql")" + } + + def unionIfNotFirst(index: Int): SQLActionBuilder = + if (index == 0) { + sql"" + } else { + sql"union all " + } + + // see https://github.com/graphcool/internal-docs/blob/master/relations.md#findings + val resolveFromBothSidesAndMerge = relationField.relation.get + .isSameFieldSameModelRelation(project) && !relationField.isList + + val query = resolveFromBothSidesAndMerge match { + case false => + parentNodeIds.distinct.view.zipWithIndex.foldLeft(sql"")((a, b) => + a concat unionIfNotFirst(b._2) concat createQuery(b._1, modelRelationSide, fieldRelationSide)) + case true => + parentNodeIds.distinct.view.zipWithIndex.foldLeft(sql"")( + (a, b) => + a concat unionIfNotFirst(b._2) concat createQuery(b._1, modelRelationSide, fieldRelationSide) concat sql"union all " concat createQuery( + b._1, + fieldRelationSide, + modelRelationSide)) + } + + (query, resultTransform) + } + + def countAllFromRelatedModels(project: Project, + relationField: Field, + parentNodeIds: List[String], + args: Option[QueryArguments]): (SQLActionBuilder, ResultTransform) = { + + val fieldTable = relationField.relatedModel(project).get.name + val unsafeRelationId = relationField.relation.get.id + val modelRelationSide = relationField.relationSide.get.toString + val fieldRelationSide = relationField.oppositeRelationSide.get.toString + + val (conditionCommand, orderByCommand, limitCommand, resultTransform) = + extractQueryArgs(project.id, fieldTable, args, defaultOrderShortcut = Some(s"""`${project.id}`.`$unsafeRelationId`.$fieldRelationSide""")) + + def createQuery(id: String) = { + sql"""(select '#$id', count(*) from `#${project.id}`.`#$fieldTable` + inner join `#${project.id}`.`#$unsafeRelationId` + on `#${project.id}`.`#$fieldTable`.id = `#${project.id}`.`#$unsafeRelationId`.#$fieldRelationSide + where `#${project.id}`.`#$unsafeRelationId`.#$modelRelationSide = '#$id' """ concat + prefixIfNotNone("and", conditionCommand) concat + prefixIfNotNone("order by", orderByCommand) concat + prefixIfNotNone("limit", limitCommand) concat sql")" + } + + def unionIfNotFirst(index: Int): SQLActionBuilder = + if (index == 0) { + sql"" + } else { + sql"union all " + } + + val query = + parentNodeIds.distinct.view.zipWithIndex.foldLeft(sql"")((a, b) => a concat unionIfNotFirst(b._2) concat createQuery(b._1)) + + (query, resultTransform) + } + + case class ColumnDescription(name: String, isNullable: Boolean, typeName: String, size: Option[Int]) + case class IndexDescription(name: Option[String], nonUnique: Boolean, column: Option[String]) + case class ForeignKeyDescription(name: Option[String], column: String, foreignTable: String, foreignColumn: String) + case class TableInfo(columns: List[ColumnDescription], indexes: List[IndexDescription], foreignKeys: List[ForeignKeyDescription]) + + def getTableInfo(projectId: String, tableName: Option[String] = None): DBIOAction[TableInfo, NoStream, Read] = { + for { + metaTables <- MTable + .getTables(cat = Some(projectId), schemaPattern = None, namePattern = tableName, types = None) + columns <- metaTables.head.getColumns + indexes <- metaTables.head.getIndexInfo(false, false) + foreignKeys <- metaTables.head.getImportedKeys + } yield + TableInfo( + columns = columns + .map(x => ColumnDescription(name = x.name, isNullable = x.isNullable.get, typeName = x.typeName, size = x.size)) + .toList, + indexes = indexes + .map(x => IndexDescription(name = x.indexName, nonUnique = x.nonUnique, column = x.column)) + .toList, + foreignKeys = foreignKeys + .map(x => ForeignKeyDescription(name = x.fkName, column = x.fkColumn, foreignColumn = x.pkColumn, foreignTable = x.pkTable.name)) + .toList + ) + } + + def getTables(projectId: String) = { + for { + metaTables <- MTable.getTables(cat = Some(projectId), schemaPattern = None, namePattern = None, types = None) + } yield metaTables.map(table => table.name.name) + } + + def getSchemas = { + for { + catalogs <- DatabaseMeta.getCatalogs + } yield catalogs + } + + type ResultTransform = Function[List[DataItem], ResolverResult] +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/DeferredTypes.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/DeferredTypes.scala new file mode 100644 index 0000000000..5716809d99 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/DeferredTypes.scala @@ -0,0 +1,66 @@ +package cool.graph.client.database + +import cool.graph.DataItem +import cool.graph.shared.models.{AuthenticatedRequest, Field, Model} +import sangria.execution.deferred.Deferred + +import scala.concurrent.Future + +object DeferredTypes { + + trait Ordered { + def order: Int + } + + case class OrderedDeferred[T](deferred: T, order: Int) extends Ordered + case class OrderedDeferredFutureResult[ResultType](future: Future[ResultType], order: Int) extends Ordered + + trait ModelArgs { + def model: Model + def args: Option[QueryArguments] + } + + trait ModelDeferred[+T] extends ModelArgs with Deferred[T] { + model: Model + args: Option[QueryArguments] + } + + case class ManyModelDeferred[ConnectionOutputType](model: Model, args: Option[QueryArguments]) extends ModelDeferred[ConnectionOutputType] + + case class ManyModelExistsDeferred(model: Model, args: Option[QueryArguments]) extends ModelDeferred[Boolean] + + case class CountManyModelDeferred(model: Model, args: Option[QueryArguments]) extends ModelDeferred[Int] + + trait RelatedArgs { + def relationField: Field + def parentNodeId: String + def args: Option[QueryArguments] + } + + trait RelationDeferred[+T] extends RelatedArgs with Deferred[T] { + def relationField: Field + def parentNodeId: String + def args: Option[QueryArguments] + } + + type OneDeferredResultType = Option[DataItem] + case class OneDeferred(model: Model, key: String, value: Any) extends Deferred[OneDeferredResultType] + case class ToOneDeferred(relationField: Field, parentNodeId: String, args: Option[QueryArguments]) extends RelationDeferred[OneDeferredResultType] + + case class ToManyDeferred[ConnectionOutputType](relationField: Field, parentNodeId: String, args: Option[QueryArguments]) + extends RelationDeferred[ConnectionOutputType] + + case class CountToManyDeferred(relationField: Field, parentNodeId: String, args: Option[QueryArguments]) extends RelationDeferred[Int] + + type SimpleConnectionOutputType = Seq[DataItem] + type RelayConnectionOutputType = IdBasedConnection[DataItem] + + case class CheckPermissionDeferred(model: Model, + field: Field, + nodeId: String, + authenticatedRequest: Option[AuthenticatedRequest], + value: Any, + node: DataItem, + alwaysQueryMasterDatabase: Boolean) + extends Deferred[Boolean] +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/FilterArguments.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/FilterArguments.scala new file mode 100644 index 0000000000..95148ca479 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/FilterArguments.scala @@ -0,0 +1,130 @@ +package cool.graph.client.database + +import cool.graph.shared.models.{Field, Model, TypeIdentifier} + +case class FieldFilterTuple(field: Option[Field], filterArg: FilterArgument) +case class FilterArgument(name: String, description: String, isList: Boolean = false) + +class FilterArguments(model: Model, isSubscriptionFilter: Boolean = false) { + + private val index = model.fields + .flatMap(field => { + FilterArguments + .getFieldFilters(field) + .map(filter => { + (field.name + filter.name, FieldFilterTuple(Some(field), filter)) + }) + }) + .toMap + + def lookup(filter: String): FieldFilterTuple = filter match { + case "AND" => + FieldFilterTuple(None, FilterArguments.ANDFilter) + + case "OR" => + FieldFilterTuple(None, FilterArguments.ORFilter) + + case "boolean" if isSubscriptionFilter => + FieldFilterTuple(None, FilterArguments.booleanFilter) + + case "node" if isSubscriptionFilter => + FieldFilterTuple(None, FilterArguments.nodeFilter) + + case _ => + index.get(filter) match { + case None => + throw new Exception(s""""No field for the filter "$filter" has been found.""") + + case Some(fieldFilterTuple) => + fieldFilterTuple + } + } +} + +object FilterArguments { + + val ANDFilter = FilterArgument("AND", "Logical AND on all given filters.") + val ORFilter = FilterArgument("OR", "Logical OR on all given filters.") + val booleanFilter = FilterArgument("boolean", "") + val nodeFilter = FilterArgument("node", "") + + private val baseFilters = List( + FilterArgument("", ""), + FilterArgument("_not", "All values that are not equal to given value.") + ) + + private val inclusionFilters = List( + FilterArgument("_in", "All values that are contained in given list.", isList = true), + FilterArgument("_not_in", "All values that are not contained in given list.", isList = true) + ) + + private val alphanumericFilters = List( + FilterArgument("_lt", "All values less than the given value."), + FilterArgument("_lte", "All values less than or equal the given value."), + FilterArgument("_gt", "All values greater than the given value."), + FilterArgument("_gte", "All values greater than or equal the given value.") + ) + + private val stringFilters = List( + FilterArgument("_contains", "All values containing the given string."), + FilterArgument("_not_contains", "All values not containing the given string."), + FilterArgument("_starts_with", "All values starting with the given string."), + FilterArgument("_not_starts_with", "All values not starting with the given string."), + FilterArgument("_ends_with", "All values ending with the given string."), + FilterArgument("_not_ends_with", "All values not ending with the given string.") + ) + + private val listFilters = List( + FilterArgument("_contains", "All values (list) containing the given value."), + FilterArgument("_contains_all", "All values (list) containing all the values from the given list."), + FilterArgument("_contains_any", "All values (list) containing at least one of the given values.") + ) + + private val lengthFilters = List( + FilterArgument("_length", "All values matching the given length."), + FilterArgument("_length_not", "All values not matching the given length."), + FilterArgument("_length_lt", "All values with a length less than the given length."), + FilterArgument("_length_lte", "All values with a length less than or equal the given length."), + FilterArgument("_length_gt", "All values with a length greater than the given length."), + FilterArgument("_length_gte", "All values with a length less than or equal the given length."), + FilterArgument("_length_in", "All values that have one of the lengths specified."), + FilterArgument("_length_not_in", "All values that do not have any of the lengths specified.") + ) + + private val multiRelationFilters = List( + FilterArgument("_every", "All nodes where all nodes in the relation satisfy the given condition."), + FilterArgument("_some", "All nodes that have at least one node in the relation satisfying the given condition."), + FilterArgument("_none", "All nodes that have no node in the relation satisfying the given condition.") + ) + + private val oneRelationFilters = List( + FilterArgument("", "") +// "_is_null" + ) + + def getFieldFilters(field: Field): List[FilterArgument] = { + val filters = + if (field.isList) { + field.typeIdentifier match { + case TypeIdentifier.Relation => List(multiRelationFilters) + case _ => List() + } + } else { + field.typeIdentifier match { + case TypeIdentifier.GraphQLID => List(baseFilters, inclusionFilters, alphanumericFilters, stringFilters) + case TypeIdentifier.String => List(baseFilters, inclusionFilters, alphanumericFilters, stringFilters) + case TypeIdentifier.Int => List(baseFilters, inclusionFilters, alphanumericFilters) + case TypeIdentifier.Float => List(baseFilters, inclusionFilters, alphanumericFilters) + case TypeIdentifier.Boolean => List(baseFilters) + case TypeIdentifier.Enum => List(baseFilters, inclusionFilters) + case TypeIdentifier.DateTime => List(baseFilters, inclusionFilters, alphanumericFilters) + case TypeIdentifier.Password => List() + case TypeIdentifier.Json => List() + case TypeIdentifier.Relation => List(oneRelationFilters) + case _ => List() + } + } + + filters.flatten + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/IdBasedConnection.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/IdBasedConnection.scala new file mode 100644 index 0000000000..c659c61703 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/IdBasedConnection.scala @@ -0,0 +1,157 @@ +package cool.graph.client.database + +import cool.graph.shared.models +import sangria.schema._ + +import scala.annotation.implicitNotFound +import scala.language.higherKinds +import scala.reflect.ClassTag + +case class ConnectionParentElement(nodeId: Option[String], field: Option[models.Field], args: Option[QueryArguments]) + +trait IdBasedConnection[T] { + def pageInfo: PageInfo + def edges: Seq[Edge[T]] + def parent: ConnectionParentElement +} + +object IdBasedConnection { + object Args { + val Before = Argument("before", OptionInputType(StringType)) + val After = Argument("after", OptionInputType(StringType)) + val First = Argument("first", OptionInputType(IntType)) + val Last = Argument("last", OptionInputType(IntType)) + + val All = Before :: After :: First :: Last :: Nil + } + + def isValidNodeType[Val](nodeType: OutputType[Val]): Boolean = + nodeType match { + case _: ScalarType[_] | _: EnumType[_] | _: CompositeType[_] ⇒ true + case OptionType(ofType) ⇒ isValidNodeType(ofType) + case _ ⇒ false + } + + def definition[Ctx, Conn[_], Val]( + name: String, + nodeType: OutputType[Val], + edgeFields: ⇒ List[Field[Ctx, Edge[Val]]] = Nil, + connectionFields: ⇒ List[Field[Ctx, Conn[Val]]] = Nil + )(implicit connEv: IdBasedConnectionLike[Conn, Val], classEv: ClassTag[Conn[Val]]) = { + if (!isValidNodeType(nodeType)) + throw new IllegalArgumentException( + "Node type is invalid. It must be either a Scalar, Enum, Object, Interface, Union, " + + "or a Non‐Null wrapper around one of those types. Notably, this field cannot return a list.") + + val edgeType = ObjectType[Ctx, Edge[Val]]( + name + "Edge", + "An edge in a connection.", + () ⇒ { + List[Field[Ctx, Edge[Val]]]( + Field("node", nodeType, Some("The item at the end of the edge."), resolve = _.value.node), + Field("cursor", StringType, Some("A cursor for use in pagination."), resolve = _.value.cursor) + ) ++ edgeFields + } + ) + + val connectionType = ObjectType[Ctx, Conn[Val]]( + name + "Connection", + "A connection to a list of items.", + () ⇒ { + List[Field[Ctx, Conn[Val]]]( + Field("pageInfo", PageInfoType, Some("Information to aid in pagination."), resolve = ctx ⇒ connEv.pageInfo(ctx.value)), + Field( + "edges", + OptionType(ListType(OptionType(edgeType))), + Some("A list of edges."), + resolve = ctx ⇒ { + val items = ctx.value + val edges = connEv.edges(items) + edges map (Some(_)) + } + ) + ) ++ connectionFields + } + ) + + IdBasedConnectionDefinition(edgeType, connectionType) + } + + /** + * The common page info type used by all connections. + */ + val PageInfoType = + ObjectType( + "PageInfo", + "Information about pagination in a connection.", + fields[Unit, PageInfo]( + Field("hasNextPage", BooleanType, Some("When paginating forwards, are there more items?"), resolve = _.value.hasNextPage), + Field("hasPreviousPage", BooleanType, Some("When paginating backwards, are there more items?"), resolve = _.value.hasPreviousPage), + Field( + "startCursor", + OptionType(StringType), + Some("When paginating backwards, the cursor to continue."), + resolve = _.value.startCursor + ), + Field("endCursor", OptionType(StringType), Some("When paginating forwards, the cursor to continue."), resolve = _.value.endCursor) + ) + ) + + val CursorPrefix = "arrayconnection:" + + def empty[T] = + DefaultIdBasedConnection(PageInfo.empty, Vector.empty[Edge[T]], ConnectionParentElement(None, None, None)) +} + +case class SliceInfo(sliceStart: Int, size: Int) + +case class IdBasedConnectionDefinition[Ctx, Conn, Val](edgeType: ObjectType[Ctx, Edge[Val]], connectionType: ObjectType[Ctx, Conn]) + +case class DefaultIdBasedConnection[T](pageInfo: PageInfo, edges: Seq[Edge[T]], parent: ConnectionParentElement) extends IdBasedConnection[T] + +trait Edge[T] { + def node: T + def cursor: String +} + +object Edge { + def apply[T](node: T, cursor: String) = DefaultEdge(node, cursor) +} + +case class DefaultEdge[T](node: T, cursor: String) extends Edge[T] + +case class PageInfo(hasNextPage: Boolean = false, hasPreviousPage: Boolean = false, startCursor: Option[String] = None, endCursor: Option[String] = None) + +object PageInfo { + def empty = PageInfo() +} + +@implicitNotFound( + "Type ${T} can't be used as a IdBasedConnection. Please consider defining implicit instance of sangria.relay.IdBasedConnectionLike for type ${T} or extending IdBasedConnection trait.") +trait IdBasedConnectionLike[T[_], E] { + def pageInfo(conn: T[E]): PageInfo + def edges(conn: T[E]): Seq[Edge[E]] +} + +object IdBasedConnectionLike { + private object IdBasedConnectionIsIdBasedConnectionLike$ extends IdBasedConnectionLike[IdBasedConnection, Any] { + override def pageInfo(conn: IdBasedConnection[Any]) = conn.pageInfo + override def edges(conn: IdBasedConnection[Any]) = conn.edges + } + + implicit def connectionIsConnectionLike[E, T[_]]: IdBasedConnectionLike[T, E] = + IdBasedConnectionIsIdBasedConnectionLike$ + .asInstanceOf[IdBasedConnectionLike[T, E]] +} + +case class IdBasedConnectionArgs(before: Option[String] = None, after: Option[String] = None, first: Option[Int] = None, last: Option[Int] = None) + +object IdBasedConnectionArgs { + def apply(args: WithArguments): IdBasedConnectionArgs = + IdBasedConnectionArgs(args arg IdBasedConnection.Args.Before, + args arg IdBasedConnection.Args.After, + args arg IdBasedConnection.Args.First, + args arg IdBasedConnection.Args.Last) + + val empty = IdBasedConnectionArgs() +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectDataresolver.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectDataresolver.scala new file mode 100644 index 0000000000..324a9c38f2 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectDataresolver.scala @@ -0,0 +1,206 @@ +package cool.graph.client.database + +import cool.graph.client.database.DatabaseQueryBuilder._ +import cool.graph.shared.models._ +import cool.graph.{DataItem, FilterElement, RequestContextTrait} +import scaldi._ +import slick.dbio.Effect.Read +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.SQLActionBuilder +import slick.lifted.TableQuery +import slick.sql.{SqlAction, SqlStreamingAction} + +import scala.collection.immutable.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class ProjectDataresolver(override val project: Project, override val requestContext: Option[RequestContextTrait])(implicit inj: Injector) + extends DataResolver(project = project, requestContext = requestContext) + with Injectable { + + def this(project: Project, requestContext: RequestContextTrait)(implicit inj: Injector) = + this(project, Some(requestContext)) + + def resolveByModel(model: Model, args: Option[QueryArguments] = None): Future[ResolverResult] = { + val (query, resultTransform) = DatabaseQueryBuilder.selectAllFromModel(project.id, model.name, args) + + performWithTiming("resolveByModel", readonlyClientDatabase.run(readOnlyDataItem(query))) + .map(_.toList.map(mapDataItem(model)(_))) + .map(resultTransform(_)) + } + + def countByModel(model: Model, args: Option[QueryArguments] = None): Future[Int] = { + val query = DatabaseQueryBuilder.countAllFromModel(project.id, model.name, args) + performWithTiming("countByModel", readonlyClientDatabase.run(readOnlyInt(query))).map(_.head) + } + + def existsByModelAndId(model: Model, id: String): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsByModelAndId(project.id, model.name, id) + + performWithTiming("existsByModelAndId", readonlyClientDatabase.run(readOnlyBoolean(query))).map(_.head) + } + + def existsByModel(model: Model): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsByModel(project.id, model.name) + + performWithTiming("existsByModel", readonlyClientDatabase.run(readOnlyBoolean(query))).map(_.head) + } + + def resolveByUnique(model: Model, key: String, value: Any): Future[Option[DataItem]] = { + batchResolveByUnique(model, key, List(value)).map(_.headOption) + } + + def resolveByUniqueWithoutValidation(model: Model, key: String, value: Any): Future[Option[DataItem]] = { + batchResolveByUniqueWithoutValidation(model, key, List(value)).map(_.headOption) + } + + def batchResolveByUnique(model: Model, key: String, values: List[Any]): Future[List[DataItem]] = { + val query = DatabaseQueryBuilder.batchSelectFromModelByUnique(project.id, model.name, key, values) + + performWithTiming("batchResolveByUnique", readonlyClientDatabase.run(readOnlyDataItem(query))) + .map(_.toList) + .map(_.map(mapDataItem(model))) + } + + def batchResolveByUniqueWithoutValidation(model: Model, key: String, values: List[Any]): Future[List[DataItem]] = { + val query = DatabaseQueryBuilder.batchSelectFromModelByUnique(project.id, model.name, key, values) + + performWithTiming("batchResolveByUnique", readonlyClientDatabase.run(readOnlyDataItem(query))) + .map(_.toList) + .map(_.map(mapDataItemWithoutValidation(model))) + } + + def resolveByGlobalId(globalId: String): Future[Option[DataItem]] = { + if (globalId == "viewer-fixed") { + return Future.successful(Some(DataItem(globalId, Map(), Some("Viewer")))) + } + + val query: SqlAction[Option[String], NoStream, Read] = TableQuery(new ProjectRelayIdTable(_, project.id)) + .filter(_.id === globalId) + .map(_.modelId) + .take(1) + .result + .headOption + + readonlyClientDatabase + .run(query) + .map { + case Some(modelId) => + val model = project.getModelById_!(modelId) + resolveByUnique(model, "id", globalId).map(_.map(mapDataItem(model)).map(_.copy(typeName = Some(model.name)))) + case _ => Future.successful(None) + } + .flatMap(identity) + } + + def resolveRelation(relationId: String, aId: String, bId: String): Future[ResolverResult] = { + val (query, resultTransform) = DatabaseQueryBuilder.selectAllFromModel( + project.id, + relationId, + Some(QueryArguments(None, None, None, None, None, Some(List(FilterElement("A", aId), FilterElement("B", bId))), None))) + + performWithTiming("resolveRelation", + readonlyClientDatabase + .run( + readOnlyDataItem(query) + ) + .map(_.toList) + .map(resultTransform)) + } + + def resolveByRelation(fromField: Field, fromModelId: String, args: Option[QueryArguments]): Future[ResolverResult] = { + val (query, resultTransform) = + DatabaseQueryBuilder.batchSelectAllFromRelatedModel(project, fromField, List(fromModelId), args) + + performWithTiming( + "resolveByRelation", + readonlyClientDatabase + .run(readOnlyDataItem(query)) + .map(_.toList.map(mapDataItem(fromField.relatedModel(project).get))) + .map(resultTransform) + ) + } + + def resolveByRelationManyModels(fromField: Field, fromModelIds: List[String], args: Option[QueryArguments]): Future[Seq[ResolverResult]] = { + val (query, resultTransform) = + DatabaseQueryBuilder + .batchSelectAllFromRelatedModel(project, fromField, fromModelIds, args) + + performWithTiming( + "resolveByRelation", + readonlyClientDatabase + .run(readOnlyDataItem(query)) + .map(_.toList.map(mapDataItem(fromField.relatedModel(project).get))) + .map((items: List[DataItem]) => { + val itemGroupsByModelId = items.groupBy(item => { + item.userData + .get(fromField.relationSide.get.toString) + .flatten + }) + + fromModelIds.map(id => { + itemGroupsByModelId.find(_._1.contains(id)) match { + case Some((_, itemsForId)) => resultTransform(itemsForId).copy(parentModelId = Some(id)) + case None => ResolverResult(Seq.empty, parentModelId = Some(id)) + } + }) + }) + ) + } + + def countByRelationManyModels(fromField: Field, fromNodeIds: List[String], args: Option[QueryArguments]): Future[List[(String, Int)]] = { + + val (query, _) = DatabaseQueryBuilder.countAllFromRelatedModels(project, fromField, fromNodeIds, args) + + performWithTiming("countByRelation", readonlyClientDatabase.run(readOnlyStringInt(query)).map(_.toList)) + } + + def itemCountForModel(model: Model): Future[Int] = { + val query = DatabaseQueryBuilder.itemCountForTable(project.id, model.name) + performWithTiming("itemCountForModel", readonlyClientDatabase.run(readOnlyInt(query)).map(_.head)) + } + + def existsNullByModelAndScalarField(model: Model, field: Field): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsNullByModelAndScalarField(project.id, model.name, field.name) + + performWithTiming("existsNullByModelAndScalarField", readonlyClientDatabase.run(readOnlyBoolean(query)).map(_.head)) + } + + def existsNullByModelAndRelationField(model: Model, field: Field): Future[Boolean] = { + val query = DatabaseQueryBuilder.existsNullByModelAndRelationField(project.id, model.name, field) + + performWithTiming("existsNullByModelAndRelationField", readonlyClientDatabase.run(readOnlyBoolean(query)).map(_.head)) + } + + def itemCountForRelation(relation: Relation): Future[Int] = { + val query = DatabaseQueryBuilder.itemCountForTable(project.id, relation.id) + + performWithTiming("itemCountForRelation", readonlyClientDatabase.run(readOnlyInt(query))).map(_.head) + } + + // note: Explicitly mark queries generated from raw sql as readonly to make aurora endpoint selection work + // see also http://danielwestheide.com/blog/2015/06/28/put-your-writes-where-your-master-is-compile-time-restriction-of-slick-effect-types.html + private def readOnlyDataItem(query: SQLActionBuilder): SqlStreamingAction[Vector[DataItem], DataItem, Read] = { + val action: SqlStreamingAction[Vector[DataItem], DataItem, Read] = query.as[DataItem] + + action + } + + private def readOnlyInt(query: SQLActionBuilder): SqlStreamingAction[Vector[Int], Int, Read] = { + val action: SqlStreamingAction[Vector[Int], Int, Read] = query.as[Int] + + action + } + + private def readOnlyBoolean(query: SQLActionBuilder): SqlStreamingAction[Vector[Boolean], Boolean, Read] = { + val action: SqlStreamingAction[Vector[Boolean], Boolean, Read] = query.as[Boolean] + + action + } + + private def readOnlyStringInt(query: SQLActionBuilder): SqlStreamingAction[Vector[(String, Int)], (String, Int), Read] = { + val action: SqlStreamingAction[Vector[(String, Int)], (String, Int), Read] = query.as[(String, Int)] + + action + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectRelayIdTable.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectRelayIdTable.scala new file mode 100644 index 0000000000..d6446bf25e --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/ProjectRelayIdTable.scala @@ -0,0 +1,13 @@ +package cool.graph.client.database + +import slick.jdbc.MySQLProfile.api._ + +case class ProjectRelayId(id: String, modelId: String) + +class ProjectRelayIdTable(tag: Tag, schema: String) extends Table[ProjectRelayId](tag, Some(schema), "_RelayId") { + + def id = column[String]("id", O.PrimaryKey) + def modelId = column[String]("modelId") + + def * = (id, modelId) <> ((ProjectRelayId.apply _).tupled, ProjectRelayId.unapply) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/QueryArguments.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/QueryArguments.scala new file mode 100644 index 0000000000..d5ede1822f --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/QueryArguments.scala @@ -0,0 +1,393 @@ +package cool.graph.client.database + +import cool.graph._ +import cool.graph.Types._ +import cool.graph.shared.errors.UserAPIErrors.{InvalidFirstArgument, InvalidLastArgument, InvalidSkipArgument} +import cool.graph.client.database.DatabaseQueryBuilder.ResultTransform +import cool.graph.shared.errors.{UserAPIErrors, UserInputErrors} +import cool.graph.shared.models.{Field, TypeIdentifier} +import slick.jdbc.SQLActionBuilder + +case class QueryArguments(skip: Option[Int], + after: Option[String], + first: Option[Int], + before: Option[String], + last: Option[Int], + filter: Option[DataItemFilterCollection], + orderBy: Option[OrderBy]) { + + val MAX_NODE_COUNT = 1000 + + import SlickExtensions._ + import slick.jdbc.MySQLProfile.api._ + + val isReverseOrder = last.isDefined + + // The job of these methods is to return dynamically generated conditions or commands, but without the corresponding + // keyword. For example "extractWhereConditionCommand" should return something line "q = 3 and z = '7'", without the + // "where" keyword. This is because we might need to combine these commands with other commands. If nothing is to be + // returned, DO NOT return an empty string, but None instead. + + def extractOrderByCommand(projectId: String, modelId: String, defaultOrderShortcut: Option[String] = None): Option[SQLActionBuilder] = { + + if (first.isDefined && last.isDefined) { + throw UserAPIErrors.InvalidConnectionArguments() + } + + // The limit instruction only works from up to down. Therefore, we have to invert order when we use before. + val defaultOrder = orderBy.map(_.sortOrder.toString).getOrElse("asc") + val (order, idOrder) = isReverseOrder match { + case true => (invertOrder(defaultOrder), "desc") + case false => (defaultOrder, "asc") + } + + val idField = s"`$projectId`.`$modelId`.`id`" + + val res = orderBy match { + case Some(orderByArg) if orderByArg.field.name != "id" => + val orderByField = s"`$projectId`.`$modelId`.`${orderByArg.field.name}`" + + // First order by the orderByField, then by id to break ties + Some(sql"#$orderByField #$order, #$idField #$idOrder") + + case _ => + // be default, order by id. For performance reason use the id in the relation table + Some(sql"#${defaultOrderShortcut.getOrElse(idField)} #$order") + + } + res + } + + def extractLimitCommand(projectId: String, modelId: String, maxNodeCount: Int = MAX_NODE_COUNT): Option[SQLActionBuilder] = { + + (first, last, skip) match { + case (Some(first), _, _) if first < 0 => throw InvalidFirstArgument() + case (_, Some(last), _) if last < 0 => throw InvalidLastArgument() + case (_, _, Some(skip)) if skip < 0 => throw InvalidSkipArgument() + case _ => { + val count: Option[Int] = last.isDefined match { + case true => last + case false => first + } + // Increase by 1 to know if we have a next page / previous page for relay queries + val limitedCount: String = count match { + case None => maxNodeCount.toString + case Some(x) if x > maxNodeCount => + throw UserInputErrors.TooManyNodesRequested(x) + case Some(x) => (x + 1).toString + } + Some(sql"${skip.getOrElse(0)}, #$limitedCount") + } + } + } + + // If order is inverted we have to reverse the returned data items. We do this in-mem to keep the sql query simple. + // Also, remove excess items from limit + 1 queries and set page info (hasNext, hasPrevious). + def extractResultTransform(projectId: String, modelId: String): ResultTransform = + (list: List[DataItem]) => { + val items = isReverseOrder match { + case true => list.reverse + case false => list + } + + (first, last) match { + case (Some(f), _) => + if (items.size > f) { + ResolverResult(items.dropRight(1), hasNextPage = true) + } else { + ResolverResult(items) + } + + case (_, Some(l)) => + if (items.size > l) { + ResolverResult(items.tail, hasPreviousPage = true) + } else { + ResolverResult(items) + } + + case _ => + ResolverResult(items) + } + } + + def extractWhereConditionCommand(projectId: String, modelId: String): Option[SQLActionBuilder] = { + + if (first.isDefined && last.isDefined) { + throw UserAPIErrors.InvalidConnectionArguments() + } + + val standardCondition = filter match { + case Some(filterArg) => + generateFilterConditions(projectId, modelId, filterArg) + case None => None + } + + val cursorCondition = + buildCursorCondition(projectId, modelId, standardCondition) + + val condition = cursorCondition match { + case None => standardCondition + case Some(cursorConditionArg) => Some(cursorConditionArg) + } + + condition + } + + def invertOrder(order: String) = order.trim().toLowerCase match { + case "desc" => "asc" + case "asc" => "desc" + case _ => throw new IllegalArgumentException + } + + // This creates a query that checks if the id is in a certain set returned by a subquery Q. + // The subquery Q fetches all the ID's defined by the cursors and order. + // On invalid cursor params, no error is thrown. The result set will just be empty. + def buildCursorCondition(projectId: String, modelId: String, injectedFilter: Option[SQLActionBuilder]): Option[SQLActionBuilder] = { + // If both params are empty, don't generate any query. + if (before.isEmpty && after.isEmpty) + return None + + val idField = s"`$projectId`.`$modelId`.`id`" + + // First, we fetch the ordering for the query. If none is passed, we order by id, ascending. + // We need that since before/after are dependent on the order. + val (orderByField, sortDirection) = orderBy match { + case Some(orderByArg) => (s"`$projectId`.`$modelId`.`${orderByArg.field.name}`", orderByArg.sortOrder.toString) + case None => (idField, "asc") + } + + // Then, we select the comparison operation and construct the cursors. For instance, if we use ascending order, and we want + // to get the items before, we use the "<" comparator on the column that defines the order. + def cursorFor(cursor: String, cursorType: String): Option[SQLActionBuilder] = { + val compOperator = (cursorType, sortDirection.toLowerCase.trim) match { + case ("before", "asc") => "<" + case ("before", "desc") => ">" + case ("after", "asc") => ">" + case ("after", "desc") => "<" + case _ => throw new IllegalArgumentException + } + + Some(sql"(#$orderByField, #$idField) #$compOperator ((select #$orderByField from `#$projectId`.`#$modelId` where #$idField = '#$cursor'), '#$cursor')") + } + + val afterCursorFilter = after match { + case Some(afterCursor) => cursorFor(afterCursor, "after") + case _ => None + } + + val beforeCursorFilter = before match { + case Some(beforeCursor) => cursorFor(beforeCursor, "before") + case _ => None + } + + // Fuse cursor commands and injected where command + val whereCommand = combineByAnd(List(injectedFilter, afterCursorFilter, beforeCursorFilter).flatten) + + whereCommand.map(c => sql"" concat c) + } + + def generateInStatement(items: Seq[Any]) = { + val combinedItems = combineByComma(items.map(escapeUnsafeParam)) + sql" IN (" concat combinedItems concat sql")" + } + + def generateFilterConditions(projectId: String, tableName: String, filter: Seq[Any]): Option[SQLActionBuilder] = { + // don't allow options that are Some(value), options that are None are ok +// assert(filter.count { +// case (key, value) => +// value.isInstanceOf[Option[Any]] && (value match { +// case Some(v) => true +// case None => false +// }) +// } == 0) + def getAliasAndTableName(fromModel: String, toModel: String): (String, String) = { + var modTableName = "" + if (!tableName.contains("_")) + modTableName = projectId + "`.`" + fromModel + else modTableName = tableName + val alias = toModel + "_" + tableName + (alias, modTableName) + } + + def filterOnRelation(relationTableName: String, relationFilter: FilterElementRelation) = { + Some(generateFilterConditions(projectId, relationTableName, relationFilter.filter).getOrElse(sql"True")) + } + + val sqlParts = filter + .map { + case FilterElement(key, None, Some(field), filterName, None) => + None + case FilterElement(key, value, None, filterName, None) if filterName == "AND" => { + val values = value + .asInstanceOf[Seq[Any]] + .map(subFilter => generateFilterConditions(projectId, tableName, subFilter.asInstanceOf[Seq[Any]])) + .collect { + case Some(x) => x + } + combineByAnd(values) + } + case FilterElement(key, value, None, filterName, None) if filterName == "AND" => { + val values = value + .asInstanceOf[Seq[Any]] + .map(subFilter => generateFilterConditions(projectId, tableName, subFilter.asInstanceOf[Seq[Any]])) + .collect { + case Some(x) => x + } + combineByAnd(values) + } + case FilterElement(key, value, None, filterName, None) if filterName == "OR" => { + val values = value + .asInstanceOf[Seq[Any]] + .map(subFilter => generateFilterConditions(projectId, tableName, subFilter.asInstanceOf[Seq[Any]])) + .collect { + case Some(x) => x + } + combineByOr(values) + } + case FilterElement(key, value, None, filterName, None) if filterName == "node" => { + val values = value + .asInstanceOf[Seq[Any]] + .map(subFilter => generateFilterConditions(projectId, tableName, subFilter.asInstanceOf[Seq[Any]])) + .collect { + case Some(x) => x + } + combineByOr(values) + } + // the boolean filter comes from precomputed fields + case FilterElement(key, value, None, filterName, None) if filterName == "boolean" => { + value match { + case true => + Some(sql"TRUE") + case false => + Some(sql"FALSE") + } + } + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_contains" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` LIKE " concat escapeUnsafeParam(s"%$value%")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_not_contains" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` NOT LIKE " concat escapeUnsafeParam(s"%$value%")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_starts_with" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` LIKE " concat escapeUnsafeParam(s"$value%")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_not_starts_with" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` NOT LIKE " concat escapeUnsafeParam(s"$value%")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_ends_with" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` LIKE " concat escapeUnsafeParam(s"%$value")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_not_ends_with" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` NOT LIKE " concat escapeUnsafeParam(s"%$value")) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_lt" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` < " concat escapeUnsafeParam(value)) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_gt" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` > " concat escapeUnsafeParam(value)) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_lte" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` <= " concat escapeUnsafeParam(value)) + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_gte" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` >= " concat escapeUnsafeParam(value)) + + case FilterElement(key, null, Some(field), filterName, None) if filterName == "_in" => { + Some(sql"false") + } + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_in" => { + value.asInstanceOf[Seq[Any]].nonEmpty match { + case true => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` " concat generateInStatement(value.asInstanceOf[Seq[Any]])) + case false => Some(sql"false") + } + } + + case FilterElement(key, null, Some(field), filterName, None) if filterName == "_not_in" => { + Some(sql"false") + } + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_not_in" => { + value.asInstanceOf[Seq[Any]].nonEmpty match { + case true => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` NOT " concat generateInStatement(value.asInstanceOf[Seq[Any]])) + case false => Some(sql"true") + } + } + + case FilterElement(key, null, Some(field), filterName, None) if filterName == "_not" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` IS NOT NULL") + + case FilterElement(key, value, Some(field), filterName, None) if filterName == "_not" => + Some(sql"`#$projectId`.`#$tableName`.`#${field.name}` != " concat escapeUnsafeParam(value)) + + case FilterElement(key, null, Some(field: Field), filterName, None) if field.typeIdentifier == TypeIdentifier.Relation => + if (field.isList) { + throw new UserAPIErrors.FilterCannotBeNullOnToManyField(field.name) + } + Some(sql""" not exists (select * + from `#$projectId`.`#${field.relation.get.id}` + where `#$projectId`.`#${field.relation.get.id}`.`#${field.relationSide.get}` = `#$projectId`.`#$tableName`.`id` + )""") + + case FilterElement(key, null, Some(field), filterName, None) if field.typeIdentifier != TypeIdentifier.Relation => + Some(sql"`#$projectId`.`#$tableName`.`#$key` IS NULL") + + case FilterElement(key, value, _, filterName, None) => + Some(sql"`#$projectId`.`#$tableName`.`#$key` = " concat escapeUnsafeParam(value)) + + case FilterElement(key, value, Some(field), filterName, Some(relatedFilter)) if filterName == "_some" => + val (alias, modTableName) = + getAliasAndTableName(relatedFilter.fromModel.name, relatedFilter.toModel.name) + Some(sql"""exists ( + select * from `#$projectId`.`#${relatedFilter.toModel.name}` as `#$alias` + inner join `#$projectId`.`#${relatedFilter.relation.id}` + on `#$alias`.`id` = `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.oppositeRelationSide.get}` + where `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.relationSide.get}` = `#$modTableName`.`id` + and""" concat filterOnRelation(alias, relatedFilter) concat sql")") + + case FilterElement(key, value, Some(field), filterName, Some(relatedFilter)) if filterName == "_every" => + val (alias, modTableName) = + getAliasAndTableName(relatedFilter.fromModel.name, relatedFilter.toModel.name) + Some(sql"""not exists ( + select * from `#$projectId`.`#${relatedFilter.toModel.name}` as `#$alias` + inner join `#$projectId`.`#${relatedFilter.relation.id}` + on `#$alias`.`id` = `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.oppositeRelationSide.get}` + where `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.relationSide.get}` = `#$modTableName`.`id` + and not""" concat filterOnRelation(alias, relatedFilter) concat sql")") + + case FilterElement(key, value, Some(field), filterName, Some(relatedFilter)) if filterName == "_none" => + val (alias, modTableName) = + getAliasAndTableName(relatedFilter.fromModel.name, relatedFilter.toModel.name) + Some(sql"""not exists ( + select * from `#$projectId`.`#${relatedFilter.toModel.name}` as `#$alias` + inner join `#$projectId`.`#${relatedFilter.relation.id}` + on `#$alias`.`id` = `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.oppositeRelationSide.get}` + where `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.relationSide.get}` = `#$modTableName`.`id` + and """ concat filterOnRelation(alias, relatedFilter) concat sql")") + + case FilterElement(key, value, Some(field), filterName, Some(relatedFilter)) if filterName == "" => + val (alias, modTableName) = + getAliasAndTableName(relatedFilter.fromModel.name, relatedFilter.toModel.name) + Some(sql"""exists ( + select * from `#$projectId`.`#${relatedFilter.toModel.name}` as `#$alias` + inner join `#$projectId`.`#${relatedFilter.relation.id}` + on `#$alias`.`id` = `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.oppositeRelationSide.get}` + where `#$projectId`.`#${relatedFilter.relation.id}`.`#${field.relationSide.get}` = `#$modTableName`.`id` + and""" concat filterOnRelation(alias, relatedFilter) concat sql")") + + // this is used for the node: {} field in the Subscription Filter + case values: Seq[FilterElement @unchecked] => + generateFilterConditions(projectId, tableName, values) + } + .filter(_.nonEmpty) + .map(_.get) + + if (sqlParts.isEmpty) + None + else + combineByAnd(sqlParts) + } + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/database/SlickExtensions.scala b/server/backend-shared/src/main/scala/cool/graph/client/database/SlickExtensions.scala new file mode 100644 index 0000000000..a0da01daab --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/database/SlickExtensions.scala @@ -0,0 +1,103 @@ +package cool.graph.client.database + +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.{PositionedParameters, SQLActionBuilder, SetParameter} +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +object SlickExtensions { + + implicit class SQLActionBuilderConcat(a: SQLActionBuilder) { + def concat(b: SQLActionBuilder): SQLActionBuilder = { + SQLActionBuilder(a.queryParts ++ " " ++ b.queryParts, new SetParameter[Unit] { + def apply(p: Unit, pp: PositionedParameters): Unit = { + a.unitPConv.apply(p, pp) + b.unitPConv.apply(p, pp) + } + }) + } + def concat(b: Option[SQLActionBuilder]): SQLActionBuilder = b match { + case Some(b) => a concat b + case None => a + } + } + + def listToJson(param: List[Any]): String = { + param + .map(_ match { + case v: String => v.toJson + case v: JsValue => v.toJson + case v: Boolean => v.toJson + case v: Int => v.toJson + case v: Long => v.toJson + case v: Float => v.toJson + case v: Double => v.toJson + case v: BigInt => v.toJson + case v: BigDecimal => v.toJson + case v: DateTime => v.toString.toJson + }) + .toJson + .toString + } + + def escapeUnsafeParam(param: Any) = { + def unwrapSome(x: Any): Any = { + x match { + case Some(x) => x + case x => x + } + } + unwrapSome(param) match { + case param: String => sql"$param" + case param: JsValue => sql"${param.compactPrint}" + case param: Boolean => sql"$param" + case param: Int => sql"$param" + case param: Long => sql"$param" + case param: Float => sql"$param" + case param: Double => sql"$param" + case param: BigInt => sql"#${param.toString}" + case param: BigDecimal => sql"#${param.toString}" + case param: DateTime => + sql"${param.toString(DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS").withZoneUTC())}" + case param: Vector[_] => sql"${listToJson(param.toList)}" + case None => sql"NULL" + case null => sql"NULL" + case _ => + throw new IllegalArgumentException("Unsupported scalar value in SlickExtensions: " + param.toString) + } + } + + def escapeKey(key: String) = sql"`#$key`" + + def combineByAnd(actions: Iterable[SQLActionBuilder]) = + generateParentheses(combineBy(actions, "and")) + def combineByOr(actions: Iterable[SQLActionBuilder]) = + generateParentheses(combineBy(actions, "or")) + def combineByComma(actions: Iterable[SQLActionBuilder]) = + combineBy(actions, ",") + + def generateParentheses(sql: Option[SQLActionBuilder]) = { + sql match { + case None => None + case Some(sql) => + Some( + sql"(" concat sql concat sql")" + ) + } + } + + // Use this with caution, since combinator is not escaped! + def combineBy(actions: Iterable[SQLActionBuilder], combinator: String): Option[SQLActionBuilder] = + actions.toList match { + case Nil => None + case head :: Nil => Some(head) + case _ => + Some(actions.reduceLeft((a, b) => a concat sql"#$combinator" concat b)) + } + + def prefixIfNotNone(prefix: String, action: Option[SQLActionBuilder]): Option[SQLActionBuilder] = { + if (action.isEmpty) None else Some(sql"#$prefix " concat action.get) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/ModelMutationType.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/ModelMutationType.scala new file mode 100644 index 0000000000..01dda25f1f --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/ModelMutationType.scala @@ -0,0 +1,16 @@ +package cool.graph.client.schema + +import sangria.schema._ + +import cool.graph.shared.models + +object ModelMutationType { + val Type = EnumType( + "_ModelMutationType", + values = List( + EnumValue("CREATED", value = models.ModelMutationType.Created), + EnumValue("UPDATED", value = models.ModelMutationType.Updated), + EnumValue("DELETED", value = models.ModelMutationType.Deleted) + ) + ) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/OutputMapper.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/OutputMapper.scala new file mode 100644 index 0000000000..a9663927cb --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/OutputMapper.scala @@ -0,0 +1,41 @@ +package cool.graph.client.schema + +import cool.graph.DataItem +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Model, Relation} +import sangria.schema.{Args, ObjectType} + +abstract class OutputMapper { + type R + def nodePaths(model: Model): List[List[String]] + def mapCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, R] + + def mapUpdateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, R] + + def mapSubscriptionOutputType[C](model: Model, + objectType: ObjectType[C, DataItem], + updatedFields: Option[List[String]] = None, + mutation: ModelMutationType = cool.graph.shared.models.ModelMutationType.Created, + previousValues: Option[DataItem] = None, + dataItem: Option[R] = None): ObjectType[C, R] + + def mapUpdateOrCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, R] + + def mapDeleteOutputType[C](model: Model, objectType: ObjectType[C, DataItem], onlyId: Boolean = false): ObjectType[C, R] + + def mapAddToRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: cool.graph.shared.models.Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, R] + + def mapRemoveFromRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: cool.graph.shared.models.Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, R] + + def mapResolve(item: DataItem, args: Args): R +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaBuilderConstants.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaBuilderConstants.scala new file mode 100644 index 0000000000..35f69d73b3 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaBuilderConstants.scala @@ -0,0 +1,8 @@ +package cool.graph.client.schema + +object SchemaBuilderConstants { + val mutationDepth = 3 + + val idListSuffix = "Ids" + val idSuffix = "Id" +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaModelObjectTypesBuilder.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaModelObjectTypesBuilder.scala new file mode 100644 index 0000000000..a3dc16d675 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/SchemaModelObjectTypesBuilder.scala @@ -0,0 +1,421 @@ +package cool.graph.client.schema + +import cool.graph.GCDataTypes.GCStringConverter +import cool.graph.Types._ +import cool.graph._ +import cool.graph.client.database.DeferredTypes.{CheckPermissionDeferred, ToManyDeferred, ToOneDeferred} +import cool.graph.client.database.{FieldFilterTuple, FilterArguments, IdBasedConnection, QueryArguments} +import cool.graph.client.{FeatureMetric, SangriaQueryArguments, SchemaBuilderUtils, UserContext} +import cool.graph.deprecated.packageMocks._ +import cool.graph.shared.models.{Model, TypeIdentifier} +import cool.graph.shared.schema.CustomScalarTypes.{DateTimeType, JsonType, PasswordType} +import cool.graph.shared.{ApiMatrixFactory, models} +import cool.graph.subscriptions.SubscriptionUserContext +import org.joda.time.format.DateTimeFormat +import org.joda.time.{DateTime, DateTimeZone} +import sangria.schema.{Field, _} +import scaldi.{Injectable, Injector} +import spray.json.DefaultJsonProtocol._ +import spray.json.{JsValue, _} + +import scala.util.{Failure, Success, Try} + +abstract class SchemaModelObjectTypesBuilder[ManyDataItemType](project: models.Project, + nodeInterface: Option[InterfaceType[UserContext, DataItem]] = None, + modelPrefix: String = "", + withRelations: Boolean, + onlyId: Boolean = false)(implicit inj: Injector) + extends Injectable { + + val apiMatrix = inject[ApiMatrixFactory].create(project) + val includedModels: List[Model] = apiMatrix.filterModels(project.models) + + val interfaces: Map[String, InterfaceType[UserContext, DataItem]] = + project.installedPackages + .flatMap(_.interfaces) + .map(interface => (interface.name, toInterfaceType(interface))) + .toMap + + val modelObjectTypes: Map[String, ObjectType[UserContext, DataItem]] = + includedModels + .map(model => (model.name, modelToObjectType(model))) + .toMap + + protected def modelToObjectType(model: models.Model): ObjectType[UserContext, DataItem] = { + + new ObjectType( + name = modelPrefix + model.name, + description = model.description, + fieldsFn = () => { + apiMatrix + .filterFields(model.fields) + .filter(field => if (onlyId) field.name == "id" else true) + .filter(field => + field.isScalar match { + case true => true + case false => withRelations + }) + .map(mapClientField(model)) ++ + (withRelations match { + case true => apiMatrix.filterFields(model.relationFields).flatMap(mapMetaRelationField(model)) + case false => List() + }) + }, + interfaces = nodeInterface.toList ++ project.experimentalInterfacesForModel(model).map(i => interfaces(i.name)), + instanceCheck = (value: Any, valClass: Class[_], tpe: ObjectType[UserContext, _]) => + value match { + case DataItem(_, _, Some(tpe.name)) => true + case DataItem(_, _, Some(_)) => false + case _ => valClass.isAssignableFrom(value.getClass) + }, + astDirectives = Vector.empty + ) + } + + protected def toInterfaceType(interface: AppliedInterface): InterfaceType[UserContext, DataItem] = { + new InterfaceType( + name = interface.name, + description = Some("It's an interface"), + fieldsFn = () => { + interface.fields.map(mapInterfaceField) + }, + interfaces = List(), + manualPossibleTypes = () => + includedModels + .filter(m => project.experimentalInterfacesForModel(m).contains(interface)) + .map(m => modelObjectTypes(m.name)), + astDirectives = Vector.empty + ) + } + + def mapInterfaceField(field: AppliedInterfaceField): Field[UserContext, DataItem] = { + + // we should get this from the model ??? + val tempField = models.Field( + "temp-id", + field.name, + field.typeIdentifier, + Some(field.description), + isRequired = field.isRequired, + isList = false, + isUnique = field.isUnique, + isSystem = false, + isReadonly = false, + None, + field.defaultValue.map(x => GCStringConverter(field.typeIdentifier, field.isList).toGCValue(x).get), + None, + None + ) + + Field( + field.name, + fieldType = mapToOutputType(None, tempField), + description = Some(field.description), + arguments = List(), + resolve = (ctx: Context[UserContext, DataItem]) => { + val b = ctx.value.typeName + val model = includedModels.find(_.name == ctx.parentType.name).get // todo: this is wrong. parentType is the sangria type, so name is not the same as the model Name!!! + mapToOutputResolve(Some(model), tempField)(ctx) + }, + tags = List() + ) + } + + def mapCustomMutationField(field: models.Field): Field[UserContext, DataItem] = { + + Field( + field.name, + fieldType = mapToOutputType(None, field), + description = field.description, + arguments = List(), + resolve = (ctx: Context[UserContext, DataItem]) => { + mapToOutputResolve(None, field)(ctx) + }, + tags = List() + ) + } + + def mapMetaRelationField(model: models.Model)(field: models.Field): Option[Field[UserContext, DataItem]] = None + + def mapClientField(model: models.Model)(field: models.Field): Field[UserContext, DataItem] = Field( + field.name, + fieldType = mapToOutputType(Some(model), field), + description = field.description, + arguments = mapToListConnectionArguments(model, field), + resolve = (ctx: Context[UserContext, DataItem]) => { + mapToOutputResolve(Some(model), field)(ctx) + }, + tags = List() + ) + + def mapToOutputType(model: Option[models.Model], field: models.Field): OutputType[Any] = { + var outputType: OutputType[Any] = field.typeIdentifier match { + case TypeIdentifier.String => StringType + case TypeIdentifier.Int => IntType + case TypeIdentifier.Float => FloatType + case TypeIdentifier.Boolean => BooleanType + case TypeIdentifier.GraphQLID => IDType + case TypeIdentifier.Password => PasswordType + case TypeIdentifier.DateTime => DateTimeType + case TypeIdentifier.Json => JsonType + case TypeIdentifier.Enum => SchemaBuilderUtils.mapEnumFieldToInputType(field) + case _ => resolveConnection(field) + } + + if (field.isScalar && field.isList) { + outputType = ListType(outputType) + } + + if (!field.isRequired) { + outputType = OptionType(outputType) + } + + outputType + } + + def resolveConnection(field: cool.graph.shared.models.Field): OutputType[Any] + + def mapToListConnectionArguments(model: models.Model, field: models.Field): List[Argument[Option[Any]]] = { + + (field.isScalar, field.isList) match { + case (true, _) => List() + case (false, true) => + mapToListConnectionArguments(field.relatedModel(project).get) + case (false, false) => + mapToSingleConnectionArguments(field.relatedModel(project).get) + } + } + + def mapToListConnectionArguments(model: Model): List[Argument[Option[Any]]] = { + import SangriaQueryArguments._ + val skipArgument = Argument("skip", OptionInputType(IntType)) + + List( + filterArgument(model, project), + orderByArgument(model).asInstanceOf[Argument[Option[Any]]], + skipArgument.asInstanceOf[Argument[Option[Any]]], + IdBasedConnection.Args.After.asInstanceOf[Argument[Option[Any]]], + IdBasedConnection.Args.Before.asInstanceOf[Argument[Option[Any]]], + IdBasedConnection.Args.First.asInstanceOf[Argument[Option[Any]]], + IdBasedConnection.Args.Last.asInstanceOf[Argument[Option[Any]]] + ) + } + + def mapToSingleConnectionArguments(model: Model): List[Argument[Option[Any]]] = { + import SangriaQueryArguments._ + + List(filterArgument(model, project)) + } + + def generateFilterElement(input: Map[String, Any], model: Model, isSubscriptionFilter: Boolean = false): DataItemFilterCollection = { + val filterArguments = new FilterArguments(model, isSubscriptionFilter) + + input + .map({ + case (key, value) => + val FieldFilterTuple(field, filter) = filterArguments.lookup(key) + value match { + case value: Map[_, _] => + val typedValue = value.asInstanceOf[Map[String, Any]] + if (List("AND", "OR").contains(key) || (isSubscriptionFilter && key == "node")) { + generateFilterElement(typedValue, model, isSubscriptionFilter) + } else { + // this must be a relation filter + FilterElement( + key, + null, + field, + filter.name, + Some( + FilterElementRelation( + fromModel = model, + toModel = field.get.relatedModel(project).get, + relation = field.get.relation.get, + filter = generateFilterElement(typedValue, field.get.relatedModel(project).get, isSubscriptionFilter) + )) + ) + } + case value: Seq[Any] if value.nonEmpty && value.head.isInstanceOf[Map[_, _]] => { + FilterElement(key, + value + .asInstanceOf[Seq[Map[String, Any]]] + .map(generateFilterElement(_, model, isSubscriptionFilter)), + None, + filter.name) + } + case value: Seq[Any] => FilterElement(key, value, field, filter.name) + case _ => FilterElement(key, value, field, filter.name) + } + }) + .toList + .asInstanceOf[DataItemFilterCollection] + } + + def extractQueryArgumentsFromContext[C <: RequestContextTrait](model: Model, ctx: Context[C, Unit]): Option[QueryArguments] = { + val skipOpt = ctx.argOpt[Int]("skip") + + val rawFilterOpt: Option[Map[String, Any]] = ctx.argOpt[Map[String, Any]]("filter") + val filterOpt = rawFilterOpt.map(generateFilterElement(_, model, ctx.ctx.isSubscription)) + + if (filterOpt.isDefined) { + ctx.ctx.addFeatureMetric(FeatureMetric.Filter) + } + + val orderByOpt = ctx.argOpt[OrderBy]("orderBy") + val afterOpt = ctx.argOpt[String](IdBasedConnection.Args.After.name) + val beforeOpt = ctx.argOpt[String](IdBasedConnection.Args.Before.name) + val firstOpt = ctx.argOpt[Int](IdBasedConnection.Args.First.name) + val lastOpt = ctx.argOpt[Int](IdBasedConnection.Args.Last.name) + + Some( + SangriaQueryArguments + .createSimpleQueryArguments(skipOpt, afterOpt, firstOpt, beforeOpt, lastOpt, filterOpt, orderByOpt)) + } + + def mapToOutputResolve[C <: RequestContextTrait](model: Option[models.Model], field: models.Field)( + ctx: Context[C, DataItem]): sangria.schema.Action[UserContext, _] = { + + val item: DataItem = unwrapDataItemFromContext(ctx) + + if (!field.isScalar) { + val arguments = extractQueryArgumentsFromContext(field.relatedModel(project).get, ctx.asInstanceOf[Context[UserContext, Unit]]) + + if (field.isList) { + return ToManyDeferred[ManyDataItemType]( + field, + item.id, + arguments + ) + } + return ToOneDeferred(field, item.id, arguments) + } + + // If model is None this is a custom mutation. We currently don't check permissions on custom mutation payloads + model match { + case None => + val value = SchemaModelObjectTypesBuilder.convertScalarFieldValueFromDatabase(field, item, resolver = true) + value + + case Some(model) => + // note: UserContext is currently used in many places where we should use the higher level RequestContextTrait + // until that is cleaned up we have to explicitly check the type here. This is okay as we don't check Permission + // for ActionUserContext and AlgoliaSyncContext + // If you need to touch this it's probably better to spend the 5 hours to clean up the Context hierarchy + val value = SchemaModelObjectTypesBuilder.convertScalarFieldValueFromDatabase(field, item) + + ctx.ctx.isInstanceOf[UserContext] match { + case true => + if (ctx.ctx.mutationQueryWhitelist.isWhitelisted(ctx.path.path)) { + value + } else { + CheckPermissionDeferred( + model = model, + field = field, + value = value, + nodeId = item.id, + authenticatedRequest = ctx.ctx.asInstanceOf[UserContext].authenticatedRequest, + node = item, + alwaysQueryMasterDatabase = ctx.ctx.mutationQueryWhitelist.isMutationQuery + ) + } + case false => + ctx.ctx.isInstanceOf[SubscriptionUserContext] match { + case true => + CheckPermissionDeferred( + model = model, + field = field, + value = value, + nodeId = item.id, + authenticatedRequest = ctx.ctx.asInstanceOf[SubscriptionUserContext].authenticatedRequest, + node = item, + alwaysQueryMasterDatabase = ctx.ctx.mutationQueryWhitelist.isMutationQuery + ) + case false => value + } + } + } + } + + def unwrapDataItemFromContext[C <: RequestContextTrait](ctx: Context[C, DataItem]) = { + // note: ctx.value is sometimes of type Some[DataItem] at runtime even though the type is DataItem + //metacounts of relations being required or not is one cause see RequiredRelationMetaQueriesSpec + // todo: figure out why and fix issue at source + ctx.value.asInstanceOf[Any] match { + case Some(x: DataItem) => x + case x: DataItem => x + case None => throw new Exception("Resolved DataItem was None. This is unexpected - please investigate why and fix.") + } + } +} + +object SchemaModelObjectTypesBuilder { + + // todo: this entire thing should rely on GraphcoolDataTypes instead + def convertScalarFieldValueFromDatabase(field: models.Field, item: DataItem, resolver: Boolean = false): Any = { + field.name match { + case "id" if resolver && item.userData.contains("id") => item.userData("id").getOrElse(None) + case "id" => item.id + case _ => + (item(field.name), field.isList) match { + case (None, _) => + if (field.isRequired) { + // todo: handle this case + } + None + case (Some(value), true) => + def mapTo[T](value: Any, convert: JsValue => T): Seq[T] = { + value match { + case x: String => + Try { + x.parseJson.asInstanceOf[JsArray].elements.map(convert) + } match { + case Success(x) => x + case Failure(e) => e.printStackTrace(); Vector.empty + } + + case x: Vector[_] => + x.map(_.asInstanceOf[T]) + } + } + + field.typeIdentifier match { + case TypeIdentifier.String => mapTo(value, x => x.convertTo[String]) + case TypeIdentifier.Int => mapTo(value, x => x.convertTo[Int]) + case TypeIdentifier.Float => mapTo(value, x => x.convertTo[Double]) + case TypeIdentifier.Boolean => mapTo(value, x => x.convertTo[Boolean]) + case TypeIdentifier.GraphQLID => mapTo(value, x => x.convertTo[String]) + case TypeIdentifier.Password => mapTo(value, x => x.convertTo[String]) + case TypeIdentifier.DateTime => mapTo(value, x => new DateTime(x.convertTo[String], DateTimeZone.UTC)) + case TypeIdentifier.Enum => mapTo(value, x => x.convertTo[String]) + case TypeIdentifier.Json => mapTo(value, x => x.convertTo[JsValue]) + } + case (Some(value), false) => + def mapTo[T](value: Any) = value.asInstanceOf[T] + + field.typeIdentifier match { + case TypeIdentifier.String => mapTo[String](value) + case TypeIdentifier.Int => mapTo[Int](value) + case TypeIdentifier.Float => mapTo[Double](value) + case TypeIdentifier.Boolean => mapTo[Boolean](value) + case TypeIdentifier.GraphQLID => mapTo[String](value) + case TypeIdentifier.Password => mapTo[String](value) + case TypeIdentifier.DateTime => + value.isInstanceOf[DateTime] match { + case true => value + case false => + value.isInstanceOf[java.sql.Timestamp] match { + case true => + DateTime.parse(value.asInstanceOf[java.sql.Timestamp].toString, + DateTimeFormat + .forPattern("yyyy-MM-dd HH:mm:ss.SSS") + .withZoneUTC()) + case false => new DateTime(value.asInstanceOf[String], DateTimeZone.UTC) + } + } + case TypeIdentifier.Enum => mapTo[String](value) + case TypeIdentifier.Json => mapTo[JsValue](value) + } + } + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleOutputMapper.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleOutputMapper.scala new file mode 100644 index 0000000000..ce77091434 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleOutputMapper.scala @@ -0,0 +1,182 @@ +package cool.graph.client.schema.simple + +import cool.graph.DataItem +import cool.graph.client.UserContext +import cool.graph.client.schema.{ModelMutationType, OutputMapper} +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Field, Model, Project, Relation} +import sangria.schema +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +case class SimpleOutputMapper(project: Project, modelObjectTypes: Map[String, ObjectType[UserContext, DataItem]])(implicit inj: Injector) + extends OutputMapper + with Injectable { + + def nodePaths(model: Model) = List(List()) + + def mapOutputType[C](model: Model, objectType: ObjectType[C, DataItem], onlyId: Boolean): ObjectType[C, SimpleResolveOutput] = { + ObjectType[C, SimpleResolveOutput]( + name = objectType.name, + fieldsFn = () => { + objectType.ownFields.toList + .filter(field => if (onlyId) field.name == "id" else true) + .map { field => + field.copy( + resolve = { outerCtx: Context[C, SimpleResolveOutput] => + val castedCtx = outerCtx.asInstanceOf[Context[C, DataItem]] + field.resolve(castedCtx.copy(value = outerCtx.value.item)) + } + ) + } + } + ) + } + + def mapPreviousValuesOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, DataItem] = { + def isIncluded(outputType: OutputType[_]): Boolean = { + outputType match { + case _: ScalarType[_] | _: EnumType[_] => true + case ListType(x) => isIncluded(x) + case OptionType(x) => isIncluded(x) + case _ => false + } + } + val fields = objectType.ownFields.toList.collect { + case field if isIncluded(field.fieldType) => + field.copy( + resolve = (outerCtx: Context[C, DataItem]) => field.resolve(outerCtx) + ) + } + + ObjectType[C, DataItem]( + name = s"${objectType.name}PreviousValues", + fieldsFn = () => fields + ) + } + + override def mapCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, SimpleResolveOutput] = { + mapOutputType(model, objectType, false) + } + + override def mapUpdateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, SimpleResolveOutput] = { + mapOutputType(model, objectType, false) + } + + override def mapUpdateOrCreateOutputType[C](model: Model, objectType: ObjectType[C, DataItem]): ObjectType[C, SimpleResolveOutput] = { + mapOutputType(model, objectType, false) + } + + override def mapSubscriptionOutputType[C]( + model: Model, + objectType: ObjectType[C, DataItem], + updatedFields: Option[List[String]] = None, + mutation: ModelMutationType = cool.graph.shared.models.ModelMutationType.Created, + previousValues: Option[DataItem] = None, + dataItem: Option[SimpleResolveOutput] = None + ): ObjectType[C, SimpleResolveOutput] = { + ObjectType[C, SimpleResolveOutput]( + name = s"${model.name}SubscriptionPayload", + fieldsFn = () => + List( + schema.Field( + name = "mutation", + fieldType = ModelMutationType.Type, + description = None, + arguments = List(), + resolve = (outerCtx: Context[C, SimpleResolveOutput]) => mutation + ), + schema.Field( + name = "node", + fieldType = OptionType(mapOutputType(model, objectType, false)), + description = None, + arguments = List(), + resolve = (parentCtx: Context[C, SimpleResolveOutput]) => + dataItem match { + case None => + Some(parentCtx.value) + case Some(x) => + None + } + ), + schema.Field( + name = "updatedFields", + fieldType = OptionType(ListType(StringType)), + description = None, + arguments = List(), + resolve = (outerCtx: Context[C, SimpleResolveOutput]) => updatedFields + ), + schema.Field( + name = "previousValues", + fieldType = OptionType(mapPreviousValuesOutputType(model, objectType)), + description = None, + arguments = List(), + resolve = (outerCtx: Context[C, SimpleResolveOutput]) => previousValues + ) + ) + ) + } + + override def mapDeleteOutputType[C](model: Model, objectType: ObjectType[C, DataItem], onlyId: Boolean): ObjectType[C, SimpleResolveOutput] = + mapOutputType(model, objectType, onlyId) + + override type R = SimpleResolveOutput + + override def mapResolve(item: DataItem, args: Args): SimpleResolveOutput = + SimpleResolveOutput(item, args) + + override def mapAddToRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, SimpleResolveOutput] = + ObjectType[C, SimpleResolveOutput]( + name = s"${payloadName}Payload", + () => fields[C, SimpleResolveOutput](connectionFields(relation, fromModel, fromField, toModel, objectType): _*) + ) + + override def mapRemoveFromRelationOutputType[C](relation: Relation, + fromModel: Model, + fromField: Field, + toModel: Model, + objectType: ObjectType[C, DataItem], + payloadName: String): ObjectType[C, SimpleResolveOutput] = + ObjectType[C, SimpleResolveOutput]( + name = s"${payloadName}Payload", + () => fields[C, SimpleResolveOutput](connectionFields(relation, fromModel, fromField, toModel, objectType): _*) + ) + + def connectionFields[C](relation: Relation, + fromModel: Model, + fromField: Field, + toModel: Model, + objectType: ObjectType[C, DataItem]): List[sangria.schema.Field[C, SimpleResolveOutput]] = + List( + schema.Field[C, SimpleResolveOutput, Any, Any](name = relation.bName(project), + fieldType = OptionType(objectType), + description = None, + arguments = List(), + resolve = ctx => { + ctx.value.item + }), + schema.Field[C, SimpleResolveOutput, Any, Any]( + name = relation.aName(project), + fieldType = OptionType(modelObjectTypes(fromField.relatedModel(project).get.name)), + description = None, + arguments = List(), + resolve = ctx => { + val mutationKey = s"${fromField.relation.get.aName(project = project)}Id" + ctx.ctx + .asInstanceOf[UserContext] + .mutationDataresolver + .resolveByUnique(toModel, "id", ctx.value.args.arg[String](mutationKey)) + .map(_.get) + } + ) + ) +} + +case class SimpleResolveOutput(item: DataItem, args: Args) diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimplePermissionModelObjectTypesBuilder.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimplePermissionModelObjectTypesBuilder.scala new file mode 100644 index 0000000000..eacc7f876b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimplePermissionModelObjectTypesBuilder.scala @@ -0,0 +1,25 @@ +package cool.graph.client.schema.simple + +import cool.graph.DataItem +import cool.graph.client.UserContext +import cool.graph.shared.models +import sangria.schema._ +import scaldi.Injector + +class SimplePermissionModelObjectTypesBuilder(project: models.Project)(implicit inj: Injector) extends SimpleSchemaModelObjectTypeBuilder(project) { + + val leafField = + Field(name = "__leaf__", fieldType = StringType, description = Some("Dummy"), arguments = List[Argument[Any]](), resolve = (context: Context[_, _]) => "") + .asInstanceOf[Field[UserContext, DataItem]] + + override def modelToObjectType(model: models.Model): ObjectType[UserContext, DataItem] = + ObjectType( + model.name, + description = model.description.getOrElse(model.name), + fieldsFn = () => + model.fields + .filter(x => !x.isScalar) + .map(mapClientField(model)) :+ leafField + ) + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaModelObjectTypeBuilder.scala b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaModelObjectTypeBuilder.scala new file mode 100644 index 0000000000..e3ebc5a750 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/client/schema/simple/SimpleSchemaModelObjectTypeBuilder.scala @@ -0,0 +1,76 @@ +package cool.graph.client.schema.simple + +import cool.graph.DataItem +import cool.graph.client.database.DeferredTypes.{CountToManyDeferred, SimpleConnectionOutputType} +import cool.graph.client.database.QueryArguments +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.client.{SangriaQueryArguments, UserContext} +import cool.graph.shared.models +import cool.graph.shared.models.Field +import sangria.schema._ +import scaldi.Injector + +class SimpleSchemaModelObjectTypeBuilder(project: models.Project, + nodeInterface: Option[InterfaceType[UserContext, DataItem]] = None, + modelPrefix: String = "", + withRelations: Boolean = true, + onlyId: Boolean = false)(implicit inj: Injector) + extends SchemaModelObjectTypesBuilder[SimpleConnectionOutputType]( + project, + nodeInterface, + modelPrefix = modelPrefix, + withRelations = withRelations, + onlyId = onlyId + ) { + + val metaObjectType = sangria.schema.ObjectType( + "_QueryMeta", + description = "Meta information about the query.", + fields = sangria.schema.fields[UserContext, DataItem]( + sangria.schema + .Field(name = "count", fieldType = sangria.schema.IntType, resolve = _.value.get[CountToManyDeferred]("count")) + ) + ) + + override def resolveConnection(field: Field): OutputType[Any] = { + field.isList match { + case true => + ListType(modelObjectTypes.get(field.relatedModel(project).get.name).get) + case false => + modelObjectTypes.get(field.relatedModel(project).get.name).get + } + } + + override def mapMetaRelationField(model: models.Model)(field: models.Field): Option[sangria.schema.Field[UserContext, DataItem]] = { + + (field.relation, field.isList) match { + case (Some(_), true) => + val inputArguments = mapToListConnectionArguments(model, field) + + Some( + sangria.schema.Field( + s"_${field.name}Meta", + fieldType = metaObjectType, + description = Some("Meta information about the query."), + arguments = mapToListConnectionArguments(model, field), + resolve = (ctx: Context[UserContext, DataItem]) => { + + val item: DataItem = unwrapDataItemFromContext(ctx) + + val queryArguments: Option[QueryArguments] = + extractQueryArgumentsFromContext(field.relatedModel(project).get, ctx.asInstanceOf[Context[UserContext, Unit]]) + + val countArgs: Option[QueryArguments] = + queryArguments.map(args => SangriaQueryArguments.createSimpleQueryArguments(None, None, None, None, None, args.filter, None)) + + val countDeferred: CountToManyDeferred = CountToManyDeferred(field, item.id, countArgs) + + DataItem(id = "meta", userData = Map[String, Option[Any]]("count" -> Some(countDeferred))) + }, + tags = List() + )) + case _ => None + } + + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/Action.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/Action.scala new file mode 100644 index 0000000000..655b95077b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/Action.scala @@ -0,0 +1,49 @@ +package cool.graph.deprecated + +import com.amazonaws.services.kinesis.AmazonKinesisClient +import cool.graph.shared.models.{Model, Relation} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object ModelMutationType extends Enumeration { + val Created, Updated, Deleted = Value +} + +object RelationMutationType extends Enumeration { + val Added, Removed = Value +} + +abstract class ActionTrigger { + def getPayload: Future[Option[String]] +} + +class ModelMutationTrigger(model: Model, mutationType: ModelMutationType.Value, fragment: String) extends ActionTrigger { + + def getPayload: Future[Option[String]] = { + Future.successful(Some("model")) + } +} + +class RelationMutationTrigger(relation: Relation, mutationType: RelationMutationType.Value, fragment: String) extends ActionTrigger { + + def getPayload: Future[Option[String]] = { + Future.successful(Some("relation")) + } +} + +class Action(trigger: ActionTrigger, handler: ActionHandler, isActive: Boolean) { + def run(): Future[Unit] = { + trigger.getPayload.flatMap(handler.run) + } +} + +abstract class ActionHandler { + def run(payload: Option[String]): Future[Unit] +} + +class WebhookActionHandler(url: String, kinesis: AmazonKinesisClient) extends ActionHandler { + def run(payload: Option[String]): Future[Unit] = { + Future.successful(()) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackEvent.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackEvent.scala new file mode 100644 index 0000000000..a20c9af4fe --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackEvent.scala @@ -0,0 +1,10 @@ +package cool.graph.deprecated.actions + +import cool.graph.deprecated.actions.EventJsonProtocol.jsonFormat4 +import spray.json.{DefaultJsonProtocol, JsObject} + +case class MutationCallbackEvent(id: String, url: String, payload: String, headers: JsObject = JsObject.empty) + +object EventJsonProtocol extends DefaultJsonProtocol { + implicit val mutationCallbackEventFormat = jsonFormat4(MutationCallbackEvent) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/ActionUserContext.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/ActionUserContext.scala new file mode 100644 index 0000000000..5b89473ceb --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/ActionUserContext.scala @@ -0,0 +1,28 @@ +package cool.graph.deprecated.actions.schemas + +import akka.actor.ActorRef +import cool.graph.RequestContextTrait +import cool.graph.client.database.ProjectDataresolver +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.models.Project +import scaldi.{Injectable, Injector} + +case class ActionUserContext(project: Project, requestId: String, nodeId: String, mutation: MutationMetaData, log: Function[String, Unit])( + implicit inj: Injector) + extends RequestContextTrait + with Injectable { + + override val projectId: Option[String] = Some(project.id) + override val clientId = project.ownerId + override val requestIp = "mutation-callback-ip" + + val cloudwatch = + inject[Cloudwatch]("cloudwatch") + + val dataResolver = { + val resolver = new ProjectDataresolver(project = project, requestContext = this) + resolver.enableMasterDatabaseOnlyMode + resolver + } + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/CreateSchema.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/CreateSchema.scala new file mode 100644 index 0000000000..3af333f462 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/CreateSchema.scala @@ -0,0 +1,30 @@ +package cool.graph.deprecated.actions.schemas + +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.models.{Model, Project} +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +class CreateSchema[ManyDataItemType](model: Model, project: Project, modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType])(implicit inj: Injector) + extends Injectable { + + val createdModelField: Field[ActionUserContext, Unit] = Field( + "createdNode", + description = Some("The newly created node"), + fieldType = modelObjectTypes.modelObjectTypes(model.name), + resolve = (ctx) => { + ctx.ctx.dataResolver.resolveByUnique(model, "id", ctx.ctx.nodeId) map (_.get) + } + ) + + def build(): Schema[ActionUserContext, Unit] = { + val Query = ObjectType( + "Query", + List(createdModelField) + ) + + Schema(Query) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/DeleteSchema.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/DeleteSchema.scala new file mode 100644 index 0000000000..b13baeae17 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/DeleteSchema.scala @@ -0,0 +1,44 @@ +package cool.graph.deprecated.actions.schemas + +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.models.{Model, Project} +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +class DeleteSchema[ManyDataItemType](model: Model, project: Project, modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType])(implicit inj: Injector) + extends Injectable { + + val deletedModelField: Field[ActionUserContext, Unit] = Field( + "deletedNode", + description = Some("The deleted model"), + fieldType = modelObjectTypes.modelObjectTypes(model.name), + resolve = (ctx) => ctx.ctx.dataResolver.resolveByUnique(model, "id", ctx.ctx.nodeId) map (_.get) + ) + + val mutationFieldType: ObjectType[Unit, MutationMetaData] = ObjectType( + model.name, + description = "Mutation meta information", + fields = fields[Unit, MutationMetaData]( + Field("id", fieldType = IDType, description = Some("Mutation id for logging purposes"), resolve = _.value.id), + Field("type", fieldType = StringType, description = Some("Type of the mutation"), resolve = _.value._type) + ) + ) + + val mutationField: Field[ActionUserContext, Unit] = Field( + "mutation", + description = Some("Mutation meta information"), + fieldType = mutationFieldType, + resolve = _.ctx.mutation + ) + + def build(): Schema[ActionUserContext, Unit] = { + val Query = ObjectType( + "Query", + List(deletedModelField) + ) + + Schema(Query) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/MutationMetaData.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/MutationMetaData.scala new file mode 100644 index 0000000000..d9e9530686 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/MutationMetaData.scala @@ -0,0 +1,5 @@ +package cool.graph.deprecated.actions.schemas + +case class MutationMetaData(id: String, _type: String) + +object MutationTypes {} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/UpdateSchema.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/UpdateSchema.scala new file mode 100644 index 0000000000..a2d805aee7 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/actions/schemas/UpdateSchema.scala @@ -0,0 +1,65 @@ +package cool.graph.deprecated.actions.schemas + +import cool.graph.DataItem +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.shared.models.{Model, Project} +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global + +class UpdateSchema[ManyDataItemType](model: Model, + project: Project, + modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType], + updatedFields: List[String], + previousValues: DataItem)(implicit inj: Injector) + extends Injectable { + + val updatedModelField: Field[ActionUserContext, Unit] = Field( + "updatedNode", + description = Some("The updated node"), + fieldType = modelObjectTypes.modelObjectTypes(model.name), + resolve = (ctx) => ctx.ctx.dataResolver.resolveByUnique(model, "id", ctx.ctx.nodeId) map (_.get) + ) + + val mutationFieldType: ObjectType[Unit, MutationMetaData] = ObjectType( + model.name, + description = "Mutation meta information", + fields = fields[Unit, MutationMetaData]( + Field("id", fieldType = IDType, description = Some("Mutation id for logging purposes"), resolve = _.value.id), + Field("type", fieldType = StringType, description = Some("Type of the mutation"), resolve = _.value._type) + ) + ) + + val mutationField: Field[ActionUserContext, Unit] = Field( + "mutation", + description = Some("Mutation meta information"), + fieldType = mutationFieldType, + resolve = _.ctx.mutation + ) + + val changedFieldsField: Field[ActionUserContext, Unit] = Field( + "changedFields", + description = Some("List of all names of the fields which changed"), + fieldType = ListType(StringType), + resolve = _ => updatedFields + ) + + val previousValuesField: Field[ActionUserContext, Unit] = Field( + "previousValues", + description = Some("Previous scalar values"), + fieldType = new SimpleSchemaModelObjectTypeBuilder(project, withRelations = false, modelPrefix = "PreviousValues_") + .modelObjectTypes(model.name), + resolve = _ => previousValues + ) + + def build(): Schema[ActionUserContext, Unit] = { + val Query = ObjectType( + "Query", + List(updatedModelField, changedFieldsField, previousValuesField) + ) + + Schema(Query) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/FacebookAuthProvider.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/FacebookAuthProvider.scala new file mode 100644 index 0000000000..466bcd683e --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/FacebookAuthProvider.scala @@ -0,0 +1,42 @@ +package cool.graph.deprecated.packageMocks + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.{FunctionBinding, TypeIdentifier} + +object FacebookAuthProvider extends Package { + + val name = "TheAmazingFacebookAuthProvider" + val version = ("0." * 20) + "1-SNAPSHOT" + + /* + interface FacebookUser { + facebookUserId: String + isVerified: Boolean! @default(value="true") + } + */ + + lazy val interfaces = List(facebookUserInterface) + + lazy val facebookUserInterface = { + Interface("FacebookUser", List(facebookUserIdField, isVerifiedField)) + } + + lazy val facebookUserIdField = + InterfaceField("facebookUserId", TypeIdentifier.String, "The id Facebook uses to identify the user", isUnique = true, isRequired = false) + + lazy val isVerifiedField = InterfaceField("isVerified", + TypeIdentifier.Boolean, + "Is true if the users identity has been verified", + isUnique = false, + isRequired = true, + defaultValue = Some("true")) + + val authLambda = ServerlessFunction( + name = "authenticateFacebookUser", + input = List(InterfaceField("fbToken", TypeIdentifier.String, "", isUnique = false, isRequired = true)), + output = List(InterfaceField("token", TypeIdentifier.String, "", isUnique = false, isRequired = true)), + binding = FunctionBinding.CUSTOM_MUTATION + ) + + def functions = List(authLambda) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageMock.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageMock.scala new file mode 100644 index 0000000000..43dc87cbb5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageMock.scala @@ -0,0 +1,219 @@ +package cool.graph.deprecated.packageMocks + +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Model, Project} + +import scala.util.Try + +case class InstallConfiguration( + namesForFields: Map[InterfaceField, String], + modelsForInterfaces: Map[Interface, String], + namesForInterfaces: Map[Interface, String], + urlsForFunctions: Map[ServerlessFunction, String], + project: Project, + pat: String +) { + + def fieldNameFor(field: InterfaceField): Option[String] = namesForFields.get(field) + + def modelForInterface(interface: Interface): Model = project.getModelByName_!(modelsForInterfaces(interface)) + + def nameForInterface(interface: Interface): Option[String] = namesForInterfaces.get(interface) + + def urlForFunction(fn: ServerlessFunction): String = urlsForFunctions(fn) +} + +/** + * PACKAGE + */ +trait Package { + def name: String + def version: String + def interfaces: List[Interface] + def functions: List[Function] + + def install(config: InstallConfiguration): InstalledPackage = { + InstalledPackage( + originalPackage = Some(this), + interfaces = interfaces.map { interface => + interface.install(config) + }, + functions = functions.map { function => + function.install(config) + } + ) + } +} + +case class Interface(defaultName: String, fields: List[InterfaceField]) { + def install(config: InstallConfiguration): AppliedInterface = { + val installedFields = fields.map { field => + val fieldName: Option[String] = config.fieldNameFor(field) + field.install(name = fieldName) + } + AppliedInterface( + name = config.namesForInterfaces.getOrElse(this, defaultName), + model = config.modelForInterface(this), + originalInterface = Some(this), + fields = installedFields + ) + } +} + +case class InterfaceField(defaultName: String, + typeIdentifier: TypeIdentifier, + description: String, + isList: Boolean = false, + isUnique: Boolean = false, + isRequired: Boolean = false, + defaultValue: Option[String] = None) { + + def install(name: Option[String] = None): AppliedInterfaceField = { + AppliedInterfaceField(name.getOrElse(defaultName), this) + } +} + +sealed trait Function { + def name: String + def binding: FunctionBinding + def input: List[InterfaceField] + def output: List[InterfaceField] + + def install(config: InstallConfiguration): AppliedFunction +} +case class InlineFunction(script: String, name: String, binding: FunctionBinding, input: List[InterfaceField], output: List[InterfaceField]) extends Function { + + def install(config: InstallConfiguration): AppliedInlineFunction = { + AppliedInlineFunction( + script = script, + binding = binding, + name = name, + input = input.map(field => field.install(config.fieldNameFor(field))), + output = output.map(field => field.install(config.fieldNameFor(field))), + pat = config.pat + ) + } +} + +case class ServerlessFunction(name: String, binding: FunctionBinding, input: List[InterfaceField], output: List[InterfaceField]) extends Function { + def install(config: InstallConfiguration): AppliedServerlessFunction = { + AppliedServerlessFunction( + url = config.urlsForFunctions(this), + binding = binding, + name = name, + input = input.map(field => field.install(config.fieldNameFor(field))), + output = output.map(field => field.install(config.fieldNameFor(field))), + pat = config.pat + ) + } +} + +/** + * INSTALLED PACKAGE + */ +case class InstalledPackage(originalPackage: Option[Package], interfaces: List[AppliedInterface], functions: List[AppliedFunction]) { + + def function(binding: FunctionBinding): List[AppliedFunction] = functions.filter(_.binding == binding) + + def interfacesFor(model: Model): List[AppliedInterface] = interfaces.filter(_.model.name == model.name) +} + +case class AppliedInterface(name: String, model: Model, originalInterface: Option[Interface], fields: List[AppliedInterfaceField]) + +case class AppliedInterfaceField(name: String, originalInterfaceField: InterfaceField) { + + def typeIdentifier: TypeIdentifier = originalInterfaceField.typeIdentifier + def description: String = originalInterfaceField.description + def isUnique: Boolean = originalInterfaceField.isUnique + def isRequired: Boolean = originalInterfaceField.isRequired + def defaultValue: Option[String] = originalInterfaceField.defaultValue + def isList: Boolean = originalInterfaceField.isList +} + +sealed trait AppliedFunction { + def name: String + def binding: FunctionBinding + def input: List[AppliedInterfaceField] + def output: List[AppliedInterfaceField] + def pat: String + def context: Map[String, Any] +} + +case class AppliedInlineFunction(script: String, + name: String, + binding: FunctionBinding, + input: List[AppliedInterfaceField], + output: List[AppliedInterfaceField], + pat: String, + context: Map[String, Any] = Map()) + extends AppliedFunction + +case class AppliedServerlessFunction(url: String, + name: String, + binding: FunctionBinding, + input: List[AppliedInterfaceField], + output: List[AppliedInterfaceField], + pat: String, + context: Map[String, Any] = Map(), + requestPipelineModelId: Option[String] = None, + requestPipelineOperation: Option[RequestPipelineOperation] = None, + headers: Seq[(String, String)] = Seq.empty, + id: Option[String] = None) + extends AppliedFunction + +object PackageMock { + def getInstalledPackagesForProject(project: Project): List[InstalledPackage] = { + + def facebookMockConfig(pat: String) = InstallConfiguration( + namesForFields = Map( + FacebookAuthProvider.facebookUserIdField -> "facebookUserId" + ), + modelsForInterfaces = Map( + FacebookAuthProvider.facebookUserInterface -> "User" + ), + namesForInterfaces = Map( + FacebookAuthProvider.facebookUserInterface -> "FacebookUser" + ), + urlsForFunctions = Map( + FacebookAuthProvider.authLambda -> "https://cmwww7ara1.execute-api.eu-west-1.amazonaws.com/dev/facebook-auth-provider/authenticateFacebookUser" + ), + project, + pat + ) + + Try( + project.id match { + // soren - test project + case "cj09q7rok00hmxt00j4gteslw" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0ODk2NzUwODYsImNsaWVudElkIjoiY2lubThhOHJuMDAwMmZpcWNvMDJkMWNlOSIsInByb2plY3RJZCI6ImNqMDlxN3JvazAwaG14dDAwajRndGVzbHciLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMGNpMzE5NTA0YXdwaTAwNGRpZThmdzYifQ.gNSw0X43JrQaDFSx9lCZ4L6ppIt8JYxtMRqnT7FviF0"))) + // Mvp Space - LingoBites + case "ciyx06u900lk8016093sfx201" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0ODk3ODA1MjAsImNsaWVudElkIjoiY2l6anh5ZG5tdnVibzAxNzJpOWxiM3ozaSIsInByb2plY3RJZCI6ImNpeXgwNnU5MDBsazgwMTYwOTNzZngyMDEiLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMGU4dXVyMTAzcW8wMTE2cGsybGQ0MnEifQ.VSBfHSQvtO8ttR9hN6J99BmOzx3ENS4jKwy91v4GCgc"))) + // Martin Adams - LifePurposeApp + case "ciy0lc7u302ov0119p56aari0" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0ODk3OTQ4ODIsImNsaWVudElkIjoiY2l4dXAzZzJwMG5lMzAxMThvdTI0d2s1ZyIsInByb2plY3RJZCI6ImNpeTBsYzd1MzAyb3YwMTE5cDU2YWFyaTAiLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMGVoZW9jNDAxd2QwMTQycnp6NzkzMGkifQ.z4Ba5hm5rgpnGqu1SNAiDSeOJ_YkTDE-6aMe4ioRPWs"))) + // Jimmy Chan - Wallo + case "cizpelivr0y2u0175qqj4cxth" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0OTIyNzc2OTIsImNsaWVudElkIjoiY2l6cGQ5eTNlMGE0YzAxNzVsejgyd3hveCIsInByb2plY3RJZCI6ImNpenBlbGl2cjB5MnUwMTc1cXFqNGN4dGgiLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMWpqbHdxZTJjMHkwMTY5eHMwdzY2N2IifQ.pRyPDNOn3TBy_8XClIbodASmgf2H2dcOfuH2zkz6k1w"))) + case "cizpel9if0xqa0175hyme165a" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0OTIyNzc3NDksImNsaWVudElkIjoiY2l6cGQ5eTNlMGE0YzAxNzVsejgyd3hveCIsInByb2plY3RJZCI6ImNpenBlbDlpZjB4cWEwMTc1aHltZTE2NWEiLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMWpqbjRsbDJkYWQwMTY5dGx5NnB5MzYifQ.3Xh-ouEMxLxOv8gFYQY9wu0sqWxoUXrXDZnaVokgfhk"))) + case "cizpekk9o0x9x01734fs3zv90" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0OTIyNzc4MzYsImNsaWVudElkIjoiY2l6cGQ5eTNlMGE0YzAxNzVsejgyd3hveCIsInByb2plY3RJZCI6ImNpenBla2s5bzB4OXgwMTczNGZzM3p2OTAiLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMWpqcDAxNzJnaWowMTY5b2VvMmlmZjIifQ.fIGXRAL8LaAecolVsbdIAwqWg1gYCkUe9mHPVCkTmKM"))) + case "cj1nbfd430mgb0153mxosooo7" => + List(FacebookAuthProvider.install(facebookMockConfig( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0OTI3NjMwNDMsImNsaWVudElkIjoiY2l6cGQ5eTNlMGE0YzAxNzVsejgyd3hveCIsInByb2plY3RJZCI6ImNqMW5iZmQ0MzBtZ2IwMTUzbXhvc29vbzciLCJwZXJtYW5lbnRBdXRoVG9rZW5JZCI6ImNqMXJra254dGFlb2swMTM0MnBkaDc1MGYifQ.ZuHAMWPgmWTRzk9Gd_c9P90SCc9YR1RgBZWAFlm3sEc"))) + case "project-with-facebook" => List(FacebookAuthProvider.install(facebookMockConfig(""))) + case _ => List() + } + ).getOrElse(List.empty) + } + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageParser.scala b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageParser.scala new file mode 100644 index 0000000000..9afcb47d08 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/deprecated/packageMocks/PackageParser.scala @@ -0,0 +1,105 @@ +package cool.graph.deprecated.packageMocks + +import cool.graph.shared.TypeInfo +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.{FunctionBinding, Project, TypeIdentifier} +import net.jcazevedo.moultingyaml._ +import net.jcazevedo.moultingyaml.DefaultYamlProtocol._ +import sangria.ast.{Argument, InterfaceTypeDefinition, ObjectTypeDefinition, Value} + +object PackageParser { + case class PackageDefinition(name: String, + functions: Map[String, FunctionDefinition], + interfaces: Map[String, InterfaceDefinition], + install: List[InstallDefinition]) + case class FunctionDefinition(schema: String, `type`: String, url: Option[String]) + case class InterfaceDefinition(schema: String) + case class InstallDefinition(`type`: String, binding: String, name: Option[String], onType: Option[String]) + + object PackageYamlProtocol extends DefaultYamlProtocol { + implicit val installFormat = yamlFormat4(InstallDefinition) + implicit val interfaceFormat = yamlFormat1(InterfaceDefinition) + implicit val functionFormat = yamlFormat3(FunctionDefinition) + implicit val PackageFormat = yamlFormat4(PackageDefinition) + } + + def parse(packageDefinition: String): PackageDefinition = { + import PackageYamlProtocol._ + + packageDefinition.parseYaml.convertTo[PackageDefinition] + } + + def install(packageDefinition: PackageDefinition, project: Project): InstalledPackage = { + val pat = get(project.rootTokens.find(_.name == packageDefinition.name).map(_.token), s"No PAT called '${packageDefinition.name}'") + + val installedFunctions = packageDefinition.install + .filter(_.`type` == "mutation") + .map(f => { + val boundName = f.binding.split('.')(1) + val boundFunction: FunctionDefinition = packageDefinition.functions(boundName) + AppliedServerlessFunction( + url = boundFunction.url.get, + name = f.name.getOrElse(boundName), + binding = FunctionBinding.CUSTOM_MUTATION, + input = fieldsFromInterface(boundFunction.schema, "input"), + output = fieldsFromInterface(boundFunction.schema, "output"), + pat = pat, + context = Map(("onType" -> f.onType.getOrElse(""))) + ) + }) + + val installedInterfaces = packageDefinition.install + .filter(_.`type` == "interface") + .map(i => { + val boundName = i.binding.split('.')(1) + val name = i.name.getOrElse(boundName) + val boundInterface = packageDefinition.interfaces(boundName) + val onType = + get(i.onType, s"You have to specify the 'onType' argument to define on what type the interface should be added") + val model = get(project.models.find(_.name == onType), s"Could not add interface '$name' to type '$onType' as it doesn't exist in your project") + + AppliedInterface(name = name, model = model, originalInterface = None, fieldsFromInterface(boundInterface.schema, boundName)) + }) + + InstalledPackage(originalPackage = None, functions = installedFunctions, interfaces = installedInterfaces) + } + + private def fieldsFromInterface(schema: String, interfaceName: String): List[AppliedInterfaceField] = { + + val ast = + sangria.parser.QueryParser.parse(schema) + val definitions = ast.get.definitions + def interfaceTypeDefinitions = definitions collect { + case x: InterfaceTypeDefinition => x + } + + val fields = get(interfaceTypeDefinitions.find(_.name == interfaceName).map(_.fields), s"no interface called '$interfaceName' in schema '$schema'") + + fields + .map(f => { + val defaultValue: Option[String] = f.directives + .find(_.name == "defaultValue") + .flatMap(_.arguments.find(_.name == "value").map(_.value.renderCompact)) + val typeInfo = TypeInfo.extract(f, None, Seq(), true) + AppliedInterfaceField( + name = f.name, + originalInterfaceField = InterfaceField( + defaultName = f.name, + typeIdentifier = typeInfo.typeIdentifier, + description = "", + isUnique = typeInfo.isUnique, + isRequired = typeInfo.isRequired, + isList = typeInfo.isList, + defaultValue = defaultValue + ) + ) + }) + .toList + } + + private def get[T](option: Option[T], error: String): T = option match { + case Some(model) => model + case None => + sys.error(error) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/ApiMatrix.scala b/server/backend-shared/src/main/scala/cool/graph/shared/ApiMatrix.scala new file mode 100644 index 0000000000..a0c707605b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/ApiMatrix.scala @@ -0,0 +1,52 @@ +package cool.graph.shared + +import cool.graph.shared.models.{Field, Model, Project, Relation} + +object ApiMatrixFactory { + def apply(fn: Project => DefaultApiMatrix): ApiMatrixFactory = new ApiMatrixFactory { + override def create(project: Project) = fn(project) + } +} + +trait ApiMatrixFactory { + def create(project: Project): DefaultApiMatrix +} + +case class DefaultApiMatrix(project: Project) { + def includeModel(modelName: String): Boolean = { + true + } + + def filterModels(models: List[Model]): List[Model] = { + models.filter(model => includeModel(model.name)) + } + + def filterModel(model: Model): Option[Model] = { + filterModels(List(model)).headOption + } + + def includeRelation(relation: Relation): Boolean = { + includeModel(relation.getModelA_!(project).name) && includeModel(relation.getModelB_!(project).name) + } + + def filterRelations(relations: List[Relation]): List[Relation] = { + relations.filter(relation => includeRelation(relation)) + } + + def filterNonRequiredRelations(relations: List[Relation]): List[Relation] = { + relations.filter(relation => { + val aFieldRequired = relation.getModelAField(project).exists(_.isRequired) + val bFieldRequired = relation.getModelBField(project).exists(_.isRequired) + + !aFieldRequired && !bFieldRequired + }) + } + + def includeField(field: Field): Boolean = { + field.isScalar || includeModel(field.relatedModel(project).get.name) + } + + def filterFields(fields: List[Field]): List[Field] = { + fields.filter(includeField) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/DatabaseConstraints.scala b/server/backend-shared/src/main/scala/cool/graph/shared/DatabaseConstraints.scala new file mode 100644 index 0000000000..c54b177655 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/DatabaseConstraints.scala @@ -0,0 +1,49 @@ +package cool.graph.shared + +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.shared.models.Field + +object NameConstraints { + def isValidEnumValueName(name: String): Boolean = name.length <= 191 && name.matches("^[A-Z][a-zA-Z0-9_]*$") + + def isValidDataItemId(id: String): Boolean = id.length <= 25 && id.matches("^[a-zA-Z0-9\\-_]*$") + + def isValidFieldName(name: String): Boolean = name.length <= 64 && name.matches("^[a-z][a-zA-Z0-9]*$") + + def isValidEnumTypeName(name: String): Boolean = name.length <= 64 && name.matches("^[A-Z][a-zA-Z0-9_]*$") + + def isValidModelName(name: String): Boolean = name.length <= 64 && name.matches("^[A-Z][a-zA-Z0-9]*$") + + def isValidRelationName(name: String): Boolean = name.length <= 64 && name.matches("^[A-Z][a-zA-Z0-9]*$") + + def isValidProjectName(name: String): Boolean = name.length <= 64 && name.matches("^[a-zA-Z][a-zA-Z0-9\\-_ ]*$") + + def isValidProjectAlias(alias: String): Boolean = + alias.length <= 64 && alias.matches("^[a-zA-Z0-9\\-_]*$") // we are abusing "" in UpdateProject as replacement for null + + def isValidFunctionName(name: String): Boolean = 1 <= name.length && name.length <= 64 && name.matches("^[a-zA-Z0-9\\-_]*$") +} + +object DatabaseConstraints { + def isValueSizeValid(value: Any, field: Field): Boolean = { + + // we can assume that `value` is already sane checked by the query-layer. we only check size here. + DatabaseMutationBuilder + .sqlTypeForScalarTypeIdentifier(isList = field.isList, typeIdentifier = field.typeIdentifier) match { + case "char(25)" => value.toString.length <= 25 + // at this level we know by courtesy of the type system that boolean, int and datetime won't be too big for mysql + case "boolean" | "int" | "datetime(3)" => true + case "text" | "mediumtext" => value.toString.length <= 262144 + // plain string is part before decimal point. if part after decimal point is longer than 30 characters, mysql will truncate that without throwing an error, which is fine + case "Decimal(65,30)" => + val asDouble = value match { + case x: Double => x + case x: String => x.toDouble + case x: BigDecimal => x.toDouble + case x: Any => sys.error("Received an invalid type here. Class: " + x.getClass.toString + " value: " + x.toString) + } + BigDecimal(asDouble).underlying().toPlainString.length <= 35 + case "varchar(191)" => value.toString.length <= 191 + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/RelationFieldMirrorColumn.scala b/server/backend-shared/src/main/scala/cool/graph/shared/RelationFieldMirrorColumn.scala new file mode 100644 index 0000000000..12a7b0b302 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/RelationFieldMirrorColumn.scala @@ -0,0 +1,15 @@ +package cool.graph.shared + +import cool.graph.shared.models.{Field, Project, Relation} + +object RelationFieldMirrorColumn { + def mirrorColumnName(project: Project, field: Field, relation: Relation): String = { + val fieldModel = project.getModelByFieldId_!(field.id) + val modelB = relation.modelBId + val modelA = relation.modelAId + fieldModel.id match { + case `modelA` => s"A_${field.name}" + case `modelB` => s"B_${field.name}" + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/SchemaSerializer.scala b/server/backend-shared/src/main/scala/cool/graph/shared/SchemaSerializer.scala new file mode 100644 index 0000000000..f3398ddcf5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/SchemaSerializer.scala @@ -0,0 +1,569 @@ +package cool.graph.shared + +import cool.graph.GCDataTypes.{GCJsonConverter, GCStringConverter, GCValue} +import cool.graph.shared.models.FieldConstraintType.FieldConstraintType +import cool.graph.shared.models.IntegrationName.IntegrationName +import cool.graph.shared.models.RelationSide.RelationSide +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models._ +import org.joda.time.DateTime +import org.joda.time.format.ISODateTimeFormat +import spray.json.{DefaultJsonProtocol, DeserializationException, JsString, JsValue, RootJsonFormat, _} + +import scala.util.Try + +object SchemaSerializer { + type ClientAndProjectIds = (Client, List[String]) + + class EnumJsonConverter[T <: scala.Enumeration](enu: T) extends RootJsonFormat[T#Value] { + override def write(obj: T#Value): JsValue = JsString(obj.toString) + + override def read(json: JsValue): T#Value = { + json match { + case JsString(txt) => enu.withName(txt) + case somethingElse => + throw DeserializationException(s"Expected a value from enum $enu instead of $somethingElse") + } + } + } + + object EnumFormats { + implicit val CustomerSourceConverter = new EnumJsonConverter(CustomerSource) + implicit val RegionConverter = new EnumJsonConverter(Region) + implicit val TypeIdentifierConverter = new EnumJsonConverter(TypeIdentifier) + implicit val RelationSideConverter = new EnumJsonConverter(RelationSide) + implicit val UserTypeConverter = new EnumJsonConverter(UserType) + implicit val CustomRuleConverter = new EnumJsonConverter(CustomRule) + implicit val ModelOperationConverter = new EnumJsonConverter(ModelOperation) + implicit val ActionTriggerTypeConverter = new EnumJsonConverter(ActionTriggerType) + implicit val ActionHandlerTypeConverter = new EnumJsonConverter(ActionHandlerType) + implicit val ActionTriggerMutationModelMutationTypeConverter = new EnumJsonConverter(ActionTriggerMutationModelMutationType) + implicit val ActionTriggerMutationRelationMutationTypeConverter = new EnumJsonConverter(ActionTriggerMutationRelationMutationType) + implicit val IntegrationNameConverter = new EnumJsonConverter(IntegrationName) + implicit val IntegrationTypeConverter = new EnumJsonConverter(IntegrationType) + implicit val SeatStatusConverter = new EnumJsonConverter(SeatStatus) + implicit val FieldConstraintTypeConverter = new EnumJsonConverter(FieldConstraintType) + implicit val FunctionBindingConverter = new EnumJsonConverter(FunctionBinding) + implicit val FunctionTypeConverter = new EnumJsonConverter(FunctionType) + implicit val RequestPipelineOperationConverter = new EnumJsonConverter(RequestPipelineOperation) + } + + object CaseClassFormats extends DefaultJsonProtocol { + import EnumFormats._ + + implicit object DateTimeFormat extends RootJsonFormat[DateTime] { + + val formatter = ISODateTimeFormat.basicDateTime + + def write(obj: DateTime): JsValue = { + JsString(formatter.print(obj)) + } + + def read(json: JsValue): DateTime = json match { + case JsString(s) => + try { + formatter.parseDateTime(s) + } catch { + case t: Throwable => error(s) + } + case _ => + error(json.toString()) + } + + def error(v: Any): DateTime = { + val example = formatter.print(0) + deserializationError(f"'$v' is not a valid date value. Dates must be in compact ISO-8601 format, e.g. '$example'") + } + } + + implicit lazy val projectDatabaseFormat = jsonFormat4(ProjectDatabase.apply) + implicit val enumFormat = jsonFormat3(Enum.apply) + implicit val relationFieldMirrorFormat = jsonFormat3(RelationFieldMirror.apply) + implicit val relationPermissionFormat = jsonFormat11(RelationPermission.apply) + implicit lazy val actionHandlerWebhookFormat = jsonFormat3(ActionHandlerWebhook.apply) + implicit lazy val actionTriggerMutationModelFormat = jsonFormat4(ActionTriggerMutationModel.apply) + implicit lazy val actionTriggerMutationRelationFormat = jsonFormat4(ActionTriggerMutationRelation.apply) + implicit lazy val actionFormat = jsonFormat8(Action.apply) + implicit lazy val permanentAuthTokenFormat = jsonFormat4(RootToken.apply) + implicit lazy val seatFormat = jsonFormat6(Seat.apply) + implicit lazy val packageDefinitionFormat = jsonFormat4(PackageDefinition.apply) + + implicit object FieldConstraintFormat extends RootJsonFormat[FieldConstraint] { + + implicit val stringConstraintFormat = + jsonFormat(StringConstraint.apply, + "id", + "fieldId", + "equalsString", + "oneOfString", + "minLength", + "maxLength", + "startsWith", + "endsWith", + "includes", + "regex") + + implicit val numberConstraintFormat = + jsonFormat(NumberConstraint.apply, "id", "fieldId", "equalsNumber", "oneOfNumber", "min", "max", "exclusiveMin", "exclusiveMax", "multipleOf") + + implicit val booleanConstraintFormat = jsonFormat(BooleanConstraint.apply, "id", "fieldId", "equalsBoolean") + + implicit val listConstraintFormat = + jsonFormat(ListConstraint.apply, "id", "fieldId", "uniqueItems", "minItems", "maxItems") + + private def addTypeDiscriminator(value: JsValue, constraintType: FieldConstraintType): JsValue = { + JsObject(value.asJsObject.fields + ("constraintType" -> constraintType.toJson)) + } + + def write(obj: FieldConstraint) = obj match { + case x: StringConstraint => addTypeDiscriminator(x.toJson, FieldConstraintType.STRING) + case x: NumberConstraint => addTypeDiscriminator(x.toJson, FieldConstraintType.NUMBER) + case x: BooleanConstraint => addTypeDiscriminator(x.toJson, FieldConstraintType.BOOLEAN) + case x: ListConstraint => addTypeDiscriminator(x.toJson, FieldConstraintType.LIST) + case unknown @ _ => serializationError(s"Marshalling issue with $unknown") + } + + def read(value: JsValue): FieldConstraint = { + val typeDiscriminator = value.asJsObject().fields("constraintType").convertTo[FieldConstraintType] + typeDiscriminator match { + case FieldConstraintType.STRING => value.asJsObject.convertTo[StringConstraint] + case FieldConstraintType.NUMBER => value.asJsObject.convertTo[NumberConstraint] + case FieldConstraintType.BOOLEAN => value.asJsObject.convertTo[BooleanConstraint] + case FieldConstraintType.LIST => value.asJsObject.convertTo[ListConstraint] + case unknown @ _ => deserializationError(s"Unmarshalling issue with $unknown ") + } + } + } + + implicit val relationFormat = jsonFormat7(Relation.apply) + + implicit object FieldFormat extends RootJsonFormat[Field] { + + def write(obj: Field) = { + + val convertedDefaultValue = obj.defaultValue.map(GCJsonConverter(obj.typeIdentifier, obj.isList).fromGCValue).getOrElse(JsNull) + JsObject( + "id" -> JsString(obj.id), + "name" -> JsString(obj.name), + "typeIdentifier" -> obj.typeIdentifier.toJson, + "description" -> obj.description.toJson, + "isRequired" -> JsBoolean(obj.isRequired), + "isList" -> JsBoolean(obj.isList), + "isUnique" -> JsBoolean(obj.isUnique), + "isSystem" -> JsBoolean(obj.isSystem), + "isReadonly" -> JsBoolean(obj.isReadonly), + "enum" -> obj.enum.toJson, + "defaultValue" -> convertedDefaultValue, + "relation" -> obj.relation.toJson, + "relationSide" -> obj.relationSide.toJson, + "constraints" -> obj.constraints.toJson + ) + } + + def read(value: JsValue): Field = { + val f = value.asJsObject.fields + val typeIdentifier = f("typeIdentifier").convertTo[TypeIdentifier] + val isList = f("isList").convertTo[Boolean] + + val defaultValue: Option[GCValue] = f("defaultValue") match { + case JsNull => None + case x: JsString => Some(GCStringConverter(typeIdentifier, isList).toGCValue(x.value).get) + case x: JsValue => Some(GCJsonConverter(typeIdentifier, isList).toGCValue(x).get) + } + + Field( + id = f("id").convertTo[String], + name = f("name").convertTo[String], + typeIdentifier = typeIdentifier, + description = f("description").convertTo[Option[String]], + isRequired = f("isRequired").convertTo[Boolean], + isList = isList, + isUnique = f("isUnique").convertTo[Boolean], + isSystem = f("isSystem").convertTo[Boolean], + isReadonly = f("isReadonly").convertTo[Boolean], + enum = f("enum").convertTo[Option[Enum]], + defaultValue = defaultValue, + relation = f("relation").convertTo[Option[Relation]], + relationSide = f("relationSide").convertTo[Option[RelationSide]], + constraints = f("constraints").convertTo[List[FieldConstraint]] + ) + } + } + + implicit val modelPermissionFormat = jsonFormat12(ModelPermission.apply) + + implicit object ModelFormat extends RootJsonFormat[Model] { + + def write(obj: Model) = { + JsObject( + "id" -> JsString(obj.id), + "name" -> JsString(obj.name), + "description" -> obj.description.toJson, + "isSystem" -> JsBoolean(obj.isSystem), + "fields" -> obj.fields.toJson, + "permissions" -> obj.permissions.toJson, + "fieldPositions" -> obj.fieldPositions.toJson + ) + } + + def read(value: JsValue): Model = { + val f = value.asJsObject.fields + + Model( + id = f("id").convertTo[String], + name = f("name").convertTo[String], + description = f("description").convertTo[Option[String]], + isSystem = f("isSystem").convertTo[Boolean], + fields = f("fields").convertTo[List[Field]], + permissions = f("permissions").convertTo[List[ModelPermission]], + fieldPositions = f("fieldPositions").convertTo[List[String]] + ) + } + } + + implicit object AuthProviderMetaInformationFormat extends RootJsonFormat[AuthProviderMetaInformation] { + implicit val authProviderAuth0Format = jsonFormat4(AuthProviderAuth0) + implicit val authProviderDigitsFormat = jsonFormat3(AuthProviderDigits) + + def write(obj: AuthProviderMetaInformation) = obj match { + case x: AuthProviderDigits => x.toJson + case y: AuthProviderAuth0 => y.toJson + } + + def read(value: JsValue): AuthProviderMetaInformation = { + value.asJsObject.fields.keys.exists(_ == "domain") match { + case true => value.asJsObject.convertTo[AuthProviderAuth0] + case false => value.asJsObject.convertTo[AuthProviderDigits] + } + } + } + + implicit val algoliaSyncQueryFormat = jsonFormat5(AlgoliaSyncQuery) + + implicit object AuthProviderFormat extends RootJsonFormat[AuthProvider] { + + def write(obj: AuthProvider) = { + JsObject( + "id" -> JsString(obj.id), + "subTableId" -> JsString(obj.subTableId), + "isEnabled" -> JsBoolean(obj.isEnabled), + "name" -> obj.name.toJson, + "metaInformation" -> obj.metaInformation.toJson + ) + } + + def read(value: JsValue): AuthProvider = { + val f = value.asJsObject.fields + + AuthProvider( + id = f("id").convertTo[String], + subTableId = f("subTableId").convertTo[String], + isEnabled = f("isEnabled").convertTo[Boolean], + name = f("name").convertTo[IntegrationName], + metaInformation = f("metaInformation").convertTo[Option[AuthProviderMetaInformation]] + ) + } + } + + implicit object SearchProviderAlgoliaFormat extends RootJsonFormat[SearchProviderAlgolia] { + + def write(obj: SearchProviderAlgolia) = { + JsObject( + "id" -> JsString(obj.id), + "subTableId" -> JsString(obj.subTableId), + "applicationId" -> JsString(obj.applicationId), + "apiKey" -> JsString(obj.apiKey), + "algoliaSyncQueries" -> obj.algoliaSyncQueries.toJson, + "isEnabled" -> JsBoolean(obj.isEnabled), + "name" -> obj.name.toJson + ) + } + + def read(value: JsValue): SearchProviderAlgolia = { + val f = value.asJsObject.fields + + SearchProviderAlgolia( + id = f("id").convertTo[String], + subTableId = f("subTableId").convertTo[String], + applicationId = f("applicationId").convertTo[String], + apiKey = f("apiKey").convertTo[String], + algoliaSyncQueries = f("algoliaSyncQueries").convertTo[List[AlgoliaSyncQuery]], + isEnabled = f("isEnabled").convertTo[Boolean], + name = f("name").convertTo[IntegrationName] + ) + } + } + + implicit object IntegrationFormat extends RootJsonFormat[Integration] { + + def write(obj: Integration) = obj match { + case x: AuthProvider => x.toJson + case y: SearchProviderAlgolia => y.toJson + case unknown @ _ => serializationError(s"Marshalling issue with $unknown") + } + + def read(value: JsValue): Integration = { + value.asJsObject.fields.keys.exists(_ == "algoliaSyncQueries") match { + case true => value.asJsObject.convertTo[SearchProviderAlgolia] + case false => value.asJsObject.convertTo[AuthProvider] + } + } + } + + implicit object Auth0FunctionFormat extends RootJsonFormat[Auth0Function] { + def write(obj: Auth0Function) = { + JsObject( + "code" -> JsString(obj.code), + "codeFilePath" -> obj.codeFilePath.toJson, + "auth0Id" -> JsString(obj.auth0Id), + "url" -> JsString(obj.url), + "headers" -> obj.headers.toJson + ) + } + + def read(value: JsValue): Auth0Function = { + val f = value.asJsObject.fields + + Auth0Function( + code = f("code").convertTo[String], + codeFilePath = f("codeFilePath").convertTo[Option[String]], + auth0Id = f("auth0Id").convertTo[String], + url = f("url").convertTo[String], + headers = f("headers").convertTo[Seq[(String, String)]] + ) + } + } + + implicit object WebhookFunctionFormat extends RootJsonFormat[WebhookFunction] { + def write(obj: WebhookFunction) = { + JsObject( + "url" -> JsString(obj.url), + "headers" -> obj.headers.toJson + ) + } + + def read(value: JsValue): WebhookFunction = { + val f = value.asJsObject.fields + + WebhookFunction( + url = f("url").convertTo[String], + headers = f("headers").convertTo[Seq[(String, String)]] + ) + } + } + + implicit object managedFunctionFormat extends RootJsonFormat[ManagedFunction] { + def write(obj: ManagedFunction) = { + obj.codeFilePath match { + case Some(codeFilePath) => + JsObject( + "codeFilePath" -> JsString(codeFilePath) + ) + case None => JsObject.empty + } + } + + def read(value: JsValue): ManagedFunction = { + val f = value.asJsObject.fields + + ManagedFunction( + codeFilePath = f.get("codeFilePath").map(_.convertTo[String]) + ) + } + } + + implicit object FunctionDeliveryFormat extends RootJsonFormat[FunctionDelivery] { + + def write(obj: FunctionDelivery) = obj match { + case x: Auth0Function => x.toJson + case y: WebhookFunction => y.toJson + case z: ManagedFunction => + z.codeFilePath match { + case Some(codeFilePath) => JsObject("_isCodeFunction" -> JsBoolean(true), "codeFilePath" -> JsString(codeFilePath)) + case None => JsObject("_isCodeFunction" -> JsBoolean(true)) + } + case unknown @ _ => serializationError(s"Marshalling issue with unknown function delivery: $unknown") + } + + def read(value: JsValue): FunctionDelivery = { + () match { + case _ if value.asJsObject.fields.keys.exists(_ == "auth0Id") => value.asJsObject.convertTo[Auth0Function] + case _ if value.asJsObject.fields.keys.exists(_ == "_isCodeFunction") => value.asJsObject.convertTo[ManagedFunction] + case _ => value.asJsObject.convertTo[WebhookFunction] + } + } + } + + implicit object FunctionFormat extends RootJsonFormat[Function] { + implicit val serversideSubscriptionFunctionFormat = jsonFormat6(ServerSideSubscriptionFunction) + implicit val requestPipelineFunctionFormat = jsonFormat7(RequestPipelineFunction) + implicit val freeTypeFormat = jsonFormat4(FreeType) + implicit val customMutationFunctionFormat = jsonFormat9(CustomMutationFunction.apply) + implicit val customQueryFunctionFormat = jsonFormat9(CustomQueryFunction.apply) + + def write(obj: Function) = obj match { + case obj: ServerSideSubscriptionFunction => + JsObject( + "id" -> obj.id.toJson, + "name" -> obj.name.toJson, + "isActive" -> obj.isActive.toJson, + "query" -> obj.query.toJson, + "queryFilePath" -> obj.queryFilePath.toJson, + "delivery" -> obj.delivery.toJson, + "binding" -> obj.binding.toJson + ) + + case obj: RequestPipelineFunction => + JsObject( + "id" -> obj.id.toJson, + "name" -> obj.name.toJson, + "isActive" -> obj.isActive.toJson, + "modelId" -> obj.modelId.toJson, + "delivery" -> obj.delivery.toJson, + "binding" -> obj.binding.toJson, + "operation" -> obj.operation.toJson + ) + + case obj: CustomMutationFunction => + JsObject( + "id" -> obj.id.toJson, + "name" -> obj.name.toJson, + "isActive" -> obj.isActive.toJson, + "schema" -> obj.schema.toJson, + "schemaFilePath" -> obj.schemaFilePath.toJson, + "delivery" -> obj.delivery.toJson, + "binding" -> obj.binding.toJson, + "mutationName" -> obj.mutationName.toJson, + "arguments" -> obj.arguments.toJson, + "payloadType" -> obj.payloadType.toJson + ) + + case obj: CustomQueryFunction => + JsObject( + "id" -> obj.id.toJson, + "name" -> obj.name.toJson, + "isActive" -> obj.isActive.toJson, + "schema" -> obj.schema.toJson, + "schemaFilePath" -> obj.schemaFilePath.toJson, + "delivery" -> obj.delivery.toJson, + "binding" -> obj.binding.toJson, + "queryName" -> obj.queryName.toJson, + "arguments" -> obj.arguments.toJson, + "payloadType" -> obj.payloadType.toJson + ) + + case unknown @ _ => serializationError(s"Marshalling issue with unknown function: $unknown") + } + + def read(value: JsValue): Function = { + val binding = value.asJsObject.fields.getOrElse("binding", sys.error(s"binding not present on function: ${value.prettyPrint}")) + + FunctionBinding.withName(binding.convertTo[String]) match { + case FunctionBinding.CUSTOM_QUERY => + value.asJsObject.convertTo[CustomQueryFunction] + + case FunctionBinding.CUSTOM_MUTATION => + value.asJsObject.convertTo[CustomMutationFunction] + + case FunctionBinding.TRANSFORM_REQUEST | FunctionBinding.PRE_WRITE | FunctionBinding.TRANSFORM_ARGUMENT | FunctionBinding.TRANSFORM_PAYLOAD => + value.asJsObject.convertTo[RequestPipelineFunction] + + case FunctionBinding.SERVERSIDE_SUBSCRIPTION => + value.asJsObject.convertTo[ServerSideSubscriptionFunction] + } + } + } + + implicit val featureToggleFormat = jsonFormat3(FeatureToggle) + + implicit object projectFormat extends RootJsonFormat[Project] { + + def write(obj: Project) = { + JsObject( + "id" -> JsString(obj.id), + "name" -> JsString(obj.name), + "projectDatabase" -> obj.projectDatabase.toJson, + "ownerId" -> obj.ownerId.toJson, + "alias" -> obj.alias.toJson, + "revision" -> obj.revision.toJson, + "webhookUrl" -> obj.webhookUrl.toJson, + "models" -> obj.models.toJson, + "relations" -> obj.relations.toJson, + "enums" -> obj.enums.toJson, + "actions" -> obj.actions.toJson, + "permanentAuthTokens" -> obj.rootTokens.toJson, + "integrations" -> obj.integrations.toJson, + "seats" -> obj.seats.toJson, + "allowQueries" -> obj.allowQueries.toJson, + "allowMutations" -> obj.allowMutations.toJson, + "packageDefinitions" -> obj.packageDefinitions.toJson, + "functions" -> obj.functions.toJson, + "featureToggles" -> obj.featureToggles.toJson, + "typePositions" -> obj.typePositions.toJson, + "isEjected" -> JsBoolean(obj.isEjected), + "hasGlobalStarPermission" -> JsBoolean(obj.hasGlobalStarPermission) + ) + } + + def read(value: JsValue): Project = { + val f = value.asJsObject.fields + + try { + Project( + id = f("id").convertTo[String], + name = f("name").convertTo[String], + projectDatabase = f("projectDatabase").convertTo[ProjectDatabase], + ownerId = f("ownerId").convertTo[String], + alias = f("alias").convertTo[Option[String]], + revision = f("revision").convertTo[Int], + webhookUrl = f("webhookUrl").convertTo[Option[String]], + models = f("models").convertTo[List[Model]], + relations = f("relations").convertTo[List[Relation]], + enums = f("enums").convertTo[List[Enum]], + actions = f("actions").convertTo[List[Action]], + rootTokens = f("permanentAuthTokens").convertTo[List[RootToken]], + integrations = f("integrations").convertTo[List[Integration]], + seats = f("seats").convertTo[List[Seat]], + allowQueries = f("allowQueries").convertTo[Boolean], + allowMutations = f("allowMutations").convertTo[Boolean], + packageDefinitions = f("packageDefinitions").convertTo[List[PackageDefinition]], + functions = f("functions").convertTo[List[Function]], + featureToggles = f("featureToggles").convertTo[List[FeatureToggle]], + typePositions = f("typePositions").convertTo[List[String]], + isEjected = f("isEjected").convertTo[Boolean], + hasGlobalStarPermission = f("hasGlobalStarPermission").convertTo[Boolean] + ) + } catch { + case e: Throwable => sys.error("Couldn't parse Project: " + e.getMessage) + } + } + } + + implicit val clientFormat: RootJsonFormat[Client] = jsonFormat11(Client.apply) + implicit val projectWithClientIdFormat = jsonFormat(ProjectWithClientId.apply, "project", "clientId") + } + + def serialize(projectWithClientId: ProjectWithClientId): String = { + import CaseClassFormats._ + + projectWithClientId.toJson.compactPrint + } + + def serialize(project: Project): String = { + import CaseClassFormats._ + + project.toJson.compactPrint + } + + def deserializeProjectWithClientId(string: String): Try[ProjectWithClientId] = { + import CaseClassFormats._ + + Try(string.parseJson.convertTo[ProjectWithClientId]) + } + + def deserializeProject(string: String): Try[Project] = { + import CaseClassFormats._ + + Try(string.parseJson.convertTo[Project]) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/TypeInfo.scala b/server/backend-shared/src/main/scala/cool/graph/shared/TypeInfo.scala new file mode 100644 index 0000000000..292d1ccd52 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/TypeInfo.scala @@ -0,0 +1,104 @@ +package cool.graph.shared + +import cool.graph.shared.errors.UserInputErrors.InvalidSchema +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Field => GraphcoolField, _} +import sangria.ast._ + +import scala.collection.Seq + +case class TypeInfo(typeIdentifier: TypeIdentifier, isList: Boolean, isRequired: Boolean, enumValues: List[String], typename: String, isUnique: Boolean) + +object TypeInfo { + def extract(f: FieldDefinition, relation: Option[Relation], enumTypeDefinitions: Seq[EnumTypeDefinition], allowNullsInScalarList: Boolean): TypeInfo = { + val isUnique = f.directives.exists(_.name == "isUnique") + + if (allowNullsInScalarList) { + extractWithNullListValues(f.fieldType, isUnique, relation, enumTypeDefinitions) + } else { + extract(f.fieldType, isUnique, relation, enumTypeDefinitions) + } + } + + def extract(f: InputValueDefinition, allowNullsInScalarList: Boolean): TypeInfo = { + val isUnique = f.directives.exists(_.name == "isUnique") + + if (allowNullsInScalarList) { + extractWithNullListValues(f.valueType, isUnique) + } else { + extract(f.valueType, isUnique) + } + } + + def extractWithNullListValues(tpe: Type, + isUnique: Boolean, + relation: Option[Relation] = None, + enumTypeDefinitions: Seq[EnumTypeDefinition] = Seq.empty): TypeInfo = tpe match { + case NamedType(name, _) => + create(typeName = name, isList = false, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case NotNullType(NamedType(name, _), _) => + create(typeName = name, isList = false, isRequired = true, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case ListType(NamedType(name, _), _) => + create(typeName = name, isList = true, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case ListType(NotNullType(NamedType(name, _), _), _) => + create(typeName = name, isList = true, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case NotNullType(ListType(NamedType(name, _), _), _) => + create(typeName = name, isList = true, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case NotNullType(ListType(NotNullType(NamedType(name, _), _), _), _) => + create(typeName = name, isList = true, isRequired = true, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case x => throw InvalidSchema(s"Invalid field type definition detected. ${x.toString}") + } + + def extract(tpe: Type, isUnique: Boolean, relation: Option[Relation] = None, enumTypeDefinitions: Seq[EnumTypeDefinition] = Seq.empty): TypeInfo = tpe match { + case NamedType(name, _) => + create(typeName = name, isList = false, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case NotNullType(NamedType(name, _), _) => + create(typeName = name, isList = false, isRequired = true, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case ListType(NotNullType(NamedType(name, _), _), _) => + create(typeName = name, isList = true, isRequired = false, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case NotNullType(ListType(NotNullType(NamedType(name, _), _), _), _) => + create(typeName = name, isList = true, isRequired = true, relation = relation, isUnique = isUnique, enumTypeDefinitions = enumTypeDefinitions) + + case x => throw InvalidSchema("Invalid field type definition detected. Valid field type formats: Int, Int!, [Int!], [Int!]! for example.") // add offending type and model/relation/field + } + + private def create(typeName: String, + isList: Boolean, + isRequired: Boolean, + relation: Option[Relation], + isUnique: Boolean, + enumTypeDefinitions: Seq[EnumTypeDefinition]): TypeInfo = { + val enum = enumTypeDefinitions.find(_.name == typeName) + val typeIdentifier = enum match { + case Some(_) => TypeIdentifier.Enum + case None => typeIdentifierFor(typeName) + } + + val enumValues = enum match { + case Some(enumType) => enumType.values.map(_.name).toList + case None => List.empty + } + + TypeInfo(typeIdentifier, isList, relation.isEmpty && isRequired, enumValues, typeName, isUnique) + } + + def typeIdentifierFor(name: String): TypeIdentifier.Value = { + if (name == "ID") { + TypeIdentifier.GraphQLID + } else { + TypeIdentifier.withNameOpt(name) match { + case Some(t) => t + case None => TypeIdentifier.Relation + } + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/adapters/HttpFunctionHeaders.scala b/server/backend-shared/src/main/scala/cool/graph/shared/adapters/HttpFunctionHeaders.scala new file mode 100644 index 0000000000..25e3b53220 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/adapters/HttpFunctionHeaders.scala @@ -0,0 +1,37 @@ +package cool.graph.shared.adapters + +import cool.graph.shared.errors.SystemErrors + +object HttpFunctionHeaders { + import cool.graph.util.json.Json._ + import spray.json.DefaultJsonProtocol.StringJsonFormat + import spray.json._ + + implicit val seqToJsObjectFormatter = new JsonFormat[Seq[(String, String)]] { + override def write(seq: Seq[(String, String)]): JsValue = { + val fields = seq.map { + case (key, value) => (key, JsString(value)) + } + JsObject(fields: _*) + } + + override def read(json: JsValue): Seq[(String, String)] = { + json.asJsObject.fields.map { + case (key, jsValue) => key -> jsValue.convertTo[String] + }.toSeq + } + } + + def read(headersJson: Option[String]): Seq[(String, String)] = { + val json = headersJson.getOrElse("{}") + json.tryParseJson.getOrElse(throw SystemErrors.InvalidFunctionHeader(json)).convertTo[Seq[(String, String)]] + } + + def readOpt(headersJson: Option[String]): Option[Seq[(String, String)]] = { + headersJson.map(_.parseJson.convertTo[Seq[(String, String)]]) + } + + def write(headers: Seq[(String, String)]): JsObject = { + headers.toJson.asJsObject + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/algolia/AlgoliaContext.scala b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/AlgoliaContext.scala new file mode 100644 index 0000000000..726ed808e5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/AlgoliaContext.scala @@ -0,0 +1,40 @@ +package cool.graph.shared.algolia + +import cool.graph.RequestContextTrait +import cool.graph.client.database.ProjectDataresolver +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.models.Project +import scaldi.{Injectable, Injector} + +case class AlgoliaContext(project: Project, requestId: String, nodeId: String, log: Function[String, Unit])(implicit inj: Injector) + extends RequestContextTrait + with Injectable { + + override val projectId: Option[String] = Some(project.id) + override val clientId = project.ownerId + override val requestIp = "algolia-ip" + + val cloudwatch = inject[Cloudwatch]("cloudwatch") + + val dataResolver = { + val resolver = new ProjectDataresolver(project = project, requestContext = this) + resolver.enableMasterDatabaseOnlyMode + resolver + } + +} + +case class AlgoliaFullModelContext(project: Project, requestId: String, log: Function[String, Unit])(implicit inj: Injector) + extends RequestContextTrait + with Injectable { + + override val projectId: Option[String] = Some(project.id) + override val clientId = project.ownerId + override val requestIp = "mutation-callback-ip" + + val cloudwatch = inject[Cloudwatch]("cloudwatch") + + // using the readonly replica here is fine as this doesn't happen in response to data changes + val dataResolver = + new ProjectDataresolver(project = project, requestContext = this) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/algolia/Types.scala b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/Types.scala new file mode 100644 index 0000000000..3d102fd9c2 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/Types.scala @@ -0,0 +1,17 @@ +package cool.graph.shared.algolia + +import spray.json._ + +object AlgoliaEventJsonProtocol extends DefaultJsonProtocol { + implicit val eventFormat: RootJsonFormat[AlgoliaEvent] = jsonFormat7(AlgoliaEvent) +} + +case class AlgoliaEvent( + indexName: String, + applicationId: String, + apiKey: String, + operation: String, + nodeId: String, + requestId: String, + queryResult: String +) diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaFullModelSchema.scala b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaFullModelSchema.scala new file mode 100644 index 0000000000..94c23384d1 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaFullModelSchema.scala @@ -0,0 +1,49 @@ +package cool.graph.shared.algolia.schemas + +import cool.graph.Types.DataItemFilterCollection +import cool.graph.client.database.QueryArguments +import cool.graph.client.SangriaQueryArguments +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.algolia.AlgoliaFullModelContext +import cool.graph.shared.models.{Model, Project} +import sangria.schema.{Field, ListType, ObjectType, Schema} +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContextExecutor + +class AlgoliaFullModelSchema[ManyDataItemType](project: Project, model: Model, modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType])( + implicit injector: Injector) + extends Injectable { + + implicit val dispatcher = + inject[ExecutionContextExecutor](identified by "dispatcher") + + val algoliaSyncField: Field[AlgoliaFullModelContext, Unit] = Field( + "node", + description = Some("The table to synchronize with Algolia."), + arguments = List(SangriaQueryArguments.filterArgument(model = model, project = project)), + fieldType = ListType(modelObjectTypes.modelObjectTypes(model.name)), + resolve = (ctx) => { + + val filter: DataItemFilterCollection = modelObjectTypes + .extractQueryArgumentsFromContext(model = model, ctx = ctx) + .flatMap(_.filter) + .getOrElse(List()) + + val arguments = Some(QueryArguments(filter = Some(filter), skip = None, after = None, first = None, before = None, last = None, orderBy = None)) + + ctx.ctx.dataResolver + .resolveByModel(model, arguments) + .map(result => result.items) + } + ) + + def build(): Schema[AlgoliaFullModelContext, Unit] = { + val Query = ObjectType( + "Query", + List(algoliaSyncField) + ) + + Schema(Query) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaSchema.scala b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaSchema.scala new file mode 100644 index 0000000000..415d8f3866 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/algolia/schemas/AlgoliaSchema.scala @@ -0,0 +1,40 @@ +package cool.graph.shared.algolia.schemas + +import cool.graph.client.SangriaQueryArguments +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.algolia.AlgoliaContext +import cool.graph.shared.models.{Model, Project} +import cool.graph.{DataItem, FilteredResolver} +import sangria.schema.{Context, Field, ObjectType, OptionType, Schema} +import scaldi.{Injectable, Injector} + +import scala.concurrent.{ExecutionContextExecutor, Future} + +class AlgoliaSchema[ManyDataItemType](project: Project, model: Model, modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType])( + implicit injector: Injector) + extends Injectable { + + implicit val dispatcher = + inject[ExecutionContextExecutor](identified by "dispatcher") + + def resolve[ManyDataItemType](ctx: Context[AlgoliaContext, Unit]): Future[Option[DataItem]] = { + FilteredResolver.resolve(modelObjectTypes, model, ctx.ctx.nodeId, ctx, ctx.ctx.dataResolver) + } + + val algoliaSyncField: Field[AlgoliaContext, Unit] = Field( + "node", + description = Some("The model to synchronize with Algolia."), + arguments = List(SangriaQueryArguments.filterArgument(model = model, project = project)), + fieldType = OptionType(modelObjectTypes.modelObjectTypes.get(model.name).get), + resolve = (ctx) => resolve(ctx) + ) + + def build(): Schema[AlgoliaContext, Unit] = { + val Query = ObjectType( + "Query", + List(algoliaSyncField) + ) + + Schema(Query) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/authorization/SharedAuth.scala b/server/backend-shared/src/main/scala/cool/graph/shared/authorization/SharedAuth.scala new file mode 100644 index 0000000000..2c78cd67c7 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/authorization/SharedAuth.scala @@ -0,0 +1,100 @@ +package cool.graph.shared.authorization + +import java.time.Instant + +import com.typesafe.config.Config +import cool.graph.DataItem +import cool.graph.shared.models._ +import pdi.jwt +import pdi.jwt.{Jwt, JwtAlgorithm, JwtClaim, JwtOptions} +import spray.json._ + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +case class JwtUserData[T](projectId: String, userId: String, authData: Option[T], modelName: String = "User") +case class JwtCustomerData(clientId: String) +case class JwtPermanentAuthTokenData(clientId: String, projectId: String, permanentAuthTokenId: String) + +object JwtClaimJsonProtocol extends DefaultJsonProtocol { + implicit val formatClientModel = jsonFormat(JwtCustomerData, "clientId") + implicit def formatUserModel[T: JsonFormat] = jsonFormat(JwtUserData.apply[T], "projectId", "userId", "authData", "modelName") + implicit val formatPermanentAuthTokenModel = jsonFormat(JwtPermanentAuthTokenData, "clientId", "projectId", "permanentAuthTokenId") +} + +trait SharedAuth { + import JwtClaimJsonProtocol._ + + val config: Config + lazy val jwtSecret: String = config.getString("jwtSecret") + val expiringSeconds: Int = 60 * 60 * 24 * 30 + + case class Expiration(exp: Long) + implicit val formatExpiration = jsonFormat(Expiration, "exp") + + def loginUser[T: JsonFormat](project: Project, user: DataItem, authData: Option[T]): Future[String] = { + val claimPayload = JwtUserData(projectId = project.id, userId = user.id, authData = authData).toJson.compactPrint + val sessionToken = Jwt.encode(JwtClaim(claimPayload).issuedNow.expiresIn(expiringSeconds), jwtSecret, JwtAlgorithm.HS256) + + Future.successful(sessionToken) + } + + /** + * Checks if the given token has an expiration, in which case it checks if the token expired. + * If the token has no expiration, it is treated as not expired. + * + * Note: Assumes JWT secret has already been verified. + */ + protected def isExpired(sessionToken: String): Boolean = { + Jwt + .decodeRaw(sessionToken, JwtOptions(signature = false, expiration = false)) + .map(_.parseJson.convertTo[Expiration]) + .map(_.exp) match { + case Success(expiration) => + (expiration * 1000) < Instant.now().toEpochMilli + + case Failure(e) => { + // todo: instead of returning false when there is no exp, make sure all tokens have exp + println("token-had-no-exp-claim") + false + } + } + } + + protected def parseTokenAsClientData(sessionToken: String): Option[JwtCustomerData] = { + Jwt + .decodeRaw(sessionToken, config.getString("jwtSecret"), Seq(JwtAlgorithm.HS256)) + .map(_.parseJson.convertTo[JwtCustomerData]) + .map(Some(_)) + .getOrElse(None) + } + + def parseTokenAsTemporaryRootToken(token: String): Option[JwtPermanentAuthTokenData] = { + Jwt + .decodeRaw(token, config.getString("jwtSecret"), Seq(JwtAlgorithm.HS256)) + .map(_.parseJson.convertTo[JwtPermanentAuthTokenData]) + .map(Some(_)) + .getOrElse(None) + } + + def isValidTemporaryRootToken(project: Project, token: String): Boolean = { + parseTokenAsTemporaryRootToken(token) match { + case Some(rootToken) => !isExpired(token) && rootToken.projectId == project.id + case None => false + } + } + + def generateRootToken(clientId: String, projectId: String, id: String, expiresInSeconds: Option[Long]): String = { + val claim = JwtClaim(JwtPermanentAuthTokenData(clientId = clientId, projectId = projectId, permanentAuthTokenId = id).toJson.compactPrint).issuedNow + val claimToEncode = expiresInSeconds match { + case Some(expiration) => claim.expiresIn(expiration) + case None => claim + } + + Jwt.encode( + claimToEncode, + config.getString("jwtSecret"), + jwt.JwtAlgorithm.HS256 + ) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/database/GlobalDatabaseManager.scala b/server/backend-shared/src/main/scala/cool/graph/shared/database/GlobalDatabaseManager.scala new file mode 100644 index 0000000000..8d58a166a3 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/database/GlobalDatabaseManager.scala @@ -0,0 +1,92 @@ +package cool.graph.shared.database + +import com.typesafe.config.{Config, ConfigObject} +import cool.graph.shared.models.Region.Region +import cool.graph.shared.models.{Project, ProjectDatabase, Region} +import slick.jdbc.MySQLProfile.api._ +import slick.jdbc.MySQLProfile.backend.DatabaseDef + +object InternalAndProjectDbs { + def apply(internal: InternalDatabase, client: Databases): InternalAndProjectDbs = { + InternalAndProjectDbs(internal, Some(client)) + } +} +case class InternalAndProjectDbs(internal: InternalDatabase, client: Option[Databases] = None) +case class Databases(master: DatabaseDef, readOnly: DatabaseDef) +case class InternalDatabase(databaseDef: DatabaseDef) + +/** + * Unfortunately the system api needs access to the client db in each region. + * Therefore we use this class to select the correct db for a project. + * As the system and client apis use the same DataResolver we also use this intermediary class in client api, + * even though they are only configured with access to the local client db. + */ +case class ProjectDatabaseRef(region: Region, name: String) + +case class GlobalDatabaseManager(currentRegion: Region, databases: Map[ProjectDatabaseRef, Databases]) { + + def getDbForProject(project: Project): Databases = getDbForProjectDatabase(project.projectDatabase) + + def getDbForProjectDatabase(projectDatabase: ProjectDatabase): Databases = { + val projectDbRef = ProjectDatabaseRef(projectDatabase.region, projectDatabase.name) + databases.get(projectDbRef) match { + case None => + sys.error(s"This service is not configured to access Client Db with name [${projectDbRef.name}] in region '${projectDbRef.region}'") + case Some(db) => db + } + } +} + +object GlobalDatabaseManager { + val singleConfigRoot = "clientDatabases" + val allConfigRoot = "allClientDatabases" + val awsRegionConfigProp = "awsRegion" + + def initializeForSingleRegion(config: Config): GlobalDatabaseManager = { + import scala.collection.JavaConversions._ + + config.resolve() + val currentRegion = Region.withName(config.getString(awsRegionConfigProp)) + + val databasesMap = for { + (dbName, _) <- config.getObject(singleConfigRoot) + } yield { + val readOnlyPath = s"$singleConfigRoot.$dbName.readonly" + val masterDb = Database.forConfig(s"$singleConfigRoot.$dbName.master", config) + lazy val readOnlyDb = Database.forConfig(readOnlyPath, config) + + val dbs = Databases( + master = masterDb, + readOnly = if (config.hasPath(readOnlyPath)) readOnlyDb else masterDb + ) + + ProjectDatabaseRef(currentRegion, dbName) -> dbs + } + + GlobalDatabaseManager(currentRegion = currentRegion, databases = databasesMap.toMap) + } + + def initializeForMultipleRegions(config: Config): GlobalDatabaseManager = { + import scala.collection.JavaConversions._ + + val currentRegion = Region.withName(config.getString(awsRegionConfigProp)) + + val databasesMap = for { + (regionName, regionValue) <- config.getObject(allConfigRoot) + (dbName, _) <- regionValue.asInstanceOf[ConfigObject] + } yield { + val readOnlyPath = s"$allConfigRoot.$regionName.$dbName.readonly" + val masterDb = Database.forConfig(s"$allConfigRoot.$regionName.$dbName.master", config) + lazy val readOnlyDb = Database.forConfig(readOnlyPath, config) + + val dbs = Databases( + master = masterDb, + readOnly = if (config.hasPath(readOnlyPath)) readOnlyDb else masterDb + ) + + ProjectDatabaseRef(Region.withName(regionName), dbName) -> dbs + } + + GlobalDatabaseManager(currentRegion = currentRegion, databases = databasesMap.toMap) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/errors/Errors.scala b/server/backend-shared/src/main/scala/cool/graph/shared/errors/Errors.scala new file mode 100644 index 0000000000..0f26622388 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/errors/Errors.scala @@ -0,0 +1,554 @@ +package cool.graph.shared.errors + +import cool.graph.MutactionExecutionResult +import cool.graph.shared.errors.SystemErrors.SchemaError +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import spray.json.{JsObject, JsString, JsValue} + +abstract class GeneralError(message: String) extends Exception with MutactionExecutionResult { + override def getMessage: String = message +} + +abstract class UserFacingError(message: String, errorCode: Int, val functionError: Option[JsValue] = None) extends GeneralError(message) { + val code: Int = errorCode +} + +object CommonErrors { + case class TimeoutExceeded() extends UserFacingError("The query took too long to process. Either try again later or try a simpler query.", 1000) + case class InputCompletelyMalformed(input: String) extends UserFacingError(s"input could not be parsed: '$input'", 1001) + + case class QueriesNotAllowedForProject(projectId: String) extends UserFacingError(s"Queries are not allowed for the project with id '$projectId'", 1002) + + case class MutationsNotAllowedForProject(projectId: String) + extends UserFacingError(s"The project '$projectId' is currently in read-only mode. Please try again in a few minutes", 1003) +} + +// errors caused by the system - should only appear in system! +// these errors are typically caused by our console or third party applications not using our api correctly +object SystemErrors { + trait WithSchemaError { + def schemaError: Option[SchemaError] = None + } + + abstract class SystemApiError(message: String, errorCode: Int) extends UserFacingError(message, errorCode) with WithSchemaError + case class SchemaError(`type`: String, description: String, field: Option[String]) + + object SchemaError { + def apply(`type`: String, field: String, description: String): SchemaError = { + SchemaError(`type`, description, Some(field)) + } + + def apply(`type`: String, description: String): SchemaError = { + SchemaError(`type`, description, None) + } + + def global(description: String): SchemaError = { + SchemaError("Global", description, None) + } + } + + case class ProjectPushError(description: String) extends Exception with WithSchemaError { + override def schemaError: Option[SchemaError] = Some(SchemaError("Global", description = description)) + } + + case class InvalidProjectId(projectId: String) extends SystemApiError(s"No project with id '$projectId'", 4000) + + case class InvalidModelId(modelId: String) extends SystemApiError(s"No model with id '$modelId'", 4001) + + case class InvalidAuthProviderId(authProviderId: String) extends SystemApiError(s"No authProvider with id '$authProviderId'", 4002) + + case class InvalidFieldId(fieldId: String) extends SystemApiError(s"No field with id '$fieldId'", 4003) + + case class InvalidRelationFieldMirrorId(relationFieldMirrorId: String) extends SystemApiError(s"No field with id '$relationFieldMirrorId'", 4004) + + case class InvalidModelPermissionId(modelPermissionId: String) extends SystemApiError(s"No modelPermission with id '$modelPermissionId'", 4005) + + case class InvalidPermissionId(permissionId: String) extends SystemApiError(s"No permission with id '$permissionId'", 4006) + + case class InvalidAlgoliaSyncQueryId(algoliaSyncQueryId: String) extends SystemApiError(s"No algoliaSyncQuery with id '$algoliaSyncQueryId'", 4007) + + case class InvalidStateException(message: String) + extends SystemApiError(s"Something unexpected happened and as a result your account is in an invalid state. Please contact support.'$message'", 4008) + + case class InvalidActionId(actionId: String) extends SystemApiError(s"No action with id '$actionId'", 4009) + + case class InvalidRelation(error: String) extends SystemApiError(s"The relation is invalid. Reason: $error", 4010) + + case class UnknownExecutionError(message: String, stacktrace: String) + extends SystemApiError(s"Something unexpected happened in an Action: '$message' - '$stacktrace'", 4011) + + case class InvalidModel(reason: String) extends SystemApiError(s"Please supply a valid model. Reason: $reason", 4012) + + // 4013 is not in use at the moment + + case class FieldNotInModel(fieldName: String, modelName: String) + extends SystemApiError(s"Field with the name '$fieldName' does not exist on the model '$modelName'", 4014) + + case class ModelPermissionNotInModel(modelPermissionId: String, modelName: String) + extends SystemApiError(s"ModelPermission '$modelPermissionId' does not exist on the model '$modelName'", 4015) + + case class CannotUpdateSystemField(fieldName: String, modelName: String) + extends SystemApiError(s"Field with the name '$fieldName' in model '$modelName' is a system field and cannot be updated", 4016) { + + override val schemaError = Some(SchemaError(modelName, fieldName, s"The field `$fieldName` is a system field and cannot be updated.")) + } + + case class SystemFieldCannotBeRemoved(fieldName: String) + extends SystemApiError(s"Field with the name '$fieldName' is a system field and cannot be removed", 4017) + + case class SystemModelCannotBeRemoved(modelName: String) + extends SystemApiError(s"Model with the name '$modelName' is a system model and cannot be removed", 4018) + + case class NoModelForField(fieldName: String) extends SystemApiError(s"No model found for field $fieldName", 4019) + + case class IsNotScalar(typeIdentifier: String) + extends SystemApiError(s"You can only create scalar fields and '$typeIdentifier' is not a scalar value. Did you intend to create a relation?", 4020) + + case class InvalidSecret() extends SystemApiError(s"Provided secret is not correct", 4021) + + case class InvalidRelationId(relationId: String) extends SystemApiError(s"No relation with id '$relationId'", 4022) + + case class InvalidClientId(clientId: String) extends SystemApiError(s"No client with id '$clientId'", 4023) + + case class CantDeleteLastProject() extends SystemApiError("You cannot delete the last project in your account.", 4024) + + case class CantDeleteRelationField(fieldName: String) extends SystemApiError(s"You cannot delete a field that is part of a relation: '$fieldName'", 4025) + + case class CantDeleteProtectedProject(projectId: String) extends SystemApiError(s"You cannot delete a protected project: '$projectId'", 4026) + + case class InvalidSeatEmail(email: String) extends SystemApiError(s"No seat with email '$email'", 4027) + + case class InvalidPatForProject(projectId: String) extends SystemApiError(s"The provided pat is not valid for project '$projectId'", 4028) + + case class InvalidActionTriggerMutationModelId(actiontriggermutationmodelId: String) + extends SystemApiError(s"No actiontriggermutationmodel with id '$actiontriggermutationmodelId'", 4029) + + case class InvalidActionTriggerMutationRelationId(actiontriggermutationmodelId: String) + extends SystemApiError(s"No actiontriggermutationrelation with id '$actiontriggermutationmodelId'", 4030) + + case class InvalidIntegrationId(integrationId: String) extends SystemApiError(s"No Integration with id '$integrationId'", 4031) + + case class InvalidSeatId(seatId: String) extends SystemApiError(s"No Seat with id '$seatId'", 4032) + + case class InvalidProjectName(name: String) extends SystemApiError(s"No Project with name '$name'", 4033) + + case class RelationPermissionNotInModel(relationPermissionId: String, relationName: String) + extends SystemApiError(s"RelationPermission '$relationPermissionId' does not exist on the relation '$relationName'", 4034) + + case class InvalidRelationPermissionId(relationPermissionId: String) extends SystemApiError(s"No relationPermission with id '$relationPermissionId'", 4035) + + case class InvalidPackageDefinitionId(packageDefinitionId: String) extends SystemApiError(s"No PackageDefinition with id '$packageDefinitionId'", 4036) + + case class InvalidEnumId(id: String) extends SystemApiError(s"No Enum with id '$id'", 4037) + + case class InvalidFunctionId(id: String) extends SystemApiError(s"No Function with id '$id'", 4038) + + case class InvalidPackageName(packageName: String) extends SystemApiError(s"No Package with name '$packageName'", 4039) + + case class InvalidEnumName(enumName: String) extends SystemApiError(s"An Enum with the name '$enumName' already exists.", 4040) + + case class InvalidProjectDatabase(projectDatabaseIdOrRegion: String) + extends SystemApiError(s"A ProjectDatabase with the id or region '$projectDatabaseIdOrRegion' does not exist.", 4041) + case class InvalidFieldConstraintId(constraintId: String) extends SystemApiError(s"A Constraint with the id '$constraintId' does not exist.", 4041) + + case class DuplicateFieldConstraint(constraintType: String, fieldId: String) + extends SystemApiError(s"A Constraint with the type '$constraintType' already exists for the field with the id: $fieldId.", 4042) + + case class FieldConstraintTypeNotCompatibleWithField(constraintType: String, fieldId: String, fieldType: String) + extends SystemApiError(s"A Constraint with the type '$constraintType' is not possible on the field with the type: $fieldType and the id: $fieldId.", 4043) + + case class ListFieldConstraintOnlyOnListFields(fieldId: String) + extends SystemApiError(s"The field with the id: '$fieldId' is not a list field and therefore cannot take a List constraint", 4044) + + case class UpdatingTheFieldWouldViolateConstraint(fieldId: String, constraintId: String) + extends SystemApiError(s"Updating the field with the id: '$fieldId' would violate the constraint with the id: $constraintId", 4045) + + case class InvalidFunctionName(name: String) extends SystemApiError(s"No Function with name '$name'", 4046) + + case class InvalidRequestPipelineOperation(operation: String) + extends SystemApiError(s"RequestPipeline Operation has to be create, update or delete. You provided '$operation'", 4047) + + case class InvalidFunctionType(typename: String) extends SystemApiError(s"The function type was invalid. You provided '$typename'", 4048) + + case class InvalidFunctionHeader(header: String) extends SystemApiError(s"The function header was invalid. You provided '$header'", 4049) + + case class InvalidPredefinedFieldFormat(fieldName: String, underlying: String) + extends SystemApiError(s"The field $fieldName is a predefined but hidden type and has to have a specific format to be exposed. $underlying", 4050) + + case class InvalidSeatClientId(clientId: String) extends SystemApiError(s"No Seat with clientId '$clientId' found on the project.", 4051) + + case class OnlyOwnerOfProjectCanTransferOwnership() extends SystemApiError(s"Only the owner of a project can transfer ownership.", 4052) + + case class NewOwnerOfAProjectNeedsAClientId() + extends SystemApiError( + s"The collaborator you are trying to make an owner has not joined graph.cool yet. Please ask him to register before transferring the ownership.", + 4053) + + case class EmailAlreadyIsTheProjectOwner(email: String) extends SystemApiError(s"The project is already owned by the seat with the email: '$email'", 4054) + +} + +// errors caused by user input - these errors should not appear in simple or relay! +object UserInputErrors { + import SystemErrors.SystemApiError + + case class InvalidRootTokenId(rootTokenId: String) extends SystemApiError(s"No Permanent Auth Token with id '$rootTokenId'", 2000) + + case class InvalidSession() extends SystemApiError("No valid session", 2001) + + case class ModelWithNameAlreadyExists(name: String) extends SystemApiError(s"A model with the name '$name' already exists in your project", 2002) + + case class ProjectWithNameAlreadyExists(name: String) extends SystemApiError(s"A project with the name '$name' already exists in your account", 2003) + + case class ChangedIsListAndNoMigrationValue(fieldName: String) + extends SystemApiError(s"'$fieldName' is changed to or from a list scalar type and you did not specify a migrationValue.", 2004) + + case class InvalidPassword() extends SystemApiError(s"The password is not correct", 2005) + + case class InvalidResetPasswordToken(token: String) extends SystemApiError(s"That reset password token is not valid. Maybe you used it already?", 2006) + + case class RequiredAndNoMigrationValue(modelName: String, fieldName: String) + extends SystemApiError(s"'$fieldName' is required and you did not specify a migrationValue.", 2007) { + + override val schemaError = Some { + SchemaError( + modelName, + fieldName, + s"""The field `$fieldName` must specify the `@migrationValue` directive, because its type was changed or it became required: `@migrationValue(value: "42")`""" + ) + } + } + + case class InvalidName(name: String) extends SystemApiError(InvalidNames.default(name), 2008) + case class InvalidNameMustStartUppercase(name: String) extends SystemApiError(InvalidNames.mustStartUppercase(name), 2008) + object InvalidNames { + def mustStartUppercase(name: String): String = s"'${default(name)} It must begin with an uppercase letter. It may contain letters and numbers." + def default(name: String): String = s"'$name' is not a valid name." + } + + case class FieldAreadyExists(name: String) extends SystemApiError(s"A field with the name '$name' already exists", 2009) + + case class MissingEnumValues() extends SystemApiError("You must provide an enumValues argument when specifying the 'Enum' typeIdentifier", 2010) + + case class InvalidValueForScalarType(value: String, typeIdentifier: TypeIdentifier) + extends SystemApiError(s"'$value' is not a valid value for type '$typeIdentifier'", 2011) + + case class InvalidUserPath(modelName: String) extends SystemApiError(s"Not a valid user path for model $modelName.", 2012) + + case class FailedLoginException() extends SystemApiError("Wrong user data", 2013) + + case class EdgesAlreadyExist() + extends SystemApiError(s"You cannot change the models of a relation that contains edges. Either remove all edges or create a new relation", 2014) + + case class NotFoundException(reason: String) extends SystemApiError(reason, 2015) + + case class OneToManyRelationSameModelSameField() + extends SystemApiError(s"Cannot create a one-to-many relation between the same model using the same field", 2016) + + case class ClientEmailInUse() extends SystemApiError(s"That email is already in use", 2017) + + case class CouldNotActivateIntegration(name: String, reason: String) extends SystemApiError(s"Could not activate integration: $name. '$reason'", 2018) + + case class CouldNotDeactivateIntegration(name: String, reason: String) extends SystemApiError(s"Could not deactivate integration: $name. '$reason'", 2019) + + case class RelationNameAlreadyExists(name: String) extends SystemApiError(s"A relation with that name already exists: $name.", 2020) + + case class EnumValueInUse() extends SystemApiError(s"The Enum value you are removing is in use. Please provide a migration Value.", 2021) { + override val schemaError = Some { + SchemaError.global( + s"An enum type is used in a non-list enum field on a type that has nodes and therefore can't be removed. Please provide a migrationValue.") + } + } + + case class CantRemoveEnumValueWhenNodesExist(modelName: String, fieldName: String) + extends SystemApiError( + s"It is not possible to remove an enum value for a List field when there are existing data nodes. Please provide a migration Value for $fieldName on $modelName.", + 2022 + ) { + override val schemaError = Some { + SchemaError( + modelName, + fieldName, + s"The type `$modelName` has nodes and therefore the enum values associated with `$fieldName` can't be removed. Please provide a migrationValue." + ) + } + } + + case class ActionInputIsInconsistent(message: String) extends SystemApiError(s"The input you provided for the action is invalid: $message", 2023) + + case class ExistingDuplicateDataPreventsUniqueIndex(fieldName: String) + extends SystemApiError(s"The field '$fieldName' contains duplicate data. Please remove duplicates before enabling the unique constraint", 2024) + + case class DefaultValueIsNotValidEnum(value: String) + extends SystemApiError(s"The specified default value '$value' is not a valid Enum Value for this field.", 2025) + + case class DuplicateEmailFromMultipleProviders(email: String) + extends SystemApiError( + s"It looks like you previously signed up with a different provider with the same email ($email). Please sign in with the same provider again.", + 2026) + + case class RequiredSearchProviderAlgoliaNotPresent() + extends SystemApiError(s"You must enable the Algolia integration before you add queries to sync data. Please enable this integration first.", 2027) + + case class AlgoliaCredentialsDontHaveRequiredPermissions() + extends SystemApiError( + s"Please check that the Application ID and API Key is correct. You can find both on the API Keys page in the Algolia web interface. You must create a new API Key and enable 'Add records' and 'Delete records'. Make sure that you are not using the Admin API Key, as Algolia doesn't allow it to be used here.", + 2028 + ) + + case class ProjectAlreadyHasSearchProviderAlgolia() + extends SystemApiError(s"This project already has an Algolia integration. Try setup a sync query for a new modal using the existing integration.", 2029) + + case class ObjectDoesNotExistInCurrentProject(message: String) extends SystemApiError(s"The referenced object does not exist in this project: $message", 2030) + + case class RelationChangedFromListToSingleAndNodesPresent(fieldName: String) + extends SystemApiError( + s"'$fieldName' is a relation field. Changing it from a to-many to a to-one field is not allowed when there are already nodes in the relation.", + 2031) + + case class TooManyNodesToExportData(maxCount: Int) + extends SystemApiError(s"One of your models had more than $maxCount nodes. Please contact support to get a manual data export.", 2032) + + case class InvalidProjectAlias(alias: String) extends SystemApiError(s"'$alias' is not a valid project alias", 2033) + + case class ProjectWithAliasAlreadyExists(alias: String) + extends SystemApiError(s"A project with the alias '$alias' already exists. Aliases are globally unique. Please try something else.", 2034) + + case class ProjectAliasEqualsAnExistingId(alias: String) + extends SystemApiError(s"A project with the id '$alias' already exists. You cannot set the alias to that of an existing project id!.", 2035) + + case class EmailIsNotGraphcoolUser(email: String) + extends SystemApiError(s"No Graphcool user exists with the email '$email'. Please ask your collaborator to create a Graphcool account.", 2036) + + case class CollaboratorProjectWithNameAlreadyExists(name: String) + extends SystemApiError(s"A project with the name '$name' already exists in collaborators account", 2037) + + case class StripeError(message: String) extends SystemApiError(message, 2038) + + case class InvalidSchema(message: String) extends SystemApiError(s"The schema is invalid: $message", 2040) + + case class TooManyNodesRequested(maxCount: Int) + extends SystemApiError(s"You requested $maxCount nodes. We will only return up to 1000 nodes per query.", 2041) + + case class MigrationValueIsNotValidEnum(value: String) + extends SystemApiError(s"The specified migration value '$value' is not a valid Enum Value for this field.", 2042) + + case class ListRelationsCannotBeRequired(fieldName: String) + extends SystemApiError(s"The field '$fieldName' is a list relation and can not be required.", 2043) + + case class EnumIsReferencedByField(fieldName: String, typeName: String) + extends SystemApiError(s"The field '$fieldName' on type '$typeName' is still referencing this enum.", 2044) + + case class NoEnumSelectedAlthoughSetToEnumType(fieldName: String) + extends SystemApiError(s"The field type for field '$fieldName' is set to enum. You must also select an existing enum.", 2045) + + case class TypeAlreadyExists(name: String) extends SystemApiError(s"A type with the name '$name' already exists in your project", 2046) + + case class SettingRelationRequiredButNodesExist(fieldName: String) + extends SystemApiError(s"'$fieldName' is required but there are already nodes present without that relation.", 2047) + + case class ServerSideSubscriptionQueryIsInvalid(error: String, functionName: String) + extends SystemApiError(s"The supplied query for the server side subscription `$functionName` is invalid. $error", 2048) + + case class InvalidMigrationValueForEnum(modelName: String, fieldName: String, migrationValue: String) + extends SystemApiError(s"You supplied an enum migrationValue that is not appropriate for model: $modelName field: $fieldName value: $migrationValue", + 2049) { + override val schemaError = Some { + SchemaError(modelName, fieldName, s"The provided migrationValue `$migrationValue` has the wrong List status for field `$fieldName` on type `$modelName`.") + } + } + + case class CantRenameSystemModels(name: String) extends SystemApiError(s"You tried renaming a system model. This is not possible. modelName: $name", 2050) + + case class TypeChangeRequiresMigrationValue(fieldName: String) extends SystemApiError(s"The type change on '$fieldName' requires a migrationValue.", 2051) + + case class AddingRequiredRelationButNodesExistForModel(modelName: String, fieldName: String) + extends SystemApiError(s"You are adding a required relation to '$modelName' but there are already items.", 2052) { + + override val schemaError = Some { + SchemaError( + modelName, + fieldName, + s"The relation field `$fieldName` cannot be made required, because there are already instances of the enclosing type that violate this constraint." + ) + } + } + + case class SchemaExtensionParseError(functionName: String, message: String) + extends SystemApiError(s"Schema Extension Error for function '$functionName': $message", 2053) + + case class FunctionWithNameAlreadyExists(name: String) extends SystemApiError(s"A function with the name '$name' already exists in your project", 2054) + + case class SameRequestPipeLineFunctionAlreadyExists(modelName: String, operation: String, binding: String) + extends SystemApiError( + s"A Request Pipeline Function for type $modelName, the trigger '$operation' and the step '$binding' already exists in your project.", + 2055) + + case class FunctionHasInvalidUrl(name: String, url: String) extends SystemApiError(s"Function with name '$name' has invalid url: '$url'", 2056) + + case class EnumValueUsedAsDefaultValue(value: String, fieldName: String) + extends SystemApiError(s"The enumValue '$value' can't be removed. It is used as DefaultValue on field: '$fieldName'", 2057) + + case class PermissionQueryIsInvalid(error: String, permissionNameOrId: String) + extends SystemApiError(s"The supplied query for the permission `$permissionNameOrId` is invalid. $error", 2058) + + case class RootTokenNameAlreadyInUse(rootTokenName: String) extends SystemApiError(s"There is already a RootToken with the name `$rootTokenName`.", 2059) + + case class IllegalFunctionName(name: String) extends SystemApiError(s"The function name does not match the naming rule. Name: '$name'", 2060) + + case class ProjectEjectFailure(message: String) extends SystemApiError(s"The project could not be ejected because $message", 2061) + + case class InvalidRootTokenName(name: String) extends SystemApiError(s"No RootToken with the name: $name", 2062) + + case class ResolverPayloadIsRequired() extends SystemApiError(s"The payloadType for the resolver is not nullable.", 2063) + + case class ResolverFunctionHasDuplicateSchemaFilePath(name: String, path: String) + extends SystemApiError(s"The Resolver Function with name '$name' has the path: '$path'. This schemaFilePath is already in use.", 2064) + + case class FunctionHasInvalidPayloadName(name: String, payloadName: String) + extends SystemApiError(s"Function with name '$name' has invalid payloadName: '$payloadName'", 2065) + + case class QueryPermissionParseError(ruleName: String, message: String) + extends SystemApiError(s"Query Permission Error for permission '$ruleName': $message", 2066) + + case class ModelOrRelationForPermissionDoesNotExist(name: String) + extends SystemApiError(s"Did not find the type or relation you provided a permission for: '$name'", 2066) +} + +// errors caused by the client when using the relay/simple API- should only appear in relay/simple/shared! +object UserAPIErrors { + abstract class ClientApiError(message: String, errorCode: Int) extends UserFacingError(message, errorCode) + + case class GraphQLArgumentsException(reason: String) extends ClientApiError(reason, 3000) + + case class IdIsInvalid(id: String) extends ClientApiError(s"The given id '$id' is invalid.", 3001) + + case class DataItemDoesNotExist(modelId: String, id: String) extends ClientApiError(s"'$modelId' has no item with id '$id'", 3002) + + case class IdIsMissing() extends ClientApiError(s"An Id argument was expected, but not found.", 3003) + + case class DataItemAlreadyExists(modelId: String, id: String) extends ClientApiError(s"'$modelId' already has an item with id '$id'", 3004) + + case class ExtraArguments(arguments: List[String], model: String) + extends ClientApiError(s"The parameters $arguments were present in the argument list, but are not present in the model $model.", 3005) + + case class InvalidValue(valueName: String) extends ClientApiError(s"Please supply a valid value for $valueName.", 3006) + + case class ValueTooLong(fieldName: String) extends ClientApiError(s"Value for field $fieldName is too long.", 3007) + + case class InsufficientPermissions(reason: String) extends ClientApiError(reason, 3008) + + case class RelationAlreadyFull(relationId: String, field1: String, field2: String) + extends ClientApiError(s"'$relationId' is already connecting fields '$field1' and '$field2'", 3009) + + case class UniqueConstraintViolation(modelName: String, details: String) + extends ClientApiError(s"A unique constraint would be violated on $modelName. Details: $details", 3010) + + case class NodeDoesNotExist(id: String) + extends ClientApiError( + s"You are referencing a node that does not exist. Please check your mutation to make sure you are only creating edges between existing nodes. Id if available: $id", + 3011 + ) + + case class ItemAlreadyInRelation() extends ClientApiError(s"An edge already exists between the two nodes.", 3012) + + case class NodeNotFoundError(id: String) extends ClientApiError(s"Node with id $id not found", 3013) + + // todo: throw in simple + case class InvalidConnectionArguments() + extends ClientApiError( + s"Including a value for both first and last is not supported. See the spec for a discussion of why https://facebook.github.io/relay/graphql/connections.htm#sec-Pagination-algorithm", + 3014 + ) + + case class InvalidToken() + extends ClientApiError(s"Your token is invalid. It might have expired or you might be using a token from a different project.", 3015) + + case class ProjectNotFound(projectId: String) extends ClientApiError(s"Project not found: '$projectId'", 3016) + + case class InvalidSigninData() extends ClientApiError("Your signin credentials are incorrect. Please try again", 3018) + + case class ReadonlyField(fieldName: String) extends ClientApiError(s"The field $fieldName is read only.", 3019) + + case class FieldCannotBeNull(fieldName: String = "") + extends ClientApiError( + s"You are trying to set a required field to null. If you are using GraphQL arguments, make sure that you specify a value for all arguments. Fieldname if known: $fieldName", + 3020 + ) + + case class CannotCreateUserWhenSignedIn() extends ClientApiError(s"It is not possible to create a user when you are already signed in.", 3021) + + case class CannotSignInCredentialsInvalid() extends ClientApiError(s"No user found with that information", 3022) + + case class CannotSignUpUserWithCredentialsExist() extends ClientApiError(s"User already exists with that information", 3023) + + case class VariablesParsingError(variables: String) extends ClientApiError(s"Variables could not be parsed as json: $variables", 3024) + + case class Auth0IdTokenIsInvalid() + extends ClientApiError(s"The provided idToken is invalid. Please see https://auth0.com/docs/tokens/id_token for how to obtain a valid idToken", 3025) + + case class InvalidFirstArgument() extends ClientApiError(s"The 'first' argument must be non negative", 3026) + + case class InvalidLastArgument() extends ClientApiError(s"The 'last' argument must be non negative", 3027) + + case class InvalidSkipArgument() extends ClientApiError(s"The 'skip' argument must be non negative", 3028) + + case class UnsuccessfulSynchronousMutationCallback() extends ClientApiError(s"A Synchronous Mutation Callback failed", 3029) + + case class InvalidAuthProviderData(message: String) extends ClientApiError(s"provided authProvider fields is invalid: '$message'", 3030) + + case class GenericServerlessFunctionError(functionName: String, message: String) + extends ClientApiError(s"The function '$functionName' returned an error: '$message'", 3031) + + case class RelationIsRequired(fieldName: String, typeName: String) + extends ClientApiError(s"The field '$fieldName' on type '$typeName' is required. Performing this mutation would violate the constraint", 3032) + + case class FilterCannotBeNullOnToManyField(fieldName: String) + extends ClientApiError(s"The field '$fieldName' is a toMany relation. This cannot be filtered by null.", 3033) + + case class UnhandledFunctionError(functionName: String, requestId: String) + extends ClientApiError(s"The function '$functionName' returned an unhandled error. Please check the logs for requestId '$requestId'", 3034) + + case class ConstraintViolated(error: String) extends ClientApiError("The input value violated one or more constraints: " + error, 3035) + + case class InputInvalid(input: String, fieldName: String, fieldType: String) + extends ClientApiError(s"The input value $input was not valid for field $fieldName of type $fieldType.", 3036) + + case class ValueNotAValidJson(fieldName: String, value: String) + extends ClientApiError(s"The value in the field '$fieldName' is not a valid Json: '$value'", 3037) + + case class StoredValueForFieldNotValid(fieldName: String, modelName: String) + extends ClientApiError(s"The value in the field '$fieldName' on the model '$modelName' ist not valid for that field.", 3038) + +} + +object RequestPipelineErrors { + abstract class RequestPipelineError(message: String, errorCode: Int, functionError: Option[JsValue] = None) + extends UserFacingError(message, errorCode, functionError) + + case class UnhandledFunctionError(executionId: String) + extends RequestPipelineError(s"""A function returned an unhandled error. Please check the logs for executionId '$executionId'""", 5000) + + case class FunctionReturnedErrorMessage(error: String) extends RequestPipelineError(s"""function execution error: $error""", 5001, Some(JsString(error))) + + case class FunctionReturnedErrorObject(errorObject: JsObject) extends RequestPipelineError(s"""function execution error""", 5002, Some(errorObject)) + + case class FunctionReturnedInvalidBody(executionId: String) + extends RequestPipelineError( + s"""A function returned an invalid body. You can refer to the docs for the expected shape. Please check the logs for executionId '$executionId'""", + 5003 + ) + + case class JsonObjectDoesNotMatchGraphQLType(fieldName: String, expectedFieldType: String, json: String) + extends RequestPipelineError( + s"Returned Json Object does not match the GraphQL type. The field '$fieldName' should be of type $expectedFieldType \n\n Json: $json\n\n", + 5004) + + case class FunctionWebhookURLWasNotValid(executionId: String) + extends RequestPipelineError(s"""A function webhook url was not valid. Please check the logs for executionId '$executionId'""", 5005) + + case class ReturnedDataWasNotAnObject() extends RequestPipelineError(s"""The return value should include a 'data' field of type object""", 5006) + + case class DataDoesNotMatchPayloadType() extends RequestPipelineError(s"""The value of the data object did not match the specified payloadType.""", 5007) + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/KinesisPublisher.scala b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/KinesisPublisher.scala new file mode 100644 index 0000000000..4be992eb40 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/KinesisPublisher.scala @@ -0,0 +1,33 @@ +package cool.graph.shared.externalServices + +import java.nio.ByteBuffer + +import com.amazonaws.services.kinesis.AmazonKinesis +import com.amazonaws.services.kinesis.model.PutRecordResult +import cool.graph.cuid.Cuid +import scaldi.{Injectable, Injector} + +import scala.collection.parallel.mutable.ParTrieMap + +trait KinesisPublisher { + def putRecord(payload: String, shardId: String = "0"): PutRecordResult +} + +class KinesisPublisherMock extends KinesisPublisher { + val messages = scala.collection.mutable.Map.empty[String, String] + + def clearMessages = messages.clear() + + override def putRecord(payload: String, shardId: String = "0"): PutRecordResult = { + messages.put(Cuid.createCuid(), payload) + + new PutRecordResult().withSequenceNumber("0").withShardId("0") + } +} + +class KinesisPublisherImplementation(streamName: String, kinesis: AmazonKinesis) extends KinesisPublisher with Injectable { + + override def putRecord(payload: String, shardId: String = "0"): PutRecordResult = { + kinesis.putRecord(streamName, ByteBuffer.wrap(payload.getBytes()), shardId) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/SnsPublisher.scala b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/SnsPublisher.scala new file mode 100644 index 0000000000..4d2282ce9e --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/SnsPublisher.scala @@ -0,0 +1,43 @@ +package cool.graph.shared.externalServices + +import com.amazonaws.SdkClientException +import com.amazonaws.services.sns.AmazonSNS +import com.amazonaws.services.sns.model.{PublishRequest, PublishResult} +import cool.graph.cuid.Cuid +import scaldi.{Injectable, Injector} + +trait SnsPublisher { + def putRecord(payload: String): PublishResult +} + +class SnsPublisherMock extends SnsPublisher { + val messages = scala.collection.parallel.mutable.ParTrieMap[String, String]() + + def clearMessages = { + messages.clear() + } + + override def putRecord(payload: String): PublishResult = { + messages.put(Cuid.createCuid(), payload) + + new PublishResult().withMessageId("0") + } +} + +class SnsPublisherImplementation(topic: String)(implicit inj: Injector) extends SnsPublisher with Injectable { + + val sns = inject[AmazonSNS](identified by "sns") + + override def putRecord(payload: String): PublishResult = { + + // todo: find a better way to handle this locally - perhaps with a docker based sns + try { + sns.publish(new PublishRequest(topic, payload)) + } catch { + case e: SdkClientException => { + println(e.getMessage) + new PublishResult().withMessageId("999") + } + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/TestableTime.scala b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/TestableTime.scala new file mode 100644 index 0000000000..9d4367aee5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/externalServices/TestableTime.scala @@ -0,0 +1,21 @@ +package cool.graph.shared.externalServices + +import org.joda.time.DateTime + +trait TestableTime { + def DateTime: org.joda.time.DateTime +} + +class TestableTimeImplementation extends TestableTime { + override def DateTime: DateTime = org.joda.time.DateTime.now +} + +/** + * The Mock generates a DateTime the first time it is called and holds on to it. + * Reusing the same mock for an entire test allows us to verify generated DateTimes + */ +class TestableTimeMock extends TestableTime { + var cache = org.joda.time.DateTime.now + def setDateTime(dateTime: DateTime) = cache = dateTime + override def DateTime: DateTime = cache +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/functions/EndpointResolver.scala b/server/backend-shared/src/main/scala/cool/graph/shared/functions/EndpointResolver.scala new file mode 100644 index 0000000000..eec3b97a71 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/functions/EndpointResolver.scala @@ -0,0 +1,60 @@ +package cool.graph.shared.functions + +sealed trait EndpointResolver { + def endpoints(projectId: String): GraphcoolEndpoints +} + +case class GraphcoolEndpoints(simple: String, relay: String, system: String, subscriptions: String) { + def toMap: Map[String, String] = { + Map( + "simple" -> simple, + "relay" -> relay, + "system" -> system, + "subscriptions" -> subscriptions + ) + } +} + +case class LocalEndpointResolver() extends EndpointResolver { + val port = sys.env.getOrElse("PORT", sys.error("PORT env var required but not found.")) + val dockerContainerDNSName = "graphcool" + val dockerContainerBase = s"http://$dockerContainerDNSName:$port" + + override def endpoints(projectId: String) = { + GraphcoolEndpoints( + simple = s"$dockerContainerBase/simple/v1/$projectId", + relay = s"$dockerContainerBase/relay/v1/$projectId", + system = s"$dockerContainerBase/system", + subscriptions = s"$dockerContainerBase/subscriptions/v1/$projectId" + ) + } +} + +case class LiveEndpointResolver() extends EndpointResolver { + val awsRegion = sys.env.getOrElse("AWS_REGION", sys.error("AWS_REGION env var required but not found.")) + + override def endpoints(projectId: String) = { + val subscriptionsEndpoint = awsRegion match { + case "eu-west-1" => s"wss://subscriptions.graph.cool/v1/$projectId" + case other => s"wss://subscriptions.$other.graph.cool/v1/$projectId" + } + + GraphcoolEndpoints( + simple = s"https://api.graph.cool/simple/v1/$projectId", + relay = s"https://api.graph.cool/relay/v1/$projectId", + system = s"https://api.graph.cool/system", + subscriptions = subscriptionsEndpoint + ) + } +} + +case class MockEndpointResolver() extends EndpointResolver { + override def endpoints(projectId: String) = { + GraphcoolEndpoints( + simple = s"http://test.cool/simple/v1/$projectId", + relay = s"http://test.cool/relay/v1/$projectId", + system = s"http://test.cool/system", + subscriptions = s"http://test.cool/subscriptions/v1/$projectId" + ) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/functions/Lambda.scala b/server/backend-shared/src/main/scala/cool/graph/shared/functions/Lambda.scala new file mode 100644 index 0000000000..b05d435a5c --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/functions/Lambda.scala @@ -0,0 +1,21 @@ +package cool.graph.shared.functions + +import cool.graph.shared.models.Project + +import scala.concurrent.Future + +trait FunctionEnvironment { + def getTemporaryUploadUrl(project: Project): Future[String] + def deploy(project: Project, externalFile: ExternalFile, name: String): Future[DeployResponse] + def invoke(project: Project, name: String, event: String): Future[InvokeResponse] +} + +sealed trait DeployResponse +case class DeploySuccess() extends DeployResponse +case class DeployFailure(exception: Throwable) extends DeployResponse + +sealed trait InvokeResponse +case class InvokeSuccess(returnValue: String) extends InvokeResponse +case class InvokeFailure(exception: Throwable) extends InvokeResponse + +case class ExternalFile(url: String, lambdaHandler: String, devHandler: String, hash: Option[String]) \ No newline at end of file diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/DevFunctionEnvironment.scala b/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/DevFunctionEnvironment.scala new file mode 100644 index 0000000000..ed6f612670 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/DevFunctionEnvironment.scala @@ -0,0 +1,107 @@ +package cool.graph.shared.functions.dev + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model._ +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.ActorMaterializer +import cool.graph.cuid.Cuid +import cool.graph.shared.functions._ +import cool.graph.shared.models.Project +import cool.graph.utils.future.FutureUtils._ +import play.api.libs.json.{JsError, JsSuccess, Json} +import spray.json.{JsArray, JsObject, JsString} +import spray.json._ + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DevFunctionEnvironment()(implicit system: ActorSystem, materializer: ActorMaterializer) extends FunctionEnvironment { + import Conversions._ + import system.dispatcher + + private val akkaHttp = Http()(system) + + val functionEndpointInternal: String = + sys.env.getOrElse("FUNCTION_ENDPOINT_INTERNAL", sys.error("FUNCTION_ENDPOINT_INTERNAL env var required for dev function deployment.")).stripSuffix("/") + + val functionEndpointExternal: String = + sys.env.getOrElse("FUNCTION_ENDPOINT_EXTERNAL", sys.error("FUNCTION_ENDPOINT_EXTERNAL env var required for dev function deployment.")).stripSuffix("/") + + override def getTemporaryUploadUrl(project: Project): Future[String] = { + val deployId = Cuid.createCuid() + Future.successful(s"$functionEndpointExternal/functions/files/${project.id}/$deployId") + } + + override def deploy(project: Project, externalFile: ExternalFile, name: String): Future[DeployResponse] = { + val body = Json.toJson(DeploymentInput(externalFile.url, externalFile.devHandler, name)).toString() + + val akkaRequest = HttpRequest( + uri = s"$functionEndpointInternal/functions/deploy/${project.id}", + method = HttpMethods.POST, + entity = HttpEntity(ContentTypes.`application/json`, body.toString) + ) + + akkaHttp.singleRequest(akkaRequest).flatMap(Unmarshal(_).to[String]).toFutureTry.flatMap { + case Success(responseBody) => + Json.parse(responseBody).validate[StatusResponse] match { + case JsSuccess(status, _) => + if (status.success) { + Future.successful(DeploySuccess()) + } else { + Future.successful(DeployFailure(new Exception(status.error.getOrElse("")))) + } + + case JsError(e) => + Future.successful(DeployFailure(new Exception(e.toString))) + } + + case Failure(e) => + Future.successful(DeployFailure(e)) + } + } + + override def invoke(project: Project, name: String, event: String): Future[InvokeResponse] = { + val body = Json.toJson(FunctionInvocation(name, event)).toString() + + val akkaRequest = HttpRequest( + uri = s"$functionEndpointInternal/functions/invoke/${project.id}", + method = HttpMethods.POST, + entity = HttpEntity(ContentTypes.`application/json`, body.toString) + ) + + akkaHttp.singleRequest(akkaRequest).flatMap(Unmarshal(_).to[String]).toFutureTry.flatMap { + case Success(responseBody) => + Json.parse(responseBody).validate[FunctionInvocationResult] match { + case JsSuccess(result, _) => + val returnValue = Try { result.value.map(_.toString).getOrElse("").parseJson } match { + case Success(parsedJson) => parsedJson + case Failure(_) => JsObject("error" -> JsString("Function did not return a valid response. Check your function code / logs.")) + } + + val output = JsObject( + "logs" -> JsArray( + JsObject("stdout" -> JsString(result.stdout.getOrElse(""))), + JsObject("stderr" -> JsString(result.stderr.getOrElse(""))), + JsObject("error" -> JsString(result.error.getOrElse(""))) + ), + "response" -> returnValue + ).compactPrint + + if (result.success) { + Future.successful(InvokeSuccess(output)) + } else { + Future.successful(InvokeFailure(new Exception(output))) + } + + case JsError(e) => + Future.successful(InvokeFailure(new Exception(e.toString))) + } + + case Failure(e) => + Future.successful(InvokeFailure(e)) + } + } + + private def convertResponse(akkaResponse: HttpResponse): Future[String] = Unmarshal(akkaResponse).to[String] +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/Protocol.scala b/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/Protocol.scala new file mode 100644 index 0000000000..4ae342ee05 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/functions/dev/Protocol.scala @@ -0,0 +1,22 @@ +package cool.graph.shared.functions.dev + +import play.api.libs.json.{JsObject, Json} + +object Conversions { + implicit val deploymentInputFormat = Json.format[DeploymentInput] + implicit val statusResponseFormat = Json.format[StatusResponse] + implicit val functionInvocationFormat = Json.format[FunctionInvocation] + implicit val invacationResultFormat = Json.format[FunctionInvocationResult] +} + +case class DeploymentInput(zipUrl: String, handlerPath: String, functionName: String) +case class StatusResponse(success: Boolean, error: Option[String] = None) +case class FunctionInvocation(functionName: String, input: String) + +case class FunctionInvocationResult( + success: Boolean, + error: Option[String], + value: Option[JsObject], + stdout: Option[String], + stderr: Option[String] +) diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/functions/lambda/LambdaFunctionEnvironment.scala b/server/backend-shared/src/main/scala/cool/graph/shared/functions/lambda/LambdaFunctionEnvironment.scala new file mode 100644 index 0000000000..6604189c39 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/functions/lambda/LambdaFunctionEnvironment.scala @@ -0,0 +1,180 @@ +package cool.graph.shared.functions.lambda + +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.concurrent.{CompletableFuture, CompletionException} + +import com.amazonaws.HttpMethod +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.s3.AmazonS3ClientBuilder +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest +import cool.graph.cuid.Cuid +import cool.graph.shared.functions._ +import cool.graph.shared.models.Project +import software.amazon.awssdk.auth.{AwsCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.lambda.LambdaAsyncClient +import software.amazon.awssdk.services.lambda.model.{ + CreateFunctionRequest, + FunctionCode, + InvocationType, + InvokeRequest, + LogType, + ResourceConflictException, + Runtime, + UpdateFunctionCodeRequest, + UpdateFunctionCodeResponse, + UpdateFunctionConfigurationRequest +} +import spray.json.{JsArray, JsObject, JsString} + +import scala.compat.java8.FutureConverters._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scalaj.http.Base64 + +object LambdaFunctionEnvironment { + def parseLambdaLogs(logs: String): Vector[JsObject] = { + val lines = logs.split("\\n").filter(line => !line.isEmpty && !line.startsWith("START") && !line.startsWith("END") && !line.startsWith("REPORT")) + + val groupings = lines.foldLeft(Vector.empty[Vector[String]])((acc: Vector[Vector[String]], next: String) => { + if (next.matches("\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+.*")) { + acc :+ Vector(next) + } else { + acc.dropRight(1) :+ (acc.last :+ next) + } + }) + + groupings.map(lineGroup => { + val segments = lineGroup.head.split("[\\t]", -1) + val timeStamp = segments.head + + JsObject(timeStamp -> JsString((Vector(segments.last) ++ lineGroup.tail).mkString("\n").stripLineEnd.trim)) + }) + } +} + +case class LambdaFunctionEnvironment(accessKeyId: String, secretAccessKey: String) extends FunctionEnvironment { + val lambdaCredentials = new StaticCredentialsProvider(new AwsCredentials(accessKeyId, secretAccessKey)) + + def lambdaClient(project: Project): LambdaAsyncClient = + LambdaAsyncClient + .builder() + .region(awsRegion(project)) + .credentialsProvider(lambdaCredentials) + .build() + + val s3Credentials = new BasicAWSCredentials(accessKeyId, secretAccessKey) + + def s3Client(project: Project) = { + val region = awsRegion(project).toString + + AmazonS3ClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(s3Credentials)) + .withEndpointConfiguration(new EndpointConfiguration(s"s3-$region.amazonaws.com", region)) + .build + } + + val deployBuckets = Map( + cool.graph.shared.models.Region.EU_WEST_1 -> "graphcool-lambda-deploy-eu-west-1", + cool.graph.shared.models.Region.US_WEST_2 -> "graphcool-lambda-deploy-us-west-2", + cool.graph.shared.models.Region.AP_NORTHEAST_1 -> "graphcool-lambda-deploy-ap-northeast-1" + ) + + def awsRegion(project: Project) = project.region match { + case cool.graph.shared.models.Region.EU_WEST_1 => Region.EU_WEST_1 + case cool.graph.shared.models.Region.US_WEST_2 => Region.US_WEST_2 + case cool.graph.shared.models.Region.AP_NORTHEAST_1 => Region.AP_NORTHEAST_1 + case _ => Region.EU_WEST_1 + } + + def getTemporaryUploadUrl(project: Project): Future[String] = { + val expiration = new java.util.Date() + val oneHourFromNow = expiration.getTime + 1000 * 60 * 60 + + expiration.setTime(oneHourFromNow) + + val generatePresignedUrlRequest = new GeneratePresignedUrlRequest(deployBuckets(project.region), Cuid.createCuid()) + + generatePresignedUrlRequest.setMethod(HttpMethod.PUT) + generatePresignedUrlRequest.setExpiration(expiration) + + Future.successful(s3Client(project).generatePresignedUrl(generatePresignedUrlRequest).toString) + } + + def deploy(project: Project, externalFile: ExternalFile, name: String): Future[DeployResponse] = { + val key = externalFile.url.split("\\?").head.split("/").last + + def create = + lambdaClient(project) + .createFunction( + CreateFunctionRequest.builder + .code(FunctionCode.builder().s3Bucket(deployBuckets(project.region)).s3Key(key).build()) + .functionName(lambdaFunctionName(project, name)) + .handler(externalFile.lambdaHandler) + .role("arn:aws:iam::484631947980:role/service-role/defaultLambdaFunctionRole") + .timeout(15) + .memorySize(512) + .runtime(Runtime.Nodejs610) + .build()) + .toScala + .map(_ => DeploySuccess()) + + def update = { + val updateCode: CompletableFuture[UpdateFunctionCodeResponse] = lambdaClient(project) + .updateFunctionCode( + UpdateFunctionCodeRequest.builder + .s3Bucket(deployBuckets(project.region)) + .s3Key(key) + .functionName(lambdaFunctionName(project, name)) + .build() + ) + + lazy val updateConfiguration = lambdaClient(project) + .updateFunctionConfiguration( + UpdateFunctionConfigurationRequest.builder + .functionName(lambdaFunctionName(project, name)) + .handler(externalFile.lambdaHandler) + .build() + ) + + for { + _ <- updateCode.toScala + _ <- updateConfiguration.toScala + } yield DeploySuccess() + } + + create.recoverWith { + case e: CompletionException if e.getCause.isInstanceOf[ResourceConflictException] => update.recover { case e: Throwable => DeployFailure(e) } + case e: Throwable => Future.successful(DeployFailure(e)) + } + } + + def invoke(project: Project, name: String, event: String): Future[InvokeResponse] = { + lambdaClient(project) + .invoke( + InvokeRequest.builder + .functionName(lambdaFunctionName(project, name)) + .invocationType(InvocationType.RequestResponse) + .logType(LogType.Tail) // return last 4kb of function logs + .payload(ByteBuffer.wrap(event.getBytes("utf-8"))) + .build() + ) + .toScala + .map(response => + if (response.statusCode() == 200) { + val returnValue = StandardCharsets.UTF_8.decode(response.payload()).toString + val logMessage = Base64.decodeString(response.logResult()) + val logLines = LambdaFunctionEnvironment.parseLambdaLogs(logMessage) + val returnValueWithLogEnvelope = s"""{"logs":${JsArray(logLines).compactPrint}, "response": $returnValue}""" + + InvokeSuccess(returnValue = returnValueWithLogEnvelope) + } else { + InvokeFailure(sys.error(s"statusCode was ${response.statusCode()}")) + }) + .recover { case e: Throwable => InvokeFailure(e) } + } + + private def lambdaFunctionName(project: Project, functionName: String) = s"${project.id}-$functionName" +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/logging/LogData.scala b/server/backend-shared/src/main/scala/cool/graph/shared/logging/LogData.scala new file mode 100644 index 0000000000..4cbfc20d4b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/logging/LogData.scala @@ -0,0 +1,49 @@ +package cool.graph.shared.logging + +import cool.graph.JsonFormats +import spray.json.{DefaultJsonProtocol, _} + +object LogKey extends Enumeration { + val RequestNew = Value("request/new") + val RequestQuery = Value("request/query") + val RequestComplete = Value("request/complete") + val RequestMetricsFields = Value("request/metrics/fields") + val RequestMetricsSql = Value("request/metrics/sql") + val RequestMetricsMutactions = Value("request/metrics/mutactions") + val UnhandledError = Value("error/unhandled") + val HandledError = Value("error/handled") + val MutactionWebhook = Value("mutaction/webhook") + val AlgoliaSyncQuery = Value("mutaction/algoliaSyncQuery") + val ActionHandlerWebhookComplete = Value("action_handler/webhook/complete") + val IntegrityViolation = Value("integrity/violation") + val RequestProxyBegin = Value("request/proxy/begin") + val RequestProxyData = Value("request/proxy/data") +} + +case class LogData( + key: LogKey.Value, + requestId: String, + clientId: Option[String] = None, + projectId: Option[String] = None, + message: Option[String] = None, + payload: Option[Map[String, Any]] = None +) { + import LogFormats._ + + lazy val json: String = this.toJson(logDataFormat).compactPrint +} + +object LogFormats extends DefaultJsonProtocol { + import JsonFormats.AnyJsonFormat + + implicit object LogKeyJsonFormat extends RootJsonFormat[LogKey.Value] { + def write(obj: LogKey.Value): JsValue = JsString(obj.toString) + + def read(json: JsValue): LogKey.Value = json match { + case JsString(str) => LogKey.withName(str) + case _ => throw new DeserializationException("Enum string expected") + } + } + + implicit val logDataFormat: RootJsonFormat[LogData] = jsonFormat(LogData, "log_key", "request_id", "client_id", "project_id", "message", "payload") +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/logging/RequestLogger.scala b/server/backend-shared/src/main/scala/cool/graph/shared/logging/RequestLogger.scala new file mode 100644 index 0000000000..2680ad5cfa --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/logging/RequestLogger.scala @@ -0,0 +1,42 @@ +package cool.graph.shared.logging + +import cool.graph.cuid.Cuid.createCuid + +class RequestLogger(requestIdPrefix: String, log: Function[String, Unit]) { + val requestId: String = requestIdPrefix + ":" + createCuid() + var requestBeginningTime: Option[Long] = None + + def query(query: String, args: String): Unit = { + log( + LogData( + key = LogKey.RequestQuery, + requestId = requestId, + payload = Some(Map("query" -> query, "arguments" -> args)) + ).json + ) + } + + def begin: String = { + requestBeginningTime = Some(System.currentTimeMillis()) + log(LogData(LogKey.RequestNew, requestId).json) + + requestId + } + + def end(projectId: Option[String] = None, clientId: Option[String] = None): Unit = + requestBeginningTime match { + case None => + sys.error("you must call begin before end") + + case Some(beginTime) => + log( + LogData( + key = LogKey.RequestComplete, + requestId = requestId, + projectId = projectId, + clientId = clientId, + payload = Some(Map("request_duration" -> (System.currentTimeMillis() - beginTime))) + ).json + ) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/models/Function.scala b/server/backend-shared/src/main/scala/cool/graph/shared/models/Function.scala new file mode 100644 index 0000000000..9160ea1346 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/models/Function.scala @@ -0,0 +1,425 @@ +package cool.graph.shared.models + +import cool.graph.DataItem +import cool.graph.Types.Id +import cool.graph.client.UserContext +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.cuid.Cuid +import cool.graph.shared.{TypeInfo, models} +import cool.graph.shared.errors.UserInputErrors.{InvalidSchema, SchemaExtensionParseError} +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.FunctionType.FunctionType +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.subscriptions.schemas.QueryTransformer +import sangria.ast +import sangria.schema +import sangria.schema.{ListType, ObjectType, OptionType, OutputType} +import sangria.parser.QueryParser + +object FunctionBinding extends Enumeration { + type FunctionBinding = Value + val CUSTOM_MUTATION: models.FunctionBinding.Value = Value("CUSTOM_MUTATION") + val CUSTOM_QUERY: models.FunctionBinding.Value = Value("CUSTOM_QUERY") + val SERVERSIDE_SUBSCRIPTION: models.FunctionBinding.Value = Value("SERVERSIDE_SUBSCRIPTION") + val TRANSFORM_REQUEST: models.FunctionBinding.Value = Value("TRANSFORM_REQUEST") + val TRANSFORM_ARGUMENT: models.FunctionBinding.Value = Value("TRANSFORM_ARGUMENT") + val PRE_WRITE: models.FunctionBinding.Value = Value("PRE_WRITE") + val TRANSFORM_PAYLOAD: models.FunctionBinding.Value = Value("TRANSFORM_PAYLOAD") + val TRANSFORM_RESPONSE: models.FunctionBinding.Value = Value("TRANSFORM_RESPONSE") +} + +object FunctionType extends Enumeration { + type FunctionType = Value + val WEBHOOK: models.FunctionType.Value = Value("WEBHOOK") + val CODE: models.FunctionType.Value = Value("AUTH0") +} + +sealed trait Function { + def id: Id + def name: String + def isActive: Boolean + def delivery: FunctionDelivery + def binding: FunctionBinding +} + +case class ServerSideSubscriptionFunction( + id: Id, + name: String, + isActive: Boolean, + query: String, + queryFilePath: Option[String] = None, + delivery: FunctionDelivery +) extends Function { + def isServerSideSubscriptionFor(model: Model, mutationType: ModelMutationType): Boolean = { + val queryDoc = QueryParser.parse(query).get + val modelNameInQuery = QueryTransformer.getModelNameFromSubscription(queryDoc).get + val mutationTypesInQuery = QueryTransformer.getMutationTypesFromSubscription(queryDoc) + model.name == modelNameInQuery && mutationTypesInQuery.contains(mutationType) + } + + def binding: models.FunctionBinding.Value = FunctionBinding.SERVERSIDE_SUBSCRIPTION +} + +case class RequestPipelineFunction( + id: Id, + name: String, + isActive: Boolean, + binding: FunctionBinding, + modelId: Id, + operation: RequestPipelineOperation, + delivery: FunctionDelivery +) extends Function + +sealed trait FunctionDelivery { + val functionType: FunctionType + + def update(headers: Option[Seq[(String, String)]], + functionType: Option[FunctionType], + webhookUrl: Option[String], + inlineCode: Option[String], + auth0Id: Option[String], + codeFilePath: Option[String] = None): FunctionDelivery = { + + // FIXME: how could we do a proper validation before calling those .get()? + (functionType.getOrElse(this.functionType), this) match { + case (FunctionType.WEBHOOK, webhook: WebhookFunction) => + webhook.copy(url = webhookUrl.getOrElse(webhook.url), headers = headers.getOrElse(webhook.headers)) + + case (FunctionType.WEBHOOK, _) => + WebhookFunction(url = webhookUrl.get, headers = headers.getOrElse(Seq.empty)) + + case (FunctionType.CODE, auth0Fn: Auth0Function) => + auth0Fn.copy( + code = inlineCode.getOrElse(auth0Fn.code), + codeFilePath = codeFilePath.orElse(auth0Fn.codeFilePath), + url = webhookUrl.getOrElse(auth0Fn.url), + auth0Id = auth0Id.getOrElse(auth0Fn.auth0Id), + headers = headers.getOrElse(auth0Fn.headers) + ) + case (FunctionType.CODE, fn: ManagedFunction) => + fn + + case (FunctionType.CODE, _) => + Auth0Function( + code = inlineCode.get, + codeFilePath = codeFilePath, + url = webhookUrl.get, + auth0Id = auth0Id.get, + headers = headers.getOrElse(Seq.empty) + ) + + case (_, _) => + sys.error("This clause is impossible to reach, but Scala Enumerations are stupid so the compiler cannot check it.") + } + } +} +sealed trait CodeFunction extends FunctionDelivery { + val code: String +} + +sealed trait HttpFunction extends FunctionDelivery { + def headers: Seq[(String, String)] + def url: String +} + +//case class LambdaFunction(code: String, arn: String) extends CodeFunction { +// override val functionType: FunctionType = FunctionType.LAMBDA +//} + +case class Auth0Function(code: String, codeFilePath: Option[String] = None, auth0Id: String, url: String, headers: Seq[(String, String)]) + extends CodeFunction + with HttpFunction { + override val functionType: FunctionType = FunctionType.CODE +} + +// Function to be deployed and invoked by the function runtime configured for the cluster +case class ManagedFunction(codeFilePath: Option[String] = None) extends FunctionDelivery { + override val functionType = FunctionType.CODE +} + +case class WebhookFunction(url: String, headers: Seq[(String, String)]) extends HttpFunction { + override val functionType: FunctionType = FunctionType.WEBHOOK +} + +case class FunctionDataItems(isNull: Boolean, dataItems: Vector[DataItem]) + +case class FreeType( + name: String, + isList: Boolean, + isRequired: Boolean, + fields: List[Field] +) { + + def createOutputType(modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[_]): schema.ObjectType[UserContext, DataItem] = { + ObjectType( + name = this.name, + description = Some(this.name), + fieldsFn = () => { this.fields.map(modelObjectTypesBuilder.mapCustomMutationField) }, + interfaces = List(), + instanceCheck = (value: Any, valClass: Class[_], tpe: ObjectType[UserContext, _]) => + value match { + case DataItem(_, _, Some(tpe.name)) => true + case DataItem(_, _, Some(_)) => false + case _ => valClass.isAssignableFrom(value.getClass) + }, + astDirectives = Vector.empty + ) + } + + def getFieldType(modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[_]): OutputType[Equals] = { + val fieldType = (this.isList, this.isRequired) match { + case (false, false) => OptionType(createOutputType(modelObjectTypesBuilder)) + case (false, true) => createOutputType(modelObjectTypesBuilder) + case (true, false) => OptionType(ListType(createOutputType(modelObjectTypesBuilder))) + case (true, true) => ListType(createOutputType(modelObjectTypesBuilder)) + } + fieldType + } + + def adjustResolveType(x: FunctionDataItems): Equals = { + (this.isList, this.isRequired, x.isNull) match { + case (_, _, true) => None + case (false, false, false) => x.dataItems.headOption + case (false, true, false) => x.dataItems.head + case (true, false, false) => Option(x.dataItems) + case (true, true, false) => x.dataItems + } + } +} + +sealed trait SchemaExtensionFunction extends cool.graph.shared.models.Function { + def schema: String + def schemaFilePath: Option[String] +} + +object SchemaExtensionFunction { + def createFunction( + id: Id, + name: String, + isActive: Boolean, + schema: String, + delivery: FunctionDelivery, + schemaFilePath: Option[String] = None + ): SchemaExtensionFunction = { + FunctionSchemaParser.determineBinding(name, schema) match { + case FunctionBinding.CUSTOM_QUERY => + CustomQueryFunction( + id = id: Id, + name = name: String, + isActive = isActive: Boolean, + schema = schema: String, + schemaFilePath = schemaFilePath, + delivery = delivery: FunctionDelivery + ) + + case FunctionBinding.CUSTOM_MUTATION => + CustomMutationFunction( + id = id: Id, + name = name: String, + isActive = isActive: Boolean, + schema = schema: String, + schemaFilePath = schemaFilePath, + delivery = delivery: FunctionDelivery + ) + + case _ => + throw SchemaExtensionParseError(name, "Schema did not contain a schema extension") + } + } +} + +case class CustomMutationFunction( + id: Id, + name: String, + isActive: Boolean, + schema: String, + schemaFilePath: Option[String] = None, + delivery: FunctionDelivery, + mutationName: String, + arguments: List[Field], + payloadType: FreeType +) extends cool.graph.shared.models.Function + with SchemaExtensionFunction { + def binding: models.FunctionBinding.Value = FunctionBinding.CUSTOM_MUTATION +} + +object CustomMutationFunction { + def apply( + id: Id, + name: String, + isActive: Boolean, + schema: String, + schemaFilePath: Option[String], + delivery: FunctionDelivery + ): CustomMutationFunction = { + val parsedSchema = FunctionSchemaParser.parse( + functionName = name, + schema = schema, + definitionName = "Mutation", + extendError = """Must extend Mutation: extend type Mutation { myMutation(arg1: Int): MyPayload }""", + extendContentError = """Must contain a mutation: extend type Mutation { myMutation(arg1: Int): MyPayload }""" + ) + new CustomMutationFunction( + id = id, + name = name, + isActive = isActive, + schema = schema, + schemaFilePath = schemaFilePath, + delivery = delivery, + mutationName = parsedSchema.name, + arguments = parsedSchema.args, + payloadType = parsedSchema.payloadType + ) + } +} + +case class CustomQueryFunction( + id: Id, + name: String, + isActive: Boolean, + schema: String, + schemaFilePath: Option[String] = None, + delivery: FunctionDelivery, + queryName: String, + arguments: List[Field], + payloadType: FreeType +) extends cool.graph.shared.models.Function + with SchemaExtensionFunction { + def binding: models.FunctionBinding.Value = FunctionBinding.CUSTOM_QUERY +} + +object CustomQueryFunction { + def apply( + id: Id, + name: String, + isActive: Boolean, + schema: String, + schemaFilePath: Option[String], + delivery: FunctionDelivery + ): CustomQueryFunction = { + val parsedSchema = FunctionSchemaParser.parse( + functionName = name, + schema, + definitionName = "Query", + extendError = """Must extend Query: extend type Query { myQuery(arg1: Int): MyPayload }""", + extendContentError = """Must contain a query: extend type Query { myQuery(arg1: Int): MyPayload }""" + ) + new CustomQueryFunction( + id = id, + name = name, + isActive = isActive, + schema = schema, + schemaFilePath = schemaFilePath, + delivery = delivery, + queryName = parsedSchema.name, + arguments = parsedSchema.args, + payloadType = parsedSchema.payloadType + ) + } +} + +protected case class ParsedSchema(name: String, args: List[Field], payloadType: FreeType) + +object FunctionSchemaParser { + def parse(functionName: String, schema: String, definitionName: String, extendError: String, extendContentError: String): ParsedSchema = { + val doc = sangria.parser.QueryParser.parse(schema).getOrElse(throw SchemaExtensionParseError(functionName, s"""Could not parse schema: $schema""")) + + val extensionDefinition = (doc.definitions collect { + case x: ast.TypeExtensionDefinition if x.definition.name == definitionName => x.definition + }).headOption.getOrElse(throw SchemaExtensionParseError(functionName, extendError)) + + val actualOperationDef: ast.FieldDefinition = + extensionDefinition.fields.headOption.getOrElse(throw SchemaExtensionParseError(functionName, extendContentError)) + val payloadTypeName = actualOperationDef.fieldType.namedType.name + + if (extensionDefinition.fields.length > 1) + throw SchemaExtensionParseError(functionName, """Only one query or mutation can be added in a schema extension""") + + val args: List[Field] = actualOperationDef.arguments.map(x => mapInputValueDefinitionToField(functionName, x)).toList + + val payloadTypeDefinitions = doc.definitions.collect { case x: ast.ObjectTypeDefinition => x } + + if (payloadTypeDefinitions.isEmpty) + throw SchemaExtensionParseError(functionName, """Must provide return type. For example: type MyPayload { someField: Boolean }""") + + if (payloadTypeDefinitions.length > 1) + throw SchemaExtensionParseError(functionName, """Only one return type can be specified in a schema extension""") + + val selectedPayloadTypeDefinition = payloadTypeDefinitions + .find(_.name == payloadTypeName) + .getOrElse(throw SchemaExtensionParseError( + functionName, + s"""Return type must match a type in the schema extension. $payloadTypeName did not match any of ${payloadTypeDefinitions + .map(_.name) + .mkString(", ")}""" + )) + + val typeFields = selectedPayloadTypeDefinition.fields.map(x => mapFieldDefinitionToField(functionName, x)).toList + + if (typeFields.exists( + field => field.name.toLowerCase == "id" && field.typeIdentifier != TypeIdentifier.String && field.typeIdentifier != TypeIdentifier.GraphQLID)) + throw SchemaExtensionParseError(functionName, """The name id is reserved for fields with type ID or String.""") + + val mutationType = actualOperationDef.fieldType match { + case ast.NamedType(name, _) => + FreeType(name = name, isList = false, isRequired = false, fields = typeFields) + + case ast.NotNullType(ast.NamedType(name, _), _) => + FreeType(name = name, isList = false, isRequired = true, fields = typeFields) + + case ast.ListType(ast.NotNullType(ast.NamedType(name, _), _), _) => + FreeType(name = name, isList = true, isRequired = false, fields = typeFields) + + case ast.NotNullType(ast.ListType(ast.NotNullType(ast.NamedType(name, _), _), _), _) => + FreeType(name = name, isList = true, isRequired = true, fields = typeFields) + + case _ => + throw InvalidSchema("Invalid field type definition detected. Valid field type formats: Int, Int!, [Int!], [Int!]! for example.") + } + + ParsedSchema(actualOperationDef.name, args, mutationType) + } + + def determineBinding(functionName: String, schema: String): FunctionBinding = { + val doc = sangria.parser.QueryParser.parse(schema).getOrElse(throw SchemaExtensionParseError(functionName, s"""Could not parse schema: $schema""")) + val typeExtensionDefinitions = doc.definitions collect { case x: ast.TypeExtensionDefinition => x } + + if (typeExtensionDefinitions.length > 1) throw SchemaExtensionParseError(functionName, "Schema must not contain more than one type extension") + + val extensionName = typeExtensionDefinitions.headOption + .getOrElse(throw SchemaExtensionParseError(functionName, "Schema must contain a type extension")) + .definition + .name + + extensionName match { + case "Mutation" => FunctionBinding.CUSTOM_MUTATION + case "Query" => FunctionBinding.CUSTOM_QUERY + case x => throw SchemaExtensionParseError(functionName, s"Must extend either Query or Mutation. Not '$x'") + + } + } + + private def mapInputValueDefinitionToField(functionName: String, ivd: ast.InputValueDefinition): Field = + typeInfoToField(functionName, ivd.name, TypeInfo.extract(f = ivd, allowNullsInScalarList = true)) + + private def mapFieldDefinitionToField(functionName: String, fd: ast.FieldDefinition): Field = + typeInfoToField(functionName, fd.name, TypeInfo.extract(fd, None, Seq.empty, allowNullsInScalarList = true)) + + private def typeInfoToField(functionName: String, fieldName: String, typeInfo: TypeInfo) = { + if (typeInfo.typeIdentifier == TypeIdentifier.Relation) + throw SchemaExtensionParseError(functionName, s"Relations are currently not supported. Field '$fieldName'") + + Field( + id = Cuid.createCuid(), + name = fieldName, + typeIdentifier = typeInfo.typeIdentifier, + description = None, + isRequired = typeInfo.isRequired, + isList = typeInfo.isList, + isUnique = false, + isSystem = false, + isReadonly = false + ) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/models/ManagedFields.scala b/server/backend-shared/src/main/scala/cool/graph/shared/models/ManagedFields.scala new file mode 100644 index 0000000000..a8453fc38f --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/models/ManagedFields.scala @@ -0,0 +1,37 @@ +package cool.graph.shared.models + +import cool.graph.shared.models.IntegrationName.IntegrationName +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier + +object ManagedFields { + case class ManagedField( + defaultName: String, + typeIdentifier: TypeIdentifier, + description: Option[String] = None, + isUnique: Boolean = false, + isReadonly: Boolean = true + ) + + def apply(authProviderName: IntegrationName): List[ManagedField] = { + authProviderName match { + case IntegrationName.AuthProviderEmail => emailAuthProviderManagedFields + case IntegrationName.AuthProviderDigits => digisAuthProviderManagedFields + case IntegrationName.AuthProviderAuth0 => auth0AuthProviderManagedFields + case _ => throw new Exception(s"$authProviderName is not an AuthProvider") + } + } + + private lazy val emailAuthProviderManagedFields = + List( + ManagedField(defaultName = "email", typeIdentifier = TypeIdentifier.String, isUnique = true, isReadonly = true), + ManagedField(defaultName = "password", typeIdentifier = TypeIdentifier.String, isReadonly = true) + ) + + private lazy val digisAuthProviderManagedFields = List( + ManagedField(defaultName = "digitsId", typeIdentifier = TypeIdentifier.String, isUnique = true) + ) + + private lazy val auth0AuthProviderManagedFields = List(auth0UserId) + + lazy val auth0UserId = ManagedField(defaultName = "auth0UserId", typeIdentifier = TypeIdentifier.String, isUnique = true) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/models/ModelParser.scala b/server/backend-shared/src/main/scala/cool/graph/shared/models/ModelParser.scala new file mode 100644 index 0000000000..032eee0e13 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/models/ModelParser.scala @@ -0,0 +1,125 @@ +package cool.graph.shared.models + +import cool.graph.shared.ApiMatrixFactory +import scaldi.{Injectable, Injector} + +// returns Models, fields etc from a project taking ApiMatrix into account +object ModelParser extends Injectable { + + def action(project: Project, actionId: String): Option[Action] = { + project.actions.find(_.id == actionId) + } + + def relation(project: Project, relationId: String, injector: Injector): Option[Relation] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + apiMatrix.filterRelations(project.relations).find(_.id == relationId) + } + + def seat(project: Project, seatId: String): Option[Seat] = { + project.seats.find(_.id == seatId) + } + + def packageDefinition(project: Project, packageDefinitionId: String): Option[PackageDefinition] = { + project.packageDefinitions.find(_.id == packageDefinitionId) + } + + def relationByName(project: Project, relationName: String, injector: Injector): Option[Relation] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + project.relations.find(relation => relation.name == relationName && apiMatrix.includeRelation(relation)) + } + + def actionTriggerMutationModel(project: Project, actionTriggerMutationModelId: String): Option[ActionTriggerMutationModel] = { + project.actions + .flatMap(_.triggerMutationModel) + .find(_.id == actionTriggerMutationModelId) + } + + def actionTriggerMutationRelation(project: Project, actionTriggerMutationRelationId: String): Option[ActionTriggerMutationRelation] = { + project.actions + .flatMap(_.triggerMutationRelation) + .find(_.id == actionTriggerMutationRelationId) + } + + def actionHandlerWebhook(project: Project, actionHandlerWebhookId: String): Option[ActionHandlerWebhook] = { + project.actions + .flatMap(_.handlerWebhook) + .find(_.id == actionHandlerWebhookId) + } + + def function(project: Project, functionId: String): Option[Function] = { + project.functions.find(_.id == functionId) + } + + def modelPermission(project: Project, modelPermissionId: String): Option[ModelPermission] = { + project.models + .flatMap(_.permissions) + .find(_.id == modelPermissionId) + } + + def relationPermission(project: Project, relationPermissionId: String, injector: Injector): Option[RelationPermission] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + apiMatrix + .filterRelations(project.relations) + .flatMap(_.permissions) + .find(_.id == relationPermissionId) + } + + def integration( + project: Project, + integrationId: String + ): Option[Integration] = { + project.integrations + .find(_.id == integrationId) + } + + def algoliaSyncQuery( + project: Project, + algoliaSyncQueryId: String + ): Option[AlgoliaSyncQuery] = { + project.integrations + .collect { + case x: SearchProviderAlgolia => + x + } + .flatMap(_.algoliaSyncQueries) + .find(_.id == algoliaSyncQueryId) + } + + def field(project: Project, fieldId: String, injector: Injector): Option[Field] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + apiMatrix + .filterModels(project.models) + .flatMap(model => apiMatrix.filterFields(model.fields)) + .find(_.id == fieldId) + } + + def fieldByName(project: Project, modelName: String, fieldName: String, injector: Injector): Option[Field] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + apiMatrix + .filterModels(project.models) + .find(_.name == modelName) + .map(model => apiMatrix.filterFields(model.fields)) + .flatMap(_.find(_.name == fieldName)) + + } + + def model(project: Project, modelId: String, injector: Injector): Option[Model] = { + val apiMatrix = getApiMatrixFactory(injector).create(project) + apiMatrix.filterModels(project.models).find(_.id == modelId) + } + + def modelByName(project: Project, modelName: String, injector: Injector): Option[Model] = { + + val apiMatrix = getApiMatrixFactory(injector).create(project) + project.models.find( + model => + model.name == modelName && + apiMatrix.includeModel(model.name)) + + } + + private def getApiMatrixFactory(injector: Injector): ApiMatrixFactory = { + implicit val inj = injector + inject[ApiMatrixFactory] + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/models/Models.scala b/server/backend-shared/src/main/scala/cool/graph/shared/models/Models.scala new file mode 100644 index 0000000000..0586723072 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/models/Models.scala @@ -0,0 +1,1068 @@ +package cool.graph.shared.models + +import cool.graph.GCDataTypes.GCValue +import cool.graph.shared.errors.SystemErrors._ +import cool.graph.Types.Id +import cool.graph.cuid.Cuid +import cool.graph.deprecated.packageMocks._ +import cool.graph.shared.errors.{SystemErrors, UserInputErrors} +import cool.graph.shared.models.ActionTriggerMutationModelMutationType.ActionTriggerMutationModelMutationType +import cool.graph.shared.models.CustomRule.CustomRule +import cool.graph.shared.models.FieldConstraintType.FieldConstraintType +import cool.graph.shared.models.FunctionBinding.FunctionBinding +import cool.graph.shared.models.IntegrationName.IntegrationName +import cool.graph.shared.models.IntegrationType.IntegrationType +import cool.graph.shared.models.LogStatus.LogStatus +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.ModelOperation.ModelOperation +import cool.graph.shared.models.Region.Region +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models.SeatStatus.SeatStatus +import cool.graph.shared.models.UserType.UserType +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.{shared, _} +import org.joda.time.DateTime +import sangria.relay.Node +import sangria.schema.ScalarType +import scaldi.Injector + +import scala.util.control.NonFatal + +object CustomerSource extends Enumeration { + type CustomerSource = Value + val LEARN_RELAY = Value("LEARN_RELAY") + val LEARN_APOLLO = Value("LEARN_APOLLO") + val DOCS = Value("DOCS") + val WAIT_LIST = Value("WAIT_LIST") + val HOMEPAGE = Value("HOMEPAGE") +} + +object MutationLogStatus extends Enumeration { + type MutationLogStatus = Value + val SCHEDULED = Value("SCHEDULED") + val SUCCESS = Value("SUCCESS") + val FAILURE = Value("FAILURE") + val ROLLEDBACK = Value("ROLLEDBACK") +} + +case class Client( + id: Id, + auth0Id: Option[String] = None, + isAuth0IdentityProviderEmail: Boolean = false, + name: String, + email: String, + hashedPassword: String, + resetPasswordSecret: Option[String] = None, + source: CustomerSource.Value, + projects: List[Project] = List(), + createdAt: DateTime, + updatedAt: DateTime +) extends Node + +object SeatStatus extends Enumeration { + type SeatStatus = Value + val JOINED = Value("JOINED") + val INVITED_TO_PROJECT = Value("INVITED_TO_PROJECT") + val INVITED_TO_GRAPHCOOL = Value("INVITED_TO_GRAPHCOOL") +} + +object Region extends Enumeration { + type Region = Value + val EU_WEST_1 = Value("eu-west-1") + val US_WEST_2 = Value("us-west-2") + val AP_NORTHEAST_1 = Value("ap-northeast-1") +} + +case class Seat(id: String, status: SeatStatus, isOwner: Boolean, email: String, clientId: Option[String], name: Option[String]) extends Node + +case class PackageDefinition( + id: Id, + name: String, + definition: String, + formatVersion: Int +) extends Node + +object LogStatus extends Enumeration { + type LogStatus = Value + val SUCCESS = Value("SUCCESS") + val FAILURE = Value("FAILURE") +} + +object RequestPipelineOperation extends Enumeration { + type RequestPipelineOperation = Value + val CREATE = Value("CREATE") + val UPDATE = Value("UPDATE") + val DELETE = Value("DELETE") +} + +case class Log( + id: Id, + requestId: Option[String], + status: LogStatus, + duration: Int, + timestamp: DateTime, + message: String +) extends Node + +case class Project( + id: Id, + name: String, + projectDatabase: ProjectDatabase, + ownerId: Id, + alias: Option[String] = None, + revision: Int = 1, + webhookUrl: Option[String] = None, + models: List[Model] = List.empty, + relations: List[Relation] = List.empty, + enums: List[Enum] = List.empty, + actions: List[Action] = List.empty, + rootTokens: List[RootToken] = List.empty, + integrations: List[Integration] = List.empty, + seats: List[Seat] = List.empty, + allowQueries: Boolean = true, + allowMutations: Boolean = true, + packageDefinitions: List[PackageDefinition] = List.empty, + functions: List[Function] = List.empty, + featureToggles: List[FeatureToggle] = List.empty, + typePositions: List[Id] = List.empty, + isEjected: Boolean = false, + hasGlobalStarPermission: Boolean = false +) extends Node { + + val requestPipelineFunctions: List[RequestPipelineFunction] = functions.collect { case x: RequestPipelineFunction => x } + val serverSideSubscriptionFunctions: List[ServerSideSubscriptionFunction] = functions.collect { case x: ServerSideSubscriptionFunction => x } + val isGlobalEnumsEnabled: Boolean = featureToggles.exists(toggle => toggle.name == "isGlobalEnumsEnabled" && toggle.isEnabled) + val customQueryFunctions: List[CustomQueryFunction] = functions.collect { case x: CustomQueryFunction => x } + val customMutationFunctions: List[CustomMutationFunction] = + functions.collect { case x: CustomMutationFunction => x } ++ + experimentalAuthProvidersCustomMutations + .collect { case x: AppliedServerlessFunction => x } + .map(exp => + CustomMutationFunction( + id = Cuid.createCuid(), + name = exp.name, + isActive = true, + schema = "", + delivery = WebhookFunction(exp.url, Seq.empty), + mutationName = exp.name, + arguments = exp.input.map( + f => + Field(Cuid.createCuid(), + f.name, + f.typeIdentifier, + Some(f.description), + f.isRequired, + f.isList, + f.isUnique, + isSystem = false, + isReadonly = false)), + payloadType = FreeType( + name = s"${exp.name}Payload", + isList = false, //Todo this is dummy data + isRequired = false, // this too + fields = exp.output.map( + f => + Field(Cuid.createCuid(), + f.name, + f.typeIdentifier, + Some(f.description), + f.isRequired, + f.isList, + f.isUnique, + isSystem = false, + isReadonly = false) + ) + ) + )) + + // This will be deleted in a few weeks + lazy val installedPackages: List[InstalledPackage] = { + PackageMock.getInstalledPackagesForProject(this) ++ this.packageDefinitions + .flatMap(d => { + try { + Some(PackageParser.install(PackageParser.parse(d.definition), this)) + } catch { + case NonFatal(e) => + println(s"Package '${d.name}' has been deactivated because of '${e.getMessage}' '${e.getStackTrace.mkString("\n")}'") + None + } + }) + } + + def activeCustomQueryFunctions: List[CustomQueryFunction] = customQueryFunctions.filter(_.isActive) + def region: Region = projectDatabase.region + def activeCustomMutationFunctions: List[CustomMutationFunction] = customMutationFunctions.filter(_.isActive) + def schemaExtensionFunctions(): List[SchemaExtensionFunction] = customQueryFunctions ++ customMutationFunctions + + // This will be deleted in a few weeks + def experimentalAuthProvidersCustomMutations: List[AppliedFunction] = installedPackages.flatMap(_.function(FunctionBinding.CUSTOM_MUTATION)) + + // This will be deleted in a few weeks + def experimentalInterfacesForModel(model: Model): List[AppliedInterface] = installedPackages.flatMap(_.interfacesFor(model)) + + def requestPipelineFunctionForModel(model: Model, binding: FunctionBinding, operation: RequestPipelineOperation): Option[RequestPipelineFunction] = + requestPipelineFunctions.filter(_.isActive).find(x => x.modelId == model.id && x.binding == binding && x.operation == operation) + + def actionsFor(modelId: Types.Id, trigger: ActionTriggerMutationModelMutationType): List[Action] = { + this.actions.filter { action => + action.isActive && + action.triggerMutationModel.exists(_.modelId == modelId) && + action.triggerMutationModel.exists(_.mutationType == trigger) + } + } + + def serverSideSubscriptionFunctionsFor(model: Model, mutationType: ModelMutationType): Seq[ServerSideSubscriptionFunction] = { + serverSideSubscriptionFunctions + .filter(_.isActive) + .filter(_.isServerSideSubscriptionFor(model, mutationType)) + } + + def hasEnabledAuthProvider: Boolean = authProviders.exists(_.isEnabled) + def authProviders: List[AuthProvider] = integrations.collect { case authProvider: AuthProvider => authProvider } + + def searchProviderAlgolia: Option[SearchProviderAlgolia] = { + integrations + .collect { case searchProviderAlgolia: SearchProviderAlgolia => searchProviderAlgolia } + .find(_.name == IntegrationName.SearchProviderAlgolia) + } + + def getAuthProviderById(id: Id): Option[AuthProvider] = authProviders.find(_.id == id) + def getAuthProviderById_!(id: Id): AuthProvider = getAuthProviderById(id).getOrElse(throw SystemErrors.InvalidAuthProviderId(id)) + + def getServerSideSubscriptionFunction(id: Id): Option[ServerSideSubscriptionFunction] = serverSideSubscriptionFunctions.find(_.id == id) + def getServerSideSubscriptionFunction_!(id: Id): ServerSideSubscriptionFunction = + getServerSideSubscriptionFunction(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getRequestPipelineFunction(id: Id): Option[RequestPipelineFunction] = requestPipelineFunctions.find(_.id == id) + def getRequestPipelineFunction_!(id: Id): RequestPipelineFunction = getRequestPipelineFunction(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getSchemaExtensionFunction(id: Id): Option[SchemaExtensionFunction] = schemaExtensionFunctions().find(_.id == id) + def getSchemaExtensionFunction_!(id: Id): SchemaExtensionFunction = getSchemaExtensionFunction(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getCustomMutationFunction(id: Id): Option[CustomMutationFunction] = customMutationFunctions.find(_.id == id) + def getCustomMutationFunction_!(id: Id): CustomMutationFunction = getCustomMutationFunction(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getCustomQueryFunction(id: Id): Option[CustomQueryFunction] = customQueryFunctions.find(_.id == id) + def getCustomQueryFunction_!(id: Id): CustomQueryFunction = getCustomQueryFunction(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getFunctionById(id: Id): Option[Function] = functions.find(_.id == id) + def getFunctionById_!(id: Id): Function = getFunctionById(id).getOrElse(throw SystemErrors.InvalidFunctionId(id)) + + def getFunctionByName(name: String): Option[Function] = functions.find(_.name == name) + def getFunctionByName_!(name: String): Function = getFunctionByName(name).getOrElse(throw SystemErrors.InvalidFunctionName(name)) + + def getModelById(id: Id): Option[Model] = models.find(_.id == id) + def getModelById_!(id: Id): Model = getModelById(id).getOrElse(throw SystemErrors.InvalidModelId(id)) + + def getModelByModelPermissionId(id: Id): Option[Model] = models.find(_.permissions.exists(_.id == id)) + def getModelByModelPermissionId_!(id: Id): Model = getModelByModelPermissionId(id).getOrElse(throw SystemErrors.InvalidModelPermissionId(id)) + + def getRelationByRelationPermissionId(id: Id): Option[Relation] = relations.find(_.permissions.exists(_.id == id)) + def getRelationByRelationPermissionId_!(id: Id): Relation = + relations.find(_.permissions.exists(_.id == id)).getOrElse(throw SystemErrors.InvalidRelationPermissionId(id)) + + def getActionById(id: Id): Option[Action] = actions.find(_.id == id) + def getActionById_!(id: Id): Action = getActionById(id).getOrElse(throw SystemErrors.InvalidActionId(id)) + + def getRootTokenById(id: String): Option[RootToken] = rootTokens.find(_.id == id) + def getRootTokenById_!(id: String): RootToken = getRootTokenById(id).getOrElse(throw UserInputErrors.InvalidRootTokenId(id)) + + def getRootTokenByName(name: String): Option[RootToken] = rootTokens.find(_.name == name) + def getRootTokenByName_!(name: String): RootToken = getRootTokenById(name).getOrElse(throw UserInputErrors.InvalidRootTokenName(name)) + + // note: mysql columns are case insensitive, so we have to be as well + def getModelByName(name: String): Option[Model] = models.find(_.name.toLowerCase() == name.toLowerCase()) + def getModelByName_!(name: String): Model = getModelByName(name).getOrElse(throw SystemErrors.InvalidModel(s"No model with name: $name found.")) + + def getModelByFieldId(id: Id): Option[Model] = models.find(_.fields.exists(_.id == id)) + def getModelByFieldId_!(id: Id): Model = getModelByFieldId(id).getOrElse(throw SystemErrors.InvalidModel(s"No model with a field with id: $id found.")) + + def getFieldById(id: Id): Option[Field] = models.flatMap(_.fields).find(_.id == id) + def getFieldById_!(id: Id): Field = getFieldById(id).getOrElse(throw SystemErrors.InvalidFieldId(id)) + + def getFieldConstraintById(id: Id): Option[FieldConstraint] = { + val fields = models.flatMap(_.fields) + val constraints = fields.flatMap(_.constraints) + constraints.find(_.id == id) + } + def getFieldConstraintById_!(id: Id): FieldConstraint = getFieldConstraintById(id).getOrElse(throw SystemErrors.InvalidFieldConstraintId(id)) + + def getEnumById(enumId: String): Option[Enum] = enums.find(_.id == enumId) + def getEnumById_!(enumId: String): Enum = getEnumById(enumId).getOrElse(throw SystemErrors.InvalidEnumId(id = enumId)) + + // note: mysql columns are case insensitive, so we have to be as well + def getEnumByName(name: String): Option[Enum] = enums.find(_.name.toLowerCase == name.toLowerCase) + + def getRelationById(id: Id): Option[Relation] = relations.find(_.id == id) + def getRelationById_!(id: Id): Relation = getRelationById(id).getOrElse(throw SystemErrors.InvalidRelationId(id)) + + def getRelationByName(name: String): Option[Relation] = relations.find(_.name == name) + def getRelationByName_!(name: String): Relation = + getRelationByName(name).getOrElse(throw SystemErrors.InvalidRelation("There is no relation with name: " + name)) + + def getRelationFieldMirrorById(id: Id): Option[RelationFieldMirror] = relations.flatMap(_.fieldMirrors).find(_.id == id) + + def getFieldByRelationFieldMirrorId(id: Id): Option[Field] = getRelationFieldMirrorById(id).flatMap(mirror => getFieldById(mirror.fieldId)) + def getFieldByRelationFieldMirrorId_!(id: Id): Field = getFieldByRelationFieldMirrorId(id).getOrElse(throw SystemErrors.InvalidRelationFieldMirrorId(id)) + + def getRelationByFieldMirrorId(id: Id): Option[Relation] = relations.find(_.fieldMirrors.exists(_.id == id)) + def getRelationByFieldMirrorId_!(id: Id): Relation = getRelationByFieldMirrorId(id).getOrElse(throw SystemErrors.InvalidRelationFieldMirrorId(id)) + + def getIntegrationByTypeAndName(integrationType: IntegrationType, name: IntegrationName): Option[Integration] = { + integrations.filter(_.integrationType == integrationType).find(_.name == name) + } + + def getSearchProviderAlgoliaById(id: Id): Option[SearchProviderAlgolia] = { + authProviders + .map(_.metaInformation) + .collect { case Some(metaInfo: SearchProviderAlgolia) => metaInfo } + .find(_.id == id) + } + + def getSearchProviderAlgoliaByAlgoliaSyncQueryId_!(id: Id): SearchProviderAlgolia = { + getSearchProviderAlgoliaByAlgoliaSyncQueryId(id).getOrElse(throw InvalidAlgoliaSyncQueryId(id)) + } + + def getSearchProviderAlgoliaByAlgoliaSyncQueryId(id: Id): Option[SearchProviderAlgolia] = { + integrations + .collect { case searchProviderAlgolia: SearchProviderAlgolia => searchProviderAlgolia } + .find(_.algoliaSyncQueries.exists(_.id == id)) + } + + def getAlgoliaSyncQueryById_!(id: Id): AlgoliaSyncQuery = getAlgoliaSyncQueryById(id).getOrElse(throw InvalidAlgoliaSyncQueryId(id)) + + def getAlgoliaSyncQueryById(id: Id): Option[AlgoliaSyncQuery] = { + integrations + .collect { case searchProviderAlgolia: SearchProviderAlgolia => searchProviderAlgolia } + .flatMap(_.algoliaSyncQueries) + .find(_.id == id) + } + + def getFieldsByRelationId(id: Id): List[Field] = models.flatMap(_.fields).filter(f => f.relation.isDefined && f.relation.get.id == id) + + def getRelationFieldMirrorsByFieldId(id: Id): List[RelationFieldMirror] = relations.flatMap(_.fieldMirrors).filter(f => f.fieldId == id) + + lazy val getOneRelations: List[Relation] = { + relations.filter( + relation => + !relation.getModelAField(this).exists(_.isList) && + !relation.getModelBField(this).exists(_.isList)) + } + + lazy val getManyRelations: List[Relation] = relations.filter(x => !getOneRelations.contains(x)) + + def getRelatedModelForField(field: Field): Option[Model] = { + val relation = field.relation.getOrElse { + return None + } + + val modelId = field.relationSide match { + case Some(side) if side == RelationSide.A => Some(relation.modelBId) + case Some(side) if side == RelationSide.B => Some(relation.modelAId) + case _ => None + } + + modelId.flatMap(id => getModelById(id)) + } + + def getReverseRelationField(field: Field): Option[Field] = { + val relation = field.relation.getOrElse { return None } + val relationSide = field.relationSide.getOrElse { return None } + + val relatedModelId = relationSide match { + case RelationSide.A => relation.modelBId + case RelationSide.B => relation.modelAId + } + + val relatedModel = getModelById_!(relatedModelId) + + relatedModel.fields.find( + relatedField => + relatedField.relation + .contains(relation) && relatedField.id != field.id) match { + case Some(relatedField) => Some(relatedField) + case None => relatedModel.fields.find(relatedField => relatedField.relation.contains(relation)) + } + + } + + def seatByEmail(email: String): Option[Seat] = seats.find(_.email == email) + def seatByEmail_!(email: String): Seat = seatByEmail(email).getOrElse(throw SystemErrors.InvalidSeatEmail(email)) + + def seatByClientId(clientId: Id): Option[Seat] = seats.find(_.clientId.contains(clientId)) + def seatByClientId_!(clientId: Id): Seat = seatByClientId(clientId).getOrElse(throw SystemErrors.InvalidSeatClientId(clientId)) + + def getModelPermissionById(id: Id): Option[ModelPermission] = models.flatMap(_.permissions).find(_.id == id) + def getModelPermissionById_!(id: Id): ModelPermission = getModelPermissionById(id).getOrElse(throw SystemErrors.InvalidModelPermissionId(id)) + + def getRelationPermissionById(id: Id): Option[RelationPermission] = relations.flatMap(_.permissions).find(_.id == id) + def getRelationPermissionById_!(id: Id): RelationPermission = getRelationPermissionById(id).getOrElse(throw SystemErrors.InvalidRelationPermissionId(id)) + + def modelPermissions: List[ModelPermission] = models.flatMap(_.permissions) + def relationPermissions: Seq[RelationPermission] = relations.flatMap(_.permissions) + + def relationPermissionByRelationPermissionId(id: Id): Option[RelationPermission] = relations.flatMap(_.permissions).find(_.id == id) + def relationPermissionByRelationPermissionId_!(id: Id): RelationPermission = + relationPermissionByRelationPermissionId(id).getOrElse(throw SystemErrors.InvalidRelationPermissionId(id)) + + def relationByRelationPermissionId(id: Id): Option[Relation] = relations.find(_.permissions.exists(_.id == id)) + def relationByRelationPermissionId_!(id: Id): Relation = relationByRelationPermissionId(id).getOrElse(throw SystemErrors.InvalidRelationPermissionId(id)) + + def allFields: Seq[Field] = models.flatMap(_.fields) + + def hasSchemaNameConflict(name: String, id: String): Boolean = { + val conflictingCustomMutation = this.customMutationFunctions.exists(f => f.mutationName == name && f.id != id) + val conflictingCustomQuery = this.customQueryFunctions.exists(f => f.queryName == name && f.id != id) + val conflictingType = this.models.exists(model => List(s"create${model.name}", s"update${model.name}", s"delete${model.name}").contains(name)) + + conflictingCustomMutation || conflictingCustomQuery || conflictingType + } +} + +case class ProjectWithClientId(project: Project, clientId: Id) { + val id: Id = project.id +} +case class ProjectWithClient(project: Project, client: Client) + +case class ProjectDatabase(id: Id, region: Region, name: String, isDefaultForRegion: Boolean = false) extends Node + +trait AuthProviderMetaInformation { + val id: String +} + +case class AuthProviderDigits( + id: String, + consumerKey: String, + consumerSecret: String +) extends AuthProviderMetaInformation + +case class AuthProviderAuth0( + id: String, + domain: String, + clientId: String, + clientSecret: String +) extends AuthProviderMetaInformation + +case class SearchProviderAlgolia( + id: String, + subTableId: String, + applicationId: String, + apiKey: String, + algoliaSyncQueries: List[AlgoliaSyncQuery] = List(), + isEnabled: Boolean, + name: IntegrationName +) extends Node + with Integration { + override val integrationType: IntegrationType = IntegrationType.SearchProvider +} + +case class AlgoliaSyncQuery( + id: String, + indexName: String, + fragment: String, + isEnabled: Boolean, + model: Model +) extends Node + +sealed trait AuthenticatedRequest { + def id: String + def originalToken: String + val isAdmin: Boolean = this match { + case _: AuthenticatedCustomer => true + case _: AuthenticatedRootToken => true + case _: AuthenticatedUser => false + } +} + +case class AuthenticatedUser(id: String, typeName: String, originalToken: String) extends AuthenticatedRequest +case class AuthenticatedCustomer(id: String, originalToken: String) extends AuthenticatedRequest +case class AuthenticatedRootToken(id: String, originalToken: String) extends AuthenticatedRequest + +object IntegrationType extends Enumeration { + type IntegrationType = Value + val AuthProvider = Value("AUTH_PROVIDER") + val SearchProvider = Value("SEARCH_PROVIDER") +} + +object IntegrationName extends Enumeration { + type IntegrationName = Value + val AuthProviderAuth0 = Value("AUTH_PROVIDER_AUTH0") + val AuthProviderDigits = Value("AUTH_PROVIDER_DIGITS") + val AuthProviderEmail = Value("AUTH_PROVIDER_EMAIL") + val SearchProviderAlgolia = Value("SEARCH_PROVIDER_ALGOLIA") +} + +case class AuthProvider( + id: String, + subTableId: String = "this-should-be-set-explicitly", + isEnabled: Boolean, + name: IntegrationName.IntegrationName, // note: this defines the meta table name + metaInformation: Option[AuthProviderMetaInformation] +) extends Node + with Integration { + override val integrationType = IntegrationType.AuthProvider +} + +trait Integration { + val id: String + val subTableId: String + val isEnabled: Boolean + val integrationType: IntegrationType.IntegrationType + val name: IntegrationName.IntegrationName +} + +case class ModelPermission( + id: Id, + operation: ModelOperation, + userType: UserType, + rule: CustomRule = CustomRule.None, + ruleName: Option[String] = None, + ruleGraphQuery: Option[String] = None, + ruleGraphQueryFilePath: Option[String] = None, + ruleWebhookUrl: Option[String] = None, + fieldIds: List[String] = List(), + applyToWholeModel: Boolean, + description: Option[String] = None, + isActive: Boolean +) extends Node { + def isCustom: Boolean = rule != CustomRule.None + + def isNotCustom: Boolean = !isCustom + + def operationString = operation match { + case ModelOperation.Create => "create" + case ModelOperation.Read => "read" + case ModelOperation.Update => "update" + case ModelOperation.Delete => "delete" + } +} + +object ModelPermission { + def publicPermissions: List[ModelPermission] = + List(ModelOperation.Read, ModelOperation.Create, ModelOperation.Update, ModelOperation.Delete) + .map( + operation => + ModelPermission( + id = Cuid.createCuid(), + operation = operation, + userType = UserType.Everyone, + rule = CustomRule.None, + ruleName = None, + ruleGraphQuery = None, + ruleWebhookUrl = None, + isActive = true, + fieldIds = List.empty, + applyToWholeModel = true + )) + + def authenticatedPermissions: List[ModelPermission] = + List(ModelOperation.Read, ModelOperation.Create, ModelOperation.Update, ModelOperation.Delete) + .map( + operation => + ModelPermission( + id = Cuid.createCuid(), + operation = operation, + userType = UserType.Authenticated, + rule = CustomRule.None, + ruleName = None, + ruleGraphQuery = None, + ruleWebhookUrl = None, + isActive = true, + fieldIds = List.empty, + applyToWholeModel = true + )) +} + +case class RelationPermission( + id: Id, + connect: Boolean, + disconnect: Boolean, + userType: UserType, + rule: CustomRule = CustomRule.None, + ruleName: Option[String] = None, + ruleGraphQuery: Option[String] = None, + ruleGraphQueryFilePath: Option[String] = None, + ruleWebhookUrl: Option[String] = None, + description: Option[String] = None, + isActive: Boolean +) extends Node { + def isCustom: Boolean = rule != CustomRule.None + + def isNotCustom: Boolean = !isCustom + + def operation = (connect, disconnect) match { + case (true, false) => "connect" + case (false, true) => "disconnect" + case (true, true) => "*" + case (false, false) => "none" + } + + def operationString = (connect, disconnect) match { + case (true, false) => "connect" + case (false, true) => "disconnect" + case (true, true) => "connectAndDisconnect" + case (false, false) => "none" + } + +} + +object RelationPermission { + def publicPermissions = + List( + RelationPermission( + id = Cuid.createCuid(), + connect = true, + disconnect = true, + userType = UserType.Everyone, + rule = CustomRule.None, + ruleName = None, + ruleGraphQuery = None, + ruleWebhookUrl = None, + isActive = true + )) +} + +case class Model( + id: Id, + name: String, + description: Option[String] = None, + isSystem: Boolean, + fields: List[Field] = List.empty, + permissions: List[ModelPermission] = List.empty, + fieldPositions: List[Id] = List.empty +) extends Node { + + lazy val scalarFields: List[Field] = fields.filter(_.isScalar) + lazy val relationFields: List[Field] = fields.filter(_.isRelation) + lazy val singleRelationFields: List[Field] = relationFields.filter(!_.isList) + lazy val listRelationFields: List[Field] = relationFields.filter(_.isList) + + def relationFieldForIdAndSide(relationId: String, relationSide: RelationSide.Value): Option[Field] = { + fields.find(_.isRelationWithIdAndSide(relationId, relationSide)) + } + + lazy val relations: List[Relation] = { + fields + .map(_.relation) + .collect { case Some(relation) => relation } + .distinct + } + + def withoutFieldsForRelation(relation: Relation): Model = withoutFieldsForRelations(Seq(relation)) + + def withoutFieldsForRelations(relations: Seq[Relation]): Model = { + val newFields = for { + field <- fields + if relations.forall(relation => !field.isRelationWithId(relation.id)) + } yield field + copy(fields = newFields) + } + + def filterFields(fn: Field => Boolean): Model = copy(fields = this.fields.filter(fn)) + + def getFieldById_!(id: Id): Field = getFieldById(id).getOrElse(throw InvalidFieldId(id)) + def getFieldById(id: Id): Option[Field] = fields.find(_.id == id) + + def getFieldByName_!(name: String): Field = getFieldByName(name).getOrElse(throw FieldNotInModel(fieldName = name, modelName = this.name)) + def getFieldByName(name: String): Option[Field] = fields.find(_.name == name) + + def getPermissionById(id: Id): Option[ModelPermission] = permissions.find(_.id == id) + + lazy val getCamelCasedName: String = Character.toLowerCase(name.charAt(0)) + name.substring(1) + lazy val isUserModel: Boolean = name == "User" + + lazy val hasQueryPermissions: Boolean = permissions.exists(permission => permission.isCustom && permission.isActive) +} + +object RelationSide extends Enumeration { + type RelationSide = Value + val A = Value("A") + val B = Value("B") +} + +object TypeIdentifier extends Enumeration { + // note: casing of values are chosen to match our TypeIdentifiers + type TypeIdentifier = Value + val String = Value("String") + val Int = Value("Int") + val Float = Value("Float") + val Boolean = Value("Boolean") + val Password = Value("Password") + val DateTime = Value("DateTime") + val GraphQLID = Value("GraphQLID") + val Enum = Value("Enum") + val Json = Value("Json") + val Relation = Value("Relation") + + def withNameOpt(name: String): Option[TypeIdentifier.Value] = this.values.find(_.toString == name) + + def toSangriaScalarType(typeIdentifier: TypeIdentifier): ScalarType[Any] = { + (typeIdentifier match { + case TypeIdentifier.String => sangria.schema.StringType + case TypeIdentifier.Int => sangria.schema.IntType + case TypeIdentifier.Float => sangria.schema.FloatType + case TypeIdentifier.Boolean => sangria.schema.BooleanType + case TypeIdentifier.GraphQLID => sangria.schema.IDType + case TypeIdentifier.Password => CustomScalarTypes.PasswordType + case TypeIdentifier.DateTime => shared.schema.CustomScalarTypes.DateTimeType + case TypeIdentifier.Json => shared.schema.CustomScalarTypes.JsonType + case TypeIdentifier.Enum => sangria.schema.StringType + case TypeIdentifier.Relation => sys.error("Relation TypeIdentifier does not map to scalar type ") + }).asInstanceOf[sangria.schema.ScalarType[Any]] + } +} + +case class Enum( + id: Id, + name: String, + values: Seq[String] = Seq.empty +) extends Node + +case class FeatureToggle( + id: Id, + name: String, + isEnabled: Boolean +) extends Node + +case class Field( + id: Id, + name: String, + typeIdentifier: TypeIdentifier.TypeIdentifier, + description: Option[String] = None, + isRequired: Boolean, + isList: Boolean, + isUnique: Boolean, + isSystem: Boolean, + isReadonly: Boolean, + enum: Option[Enum] = None, + defaultValue: Option[GCValue] = None, + relation: Option[Relation] = None, + relationSide: Option[RelationSide.Value] = None, + constraints: List[FieldConstraint] = List.empty +) extends Node { + + def isScalar: Boolean = CustomScalarTypes.isScalar(typeIdentifier) + def isRelation: Boolean = typeIdentifier == TypeIdentifier.Relation + def isRelationWithId(relationId: String): Boolean = relation.exists(_.id == relationId) + + def isRelationWithIdAndSide(relationId: String, relationSide: RelationSide.Value): Boolean = { + isRelationWithId(relationId) && this.relationSide.contains(relationSide) + } + + def isWritable: Boolean = !isReadonly + + def isOneToOneRelation(project: Project): Boolean = { + val otherField = relatedFieldEager(project) + !this.isList && !otherField.isList + } + + def isManyToManyRelation(project: Project): Boolean = { + val otherField = relatedFieldEager(project) + this.isList && otherField.isList + } + + def isOneToManyRelation(project: Project): Boolean = { + val otherField = relatedFieldEager(project) + (this.isList && !otherField.isList) || (!this.isList && otherField.isList) + } + + def managedBy(project: Project)(implicit inj: Injector): Option[AuthProvider] = { + project.authProviders.collect { + case i + if i.integrationType == IntegrationType.AuthProvider && project + .getModelByFieldId(id) + .get + .name == "User" && + ManagedFields(i.name) + .exists(_.defaultName == name) => + i + }.headOption + } + + def oppositeRelationSide: Option[RelationSide.Value] = { + relationSide match { + case Some(RelationSide.A) => Some(RelationSide.B) + case Some(RelationSide.B) => Some(RelationSide.A) + case x => throw SystemErrors.InvalidStateException(message = s" relationSide was $x") + } + } + + def relatedModel_!(project: Project): Model = { + relatedModel(project) match { + case None => sys.error(s"Could not find relatedModel for field [$name] on model [${model(project)}]") + case Some(model) => model + } + } + + def relatedModel(project: Project): Option[Model] = { + relation.flatMap(relation => { + relationSide match { + case Some(RelationSide.A) => relation.getModelB(project) + case Some(RelationSide.B) => relation.getModelA(project) + case x => throw SystemErrors.InvalidStateException(message = s" relationSide was $x") + } + }) + } + + def model(project: Project): Option[Model] = { + relation.flatMap(relation => { + relationSide match { + case Some(RelationSide.A) => relation.getModelA(project) + case Some(RelationSide.B) => relation.getModelB(project) + case x => throw SystemErrors.InvalidStateException(message = s" relationSide was $x") + } + }) + } + + def relatedFieldEager(project: Project): Field = { + val fields = relatedModel(project).get.fields + + var returnField = fields.find { field => + field.relation.exists { relation => + val isTheSameField = field.id == this.id + val isTheSameRelation = relation.id == this.relation.get.id + isTheSameRelation && !isTheSameField + } + } + + if (returnField.isEmpty) { + returnField = fields.find { relatedField => + relatedField.relation.exists { relation => + relation.id == this.relation.get.id + } + } + } + returnField.head + } +} + +sealed trait FieldConstraint extends Node { + val id: String; val fieldId: String; val constraintType: FieldConstraintType +} + +case class StringConstraint(id: String, + fieldId: String, + equalsString: Option[String] = None, + oneOfString: List[String] = List.empty, + minLength: Option[Int] = None, + maxLength: Option[Int] = None, + startsWith: Option[String] = None, + endsWith: Option[String] = None, + includes: Option[String] = None, + regex: Option[String] = None) + extends FieldConstraint { + val constraintType: FieldConstraintType = FieldConstraintType.STRING +} + +case class NumberConstraint(id: String, + fieldId: String, + equalsNumber: Option[Double] = None, + oneOfNumber: List[Double] = List.empty, + min: Option[Double] = None, + max: Option[Double] = None, + exclusiveMin: Option[Double] = None, + exclusiveMax: Option[Double] = None, + multipleOf: Option[Double] = None) + extends FieldConstraint { + val constraintType: FieldConstraintType = FieldConstraintType.NUMBER +} + +case class BooleanConstraint(id: String, fieldId: String, equalsBoolean: Option[Boolean] = None) extends FieldConstraint { + val constraintType: FieldConstraintType = FieldConstraintType.BOOLEAN +} + +case class ListConstraint(id: String, fieldId: String, uniqueItems: Option[Boolean] = None, minItems: Option[Int] = None, maxItems: Option[Int] = None) + extends FieldConstraint { + val constraintType: FieldConstraintType = FieldConstraintType.LIST +} + +object FieldConstraintType extends Enumeration { + type FieldConstraintType = Value + val STRING = Value("STRING") + val NUMBER = Value("NUMBER") + val BOOLEAN = Value("BOOLEAN") + val LIST = Value("LIST") +} + +// NOTE modelA/modelB should actually be included here +// but left out for now because of cyclic dependencies +case class Relation( + id: Id, + name: String, + description: Option[String] = None, + // BEWARE: if the relation looks like this: val relation = Relation(id = "relationId", modelAId = "userId", modelBId = "todoId") + // then the relationSide for the fields have to be "opposite", because the field's side is the side of _the other_ model + // val userField = Field(..., relation = Some(relation), relationSide = Some(RelationSide.B) + // val todoField = Field(..., relation = Some(relation), relationSide = Some(RelationSide.A) + modelAId: Id, + modelBId: Id, + fieldMirrors: List[RelationFieldMirror] = List(), + permissions: List[RelationPermission] = List() +) extends Node { + def connectsTheModels(model1: Model, model2: Model): Boolean = { + (modelAId == model1.id && modelBId == model2.id) || (modelAId == model2.id && modelBId == model1.id) + } + + def isSameModelRelation(project: Project): Boolean = getModelA(project) == getModelB(project) + def isSameFieldSameModelRelation(project: Project): Boolean = getModelAField(project) == getModelBField(project) + + def getModelA(project: Project): Option[Model] = project.getModelById(modelAId) + def getModelA_!(project: Project): Model = getModelA(project).getOrElse(throw SystemErrors.InvalidRelation("A relation should have a valid Model A.")) + + def getModelB(project: Project): Option[Model] = project.getModelById(modelBId) + def getModelB_!(project: Project): Model = getModelB(project).getOrElse(throw SystemErrors.InvalidRelation("A relation should have a valid Model B.")) + + def getOtherModel_!(project: Project, model: Model): Model = { + model.id match { + case `modelAId` => getModelB_!(project) + case `modelBId` => getModelA_!(project) + case _ => throw SystemErrors.InvalidRelation(s"The model with the id ${model.id} is not part of this relation.") + } + } + + def fields(project: Project): Iterable[Field] = getModelAField(project) ++ getModelBField(project) + + def getOtherField_!(project: Project, model: Model): Field = { + model.id match { + case `modelAId` => getModelBField_!(project) + case `modelBId` => getModelAField_!(project) + case _ => throw SystemErrors.InvalidRelation(s"The model with the id ${model.id} is not part of this relation.") + } + } + + def getModelAField(project: Project): Option[Field] = modelFieldFor(project, modelAId, RelationSide.A) + def getModelAField_!(project: Project): Field = + getModelAField(project).getOrElse(throw SystemErrors.InvalidRelation("A relation must have a field on model A.")) + + def getModelBField(project: Project): Option[Field] = { + // note: defaults to modelAField to handle same model, same field relations + modelFieldFor(project, modelBId, RelationSide.B).orElse(getModelAField(project)) + } + def getModelBField_!(project: Project): Field = + getModelBField(project).getOrElse(throw SystemErrors.InvalidRelation("This must return a Model, if not Model B then Model A.")) + + private def modelFieldFor(project: Project, modelId: String, relationSide: RelationSide.Value): Option[Field] = { + for { + model <- project.getModelById(modelId) + field <- model.relationFieldForIdAndSide(relationId = id, relationSide = relationSide) + } yield field + } + + def aName(project: Project): String = + getModelAField(project) + .map(field => s"${field.name}${makeUnique("1", project)}${field.relatedModel(project).get.name}") + .getOrElse("from") + + def bName(project: Project): String = + getModelBField(project) + .map(field => s"${field.name}${makeUnique("2", project)}${field.relatedModel(project).get.name}") + .getOrElse("to") + + private def makeUnique(x: String, project: Project) = if (getModelAField(project) == getModelBField(project)) x else "" + + def fieldSide(project: Project, field: Field): cool.graph.shared.models.RelationSide.Value = { + val fieldModel = project.getModelByFieldId_!(field.id) + fieldModel.id match { + case `modelAId` => RelationSide.A + case `modelBId` => RelationSide.B + } + } + + def getPermissionById(id: String): Option[RelationPermission] = permissions.find(_.id == id) + + def getRelationFieldMirrorById(id: String): Option[RelationFieldMirror] = fieldMirrors.find(_.id == id) + def getRelationFieldMirrorById_!(id: String): RelationFieldMirror = + getRelationFieldMirrorById(id).getOrElse(throw SystemErrors.InvalidRelationFieldMirrorId(id)) + +} + +case class RelationFieldMirror( + id: String, + relationId: String, + fieldId: String +) extends Node + +object UserType extends Enumeration { + type UserType = Value + val Everyone = Value("EVERYONE") + val Authenticated = Value("AUTHENTICATED") +} + +object ModelMutationType extends Enumeration { + type ModelMutationType = Value + val Created = Value("CREATED") + val Updated = Value("UPDATED") + val Deleted = Value("DELETED") +} + +object CustomRule extends Enumeration { + type CustomRule = Value + val None = Value("NONE") + val Graph = Value("GRAPH") + val Webhook = Value("WEBHOOK") +} + +object ModelOperation extends Enumeration { + type ModelOperation = Value + val Create = Value("CREATE") + val Read = Value("READ") + val Update = Value("UPDATE") + val Delete = Value("DELETE") +} + +case class RootToken(id: Id, token: String, name: String, created: DateTime) extends Node + +object ActionTriggerType extends Enumeration { + type ActionTriggerType = Value + val MutationModel = Value("MUTATION_MODEL") + val MutationRelation = Value("MUTATION_RELATION") +} + +object ActionHandlerType extends Enumeration { + type ActionHandlerType = Value + val Webhook = Value("WEBHOOK") +} + +case class Action( + id: Id, + isActive: Boolean, + triggerType: ActionTriggerType.Value, + handlerType: ActionHandlerType.Value, + description: Option[String] = None, + handlerWebhook: Option[ActionHandlerWebhook] = None, + triggerMutationModel: Option[ActionTriggerMutationModel] = None, + triggerMutationRelation: Option[ActionTriggerMutationRelation] = None +) extends Node + +case class ActionHandlerWebhook( + id: Id, + url: String, + isAsync: Boolean +) extends Node + +object ActionTriggerMutationModelMutationType extends Enumeration { + type ActionTriggerMutationModelMutationType = Value + val Create = Value("CREATE") + val Update = Value("UPDATE") + val Delete = Value("DELETE") +} + +case class ActionTriggerMutationModel( + id: Id, + modelId: String, + mutationType: ActionTriggerMutationModelMutationType.Value, + fragment: String +) extends Node + +object ActionTriggerMutationRelationMutationType extends Enumeration { + type ActionTriggerMutationRelationMutationType = Value + val Add = Value("ADD") + val Remove = Value("REMOVE") +} + +case class ActionTriggerMutationRelation( + id: Id, + relationId: String, + mutationType: ActionTriggerMutationRelationMutationType.Value, + fragment: String +) extends Node diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/InvalidInput.scala b/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/InvalidInput.scala new file mode 100644 index 0000000000..2a88a4be15 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/InvalidInput.scala @@ -0,0 +1,32 @@ +package cool.graph.shared.mutactions + +import cool.graph._ +import cool.graph.shared.errors.GeneralError +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class InvalidInput(error: GeneralError, isInvalid: Future[Boolean] = Future.successful(true))(implicit inj: Injector) extends Mutaction with Injectable { + + override def execute: Future[MutactionExecutionResult] = Future.successful(MutactionExecutionSuccess()) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = isInvalid.map { + case true => Failure(error) + case false => Success(MutactionVerificationSuccess()) + } +} + +case class InvalidInputClientSqlMutaction(error: GeneralError, isInvalid: () => Future[Boolean] = () => Future.successful(true)) extends ClientSqlMutaction { + lazy val isInvalidResult = isInvalid() + + override def execute: Future[ClientSqlStatementResult[Any]] = Future.successful(ClientSqlStatementResult(sqlAction = DBIO.seq())) + + override def verify(): Future[Try[MutactionVerificationSuccess]] = + isInvalidResult.map { + case true => Failure(error) + case false => Success(MutactionVerificationSuccess()) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/MutationTypes.scala b/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/MutationTypes.scala new file mode 100644 index 0000000000..f7e38788c5 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/mutactions/MutationTypes.scala @@ -0,0 +1,30 @@ +package cool.graph.shared.mutactions + +import cool.graph.Types.Id +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.Field + +import scala.language.reflectiveCalls + +object MutationTypes { + case class ArgumentValue(name: String, value: Any, field: Option[Field] = None) { + def unwrappedValue: Any = { + def unwrapSome(x: Any): Any = { + x match { + case Some(x) => x + case x => x + } + } + unwrapSome(value) + } + } + object ArgumentValue { + def apply(name: String, value: Any, field: Field): ArgumentValue = ArgumentValue(name, value, Some(field)) + } + + object ArgumentValueList { + def getId(args: List[ArgumentValue]): Option[Id] = args.find(_.name == "id").map(_.value.toString) + def getId_!(args: List[ArgumentValue]): Id = getId(args).getOrElse(throw UserAPIErrors.IdIsMissing()) + + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/queryPermissions/PermissionSchemaResolver.scala b/server/backend-shared/src/main/scala/cool/graph/shared/queryPermissions/PermissionSchemaResolver.scala new file mode 100644 index 0000000000..e022112e04 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/queryPermissions/PermissionSchemaResolver.scala @@ -0,0 +1,84 @@ +package cool.graph.shared.queryPermissions + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import com.typesafe.scalalogging.LazyLogging +import cool.graph.client.UserContext +import cool.graph.client.database.DeferredTypes.ManyModelExistsDeferred +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.shared.{ApiMatrixFactory, models} +import cool.graph.shared.models.Project +import sangria.execution.Executor +import sangria.introspection.introspectionQuery +import sangria.schema.{Context, Field, ObjectType, Schema} +import scaldi.{Injectable, Injector} +import spray.json.JsObject + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class PermissionSchemaResolver(implicit inj: Injector) extends Injectable with LazyLogging { + + import sangria.marshalling.sprayJson._ + + def resolve(project: Project): Future[String] = { + + implicit val system = inject[ActorSystem](identified by "actorSystem") + implicit val materializer = inject[ActorMaterializer](identified by "actorMaterializer") + + val permissionSchema = PermissionSchemaResolver.permissionSchema(project) + + Executor + .execute( + schema = permissionSchema, + queryAst = introspectionQuery, + userContext = new UserContext( + project = project, + authenticatedRequest = None, + requestId = "PermissionSchemaResolver-request-id", + requestIp = "PermissionSchemaResolver-request-ip", + clientId = "PermissionSchemaResolver-client-id", + log = (_) => (), + queryAst = Some(introspectionQuery) + ) + ) + .map { response => + val JsObject(fields) = response + fields("data").compactPrint + } + } +} + +object PermissionSchemaResolver extends Injectable { + def permissionSchema(project: Project)(implicit inj: Injector): Schema[UserContext, Unit] = { + val apiMatrix = inject[ApiMatrixFactory].create(project) + val includedModels = project.models.filter(model => apiMatrix.includeModel(model.name)) + val schemaBuilder = new SimpleSchemaModelObjectTypeBuilder(project, None) + + def getConnectionArguments(model: models.Model) = { + schemaBuilder.mapToListConnectionArguments(model) + } + + def resolveGetAllItemsQuery(model: models.Model, ctx: Context[UserContext, Unit]): sangria.schema.Action[UserContext, Boolean] = { + val arguments = schemaBuilder.extractQueryArgumentsFromContext(model, ctx) + + ManyModelExistsDeferred(model, arguments) + } + + def getModelField(model: models.Model): Field[UserContext, Unit] = { + Field( + s"Some${model.name.capitalize}Exists", + fieldType = sangria.schema.BooleanType, + arguments = getConnectionArguments(model), + resolve = (ctx) => { + resolveGetAllItemsQuery(model, ctx) + } + ) + } + + val query = ObjectType("Query", includedModels.map(getModelField)) + val mutation = None + + Schema(query, mutation) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/schema/CustomScalarTypes.scala b/server/backend-shared/src/main/scala/cool/graph/shared/schema/CustomScalarTypes.scala new file mode 100644 index 0000000000..33251282af --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/schema/CustomScalarTypes.scala @@ -0,0 +1,162 @@ +package cool.graph.shared.schema + +import cool.graph.shared.models.{Field, TypeIdentifier} +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.util.crypto.Crypto +import org.joda.time.format.DateTimeFormat +import org.joda.time.{DateTime, DateTimeZone} +import sangria.ast +import sangria.schema._ +import sangria.validation.{StringCoercionViolation, ValueCoercionViolation} +import spray.json._ + +import scala.util.{Failure, Success, Try} + +object CustomScalarTypes { + + val PasswordType = ScalarType[String]( + "Password", + description = Some("Values of type password are stored safely."), + coerceOutput = valueOutput, + coerceUserInput = { + case s: String ⇒ Right(Crypto.hash(s)) + case _ ⇒ Left(StringCoercionViolation) + }, + coerceInput = { + case ast.StringValue(s, _, _) ⇒ Right(Crypto.hash(s)) + case _ ⇒ Left(StringCoercionViolation) + } + ) + + case object DateCoercionViolation extends ValueCoercionViolation("Date value expected") + + def parseDate(s: String) = Try(new DateTime(s, DateTimeZone.UTC)) match { + case Success(date) ⇒ Right(date) + case Failure(_) ⇒ Left(DateCoercionViolation) + } + + val DateTimeType = + ScalarType[DateTime]( + "DateTime", + coerceOutput = (d, caps) => { + d.toDateTime + }, + coerceUserInput = { + case s: String ⇒ parseDate(s) + case _ ⇒ Left(DateCoercionViolation) + }, + coerceInput = { + case ast.StringValue(s, _, _) ⇒ parseDate(s) + case _ ⇒ Left(DateCoercionViolation) + } + ) + + case object JsonCoercionViolation extends ValueCoercionViolation("Not valid JSON") + + def parseJson(s: String) = Try(s.parseJson) match { + case Success(json) ⇒ Right(json) + case Failure(_) ⇒ Left(JsonCoercionViolation) + } + + val JsonType = ScalarType[JsValue]( + "Json", + description = Some("Raw JSON value"), + coerceOutput = (value, _) ⇒ value, + coerceUserInput = { + case v: String ⇒ Right(JsString(v)) + case v: Boolean ⇒ Right(JsBoolean(v)) + case v: Int ⇒ Right(JsNumber(v)) + case v: Long ⇒ Right(JsNumber(v)) + case v: Float ⇒ Right(JsNumber(v)) + case v: Double ⇒ Right(JsNumber(v)) + case v: BigInt ⇒ Right(JsNumber(v)) + case v: BigDecimal ⇒ Right(JsNumber(v)) + case v: DateTime ⇒ + Right( + JsString( + v.toString(DateTimeFormat + .forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z") + .withZoneUTC()))) + case v: JsValue ⇒ Right(v) + }, + coerceInput = { + case ast.StringValue(jsonStr, _, _) ⇒ parseJson(jsonStr) + case _ ⇒ Left(JsonCoercionViolation) + } + ) + + def isScalar(typeIdentifier: TypeIdentifier.TypeIdentifier) = typeIdentifier != TypeIdentifier.Relation + + def isScalar(typeIdentifier: String) = TypeIdentifier.values.map(_.toString).contains(typeIdentifier) + + def parseValueFromString(value: String, typeIdentifier: TypeIdentifier, isList: Boolean): Option[Any] = { + + def parseOne(value: String): Option[Any] = + try { + typeIdentifier match { + case TypeIdentifier.String => Some(value) + case TypeIdentifier.Int => Some(Integer.parseInt(value)) + case TypeIdentifier.Float => Some((if (value == null) { "0" } else { value }).toDouble) + case TypeIdentifier.Boolean => Some(value.toBoolean) + case TypeIdentifier.Password => Some(value) + case TypeIdentifier.DateTime => Some(new DateTime(value, DateTimeZone.UTC)) + case TypeIdentifier.GraphQLID => Some(value) + case TypeIdentifier.Enum => Some(value) + case TypeIdentifier.Json => Some(value.parseJson) + case _ => None + } + } catch { + case e: Exception => None + } + + if (isList) { + var elements: Option[Vector[Option[Any]]] = None + + def trySplitting(function: => Option[Vector[Option[Any]]]) = { + elements = try { function } catch { case e: Exception => None } + } + + def stripBrackets = { + if (!value.startsWith("[") || !value.endsWith("]")) { throw new Exception() } + value.stripPrefix("[").stripSuffix("]").split(",").map(_.trim()).to[Vector] + } + + def stripQuotes(x: String) = { + if (!x.startsWith("\"") || !x.endsWith("\"")) { throw new Exception() } + x.stripPrefix("\"").stripSuffix("\"") + } + + def dateTimeList = { Some(stripBrackets.map(x => stripQuotes(x)).map(e => parseOne(e))) } + def stringList = { Some(stripBrackets.map(x => stripQuotes(x)).map(e => parseOne(e))) } + def enumList = { Some(stripBrackets.map(e => parseOne(e))) } + def otherList = { Some(value.parseJson.asInstanceOf[JsArray].elements.map(e => parseOne(e.toString()))) } + + if (value.replace(" ", "") == "[]") { + return Some(value) + } else { + typeIdentifier match { + case TypeIdentifier.DateTime => trySplitting(dateTimeList) + case TypeIdentifier.String => trySplitting(stringList) + case TypeIdentifier.Enum => trySplitting(enumList) + case _ => trySplitting(otherList) + } + } + + if (elements.isEmpty || elements.get.exists(_.isEmpty)) { + None + } else { + Some(elements.map(_ collect { case Some(x) => x })) + } + } else { + parseOne(value) + } + } + + def isValidScalarType(value: String, field: Field) = parseValueFromString(value, field.typeIdentifier, field.isList).isDefined + + def parseTypeIdentifier(typeIdentifier: String) = + TypeIdentifier.values.map(_.toString).contains(typeIdentifier) match { + case true => TypeIdentifier.withName(typeIdentifier) + case false => TypeIdentifier.Relation + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/shared/schema/JsonMarshalling.scala b/server/backend-shared/src/main/scala/cool/graph/shared/schema/JsonMarshalling.scala new file mode 100644 index 0000000000..39ba339233 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/shared/schema/JsonMarshalling.scala @@ -0,0 +1,88 @@ +package cool.graph.shared.schema + +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat +import sangria.marshalling.{ArrayMapBuilder, InputUnmarshaller, ResultMarshaller, ScalarValueInfo} +import spray.json.{JsArray, JsBoolean, JsNull, JsNumber, JsObject, JsString, JsValue} + +object JsonMarshalling { + + implicit object CustomSprayJsonResultMarshaller extends ResultMarshaller { + type Node = JsValue + type MapBuilder = ArrayMapBuilder[Node] + + def emptyMapNode(keys: Seq[String]) = new ArrayMapBuilder[Node](keys) + + def addMapNodeElem(builder: MapBuilder, key: String, value: Node, optional: Boolean) = builder.add(key, value) + + def mapNode(builder: MapBuilder) = JsObject(builder.toMap) + + def mapNode(keyValues: Seq[(String, JsValue)]) = JsObject(keyValues: _*) + + def arrayNode(values: Vector[JsValue]) = JsArray(values) + + def optionalArrayNodeValue(value: Option[JsValue]) = value match { + case Some(v) ⇒ v + case None ⇒ nullNode + } + + def scalarNode(value: Any, typeName: String, info: Set[ScalarValueInfo]) = + value match { + case v: String ⇒ JsString(v) + case v: Boolean ⇒ JsBoolean(v) + case v: Int ⇒ JsNumber(v) + case v: Long ⇒ JsNumber(v) + case v: Float ⇒ JsNumber(v) + case v: Double ⇒ JsNumber(v) + case v: BigInt ⇒ JsNumber(v) + case v: BigDecimal ⇒ JsNumber(v) + case v: DateTime ⇒ JsString(v.toString(DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z").withZoneUTC())) + case v: JsValue ⇒ v + case v ⇒ throw new IllegalArgumentException("Unsupported scalar value in CustomSprayJsonResultMarshaller: " + v) + } + + def enumNode(value: String, typeName: String) = JsString(value) + + def nullNode = JsNull + + def renderCompact(node: JsValue) = node.compactPrint + + def renderPretty(node: JsValue) = node.prettyPrint + } + + implicit object SprayJsonInputUnmarshaller extends InputUnmarshaller[JsValue] { + + def getRootMapValue(node: JsValue, key: String): Option[JsValue] = node.asInstanceOf[JsObject].fields get key + + def isListNode(node: JsValue) = node.isInstanceOf[JsArray] + + def getListValue(node: JsValue) = node.asInstanceOf[JsArray].elements + + def isMapNode(node: JsValue) = node.isInstanceOf[JsObject] + + def getMapValue(node: JsValue, key: String) = node.asInstanceOf[JsObject].fields get key + + def getMapKeys(node: JsValue) = node.asInstanceOf[JsObject].fields.keys + + def isDefined(node: JsValue) = node != JsNull + + def getScalarValue(node: JsValue): Any = node match { + case JsBoolean(b) ⇒ b + case JsNumber(d) ⇒ d.toBigIntExact getOrElse d + case JsString(s) ⇒ s + case n ⇒ n + } + + def getScalaScalarValue(node: JsValue) = getScalarValue(node) + + def isEnumNode(node: JsValue) = node.isInstanceOf[JsString] + + def isScalarNode(node: JsValue) = true + + def isVariableNode(node: JsValue) = false + + def getVariableName(node: JsValue) = throw new IllegalArgumentException("variables are not supported") + + def render(node: JsValue) = node.compactPrint + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/SubscriptionUserContext.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/SubscriptionUserContext.scala new file mode 100644 index 0000000000..e03e4e2b7b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/SubscriptionUserContext.scala @@ -0,0 +1,32 @@ +package cool.graph.subscriptions + +import cool.graph.RequestContextTrait +import cool.graph.client.UserContextTrait +import cool.graph.deprecated.actions.schemas.MutationMetaData +import cool.graph.client.database.ProjectDataresolver +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.models.{AuthenticatedRequest, Project} +import sangria.ast.Document +import scaldi.{Injectable, Injector} + +case class SubscriptionUserContext(nodeId: String, + mutation: MutationMetaData, + project: Project, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + clientId: String, + log: Function[String, Unit], + override val queryAst: Option[Document] = None)(implicit inj: Injector) + extends UserContextTrait + with RequestContextTrait + with Injectable { + + override val isSubscription: Boolean = true + override val projectId: Option[String] = Some(project.id) + + val cloudwatch = inject[Cloudwatch]("cloudwatch") + + val dataResolver = + new ProjectDataresolver(project = project, requestContext = this) + override val requestIp: String = "subscription-callback-ip" // todo: get the correct ip from server +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/MyVisitor.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/MyVisitor.scala new file mode 100644 index 0000000000..810e1f780b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/MyVisitor.scala @@ -0,0 +1,483 @@ +package cool.graph.subscriptions.schemas + +import sangria.ast._ + +/** + * Limitations of the Ast Transformer + * - Only the onEnter callback can change nodes + * - the onLeave callback gets called with the old children + * - no skip or break functionality anymore + * - comments can't be transformed + * + * All these limitations could be eliminated. However, that would take much more effort and would make the code + * much more complex. + */ +object MyAstVisitor { + + def visitAst( + doc: AstNode, + onEnter: AstNode ⇒ Option[AstNode] = _ ⇒ None, + onLeave: AstNode ⇒ Option[AstNode] = _ ⇒ None + ): AstNode = { + + def breakOrSkip(cmd: Option[AstNode]) = cmd match { + case _ => + true + } + + def map(cmd: Option[AstNode], originalNode: AstNode): AstNode = cmd match { + case Some(x) => + x + case None => + originalNode + } + + // necessary as `Value` is a sealed trait, which can't be used in instanceOf + def mapValues(values: Vector[AstNode]) = { + values.map(collectValue) + } + + def collectValue(value: AstNode) = value match { + case x @ IntValue(_, _, _) => + x + case x @ BigIntValue(_, _, _) => + x + case x @ FloatValue(_, _, _) => + x + case x @ BigDecimalValue(_, _, _) => + x + case x @ StringValue(_, _, _) => + x + case x @ BooleanValue(_, _, _) => + x + case x @ EnumValue(_, _, _) => + x + case x @ ListValue(_, _, _) => + x + case x @ VariableValue(_, _, _) => + x + case x @ NullValue(_, _) => + x + case x @ ObjectValue(_, _, _) => + x + // this case is only to trick the compiler and shouldn't occur + case _ => + value.asInstanceOf[ObjectValue] + } + + def loop(node: AstNode): AstNode = + node match { + case n @ Document(defs, trailingComments, _, _) ⇒ + var newDefs = defs + val cmd = onEnter(n).asInstanceOf[Option[Document]] + cmd match { + case None => + newDefs = defs.map(d ⇒ loop(d).asInstanceOf[Definition]) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newDefs = newN.definitions.map(d ⇒ loop(d).asInstanceOf[Definition]) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + if (breakOrSkip(cmd)) { + newDefs = defs.map(d ⇒ loop(d).asInstanceOf[Definition]) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n).asInstanceOf[Document].copy(definitions = newDefs) + case n @ FragmentDefinition(_, cond, dirs, sels, comments, trailingComments, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[FragmentDefinition]] + var newDirs = dirs + var newSels = sels + var newComments = comments + var newTrailingComments = trailingComments + loop(cond) + cmd match { + case None => + newDirs = dirs.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = sels.map(s ⇒ loop(s).asInstanceOf[Selection]) + newComments = comments.map(s ⇒ loop(s).asInstanceOf[Comment]) + newTrailingComments = trailingComments.map(s ⇒ loop(s).asInstanceOf[Comment]) + breakOrSkip(onLeave(n)) + case Some(newN) => + newDirs = newN.directives.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = newN.selections.map(s ⇒ loop(s).asInstanceOf[Selection]) + newComments = newN.comments.map(s ⇒ loop(s).asInstanceOf[Comment]) + newTrailingComments = newN.trailingComments.map(s ⇒ loop(s).asInstanceOf[Comment]) + breakOrSkip(onLeave(newN)) + } + map(cmd, n) + .asInstanceOf[FragmentDefinition] + .copy(directives = newDirs, selections = newSels, comments = newComments, trailingComments = newTrailingComments) + case n @ OperationDefinition(_, _, vars, dirs, sels, comment, trailingComments, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[OperationDefinition]] + var newVars = vars + var newDirs = dirs + var newSels = sels + + cmd match { + case None => + newVars = vars.map(d ⇒ loop(d).asInstanceOf[VariableDefinition]) + newDirs = dirs.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = sels.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newVars = newN.variables.map(d ⇒ loop(d).asInstanceOf[VariableDefinition]) + newDirs = newN.directives.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = newN.selections.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + map(cmd, n) + .asInstanceOf[OperationDefinition] + .copy(variables = newVars, directives = newDirs, selections = newSels) + case n @ VariableDefinition(_, tpe, default, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(onEnter(n))) { + loop(tpe) + default.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ InlineFragment(cond, dirs, sels, comment, trailingComments, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[InlineFragment]] + var newDirs = dirs + var newSels = sels + cmd match { + case None => + cond.foreach(c ⇒ loop(c)) + newDirs = dirs.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = sels.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newN.typeCondition.foreach(c ⇒ loop(c)) + newDirs = newN.directives.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = newN.selections.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n).asInstanceOf[InlineFragment].copy(directives = newDirs, selections = newSels) + case n @ FragmentSpread(_, dirs, comment, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[FragmentSpread]] + var newDirs = dirs + cmd match { + case None => + newDirs = dirs.map(d ⇒ loop(d).asInstanceOf[Directive]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newDirs = newN.directives.map(d ⇒ loop(d).asInstanceOf[Directive]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + map(cmd, n).asInstanceOf[FragmentSpread].copy(directives = newDirs) + case n @ NotNullType(ofType, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + loop(ofType) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ ListType(ofType, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + loop(ofType) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ Field(_, _, args, dirs, sels, comment, trailingComments, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[Field]] + var newArgs = args + var newDirs = dirs + var newSels = sels + cmd match { + case None => + newArgs = args.map(d ⇒ loop(d).asInstanceOf[Argument]) + newDirs = dirs.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = sels.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newArgs = newN.arguments.map(d ⇒ loop(d).asInstanceOf[Argument]) + newDirs = newN.directives.map(d ⇒ loop(d).asInstanceOf[Directive]) + newSels = newN.selections.map(s ⇒ loop(s).asInstanceOf[Selection]) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + map(cmd, n).asInstanceOf[Field].copy(arguments = newArgs, directives = newDirs, selections = newSels) + case n @ Argument(_, v, comment, _) ⇒ + val cmd = onEnter(n) + var newV = v + if (breakOrSkip(cmd)) { + newV = collectValue(loop(v)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n).asInstanceOf[Argument].copy(value = newV) + case n @ ObjectField(_, v, comment, _) ⇒ + val cmd = onEnter(n) + val newV = collectValue(loop(v)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + cmd match { + case None => + n.copy(value = newV) + case Some(newN) => + newN + } + case n @ Directive(_, args, comment, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[Directive]] + var newArgs = args + cmd match { + case None => + newArgs = args.map(d ⇒ loop(d).asInstanceOf[Argument]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newArgs = newN.arguments.map(d ⇒ loop(d).asInstanceOf[Argument]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + map(cmd, n).asInstanceOf[Directive].copy(arguments = newArgs) + case n @ ListValue(vals, comment, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[ListValue]] + var newVals = vals + cmd match { + case None => + newVals = mapValues(vals.map(v ⇒ loop(v))) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newVals = mapValues(newN.values.map(v ⇒ loop(v))) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n).asInstanceOf[ListValue].copy(values = newVals) + case n @ ObjectValue(fields, comment, _) ⇒ + val cmd = onEnter(n).asInstanceOf[Option[ObjectValue]] + var newFields = fields + cmd match { + case None => + newFields = fields.map(f ⇒ loop(f).asInstanceOf[ObjectField]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + case Some(newN) => + newFields = newN.fields.map(f ⇒ loop(f).asInstanceOf[ObjectField]) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(newN)) + } + map(cmd, n).asInstanceOf[ObjectValue].copy(fields = newFields) + case n @ BigDecimalValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ BooleanValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ Comment(_, _) ⇒ + if (breakOrSkip(onEnter(n))) { + breakOrSkip(onLeave(n)) + } + n + case n @ VariableValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ EnumValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ NullValue(comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ NamedType(_, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ StringValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ BigIntValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ IntValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + case n @ FloatValue(_, comment, _) ⇒ + val cmd = onEnter(n) + if (breakOrSkip(cmd)) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + map(cmd, n) + + // IDL schema definition + + case n @ ScalarTypeDefinition(_, dirs, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ FieldDefinition(name, fieldType, args, dirs, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + loop(fieldType) + args.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ InputValueDefinition(_, valueType, default, dirs, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + loop(valueType) + default.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ ObjectTypeDefinition(_, interfaces, fields, dirs, comment, trailingComments, _) ⇒ + if (breakOrSkip(onEnter(n))) { + interfaces.foreach(d ⇒ loop(d)) + fields.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ InterfaceTypeDefinition(_, fields, dirs, comment, trailingComments, _) ⇒ + if (breakOrSkip(onEnter(n))) { + fields.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ UnionTypeDefinition(_, types, dirs, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + types.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ EnumTypeDefinition(_, values, dirs, comment, trailingComments, _) ⇒ + if (breakOrSkip(onEnter(n))) { + values.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ EnumValueDefinition(_, dirs, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ InputObjectTypeDefinition(_, fields, dirs, comment, trailingComments, _) ⇒ + if (breakOrSkip(onEnter(n))) { + fields.foreach(d ⇒ loop(d)) + dirs.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ TypeExtensionDefinition(definition, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + loop(definition) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ DirectiveDefinition(_, args, locations, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + args.foreach(d ⇒ loop(d)) + locations.foreach(d ⇒ loop(d)) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ DirectiveLocation(_, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ SchemaDefinition(ops, dirs, comment, trailingComments, _) ⇒ + if (breakOrSkip(onEnter(n))) { + ops.foreach(s ⇒ loop(s)) + dirs.foreach(s ⇒ loop(s)) + comment.foreach(s ⇒ loop(s)) + trailingComments.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n @ OperationTypeDefinition(_, tpe, comment, _) ⇒ + if (breakOrSkip(onEnter(n))) { + loop(tpe) + comment.foreach(s ⇒ loop(s)) + breakOrSkip(onLeave(n)) + } + n + case n => n + } + +// breakable { + loop(doc) +// } + + } +} + +object MyAstVisitorCommand extends Enumeration { + val Skip, Continue, Break, Transform = Value +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/QueryTransformer.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/QueryTransformer.scala new file mode 100644 index 0000000000..808b4db0ca --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/QueryTransformer.scala @@ -0,0 +1,196 @@ +package cool.graph.subscriptions.schemas + +import cool.graph.shared.models.ModelMutationType +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import sangria.ast.OperationType.Subscription +import sangria.ast._ +import sangria.visitor.VisitorCommand + +object QueryTransformer { + def replaceMutationInFilter(query: Document, mutation: ModelMutationType): AstNode = { + val mutationName = mutation match { + case ModelMutationType.Created => + "CREATED" + case ModelMutationType.Updated => + "UPDATED" + case ModelMutationType.Deleted => + "DELETED" + } + MyAstVisitor.visitAst( + query, + onEnter = { + case ObjectField("mutation_in", EnumValue(value, _, _), _, _) => + val exists = mutationName == value + Some(ObjectField("boolean", BooleanValue(exists))) + + case ObjectField("mutation_in", ListValue(values, _, _), _, _) => + values match { + case (x: EnumValue) +: xs => + var exists = false + val list = values.asInstanceOf[Vector[EnumValue]] + list.foreach(mutation => { + if (mutation.value == mutationName) { + exists = true + } + }) + Some(ObjectField("boolean", BooleanValue(exists))) + + case _ => + None + } + + case _ => + None + }, + onLeave = (node) => { + None + } + ) + } + + def replaceUpdatedFieldsInFilter(query: Document, updatedFields: Set[String]) = { + MyAstVisitor.visitAst( + query, + onEnter = { + case ObjectField(fieldName @ ("updatedFields_contains_every" | "updatedFields_contains_some"), ListValue(values, _, _), _, _) => + values match { + case (x: StringValue) +: _ => + val list = values.asInstanceOf[Vector[StringValue]] + val valuesSet = list.map(_.value).toSet + + fieldName match { + case "updatedFields_contains_every" => + val containsEvery = valuesSet.subsetOf(updatedFields) + Some(ObjectField("boolean", BooleanValue(containsEvery))) + + case "updatedFields_contains_some" => + // is one of the fields in the list included in the updated fields? + val containsSome = valuesSet.exists(updatedFields.contains) + Some(ObjectField("boolean", BooleanValue(containsSome))) + + case _ => + None + } + + case _ => + None + } + + case ObjectField("updatedFields_contains", StringValue(value, _, _), _, _) => + val contains = updatedFields.contains(value) + Some(ObjectField("boolean", BooleanValue(contains))) + + case _ => + None + }, + onLeave = (node) => { + None + } + ) + } + + def mergeBooleans(query: Document) = { + MyAstVisitor.visitAst( + query, + onEnter = { + case x @ ObjectValue(fields, _, _) => + var boolean = true + var booleanFound = false + + fields.foreach({ + case ObjectField("boolean", BooleanValue(value, _, _), _, _) => + boolean = boolean && value + case _ => + }) + + val filteredFields = fields.flatMap(field => { + field match { + case ObjectField("boolean", BooleanValue(value, _, _), _, _) => + booleanFound match { + case true => + None + + case false => + booleanFound = true + Some(field.copy(value = BooleanValue(boolean))) + } + case _ => + Some(field) + } + }) + + Some(x.copy(fields = filteredFields)) + + case _ => + None + }, + onLeave = (node) => { + None + } + ) + } + + def getModelNameFromSubscription(query: Document): Option[String] = { + var modelName: Option[String] = None + + AstVisitor.visit( + query, + onEnter = (node: AstNode) => { + node match { + case OperationDefinition(Subscription, _, _, _, selections, _, _, _) => + selections match { + case (x: Field) +: _ => modelName = Some(x.name) + case _ => + } + + case _ => + } + VisitorCommand.Continue + }, + onLeave = _ => { + VisitorCommand.Continue + } + ) + modelName + } + + def getMutationTypesFromSubscription(query: Document): Set[ModelMutationType] = { + + var mutations: Set[ModelMutationType] = Set.empty + + AstVisitor.visit( + query, + onEnter = (node: AstNode) => { + node match { + case ObjectField("mutation_in", ListValue(values, _, _), _, _) => + values match { + case (x: EnumValue) +: xs => + val list = values.asInstanceOf[Vector[EnumValue]] + list.foreach(mutation => { + mutation.value match { + case "CREATED" => + mutations += ModelMutationType.Created + case "DELETED" => + mutations += ModelMutationType.Deleted + case "UPDATED" => + mutations += ModelMutationType.Updated + } + }) + + case _ => + } + + case _ => + } + VisitorCommand.Continue + }, + onLeave = (node) => { + VisitorCommand.Continue + } + ) + + if (mutations.isEmpty) mutations ++= Set(ModelMutationType.Created, ModelMutationType.Deleted, ModelMutationType.Updated) + + mutations + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionDataResolver.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionDataResolver.scala new file mode 100644 index 0000000000..88be4d7bb8 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionDataResolver.scala @@ -0,0 +1,22 @@ +package cool.graph.subscriptions.schemas + +import cool.graph.FilteredResolver +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.client.schema.simple.SimpleResolveOutput +import cool.graph.shared.models.Model +import cool.graph.subscriptions.SubscriptionUserContext +import sangria.schema.{Args, Context} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object SubscriptionDataResolver { + + def resolve[ManyDataItemType](modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType], + model: Model, + ctx: Context[SubscriptionUserContext, Unit]): Future[Option[SimpleResolveOutput]] = { + FilteredResolver + .resolve(modelObjectTypes, model, ctx.ctx.nodeId, ctx, ctx.ctx.dataResolver) + .map(_.map(dataItem => SimpleResolveOutput(dataItem, Args.empty))) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionQueryValidator.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionQueryValidator.scala new file mode 100644 index 0000000000..d41dfb81a0 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionQueryValidator.scala @@ -0,0 +1,52 @@ +package cool.graph.subscriptions.schemas + +import cool.graph.shared.models.{Model, ModelMutationType, Project} +import org.scalactic.{Bad, Good, Or} +import sangria.ast.Document +import sangria.parser.QueryParser +import sangria.validation.QueryValidator +import scaldi.Injector + +import scala.util.{Failure, Success} + +case class SubscriptionQueryError(errorMessage: String) + +case class SubscriptionQueryValidator(project: Project)(implicit inj: Injector) { + + def validate(query: String): Model Or Seq[SubscriptionQueryError] = { + queryDocument(query).flatMap(validate) + } + + def validate(queryDoc: Document): Model Or Seq[SubscriptionQueryError] = { + for { + modelName <- modelName(queryDoc) + model <- modelFor(modelName) + _ <- validateSubscriptionQuery(queryDoc, model) + } yield model + } + + def queryDocument(query: String): Document Or Seq[SubscriptionQueryError] = QueryParser.parse(query) match { + case Success(doc) => Good(doc) + case Failure(_) => Bad(Seq(SubscriptionQueryError("The subscription query is invalid GraphQL."))) + } + + def modelName(queryDoc: Document): String Or Seq[SubscriptionQueryError] = + QueryTransformer.getModelNameFromSubscription(queryDoc) match { + case Some(modelName) => Good(modelName) + case None => + Bad(Seq(SubscriptionQueryError("The provided query doesn't include any known model name. Please check for the latest subscriptions API."))) + } + + def modelFor(model: String): Model Or Seq[SubscriptionQueryError] = project.getModelByName(model) match { + case Some(model) => Good(model) + case None => Bad(Seq(SubscriptionQueryError("The provided query doesn't include any known model name. Please check for the latest subscriptions API."))) + } + + def validateSubscriptionQuery(queryDoc: Document, model: Model): Unit Or Seq[SubscriptionQueryError] = { + val schema = SubscriptionSchema(model, project, None, ModelMutationType.Created, None, true).build + val violations = QueryValidator.default.validateQuery(schema, queryDoc) + if (violations.nonEmpty) { + Bad(violations.map(v => SubscriptionQueryError(v.errorMessage))) + } else Good(()) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionSchema.scala b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionSchema.scala new file mode 100644 index 0000000000..d1a8fb0760 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/subscriptions/schemas/SubscriptionSchema.scala @@ -0,0 +1,82 @@ +package cool.graph.subscriptions.schemas + +import cool.graph.DataItem +import cool.graph.client.schema.simple.{SimpleOutputMapper, SimpleResolveOutput, SimpleSchemaModelObjectTypeBuilder} +import cool.graph.client.{SangriaQueryArguments, UserContext} +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models.{Model, ModelMutationType, Project} +import cool.graph.subscriptions.SubscriptionUserContext +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future + +case class SubscriptionSchema[ManyDataItemType](model: Model, + project: Project, + updatedFields: Option[List[String]], + mutation: ModelMutationType, + previousValues: Option[DataItem], + externalSchema: Boolean = false)(implicit inj: Injector) + extends Injectable { + val isDelete: Boolean = mutation == ModelMutationType.Deleted + + val schemaBuilder = new SimpleSchemaModelObjectTypeBuilder(project) + val modelObjectTypes: Map[String, ObjectType[UserContext, DataItem]] = schemaBuilder.modelObjectTypes + val outputMapper = SimpleOutputMapper(project, modelObjectTypes) + + val subscriptionField: Field[SubscriptionUserContext, Unit] = Field( + s"${model.name}", + description = Some("The updated node"), + fieldType = OptionType( + outputMapper + .mapSubscriptionOutputType( + model, + modelObjectTypes(model.name), + updatedFields, + mutation, + previousValues, + isDelete match { + case false => None + case true => Some(SimpleResolveOutput(DataItem("", Map.empty), Args.empty)) + } + )), + arguments = List( + externalSchema match { + case false => SangriaQueryArguments.internalFilterSubscriptionArgument(model = model, project = project) + case true => SangriaQueryArguments.filterSubscriptionArgument(model = model, project = project) + } + ), + resolve = (ctx) => + isDelete match { + case false => + SubscriptionDataResolver.resolve(schemaBuilder, model, ctx) + + case true => +// Future.successful(None) + // in the delete case there MUST be the previousValues + Future.successful(Some(SimpleResolveOutput(previousValues.get, Args.empty))) + } + ) + + val createDummyField: Field[SubscriptionUserContext, Unit] = Field( + "dummy", + description = Some("This is only a dummy field due to the API of Schema of Sangria, as Query is not optional"), + fieldType = StringType, + resolve = (ctx) => "" + ) + + def build(): Schema[SubscriptionUserContext, Unit] = { + val Subscription = Some( + ObjectType( + "Subscription", + List(subscriptionField) + )) + + val Query = ObjectType( + "Query", + List(createDummyField) + ) + + Schema(Query, None, Subscription) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/ErrorHandlerFactory.scala b/server/backend-shared/src/main/scala/cool/graph/util/ErrorHandlerFactory.scala new file mode 100644 index 0000000000..e73ef8f3af --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/ErrorHandlerFactory.scala @@ -0,0 +1,178 @@ +package cool.graph.util + +import akka.http.scaladsl.model.StatusCode +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.server.Directives.complete +import akka.http.scaladsl.server.{ExceptionHandler => AkkaHttpExceptionHandler} +import cool.graph.bugsnag.{BugSnagger, GraphCoolRequest} +import cool.graph.client.{HandledError, UnhandledError} +import cool.graph.cloudwatch.Cloudwatch +import cool.graph.shared.errors.UserFacingError +import cool.graph.shared.logging.{LogData, LogKey} +import sangria.execution.Executor.{ExceptionHandler => SangriaExceptionHandler} +import sangria.execution._ +import sangria.marshalling.MarshallingUtil._ +import sangria.marshalling.sprayJson._ +import sangria.marshalling.{ResultMarshaller, SimpleResultMarshallerForType} +import scaldi.{Injectable, Injector} +import spray.json.{JsNumber, JsObject, JsString, JsValue} + +import scala.concurrent.ExecutionException + +/** + * Created by sorenbs on 19/07/16. + */ +object ErrorHandlerFactory extends Injectable { + + def internalErrorMessage(requestId: String) = + s"Whoops. Looks like an internal server error. Please contact us from the Console (https://console.graph.cool) or via email (support@graph.cool) and include your Request ID: $requestId" + + def apply(log: Function[String, Unit])(implicit inj: Injector): ErrorHandlerFactory = { + val cloudwatch: Cloudwatch = inject[Cloudwatch]("cloudwatch") + val bugsnagger: BugSnagger = inject[BugSnagger] + ErrorHandlerFactory(log, cloudwatch, bugsnagger) + } +} + +case class ErrorHandlerFactory( + log: Function[String, Unit], + cloudwatch: Cloudwatch, + bugsnagger: BugSnagger +) { + + type UnhandledErrorLogger = Throwable => (StatusCode, JsObject) + + def sangriaAndUnhandledHandlers( + requestId: String, + query: String, + variables: JsValue, + clientId: Option[String], + projectId: Option[String] + ): (SangriaExceptionHandler, UnhandledErrorLogger) = { + sangriaHandler(requestId, query, variables, clientId, projectId) -> unhandledErrorHandler(requestId, query, variables, clientId, projectId) + } + + def sangriaHandler( + requestId: String, + query: String, + variables: JsValue, + clientId: Option[String], + projectId: Option[String] + ): SangriaExceptionHandler = { + val errorLogger = logError(requestId, query, variables, clientId, projectId) + val bugsnag = reportToBugsnag(requestId, query, variables, clientId, projectId) + val exceptionHandler: SangriaExceptionHandler = { + case (m: ResultMarshaller, e: UserFacingError) => + errorLogger(e, LogKey.HandledError) + val additionalFields: Seq[(String, m.Node)] = + Seq("code" -> m.scalarNode(e.code, "Int", Set.empty), "requestId" -> m.scalarNode(requestId, "Int", Set.empty)) + + val optionalAdditionalFields = e.functionError.map { functionError => + "functionError" -> functionError.convertMarshaled(SimpleResultMarshallerForType(m)) //.convertMarshaled[sangria.ast.AstNode] + } + + HandledException(e.getMessage, Map(additionalFields ++ optionalAdditionalFields: _*)) + + case (m, e: ExecutionException) => + e.getCause.printStackTrace() + errorLogger(e, LogKey.UnhandledError) + bugsnag(e) + HandledException(ErrorHandlerFactory.internalErrorMessage(requestId), Map("requestId" -> m.scalarNode(requestId, "Int", Set.empty))) + + case (m, e) => + errorLogger(e, LogKey.UnhandledError) + bugsnag(e) + HandledException(ErrorHandlerFactory.internalErrorMessage(requestId), Map("requestId" -> m.scalarNode(requestId, "Int", Set.empty))) + } + exceptionHandler + } + + def akkaHttpHandler( + requestId: String, + query: String = "unknown", + variables: JsValue = JsObject.empty, + clientId: Option[String] = None, + projectId: Option[String] = None + ): AkkaHttpExceptionHandler = { + import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ + AkkaHttpExceptionHandler { + case e: Throwable => complete(unhandledErrorHandler(requestId)(e)) + } + } + + def unhandledErrorHandler( + requestId: String, + query: String = "unknown", + variables: JsValue = JsObject.empty, + clientId: Option[String] = None, + projectId: Option[String] = None + ): UnhandledErrorLogger = { error: Throwable => + val errorLogger = logError(requestId, query, variables, clientId, projectId) + error match { + case e: UserFacingError => + errorLogger(e, LogKey.HandledError) + OK -> JsObject("code" -> JsNumber(e.code), "requestId" -> JsString(requestId), "error" -> JsString(error.getMessage)) + + case e => + errorLogger(e, LogKey.UnhandledError) + InternalServerError → JsObject("requestId" -> JsString(requestId), "error" -> JsString(ErrorHandlerFactory.internalErrorMessage(requestId))) + } + } + + private def logError( + requestId: String, + query: String, + variables: JsValue, + clientId: Option[String], + projectId: Option[String] + ): (Throwable, LogKey.Value) => Unit = (error: Throwable, logKey: LogKey.Value) => { + val payload = error match { + case error: UserFacingError => + Map( + "message" -> error.getMessage, + "code" -> error.code, + "query" -> query, + "variables" -> variables, + "exception" -> error.toString, + "stack_trace" -> error.getStackTrace + .map(_.toString) + .mkString(", ") + ) + case error => + Map( + "message" -> error.getMessage, + "code" -> 0, + "query" -> query, + "variables" -> variables, + "exception" -> error.toString, + "stack_trace" -> error.getStackTrace + .map(_.toString) + .mkString(", ") + ) + } + + cloudwatch.measure(error match { + case e: UserFacingError => HandledError(e) + case e => UnhandledError(e) + }) + + log(LogData(logKey, requestId, clientId, projectId, payload = Some(payload)).json) + } + + private def reportToBugsnag( + requestId: String, + query: String, + variables: JsValue, + clientId: Option[String], + projectId: Option[String] + ): Throwable => Unit = { t: Throwable => + val request = GraphCoolRequest( + requestId = requestId, + clientId = clientId, + projectId = projectId, + query = query, + variables = variables.prettyPrint + ) + bugsnagger.report(t, request) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/collection/ToImmutables.scala b/server/backend-shared/src/main/scala/cool/graph/util/collection/ToImmutables.scala new file mode 100644 index 0000000000..3358baf876 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/collection/ToImmutables.scala @@ -0,0 +1,9 @@ +package cool.graph.util.collection + +object ToImmutable { + implicit class ToImmutableSeq[T](seq: Seq[T]) { + def toImmutable: collection.immutable.Seq[T] = { + collection.immutable.Seq(seq: _*) + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/FromInputImplicit.scala b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/FromInputImplicit.scala new file mode 100644 index 0000000000..a03b8becd3 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/FromInputImplicit.scala @@ -0,0 +1,16 @@ +package cool.graph.util.coolSangria + +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput, ResultMarshaller} + +object FromInputImplicit { + + implicit val DefaultScalaResultMarshaller: FromInput[Any] = new FromInput[Any] { + override val marshaller: ResultMarshaller = ResultMarshaller.defaultResultMarshaller + override def fromResult(node: marshaller.Node): Any = node + } + + implicit val CoercedResultMarshaller: FromInput[Any] = new FromInput[Any] { + override val marshaller: ResultMarshaller = CoercedScalaResultMarshaller.default + override def fromResult(node: marshaller.Node): Any = node + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/ManualMarshallerHelpers.scala b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/ManualMarshallerHelpers.scala new file mode 100644 index 0000000000..fe0bf7b2fb --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/ManualMarshallerHelpers.scala @@ -0,0 +1,29 @@ +package cool.graph.util.coolSangria + +object ManualMarshallerHelpers { + implicit class ManualMarshallerHelper(args: Any) { + val asMap: Map[String, Any] = args.asInstanceOf[Map[String, Any]] + + def clientMutationId: Option[String] = optionalArgAsString("clientMutationId") + + def requiredArgAsString(name: String): String = requiredArgAs[String](name) + def optionalArgAsString(name: String): Option[String] = optionalArgAs[String](name) + + def requiredArgAsBoolean(name: String): Boolean = requiredArgAs[Boolean](name) + def optionalArgAsBoolean(name: String): Option[Boolean] = optionalArgAs[Boolean](name) + + def requiredArgAs[T](name: String): T = asMap(name).asInstanceOf[T] + def optionalArgAs[T](name: String): Option[T] = asMap.get(name).flatMap(x => x.asInstanceOf[Option[T]]) + + def optionalOptionalArgAsString(name: String): Option[Option[String]] = { + + asMap.get(name) match { + case None => None + case Some(None) => Some(None) + case Some(x: String) => Some(Some(x)) + case Some(Some(x: String)) => Some(Some(x)) + case x => sys.error("OptionalOptionalArgsAsStringFailed" + x.toString) + } + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/Sangria.scala b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/Sangria.scala new file mode 100644 index 0000000000..25a360bb18 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/coolSangria/Sangria.scala @@ -0,0 +1,12 @@ +package cool.graph.util.coolSangria + +import sangria.schema.Args + +import scala.collection.concurrent.TrieMap + +object Sangria { + + def rawArgs(raw: Map[String, Any]): Args = { + new Args(raw, Set.empty, Set.empty, Set.empty, TrieMap.empty) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/crypto/Crypto.scala b/server/backend-shared/src/main/scala/cool/graph/util/crypto/Crypto.scala new file mode 100644 index 0000000000..d022987f1f --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/crypto/Crypto.scala @@ -0,0 +1,9 @@ +package cool.graph.util.crypto + +import com.github.t3hnar.bcrypt._ + +object Crypto { + def hash(password: String): String = password.bcrypt + + def verify(password: String, hash: String): Boolean = password.isBcrypted(hash) +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/debug/DebugMacros.scala b/server/backend-shared/src/main/scala/cool/graph/util/debug/DebugMacros.scala new file mode 100644 index 0000000000..b15add51d8 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/debug/DebugMacros.scala @@ -0,0 +1,63 @@ +package cool.graph.util.debug + +import language.experimental.macros + +import reflect.macros.blackbox.Context + +trait DebugMacros { + def debug(params: Any*): Unit = macro DebugMacros.debug_impl +} + +object DebugMacros extends DebugMacros { + def hello(): Unit = macro hello_impl + + def hello_impl(c: Context)(): c.Expr[Unit] = { + import c.universe._ + reify { println("Hello World!") } + } + + def printparam(param: Any): Unit = macro printparam_impl + + def printparam_impl(c: Context)(param: c.Expr[Any]): c.Expr[Unit] = { + import c.universe._ + reify { println(param.splice) } + } + + def debug1(param: Any): Unit = macro debug1_impl + + def debug1_impl(c: Context)(param: c.Expr[Any]): c.Expr[Unit] = { + import c.universe._ + val paramRep = show(param.tree) + val paramRepTree = Literal(Constant(paramRep)) + val paramRepExpr = c.Expr[String](paramRepTree) + reify { println(paramRepExpr.splice + " = " + param.splice) } + } + + def debug_impl(c: Context)(params: c.Expr[Any]*): c.Expr[Unit] = { + import c.universe._ + + val trees = params.map { param => + param.tree match { + // Keeping constants as-is + // The c.universe prefixes aren't necessary, but otherwise Idea keeps importing weird stuff ... + case c.universe.Literal(c.universe.Constant(const)) => { + val reified = reify { print(param.splice) } + reified.tree + } + case _ => { + val paramRep = show(param.tree) + val paramRepTree = Literal(Constant(paramRep)) + val paramRepExpr = c.Expr[String](paramRepTree) + val reified = reify { print(paramRepExpr.splice + " = " + param.splice) } + reified.tree + } + } + } + + // Inserting ", " between trees, and a println at the end. + val separators = (1 to trees.size - 1).map(_ => (reify { print(", ") }).tree) :+ (reify { println() }).tree + val treesWithSeparators = trees.zip(separators).flatMap(p => List(p._1, p._2)) + + c.Expr[Unit](Block(treesWithSeparators.toList, Literal(Constant(())))) + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/exceptions/ExceptionStacktraceToString.scala b/server/backend-shared/src/main/scala/cool/graph/util/exceptions/ExceptionStacktraceToString.scala new file mode 100644 index 0000000000..7d74ed199d --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/exceptions/ExceptionStacktraceToString.scala @@ -0,0 +1,17 @@ +package cool.graph.util.exceptions + +import java.io.{PrintWriter, StringWriter} + +object ExceptionStacktraceToString { + + implicit class ThrowableStacktraceExtension(t: Throwable) { + def stackTraceAsString: String = ExceptionStacktraceToString(t) + } + + def apply(t: Throwable): String = { + val sw = new StringWriter() + val pw = new PrintWriter(sw) + t.printStackTrace(pw) + sw.toString() + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/json/Json.scala b/server/backend-shared/src/main/scala/cool/graph/util/json/Json.scala new file mode 100644 index 0000000000..62de47f273 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/json/Json.scala @@ -0,0 +1,94 @@ +package cool.graph.util.json + +import spray.json._ + +import scala.util.{Failure, Success, Try} +import cool.graph.util.exceptions.ExceptionStacktraceToString._ + +object Json extends SprayJsonExtensions { + + /** + * extracts a nested json value by a given path like "foo.bar.fizz" + */ + def getPathAs[T <: JsValue](json: JsValue, path: String): T = { + def getArrayIndex(pathElement: String): Option[Int] = Try(pathElement.replaceAllLiterally("[", "").replaceAllLiterally("]", "").toInt).toOption + + def getPathAsInternal[T <: JsValue](json: JsValue, pathElements: Seq[String]): Try[T] = { + if (pathElements.isEmpty) { + Try(json.asInstanceOf[T]) + } else if (getArrayIndex(pathElements.head).isDefined) { + Try(json.asInstanceOf[JsArray]) match { + case Success(jsList) => + val index = getArrayIndex(pathElements.head).get + val subJson = jsList.elements + .lift(index) + .getOrElse(sys.error(s"Could not find pathElement [${pathElements.head} in this json $json]")) + getPathAsInternal(subJson, pathElements.tail) + case Failure(e) => Failure(e) //sys.error(s"[$json] is not a Jsbject!") + } + } else { + Try(json.asJsObject) match { + case Success(jsObject) => + val subJson = jsObject.fields.getOrElse(pathElements.head, sys.error(s"Could not find pathElement [${pathElements.head} in this json $json]")) + getPathAsInternal(subJson, pathElements.tail) + case Failure(e) => Failure(e) //sys.error(s"[$json] is not a Jsbject!") + } + } + } + getPathAsInternal[T](json, path.split('.')) match { + case Success(x) => + x + case Failure(e) => + sys.error(s"Getting the path $path in $json failed with the following error: ${e.stackTraceAsString}") + } + } + + def getPathAs[T <: JsValue](jsonString: String, path: String): T = { + import spray.json._ + getPathAs(jsonString.parseJson, path) + } + +} + +trait SprayJsonExtensions { + implicit class StringExtensions(string: String) { + def tryParseJson(): Try[JsValue] = Try { string.parseJson } + } + + implicit class JsValueParsingExtensions(jsValue: JsValue) { + def pathAs[T <: JsValue](path: String): T = Json.getPathAs[T](jsValue, path) + + def pathAsJsValue(path: String): JsValue = pathAs[JsValue](path) + def pathAsJsObject(path: String): JsObject = pathAs[JsObject](path) + def pathExists(path: String): Boolean = Try(pathAsJsValue(path)).map(_ => true).getOrElse(false) + + def pathAsSeq(path: String): Seq[JsValue] = Json.getPathAs[JsArray](jsValue, path).elements + def pathAsSeqOfType[T](path: String)(implicit format: JsonFormat[T]): Seq[T] = + Json.getPathAs[JsArray](jsValue, path).elements.map(_.convertTo[T]) + + def pathAsString(path: String): String = { + try { + pathAs[JsString](path).value + } catch { + case e: Exception => + pathAs[JsNull.type](path) + null + } + } + + def pathAsLong(path: String): Long = pathAs[JsNumber](path).value.toLong + + def pathAsFloat(path: String): Float = pathAs[JsNumber](path).value.toFloat + + def pathAsDouble(path: String): Double = pathAs[JsNumber](path).value.toDouble + + def pathAsBool(path: String): Boolean = pathAs[JsBoolean](path).value + + def getFirstErrorMessage = jsValue.pathAsSeq("errors").head.pathAsString("message") + + def getFirstErrorCode = jsValue.pathAsSeq("errors").head.pathAsLong("code") + + def getFirstFunctionErrorMessage = jsValue.pathAsSeq("errors").head.pathAsString("functionError") + } + +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/json/PlaySprayConversions.scala b/server/backend-shared/src/main/scala/cool/graph/util/json/PlaySprayConversions.scala new file mode 100644 index 0000000000..033112dbf8 --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/json/PlaySprayConversions.scala @@ -0,0 +1,47 @@ +package cool.graph.util.json + +import play.api.libs.json.{ + JsArray => PJsArray, + JsBoolean => PJsBoolean, + JsNull => PJsNull, + JsNumber => PJsNumber, + JsObject => PJsObject, + JsString => PJsString, + JsValue => PJsValue +} +import spray.json._ + +object PlaySprayConversions extends PlaySprayConversions + +trait PlaySprayConversions { + + implicit class PlayToSprayExtension(jsValue: PJsValue) { + def toSpray(): JsValue = toSprayImpl(jsValue) + } + + implicit class SprayToPlayExtension(jsValue: JsValue) { + def toPlay(): PJsValue = toPlayImpl(jsValue) + } + + private def toSprayImpl(jsValue: PJsValue): JsValue = { + jsValue match { + case PJsObject(fields) => JsObject(fields.map { case (name, jsValue) => (name, toSprayImpl(jsValue)) }.toMap) + case PJsArray(elements) => JsArray(elements.map(toSprayImpl).toVector) + case PJsString(s) => JsString(s) + case PJsNumber(nr) => JsNumber(nr) + case PJsBoolean(b) => JsBoolean(b) + case PJsNull => JsNull + } + } + + private def toPlayImpl(jsValue: JsValue): PJsValue = { + jsValue match { + case JsObject(fields) => PJsObject(fields.mapValues(toPlayImpl).toSeq) + case JsArray(elements) => PJsArray(elements.map(toPlayImpl)) + case JsString(s) => PJsString(s) + case JsNumber(nr) => PJsNumber(nr) + case JsBoolean(b) => PJsBoolean(b) + case JsNull => PJsNull + } + } +} diff --git a/server/backend-shared/src/main/scala/cool/graph/util/performance/TimeHelper.scala b/server/backend-shared/src/main/scala/cool/graph/util/performance/TimeHelper.scala new file mode 100644 index 0000000000..976d93cc2b --- /dev/null +++ b/server/backend-shared/src/main/scala/cool/graph/util/performance/TimeHelper.scala @@ -0,0 +1,13 @@ +package cool.graph.util.performance + +trait TimeHelper { + def time[R](measurementName: String = "")(block: => R): R = { + val t0 = System.nanoTime() + val result = block + val t1 = System.nanoTime() + val diffInMicros = (t1 - t0) / 1000 + val millis = diffInMicros.toDouble / 1000 + println(s"Elapsed time [$measurementName]: ${millis}ms") + result + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/TransactionSpec.scala b/server/backend-shared/src/test/scala/cool/graph/TransactionSpec.scala new file mode 100644 index 0000000000..718782b121 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/TransactionSpec.scala @@ -0,0 +1,63 @@ +package cool.graph + +import cool.graph.client.database.DataResolver +import cool.graph.shared.database.Databases +import org.scalatest.{FlatSpec, Matchers} + +import scala.concurrent.Future +import scala.util.{Failure, Random, Success, Try} + +class TransactionSpec extends FlatSpec with Matchers { + import cool.graph.util.AwaitUtils._ + + import scala.language.reflectiveCalls + + val dataResolver: DataResolver = null // we don't need it for those tests + + "Transaction.verify" should "return a success if it contains no Mutactions at all" in { + val transaction = Transaction(List.empty, dataResolver) + val result = await(transaction.verify()) + result should be(Success(MutactionVerificationSuccess())) + } + + "Transaction.verify" should "return a success if all Mutactions succeed" in { + val mutactions = List(successfulMutaction, successfulMutaction, successfulMutaction) + val transaction = Transaction(mutactions, dataResolver) + val result = await(transaction.verify()) + result should be(Success(MutactionVerificationSuccess())) + } + + "Transaction.verify" should "return the failure of the first failed Mutaction" in { + for (i <- 1 to 10) { + val failedMutactions = + Random.shuffle(List(failedMutaction("error 1"), failedMutaction("error 2"), failedMutaction("error 3"))) + val mutactions = List(successfulMutaction) ++ failedMutactions + val transaction = Transaction(mutactions, dataResolver) + val result = await(transaction.verify()) + result.isFailure should be(true) + result.failed.get.getMessage should be(failedMutactions.head.errorMessage) + } + } + + def failedMutaction(errorMsg: String) = { + new ClientSqlMutaction { + val errorMessage = errorMsg + + override def execute = ??? + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(Failure(new Exception(errorMessage))) + } + } + } + + def successfulMutaction = { + new ClientSqlMutaction { + override def execute = ??? + + override def verify(): Future[Try[MutactionVerificationSuccess]] = { + Future.successful(Success(MutactionVerificationSuccess())) + } + } + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/UtilsSpec.scala b/server/backend-shared/src/test/scala/cool/graph/UtilsSpec.scala new file mode 100644 index 0000000000..f2dc186eb9 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/UtilsSpec.scala @@ -0,0 +1,39 @@ +package cool.graph + +import org.scalatest.{FlatSpec, Matchers} + +class UtilsSpec extends FlatSpec with Matchers { + + implicit val caseClassFormat = cool.graph.JsonFormats.CaseClassFormat + import spray.json._ + + "CaseClassFormat" should "format simple case class" in { + case class Simple(string: String, int: Int) + + val instance = Simple("a", 1) + + val json = instance.asInstanceOf[Product].toJson.toString + json should be("""{"string":"a","int":1}""") + } + + "CaseClassFormat" should "format complex case class" in { + case class Simple(string: String, int: Int) + case class Complex(int: Int, simple: Simple) + + val instance = Complex(1, Simple("a", 2)) + + val json = instance.asInstanceOf[Product].toJson.toString + json should be("""{"int":1,"simple":"..."}""") + } + + "CaseClassFormat" should "format complex case class with id" in { + case class Simple(id: String, string: String, int: Int) + case class Complex(int: Int, simple: Simple) + + val instance = Complex(1, Simple("id1", "a", 2)) + + val json = instance.asInstanceOf[Product].toJson.toString + json should be("""{"int":1,"simple":"id1"}""") + } + +} diff --git a/server/backend-shared/src/test/scala/cool/graph/client/database/GlobalDatabaseManagerSpec.scala b/server/backend-shared/src/test/scala/cool/graph/client/database/GlobalDatabaseManagerSpec.scala new file mode 100644 index 0000000000..8a1bc64c9b --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/client/database/GlobalDatabaseManagerSpec.scala @@ -0,0 +1,121 @@ +package cool.graph.client.database + +import com.typesafe.config.ConfigFactory +import cool.graph.shared.database.{GlobalDatabaseManager, ProjectDatabaseRef} +import cool.graph.shared.models.Region +import org.scalatest.{FlatSpec, Matchers} + +class GlobalDatabaseManagerSpec extends FlatSpec with Matchers { + + it should "initialize correctly for a single region" in { + val config = ConfigFactory.parseString(s""" + |awsRegion = "eu-west-1" + | + |clientDatabases { + | client1 { + | master { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host1:1000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | numThreads = 1 + | connectionTimeout = 5000 + | } + | readonly { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host2:2000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | readOnly = true + | numThreads = 1 + | connectionTimeout = 5000 + | } + | } + | + | client2 { + | master { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host3:3000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | numThreads = 1 + | connectionTimeout = 5000 + | } + | } + |} + """.stripMargin) + + val region = Region.EU_WEST_1 + val result = GlobalDatabaseManager.initializeForSingleRegion(config) + result.currentRegion should equal(region) + result.databases should have size (2) + result.databases should contain key (ProjectDatabaseRef(region, name = "client1")) + result.databases should contain key (ProjectDatabaseRef(region, name = "client2")) + } + + it should "initialize correctly for a multiple regions" in { + val config = ConfigFactory.parseString(s""" + |awsRegion = "ap-northeast-1" + | + |allClientDatabases { + | eu-west-1 { + | client1 { + | master { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host1:1000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | numThreads = 1 + | connectionTimeout = 5000 + | } + | readonly { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host2:2000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | readOnly = true + | numThreads = 1 + | connectionTimeout = 5000 + | } + | } + | } + | us-west-2 { + | client1 { + | master { + | connectionInitSql="set names utf8mb4" + | dataSourceClass = "slick.jdbc.DriverDataSource" + | properties { + | url = "jdbc:mysql:aurora://host3:3000/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + | user = user + | password = password + | } + | numThreads = 1 + | connectionTimeout = 5000 + | } + | } + | } + |} + """.stripMargin) + + val result = GlobalDatabaseManager.initializeForMultipleRegions(config) + result.currentRegion should equal(Region.AP_NORTHEAST_1) + result.databases should have size (2) + result.databases should contain key (ProjectDatabaseRef(Region.EU_WEST_1, name = "client1")) + result.databases should contain key (ProjectDatabaseRef(Region.US_WEST_2, name = "client1")) + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/deprecated/packageMocks/PackageParserSpec/PackageParserSpec.scala b/server/backend-shared/src/test/scala/cool/graph/deprecated/packageMocks/PackageParserSpec/PackageParserSpec.scala new file mode 100644 index 0000000000..602c2d7277 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/deprecated/packageMocks/PackageParserSpec/PackageParserSpec.scala @@ -0,0 +1,47 @@ +package cool.graph.deprecated.packageMocks.PackageParserSpec + +import cool.graph.deprecated.packageMocks.PackageParser +import org.scalatest.{FlatSpec, Matchers} + +class PackageParserSpec extends FlatSpec with Matchers { + "PackageParser" should "work" in { + val packageYaml = + """ + |name: anonymous-auth-provider + | + |functions: + | authenticateAnonymousUser: + | schema: > + | type input { + | secret: String! + | } + | type output { + | token: String! + | } + | type: webhook + | url: https://some-webhook + | + |interfaces: + | AnonymousUser: + | schema: > + | interface AnonymousUser { + | secret: String + | isVerified: Boolean! + | } + | + |# This is configured by user when installing + |install: + | - type: mutation + | binding: functions.authenticateAnonymousUser + | name: authenticateAnonymousCustomer + | - type: interface + | binding: interfaces.AnonymousUser + | onType: Customer + | + """.stripMargin + + val importedPackage = PackageParser.parse(packageYaml) + + println(importedPackage) + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/functions/lambda/LambdaLogsSpec.scala b/server/backend-shared/src/test/scala/cool/graph/functions/lambda/LambdaLogsSpec.scala new file mode 100644 index 0000000000..2227036172 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/functions/lambda/LambdaLogsSpec.scala @@ -0,0 +1,48 @@ +package cool.graph.functions.lambda + +import cool.graph.shared.functions.lambda.LambdaFunctionEnvironment +import org.scalatest.{FlatSpec, Matchers} +import spray.json.{JsObject, JsString} + +class LambdaLogsSpec extends FlatSpec with Matchers { + "Logs parsing for lambda" should "return the correct aggregation of lines" in { + val testString = + """ + |START RequestId: fb6c1b70-afef-11e7-b988-db72e0053f77 Version: $LATEST + |2017-10-13T08:24:50.856Z fb6c1b70-afef-11e7-b988-db72e0053f77 getting event {} + |2017-10-13T08:24:50.856Z fb6c1b70-afef-11e7-b988-db72e0053f77 requiring event => { + | return { + | data: { + | message: "msg" + | } + | } + |} + |2017-10-13T08:24:50.857Z fb6c1b70-afef-11e7-b988-db72e0053f77 {"errorMessage":"Cannot read property 'name' of undefined","errorType":"TypeError","stackTrace":["module.exports.event (/var/task/src/hello2.js:6:47)","executeFunction (/var/task/src/hello2-lambda.js:14:19)","exports.handle (/var/task/src/hello2-lambda.js:9:3)"]} + |END RequestId: fb6c1b70-afef-11e7-b988-db72e0053f77 + |REPORT RequestId: fb6c1b70-afef-11e7-b988-db72e0053f77 Duration: 1.10 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 26 MB + """.stripMargin + + val testString2 = + """ + |2017-10-23T10:05:04.839Z a426c566-b7d9-11e7-a701-7b78cbef51e9 20 + |2017-10-23T10:05:04.839Z a426c566-b7d9-11e7-a701-7b78cbef51e9 null + |2017-10-23T10:05:04.839Z a426c566-b7d9-11e7-a701-7b78cbef51e9 { big: 'OBJECT' } + """.stripMargin + + val logs = LambdaFunctionEnvironment.parseLambdaLogs(testString) + logs should contain(JsObject("2017-10-13T08:24:50.856Z" -> JsString("getting event {}"))) + logs should contain( + JsObject("2017-10-13T08:24:50.856Z" -> JsString("requiring event => {\n return {\n data: {\n message: \"msg\"\n }\n }\n}"))) + logs should contain(JsObject("2017-10-13T08:24:50.857Z" -> JsString( + """{"errorMessage":"Cannot read property 'name' of undefined","errorType":"TypeError","stackTrace":["module.exports.event (/var/task/src/hello2.js:6:47)","executeFunction (/var/task/src/hello2-lambda.js:14:19)","exports.handle (/var/task/src/hello2-lambda.js:9:3)"]}"""))) + + val logs2 = LambdaFunctionEnvironment.parseLambdaLogs(testString2) + + logs.length shouldEqual 3 + + logs2.length shouldEqual 3 + logs2 should contain(JsObject("2017-10-23T10:05:04.839Z" -> JsString("20"))) + logs2 should contain(JsObject("2017-10-23T10:05:04.839Z" -> JsString("null"))) + logs2 should contain(JsObject("2017-10-23T10:05:04.839Z" -> JsString("{ big: 'OBJECT' }"))) + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/util/AwaitUtils.scala b/server/backend-shared/src/test/scala/cool/graph/util/AwaitUtils.scala new file mode 100644 index 0000000000..eba13dfff2 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/util/AwaitUtils.scala @@ -0,0 +1,17 @@ +package cool.graph.util + +import scala.concurrent.{Await, Awaitable} + +object AwaitUtils { + def await[T](awaitable: Awaitable[T]): T = { + import scala.concurrent.duration._ + Await.result(awaitable, 5.seconds) + } + + implicit class AwaitableExtension[T](awaitable: Awaitable[T]) { + import scala.concurrent.duration._ + def await: T = { + Await.result(awaitable, 5.seconds) + } + } +} diff --git a/server/backend-shared/src/test/scala/cool/graph/util/JsonStringExtensionsSpec.scala b/server/backend-shared/src/test/scala/cool/graph/util/JsonStringExtensionsSpec.scala new file mode 100644 index 0000000000..bfc30654e0 --- /dev/null +++ b/server/backend-shared/src/test/scala/cool/graph/util/JsonStringExtensionsSpec.scala @@ -0,0 +1,27 @@ +package cool.graph.util + +import cool.graph.util.json.Json._ +import org.scalatest.{Matchers, WordSpec} +import spray.json._ + +class JsonStringExtensionsSpec extends WordSpec with Matchers { + + "pathAs" should { + "get string" in { + """{"a": "b"}""".parseJson.pathAsString("a") should be("b") + } + + "get string nested in array" in { + val json = """{"a": ["b", "c"]}""".parseJson + json.pathAsString("a.[0]") should be("b") + json.pathAsString("a.[1]") should be("c") + } + + "get string nested in object in array" in { + val json = """{"a": [{"b":"c"}, {"b":"d"}]}""".parseJson + json.pathAsString("a.[0].b") should be("c") + json.pathAsString("a.[1].b") should be("d") + } + } + +} diff --git a/server/backend-workers/build.sbt b/server/backend-workers/build.sbt new file mode 100644 index 0000000000..072b38b61a --- /dev/null +++ b/server/backend-workers/build.sbt @@ -0,0 +1,2 @@ +name := "backend-workers" +mainClass in Compile := Some("cool.graph.worker.WorkerMain") \ No newline at end of file diff --git a/server/backend-workers/src/main/resources/application.conf b/server/backend-workers/src/main/resources/application.conf new file mode 100644 index 0000000000..ce076095f9 --- /dev/null +++ b/server/backend-workers/src/main/resources/application.conf @@ -0,0 +1,43 @@ +akka { + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + request-timeout = 60s // Clone Project is too slow for default 20s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } +} + + +// Todo this is a silly pattern. Should probably be done in code +logs { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"/"${?SQL_LOGS_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +logsRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true&characterEncoding=UTF-8&usePipelineAuth=false" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +slick.dbs.default.db.connectionInitSql="set names utf8mb4" \ No newline at end of file diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/WorkerMain.scala b/server/backend-workers/src/main/scala/cool/graph/worker/WorkerMain.scala new file mode 100644 index 0000000000..15033007d8 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/WorkerMain.scala @@ -0,0 +1,19 @@ +package cool.graph.worker + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.worker.services.WorkerCloudServices +import cool.graph.worker.utils.Env + +object WorkerMain extends App { + implicit val bugsnagger = BugSnaggerImpl(Env.bugsangApiKey) + implicit val system = ActorSystem("backend-workers") + implicit val materializer = ActorMaterializer() + + val services = WorkerCloudServices() + val serverExecutor = ServerExecutor(8090, WorkerServer(services)) + + serverExecutor.startBlocking() +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/WorkerServer.scala b/server/backend-workers/src/main/scala/cool/graph/worker/WorkerServer.scala new file mode 100644 index 0000000000..f64ab16ce3 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/WorkerServer.scala @@ -0,0 +1,45 @@ +package cool.graph.worker + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.{Routes, Server} +import cool.graph.bugsnag.BugSnagger +import cool.graph.worker.services.WorkerServices +import cool.graph.worker.workers.{FunctionLogsWorker, WebhookDelivererWorker, Worker} + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +case class WorkerServer(services: WorkerServices, prefix: String = "")(implicit system: ActorSystem, materializer: ActorMaterializer, bugsnag: BugSnagger) + extends Server { + import system.dispatcher + + val workers = Vector[Worker]( + FunctionLogsWorker(services.logsDb, services.logsQueue), + WebhookDelivererWorker(services.httpClient, services.webhooksConsumer, services.logsQueue) + ) + + val innerRoutes = Routes.emptyRoute + + def healthCheck: Future[_] = Future.successful(()) + + override def onStart: Future[_] = { + println("Initializing workers...") + val initFutures = Future.sequence(workers.map(_.start)) + + initFutures.onComplete { + case Success(_) => println(s"Successfully started ${workers.length} workers.") + case Failure(err) => println(s"Failed to initialize workers: $err") + } + + initFutures + } + + override def onStop: Future[_] = { + println("Stopping workers...") + val stopFutures = Future.sequence(workers.map(_.stop)) + + stopFutures.onComplete(_ => services.shutdown) + stopFutures + } +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/helpers/FunctionLogsErrorShovel.scala b/server/backend-workers/src/main/scala/cool/graph/worker/helpers/FunctionLogsErrorShovel.scala new file mode 100644 index 0000000000..465dba1546 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/helpers/FunctionLogsErrorShovel.scala @@ -0,0 +1,88 @@ +package cool.graph.worker.helpers + +import java.util.concurrent.atomic.AtomicInteger + +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.messagebus.Conversions.ByteUnmarshaller +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.worker.payloads.{JsonConversions, LogItem} +import cool.graph.worker.utils.Utils +import org.joda.time.DateTime +import play.api.libs.json.{JsObject, Json} + +import scala.concurrent.{Await, Future} +import scala.util.{Failure, Success, Try} + +/** + * Executable util to shovel messages out of the function logs error queue into the processing queue. + * Restores routing key to normal 'mgs.0' and has fault-tolerant body parsing to transition failed messages to the + * new error json format. + */ +object FunctionLogsErrorShovel extends App { + import JsonConversions._ + + import scala.concurrent.ExecutionContext.Implicits.global + import scala.concurrent.duration._ + + case class OldLogItem( + id: String, + projectId: String, + functionId: String, + requestId: String, + status: String, + duration: Long, + timestamp: String, + message: String + ) { + def toLogItem: LogItem = { + status match { + case "SUCCESS" => LogItem(id, projectId, functionId, requestId, status, duration, timestamp, Json.parse(message).as[JsObject]) + case "FAILURE" => LogItem(id, projectId, functionId, requestId, status, duration, timestamp, Json.obj("error" -> message)) + } + } + } + + implicit val bugsnagger = BugSnaggerImpl("") + implicit val oldLogItemFormat = Json.format[OldLogItem] + + val amqpUri = sys.env("RABBITMQ_URI") + + val faultTolerantUnmarshaller: ByteUnmarshaller[LogItem] = { bytes => + Try { logItemUnmarshaller(bytes) }.orElse(fromOldLogItemFormat(bytes)) match { + case Success(logItem) => logItem.copy(timestamp = correctLogTimestamp(logItem.timestamp)) + case Failure(err) => throw err + } + } + + val marshaller = JsonConversions.logItemMarshaller + val targetPublisher = RabbitQueue.publisher[LogItem](amqpUri, "function-logs") + val counter = new AtomicInteger(0) + + val consumeFn = { msg: LogItem => + println(s"[FunctionLogsErrorShovel][${counter.incrementAndGet()}]] Re-processing: $msg") + targetPublisher.publish(msg) + Future.successful(()) + } + + val plainErrConsumer = + RabbitQueue.plainConsumer[LogItem](amqpUri, "function-logs-error", "function-logs", autoDelete = false)(bugsnagger, faultTolerantUnmarshaller) + + def fromOldLogItemFormat(bytes: Array[Byte]): Try[LogItem] = Try { Json.parse(bytes).as[OldLogItem].toLogItem } + + def correctLogTimestamp(timestamp: String): String = { + val dt = DateTime.parse(timestamp) + val newTst = Utils.msqlDateFormatter.print(dt) + + println(s"[FunctionLogsErrorShovel]\t$timestamp\t->\t$newTst") + newTst + } + + plainErrConsumer.withConsumer(consumeFn) + + println("Press enter to terminate...") + scala.io.StdIn.readLine() + println("Terminating.") + + plainErrConsumer.shutdown + targetPublisher.shutdown +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/payloads/JsonConversions.scala b/server/backend-workers/src/main/scala/cool/graph/worker/payloads/JsonConversions.scala new file mode 100644 index 0000000000..70bdf006ff --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/payloads/JsonConversions.scala @@ -0,0 +1,20 @@ +package cool.graph.worker.payloads + +import cool.graph.messagebus.Conversions +import cool.graph.messagebus.Conversions.{ByteMarshaller, ByteUnmarshaller} +import play.api.libs.json._ + +object JsonConversions { + + implicit val mapStringReads: Reads[Map[String, String]] = Reads.mapReads[String] + implicit val mapStringWrites: OWrites[collection.Map[String, String]] = Writes.mapWrites[String] + + implicit val webhookFormat: OFormat[Webhook] = Json.format[Webhook] + implicit val logItemFormat: OFormat[LogItem] = Json.format[LogItem] + + implicit val webhookMarshaller: ByteMarshaller[Webhook] = Conversions.Marshallers.FromJsonBackedType[Webhook]() + implicit val webhookUnmarshaller: ByteUnmarshaller[Webhook] = Conversions.Unmarshallers.ToJsonBackedType[Webhook]() + + implicit val logItemUnmarshaller: ByteUnmarshaller[LogItem] = Conversions.Unmarshallers.ToJsonBackedType[LogItem]() + implicit val logItemMarshaller: ByteMarshaller[LogItem] = Conversions.Marshallers.FromJsonBackedType[LogItem]() +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/payloads/Payloads.scala b/server/backend-workers/src/main/scala/cool/graph/worker/payloads/Payloads.scala new file mode 100644 index 0000000000..9329cedc85 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/payloads/Payloads.scala @@ -0,0 +1,24 @@ +package cool.graph.worker.payloads + +import play.api.libs.json.JsObject + +case class Webhook( + projectId: String, + functionId: String, + requestId: String, + url: String, + payload: String, + id: String, + headers: Map[String, String] +) + +case class LogItem( + id: String, + projectId: String, + functionId: String, + requestId: String, + status: String, + duration: Long, + timestamp: String, + message: JsObject +) diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/services/WorkerServices.scala b/server/backend-workers/src/main/scala/cool/graph/worker/services/WorkerServices.scala new file mode 100644 index 0000000000..0c5f554370 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/services/WorkerServices.scala @@ -0,0 +1,61 @@ +package cool.graph.worker.services + +import akka.stream.ActorMaterializer +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.queue.LinearBackoff +import cool.graph.messagebus.queue.rabbit.RabbitQueue +import cool.graph.messagebus.{Queue, QueueConsumer} +import cool.graph.worker.payloads.{LogItem, Webhook} +import cool.graph.worker.utils.Env +import play.api.libs.ws.ahc.StandaloneAhcWSClient +import slick.jdbc.MySQLProfile + +import scala.concurrent.duration._ + +trait WorkerServices { + val logsDb: MySQLProfile.backend.Database + val httpClient: StandaloneAhcWSClient + val logsQueue: Queue[LogItem] + val webhooksConsumer: QueueConsumer[Webhook] + + def shutdown: Unit +} + +case class WorkerCloudServices()(implicit materializer: ActorMaterializer, bugsnagger: BugSnagger) extends WorkerServices { + import cool.graph.worker.payloads.JsonConversions._ + + lazy val httpClient = StandaloneAhcWSClient() + + lazy val logsDb: MySQLProfile.backend.Database = { + import slick.jdbc.MySQLProfile.api._ + Database.forConfig("logs") + } + + lazy val webhooksConsumer: QueueConsumer[Webhook] = RabbitQueue.consumer[Webhook](Env.clusterLocalRabbitUri, "webhooks") + lazy val logsQueue: RabbitQueue[LogItem] = RabbitQueue[LogItem](Env.clusterLocalRabbitUri, "function-logs", LinearBackoff(5.seconds)) + + def shutdown: Unit = { + httpClient.close() + logsDb.close() + logsQueue.shutdown + webhooksConsumer.shutdown + } +} + +// In the dev version the queueing impls are created / injected above the services. +case class WorkerDevServices(webhooksConsumer: QueueConsumer[Webhook], logsQueue: Queue[LogItem])(implicit materializer: ActorMaterializer) + extends WorkerServices { + lazy val httpClient = StandaloneAhcWSClient() + + lazy val logsDb: MySQLProfile.backend.Database = { + import slick.jdbc.MySQLProfile.api._ + Database.forConfig("logs") + } + + def shutdown: Unit = { + httpClient.close() + logsDb.close() + logsQueue.shutdown + webhooksConsumer.shutdown + } +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/utils/Env.scala b/server/backend-workers/src/main/scala/cool/graph/worker/utils/Env.scala new file mode 100644 index 0000000000..d11fa80d6b --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/utils/Env.scala @@ -0,0 +1,6 @@ +package cool.graph.worker.utils + +object Env { + val clusterLocalRabbitUri = sys.env("RABBITMQ_URI") + val bugsangApiKey = sys.env("BUGSNAG_API_KEY") +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/utils/Utils.scala b/server/backend-workers/src/main/scala/cool/graph/worker/utils/Utils.scala new file mode 100644 index 0000000000..bf7f04f9c1 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/utils/Utils.scala @@ -0,0 +1,13 @@ +package cool.graph.worker.utils + +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat + +object Utils { + val msqlDateFormatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSS") // mysql datetime(3) format + + /** + * Generates a mysql datetime(3) timestamp (now) + */ + def msqlDateTime3Timestamp(): String = Utils.msqlDateFormatter.print(DateTime.now()) +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/workers/FunctionLogsWorker.scala b/server/backend-workers/src/main/scala/cool/graph/worker/workers/FunctionLogsWorker.scala new file mode 100644 index 0000000000..5d529e4343 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/workers/FunctionLogsWorker.scala @@ -0,0 +1,24 @@ +package cool.graph.worker.workers + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.QueueConsumer +import cool.graph.worker.payloads.LogItem +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.{ExecutionContext, Future} + +case class FunctionLogsWorker(logsDb: Database, logsConsumer: QueueConsumer[LogItem])(implicit bugsnagger: BugSnagger, ec: ExecutionContext) extends Worker { + lazy val consumerRef = logsConsumer.withConsumer(consumeFn) + + private val consumeFn = (i: LogItem) => { + val reqCuid = i.requestId.split(":").lastOption.getOrElse(i.requestId) + + logsDb.run(sqlu""" + INSERT INTO Log (id, projectId, functionId, requestId, status, duration, timestamp, message) + VALUES(${i.id}, ${i.projectId}, ${i.functionId}, $reqCuid, ${i.status}, ${i.duration}, ${i.timestamp}, ${i.message.toString()}) + """) + } + + override def start: Future[_] = Future { consumerRef } + override def stop: Future[_] = Future { consumerRef.stop } +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/workers/WebhookDelivererWorker.scala b/server/backend-workers/src/main/scala/cool/graph/worker/workers/WebhookDelivererWorker.scala new file mode 100644 index 0000000000..705502e153 --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/workers/WebhookDelivererWorker.scala @@ -0,0 +1,103 @@ +package cool.graph.worker.workers +import cool.graph.bugsnag.BugSnagger +import cool.graph.cuid.Cuid +import cool.graph.messagebus.{QueueConsumer, QueuePublisher} +import cool.graph.utils.future.FutureUtils._ +import cool.graph.worker.payloads.{LogItem, Webhook} +import cool.graph.worker.utils.Utils +import play.api.libs.json.{JsArray, JsObject, Json} +import play.api.libs.ws.ahc.StandaloneAhcWSClient + +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +case class WebhookDelivererWorker( + httpClient: StandaloneAhcWSClient, + webhooksConsumer: QueueConsumer[Webhook], + logsPublisher: QueuePublisher[LogItem] +)(implicit bugsnagger: BugSnagger, ec: ExecutionContext) + extends Worker { + import scala.concurrent.ExecutionContext.Implicits.global + + // Current decision: Do not retry delivery, treat all return codes as work item "success" (== ack). + val consumeFn = (wh: Webhook) => { + val req = httpClient.url(wh.url).withHttpHeaders(wh.headers.toList :+ ("Content-Type", "application/json"): _*).withRequestTimeout(5.seconds) + val startTime = System.currentTimeMillis() + val response = req.post(wh.payload) + + response.toFutureTry.flatMap { + case Success(resp) => + val timing = System.currentTimeMillis() - startTime + val body = resp.body[String] + val timestamp = Utils.msqlDateTime3Timestamp() + + val logItem = resp.status match { + case x if x >= 200 && x < 300 => + val functionReturnValue = formatFunctionSuccessMessage(wh.payload, body) + LogItem(Cuid.createCuid(), wh.projectId, wh.functionId, wh.requestId, "SUCCESS", timing, timestamp, functionReturnValue) + + case x => + val message = s"Call to ${wh.url} failed with status $x, response body $body and headers [${formatHeaders(resp.headers)}]" + LogItem(Cuid.createCuid(), wh.projectId, wh.functionId, wh.requestId, "FAILURE", timing, timestamp, formatFunctionErrorMessage(message)) + } + + logsPublisher.publish(logItem) + Future.successful(()) + + case Failure(err) => + val timing = System.currentTimeMillis() - startTime + val message = s"Call to ${wh.url} failed with: ${err.getMessage}" + val timestamp = Utils.msqlDateTime3Timestamp() + val logItem = LogItem(Cuid.createCuid(), wh.projectId, wh.functionId, wh.requestId, "FAILURE", timing, timestamp, formatFunctionErrorMessage(message)) + + logsPublisher.publish(logItem) + Future.successful(()) + } + } + + lazy val consumerRef = webhooksConsumer.withConsumer(consumeFn) + + /** + * Formats a given map of headers to a single line string representation "H1: V1 | H2: V2 ...". + * + * @param headers The headers to format + * @return A single-line string in the format "header: value | nextHeader: value ...". + * If multiple values per header are given, they are treated as separate instances of the same header. + * E.g. X-Test-Header: 1 | X-Test-Header: 2 | Content-Type: appliation/json + */ + def formatHeaders(headers: Map[String, Seq[String]]): String = { + headers.flatMap(header => header._2.map(headerValue => s"${header._1}: $headerValue")).mkString(" | ") + } + + /** + * Formats a function log message according to our schema. + * + * @param payload Payload send with the webhook delivery. + * @param responseBody Webhook delivery return body + * @return A JsObject that can be used in the log message field of the function log. + */ + def formatFunctionSuccessMessage(payload: String, responseBody: String): JsObject = { + val returnValue = Try { Json.parse(responseBody).validate[JsObject].get } match { + case Success(json) => json + case Failure(_) => Json.obj("rawResponse" -> responseBody) + } + + Json.obj( + "event" -> payload, + "logs" -> (returnValue \ "logs").getOrElse(JsArray(Seq.empty)), + "returnValue" -> returnValue + ) + } + + /** + * Formats a function log error message according to our schema. + * + * @param errMsg Payload send with the webhook delivery. + * @return A JsObject that can be used in the log message field of the function log. + */ + def formatFunctionErrorMessage(errMsg: String): JsObject = Json.obj("error" -> errMsg) + + override def start: Future[_] = Future { consumerRef } + override def stop: Future[_] = Future { consumerRef.stop } +} diff --git a/server/backend-workers/src/main/scala/cool/graph/worker/workers/Worker.scala b/server/backend-workers/src/main/scala/cool/graph/worker/workers/Worker.scala new file mode 100644 index 0000000000..1c79bc818d --- /dev/null +++ b/server/backend-workers/src/main/scala/cool/graph/worker/workers/Worker.scala @@ -0,0 +1,8 @@ +package cool.graph.worker.workers + +import scala.concurrent.Future + +trait Worker { + def start: Future[_] = Future.successful(()) + def stop: Future[_] = Future.successful(()) +} diff --git a/server/backend-workers/src/test/scala/cool/graph/worker/SpecHelper.scala b/server/backend-workers/src/test/scala/cool/graph/worker/SpecHelper.scala new file mode 100644 index 0000000000..ac7ca377ef --- /dev/null +++ b/server/backend-workers/src/test/scala/cool/graph/worker/SpecHelper.scala @@ -0,0 +1,37 @@ +package cool.graph.worker + +import scala.concurrent.Await + +object SpecHelper { + import slick.jdbc.MySQLProfile.api._ + + import scala.concurrent.duration._ + + def recreateLogSchemaActions(): DBIOAction[Unit, NoStream, Effect] = DBIO.seq(dropAction, setupActions) + + lazy val dropAction = DBIO.seq(sqlu"DROP SCHEMA IF EXISTS `logs`;") + + lazy val setupActions = DBIO.seq( + sqlu"CREATE SCHEMA IF NOT EXISTS `logs` DEFAULT CHARACTER SET utf8mb4;", + sqlu"USE `logs`;", + sqlu""" + CREATE TABLE IF NOT EXISTS `Log` ( + `id` varchar(25) NOT NULL, + `projectId` varchar(25) NOT NULL, + `functionId` varchar(25) NOT NULL, + `requestId` varchar(25) NOT NULL, + `status` enum('SUCCESS','FAILURE') NOT NULL, + `duration` int(11) NOT NULL, + `timestamp` datetime(3) NOT NULL, + `message` mediumtext NOT NULL, + PRIMARY KEY (`id`), + KEY `functionId` (`functionId`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;""" + ) + + def recreateLogsDatabase(): Unit = { + val logsRoot = Database.forConfig("logsRoot") + Await.result(logsRoot.run(SpecHelper.recreateLogSchemaActions()), 30.seconds) + logsRoot.close() + } +} diff --git a/server/backend-workers/src/test/scala/cool/graph/worker/workers/FunctionLogsWorkerSpec.scala b/server/backend-workers/src/test/scala/cool/graph/worker/workers/FunctionLogsWorkerSpec.scala new file mode 100644 index 0000000000..81be1cc1bf --- /dev/null +++ b/server/backend-workers/src/test/scala/cool/graph/worker/workers/FunctionLogsWorkerSpec.scala @@ -0,0 +1,101 @@ +package cool.graph.worker.workers + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import akka.testkit.TestKit +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.messagebus.testkits.RabbitQueueTestKit +import cool.graph.worker.payloads.{JsonConversions, LogItem} +import cool.graph.worker.services.{WorkerCloudServices, WorkerServices} +import cool.graph.worker.SpecHelper +import cool.graph.worker.utils.Env +import org.joda.time.DateTime +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} +import play.api.libs.json.Json + +import scala.concurrent.Await +import scala.util.Try + +class FunctionLogsWorkerSpec + extends TestKit(ActorSystem("queueing-spec")) + with WordSpecLike + with Matchers + with BeforeAndAfterEach + with BeforeAndAfterAll + with ScalaFutures { + import slick.jdbc.MySQLProfile.api._ + import JsonConversions._ + import scala.concurrent.duration._ + import scala.concurrent.ExecutionContext.Implicits.global + + var services: WorkerServices = _ + var logItemPublisher: RabbitQueueTestKit[LogItem] = _ + var worker: FunctionLogsWorker = _ + + implicit val materializer = ActorMaterializer() + implicit val bugSnagger = BugSnaggerImpl("") + + override def beforeEach(): Unit = { + SpecHelper.recreateLogsDatabase() + + services = WorkerCloudServices() + worker = FunctionLogsWorker(services.logsDb, services.logsQueue) + worker.start.futureValue + + logItemPublisher = RabbitQueueTestKit[LogItem](Env.clusterLocalRabbitUri, "function-logs") + } + + override def afterEach(): Unit = { + services.shutdown + + Try { worker.stop.futureValue } + Try { logItemPublisher.shutdown() } + } + + override def afterAll = shutdown(verifySystemShutdown = true) + + def getAllLogItemsCount() = Await.result(services.logsDb.run(sql"SELECT count(*) FROM Log".as[(Int)]), 2.seconds) + + "The FunctionLogsWorker" should { + "work off valid items" in { + val item1 = LogItem("id1", "pId1", "fId1", "reqId1", "SUCCESS", 123, DateTime.now.toLocalDateTime.toString(), Json.obj("test" -> "Testmessage1 😂")) + val item2 = + s""" + { + "id": "id2", + "projectId": "pId2", + "functionId": "fId2", + "requestId": "reqId2", + "status": "FAILURE", + "duration": 321, + "timestamp": "${DateTime.now.toLocalDateTime.toString()}", + "message": { + "test": "Testmessage2 😂😂😂" + } + } + """.stripMargin + + logItemPublisher.publish(item1) + logItemPublisher.publishPlain("msg.0", item2) + + // Give the worker a bit of time to do the thing + Thread.sleep(2000) + + getAllLogItemsCount().head shouldBe 2 + } + + "ignore invalid items and work off valid ones regardless" in { + val item1 = LogItem("id1", "pId1", "fId1", "reqId1", "SUCCESS", 123, DateTime.now.toLocalDateTime.toString(), Json.obj("test" -> "Testmessage 😂")) + + // Publish an invalid log item + logItemPublisher.publishPlain("msg.0", "Invalid (should be a full json)") + logItemPublisher.publish(item1) + + // Give the worker a bit of time to do the thing. Why is the latency so gigantic? + Thread.sleep(10000) + + getAllLogItemsCount().head shouldBe 1 + } + } +} diff --git a/server/backend-workers/src/test/scala/cool/graph/worker/workers/WebhookDelivererWorkerSpec.scala b/server/backend-workers/src/test/scala/cool/graph/worker/workers/WebhookDelivererWorkerSpec.scala new file mode 100644 index 0000000000..2a742b2547 --- /dev/null +++ b/server/backend-workers/src/test/scala/cool/graph/worker/workers/WebhookDelivererWorkerSpec.scala @@ -0,0 +1,222 @@ +package cool.graph.worker.workers + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import akka.testkit.TestKit +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.messagebus.testkits.RabbitQueueTestKit +import cool.graph.stub.Import.withStubServer +import cool.graph.stub.StubDsl.Default.Request +import cool.graph.worker.payloads.{JsonConversions, LogItem, Webhook} +import cool.graph.worker.services.{WorkerCloudServices, WorkerServices} +import cool.graph.worker.SpecHelper +import cool.graph.worker.utils.Env +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} +import play.api.libs.json.{JsObject, Json} + +import scala.util.Try +import scala.concurrent.duration._ + +class WebhookDelivererWorkerSpec + extends TestKit(ActorSystem("webhookSpec")) + with WordSpecLike + with Matchers + with BeforeAndAfterEach + with BeforeAndAfterAll + with ScalaFutures { + import JsonConversions._ + import scala.concurrent.ExecutionContext.Implicits.global + + var services: WorkerServices = _ + var worker: WebhookDelivererWorker = _ + var webhookPublisher: RabbitQueueTestKit[Webhook] = _ + var logsConsumer: RabbitQueueTestKit[LogItem] = _ + + implicit val materializer = ActorMaterializer() + implicit val bugSnagger = BugSnaggerImpl("") + + override def beforeEach(): Unit = { + SpecHelper.recreateLogsDatabase() + + services = WorkerCloudServices() + worker = WebhookDelivererWorker(services.httpClient, services.webhooksConsumer, services.logsQueue) + worker.start.futureValue + + webhookPublisher = RabbitQueueTestKit[Webhook](Env.clusterLocalRabbitUri, "webhooks") + logsConsumer = RabbitQueueTestKit[LogItem](Env.clusterLocalRabbitUri, "function-logs") + + logsConsumer.withTestConsumers() + } + + override def afterEach(): Unit = { + services.shutdown + + Try { worker.stop.futureValue } + Try { webhookPublisher.shutdown() } + Try { logsConsumer.shutdown() } + } + + override def afterAll = shutdown(verifySystemShutdown = true) + + "The webhooks delivery worker" should { + "work off items and log a success message if the delivery was successful" in { + val stubs = List( + Request("POST", "/function-endpoint") + .stub(200, """{"data": "stuff", "logs": ["log1", "log2"]}""") + .ignoreBody) + + withStubServer(stubs).withArg { server => + val webhook = + Webhook( + "pid", + "fid", + "rid", + s"http://localhost:${server.port}/function-endpoint", + "GIGAPIZZA", + "someId", + Map("X-Cheese-Header" -> "Gouda") + ) + + webhookPublisher.publish(webhook) + + // Give the worker time to work off + Thread.sleep(1000) + + logsConsumer.expectMsgCount(1) + + val logMessage: LogItem = logsConsumer.messages.head + + logMessage.projectId shouldBe "pid" + logMessage.functionId shouldBe "fid" + logMessage.requestId shouldBe "rid" + logMessage.id shouldNot be(empty) + logMessage.status shouldBe "SUCCESS" + logMessage.timestamp shouldNot be(empty) + logMessage.duration > 0 shouldBe true + logMessage.message shouldBe a[JsObject] + (logMessage.message \ "event").get.as[String] shouldBe "GIGAPIZZA" + (logMessage.message \ "logs").get.as[Seq[String]] shouldBe Seq("log1", "log2") + (logMessage.message \ "returnValue").get shouldBe Json.obj("data" -> "stuff", "logs" -> Seq("log1", "log2")) + } + } + + "work off items and log a failure message if the delivery was unsuccessful" in { + val stubs = List( + Request("POST", "/function-endpoint") + .stub(400, """{"error": what are you doing?"}""") + .ignoreBody) + + withStubServer(stubs).withArg { server => + val webhook = + Webhook( + "pid", + "fid", + "rid", + s"http://localhost:${server.port}/function-endpoint", + "GIGAPIZZA", + "someId", + Map("X-Cheese-Header" -> "Gouda") + ) + + webhookPublisher.publish(webhook) + logsConsumer.expectMsgCount(1) + + val logMessage: LogItem = logsConsumer.messages.head + + logMessage.projectId shouldBe "pid" + logMessage.functionId shouldBe "fid" + logMessage.requestId shouldBe "rid" + logMessage.id shouldNot be(empty) + logMessage.status shouldBe "FAILURE" + logMessage.timestamp shouldNot be(empty) + logMessage.duration > 0 shouldBe true + logMessage.message shouldBe a[JsObject] + (logMessage.message \ "error").get.as[String] should include("what are you doing?") + } + } + + "work off items and log a failure message if the delivery was unsuccessful due to the http call itself failing (e.g. timeout or not available)" in { + val webhook = + Webhook( + "pid", + "fid", + "rid", + s"http://thishosthopefullydoesntexist123/function-endpoint", + "GIGAPIZZA", + "someId", + Map("X-Cheese-Header" -> "Gouda") + ) + + webhookPublisher.publish(webhook) + logsConsumer.expectMsgCount(1) + + val logMessage: LogItem = logsConsumer.messages.head + + logMessage.projectId shouldBe "pid" + logMessage.functionId shouldBe "fid" + logMessage.requestId shouldBe "rid" + logMessage.id shouldNot be(empty) + logMessage.status shouldBe "FAILURE" + logMessage.timestamp shouldNot be(empty) + logMessage.duration > 0 shouldBe true + logMessage.message shouldBe a[JsObject] + (logMessage.message \ "error").get.as[String] shouldNot be(empty) + } + + "work off items and log a success message if the delivery was successful and returned a non-json body" in { + val stubs = List( + Request("POST", "/function-endpoint") + .stub(200, "A plain response") + .ignoreBody) + + withStubServer(stubs).withArg { server => + val webhook = + Webhook( + "pid", + "fid", + "rid", + s"http://localhost:${server.port}/function-endpoint", + "GIGAPIZZA", + "someId", + Map("X-Cheese-Header" -> "Gouda") + ) + + webhookPublisher.publish(webhook) + logsConsumer.expectMsgCount(1) + + val logMessage: LogItem = logsConsumer.messages.head + + logMessage.projectId shouldBe "pid" + logMessage.functionId shouldBe "fid" + logMessage.requestId shouldBe "rid" + logMessage.id shouldNot be(empty) + logMessage.status shouldBe "SUCCESS" + logMessage.timestamp shouldNot be(empty) + logMessage.duration > 0 shouldBe true + logMessage.message shouldBe a[JsObject] + (logMessage.message \ "returnValue" \ "rawResponse").get.as[String] shouldBe "A plain response" + } + } + + "work off old mutation callbacks" in { + val stubs = List( + Request("POST", "/function-endpoint") + .stub(200, "{}") + .ignoreBody) + + withStubServer(stubs).withArg { server => + val messages = Seq( + s"""{"projectId":"test-project-id","functionId":"","requestId":"","url":"http://localhost:${server.port}/function-endpoint","payload":"{\\\"createdNode\\\":{\\\"text\\\":\\\"a comment\\\",\\\"json\\\":[1,2,3]}}","id":"cj7c3vllp001nha58lxr6cx5b","headers":{}}""", + s"""{"projectId":"test-project-id","functionId":"","requestId":"","url":"http://localhost:${server.port}/function-endpoint","payload":"{\\\"deletedNode\\\":{\\\"text\\\":\\\"UPDATED TEXT\\\",\\\"json\\\":{\\\"b\\\":[\\\"1\\\",2,{\\\"d\\\":3}]}}}","id":"cj7c3vv4b001rha58jh3otywr","headers":{}}""", + s"""{"projectId":"test-project-id","functionId":"","requestId":"","url":"http://localhost:${server.port}/function-endpoint","payload":"{\\\"updatedNode\\\":{\\\"text\\\":\\\"UPDATED TEXT\\\",\\\"json\\\":{\\\"b\\\":[\\\"1\\\",2,{\\\"d\\\":3}]}},\\\"changedFields\\\":[\\\"text\\\"],\\\"previousValues\\\":{\\\"text\\\":\\\"some text created by nesting\\\"}}","id":"cj7c3vqed001pha58o7xculq1","headers":{}}""", + s"""{"projectId":"test-project-id","functionId":"","requestId":"","url":"http://localhost:${server.port}/function-endpoint","payload":"{\\\"createdNode\\\":{\\\"text\\\":\\\"some text created by nesting\\\",\\\"json\\\":{\\\"b\\\":[\\\"1\\\",2,{\\\"d\\\":3}]}}}","id":"cj7c3vllp001mha58lh9kxikw","headers":{}}""" + ) + + messages.foreach(webhookPublisher.publishPlain("msg.0", _)) + logsConsumer.expectMsgCount(4, maxWait = 10.seconds) + logsConsumer.messages.foreach(_.status shouldBe "SUCCESS") + } + } + } +} diff --git a/server/build.sbt b/server/build.sbt new file mode 100644 index 0000000000..3647b20ffa --- /dev/null +++ b/server/build.sbt @@ -0,0 +1,403 @@ +import com.typesafe.sbt.SbtGit.GitKeys +import com.typesafe.sbt.git.ConsoleGitRunner +import sbt._ + +name := "server" +Revolver.settings + +import Dependencies._ +import com.typesafe.sbt.SbtGit + +lazy val propagateVersionToOtherRepo = taskKey[Unit]("Propagates the version of this project to another github repo.") +lazy val actualBranch = settingKey[String]("the current branch of the git repo") + +actualBranch := { + val branch = sys.env.getOrElse("BRANCH", git.gitCurrentBranch.value) + if(branch != "master"){ + sys.props += "project.version" -> s"$branch-SNAPSHOT" + } + branch +} + + +propagateVersionToOtherRepo := { + val branch = actualBranch.value + println(s"Will try to propagate the version to branch $branch in other repo.") + val githubClient = GithubClient() + githubClient.updateFile( + owner = Env.read("OTHER_REPO_OWNER"), + repo = Env.read("OTHER_REPO"), + filePath = Env.read("OTHER_REPO_FILE"), + branch = branch, + newContent = version.value + ) +} + + + +// determine the version of our artifacts with sbt-git +lazy val versionSettings = SbtGit.versionWithGit ++ Seq( + git.baseVersion := "0.8.0", + git.gitUncommittedChanges := { // the default implementation of sbt-git uses JGit which somehow always returns true here, so we roll our own impl + import sys.process._ + val gitStatusResult = "git status --porcelain".!! + if(gitStatusResult.nonEmpty){ + println("Git has uncommitted changes!") + println(gitStatusResult) + } + gitStatusResult.nonEmpty + } +) + +lazy val deploySettings = overridePublishBothSettings ++ Seq( + credentials += Credentials( + realm = "packagecloud", + host = "packagecloud.io", + userName = "", + passwd = sys.env.getOrElse("PACKAGECLOUD_PW", sys.error("PACKAGECLOUD_PW env var is not set.")) + ), + publishTo := Some("packagecloud+https" at "packagecloud+https://packagecloud.io/graphcool/graphcool"), + aether.AetherKeys.aetherWagons := Seq(aether.WagonWrapper("packagecloud+https", "io.packagecloud.maven.wagon.PackagecloudWagon")) +) + +lazy val commonSettings = deploySettings ++ versionSettings ++ Seq( + organization := "cool.graph", + organizationName := "graphcool", + scalaVersion := "2.11.8", + parallelExecution in Test := false, + publishArtifact in Test := true, + // We should gradually introduce https://tpolecat.github.io/2014/04/11/scalac-flags.html + // These needs to separately be configured in Idea + scalacOptions ++= Seq("-deprecation", "-feature", "-Xfatal-warnings"), + resolvers += "Sonatype snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/" +) + +lazy val commonBackendSettings = commonSettings ++ Seq( + libraryDependencies ++= Dependencies.common, + imageNames in docker := Seq( + ImageName(s"graphcool/${name.value}:latest") + ), + dockerfile in docker := { + val appDir = stage.value + val targetDir = "/app" + + new Dockerfile { + from("anapsix/alpine-java") + entryPoint(s"$targetDir/bin/${executableScriptName.value}") + copy(appDir, targetDir) + expose(8081) + expose(8000) + expose(3333) + } + }, + javaOptions in Universal ++= Seq( + // -J params will be added as jvm parameters + "-J-agentlib:jdwp=transport=dt_socket,server=y,address=8000,suspend=n", + "-J-Dcom.sun.management.jmxremote=true", + "-J-Dcom.sun.management.jmxremote.local.only=false", + "-J-Dcom.sun.management.jmxremote.authenticate=false", + "-J-Dcom.sun.management.jmxremote.ssl=false", + "-J-Dcom.sun.management.jmxremote.port=3333", + "-J-Dcom.sun.management.jmxremote.rmi.port=3333", + "-J-Djava.rmi.server.hostname=localhost", + "-J-Xmx2560m" + ) +) + +lazy val bugsnag = Project(id = "bugsnag", base = file("./libs/bugsnag")) + .settings(commonSettings: _*) + +lazy val akkaUtils = Project(id = "akka-utils", base = file("./libs/akka-utils")) + .settings(commonSettings: _*) + .dependsOn(bugsnag % "compile") + .dependsOn(scalaUtils % "compile") + .settings(libraryDependencies ++= Seq( + "ch.megard" %% "akka-http-cors" % "0.2.1" + )) + +lazy val cloudwatch = Project(id = "cloudwatch", base = file("./libs/cloudwatch")) + .settings(commonSettings: _*) + +lazy val metrics = Project(id = "metrics", base = file("./libs/metrics")) + .settings(commonSettings: _*) + .dependsOn(bugsnag % "compile") + .dependsOn(akkaUtils % "compile") + .settings( + libraryDependencies ++= Seq( + "com.datadoghq" % "java-dogstatsd-client" % "2.3", + "com.typesafe.akka" %% "akka-http" % "10.0.5", + Dependencies.finagle, + Dependencies.akka, + Dependencies.scalaTest + ) + ) + +lazy val rabbitProcessor = Project(id = "rabbit-processor", base = file("./libs/rabbit-processor")) + .settings(commonSettings: _*) + .dependsOn(bugsnag % "compile") + +lazy val messageBus = Project(id = "message-bus", base = file("./libs/message-bus")) + .settings(commonSettings: _*) + .dependsOn(bugsnag % "compile") + .dependsOn(akkaUtils % "compile") + .dependsOn(rabbitProcessor % "compile") + .settings(libraryDependencies ++= Seq( + Dependencies.scalaTest, + "com.typesafe.akka" %% "akka-testkit" % "2.4.17" % "compile", + "com.typesafe.play" %% "play-json" % "2.5.12" + )) + +lazy val jvmProfiler = Project(id = "jvm-profiler", base = file("./libs/jvm-profiler")) + .settings(commonSettings: _*) + .dependsOn(metrics % "compile") + .settings(libraryDependencies += Dependencies.scalaTest) + +lazy val graphQlClient = Project(id = "graphql-client", base = file("./libs/graphql-client")) + .settings(commonSettings: _*) + .settings(libraryDependencies += Dependencies.scalaTest) + .dependsOn(stubServer % "test") + .dependsOn(akkaUtils % "compile") + +lazy val javascriptEngine = Project(id = "javascript-engine", base = file("./libs/javascript-engine")) + .settings(commonSettings: _*) + +lazy val stubServer = Project(id = "stub-server", base = file("./libs/stub-server")) + .settings(commonSettings: _*) + +lazy val backendShared = + Project(id = "backend-shared", base = file("./backend-shared")) + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(unmanagedBase := baseDirectory.value / "self_built_libs") + .dependsOn(bugsnag % "compile") + .dependsOn(akkaUtils % "compile") + .dependsOn(cloudwatch % "compile") + .dependsOn(metrics % "compile") + .dependsOn(jvmProfiler % "compile") + .dependsOn(rabbitProcessor % "compile") + .dependsOn(graphQlClient % "compile") + .dependsOn(javascriptEngine % "compile") + .dependsOn(stubServer % "test") + .dependsOn(messageBus % "compile") + .dependsOn(scalaUtils % "compile") + .dependsOn(cache % "compile") + +lazy val clientShared = + Project(id = "client-shared", base = file("./client-shared")) + .settings(commonSettings: _*) + .dependsOn(backendShared % "compile") + .settings(libraryDependencies ++= Dependencies.clientShared) + +lazy val backendApiSystem = + Project(id = "backend-api-system", base = file("./backend-api-system")) + .dependsOn(backendShared % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + +lazy val backendApiSimple = + Project(id = "backend-api-simple", base = file("./backend-api-simple")) + .dependsOn(clientShared % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Dependencies.apiServer) + +lazy val backendApiRelay = + Project(id = "backend-api-relay", base = file("./backend-api-relay")) + .dependsOn(clientShared % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Dependencies.apiServer) + +lazy val backendApiSubscriptionsWebsocket = + Project(id = "backend-api-subscriptions-websocket", base = file("./backend-api-subscriptions-websocket")) + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Seq( + "com.typesafe.play" %% "play-json" % "2.5.12", + "de.heikoseeberger" %% "akka-http-play-json" % "1.14.0" excludeAll ( + ExclusionRule(organization = "com.typesafe.akka"), + ExclusionRule(organization = "com.typesafe.play") + ) + )) + .dependsOn(cloudwatch % "compile") + .dependsOn(metrics % "compile") + .dependsOn(jvmProfiler % "compile") + .dependsOn(akkaUtils % "compile") + .dependsOn(rabbitProcessor % "compile") + .dependsOn(bugsnag % "compile") + .dependsOn(messageBus % "compile") + +lazy val backendApiSimpleSubscriptions = + Project(id = "backend-api-simple-subscriptions", base = file("./backend-api-simple-subscriptions")) + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Dependencies.apiServer) + .settings(libraryDependencies ++= Seq( + "com.typesafe.play" %% "play-json" % "2.5.12", + "de.heikoseeberger" %% "akka-http-play-json" % "1.14.0" excludeAll ( + ExclusionRule(organization = "com.typesafe.akka"), + ExclusionRule(organization = "com.typesafe.play") + ) + )) + .dependsOn(clientShared % "compile") + +lazy val backendApiFileupload = + Project(id = "backend-api-fileupload", base = file("./backend-api-fileupload")) + .dependsOn(clientShared % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Dependencies.apiServer) + +lazy val backendApiSchemaManager = + Project(id = "backend-api-schema-manager", base = file("./backend-api-schema-manager")) + .dependsOn(backendApiSystem % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonBackendSettings: _*) + .settings(libraryDependencies ++= Dependencies.apiServer) + +lazy val backendWorkers = + Project(id = "backend-workers", base = file("./backend-workers")) + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings(commonSettings: _*) + .dependsOn(bugsnag % "compile") + .dependsOn(messageBus % "compile") + .dependsOn(stubServer % "test") + .dependsOn(scalaUtils % "compile") + .settings(libraryDependencies ++= Seq( + "com.typesafe.play" %% "play-json" % "2.5.12", + "com.typesafe.akka" %% "akka-http" % "10.0.5", + "com.typesafe.slick" %% "slick" % "3.2.0", + "com.typesafe.slick" %% "slick-hikaricp" % "3.2.0", + "com.typesafe.play" %% "play-ahc-ws-standalone" % "1.0.7", + "org.mariadb.jdbc" % "mariadb-java-client" % "1.5.8", + "cool.graph" % "cuid-java" % "0.1.1", + "org.scalatest" %% "scalatest" % "2.2.6" % "test" + )) + .settings( + imageNames in docker := Seq( + ImageName(s"graphcool/${name.value}:latest") + ), + dockerfile in docker := { + val appDir = stage.value + val targetDir = "/app" + + new Dockerfile { + from("anapsix/alpine-java") + entryPoint(s"$targetDir/bin/${executableScriptName.value}") + copy(appDir, targetDir) + runRaw("apk add --update mysql-client && rm -rf /var/cache/apk/*") + } + } + ) + +lazy val scalaUtils = + Project(id = "scala-utils", base = file("./libs/scala-utils")) + .settings(commonSettings: _*) + .settings(libraryDependencies ++= Seq( + scalaTest + )) + +lazy val cache = + Project(id = "cache", base = file("./libs/cache")) + .settings(commonSettings: _*) + .settings(libraryDependencies ++= Seq( + scalaTest, + caffeine, + java8Compat, + jsr305 + )) + +lazy val singleServer = Project(id = "single-server", base = file("./single-server")) + .settings(commonSettings: _*) + .dependsOn(backendApiSystem % "compile") + .dependsOn(backendWorkers % "compile") + .dependsOn(backendApiSimple % "compile") + .dependsOn(backendApiRelay % "compile") + .dependsOn(backendApiSimpleSubscriptions % "compile") + .dependsOn(backendApiSubscriptionsWebsocket % "compile") + .dependsOn(backendApiFileupload % "compile") + .dependsOn(backendApiSchemaManager % "compile") + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .settings( + imageNames in docker := Seq( + ImageName(s"graphcool/graphcool-dev:latest") + ), + dockerfile in docker := { + val appDir = stage.value + val targetDir = "/app" + + new Dockerfile { + from("anapsix/alpine-java") + entryPoint(s"$targetDir/bin/${executableScriptName.value}") + copy(appDir, targetDir) + } + } + ) + +lazy val localFaas = Project(id = "localfaas", base = file("./localfaas")) + .settings(commonSettings: _*) + .enablePlugins(sbtdocker.DockerPlugin, JavaAppPackaging) + .dependsOn(akkaUtils % "compile") + .settings( + libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-http" % "10.0.5", + "com.github.pathikrit" %% "better-files-akka" % "2.17.1", + "org.apache.commons" % "commons-compress" % "1.14", + "com.typesafe.play" %% "play-json" % "2.5.12", + "de.heikoseeberger" %% "akka-http-play-json" % "1.14.0" excludeAll ( + ExclusionRule(organization = "com.typesafe.akka"), + ExclusionRule(organization = "com.typesafe.play") + ) + ), + imageNames in docker := Seq( + ImageName(s"graphcool/localfaas:latest") + ), + dockerfile in docker := { + val appDir = stage.value + val targetDir = "/app" + + new Dockerfile { + from("openjdk:8-alpine") + runRaw("apk add --update nodejs=6.10.3-r1 bash") + entryPoint(s"$targetDir/bin/${executableScriptName.value}") + copy(appDir, targetDir) + runRaw("rm -rf /var/cache/apk/*") + } + } + ) + +val allProjects = List( + bugsnag, + akkaUtils, + cloudwatch, + metrics, + rabbitProcessor, + messageBus, + jvmProfiler, + graphQlClient, + javascriptEngine, + stubServer, + backendShared, + clientShared, + backendApiSystem, + backendApiSimple, + backendApiRelay, + backendApiSubscriptionsWebsocket, + backendApiSimpleSubscriptions, + backendApiFileupload, + backendApiSchemaManager, + backendWorkers, + scalaUtils, + cache, + singleServer, + localFaas +) + +val allLibProjects = allProjects.filter(_.base.getPath.startsWith("./libs/")).map(Project.projectToRef) +lazy val libs = (project in file("libs")).aggregate(allLibProjects: _*) + +lazy val root = (project in file(".")) + .aggregate(allProjects.map(Project.projectToRef): _*) + .settings( + publish := { } // do not publish a JAR for the root project + ) \ No newline at end of file diff --git a/server/client-shared/build.sbt b/server/client-shared/build.sbt new file mode 100644 index 0000000000..d1f5c3a9c2 --- /dev/null +++ b/server/client-shared/build.sbt @@ -0,0 +1 @@ +libraryDependencies += "com.typesafe.play" % "play-json_2.11" % "2.5.16" diff --git a/server/client-shared/src/main/resources/application.conf b/server/client-shared/src/main/resources/application.conf new file mode 100644 index 0000000000..bf7ad0f593 --- /dev/null +++ b/server/client-shared/src/main/resources/application.conf @@ -0,0 +1 @@ +privateClientApiSecret = ${PRIVATE_CLIENT_API_SECRET} \ No newline at end of file diff --git a/server/client-shared/src/main/scala/cool/graph/ArgumentSchema.scala b/server/client-shared/src/main/scala/cool/graph/ArgumentSchema.scala new file mode 100644 index 0000000000..5e3f498e81 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/ArgumentSchema.scala @@ -0,0 +1,45 @@ +package cool.graph + +import cool.graph.shared.models.Field +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.util.coolSangria.FromInputImplicit +import sangria.schema.{Args, Argument, InputField, InputType} + +trait ArgumentSchema { + def inputWrapper: Option[String] = None + + def convertSchemaArgumentsToSangriaArguments(argumentGroupName: String, arguments: List[SchemaArgument]): List[Argument[Any]] + + def extractArgumentValues(args: Args, argumentDefinitions: List[SchemaArgument]): List[ArgumentValue] +} + +/** + * just a sketch of how things could work +case class SchemaArgumentsGroup(name: String, arguments: List[SchemaArgument]) { + def convertToSangriaArguments(argumentSchema: ArgumentSchema) = { + argumentSchema.convertSchemaArgumentsToSangriaArguments(name, arguments) + } +}*/ +case class SchemaArgument(name: String, inputType: InputType[Any], description: Option[String], field: Option[Field] = None) { + import FromInputImplicit.CoercedResultMarshaller + + lazy val asSangriaInputField = InputField(name, inputType, description.getOrElse("")) + lazy val asSangriaArgument = Argument.createWithoutDefault(name, inputType, description) +} + +object SchemaArgument { + def apply(name: String, inputType: InputType[Any], description: Option[String], field: Field): SchemaArgument = { + SchemaArgument(name, inputType, description, Some(field)) + } + + def apply(name: String, inputType: InputType[Any]): SchemaArgument = { + SchemaArgument(name, inputType, None, None) + } +} +/** + * just another sketch of how things could work +sealed trait MyArgType +case class FlatType(name: String, tpe: MyArgType) extends MyArgType +case class GroupType(groupName: String, args: List[MyArgType]) extends MyArgType +case class LeafType(name: String, tpe: TypeIdentifier.Value) extends MyArgType + */ diff --git a/server/client-shared/src/main/scala/cool/graph/ClientMutation.scala b/server/client-shared/src/main/scala/cool/graph/ClientMutation.scala new file mode 100644 index 0000000000..0eed35c42a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/ClientMutation.scala @@ -0,0 +1,142 @@ +package cool.graph + +import cool.graph.Types.Id +import cool.graph.client.database.DataResolver +import cool.graph.cuid.Cuid +import cool.graph.shared.errors.{GeneralError, UserAPIErrors} +import cool.graph.shared.models.{AuthenticatedRequest, Model} +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.utils.future.FutureUtils._ +import sangria.schema.Args +import scaldi.Injector + +import scala.collection.immutable.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Try} + +trait ClientMutationNew { + def prepareMutactions(): Future[List[MutactionGroup]] + + def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] + + def getReturnValue: Future[ReturnValueResult] +} + +sealed trait ReturnValueResult +case class ReturnValue(dataItem: DataItem) extends ReturnValueResult +case class NoReturnValue(id: Id) extends ReturnValueResult + +abstract class ClientMutation(model: Model, args: Args, dataResolver: DataResolver, val argumentSchema: ArgumentSchema)(implicit inj: Injector) + extends ClientMutationNew { + + dataResolver.enableMasterDatabaseOnlyMode + + var mutactionTimings: List[Timing] = List.empty + + val mutationId: Id = Cuid.createCuid() + + def prepareMutactions(): Future[List[MutactionGroup]] + + def prepareAndPerformMutactions(): Future[List[MutactionExecutionResult]] = { + for { + mutactionGroups <- prepareMutactions() + results <- performMutactions(mutactionGroups) +// _ <- performPostExecutions(mutactionGroups) // this is probably not the way to go + } yield results + } + + def run(authenticatedRequest: Option[AuthenticatedRequest], requestContext: RequestContextTrait): Future[DataItem] = { + run(authenticatedRequest, Some(requestContext)) + } + + def run(authenticatedRequest: Option[AuthenticatedRequest] = None, requestContext: Option[RequestContextTrait] = None): Future[DataItem] = { + ClientMutationRunner.run(this, authenticatedRequest, requestContext, dataResolver.project) + } + + def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] = Future.successful(true) + + // Throw UserfacingError to reject + def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] + + val mutationDefinition: ClientMutationDefinition + + def performWithTiming[A](name: String, f: Future[A]): Future[A] = { + val begin = System.currentTimeMillis() + f andThen { + case x => + mutactionTimings :+= Timing(name, System.currentTimeMillis() - begin) + x + } + } + + def returnValueById(model: Model, id: Id): Future[ReturnValueResult] = { + dataResolver.resolveByModelAndId(model, id).map { + case Some(dataItem) => ReturnValue(dataItem) + case None => NoReturnValue(id) + } + } + + def verifyMutactions(mutactionGroups: List[MutactionGroup]): Future[List[GeneralError]] = { + val mutactions = mutactionGroups.flatMap(_.mutactions) + val verifications: Seq[Future[Try[MutactionVerificationSuccess]]] = mutactions.map { mutaction => + lazy val verifyCall = mutaction match { + case mutaction: ClientSqlDataChangeMutaction => mutaction.verify(dataResolver) + case mutaction => mutaction.verify() + } + performWithTiming(s"verify ${mutaction.getClass.getSimpleName}", verifyCall) + } + val sequenced: Future[Seq[Try[MutactionVerificationSuccess]]] = Future.sequence(verifications) + val errors = sequenced.map(_.collect { case Failure(x: GeneralError) => x }.toList) + + errors + } + + def extractScalarArgumentValues(args: Args): List[ArgumentValue] = { + argumentSchema.extractArgumentValues(args, mutationDefinition.getSchemaArguments(model)) + } + + def extractIdFromScalarArgumentValues(args: Args, name: String): Option[Id] = { + extractScalarArgumentValues(args).find(_.name == name).map(_.value.asInstanceOf[Id]) + } + def extractIdFromScalarArgumentValues_!(args: Args, name: String): Id = { + extractIdFromScalarArgumentValues(args, name).getOrElse(throw UserAPIErrors.IdIsMissing()) + } + + def performMutactions(mutactionGroups: List[MutactionGroup]): Future[List[MutactionExecutionResult]] = { + + def runWithErrorHandler(mutaction: Mutaction): Future[MutactionExecutionResult] = { + mutaction.handleErrors match { + case Some(errorHandler) => mutaction.execute.recover(errorHandler) + case None => mutaction.execute + } + } + + def performGroup(group: MutactionGroup): Future[List[MutactionExecutionResult]] = { + group match { + case MutactionGroup(mutactions, true) => + Future.sequence(mutactions.map(mutaction => performWithTiming(s"execute ${mutaction.getClass.getSimpleName}", runWithErrorHandler(mutaction)))) + case MutactionGroup(mutactions: List[Mutaction], false) => + mutactions.map(m => () => performWithTiming(s"execute ${m.getClass.getSimpleName}", runWithErrorHandler(m))).runSequentially + } + } + + // Cancel further Mutactions and MutactionGroups when a Mutaction fails + // Failures in async MutactionGroups don't stop other Mutactions in same group + mutactionGroups.map(group => () => performGroup(group)).runSequentially.map(_.flatten) + } + + def performPostExecutions(mutactionGroups: List[MutactionGroup]): Future[Boolean] = { + def performGroup(group: MutactionGroup) = { + group match { + case MutactionGroup(mutactions, true) => + Future.sequence(mutactions.map(mutaction => performWithTiming(s"performPostExecution ${mutaction.getClass.getSimpleName}", mutaction.postExecute))) + case MutactionGroup(mutactions: List[Mutaction], false) => + mutactions.map(m => () => performWithTiming(s"performPostExecution ${m.getClass.getSimpleName}", m.postExecute)).runSequentially + } + } + + val mutationGroupResults: Future[List[Boolean]] = Future.sequence(mutactionGroups.map(performGroup)).map(_.flatten) + mutationGroupResults.map(_.forall(identity)) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/ClientMutationDefinition.scala b/server/client-shared/src/main/scala/cool/graph/ClientMutationDefinition.scala new file mode 100644 index 0000000000..c4314e3ec8 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/ClientMutationDefinition.scala @@ -0,0 +1,27 @@ +package cool.graph + +import cool.graph.shared.models.Model +import sangria.schema.Argument + +trait ClientMutationDefinition { + def argumentSchema: ArgumentSchema + def argumentGroupName: String + + // TODO: there should be no need to override this one. It should be final. We should not override this one. + def getSangriaArguments(model: Model): List[Argument[Any]] = { + argumentSchema.convertSchemaArgumentsToSangriaArguments( + argumentGroupName + model.name, + getSchemaArguments(model) + ) + } + + def getSchemaArguments(model: Model): List[SchemaArgument] +} + +trait CreateOrUpdateMutationDefinition extends ClientMutationDefinition { + final def getSchemaArguments(model: Model): List[SchemaArgument] = getScalarArguments(model) ++ getRelationArguments(model) + + def getScalarArguments(model: Model): List[SchemaArgument] + + def getRelationArguments(model: Model): List[SchemaArgument] +} diff --git a/server/client-shared/src/main/scala/cool/graph/ClientMutationRunner.scala b/server/client-shared/src/main/scala/cool/graph/ClientMutationRunner.scala new file mode 100644 index 0000000000..d769e191b0 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/ClientMutationRunner.scala @@ -0,0 +1,84 @@ +package cool.graph + +import cool.graph.client.FeatureMetric +import cool.graph.client.mutactions._ +import cool.graph.shared.errors.{GeneralError, UserAPIErrors} +import cool.graph.shared.models.{AuthenticatedRequest, Project} +import scaldi.Injector + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +object ClientMutationRunner { + def run(clientMutation: ClientMutation, authenticatedRequest: Option[AuthenticatedRequest], requestContext: RequestContextTrait, project: Project)( + implicit inj: Injector): Future[DataItem] = { + run(clientMutation, authenticatedRequest, Some(requestContext), project) + } + + def run(clientMutation: ClientMutation, + authenticatedRequest: Option[AuthenticatedRequest] = None, + requestContext: Option[RequestContextTrait] = None, + project: Project)(implicit inj: Injector): Future[DataItem] = { + + clientMutation.checkPermissions(authenticatedRequest) flatMap { + case false => throw UserAPIErrors.InsufficientPermissions("Insufficient permissions for this mutation") + + case true => + for { + mutactionGroups <- clientMutation.prepareMutactions() + errors <- clientMutation.verifyMutactions(mutactionGroups) + _ = if (errors.nonEmpty) throw errors.head + _ <- clientMutation + .checkPermissionsAfterPreparingMutactions(authenticatedRequest, mutactionGroups.flatMap(_.mutactions flatMap { + case Transaction(clientSqlMutactions, _) => clientSqlMutactions + case x => List(x) + })) + executionResults <- clientMutation.performMutactions(mutactionGroups) + _ <- clientMutation.performPostExecutions(mutactionGroups) + dataItem <- { + trackApiMetrics(requestContext, mutactionGroups, project) + + requestContext.foreach(ctx => clientMutation.mutactionTimings.foreach(ctx.logMutactionTiming)) + + executionResults + .filter(_.isInstanceOf[GeneralError]) + .map(_.asInstanceOf[GeneralError]) match { + case errors if errors.nonEmpty => throw errors.head + case _ => + clientMutation.getReturnValue.map { + case ReturnValue(dataItem) => dataItem + case NoReturnValue(id) => throw UserAPIErrors.NodeNotFoundError(id) + } + } + } + } yield dataItem + } + } + + private def trackApiMetrics(context: Option[RequestContextTrait], mutactionGroups: List[MutactionGroup], project: Project)(implicit inj: Injector): Unit = { + + def containsNestedMutation: Boolean = { + val sqlMutactions = mutactionGroups.flatMap(_.mutactions collect { case Transaction(mutactions, _) => mutactions }).flatten + + val mutationMutactions = sqlMutactions.filter(m => m.isInstanceOf[CreateDataItem] || m.isInstanceOf[UpdateDataItem] || m.isInstanceOf[DeleteDataItem]) + + mutationMutactions.length > 1 + } + + def containsServersideSubscriptions: Boolean = + mutactionGroups.flatMap(_.mutactions.collect { case m: ServerSideSubscription => m }).nonEmpty + + context match { + case Some(ctx) => + if (containsNestedMutation) { + ctx.addFeatureMetric(FeatureMetric.NestedMutations) + } + if (containsServersideSubscriptions) { + ctx.addFeatureMetric(FeatureMetric.ServersideSubscriptions) + } + Unit + case _ => Unit + } + + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/MutactionGroup.scala b/server/client-shared/src/main/scala/cool/graph/MutactionGroup.scala new file mode 100644 index 0000000000..22eb99575e --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/MutactionGroup.scala @@ -0,0 +1,12 @@ +package cool.graph + +case class MutactionGroup(mutactions: List[Mutaction], async: Boolean) { + + // just for debugging! + def unpackTransactions: List[Mutaction] = { + mutactions.flatMap { + case t: Transaction => t.clientSqlMutactions + case x => Seq(x) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/authProviders/Auth0AuthProviderManager.scala b/server/client-shared/src/main/scala/cool/graph/authProviders/Auth0AuthProviderManager.scala new file mode 100644 index 0000000000..3505f83ca6 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/authProviders/Auth0AuthProviderManager.scala @@ -0,0 +1,107 @@ +package cool.graph.authProviders + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.shared.errors.UserAPIErrors.{CannotSignUpUserWithCredentialsExist, UniqueConstraintViolation} +import cool.graph.client.authorization.{Auth0Jwt, ClientAuth, ClientAuthImpl} +import cool.graph.client.database.{DataResolver, DeferredResolverProvider} +import cool.graph.client.mutations.Create +import cool.graph.client.schema.simple.SimpleArgumentSchema +import cool.graph.client.UserContext +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.IntegrationName._ +import cool.graph.shared.models.{IntegrationName, _} +import cool.graph.util.coolSangria.Sangria +import cool.graph.{ArgumentSchema, DataItem} +import sangria.schema.Context +import scaldi.Injector + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class Auth0AuthProviderManager(implicit inj: Injector) extends AuthProviderManager[Unit]()(inj) { + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val clientAuth = inject[ClientAuth] + + val auth0UserIdField = ManagedField(defaultName = "auth0UserId", typeIdentifier = TypeIdentifier.String, isUnique = true) + + val idTokenField = + ManagedField( + defaultName = "idToken", + TypeIdentifier.String, + description = Some( + "Is returned when calling any of the Auth0 functions which invoke authentication. This includes calls to the Lock widget, to the auth0.js library, or the libraries for other languages. See https://auth0.com/docs/tokens/id_token for more detail") + ) + + override val managedFields: List[ManagedField] = List(auth0UserIdField) + override val signupFields: List[ManagedField] = List(idTokenField) + override val signinFields: List[ManagedField] = List(idTokenField) + + override val integrationName: IntegrationName = IntegrationName.AuthProviderAuth0 + + override val name = "auth0" + + override def getmetaInformation: Option[AuthProviderMetaInformation] = None + + import cool.graph.client.authorization.Auth0AuthJsonProtocol._ + + def resolveSignin(ctx: Context[UserContext, Unit], args: Map[String, Any]): Future[Option[AuthData]] = { + val idToken = args(idTokenField.defaultName).asInstanceOf[String] + + Auth0Jwt.parseTokenAsAuth0AuthData(ctx.ctx.project, idToken) match { + case Some(authData) => + getUser(ctx.ctx.dataResolver, authData.auth0UserId) flatMap { + case Some(user) => + clientAuth + .loginUser(ctx.ctx.project, user, Some(authData)) + .map(token => Some(AuthData(token = token, user = user))) + case None => + throw UserAPIErrors.CannotSignInCredentialsInvalid() + } + case None => + throw UserAPIErrors.InvalidSigninData() + } + } + + override def resolveSignup[T, A](ctx: Context[UserContext, Unit], + customArgs: Map[String, Any], + providerArgs: Map[String, Any], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext]): Future[Option[AuthData]] = { + + val userModel = ctx.ctx.dataResolver.project.getModelByName_!("User") + val idToken = providerArgs(idTokenField.defaultName).asInstanceOf[String] + + Auth0Jwt.parseTokenAsAuth0AuthData(ctx.ctx.project, idToken) match { + case Some(authData) => + val createArgs = Sangria.rawArgs(raw = customArgs + (auth0UserIdField.defaultName -> authData.auth0UserId)) + val a: Future[Future[Some[AuthData]]] = + new Create( + model = userModel, + project = ctx.ctx.project, + args = createArgs, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = SimpleArgumentSchema, + allowSettingManagedFields = true + ).run(ctx.ctx.authenticatedRequest, ctx.ctx) + .recover { case e: UniqueConstraintViolation => throw CannotSignUpUserWithCredentialsExist() } + .map(user => { + clientAuth + .loginUser(ctx.ctx.project, user, Some(authData)) + .map(token => Some(AuthData(token = token, user = user))) + }) + + a.flatMap(identity) + case None => + throw UserAPIErrors.Auth0IdTokenIsInvalid() + } + } + + private def getUser(dataResolver: DataResolver, auth0UserId: String): Future[Option[DataItem]] = { + dataResolver.resolveByUnique(dataResolver.project.getModelByName_!("User"), auth0UserIdField.defaultName, auth0UserId) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/authProviders/AuthProviderManager.scala b/server/client-shared/src/main/scala/cool/graph/authProviders/AuthProviderManager.scala new file mode 100644 index 0000000000..d566d89727 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/authProviders/AuthProviderManager.scala @@ -0,0 +1,407 @@ +package cool.graph.authProviders + +import cool.graph._ +import cool.graph.client.UserContext +import cool.graph.client.database.DeferredResolverProvider +import cool.graph.client.mutations.Create +import cool.graph.client.mutations.definitions.CreateDefinition +import cool.graph.client.schema.simple.SimpleArgumentSchema +import cool.graph.client.schema.{InputTypesBuilder, SchemaModelObjectTypesBuilder} +import cool.graph.relay.schema.RelayArgumentSchema +import cool.graph.shared.errors.UserAPIErrors.InvalidAuthProviderData +import cool.graph.shared.models.IntegrationName.IntegrationName +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models._ +import sangria.schema.InputObjectType.DefaultInput +import sangria.schema.{Argument, Context, InputField, InputObjectType, InputValue, ObjectType, OptionInputType, OptionType, UpdateCtx} +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class IntegrationSigninData(token: String, user: DataItem) + +abstract class AuthProviderManager[MetaInfoType](implicit inj: Injector) extends Injectable { + + case class ManagedField(defaultName: String, + typeIdentifier: TypeIdentifier, + description: Option[String] = None, + isUnique: Boolean = false, + isReadonly: Boolean = true) + + val managedFields: List[ManagedField] + val signupFields: List[ManagedField] + val signinFields: List[ManagedField] + val integrationName: IntegrationName + val name: String + def getmetaInformation: Option[AuthProviderMetaInformation] + + protected def resolveSignin(ctx: Context[UserContext, Unit], args: Map[String, Any]): Future[Option[AuthData]] + + protected def resolveSignup[T, A](ctx: Context[UserContext, Unit], + customArgs: Map[String, Any], + providerArgs: Map[String, Any], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext]): Future[Option[AuthData]] + + private def getSigninArgumentType = { + val inputFields: List[InputField[Any]] = + signinFields.map(f => + sangria.schema.InputField(f.defaultName, TypeIdentifier.toSangriaScalarType(f.typeIdentifier), description = f.description.getOrElse(""))) + + OptionInputType( + InputObjectType( + name = integrationName.toString, + fields = inputFields + )) + } + + private def getSignupArgumentType = { + val inputFields: List[InputField[Any]] = + signupFields.map(f => + sangria.schema.InputField(f.defaultName, TypeIdentifier.toSangriaScalarType(f.typeIdentifier), description = f.description.getOrElse(""))) + + OptionInputType( + InputObjectType( + name = integrationName.toString, + fields = inputFields + )) + } +} + +case class AuthData(token: String, user: DataItem, clientMutationId: Option[String] = None) + +object AuthProviderManager { + + def simpleMutationFields[T, A]( + project: Project, + userModel: Model, + userFieldType: ObjectType[UserContext, DataItem], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext])(implicit inj: Injector): List[sangria.schema.Field[UserContext, Unit]] = { + val activeAuthProviders = project.authProviders + .filter(_.integrationType == IntegrationType.AuthProvider) + .filter(_.isEnabled) + + val hasExperimentalServerlessAuthProvider = project.experimentalAuthProvidersCustomMutations.nonEmpty + + def resolveSignin(ctx: Context[UserContext, Unit]): Future[Option[AuthData]] = { + activeAuthProviders.foreach(auth => { + val provider = AuthProviderManager.withName(auth.name) + if (ctx.args.raw.get(provider.name).isDefined) { + return provider.resolveSignin(ctx, + ctx.args + .raw(provider.name) + .asInstanceOf[Option[Map[String, Any]]] + .get) + } + }) + + Future.successful(None) + } + + def resolveCreate(ctx: Context[UserContext, Unit]): Future[Option[DataItem]] = { + +// if (!activeAuthProviders.isEmpty && ctx.ctx.user.isDefined && !ctx.ctx.user.get.isAdmin) { +// throw new CannotCreateUserWhenSignedIn() +// } + + activeAuthProviders.foreach(auth => { + val customArgs: Map[String, Any] = + ctx.args.raw.filter(x => x._1 != "authProvider") + val provider = AuthProviderManager.withName(auth.name) + if (extractAuthProviderField(ctx.args.raw).flatMap(_.get(provider.name)).isDefined) { + return provider + .resolveSignup( + ctx, + customArgs, + extractAuthProviderField(ctx.args.raw) + .get(provider.name) + .asInstanceOf[Option[Map[String, Any]]] + .get, + modelObjectTypesBuilder, + argumentSchema, + deferredResolverProvider + ) + .map(_.map(_.user)) + } + }) + + // fall back to normal create mutation when no auth providers + + if (!activeAuthProviders.isEmpty && !hasExperimentalServerlessAuthProvider) { + throw new InvalidAuthProviderData("You must include at least one Auth Provider when creating user") + } + + new Create(model = userModel, project = project, args = ctx.args, dataResolver = ctx.ctx.dataResolver, argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(Some(_)) + } + + val signinField = sangria.schema.Field( + "signinUser", + fieldType = AuthProviderManager.signinUserPayloadType(userFieldType, None, false), + arguments = activeAuthProviders.map(auth => + Argument(name = AuthProviderManager.withName(auth.name).name, AuthProviderManager.withName(auth.name).getSigninArgumentType)), + resolve = (ctx: Context[UserContext, Unit]) => resolveSignin(ctx) + ) + + val customFields = + new CreateDefinition(SimpleArgumentSchema, project, InputTypesBuilder(project, SimpleArgumentSchema)) + .getSangriaArguments(model = userModel) + .filter(removeEmailAndPassword(activeAuthProviders)) + + def authProviderType: InputObjectType[DefaultInput] = + InputObjectType( + name = "AuthProviderSignupData", + fields = activeAuthProviders.map(auth => + InputField(name = AuthProviderManager.withName(auth.name).name, AuthProviderManager.withName(auth.name).getSignupArgumentType)) + ) + + val createArguments = (activeAuthProviders.isEmpty, hasExperimentalServerlessAuthProvider) match { + case (true, _) => customFields + case (false, false) => { + customFields ++ List(sangria.schema.Argument("authProvider", authProviderType)) + } + case (false, true) => { + + customFields ++ List(sangria.schema.Argument("authProvider", OptionInputType(authProviderType))) + } + } + + val createField = sangria.schema.Field( + "createUser", + fieldType = OptionType(userFieldType), + arguments = createArguments, + resolve = (ctx: Context[UserContext, Unit]) => resolveCreate(ctx) + ) + + activeAuthProviders.isEmpty match { + case true => List(createField) + case false => List(signinField, createField) + } + } + + def relayMutationFields[T, A]( + project: Project, + userModel: Model, + viewerType: ObjectType[UserContext, Unit], + userFieldType: ObjectType[UserContext, DataItem], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext])(implicit inj: Injector): List[sangria.schema.Field[UserContext, Unit]] = { + val activeAuthProviders = project.authProviders + .filter(_.integrationType == IntegrationType.AuthProvider) + .filter(_.isEnabled) + + def resolveSignin(ctx: Context[UserContext, Unit]): Future[Option[AuthData]] = { + val clientMutationId = ctx.args + .raw("input") + .asInstanceOf[Map[String, Any]]("clientMutationId") + .asInstanceOf[String] + + activeAuthProviders.foreach(auth => { + val provider = AuthProviderManager.withName(auth.name) + val input = ctx.args.raw("input").asInstanceOf[Map[String, Any]] + if (input.get(provider.name).isDefined) { + return provider + .resolveSignin(ctx, input(provider.name).asInstanceOf[Option[Map[String, Any]]].get) + .map(_.map(_.copy(clientMutationId = Some(clientMutationId)))) + } + }) + + Future.successful(None) + } + + def resolveCreate(ctx: Context[UserContext, Unit]): Future[Option[AuthData]] = { + val clientMutationId = ctx.args + .raw("input") + .asInstanceOf[Map[String, Any]]("clientMutationId") + .asInstanceOf[String] + + activeAuthProviders.foreach(auth => { + val input = ctx.args.raw("input").asInstanceOf[Map[String, Any]] + val customArgs: Map[String, Any] = + input.filter(x => x._1 != "authProvider") + val provider = AuthProviderManager.withName(auth.name) + if (extractAuthProviderField(input) + .flatMap(_.get(provider.name)) + .isDefined) { + return provider + .resolveSignup( + ctx, + customArgs, + extractAuthProviderField(input) + .get(provider.name) + .asInstanceOf[Option[Map[String, Any]]] + .get, + modelObjectTypesBuilder, + argumentSchema, + deferredResolverProvider + ) + .map(_.map(_.copy(clientMutationId = Some(clientMutationId)))) + } + }) + + // fall back to normal create mutation when no auth providers + + if (!activeAuthProviders.isEmpty) { + throw new InvalidAuthProviderData("You must include at least one Auth Provider when creating user") + } + + new Create(model = userModel, project = project, args = ctx.args, dataResolver = ctx.ctx.dataResolver, argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(user => Some(AuthData(token = "", user = user, clientMutationId = Some(clientMutationId)))) + } + + val signinInputFields = activeAuthProviders.map( + auth => + InputField(name = AuthProviderManager.withName(auth.name).name, + AuthProviderManager + .withName(auth.name) + .getSigninArgumentType)) ++ List(InputField("clientMutationId", sangria.schema.StringType)) + + val signinInput = InputObjectType( + name = "SigninUserInput", + fields = signinInputFields + ) + + val signinField = sangria.schema.Field( + "signinUser", + fieldType = AuthProviderManager + .signinUserPayloadType(userFieldType, Some(viewerType), true), + arguments = List(Argument(name = "input", argumentType = signinInput)), + resolve = (ctx: Context[UserContext, Unit]) => + UpdateCtx({ + resolveSignin(ctx) + .map( + _.map( + authData => + authData.copy( + clientMutationId = ctx.args + .raw("input") + .asInstanceOf[Map[String, Any]] + .get("clientMutationId") + .map(_.asInstanceOf[String])))) + }) { payload => + ctx.ctx.copy(authenticatedRequest = payload.map(_.user).map(x => AuthenticatedUser(id = x.id, typeName = "User", originalToken = ""))) + } + ) + + val customFields = + new CreateDefinition(RelayArgumentSchema, project, InputTypesBuilder(project, RelayArgumentSchema)) + .getSangriaArguments(model = userModel) + .find(_.name == "input") + .get + .argumentType + .asInstanceOf[InputObjectType[_]] + .fields + .filter(removeEmailAndPassword(activeAuthProviders)) + + val createArguments = (activeAuthProviders.isEmpty match { + case true => customFields + case false => { + val authProviderType: InputObjectType[DefaultInput] = InputObjectType( + name = "AuthProviderSignupData", + fields = activeAuthProviders.map(auth => + InputField(name = AuthProviderManager.withName(auth.name).name, AuthProviderManager.withName(auth.name).getSignupArgumentType)) + ) + + customFields ++ List(sangria.schema.InputField("authProvider", authProviderType)) + } + }) + + val createInput = InputObjectType( + name = "SignupUserInput", + fields = createArguments + ) + + val createField = sangria.schema.Field( + "createUser", + fieldType = AuthProviderManager.createUserPayloadType(userFieldType, viewerType), + arguments = List(Argument(name = "input", argumentType = createInput)), + resolve = (ctx: Context[UserContext, Unit]) => resolveCreate(ctx) + ) + + activeAuthProviders.isEmpty match { + case true => List(createField) + case false => List(signinField, createField) + } + } + + private def withName(name: IntegrationName)(implicit inj: Injector): AuthProviderManager[Unit] = name match { + case IntegrationName.AuthProviderEmail => new EmailAuthProviderManager() + case IntegrationName.AuthProviderDigits => new DigitsAuthProviderManager() + case IntegrationName.AuthProviderAuth0 => new Auth0AuthProviderManager() + case _ => throw new Exception(s"$name is not an AuthProvider") + } + + private def extractAuthProviderField(args: Map[String, Any]): Option[Map[String, Any]] = { + args.get("authProvider") match { + case None => None + case Some(x) if x.isInstanceOf[Some[_]] => { + x.asInstanceOf[Some[Map[String, Any]]] + } + case Some(authProvider: Map[_, _]) => { + Some(authProvider.asInstanceOf[Map[String, Any]]) + } + } + } + + private def signinUserPayloadType(userFieldType: ObjectType[UserContext, DataItem], + viewerType: Option[ObjectType[UserContext, Unit]], + isRelay: Boolean): ObjectType[UserContext, Option[AuthData]] = { + + val fields = sangria.schema.fields[UserContext, Option[AuthData]]( + sangria.schema.Field(name = "token", fieldType = sangria.schema.OptionType(sangria.schema.StringType), resolve = _.value.map(_.token)), + sangria.schema.Field(name = "user", fieldType = sangria.schema.OptionType(userFieldType), resolve = _.value.map(_.user)) + ) ++ (isRelay match { + case true => + sangria.schema.fields[UserContext, Option[AuthData]](sangria.schema + .Field(name = "clientMutationId", fieldType = sangria.schema.OptionType(sangria.schema.StringType), resolve = _.value.flatMap(_.clientMutationId))) + case false => List() + }) ++ (viewerType.isDefined match { + case true => + sangria.schema.fields[UserContext, Option[AuthData]](sangria.schema.Field(name = "viewer", fieldType = viewerType.get, resolve = _ => ())) + case false => List() + }) + + ObjectType( + "SigninPayload", + description = "If authentication was successful the payload contains the user and a token. If unsuccessful this payload is null.", + fields = fields + ) + } + + private def createUserPayloadType(userFieldType: ObjectType[UserContext, DataItem], + viewerType: ObjectType[UserContext, Unit]): ObjectType[UserContext, Option[AuthData]] = { + + val fields = + sangria.schema.fields[UserContext, Option[AuthData]]( + sangria.schema + .Field(name = "user", fieldType = sangria.schema.OptionType(userFieldType), resolve = _.value.map(_.user)), + sangria.schema.Field(name = "clientMutationId", + fieldType = sangria.schema.OptionType(sangria.schema.StringType), + resolve = _.value.flatMap(_.clientMutationId)), + sangria.schema + .Field(name = "viewer", fieldType = viewerType, resolve = _ => ()) + ) + + ObjectType( + "CreateUserPayload", + description = "If authentication was successful the payload contains the user and a token. If unsuccessful this payload is null.", + fields = fields + ) + } + + private def removeEmailAndPassword(activeAuthProviders: List[AuthProvider]) = + (f: InputValue[_]) => { + // old password fields are not read only, so we filter them explicitly + activeAuthProviders.exists(_.name == IntegrationName.AuthProviderEmail) match { + case true => f.name != "password" + case false => true + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/authProviders/DigitsAuthProviderManager.scala b/server/client-shared/src/main/scala/cool/graph/authProviders/DigitsAuthProviderManager.scala new file mode 100644 index 0000000000..2ae6a4e32a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/authProviders/DigitsAuthProviderManager.scala @@ -0,0 +1,144 @@ +package cool.graph.authProviders + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.{Authorization, GenericHttpCredentials} +import akka.http.scaladsl.model.{HttpMethods, HttpRequest, HttpResponse, StatusCodes} +import akka.stream.ActorMaterializer +import akka.util.ByteString +import cool.graph.ArgumentSchema +import cool.graph.shared.errors.UserAPIErrors.{CannotSignInCredentialsInvalid, CannotSignUpUserWithCredentialsExist, UniqueConstraintViolation} +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.database.DeferredResolverProvider +import cool.graph.client.mutations.Create +import cool.graph.client.schema.simple.SimpleArgumentSchema +import cool.graph.client.UserContext +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.models.IntegrationName._ +import cool.graph.shared.models.{AuthProviderMetaInformation, IntegrationName, TypeIdentifier} +import cool.graph.util.coolSangria.Sangria +import org.apache.http.auth.InvalidCredentialsException +import sangria.schema.Context +import scaldi.Injector +import spray.json.{DefaultJsonProtocol, _} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class DigitsResponse(id: Int, phoneNumber: String, access: DigitsResponseAccess) +case class DigitsResponseAccess(token: String, secret: String) + +case class JwtDigitsAuthData(digitsToken: String, digitsSecret: String) + +object DigitsAuthJsonProtocol extends DefaultJsonProtocol { + implicit val responseAccessFormat: RootJsonFormat[DigitsResponseAccess] = jsonFormat(DigitsResponseAccess, "token", "secret") + implicit val responseFormat: RootJsonFormat[DigitsResponse] = jsonFormat(DigitsResponse, "id", "phone_number", "access_token") + implicit val authDataFormat: RootJsonFormat[JwtDigitsAuthData] = jsonFormat2(JwtDigitsAuthData) +} + +class DigitsAuthProviderManager(implicit inj: Injector) extends AuthProviderManager[Unit]()(inj) { + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val clientAuth = inject[ClientAuth] + + val digitsIdField = ManagedField(defaultName = "digitsId", typeIdentifier = TypeIdentifier.String, isUnique = true) + + val apiUrlField = ManagedField(defaultName = "apiUrl", TypeIdentifier.String) + val credentialsField = ManagedField(defaultName = "credentials", TypeIdentifier.String) + + override val managedFields: List[ManagedField] = List(digitsIdField) + override val signupFields: List[ManagedField] = List(apiUrlField, credentialsField) + override val signinFields: List[ManagedField] = List(apiUrlField, credentialsField) + + override val integrationName: IntegrationName = IntegrationName.AuthProviderDigits + + override val name = "digits" + + override def getmetaInformation: Option[AuthProviderMetaInformation] = None + + import DigitsAuthJsonProtocol._ + + def resolveSignin(ctx: Context[UserContext, Unit], args: Map[String, Any]): Future[Option[AuthData]] = { + + sendRequestToDigits(args) + .recover { + case e => throw CannotSignInCredentialsInvalid() + } + // TODO validate oauth payload against DIGITS_CONSUMER_KEY + .map( + resp => + ctx.ctx.dataResolver + .resolveByUnique(ctx.ctx.project.getModelByName_!("User"), "digitsId", resp.id) + .map(_ map (user => (user, JwtDigitsAuthData(digitsToken = resp.access.token, digitsSecret = resp.access.secret))))) + .flatMap(identity) + .flatMap { + case Some((user, authData)) => + clientAuth + .loginUser(ctx.ctx.project, user, Some(authData)) + .map(token => Some(AuthData(token = token, user = user))) + case None => Future.successful(None) + } + } + + def sendRequestToDigits(args: Map[String, Any]): Future[DigitsResponse] = { + val apiUrlArgument = args("apiUrl").asInstanceOf[String] + val credentialsArgument = args("credentials").asInstanceOf[String] + + Http() + .singleRequest( + HttpRequest(method = HttpMethods.GET, + uri = apiUrlArgument, + headers = + Authorization(GenericHttpCredentials(scheme = "", token = credentialsArgument)) :: Nil)) + .flatMap { + case HttpResponse(StatusCodes.OK, headers, entity, _) => + entity.dataBytes.runFold(ByteString(""))(_ ++ _) + case _ => throw new InvalidCredentialsException() + } + .map(_.decodeString("UTF-8")) + .map(_.parseJson.convertTo[DigitsResponse]) + } + + override def resolveSignup[T, A](ctx: Context[UserContext, Unit], + customArgs: Map[String, Any], + providerArgs: Map[String, Any], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext]): Future[Option[AuthData]] = { + + val userModel = + ctx.ctx.dataResolver.project.models.find(_.name == "User").get + + sendRequestToDigits(providerArgs) + .recover { + case e => throw CannotSignUpUserWithCredentialsExist() + } + // TODO validate oauth payload against DIGITS_CONSUMER_KEY + .map(resp => { + val createArgs = + Sangria.rawArgs(raw = customArgs + ("digitsId" -> resp.id)) + new Create( + model = userModel, + project = ctx.ctx.project, + args = createArgs, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = SimpleArgumentSchema, + allowSettingManagedFields = true + ).run(ctx.ctx.authenticatedRequest, ctx.ctx) + .recover { + case e: UniqueConstraintViolation => throw CannotSignUpUserWithCredentialsExist() + } + .map(user => { + + val authData = JwtDigitsAuthData(digitsToken = resp.access.token, digitsSecret = resp.access.secret) + + clientAuth + .loginUser(ctx.ctx.project, user, Some(authData)) + .map(token => Some(AuthData(token = token, user = user))) + }) + }) + .flatMap(identity) + .flatMap(identity) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/authProviders/EmailAuthProviderManager.scala b/server/client-shared/src/main/scala/cool/graph/authProviders/EmailAuthProviderManager.scala new file mode 100644 index 0000000000..cced59e626 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/authProviders/EmailAuthProviderManager.scala @@ -0,0 +1,91 @@ +package cool.graph.authProviders + +import com.github.t3hnar.bcrypt._ +import cool.graph.shared.errors.UserAPIErrors.{CannotSignUpUserWithCredentialsExist, UniqueConstraintViolation} +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.database.DeferredResolverProvider +import cool.graph.client.mutations.Create +import cool.graph.client.schema.simple.SimpleArgumentSchema +import cool.graph.client.UserContext +import cool.graph.shared.models.IntegrationName._ +import cool.graph.shared.models.{AuthProviderMetaInformation, IntegrationName, TypeIdentifier} +import cool.graph.util.coolSangria.Sangria +import cool.graph.util.crypto.Crypto +import cool.graph.ArgumentSchema +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.errors.UserAPIErrors +import sangria.schema.Context +import scaldi.Injector +import spray.json.{DefaultJsonProtocol, RootJsonFormat} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class JwtEmailAuthData(email: String) + +object EmailAuthJsonProtocol extends DefaultJsonProtocol { + implicit val authDataFormat: RootJsonFormat[JwtEmailAuthData] = jsonFormat1(JwtEmailAuthData) +} + +class EmailAuthProviderManager()(implicit inj: Injector) extends AuthProviderManager[Unit]()(inj) { + val clientAuth = inject[ClientAuth] + + val emailField = ManagedField(defaultName = "email", typeIdentifier = TypeIdentifier.String, isUnique = true, isReadonly = true) + val passwordField = ManagedField(defaultName = "password", typeIdentifier = TypeIdentifier.String, isReadonly = true) + + override val managedFields: List[ManagedField] = List(emailField, passwordField) + override val signupFields: List[ManagedField] = List(emailField, passwordField) + override val signinFields: List[ManagedField] = List(emailField, passwordField) + + override val integrationName: IntegrationName = IntegrationName.AuthProviderEmail + + override val name = "email" + + override def getmetaInformation: Option[AuthProviderMetaInformation] = None + + import EmailAuthJsonProtocol._ + + def resolveSignin(ctx: Context[UserContext, Unit], args: Map[String, Any]): Future[Option[AuthData]] = { + val email = args("email").asInstanceOf[String] + val password = args("password").asInstanceOf[String] + ctx.ctx.dataResolver.resolveByUnique(ctx.ctx.project.getModelByName_!("User"), "email", email) flatMap { + case Some(user) if password.isBcrypted(user.get[String]("password")) => + clientAuth + .loginUser(ctx.ctx.project, user, Some(JwtEmailAuthData(email = email))) + .map(token => Some(AuthData(token = token, user = user))) + case _ => throw UserAPIErrors.CannotSignInCredentialsInvalid() + } + } + + override def resolveSignup[T, A](ctx: Context[UserContext, Unit], + customArgs: Map[String, Any], + providerArgs: Map[String, Any], + modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[T], + argumentSchema: ArgumentSchema, + deferredResolverProvider: DeferredResolverProvider[_, UserContext]): Future[Option[AuthData]] = { + + val userModel = ctx.ctx.dataResolver.project.getModelByName_!("User") + + val createArgs = Sangria.rawArgs( + raw = customArgs ++ providerArgs + (passwordField.defaultName -> Crypto + .hash(providerArgs(passwordField.defaultName).asInstanceOf[String]))) + + val a = new Create( + model = userModel, + project = ctx.ctx.project, + args = createArgs, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = SimpleArgumentSchema, + allowSettingManagedFields = true + ).run(ctx.ctx.authenticatedRequest, ctx.ctx) + .recover { + case e: UniqueConstraintViolation => throw CannotSignUpUserWithCredentialsExist() + } + .map(user => + clientAuth + .loginUser(ctx.ctx.project, user, Some(JwtEmailAuthData(email = user.get[String]("email")))) + .map(token => Some(AuthData(token = token, user = user)))) + + a.flatMap(identity) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/CommonClientDependencies.scala b/server/client-shared/src/main/scala/cool/graph/client/CommonClientDependencies.scala new file mode 100644 index 0000000000..9120414e7f --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/CommonClientDependencies.scala @@ -0,0 +1,152 @@ +package cool.graph.client + +import akka.actor.{ActorSystem, Props} +import akka.stream.ActorMaterializer +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} +import com.amazonaws.services.s3.{AmazonS3, AmazonS3ClientBuilder} +import com.amazonaws.services.sns.{AmazonSNS, AmazonSNSAsyncClientBuilder} +import com.typesafe.config.{Config, ConfigFactory} +import com.typesafe.scalalogging.LazyLogging +import cool.graph.bugsnag.{BugSnagger, BugSnaggerImpl} +import cool.graph.client.authorization.{ClientAuth, ClientAuthImpl} +import cool.graph.client.finder.ProjectFetcher +import cool.graph.client.metrics.ApiMetricsMiddleware +import cool.graph.cloudwatch.CloudwatchImpl +import cool.graph.messagebus.PubSubPublisher +import cool.graph.messagebus.{PubSubSubscriber, QueuePublisher} +import cool.graph.shared.{ApiMatrixFactory, DefaultApiMatrix} +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.shared.externalServices.{KinesisPublisher, KinesisPublisherImplementation, TestableTime, TestableTimeImplementation} +import cool.graph.shared.functions.{EndpointResolver, FunctionEnvironment} +import cool.graph.util.ErrorHandlerFactory +import cool.graph.webhook.{Webhook, WebhookCaller, WebhookCallerImplementation} +import scaldi.Module + +import scala.util.Try + +trait CommonClientDependencies extends Module with LazyLogging { + implicit val system: ActorSystem + implicit val materializer: ActorMaterializer + implicit val bugSnagger = BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) + + lazy val config: Config = ConfigFactory.load() + lazy val testableTime = new TestableTimeImplementation + lazy val apiMetricsFlushInterval = 10 + + lazy val kinesis: AmazonKinesis = { + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonKinesisClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("KINESIS_ENDPOINT"), sys.env("AWS_REGION"))) + .build + } + + val projectSchemaInvalidationSubscriber: PubSubSubscriber[String] + val projectSchemaFetcher: ProjectFetcher + val functionEnvironment: FunctionEnvironment + val endpointResolver: EndpointResolver + val logsPublisher: QueuePublisher[String] + val webhooksPublisher: QueuePublisher[Webhook] + val sssEventsPublisher: PubSubPublisher[String] + val requestPrefix: String + + lazy val clientAuth = ClientAuthImpl() + lazy val apiMetricsPublisher = new KinesisPublisherImplementation(streamName = sys.env("KINESIS_STREAM_API_METRICS"), kinesis) + lazy val featureMetricActor = system.actorOf(Props(new FeatureMetricActor(apiMetricsPublisher, apiMetricsFlushInterval))) + lazy val apiMetricsMiddleware = new ApiMetricsMiddleware(testableTime, featureMetricActor) + lazy val log = (x: String) => logger.info(x) + lazy val cloudWatch = CloudwatchImpl() + lazy val errorHandlerFactory = ErrorHandlerFactory(log, cloudWatch, bugSnagger) + lazy val globalDatabaseManager = GlobalDatabaseManager.initializeForSingleRegion(config) + lazy val globalApiEndpointManager = GlobalApiEndpointManager( + euWest1 = sys.env("API_ENDPOINT_EU_WEST_1"), + usWest2 = sys.env("API_ENDPOINT_US_WEST_2"), + apNortheast1 = sys.env("API_ENDPOINT_AP_NORTHEAST_1") + ) + lazy val apiMatrixFactory = ApiMatrixFactory(DefaultApiMatrix(_)) + + bind[GlobalDatabaseManager] toNonLazy globalDatabaseManager + bind[GlobalApiEndpointManager] toNonLazy globalApiEndpointManager + bind[KinesisPublisher] identifiedBy "kinesisApiMetricsPublisher" toNonLazy apiMetricsPublisher + bind[WebhookCaller] toNonLazy new WebhookCallerImplementation() + bind[BugSnagger] toNonLazy bugSnagger + bind[ClientAuth] toNonLazy clientAuth + bind[TestableTime] toNonLazy testableTime + bind[ApiMatrixFactory] toNonLazy apiMatrixFactory + + binding identifiedBy "kinesis" toNonLazy kinesis + binding identifiedBy "cloudwatch" toNonLazy cloudWatch + binding identifiedBy "s3" toNonLazy createS3() + binding identifiedBy "s3-fileupload" toNonLazy createS3Fileupload() + binding identifiedBy "config" toNonLazy config + binding identifiedBy "sns" toNonLazy createSystemSns() + binding identifiedBy "actorSystem" toNonLazy system destroyWith (_.terminate()) + binding identifiedBy "dispatcher" toNonLazy system.dispatcher + binding identifiedBy "actorMaterializer" toNonLazy materializer + binding identifiedBy "featureMetricActor" to featureMetricActor + binding identifiedBy "api-metrics-middleware" toNonLazy new ApiMetricsMiddleware(testableTime, featureMetricActor) + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + + bind[KinesisPublisher] identifiedBy "kinesisAlgoliaSyncQueriesPublisher" toNonLazy new KinesisPublisherImplementation( + streamName = sys.env("KINESIS_STREAM_ALGOLIA_SYNC_QUERY"), + kinesis + ) + + bind[KinesisPublisher] identifiedBy "kinesisApiMetricsPublisher" toNonLazy apiMetricsPublisher + + bind[WebhookCaller] toNonLazy new WebhookCallerImplementation() + bind[BugSnagger] toNonLazy bugSnagger + + binding identifiedBy "featureMetricActor" to featureMetricActor + binding identifiedBy "api-metrics-middleware" toNonLazy new ApiMetricsMiddleware(testableTime, featureMetricActor) + + private lazy val blockedProjectIds: Vector[String] = Try { + sys.env("BLOCKED_PROJECT_IDS").split(",").toVector + }.getOrElse(Vector.empty) + + bind[ClientAuth] toNonLazy clientAuth + bind[TestableTime] toNonLazy testableTime + + binding identifiedBy "environment" toNonLazy sys.env.getOrElse("ENVIRONMENT", "local") + binding identifiedBy "service-name" toNonLazy sys.env.getOrElse("SERVICE_NAME", "local") + + private def createS3(): AmazonS3 = { + val credentials = new BasicAWSCredentials( + sys.env("AWS_ACCESS_KEY_ID"), + sys.env("AWS_SECRET_ACCESS_KEY") + ) + + AmazonS3ClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("FILEUPLOAD_S3_ENDPOINT"), sys.env("AWS_REGION"))) + .build + } + + // this is still in old SBS AWS account + private def createS3Fileupload(): AmazonS3 = { + val credentials = new BasicAWSCredentials( + sys.env("FILEUPLOAD_S3_AWS_ACCESS_KEY_ID"), + sys.env("FILEUPLOAD_S3_AWS_SECRET_ACCESS_KEY") + ) + + AmazonS3ClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("FILEUPLOAD_S3_ENDPOINT"), sys.env("AWS_REGION"))) + .build + } + + private def createSystemSns(): AmazonSNS = { + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonSNSAsyncClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("SNS_ENDPOINT_SYSTEM"), sys.env("AWS_REGION"))) + .build + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/GlobalApiEndpointManager.scala b/server/client-shared/src/main/scala/cool/graph/client/GlobalApiEndpointManager.scala new file mode 100644 index 0000000000..f5027ec7a7 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/GlobalApiEndpointManager.scala @@ -0,0 +1,15 @@ +package cool.graph.client + +import cool.graph.shared.models.Region +import cool.graph.shared.models.Region.Region + +case class GlobalApiEndpointManager(euWest1: String, usWest2: String, apNortheast1: String) { + + def getEndpointForProject(region: Region, projectId: String): String = { + region match { + case Region.EU_WEST_1 => s"${euWest1}/${projectId}" + case Region.US_WEST_2 => s"${usWest2}/${projectId}" + case Region.AP_NORTHEAST_1 => s"${apNortheast1}/${projectId}" + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/ProjectLockdownMiddleware.scala b/server/client-shared/src/main/scala/cool/graph/client/ProjectLockdownMiddleware.scala new file mode 100644 index 0000000000..50a7aeee52 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/ProjectLockdownMiddleware.scala @@ -0,0 +1,37 @@ +package cool.graph.client + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.shared.errors.CommonErrors.{MutationsNotAllowedForProject, QueriesNotAllowedForProject} +import cool.graph.RequestContextTrait +import cool.graph.shared.models.Project +import sangria.ast.{OperationDefinition, OperationType} +import sangria.execution._ + +case class ProjectLockdownMiddleware(project: Project) extends Middleware[RequestContextTrait] with LazyLogging { + + override type QueryVal = Unit + + override def beforeQuery(context: MiddlewareQueryContext[RequestContextTrait, _, _]): Unit = { + val isQuery: Boolean = context.queryAst.definitions collect { + case x: OperationDefinition if x.operationType == OperationType.Query || x.operationType == OperationType.Subscription => + x + } isDefinedAt (0) + + val isMutation: Boolean = context.queryAst.definitions collect { + case x: OperationDefinition if x.operationType == OperationType.Mutation => + x + } isDefinedAt (0) + + if (isQuery && !project.allowQueries) { + throw new QueriesNotAllowedForProject(project.id) + } + + if (isMutation && !project.allowMutations) { + throw new MutationsNotAllowedForProject(project.id) + } + + () + } + + override def afterQuery(queryVal: Unit, context: MiddlewareQueryContext[RequestContextTrait, _, _]): Unit = () +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/adapters/GraphcoolDataTypes.scala b/server/client-shared/src/main/scala/cool/graph/client/adapters/GraphcoolDataTypes.scala new file mode 100644 index 0000000000..9eec6399ec --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/adapters/GraphcoolDataTypes.scala @@ -0,0 +1,237 @@ +package cool.graph.client.adapters + +import cool.graph.shared.errors.RequestPipelineErrors.JsonObjectDoesNotMatchGraphQLType +import cool.graph.Types.UserData +import cool.graph.shared.errors.UserAPIErrors.ValueNotAValidJson +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models.{Field, TypeIdentifier} +import org.joda.time.format.DateTimeFormat +import org.joda.time.{DateTime, DateTimeZone} +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import scala.util.Try + +/** + * Data can enter Graphcool from several places: + * - Sangria (queries and mutations) + * - Json (RequestPipelineRunner, Schema Extensions) + * - Database (SQL queries) + * - Strings (default values, migration values) + * + * In all cases we convert to a common data representation. + * + * INTERNAL DATA MODEL: + * + * UserData: Map[String, Option[Any]] + * None means an explicit null, omitted input values are also omitted in the map + * + * DateTime => joda.DateTime + * String => String + * Password => String + * GraphQLId => String + * Json => JsValue + * Boolean => Boolean + * Float => Double + * Int => Int + * Enum => String + * + * relation => ????? + * + * Scalar lists are immutable.Vector[T] for scalar type T defined above + * + * + * Note: This is still WIP. See https://github.com/graphcool/backend-apis/issues/141 + * In the future we will introduce a case class hierarchy to represent valid internal types + */ +object GraphcoolDataTypes { + def fromJson(data: play.api.libs.json.JsObject, fields: List[Field]): UserData = { + val printedJson = play.api.libs.json.Json.prettyPrint(data) + val sprayJson = printedJson.parseJson.asJsObject + + fromJson(sprayJson, fields) + } + + def fromJson(data: JsObject, fields: List[Field], addNoneValuesForMissingFields: Boolean = false): UserData = { + + def getTypeIdentifier(key: String) = fields.find(_.name == key).map(_.typeIdentifier) + def isList(key: String) = fields.find(_.name == key).exists(_.isList) + def verifyJson(key: String, jsValue: JsValue) = { + if (!(jsValue.isInstanceOf[JsObject] || jsValue.isInstanceOf[JsArray])) { + throw ValueNotAValidJson(key, jsValue.prettyPrint) + } + + jsValue + } + + // todo: this error handling assumes this is only used by functions. + // this will probably change in the future + def handleError[T](fieldName: String, f: () => T): Some[T] = { + try { + Some(f()) + } catch { + case e: DeserializationException => + val typeIdentifier = getTypeIdentifier(fieldName).getOrElse("UNKNOWN") + val typeString = if (isList(fieldName)) { + s"[$typeIdentifier]" + } else { + typeIdentifier + } + throw JsonObjectDoesNotMatchGraphQLType(fieldName, typeString.toString, data.prettyPrint) + } + } + + def isListOfType(key: String, expectedtTypeIdentifier: TypeIdentifier.type => TypeIdentifier) = + isOfType(key, expectedtTypeIdentifier) && isList(key) + def isOfType(key: String, expectedtTypeIdentifier: TypeIdentifier.type => TypeIdentifier) = + getTypeIdentifier(key).contains(expectedtTypeIdentifier(TypeIdentifier)) + + def toDateTime(string: String) = new DateTime(string, DateTimeZone.UTC) + + val mappedData = data.fields + .flatMap({ + // OTHER + case (key, value) if getTypeIdentifier(key).isEmpty => None + case (key, value) if value == JsNull => Some((key, None)) + + // SCALAR LISTS + case (key, value) if isListOfType(key, _.DateTime) => Some((key, handleError(key, () => value.convertTo[Vector[String]].map(toDateTime)))) + case (key, value) if isListOfType(key, _.String) => Some((key, handleError(key, () => value.convertTo[Vector[String]]))) + case (key, value) if isListOfType(key, _.Password) => Some((key, handleError(key, () => value.convertTo[Vector[String]]))) + case (key, value) if isListOfType(key, _.GraphQLID) => Some((key, handleError(key, () => value.convertTo[Vector[String]]))) + case (key, value) if isListOfType(key, _.Relation) => None // consider: recurse + case (key, value) if isListOfType(key, _.Json) => Some((key, handleError(key, () => value.convertTo[Vector[JsValue]].map(x => verifyJson(key, x))))) + case (key, value) if isListOfType(key, _.Boolean) => Some((key, handleError(key, () => value.convertTo[Vector[Boolean]]))) + case (key, value) if isListOfType(key, _.Float) => Some((key, handleError(key, () => value.convertTo[Vector[Double]]))) + case (key, value) if isListOfType(key, _.Int) => Some((key, handleError(key, () => value.convertTo[Vector[Int]]))) + case (key, value) if isListOfType(key, _.Enum) => Some((key, handleError(key, () => value.convertTo[Vector[String]]))) + + // SCALARS + case (key, value) if isOfType(key, _.DateTime) => Some((key, handleError(key, () => toDateTime(value.convertTo[String])))) + case (key, value) if isOfType(key, _.String) => Some((key, handleError(key, () => value.convertTo[String]))) + case (key, value) if isOfType(key, _.Password) => Some((key, handleError(key, () => value.convertTo[String]))) + case (key, value) if isOfType(key, _.GraphQLID) => Some((key, handleError(key, () => value.convertTo[String]))) + case (key, value) if isOfType(key, _.Relation) => None // consider: recurse + case (key, value) if isOfType(key, _.Json) => Some((key, handleError(key, () => verifyJson(key, value.convertTo[JsValue])))) + case (key, value) if isOfType(key, _.Boolean) => Some((key, handleError(key, () => value.convertTo[Boolean]))) + case (key, value) if isOfType(key, _.Float) => Some((key, handleError(key, () => value.convertTo[Double]))) + case (key, value) if isOfType(key, _.Int) => Some((key, handleError(key, () => value.convertTo[Int]))) + case (key, value) if isOfType(key, _.Enum) => Some((key, handleError(key, () => value.convertTo[String]))) + }) + + if (addNoneValuesForMissingFields) { + val missingFields = fields.filter(field => !data.fields.keys.toList.contains(field.name)).map(field => (field.name, None)).toMap + + mappedData ++ missingFields + } else { + mappedData + } + } + + // todo: tighten this up according to types described above + // todo: use this in all places and get rid of all AnyJsonFormats + def convertToJson(data: UserData): JsObject = { + def write(x: Any): JsValue = x match { + case m: Map[_, _] => JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case l: Vector[Any] => JsArray(l.map(write)) + case l: Seq[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case n: BigDecimal => JsNumber(n) + case n: Double => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + write(unwrapSomes(data)).asJsObject + } + + // todo: This should be used as close to db as possible + // todo: this should replace DataResolver.mapDataItem + def fromSql(data: UserData, fields: List[Field]): UserData = { + + def typeIdentifier(key: String): Option[TypeIdentifier] = fields.find(_.name == key).map(_.typeIdentifier) + def isList(key: String): Boolean = fields.find(_.name == key).exists(_.isList) + def verifyIsTopLevelJsonValue(key: String, jsValue: JsValue): JsValue = { + if (!(jsValue.isInstanceOf[JsObject] || jsValue.isInstanceOf[JsArray])) { + throw ValueNotAValidJson(key, jsValue.prettyPrint) + } + jsValue + } + def mapTo[T](value: Any, convert: JsValue => T): Seq[T] = { + value match { + case x: String => + Try { + x.parseJson + .asInstanceOf[JsArray] + .elements + .map(convert) + }.getOrElse(List.empty) + case x: Vector[_] => x.map(_.asInstanceOf[T]) + } + } + + try { + data + .flatMap({ + // OTHER + case (key, Some(value)) if typeIdentifier(key).isEmpty => None + case (key, None) => Some((key, None)) + + // SCALAR LISTS + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.DateTime) && isList(key) => + Some((key, Some(mapTo(value, x => new DateTime(x.convertTo[JsValue], DateTimeZone.UTC))))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.String) && isList(key) => Some((key, Some(mapTo(value, _.convertTo[String])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Password) && isList(key) => + Some((key, Some(mapTo(value, _.convertTo[String])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.GraphQLID) && isList(key) => + Some((key, Some(mapTo(value, _.convertTo[String])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Relation) && isList(key) => None // consider: recurse + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Json) && isList(key) => + Some((key, Some(mapTo(value, x => verifyIsTopLevelJsonValue(key, x.convertTo[JsValue]))))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Boolean) && isList(key) => + Some((key, Some(mapTo(value, _.convertTo[Boolean])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Float) && isList(key) => Some((key, Some(mapTo(value, _.convertTo[Double])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Int) && isList(key) => Some((key, Some(mapTo(value, _.convertTo[Int])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Enum) && isList(key) => Some((key, Some(mapTo(value, _.convertTo[String])))) + + // SCALARS + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.DateTime) => + Some( + (key, Some(DateTime.parse(value.asInstanceOf[java.sql.Timestamp].toString, DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSS").withZoneUTC())))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.String) => Some((key, Some(value.asInstanceOf[String]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Password) => Some((key, Some(value.asInstanceOf[String]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.GraphQLID) => Some((key, Some(value.asInstanceOf[String]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Relation) => None + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Json) => + Some((key, Some(verifyIsTopLevelJsonValue(key, value.asInstanceOf[JsValue])))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Boolean) => Some((key, Some(value.asInstanceOf[Boolean]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Float) => Some((key, Some(value.asInstanceOf[Double]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Int) => Some((key, Some(value.asInstanceOf[Int]))) + case (key, Some(value)) if typeIdentifier(key).contains(TypeIdentifier.Enum) => Some((key, Some(value.asInstanceOf[String]))) + }) + } catch { + case e: DeserializationException => sys.error(s" parsing DataItem from SQL failed: ${e.getMessage}") + } + } + + def unwrapSomes(map: UserData): Map[String, Any] = { + map.map { + case (field, Some(value)) => (field, value) + case (field, None) => (field, null) + } + } + + def wrapSomes(map: Map[String, Any]): UserData = { + map.map { + case (field, Some(value)) => (field, Some(value)) + case (field, None) => (field, None) + case (field, value) => (field, Some(value)) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/Auth0Jwt.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/Auth0Jwt.scala new file mode 100644 index 0000000000..58ea954e69 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/Auth0Jwt.scala @@ -0,0 +1,49 @@ +package cool.graph.client.authorization + +import cool.graph.shared.models.{AuthProviderAuth0, IntegrationName, Project} +import pdi.jwt.{Jwt, JwtAlgorithm, JwtOptions} +import spray.json._ + +import scala.util.{Success, Try} + +object Auth0Jwt { + import Auth0AuthJsonProtocol._ + + def parseTokenAsAuth0AuthData(project: Project, idToken: String): Option[JwtAuth0AuthData] = { + for { + authProvider <- project.authProviders.find(_.name == IntegrationName.AuthProviderAuth0) + meta <- authProvider.metaInformation + clientSecret = meta.asInstanceOf[AuthProviderAuth0].clientSecret + decoded <- decode(secret = clientSecret, idToken = idToken).toOption + } yield { + val idToken = decoded.parseJson.convertTo[IdToken] + JwtAuth0AuthData(auth0UserId = idToken.sub) + } + } + + // Auth0 has two versions of client secrets: https://auth0.com/forum/t/client-secret-stored-without-base64-encoding/4338/22 + // issued before Dec 2016: Base64 + // issued after Dec 2016: UTF8 + private def decode(secret: String, idToken: String): Try[String] = { + val jwtOptions = JwtOptions(signature = true, expiration = false) + val algorithms = Seq(JwtAlgorithm.HS256) + val fromUtf8 = Jwt.decodeRaw(token = idToken, key = secret, algorithms = algorithms, options = jwtOptions) + + fromUtf8 match { + case Success(jwt) => + Success(jwt) + case _ => + val base64DecodedSecret = new String(new sun.misc.BASE64Decoder().decodeBuffer(secret)) + Jwt.decodeRaw(token = idToken, key = base64DecodedSecret, algorithms = algorithms, options = jwtOptions) + } + + } +} + +case class JwtAuth0AuthData(auth0UserId: String) +case class IdToken(iss: String, sub: String, aud: String, exp: Int, iat: Int) + +object Auth0AuthJsonProtocol extends DefaultJsonProtocol { + implicit val authDataFormat: RootJsonFormat[JwtAuth0AuthData] = jsonFormat1(JwtAuth0AuthData) + implicit val idTokenFormat: RootJsonFormat[IdToken] = jsonFormat5(IdToken) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/ClientAuthImpl.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/ClientAuthImpl.scala new file mode 100644 index 0000000000..88ad8e2d99 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/ClientAuthImpl.scala @@ -0,0 +1,138 @@ +package cool.graph.client.authorization + +import com.typesafe.config.Config +import cool.graph.DataItem +import cool.graph.client.database.ProjectDataresolver +import cool.graph.shared.authorization.{JwtCustomerData, JwtPermanentAuthTokenData, JwtUserData, SharedAuth} +import cool.graph.shared.models._ +import cool.graph.utils.future.FutureUtils._ +import pdi.jwt.{Jwt, JwtAlgorithm} +import scaldi.{Injectable, Injector} +import spray.json.JsonFormat + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success} + +trait ClientAuth { + def authenticateRequest(sessionToken: String, project: Project): Future[AuthenticatedRequest] + + def loginUser[T: JsonFormat](project: Project, user: DataItem, authData: Option[T]): Future[String] +} + +case class ClientAuthImpl()(implicit inj: Injector) extends ClientAuth with SharedAuth with Injectable { + import cool.graph.shared.authorization.JwtClaimJsonProtocol._ + import spray.json._ + + val config = inject[Config](identified by "config") + + /** + * Input: userToken, clientToken, permanentAuthToken + * Returns a userId if: + * - userToken is not expired and belongs to a user in the project + * - clientToken is not expired and belongs to a collaborator of the project + * - permanentAuthToken belongs to the project + */ + def authenticateRequest(sessionToken: String, project: Project): Future[AuthenticatedRequest] = { + tokenFromPermanentRootTokens(sessionToken, project).toFutureTry + .flatMap { + case Success(authedReq) => Future.successful(authedReq) + case Failure(_) => ensureTokenIsValid(sessionToken).flatMap(_ => tryAuthenticateToken(sessionToken, project)) + } + } + + private def tokenFromPermanentRootTokens(token: String, project: Project): Future[AuthenticatedRequest] = { + project.rootTokens.find(_.token == token) match { + case Some(RootToken(id, _, _, _)) => Future.successful(AuthenticatedRootToken(id, token)) + case None => Future.failed(new Exception(s"Token is not a PAT: '$token'")) + } + } + + private def ensureTokenIsValid(token: String): Future[Unit] = { + if (isExpired(token)) { + Future.failed(new Exception(s"Token has expired '$token'")) + } else { + Future.successful(()) + } + } + + private def tryAuthenticateToken(token: String, project: Project): Future[AuthenticatedRequest] = { + val tmpRootTokenData = parseTokenAsTemporaryRootToken(token) + val clientData = parseTokenAsClientData(token) + val userData = parseTokenAsJwtUserData(token) + val auth0Data = parseTokenAsAuth0AuthData(token, project) + + (tmpRootTokenData, clientData, userData, auth0Data) match { + case (Some(JwtPermanentAuthTokenData(_, projectId, tokenId)), _, _, _) if projectId == project.id => + tokenFromTemporaryRootToken(tokenId, token) + + case (_, Some(JwtCustomerData(jwtClientId)), _, _) => + tokenFromCollaborators(jwtClientId, token, project) + + case (_, _, Some(JwtUserData(projectId, userId, _, typeName)), _) if projectId == project.id => + tokenFromUsers(userId, typeName, token, project) + + case (_, _, _, Some(JwtAuth0AuthData(auth0UserId))) => + tokenFromAuth0(auth0UserId, token, project) + + case _ => + Future.failed(new Exception(s"Couldn't parse token '$token'")) + } + } + + def parseTokenAsJwtUserData(sessionToken: String): Option[JwtUserData[Unit]] = { + Jwt + .decodeRaw(sessionToken, config.getString("jwtSecret"), Seq(JwtAlgorithm.HS256)) + .map(_.parseJson.convertTo[JwtUserData[Unit]]) + .map(Some(_)) + .getOrElse(None) + } + + private def parseTokenAsAuth0AuthData(sessionToken: String, project: Project): Option[JwtAuth0AuthData] = { + Auth0Jwt.parseTokenAsAuth0AuthData(project, sessionToken) + } + + private def tokenFromCollaborators(clientId: String, token: String, project: Project): Future[AuthenticatedRequest] = { + if (customerIsCollaborator(clientId, project)) { + Future.successful(AuthenticatedCustomer(clientId, token)) + } else { + throw new Exception(s"The provided token is valid, but the customer is not a collaborator: '$token'") + } + } + + private def customerIsCollaborator(customerId: String, project: Project) = project.seats.exists(_.clientId.contains(customerId)) + + private def tokenFromUsers(userId: String, typeName: String, token: String, project: Project): Future[AuthenticatedRequest] = { + userFromDb(userId, typeName, project).map { _ => + AuthenticatedUser(userId, typeName, token) + } + } + + private def userFromDb(userId: String, typeName: String, project: Project): Future[DataItem] = { + val dataResolver = new ProjectDataresolver(project = project, requestContext = None) + + for { + user <- dataResolver.resolveByUnique( + Model("someId", typeName, None, isSystem = true, List()), + "id", + userId + ) + } yield { + user.getOrElse(throw new Exception(s"The provided token is valid, but the user no longer exists: '$userId'")) + } + } + + private def tokenFromAuth0(auth0UserId: String, token: String, project: Project): Future[AuthenticatedRequest] = { + getUserIdForAuth0User(auth0UserId, project).map { + case Some(userId) => AuthenticatedUser(userId, "User", token) + case None => throw new Exception(s"The provided Auth0 token is valid, but the user no longer exists: '$token'") + } + } + + private def tokenFromTemporaryRootToken(id: String, token: String): Future[AuthenticatedRequest] = Future.successful(AuthenticatedRootToken(id, token)) + + private def getUserIdForAuth0User(auth0Id: String, project: Project): Future[Option[String]] = { + val dataResolver = new ProjectDataresolver(project = project, requestContext = None) + dataResolver.resolveByUnique(dataResolver.project.getModelByName_!("User"), ManagedFields.auth0UserId.defaultName, auth0Id).map(_.map(_.id)) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/ModelPermissions.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/ModelPermissions.scala new file mode 100644 index 0000000000..3895d64108 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/ModelPermissions.scala @@ -0,0 +1,146 @@ +package cool.graph.client.authorization + +import cool.graph.client.mutations.CoolArgs +import cool.graph.shared.models._ + +object ModelPermissions { + def checkReadPermissionsForField( + model: Model, + field: Field, + authenticatedRequest: Option[AuthenticatedRequest], + project: Project + ): Boolean = { + checkGlobalStarPermissionFirst(project) { + checkPermissionsForField(model, field, ModelOperation.Read, authenticatedRequest) + } + } + + def checkPermissionsForDelete( + model: Model, + authenticatedRequest: Option[AuthenticatedRequest], + project: Project + ): Boolean = { + checkGlobalStarPermissionFirst(project) { + checkPermissionsForModel(model, ModelOperation.Delete, authenticatedRequest) + } + } + + def checkPermissionsForCreate( + model: Model, + args: CoolArgs, + authenticatedRequest: Option[AuthenticatedRequest], + project: Project + ): Boolean = { + checkGlobalStarPermissionFirst(project) { + val specialAuthProviderRule = project.hasEnabledAuthProvider && model.name == "User" + specialAuthProviderRule || checkWritePermissions(model, args, authenticatedRequest, ModelOperation.Create, project) + } + } + + def checkPermissionsForUpdate( + model: Model, + args: CoolArgs, + authenticatedRequest: Option[AuthenticatedRequest], + project: Project + ): Boolean = { + checkGlobalStarPermissionFirst(project) { + checkWritePermissions(model, args, authenticatedRequest, ModelOperation.Update, project) + } + } + + private def checkGlobalStarPermissionFirst(project: Project)(fallbackCheck: => Boolean): Boolean = { + project.hasGlobalStarPermission || fallbackCheck + } + + private def checkWritePermissions( + model: Model, + args: CoolArgs, + authenticatedRequest: Option[AuthenticatedRequest], + operation: ModelOperation.Value, + project: Project + ): Boolean = { + checkPermissionsForModel(model, operation, authenticatedRequest) && + checkPermissionsForScalarFields(model, args, authenticatedRequest, operation, project) && + checkPermissionsForRelations(model, args, authenticatedRequest, project) + } + + private def checkPermissionsForScalarFields( + model: Model, + args: CoolArgs, + authenticatedRequest: Option[AuthenticatedRequest], + operation: ModelOperation.Value, + project: Project + ): Boolean = { + val checks = for { + field <- model.scalarFields if field.name != "id" + if args.hasArgFor(field) + } yield { + checkPermissionsForField(model, field, operation, authenticatedRequest) + } + checks.forall(identity) + } + + private def checkPermissionsForRelations( + model: Model, + args: CoolArgs, + authenticatedRequest: Option[AuthenticatedRequest], + project: Project + ): Boolean = { + val subModelChecks = for { + field <- model.relationFields + subArgs <- args.subArgsList(field).getOrElse(Seq.empty) + subModel = field.relatedModel(project).get + } yield { + checkWritePermissions(subModel, subArgs, authenticatedRequest, ModelOperation.Create, project) + } + subModelChecks.forall(identity) + } + + private def checkPermissionsForField( + model: Model, + field: Field, + operation: ModelOperation.Value, + authenticatedRequest: Option[AuthenticatedRequest] + ): Boolean = { + val permissionsForField = getPermissionsForOperationAndUser(model, operation, authenticatedRequest) + .filter(p => p.applyToWholeModel || p.fieldIds.contains(field.id)) + .filter(_.isNotCustom) + + if (authenticatedRequest.exists(_.isAdmin)) { + true + } else { + permissionsForField.nonEmpty + } + } + + private def checkPermissionsForModel( + model: Model, + operation: ModelOperation.Value, + authenticatedRequest: Option[AuthenticatedRequest] + ): Boolean = { + val permissionsForModel = getPermissionsForOperationAndUser(model, operation, authenticatedRequest).filter(_.isNotCustom) + + if (authenticatedRequest.exists(_.isAdmin)) { + true + } else { + permissionsForModel.nonEmpty + } + } + + private def getPermissionsForOperationAndUser( + model: Model, + operation: ModelOperation.Value, + authenticatedRequest: Option[AuthenticatedRequest] + ): List[ModelPermission] = { + val permissionsForUser = authenticatedRequest.isDefined match { + case true => model.permissions + case false => model.permissions.filter(p => p.userType == UserType.Everyone) + } + + val permissionsForOperation = permissionsForUser + .filter(_.isActive) + .filter(_.operation == operation) + + permissionsForOperation + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/PermissionValidator.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/PermissionValidator.scala new file mode 100644 index 0000000000..aa241a18e4 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/PermissionValidator.scala @@ -0,0 +1,127 @@ +package cool.graph.client.authorization + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.Types +import cool.graph.client.authorization.queryPermissions.QueryPermissionValidator +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import cool.graph.shared.models._ +import sangria.ast.Document +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class PermissionQueryArg(name: String, value: Any, typeIdentifier: TypeIdentifier) + +class PermissionValidator(project: Project)(implicit inj: Injector) extends Injectable { + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + + val validator = new QueryPermissionValidator(project) + + def checkModelQueryPermissions( + project: Project, + permissions: List[ModelPermission], + authenticatedRequest: Option[AuthenticatedRequest], + nodeId: Types.Id, + permissionQueryArgs: Seq[PermissionQueryArg], + alwaysQueryMasterDatabase: Boolean + )(implicit inj: Injector, system: ActorSystem, materializer: ActorMaterializer): Future[Boolean] = { + if (project.hasGlobalStarPermission) { + return Future.successful(true) + } + + val predefinedVars = Map( + "$userId" -> (authenticatedRequest.map(_.id).getOrElse(""), "ID"), + "$user_id" -> (authenticatedRequest.map(_.id).getOrElse(""), "ID"), + "$nodeId" -> (nodeId, "ID"), + "$node_id" -> (nodeId, "ID") + ) ++ permissionQueryArgs + .filter(_.name != "$node_id") + .map(x => + x.name -> (x.value, x.typeIdentifier match { + case TypeIdentifier.GraphQLID => "ID" + case x => x.toString + })) + + val queries = permissions + .filter(_.rule == CustomRule.Graph) + .filter(_.userType == UserType.Everyone || authenticatedRequest.isDefined) + .map(_.ruleGraphQuery.getOrElse("")) + + Future + .sequence( + queries + .map(p => checkQueryPermission(authenticatedRequest, p, predefinedVars, alwaysQueryMasterDatabase))) + .map(_.exists(b => b)) + } + + def checkRelationQueryPermissions( + project: Project, + permissions: List[RelationPermission], + authenticatedRequest: Option[AuthenticatedRequest], + permissionQueryArgs: Map[String, (Any, String)], + alwaysQueryMasterDatabase: Boolean + ): Future[Boolean] = { + if (project.hasGlobalStarPermission) { + return Future.successful(true) + } + + val queries = permissions.filter(_.rule == CustomRule.Graph).map(_.ruleGraphQuery.getOrElse("")) + + Future + .sequence( + queries + .map(p => checkQueryPermission(authenticatedRequest, p, permissionQueryArgs, alwaysQueryMasterDatabase))) + .map(_.exists(b => b)) + } + + private def checkQueryPermission( + authenticatedRequest: Option[AuthenticatedRequest], + permission: String, + permissionQueryArgs: Map[String, (Any, String)], + alwaysQueryMasterDatabase: Boolean + ): Future[Boolean] = { + + val (injectedQuery, variables) = injectQueryParams(permission, permissionQueryArgs) + + validator.validate(injectedQuery, variables, authenticatedRequest, alwaysQueryMasterDatabase) + } + + //this generates a query to validate by prepending the provided arguments and their types in front of it/ the prepending should not happen for the correctly formatted queries + private def injectQueryParams(query: String, permissionQueryArgs: Map[String, (Any, String)]): (String, Map[String, Any]) = { + + def isQueryValidGraphQL(query: String): Option[Document] = sangria.parser.QueryParser.parse(query).toOption + + def prependQueryWithHeader(query: String) = { + val usedVars = permissionQueryArgs.filter(field => query.contains(field._1)) + val vars = usedVars.map(field => s"${field._1}: ${field._2._2}").mkString(", ") + val queryHeader = if (usedVars.isEmpty) "query " else s"query ($vars) " + queryHeader + query + " " + } + + val usedVars = permissionQueryArgs.filter(field => query.contains(field._1)) + val outputArgs = usedVars.map(field => (field._1.substring(1), field._2._1)) + val prependedQuery = prependQueryWithHeader(query) + isQueryValidGraphQL(prependedQuery) match { + case None => + isQueryValidGraphQL(query) match { + case None => ("# Could not parse the query. Please check that it is valid.\n" + query, outputArgs) // todo or throw error directly? + case Some(doc) => (query, outputArgs) + } + case Some(doc) => (prependedQuery, outputArgs) + } + } + +// private def injectQueryParams(query: String, permissionQueryArgs: Map[String, (Any, String)]): (String, Map[String, Any]) = { +// +// val usedVars = permissionQueryArgs.filter(field => query.contains(field._1)) +// val vars = usedVars.map(field => s"${field._1}: ${field._2._2}").mkString(", ") +// val queryHeader = if (usedVars.isEmpty) "query " else s"query ($vars) " +// +// (queryHeader + query + " ", usedVars.map(field => (field._1.substring(1), field._2._1))) +// } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/Permissions.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/Permissions.scala new file mode 100644 index 0000000000..c0255fc420 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/Permissions.scala @@ -0,0 +1,47 @@ +package cool.graph.client.authorization + +import cool.graph.shared.models._ + +object Permissions { + def checkNormalPermissionsForField(model: Model, + operation: ModelOperation.Value, + field: Field, + authenticatedRequest: Option[AuthenticatedRequest]): Boolean = { + val permissionsForField = Permissions + .permissionsForOperationAndUser(model, operation, authenticatedRequest) + .filter(p => p.applyToWholeModel || p.fieldIds.contains(field.id)) + .filter(_.isNotCustom) + + if (Permissions.isAdmin(authenticatedRequest)) { + true + } else { + permissionsForField.nonEmpty + } + } + + def checkPermissionsForOperationAndUser(model: Model, operation: ModelOperation.Value, authenticatedRequest: Option[AuthenticatedRequest]): Boolean = { + permissionsForOperationAndUser(model, operation, authenticatedRequest).exists(_.isNotCustom) || isAdmin(authenticatedRequest) + } + + def permissionsForOperationAndUser(model: Model, + operation: ModelOperation.Value, + authenticatedRequest: Option[AuthenticatedRequest]): List[ModelPermission] = { + val permissionsForUser = authenticatedRequest.isDefined match { + case true => model.permissions + case false => model.permissions.filter(p => p.userType == UserType.Everyone) + } + + val permissionsForOperation = permissionsForUser + .filter(_.isActive) + .filter(_.operation == operation) + + permissionsForOperation + } + + def isAdmin(authenticatedRequest: Option[AuthenticatedRequest]): Boolean = authenticatedRequest match { + case Some(_: AuthenticatedCustomer) => true + case Some(_: AuthenticatedRootToken) => true + case Some(_: AuthenticatedUser) => false + case None => false + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/RelationMutationPermissions.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/RelationMutationPermissions.scala new file mode 100644 index 0000000000..084a0f6622 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/RelationMutationPermissions.scala @@ -0,0 +1,125 @@ +package cool.graph.client.authorization + +import cool.graph.client.mutactions._ +import cool.graph.shared.models._ +import cool.graph.Mutaction +import cool.graph.shared.errors.UserAPIErrors +import scaldi.Injector + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +object RelationMutationPermissions { + + case class PermissionInput( + relation: Relation, + project: Project, + aId: String, + bId: String, + authenticatedRequest: Option[AuthenticatedRequest] + ) + + def checkAllPermissions( + project: Project, + mutactions: List[Mutaction], + authenticatedRequest: Option[AuthenticatedRequest] + )(implicit inj: Injector): Future[Unit] = { + if (authenticatedRequest.exists(_.isAdmin) || project.hasGlobalStarPermission) { + Future.successful(()) + } else { + val connectPermissions = mutactions collect { + case m: AddDataItemToManyRelation => + PermissionInput(m.relation, m.project, m.aValue, m.bValue, authenticatedRequest) + } + + val disconnectPermissions = mutactions collect { + // Remove From Relation and Unset Relation + case m: RemoveDataItemFromRelationByToAndFromField => + PermissionInput(m.project.getRelationById_!(m.relationId), m.project, m.aId, m.bId, authenticatedRequest) + +// There are four more mutactions that are used to disconnect relations, these are used when the disconnect is a side effect. +// We need to decide how to handle side effect disconnects. The mutactions all have different information available to them, +// so we would need to document which information permission queries could rely on for these. Especially the ones in the nested +// case are often called preventively, and the item on which disconnect is checked does not necessarily exist. + +// // Set Relation +// case m: RemoveDataItemFromRelationById => +// PermissionInput(m.project.getRelationById_!(m.relationId), m.project, "", "", authenticatedRequest) +// // Add To Relation +// case m: RemoveDataItemFromRelationByField => +// PermissionInput(m.field.relation.get, project, "", "", authenticatedRequest) +// // Nasty Nested create stuff -.-, also deletes, updates +// case m: RemoveDataItemFromManyRelationByFromId => +// PermissionInput(m.fromField.relation.get, project, "", "", authenticatedRequest) +// case m: RemoveDataItemFromManyRelationByToId => +// PermissionInput(m.fromField.relation.get, project, "", "", authenticatedRequest) + } + + val verifyConnectPermissions = connectPermissions.map(input => { + if (checkNormalConnectOrDisconnectPermissions(input.relation, input.authenticatedRequest, checkConnect = true, checkDisconnect = false)) { + Future.successful(()) + } else { + checkQueryPermissions(project, input.relation, authenticatedRequest, input.aId, input.bId, checkConnect = true, checkDisconnect = false) + .map(isValid => if (!isValid) throw UserAPIErrors.InsufficientPermissions("No CONNECT permissions")) + } + }) + + val verifyDisconnectPermissions = disconnectPermissions.map(input => { + if (checkNormalConnectOrDisconnectPermissions(input.relation, input.authenticatedRequest, checkConnect = false, checkDisconnect = true)) { + Future.successful(()) + } else { + checkQueryPermissions(project, input.relation, authenticatedRequest, input.aId, input.bId, checkConnect = false, checkDisconnect = true) + .map(isValid => if (!isValid) throw UserAPIErrors.InsufficientPermissions("No DISCONNECT permissions")) + } + }) + + Future.sequence(verifyConnectPermissions ++ verifyDisconnectPermissions).map(_ => ()) + } + } + + private def checkNormalConnectOrDisconnectPermissions( + relation: Relation, + authenticatedRequest: Option[AuthenticatedRequest], + checkConnect: Boolean, + checkDisconnect: Boolean + ): Boolean = { + + val permissionsForUser = authenticatedRequest.isDefined match { + case true => relation.permissions + case false => relation.permissions.filter(p => p.userType == UserType.Everyone) + } + + permissionsForUser + .filter(_.isActive) + .filter(_.connect || !checkConnect) + .filter(_.disconnect || !checkDisconnect) + .exists(_.isNotCustom) + } + + private def checkQueryPermissions( + project: Project, + relation: Relation, + authenticatedRequest: Option[AuthenticatedRequest], + aId: String, + bId: String, + checkConnect: Boolean, + checkDisconnect: Boolean + )(implicit inj: Injector): Future[Boolean] = { + + val filteredPermissions = relation.permissions + .filter(_.isActive) + .filter(_.connect || !checkConnect) + .filter(_.disconnect || !checkDisconnect) + .filter(_.rule == CustomRule.Graph) + .filter(_.userType == UserType.Everyone || authenticatedRequest.isDefined) + + val arguments = Map( + "$user_id" -> (authenticatedRequest.map(_.id).getOrElse(""), "ID"), + s"$$${relation.aName(project)}_id" -> (bId, "ID"), + s"$$${relation.bName(project)}_id" -> (aId, "ID") + ) + + val permissionValidator = new PermissionValidator(project) + permissionValidator.checkRelationQueryPermissions(project, filteredPermissions, authenticatedRequest, arguments, alwaysQueryMasterDatabase = true) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/authorization/queryPermissions/QueryPermissionValidator.scala b/server/client-shared/src/main/scala/cool/graph/client/authorization/queryPermissions/QueryPermissionValidator.scala new file mode 100644 index 0000000000..8bdff298b7 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/authorization/queryPermissions/QueryPermissionValidator.scala @@ -0,0 +1,82 @@ +package cool.graph.client.authorization.queryPermissions + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.client.UserContext +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.shared.errors.UserAPIErrors.InsufficientPermissions +import cool.graph.shared.models.{AuthenticatedRequest, Project} +import cool.graph.shared.queryPermissions.PermissionSchemaResolver +import sangria.ast._ +import sangria.execution.deferred.DeferredResolver +import sangria.execution.{DeprecationTracker, Executor} +import sangria.marshalling.queryAst._ +import sangria.marshalling.{InputUnmarshaller, QueryAstResultMarshaller} +import sangria.parser.QueryParser +import sangria.schema.Schema +import sangria.validation.QueryValidator +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success} + +class QueryPermissionValidator(project: Project)(implicit inj: Injector, system: ActorSystem, materializer: ActorMaterializer) extends Injectable { + + lazy val schema: Schema[UserContext, Unit] = PermissionSchemaResolver.permissionSchema(project) + + lazy val deferredResolverProvider: DeferredResolver[Any] = + new DeferredResolverProvider(new SimpleToManyDeferredResolver, new SimpleManyModelDeferredResolver, skipPermissionCheck = true) + .asInstanceOf[DeferredResolver[Any]] + + lazy val executor = Executor( + schema = schema.asInstanceOf[Schema[Any, Any]], + queryValidator = QueryValidator.default, + deferredResolver = deferredResolverProvider, + exceptionHandler = PartialFunction.empty, + deprecationTracker = DeprecationTracker.empty, + middleware = Nil, + maxQueryDepth = None, + queryReducers = Nil + ) + + def validate( + query: String, + variables: Map[String, Any], + authenticatedRequest: Option[AuthenticatedRequest], + alwaysQueryMasterDatabase: Boolean + ): Future[Boolean] = { + val context = new UserContext( + project = project, + authenticatedRequest = authenticatedRequest, + requestId = "grap-permission-query", + requestIp = "graph-permission-query", + project.ownerId, + (x: String) => Unit, + alwaysQueryMasterDatabase = alwaysQueryMasterDatabase + ) + + val dataFut: Future[QueryAstResultMarshaller#Node] = + QueryParser.parse(query) match { + case Success(_queryAst) => + executor + .execute(queryAst = _queryAst, userContext = context, root = (), variables = InputUnmarshaller.mapVars(variables)) + .recover { + case e: Throwable => throw InsufficientPermissions(s"Permission Query is invalid. Could not be executed. Error Message: ${e.getMessage}") + } + case Failure(error) => + throw InsufficientPermissions(s"Permission Query is invalid. Could not be parsed. Error Message: ${error.getMessage}") + } + + dataFut.map(traverseAndCheckForLeafs) + } + + private def traverseAndCheckForLeafs(root: AstNode): Boolean = { + root match { + case ObjectValue(fields, _, _) => fields.forall(field => traverseAndCheckForLeafs(field)) + case ObjectField(_, value, _, _) => traverseAndCheckForLeafs(value) + case x: BooleanValue => x.value + case _ => sys.error(s"Received unknown type of AstNode. Could not handle: $root") + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/CheckScalarFieldPermissionsDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/CheckScalarFieldPermissionsDeferredResolver.scala new file mode 100644 index 0000000000..f7ea1407c3 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/CheckScalarFieldPermissionsDeferredResolver.scala @@ -0,0 +1,121 @@ +package cool.graph.client.database + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.DataItem +import cool.graph.client.authorization.{ModelPermissions, PermissionQueryArg, PermissionValidator} +import cool.graph.client.database.DeferredTypes._ +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class CheckScalarFieldPermissionsDeferredResolver(skipPermissionCheck: Boolean, project: Project)(implicit inj: Injector) extends Injectable { + + implicit val system = inject[ActorSystem](identified by "actorSystem") + implicit val materializer = + inject[ActorMaterializer](identified by "actorMaterializer") + val permissionValidator = new PermissionValidator(project) + + def resolve(orderedDefereds: Vector[OrderedDeferred[CheckPermissionDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[Any]] = { + val deferreds = orderedDefereds.map(_.deferred) + + // check if we really can satisfy all deferreds with one database query + DeferredUtils.checkSimilarityOfPermissionDeferredsAndThrow(deferreds) + + if (skipPermissionCheck) { + return orderedDefereds.map(x => OrderedDeferredFutureResult[Any](Future.successful(x.deferred.value), x.order)) + } + + val headDeferred = deferreds.head + + val model = headDeferred.model + val authenticatedRequest = headDeferred.authenticatedRequest + val fieldsToCover = orderedDefereds.map(_.deferred.field).distinct + val allPermissions = headDeferred.model.permissions.filter(_.isActive).filter(_.operation == ModelOperation.Read) + val wholeModelPermissions = allPermissions.filter(_.applyToWholeModel) + val singleFieldPermissions = allPermissions.filter(!_.applyToWholeModel) + + def checkSimplePermissions(remainingFields: List[Field]): Future[List[Field]] = { + Future.successful(remainingFields.filter(field => !ModelPermissions.checkReadPermissionsForField(model, field, authenticatedRequest, project))) + } + + def checkWholeModelPermissions(remainingFields: List[Field]): Future[List[Field]] = { + if (remainingFields.isEmpty) { + Future.successful(List()) + } + checkQueryPermissions(authenticatedRequest, wholeModelPermissions, headDeferred.nodeId, model, headDeferred.node, headDeferred.alwaysQueryMasterDatabase) + .map(wasSuccess => { + if (wasSuccess) { + List() + } else { + remainingFields + } + }) + } + + def checkIndividualFieldPermissions(remainingFields: List[Field], remainingPermissions: List[ModelPermission]): Future[List[Field]] = { + if (remainingPermissions.isEmpty || remainingFields.isEmpty) { + Future.successful(remainingFields) + + } else { + + val (current, rest) = getMostLikelyPermission(remainingFields, remainingPermissions) + checkQueryPermissions(authenticatedRequest, List(current), headDeferred.nodeId, model, headDeferred.node, headDeferred.alwaysQueryMasterDatabase) + .flatMap(wasSuccess => { + if (wasSuccess) { + checkIndividualFieldPermissions(remainingFields.filter(x => !current.fieldIds.contains(x.id)), rest) + } else { + checkIndividualFieldPermissions(remainingFields, rest) + } + }) + } + } + + def getMostLikelyPermission(remainingFields: List[Field], remainingPermissions: List[ModelPermission]): (ModelPermission, List[ModelPermission]) = { + val current: ModelPermission = + remainingPermissions.maxBy(p => remainingFields.map(_.id).intersect(p.fieldIds).length) + val rest = remainingPermissions.filter(_ != current) + + (current, rest) + } + + val disallowedFieldIds: Future[List[Field]] = for { + remainingAfterSimplePermissions <- checkSimplePermissions(fieldsToCover.toList) + remainingAfterAllModelPermissions <- checkWholeModelPermissions(remainingAfterSimplePermissions) + remainingAfterSingleFieldPermissions <- checkIndividualFieldPermissions(remainingAfterAllModelPermissions, singleFieldPermissions) + } yield { + remainingAfterSingleFieldPermissions + } + + def deferredToResultOrError(deferred: CheckPermissionDeferred) = { + disallowedFieldIds.map(x => { + if (x.map(_.id).contains(deferred.field.id)) { + throw UserAPIErrors.InsufficientPermissions("Insufficient Permissions") + } else { + deferred.value + } + }) + } + + // assign the DataItem that was requested by each deferred + orderedDefereds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[Any](deferredToResultOrError(deferred), order) + } + } + + def checkQueryPermissions(authenticatedRequest: Option[AuthenticatedRequest], + permissions: List[ModelPermission], + nodeId: String, + model: Model, + node: DataItem, + alwaysQueryMasterDatabase: Boolean): Future[Boolean] = { + val args = model.scalarFields.map(field => PermissionQueryArg(s"$$node_${field.name}", node.getOption(field.name).getOrElse(""), field.typeIdentifier)) + + permissionValidator.checkModelQueryPermissions(project, permissions, authenticatedRequest, nodeId, args, alwaysQueryMasterDatabase) + } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/CountManyModelDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/CountManyModelDeferredResolver.scala new file mode 100644 index 0000000000..cdce66cf52 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/CountManyModelDeferredResolver.scala @@ -0,0 +1,24 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes._ + +class CountManyModelDeferredResolver { + def resolve(orderedDeferreds: Vector[OrderedDeferred[CountManyModelDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[Int]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + DeferredUtils.checkSimilarityOfModelDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val model = headDeferred.model + val args = headDeferred.args + + val futureDataItems = ctx.countByModel(model, args) + + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[Int](futureDataItems, order) + } + + results + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/CountToManyDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/CountToManyDeferredResolver.scala new file mode 100644 index 0000000000..00e2102d6b --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/CountToManyDeferredResolver.scala @@ -0,0 +1,37 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes._ + +import scala.concurrent.ExecutionContext.Implicits.global + +class CountToManyDeferredResolver { + def resolve(orderedDeferreds: Vector[OrderedDeferred[CountToManyDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[Int]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + // check if we really can satisfy all deferreds with one database query + DeferredUtils.checkSimilarityOfRelatedDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val relatedField = headDeferred.relationField + val args = headDeferred.args + + // get ids of dataitems in related model we need to fetch + val relatedModelIds = deferreds.map(_.parentNodeId).toList + + // fetch dataitems + val futureDataItems = + ctx.countByRelationManyModels(relatedField, relatedModelIds, args) + + // assign the dataitems that were requested by each deferred + val results: Vector[OrderedDeferredFutureResult[Int]] = + orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[Int](futureDataItems.map { counts => + counts.find(_._1 == deferred.parentNodeId).map(_._2).get + }, order) + } + + results + } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/DeferredResolverProvider.scala b/server/client-shared/src/main/scala/cool/graph/client/database/DeferredResolverProvider.scala new file mode 100644 index 0000000000..507e950202 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/DeferredResolverProvider.scala @@ -0,0 +1,163 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes._ +import sangria.execution.deferred.{Deferred, DeferredResolver} +import scaldi.Injector + +import scala.concurrent.{ExecutionContext, Future} +import scala.language.reflectiveCalls + +class DeferredResolverProvider[ConnectionOutputType, Context <: { def dataResolver: DataResolver }]( + toManyDeferredResolver: ToManyDeferredResolver[ConnectionOutputType], + manyModelDeferredResolver: ManyModelDeferredResolver[ConnectionOutputType], + skipPermissionCheck: Boolean = false)(implicit inj: Injector) + extends DeferredResolver[Context] { + + override def resolve(deferred: Vector[Deferred[Any]], ctx: Context, queryState: Any)(implicit ec: ExecutionContext): Vector[Future[Any]] = { + + val checkScalarFieldPermissionsDeferredResolver = + new CheckScalarFieldPermissionsDeferredResolver(skipPermissionCheck = skipPermissionCheck, ctx.dataResolver.project) + + // group orderedDeferreds by type + val orderedDeferred = DeferredUtils.tagDeferredByOrder(deferred) + + val manyModelDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: ManyModelDeferred[ConnectionOutputType], order) => + OrderedDeferred(deferred, order) + } + + val manyModelExistsDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: ManyModelExistsDeferred, order) => + OrderedDeferred(deferred, order) + } + + val countManyModelDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: CountManyModelDeferred, order) => + OrderedDeferred(deferred, order) + } + + val toManyDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: ToManyDeferred[ConnectionOutputType], order) => + OrderedDeferred(deferred, order) + } + + val countToManyDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: CountToManyDeferred, order) => + OrderedDeferred(deferred, order) + } + + val toOneDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: ToOneDeferred, order) => + OrderedDeferred(deferred, order) + } + + val oneDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: OneDeferred, order) => + OrderedDeferred(deferred, order) + } + + val checkScalarFieldPermissionsDeferreds = orderedDeferred.collect { + case OrderedDeferred(deferred: CheckPermissionDeferred, order) => + OrderedDeferred(deferred, order) + } + + // for every group, further break them down by their arguments + val manyModelDeferredsMap = DeferredUtils + .groupModelDeferred[ManyModelDeferred[ConnectionOutputType]](manyModelDeferreds) + + val manyModelExistsDeferredsMap = DeferredUtils + .groupModelExistsDeferred[ManyModelExistsDeferred](manyModelExistsDeferreds) + + val countManyModelDeferredsMap = DeferredUtils + .groupModelDeferred[CountManyModelDeferred](countManyModelDeferreds) + + val toManyDeferredsMap = + DeferredUtils.groupRelatedDeferred[ToManyDeferred[ConnectionOutputType]](toManyDeferreds) + + val countToManyDeferredsMap = + DeferredUtils.groupRelatedDeferred[CountToManyDeferred](countToManyDeferreds) + + val toOneDeferredMap = + DeferredUtils.groupRelatedDeferred[ToOneDeferred](toOneDeferreds) + + val oneDeferredsMap = DeferredUtils.groupOneDeferred(oneDeferreds) + + val checkScalarFieldPermissionsDeferredsMap = + DeferredUtils.groupPermissionDeferred(checkScalarFieldPermissionsDeferreds) + + // for every group of deferreds, resolve them + val manyModelFutureResults = manyModelDeferredsMap + .map { + case (key, value) => + manyModelDeferredResolver.resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val manyModelExistsFutureResults = manyModelExistsDeferredsMap + .map { + case (key, value) => + new ManyModelExistsDeferredResolver().resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val countManyModelFutureResults = countManyModelDeferredsMap + .map { + case (key, value) => + new CountManyModelDeferredResolver().resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val toManyFutureResults = toManyDeferredsMap + .map { + case (key, value) => + toManyDeferredResolver.resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val countToManyFutureResults = countToManyDeferredsMap + .map { + case (key, value) => + new CountToManyDeferredResolver().resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val toOneFutureResults = toOneDeferredMap + .map { + case (key, value) => + new ToOneDeferredResolver().resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val oneFutureResult = oneDeferredsMap + .map { + case (key, value) => + new OneDeferredResolver().resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + val checkScalarFieldPermissionsFutureResults = + checkScalarFieldPermissionsDeferredsMap + .map { + case (key, value) => + checkScalarFieldPermissionsDeferredResolver.resolve(value, ctx.dataResolver) + } + .toVector + .flatten + + (manyModelFutureResults ++ + manyModelExistsFutureResults ++ + countManyModelFutureResults ++ + toManyFutureResults ++ + countToManyFutureResults ++ + toOneFutureResults ++ + oneFutureResult ++ + checkScalarFieldPermissionsFutureResults).sortBy(_.order).map(_.future) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/DeferredUtils.scala b/server/client-shared/src/main/scala/cool/graph/client/database/DeferredUtils.scala new file mode 100644 index 0000000000..79cb4ad39a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/DeferredUtils.scala @@ -0,0 +1,101 @@ +package cool.graph.client.database + +import cool.graph.Types.Id +import cool.graph.client.database.DeferredTypes._ +import cool.graph.shared.models.{AuthenticatedRequest, Model} +import sangria.execution.deferred.Deferred + +object DeferredUtils { + def tagDeferredByOrder[T](deferredValues: Vector[Deferred[T]]): Vector[OrderedDeferred[Deferred[T]]] = { + deferredValues.zipWithIndex.map { + case (deferred, order) => OrderedDeferred[Deferred[T]](deferred, order) + } + } + + def groupModelDeferred[T <: ModelDeferred[Any]](modelDeferred: Vector[OrderedDeferred[T]]): Map[(Model, Option[QueryArguments]), Vector[OrderedDeferred[T]]] = { + modelDeferred.groupBy(ordered => (ordered.deferred.model, ordered.deferred.args)) + } + + def groupModelExistsDeferred[T <: ModelDeferred[Any]]( + modelExistsDeferred: Vector[OrderedDeferred[T]]): Map[(Model, Option[QueryArguments]), Vector[OrderedDeferred[T]]] = { + modelExistsDeferred.groupBy(ordered => (ordered.deferred.model, ordered.deferred.args)) + } + + def groupOneDeferred[T <: OneDeferred](oneDeferred: Vector[OrderedDeferred[T]]): Map[Model, Vector[OrderedDeferred[T]]] = { + oneDeferred.groupBy(ordered => ordered.deferred.model) + } + + def groupRelatedDeferred[T <: RelationDeferred[Any]]( + relatedDeferral: Vector[OrderedDeferred[T]]): Map[(Id, String, Option[QueryArguments]), Vector[OrderedDeferred[T]]] = { + relatedDeferral.groupBy(ordered => + (ordered.deferred.relationField.relation.get.id, ordered.deferred.relationField.relationSide.get.toString, ordered.deferred.args)) + } + + case class PermissionDeferredKey(model: Model, nodeId: String, authenticatedRequest: Option[AuthenticatedRequest]) + def groupPermissionDeferred( + permissionDeferreds: Vector[OrderedDeferred[CheckPermissionDeferred]]): Map[PermissionDeferredKey, Vector[OrderedDeferred[CheckPermissionDeferred]]] = { + permissionDeferreds.groupBy( + ordered => PermissionDeferredKey(ordered.deferred.model, ordered.deferred.nodeId, ordered.deferred.authenticatedRequest) + ) + } + + def checkSimilarityOfModelDeferredsAndThrow(deferreds: Vector[ModelDeferred[Any]]) = { + val headDeferred = deferreds.head + val model = headDeferred.model + val args = headDeferred.args + + val countSimilarDeferreds = deferreds.count { deferred => + deferred.model.name == deferred.model.name && + deferred.args == args + } + + if (countSimilarDeferreds != deferreds.length) { + throw new Error("Passed deferreds should not belong to different relations and should not have different arguments.") + } + } + + def checkSimilarityOfRelatedDeferredsAndThrow(deferreds: Vector[RelationDeferred[Any]]) = { + val headDeferred = deferreds.head + val relatedField = headDeferred.relationField + val args = headDeferred.args + + val countSimilarDeferreds = deferreds.count { d => + val myRelatedField = d.relationField + myRelatedField.relation == relatedField.relation && + myRelatedField.typeIdentifier == relatedField.typeIdentifier && + myRelatedField.relationSide == relatedField.relationSide && + d.args == args + } + + if (countSimilarDeferreds != deferreds.length) { + throw new Error("Passed deferreds should not belong to different relations and should not have different arguments.") + } + } + + def checkSimilarityOfOneDeferredsAndThrow(deferreds: Vector[OneDeferred]) = { + val headDeferred = deferreds.head + + val countSimilarDeferreds = deferreds.count { d => + d.key == headDeferred.key && + d.model == headDeferred.model + } + + if (countSimilarDeferreds != deferreds.length) { + throw new Error("Passed deferreds should not have different key or model.") + } + } + + def checkSimilarityOfPermissionDeferredsAndThrow(deferreds: Vector[CheckPermissionDeferred]) = { + val headDeferred = deferreds.head + + val countSimilarDeferreds = deferreds.count { d => + headDeferred.nodeId == d.nodeId && + headDeferred.model == headDeferred.model && + headDeferred.authenticatedRequest == headDeferred.authenticatedRequest + } + + if (countSimilarDeferreds != deferreds.length) { + throw new Error("Passed deferreds should not have dirrefent nodeIds, models or userIds.") + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/GetFieldFromSQLUniqueException.scala b/server/client-shared/src/main/scala/cool/graph/client/database/GetFieldFromSQLUniqueException.scala new file mode 100644 index 0000000000..6031cb47cf --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/GetFieldFromSQLUniqueException.scala @@ -0,0 +1,15 @@ +package cool.graph.client.database + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue + +object GetFieldFromSQLUniqueException { + + def getField(values: List[ArgumentValue], e: SQLIntegrityConstraintViolationException): String = { + values.filter(x => e.getCause.getMessage.contains("\'" + x.name + "_")) match { + case x if x.nonEmpty => "Field name = " + x.head.name + case _ => "Sorry, no more details available." + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelDeferredResolver.scala new file mode 100644 index 0000000000..cb4d204987 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelDeferredResolver.scala @@ -0,0 +1,48 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes._ + +import scala.concurrent.ExecutionContext.Implicits.global + +abstract class ManyModelDeferredResolver[ConnectionOutputType] { + def resolve(orderedDeferreds: Vector[OrderedDeferred[ManyModelDeferred[ConnectionOutputType]]], + resolver: DataResolver): Vector[OrderedDeferredFutureResult[ConnectionOutputType]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + DeferredUtils.checkSimilarityOfModelDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val model = headDeferred.model + val args = headDeferred.args + val futureResolverResults = resolver.resolveByModel(model, args) + + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[ConnectionOutputType](futureResolverResults.map(mapToConnectionOutputType(_, deferred)), order) + } + + results + } + + def mapToConnectionOutputType(input: ResolverResult, deferred: ManyModelDeferred[ConnectionOutputType]): ConnectionOutputType +} + +class SimpleManyModelDeferredResolver extends ManyModelDeferredResolver[SimpleConnectionOutputType] { + def mapToConnectionOutputType(input: ResolverResult, deferred: ManyModelDeferred[SimpleConnectionOutputType]): SimpleConnectionOutputType = + input.items.toList +} + +class RelayManyModelDeferredResolver extends ManyModelDeferredResolver[RelayConnectionOutputType] { + def mapToConnectionOutputType(input: ResolverResult, deferred: ManyModelDeferred[RelayConnectionOutputType]): RelayConnectionOutputType = { + DefaultIdBasedConnection( + PageInfo( + hasNextPage = input.hasNextPage, + hasPreviousPage = input.hasPreviousPage, + input.items.headOption.map(_.id), + input.items.lastOption.map(_.id) + ), + input.items.map(x => DefaultEdge(x, x.id)), + ConnectionParentElement(None, None, deferred.args) + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelExistsDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelExistsDeferredResolver.scala new file mode 100644 index 0000000000..c5713e2739 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/ManyModelExistsDeferredResolver.scala @@ -0,0 +1,28 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class ManyModelExistsDeferredResolver { + def resolve(orderedDeferreds: Vector[OrderedDeferred[ManyModelExistsDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[Boolean]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + DeferredUtils.checkSimilarityOfModelDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val model = headDeferred.model + val args = headDeferred.args + + // all deferred have the same return value + val futureDataItems = Future.successful(ctx.resolveByModel(model, args)) + + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[Boolean](futureDataItems.flatMap(identity).map(_.items.nonEmpty), order) + } + + results + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/OneDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/OneDeferredResolver.scala new file mode 100644 index 0000000000..4105f89a9f --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/OneDeferredResolver.scala @@ -0,0 +1,41 @@ +package cool.graph.client.database + +import cool.graph.DataItem +import cool.graph.client.database.DeferredTypes._ +import cool.graph.shared.models.Project + +import scala.concurrent.ExecutionContext.Implicits.global + +class OneDeferredResolver { + def resolve(orderedDeferreds: Vector[OrderedDeferred[OneDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[OneDeferredResultType]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + // check if we really can satisfy all deferreds with one database query + DeferredUtils.checkSimilarityOfOneDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + + // fetch dataitems + val futureDataItems = + ctx.batchResolveByUnique(headDeferred.model, headDeferred.key, deferreds.map(_.value).toList) + + // assign the dataitem that was requested by each deferred + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[OneDeferredResultType](futureDataItems.map { + dataItemsToToOneDeferredResultType(ctx.project, deferred, _) + }, order) + } + + results + } + + private def dataItemsToToOneDeferredResultType(project: Project, deferred: OneDeferred, dataItems: Seq[DataItem]): Option[DataItem] = { + + deferred.key match { + case "id" => dataItems.find(_.id == deferred.value) + case _ => + dataItems.find(_.getOption(deferred.key) == Some(deferred.value)) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/ToManyDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/ToManyDeferredResolver.scala new file mode 100644 index 0000000000..a60800960a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/ToManyDeferredResolver.scala @@ -0,0 +1,73 @@ +package cool.graph.client.database + +import cool.graph.client.database.DeferredTypes.{ToManyDeferred, _} +import cool.graph.shared.models.Project + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +abstract class ToManyDeferredResolver[ConnectionOutputType] { + def resolve(orderedDeferreds: Vector[OrderedDeferred[ToManyDeferred[ConnectionOutputType]]], + ctx: DataResolver): Vector[OrderedDeferredFutureResult[ConnectionOutputType]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + // Check if we really can satisfy all deferreds with one database query + DeferredUtils.checkSimilarityOfRelatedDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val relatedField = headDeferred.relationField + val args = headDeferred.args + + // Get ids of nodes in related model we need to fetch (actual rows of data) + val relatedModelInstanceIds = deferreds.map(_.parentNodeId).toList + + // As we are using `union all` as our batching mechanism there is very little gain from batching, + // and 500 items seems to be the cutoff point where there is no more value to be had. + val batchFutures: Seq[Future[Seq[ResolverResult]]] = relatedModelInstanceIds + .grouped(500) + .toList + .map(ctx.resolveByRelationManyModels(relatedField, _, args)) + + // Fetch resolver results + val futureResolverResults: Future[Seq[ResolverResult]] = Future + .sequence(batchFutures) + .map(_.flatten) + + // Assign the resolver results to each deferred + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[ConnectionOutputType]( + futureResolverResults.map { resolverResults => + // Each deferred has exactly one ResolverResult + mapToConnectionOutputType(resolverResults.find(_.parentModelId.contains(deferred.parentNodeId)).get, deferred, ctx.project) + }, + order + ) + } + + results + } + + def mapToConnectionOutputType(input: ResolverResult, deferred: ToManyDeferred[ConnectionOutputType], project: Project): ConnectionOutputType +} + +class SimpleToManyDeferredResolver extends ToManyDeferredResolver[SimpleConnectionOutputType] { + override def mapToConnectionOutputType(input: ResolverResult, + deferred: ToManyDeferred[SimpleConnectionOutputType], + project: Project): SimpleConnectionOutputType = input.items.toList +} + +class RelayToManyDeferredResolver extends ToManyDeferredResolver[RelayConnectionOutputType] { + def mapToConnectionOutputType(input: ResolverResult, deferred: ToManyDeferred[RelayConnectionOutputType], project: Project): RelayConnectionOutputType = { + DefaultIdBasedConnection( + PageInfo( + hasNextPage = input.hasNextPage, + hasPreviousPage = input.hasPreviousPage, + input.items.headOption.map(_.id), + input.items.lastOption.map(_.id) + ), + input.items.map(x => DefaultEdge(x, x.id)), + ConnectionParentElement(nodeId = Some(deferred.parentNodeId), field = Some(deferred.relationField), args = deferred.args) + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/database/ToOneDeferredResolver.scala b/server/client-shared/src/main/scala/cool/graph/client/database/ToOneDeferredResolver.scala new file mode 100644 index 0000000000..3e2827d3a7 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/database/ToOneDeferredResolver.scala @@ -0,0 +1,62 @@ +package cool.graph.client.database + +import cool.graph.DataItem +import cool.graph.client.database.DeferredTypes._ +import cool.graph.shared.models.Project + +import scala.concurrent.ExecutionContext.Implicits.global + +class ToOneDeferredResolver { + def resolve(orderedDeferreds: Vector[OrderedDeferred[ToOneDeferred]], ctx: DataResolver): Vector[OrderedDeferredFutureResult[OneDeferredResultType]] = { + val deferreds = orderedDeferreds.map(_.deferred) + + // check if we really can satisfy all deferreds with one database query + DeferredUtils.checkSimilarityOfRelatedDeferredsAndThrow(deferreds) + + val headDeferred = deferreds.head + val relatedField = headDeferred.relationField + val args = headDeferred.args + + // get ids of dataitems in related model we need to fetch + val relatedModelIds = deferreds.map(_.parentNodeId).toList + + // fetch dataitems + val futureDataItems = + ctx.resolveByRelationManyModels(relatedField, relatedModelIds, args).map(_.flatMap(_.items)) + + // assign the dataitem that was requested by each deferred + val results = orderedDeferreds.map { + case OrderedDeferred(deferred, order) => + OrderedDeferredFutureResult[OneDeferredResultType](futureDataItems.map { + dataItemsToToOneDeferredResultType(ctx.project, deferred, _) + }, order) + } + + results + } + + private def dataItemsToToOneDeferredResultType(project: Project, deferred: ToOneDeferred, dataItems: Seq[DataItem]): Option[DataItem] = { + + def matchesRelation(dataItem: DataItem, relationSide: String) = + dataItem.userData + .get(relationSide) + .flatten + .contains(deferred.parentNodeId) + + // see https://github.com/graphcool/internal-docs/blob/master/relations.md#findings + val resolveFromBothSidesAndMerge = + deferred.relationField.relation.get.isSameFieldSameModelRelation(project) + + dataItems.find( + dataItem => { + resolveFromBothSidesAndMerge match { + case false => + matchesRelation(dataItem, deferred.relationField.relationSide.get.toString) + case true => + dataItem.id != deferred.parentNodeId && (matchesRelation(dataItem, deferred.relationField.relationSide.get.toString) || + matchesRelation(dataItem, deferred.relationField.oppositeRelationSide.get.toString)) + } + } + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/files/FileUploader.scala b/server/client-shared/src/main/scala/cool/graph/client/files/FileUploader.scala new file mode 100644 index 0000000000..806a338b76 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/files/FileUploader.scala @@ -0,0 +1,83 @@ +package cool.graph.client.files + +import java.io.ByteArrayInputStream +import java.net.URLEncoder + +import akka.http.scaladsl.server.directives.FileInfo +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Source, StreamConverters} +import akka.util.ByteString +import com.amazonaws.services.s3.{AmazonS3} +import com.amazonaws.services.s3.internal.Mimetypes +import com.amazonaws.services.s3.model._ +import com.amazonaws.util.IOUtils +import cool.graph.cuid.Cuid +import cool.graph.shared.models.Project +import scaldi.{Injectable, Injector} + +import scala.concurrent.duration._ + +case class FileUploadResponse(size: Long, fileSecret: String, fileName: String, contentType: String) + +class FileUploader(project: Project)(implicit inj: Injector) extends Injectable { + + val s3 = inject[AmazonS3]("s3-fileupload") + implicit val materializer = + inject[ActorMaterializer](identified by "actorMaterializer") + + val bucketName = sys.env.getOrElse("FILEUPLOAD_S3_BUCKET", "dev.files.graph.cool") + + def uploadFile(metadata: FileInfo, byteSource: Source[ByteString, Any]): FileUploadResponse = { + val fileSecret = Cuid.createCuid() + val key = s"${project.id}/${fileSecret}" + + val stream = byteSource.runWith( + StreamConverters.asInputStream(600.seconds) + ) + val byteArray = IOUtils.toByteArray(stream) + + val meta = getObjectMetaData(metadata.fileName) + meta.setContentLength(byteArray.length.toLong) + + val request = new PutObjectRequest(bucketName, key, new ByteArrayInputStream(byteArray), meta) + request.setCannedAcl(CannedAccessControlList.PublicRead) + + s3.putObject(request) + + val contentType = Mimetypes.getInstance.getMimetype(metadata.fileName) + + FileUploadResponse(size = byteArray.length.toLong, fileSecret = fileSecret, fileName = metadata.fileName, contentType = contentType) + } + + def getObjectMetaData(fileName: String): ObjectMetadata = { + val contentType = Mimetypes.getInstance.getMimetype(fileName) + val meta = new ObjectMetadata() + val encodedFilename = URLEncoder.encode(fileName, "UTF-8") + + // note: we can probably do better than urlencoding the filename + // see RFC 6266: https://tools.ietf.org/html/rfc6266#section-4.3 + meta.setHeader("content-disposition", s"""filename="${encodedFilename}"; filename*="UTF-8''${encodedFilename}"""") + meta.setContentType(contentType) + + meta + } + + def setFilename(project: Project, fileSecret: String, newName: String): CopyObjectResult = { + val key = s"${project.id}/${fileSecret}" + + val request = new CopyObjectRequest(bucketName, key, bucketName, key) + request.setNewObjectMetadata(getObjectMetaData(newName)) + request.setCannedAccessControlList(CannedAccessControlList.PublicRead) + + s3.copyObject(request) + } + + def deleteFile(project: Project, fileSecret: String): Unit = { + val key = s"${project.id}/${fileSecret}" + + val request = new DeleteObjectRequest(bucketName, key) + + s3.deleteObject(request) + } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/finder/CachedProjectFetcherImpl.scala b/server/client-shared/src/main/scala/cool/graph/client/finder/CachedProjectFetcherImpl.scala new file mode 100644 index 0000000000..bc9ba5d6c2 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/finder/CachedProjectFetcherImpl.scala @@ -0,0 +1,74 @@ +package cool.graph.client.finder + +import cool.graph.cache.Cache +import cool.graph.client.metrics.BackendSharedMetrics +import cool.graph.messagebus.PubSubSubscriber +import cool.graph.messagebus.pubsub.{Everything, Message} +import cool.graph.shared.models.ProjectWithClientId +import cool.graph.utils.future.FutureUtils._ + +import scala.concurrent.Future +import scala.util.Success + +case class CachedProjectFetcherImpl( + projectFetcher: RefreshableProjectFetcher, + projectSchemaInvalidationSubscriber: PubSubSubscriber[String] + ) extends RefreshableProjectFetcher { + import scala.concurrent.ExecutionContext.Implicits.global + + private val cache = Cache.lfuAsync[String, ProjectWithClientId](initialCapacity = 16, maxCapacity = 100) + // ideally i would like to install a callback on cache for evictions. Whenever a project gets evicted i would remove it from the mapping cache as well. + // This would make sure the mapping is always up-to-date and does not grow unbounded and causes memory problems. + // So instead i am constraining the capacity to at least prohibit unbounded growth. + private val aliasToIdMapping = Cache.lfu[String, String](initialCapacity = 16, maxCapacity = 200) + + projectSchemaInvalidationSubscriber.subscribe( + Everything, + (msg: Message[String]) => { + + val projectWithClientId: Future[Option[ProjectWithClientId]] = cache.get(msg.payload) + + projectWithClientId.toFutureTry + .flatMap { + case Success(Some(p)) => + val alias: Option[String] = p.project.alias + alias.foreach(a => aliasToIdMapping.remove(a)) + Future.successful(()) + + case _ => + Future.successful(()) + } + .map(_ => cache.remove(msg.payload)) + } + ) + + override def fetch(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = { + BackendSharedMetrics.projectCacheGetCount.inc() + val potentialId = aliasToIdMapping.get(projectIdOrAlias).getOrElse(projectIdOrAlias) + + cache.getOrUpdateOpt( + potentialId, + () => { + BackendSharedMetrics.projectCacheMissCount.inc() + fetchProjectAndUpdateMapping(potentialId)(projectFetcher.fetch) + } + ) + } + + override def fetchRefreshed(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = { + val result = fetchProjectAndUpdateMapping(projectIdOrAlias)(projectFetcher.fetchRefreshed) + cache.put(projectIdOrAlias, result) + result + } + + private def fetchProjectAndUpdateMapping(projectIdOrAlias: String)(fn: String => Future[Option[ProjectWithClientId]]): Future[Option[ProjectWithClientId]] = { + val result = fn(projectIdOrAlias) + result.onSuccess { + case Some(ProjectWithClientId(project, _)) => + project.alias.foreach { alias => + aliasToIdMapping.put(alias, project.id) + } + } + result + } +} \ No newline at end of file diff --git a/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcher.scala b/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcher.scala new file mode 100644 index 0000000000..4b606bf1c8 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcher.scala @@ -0,0 +1,21 @@ +package cool.graph.client.finder + +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.ProjectWithClientId + +import scala.concurrent.{ExecutionContext, Future} + +trait ProjectFetcher { + def fetch_!(projectIdOrAlias: String)(implicit ec: ExecutionContext): Future[ProjectWithClientId] = { + fetch(projectIdOrAlias = projectIdOrAlias) map { + case None => throw UserAPIErrors.ProjectNotFound(projectIdOrAlias) + case Some(project) => project + } + } + + def fetch(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] +} + +trait RefreshableProjectFetcher extends ProjectFetcher { + def fetchRefreshed(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcherImpl.scala b/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcherImpl.scala new file mode 100644 index 0000000000..fc30f608b7 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/finder/ProjectFetcherImpl.scala @@ -0,0 +1,60 @@ +package cool.graph.client.finder + +import akka.http.scaladsl.model.Uri +import com.twitter.conversions.time._ +import com.typesafe.config.Config +import cool.graph.shared.SchemaSerializer +import cool.graph.shared.models.ProjectWithClientId +import cool.graph.twitterFutures.TwitterFutureImplicits._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ProjectFetcherImpl( + blockedProjectIds: Vector[String], + config: Config +) extends RefreshableProjectFetcher { + private val schemaManagerEndpoint = config.getString("schemaManagerEndpoint") + private val schemaManagerSecret = config.getString("schemaManagerSecret") + + private lazy val schemaService = { + val client = if (schemaManagerEndpoint.startsWith("https")) { + com.twitter.finagle.Http.client.withTls(Uri(schemaManagerEndpoint).authority.host.address()) + } else { + com.twitter.finagle.Http.client + } + + val destination = s"${Uri(schemaManagerEndpoint).authority.host}:${Uri(schemaManagerEndpoint).effectivePort}" + client.withRequestTimeout(10.seconds).newService(destination) + } + + override def fetchRefreshed(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = fetch(projectIdOrAlias, forceRefresh = true) + override def fetch(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = fetch(projectIdOrAlias, forceRefresh = false) + + /** + * Loads schema from backend-api-schema-manager service. + */ + private def fetch(projectIdOrAlias: String, forceRefresh: Boolean): Future[Option[ProjectWithClientId]] = { + if (blockedProjectIds.contains(projectIdOrAlias)) { + return Future.successful(None) + } + + // load from backend-api-schema-manager service + val uri = forceRefresh match { + case true => s"$schemaManagerEndpoint/$projectIdOrAlias?forceRefresh=true" + case false => s"$schemaManagerEndpoint/$projectIdOrAlias" + } + + val request = com.twitter.finagle.http + .RequestBuilder() + .url(uri) + .addHeader("Authorization", s"Bearer $schemaManagerSecret") + .buildGet() + + // schema deserialization failure should blow up as we have no recourse + schemaService(request).map { + case response if response.status.code >= 400 => None + case response => Some(SchemaSerializer.deserializeProjectWithClientId(response.getContentString()).get) + }.asScala + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/metrics/ApiMetricsMiddleware.scala b/server/client-shared/src/main/scala/cool/graph/client/metrics/ApiMetricsMiddleware.scala new file mode 100644 index 0000000000..b83ce576e5 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/metrics/ApiMetricsMiddleware.scala @@ -0,0 +1,34 @@ +package cool.graph.client.metrics + +import akka.actor.{ActorRef, ActorSystem} +import akka.stream.ActorMaterializer +import com.typesafe.scalalogging.LazyLogging +import cool.graph.RequestContextTrait +import cool.graph.client.ApiFeatureMetric +import cool.graph.shared.externalServices.TestableTime +import sangria.execution._ + +class ApiMetricsMiddleware( + testableTime: TestableTime, + apiMetricActor: ActorRef +)( + implicit system: ActorSystem, + materializer: ActorMaterializer +) extends Middleware[RequestContextTrait] + with LazyLogging { + + def afterQuery(queryVal: QueryVal, context: MiddlewareQueryContext[RequestContextTrait, _, _]) = { + (context.ctx.requestIp, context.ctx.projectId, context.ctx.clientId) match { + case (requestIp, Some(projectId), clientId) => { + // todo: generate list of features + + apiMetricActor ! ApiFeatureMetric(requestIp, testableTime.DateTime, projectId, clientId, context.ctx.listFeatureMetrics, context.ctx.isFromConsole) + } + case _ => println("missing data for FieldMetrics") + } + } + + override type QueryVal = Unit + + override def beforeQuery(context: MiddlewareQueryContext[RequestContextTrait, _, _]): Unit = Unit +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/metrics/BackendSharedMetrics.scala b/server/client-shared/src/main/scala/cool/graph/client/metrics/BackendSharedMetrics.scala new file mode 100644 index 0000000000..3a404f6a6e --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/metrics/BackendSharedMetrics.scala @@ -0,0 +1,19 @@ +package cool.graph.client.metrics + +import cool.graph.metrics.MetricsManager + +object BackendSharedMetrics extends MetricsManager { + + // CamelCase the service name read from env + override def serviceName = + sys.env + .getOrElse("SERVICE_NAME", "BackendShared") + .split("-") + .map { x => + x.head.toUpper + x.tail + } + .mkString + + val projectCacheGetCount = defineCounter("projectCacheGetCount") + val projectCacheMissCount = defineCounter("projectCacheMissCount") +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/metrics/ClientSharedMetrics.scala b/server/client-shared/src/main/scala/cool/graph/client/metrics/ClientSharedMetrics.scala new file mode 100644 index 0000000000..25b2d43558 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/metrics/ClientSharedMetrics.scala @@ -0,0 +1,20 @@ +package cool.graph.metrics + +import cool.graph.profiling.MemoryProfiler + +object ClientSharedMetrics extends MetricsManager { + + // CamelCase the service name read from env + override def serviceName = + sys.env + .getOrElse("SERVICE_NAME", "ClientShared") + .split("-") + .map { x => + x.head.toUpper + x.tail + } + .mkString + + MemoryProfiler.schedule(this) + + val schemaBuilderBuildTimerMetric = defineTimer("schemaBuilderBuildTimer", CustomTag("projectId", recordingThreshold = 600)) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemAsync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemAsync.scala new file mode 100644 index 0000000000..28cbc74870 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemAsync.scala @@ -0,0 +1,49 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.schemas.CreateSchema +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.messagebus.QueuePublisher +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.webhook.Webhook +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ActionWebhookForCreateDataItemAsync(model: Model, project: Project, nodeId: Id, action: Action, mutationId: Id, requestId: String)( + implicit inj: Injector) + extends ActionWebhookMutaction + with Injectable + with LazyLogging { + + override def execute: Future[MutactionExecutionResult] = { + + val webhookPublisher = inject[QueuePublisher[Webhook]](identified by "webhookPublisher") + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new CreateSchema(model = model, modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), project = project).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload.onSuccess { + case event: Event => + val whPayload = event.payload.map(p => p.compactPrint).getOrElse("") + webhookPublisher.publish(Webhook(project.id, "", requestId, event.url, whPayload, event.id, Map.empty)) + } + + payload.map(_ => MutactionExecutionSuccess()).recover { + case x => SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemSync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemSync.scala new file mode 100644 index 0000000000..805d5015be --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForCreateDataItemSync.scala @@ -0,0 +1,58 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph.shared.errors.UserAPIErrors.UnsuccessfulSynchronousMutationCallback +import cool.graph._ +import cool.graph.deprecated.actions.schemas.CreateSchema +import cool.graph.client.database.DataResolver +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.shared.errors.{SystemErrors, UserFacingError} +import cool.graph.webhook.WebhookCaller +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Success + +case class ActionWebhookForCreateDataItemSync(model: Model, project: Project, nodeId: Id, action: Action, mutationId: Id, requestId: String)( + implicit inj: Injector) + extends ActionWebhookMutaction + with Injectable + with LazyLogging { + + override def execute: Future[MutactionExecutionResult] = { + + val webhookCaller = inject[WebhookCaller] + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new CreateSchema(model = model, modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), project = project).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload + .flatMap( + payload => + webhookCaller + .call(payload.url, payload.payload.map(_.compactPrint).getOrElse("")) + .map(wasSuccess => + wasSuccess match { + case true => MutactionExecutionSuccess() + case false => + throw new UnsuccessfulSynchronousMutationCallback() + })) + .recover { + case x: UserFacingError => throw x + case x => + SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemAsync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemAsync.scala new file mode 100644 index 0000000000..03a4077ebc --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemAsync.scala @@ -0,0 +1,66 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.schemas._ +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.messagebus.QueuePublisher +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.webhook.Webhook +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Success + +case class ActionWebhookForDeleteDataItemAsync(model: Model, project: Project, nodeId: Id, action: Action, mutationId: Id, requestId: String)( + implicit inj: Injector) + extends ActionWebhookForDeleteDataItem + with Injectable + with LazyLogging { + + // note: as the node is being deleted we need to resolve the query before executing this mutaction. + // This is different than the normal execution flow for mutactions, so please be careful! + var data: Option[Webhook] = None + var prepareDataError: Option[Exception] = None + + def prepareData: Future[Event] = { + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new DeleteSchema(model = model, modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), project = project).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload.andThen({ + case Success(event) => + val whPayload = event.payload.map(p => p.compactPrint).getOrElse("") + data = Some(Webhook(project.id, "", requestId, event.url, whPayload, event.id, Map.empty)) + }) + } + + override def execute: Future[MutactionExecutionResult] = { + + prepareDataError match { + case Some(x) => + SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + Future.successful(MutactionExecutionSuccess()) + + case None => + require(data.nonEmpty, "prepareData should be invoked and awaited before executing this mutaction") + + val webhookPublisher = inject[QueuePublisher[Webhook]](identified by "webhookPublisher") + webhookPublisher.publish(data.get) + + Future.successful(MutactionExecutionSuccess()) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemSync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemSync.scala new file mode 100644 index 0000000000..71371b93db --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForDeleteDataItemSync.scala @@ -0,0 +1,75 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph.shared.errors.UserAPIErrors.UnsuccessfulSynchronousMutationCallback +import cool.graph._ +import cool.graph.deprecated.actions.schemas._ +import cool.graph.client.database.DataResolver +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.shared.errors.SystemErrors +import cool.graph.webhook.WebhookCaller +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Success + +abstract class ActionWebhookForDeleteDataItem extends ActionWebhookMutaction { + def prepareData: Future[Event] +} + +case class ActionWebhookForDeleteDataItemSync(model: Model, project: Project, nodeId: Id, action: Action, mutationId: Id, requestId: String)( + implicit inj: Injector) + extends ActionWebhookForDeleteDataItem + with Injectable + with LazyLogging { + + // note: as the node is being deleted we need to resolve the query before executing this mutaction. + // This is different than the normal execution flow for mutactions, so please be careful! + def prepareData: Future[Event] = { + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new DeleteSchema(model = model, modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), project = project).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload.andThen({ case Success(x) => data = Some(x) }) + } + + var data: Option[Event] = None + var prepareDataError: Option[Exception] = None + + override def execute: Future[MutactionExecutionResult] = { + + prepareDataError match { + case Some(x) => + SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + Future.successful(MutactionExecutionSuccess()) + + case None => + data match { + case None => + sys.error("prepareData should be invoked and awaited before executing this mutaction") + + case Some(event) => + val webhookCaller = inject[WebhookCaller] + + webhookCaller + .call(event.url, event.payload.map(_.compactPrint).getOrElse("")) + .map { + case true => MutactionExecutionSuccess() + case false => throw UnsuccessfulSynchronousMutationCallback() + } + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemAsync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemAsync.scala new file mode 100644 index 0000000000..6670a8cb84 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemAsync.scala @@ -0,0 +1,63 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.schemas._ +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.messagebus.QueuePublisher +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.webhook.Webhook +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ActionWebhookForUpdateDataItemAsync(model: Model, + project: Project, + nodeId: Id, + action: Action, + updatedFields: List[String], + mutationId: Id, + requestId: String, + previousValues: DataItem)(implicit inj: Injector) + extends ActionWebhookMutaction + with Injectable + with LazyLogging { + + import cool.graph.deprecated.actions.EventJsonProtocol._ + + override def execute: Future[MutactionExecutionResult] = { + + val webhookPublisher = inject[QueuePublisher[Webhook]](identified by "webhookPublisher") + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new UpdateSchema( + model = model, + modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), + project = project, + updatedFields = updatedFields, + previousValues = previousValues + ).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload.onSuccess { + case event: Event => + val whPayload = event.payload.map(p => p.compactPrint).getOrElse("") + webhookPublisher.publish(Webhook(project.id, "", requestId, event.url, whPayload, event.id, Map.empty)) + } + + payload.map(_ => MutactionExecutionSuccess()).recover { + case x => SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemSync.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemSync.scala new file mode 100644 index 0000000000..cd89db6bfb --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookForUpdateDataItemSync.scala @@ -0,0 +1,65 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.deprecated.actions.schemas._ +import cool.graph.deprecated.actions.{Event, MutationCallbackSchemaExecutor} +import cool.graph.shared.errors.UserAPIErrors.UnsuccessfulSynchronousMutationCallback +import cool.graph.shared.errors.{SystemErrors, UserFacingError} +import cool.graph.shared.models.{Action, Model, Project} +import cool.graph.webhook.WebhookCaller +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class ActionWebhookForUpdateDataItemSync(model: Model, + project: Project, + nodeId: Id, + action: Action, + updatedFields: List[String], + mutationId: Id, + requestId: String, + previousValues: DataItem)(implicit inj: Injector) + extends ActionWebhookMutaction + with Injectable + with LazyLogging { + + override def execute: Future[MutactionExecutionResult] = { + + val webhookCaller = inject[WebhookCaller] + + val payload: Future[Event] = + new MutationCallbackSchemaExecutor( + project, + model, + new UpdateSchema( + model = model, + modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project), + project = project, + updatedFields = updatedFields, + previousValues = previousValues + ).build(), + nodeId, + action.triggerMutationModel.get.fragment, + action.handlerWebhook.get.url, + mutationId + ).execute + + payload + .flatMap( + payload => + webhookCaller + .call(payload.url, payload.payload.map(_.compactPrint).getOrElse("")) + .map { + case true => MutactionExecutionSuccess() + case false => throw UnsuccessfulSynchronousMutationCallback() + }) + .recover { + case x: UserFacingError => throw x + case x => SystemErrors.UnknownExecutionError(x.getMessage, x.getStackTrace.map(_.toString).mkString(", ")) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookMutaction.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookMutaction.scala new file mode 100644 index 0000000000..ea6fca6b3e --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ActionWebhookMutaction.scala @@ -0,0 +1,8 @@ +package cool.graph.client.mutactions + +import cool.graph.Mutaction + +/** + * Marker interface for ActionWebhook + */ +trait ActionWebhookMutaction extends Mutaction {} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/AddDataItemToManyRelation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/AddDataItemToManyRelation.scala new file mode 100644 index 0000000000..5e9c595eb1 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/AddDataItemToManyRelation.scala @@ -0,0 +1,95 @@ +package cool.graph.client.mutactions + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder.MirrorFieldDbValues +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.cuid.Cuid +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models._ +import cool.graph.shared.{NameConstraints, RelationFieldMirrorColumn} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +/** + * Notation: It's not important which side you actually put into to or from. the only important + * thing is that fromField belongs to fromModel + */ +case class AddDataItemToManyRelation(project: Project, fromModel: Model, fromField: Field, toId: String, fromId: String, toIdAlreadyInDB: Boolean = true) + extends ClientSqlDataChangeMutaction { + + // If this assertion fires, this mutaction is used wrong by the programmer. + assert(fromModel.fields.exists(_.id == fromField.id)) + + val relationSide: cool.graph.shared.models.RelationSide.Value = fromField.relationSide.get + val relation: Relation = fromField.relation.get + + val aValue: String = if (relationSide == RelationSide.A) fromId else toId + val bValue: String = if (relationSide == RelationSide.A) toId else fromId + + val aModel: Model = relation.getModelA_!(project) + val bModel: Model = relation.getModelB_!(project) + + private def getFieldMirrors(model: Model, id: String) = + relation.fieldMirrors + .filter(mirror => model.fields.map(_.id).contains(mirror.fieldId)) + .map(mirror => { + val field = project.getFieldById_!(mirror.fieldId) + MirrorFieldDbValues( + relationColumnName = RelationFieldMirrorColumn.mirrorColumnName(project, field, relation), + modelColumnName = field.name, + model.name, + id + ) + }) + + val fieldMirrors: List[MirrorFieldDbValues] = getFieldMirrors(aModel, aValue) ++ getFieldMirrors(bModel, bValue) + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .createRelationRow(project.id, relation.id, Cuid.createCuid(), aValue, bValue, fieldMirrors))) + } + + override def handleErrors = + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + UserAPIErrors.ItemAlreadyInRelation() + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1452 => + UserAPIErrors.NodeDoesNotExist("") + }) + + override def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess]] = { + + if (toIdAlreadyInDB) { + val toModel = if (relationSide == RelationSide.A) relation.getModelB_!(project) else relation.getModelA_!(project) + resolver.existsByModelAndId(toModel, toId) map { + case false => Failure(UserAPIErrors.NodeDoesNotExist(toId)) + case true => + (NameConstraints.isValidDataItemId(aValue), NameConstraints.isValidDataItemId(bValue)) match { + case (false, _) => Failure(UserAPIErrors.IdIsInvalid(aValue)) + case (true, false) => Failure(UserAPIErrors.IdIsInvalid(bValue)) + case _ => Success(MutactionVerificationSuccess()) + } + } + } else { + Future.successful( + if (!NameConstraints.isValidDataItemId(aValue)) Failure(UserAPIErrors.IdIsInvalid(aValue)) + else if (!NameConstraints.isValidDataItemId(bValue)) Failure(UserAPIErrors.IdIsInvalid(bValue)) + else Success(MutactionVerificationSuccess())) + } + // todo: handle case where the relation table is just being created +// if (resolver.resolveRelation(relation.id, aValue, bValue).nonEmpty) { +// return Future.successful( +// Failure(RelationDoesAlreadyExist( +// aModel.name, bModel.name, aValue, bValue))) +// } + + } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/CreateDataItem.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/CreateDataItem.scala new file mode 100644 index 0000000000..942c22f379 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/CreateDataItem.scala @@ -0,0 +1,99 @@ +package cool.graph.client.mutactions + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph.GCDataTypes._ +import cool.graph.Types.Id +import cool.graph.client.database.GetFieldFromSQLUniqueException.getField +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder, ProjectRelayId, ProjectRelayIdTable} +import cool.graph.client.mutactions.validation.InputValueValidation.{transformStringifiedJson, validateDataItemInputs} +import cool.graph.client.mutations.CoolArgs +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.MutationTypes.{ArgumentValue, ArgumentValueList} +import cool.graph.{ClientSqlStatementResult, MutactionVerificationSuccess, _} +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class CreateDataItem( + project: Project, + model: Model, + values: List[ArgumentValue], + allowSettingManagedFields: Boolean = false, + requestId: Option[String] = None, + originalArgs: Option[CoolArgs] = None +)(implicit val inj: Injector) + extends ClientSqlDataChangeMutaction + with Injectable { + + val pipelineRunner = new RequestPipelineRunner(requestId.getOrElse("")) + + // FIXME: it should be guaranteed to always have an id (generate it in here) + val id: Id = ArgumentValueList.getId_!(values) + + val jsonCheckedValues: List[ArgumentValue] = { + if (model.fields.exists(_.typeIdentifier == TypeIdentifier.Json)) { + transformStringifiedJson(values, model) + } else { + values + } + } + + def getValueOrDefault(transformedValues: List[ArgumentValue], field: Field): Option[Any] = { + transformedValues + .find(_.name == field.name) + .map(v => Some(v.value)) + .getOrElse(field.defaultValue.map(GCDBValueConverter(field.typeIdentifier, field.isList).fromGCValue)) + } + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val relayIds = TableQuery(new ProjectRelayIdTable(_, project.id)) + val valuesIncludingId = jsonCheckedValues :+ ArgumentValue("id", id, model.getFieldByName_!("id")) + + for { + transformedValues <- pipelineRunner.runTransformArgument(project, model, RequestPipelineOperation.CREATE, valuesIncludingId, originalArgs) + _ <- pipelineRunner.runPreWrite(project, model, RequestPipelineOperation.CREATE, transformedValues, originalArgs) + } yield { + + ClientSqlStatementResult( + sqlAction = DBIO.seq( + DatabaseMutationBuilder.createDataItem( + project.id, + model.name, + model.scalarFields + .filter(getValueOrDefault(transformedValues, _).isDefined) + .map(field => (field.name, getValueOrDefault(transformedValues, field).get)) + .toMap + ), + relayIds += ProjectRelayId(id = ArgumentValueList.getId_!(jsonCheckedValues), model.id) + )) + } + } + + override def handleErrors = { + implicit val anyFormat = JsonFormats.AnyJsonFormat + Some({ + //https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + UserAPIErrors.UniqueConstraintViolation(model.name, getField(jsonCheckedValues, e)) + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1452 => + UserAPIErrors.NodeDoesNotExist("") + }) + } + + override def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess]] = { + val (check, _) = validateDataItemInputs(model, id, jsonCheckedValues) + if (check.isFailure) return Future.successful(check) + + resolver.existsByModelAndId(model, id) map { + case true => Failure(UserAPIErrors.DataItemAlreadyExists(model.name, id)) + case false => Success(MutactionVerificationSuccess()) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/DeleteDataItem.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/DeleteDataItem.scala new file mode 100644 index 0000000000..3c9d4c79bd --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/DeleteDataItem.scala @@ -0,0 +1,59 @@ +package cool.graph.client.mutactions + +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder, ProjectRelayIdTable} +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.shared.NameConstraints +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Model, Project, RequestPipelineOperation} +import scaldi.{Injectable, Injector} +import slick.jdbc.MySQLProfile.api._ +import slick.lifted.TableQuery + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class DeleteDataItem(project: Project, model: Model, id: Id, previousValues: DataItem, requestId: Option[String] = None)(implicit val inj: Injector) + extends ClientSqlDataChangeMutaction + with Injectable { + + val pipelineRunner = new RequestPipelineRunner(requestId.getOrElse("")) + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val relayIds = TableQuery(new ProjectRelayIdTable(_, project.id)) + + val values = List(ArgumentValue("id", id, model.getFieldByName_!("id"))) + for { + transformedValues <- pipelineRunner.runTransformArgument( + project = project, + model = model, + operation = RequestPipelineOperation.DELETE, + values = values, + originalArgs = None + ) + _ <- pipelineRunner.runPreWrite( + project = project, + model = model, + operation = RequestPipelineOperation.DELETE, + values = transformedValues, + originalArgsOpt = None + ) + } yield { + ClientSqlStatementResult( + sqlAction = DBIO.seq(DatabaseMutationBuilder.deleteDataItemById(project.id, model.name, id), relayIds.filter(_.id === id).delete)) + } + } + + override def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess]] = { + if (!NameConstraints.isValidDataItemId(id)) + return Future.successful(Failure(UserAPIErrors.IdIsInvalid(id))) + + resolver.existsByModelAndId(model, id) map { + case false => Failure(UserAPIErrors.DataItemDoesNotExist(model.name, id)) + case true => Success(MutactionVerificationSuccess()) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/PublishSubscriptionEvent.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/PublishSubscriptionEvent.scala new file mode 100644 index 0000000000..197d68f8ea --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/PublishSubscriptionEvent.scala @@ -0,0 +1,29 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.JsonFormats.AnyJsonFormat +import cool.graph._ +import cool.graph.deprecated.actions.EventJsonProtocol +import cool.graph.messagebus.PubSubPublisher +import cool.graph.messagebus.pubsub.Only +import cool.graph.shared.models.Project +import scaldi._ +import spray.json._ + +import scala.concurrent.Future + +case class PublishSubscriptionEvent(project: Project, value: Map[String, Any], mutationName: String)(implicit inj: Injector) + extends Mutaction + with Injectable + with LazyLogging { + import EventJsonProtocol._ + + val publisher = inject[PubSubPublisher[String]](identified by "sss-events-publisher") + + override def execute: Future[MutactionExecutionResult] = { + val topic = Only(s"subscription:event:${project.id}:$mutationName") + + publisher.publish(topic, value.toJson.compactPrint) + Future.successful(MutactionExecutionSuccess()) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByFromId.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByFromId.scala new file mode 100644 index 0000000000..9335109bd3 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByFromId.scala @@ -0,0 +1,24 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.models.Field + +import scala.concurrent.Future +import scala.util.Success + +case class RemoveDataItemFromManyRelationByFromId(projectId: String, fromField: Field, fromId: Id) extends ClientSqlDataChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val fromRelationSide = fromField.relationSide.get + val relation = fromField.relation.get + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .deleteDataItemByValues(projectId, relation.id, Map(fromRelationSide.toString -> fromId)))) + } + + override def rollback: Some[Future[ClientSqlStatementResult[Any]]] = Some(ClientMutactionNoop().execute) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByToId.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByToId.scala new file mode 100644 index 0000000000..6da2b2be85 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromManyRelationByToId.scala @@ -0,0 +1,33 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.models.Field + +import scala.concurrent.Future +import scala.util.{Success, Try} + +case class RemoveDataItemFromManyRelationByToId(projectId: String, fromField: Field, toId: Id) extends ClientSqlDataChangeMutaction { + + override def execute = { + val toRelationSide = fromField.oppositeRelationSide.get + val relation = fromField.relation.get + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .deleteDataItemByValues(projectId, relation.id, Map(toRelationSide.toString -> toId)))) + } + + override def rollback = { + Some(ClientMutactionNoop().execute) + } + + override def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess]] = { + + // note: we intentionally don't require that a relation actually exists + + Future.successful(Success(MutactionVerificationSuccess())) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByField.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByField.scala new file mode 100644 index 0000000000..f32e1ce0f6 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByField.scala @@ -0,0 +1,21 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.models.Field + +import scala.concurrent.Future +import scala.util.Success + +case class RemoveDataItemFromRelationByField(projectId: String, relationId: String, field: Field, id: Id) extends ClientSqlDataChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .deleteRelationRowBySideAndId(projectId, relationId, field.relationSide.get, id))) + } + + override def rollback: Some[Future[ClientSqlStatementResult[Any]]] = Some(ClientMutactionNoop().execute) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationById.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationById.scala new file mode 100644 index 0000000000..bfaaef2c71 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationById.scala @@ -0,0 +1,18 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.models.Project + +import scala.concurrent.Future +import scala.util.Success + +case class RemoveDataItemFromRelationById(project: Project, relationId: String, id: Id) extends ClientSqlDataChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + Future.successful(ClientSqlStatementResult(sqlAction = DatabaseMutationBuilder.deleteRelationRowById(project.id, relationId, id))) + } + + override def rollback: Some[Future[ClientSqlStatementResult[Any]]] = Some(ClientMutactionNoop().execute) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByToAndFromField.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByToAndFromField.scala new file mode 100644 index 0000000000..33df3c1551 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/RemoveDataItemFromRelationByToAndFromField.scala @@ -0,0 +1,49 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DataResolver, DatabaseMutationBuilder} +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Project} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class RemoveDataItemFromRelationByToAndFromField(project: Project, relationId: String, aField: Field, aId: Id, bField: Field, bId: Id) + extends ClientSqlDataChangeMutaction { + + override def execute: Future[ClientSqlStatementResult[Any]] = { + + val aRelationSide = aField.relationSide.get + // note: for relations between same model, same field a and b relation side is the same, so + // to handle that case we take oppositeRelationSide instead of bField.relationSide + val bRelationSide = aField.oppositeRelationSide.get + + Future.successful( + ClientSqlStatementResult( + sqlAction = DatabaseMutationBuilder + .deleteRelationRowByToAndFromSideAndId(project.id, relationId, aRelationSide, aId, bRelationSide, bId))) + } + + override def rollback = Some(ClientMutactionNoop().execute) + + override def verify(resolver: DataResolver): Future[Try[MutactionVerificationSuccess] with Product with Serializable] = { + def dataItemExists(field: Field, id: Id): Future[Boolean] = { + val model = project.getModelByFieldId_!(field.id) + resolver.existsByModelAndId(model, id) + } + val dataItemAExists = dataItemExists(aField, aId) + val dataItemBExists = dataItemExists(bField, bId) + for { + aExists <- dataItemAExists + bExists <- dataItemBExists + } yield { + (aExists, bExists) match { + case (true, true) => Success(MutactionVerificationSuccess()) + case (_, false) => Failure(UserAPIErrors.NodeNotFoundError(bId)) + case (false, _) => Failure(UserAPIErrors.NodeNotFoundError(aId)) + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3DeleteFIle.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3DeleteFIle.scala new file mode 100644 index 0000000000..1da429d3c1 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3DeleteFIle.scala @@ -0,0 +1,21 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph._ +import cool.graph.client.files.FileUploader +import cool.graph.shared.models.{Model, Project} +import scaldi._ + +import scala.concurrent.Future + +case class S3DeleteFIle(model: Model, project: Project, fileSecret: String)(implicit inj: Injector) extends Mutaction with Injectable with LazyLogging { + + override def execute: Future[MutactionExecutionResult] = { + + val uploader = new FileUploader(project) + + uploader.deleteFile(project, fileSecret) + + Future.successful(MutactionExecutionSuccess()) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3UpdateFileName.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3UpdateFileName.scala new file mode 100644 index 0000000000..7419ca69e4 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/S3UpdateFileName.scala @@ -0,0 +1,40 @@ +package cool.graph.client.mutactions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.shared.errors.UserAPIErrors.DataItemDoesNotExist +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.client.files.FileUploader +import cool.graph.shared.models.{Model, Project} +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class S3UpdateFileName(model: Model, project: Project, fileId: String, newName: String, resolver: DataResolver)(implicit inj: Injector) + extends Mutaction + with Injectable + with LazyLogging { + + var fileSecret: Option[String] = None + + override def execute: Future[MutactionExecutionResult] = { + + val uploader = new FileUploader(project) + + uploader.setFilename(project, fileSecret.get, newName) + + Future.successful(MutactionExecutionSuccess()) + } + + override def verify(): Future[Try[MutactionVerificationSuccess] with Product with Serializable] = { + resolver.resolveByUnique(model, "id", fileId) map { + case None => Failure(DataItemDoesNotExist(model.id, fileId)) + case node => + fileSecret = node.get.getOption[String]("secret") + + Success(MutactionVerificationSuccess()) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/ServerSideSubscription.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ServerSideSubscription.scala new file mode 100644 index 0000000000..08ee7d66c3 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/ServerSideSubscription.scala @@ -0,0 +1,178 @@ +package cool.graph.client.mutactions + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.requestPipeline.FunctionExecutor +import cool.graph.messagebus.QueuePublisher +import cool.graph.shared.functions.EndpointResolver +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models._ +import cool.graph.subscriptions.SubscriptionExecutor +import cool.graph.webhook.Webhook +import scaldi.{Injectable, Injector} +import spray.json.{JsValue, _} +import cool.graph.utils.future.FutureUtils._ + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +object ServerSideSubscription { + def extractFromMutactions(project: Project, mutactions: Seq[ClientSqlMutaction], requestId: Id)(implicit inj: Injector): Seq[ServerSideSubscription] = { + val createMutactions = mutactions.collect { case x: CreateDataItem => x } + val updateMutactions = mutactions.collect { case x: UpdateDataItem => x } + val deleteMutactions = mutactions.collect { case x: DeleteDataItem => x } + + extractFromCreateMutactions(project, createMutactions, requestId) ++ + extractFromUpdateMutactions(project, updateMutactions, requestId) ++ + extractFromDeleteMutactions(project, deleteMutactions, requestId) + } + + def extractFromCreateMutactions(project: Project, mutactions: Seq[CreateDataItem], requestId: Id)(implicit inj: Injector): Seq[ServerSideSubscription] = { + for { + mutaction <- mutactions + sssFn <- project.serverSideSubscriptionFunctionsFor(mutaction.model, ModelMutationType.Created) + } yield { + ServerSideSubscription( + project, + mutaction.model, + ModelMutationType.Created, + sssFn, + nodeId = mutaction.id, + requestId = requestId + ) + } + } + + def extractFromUpdateMutactions(project: Project, mutactions: Seq[UpdateDataItem], requestId: Id)(implicit inj: Injector): Seq[ServerSideSubscription] = { + for { + mutaction <- mutactions + sssFn <- project.serverSideSubscriptionFunctionsFor(mutaction.model, ModelMutationType.Updated) + } yield { + ServerSideSubscription( + project, + mutaction.model, + ModelMutationType.Updated, + sssFn, + nodeId = mutaction.id, + requestId = requestId, + updatedFields = Some(mutaction.namesOfUpdatedFields), + previousValues = Some(mutaction.previousValues) + ) + } + + } + + def extractFromDeleteMutactions(project: Project, mutactions: Seq[DeleteDataItem], requestId: Id)(implicit inj: Injector): Seq[ServerSideSubscription] = { + for { + mutaction <- mutactions + sssFn <- project.serverSideSubscriptionFunctionsFor(mutaction.model, ModelMutationType.Deleted) + } yield { + ServerSideSubscription( + project, + mutaction.model, + ModelMutationType.Deleted, + sssFn, + nodeId = mutaction.id, + requestId = requestId, + previousValues = Some(mutaction.previousValues) + ) + } + } +} + +case class ServerSideSubscription( + project: Project, + model: Model, + mutationType: ModelMutationType, + function: ServerSideSubscriptionFunction, + nodeId: Id, + requestId: String, + updatedFields: Option[List[String]] = None, + previousValues: Option[DataItem] = None +)(implicit inj: Injector) + extends Mutaction + with Injectable { + import scala.concurrent.ExecutionContext.Implicits.global + + val webhookPublisher = inject[QueuePublisher[Webhook]](identified by "webhookPublisher") + + override def execute: Future[MutactionExecutionResult] = { + for { + result <- executeQuery() + } yield { + result match { + case Some(JsObject(fields)) if fields.contains("data") => + val endpointResolver = inject[EndpointResolver](identified by "endpointResolver") + val context: Map[String, Any] = FunctionExecutor.createEventContext(project, "", headers = Map.empty, None, endpointResolver) + val event = JsObject(fields + ("context" -> AnyJsonFormat.write(context))) + val json = event.compactPrint + + function.delivery match { + case fn: HttpFunction => + val webhook = Webhook(project.id, function.id, requestId, fn.url, json, requestId, fn.headers.toMap) + webhookPublisher.publish(webhook) + + case fn: ManagedFunction => + new FunctionExecutor().syncWithLoggingAndErrorHandling_!(function, json, project, requestId) + + case _ => + } + + case _ => + } + + MutactionExecutionSuccess() + } + } + + def executeQuery(): Future[Option[JsValue]] = { + SubscriptionExecutor.execute( + project = project, + model = model, + mutationType = mutationType, + previousValues = previousValues, + updatedFields = updatedFields, + query = function.query, + variables = JsObject.empty, + nodeId = nodeId, + clientId = project.ownerId, + authenticatedRequest = None, + requestId = s"subscription:server_side:${project.id}", + operationName = None, + skipPermissionCheck = true, + alwaysQueryMasterDatabase = true + ) + } + + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any): JsValue = x match { + case m: Map[_, _] => + JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case l: Vector[Any] => JsArray(l.map(write)) + case l: Seq[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case n: BigDecimal => JsNumber(n) + case n: Double => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(x: JsValue): Any = { + x match { + case l: JsArray => l.elements.map(read).toList + case m: JsObject => m.fields.mapValues(read) + case s: JsString => s.value + case n: JsNumber => n.value + case b: JsBoolean => b.value + case JsNull => null + case _ => sys.error("implement all scalar types!") + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncDataItemToAlgolia.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncDataItemToAlgolia.scala new file mode 100644 index 0000000000..f0c8ba49bb --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncDataItemToAlgolia.scala @@ -0,0 +1,141 @@ +package cool.graph.client.mutactions + +import com.amazonaws.services.kinesis.model.PutRecordResult +import com.typesafe.scalalogging.LazyLogging +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.shared.algolia.AlgoliaEventJsonProtocol._ +import cool.graph.shared.algolia.schemas.AlgoliaSchema +import cool.graph.shared.algolia.{AlgoliaContext, AlgoliaEvent} +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.externalServices.KinesisPublisher +import cool.graph.shared.logging.{LogData, LogKey} +import cool.graph.shared.models.{AlgoliaSyncQuery, Model, Project, SearchProviderAlgolia} +import cool.graph.shared.schema.JsonMarshalling._ +import sangria.ast.Document +import sangria.execution.Executor +import sangria.parser.QueryParser +import scaldi.{Injectable, Injector} +import spray.json.{JsString, _} + +import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} +import scala.util.{Failure, Success} + +case class SyncDataItemToAlgolia( + model: Model, + project: Project, + nodeId: Id, + syncQuery: AlgoliaSyncQuery, + searchProviderAlgolia: SearchProviderAlgolia, + requestId: String, + operation: String +)(implicit inj: Injector) + extends Mutaction + with Injectable + with LazyLogging { + + override def execute: Future[MutactionExecutionResult] = { + searchProviderAlgolia.isEnabled match { + case false => + Future.successful(MutactionExecutionSuccess()) + case true => + val algoliaSyncPublisher = inject[KinesisPublisher](identified by "kinesisAlgoliaSyncQueriesPublisher") + implicit val dispatcher = inject[ExecutionContextExecutor](identified by "dispatcher") + + val parsedGraphQLQuery = QueryParser.parse(syncQuery.fragment) + val queryResultFuture: Future[Option[JsValue]] = + parsedGraphQLQuery match { + case Success(validQueryAst) => + operation match { + case "delete" => Future.successful(Some("".toJson)) + case _ => performQueryWith(validQueryAst).map(_.map((dataMap: JsValue) => cleanAndAddObjectIdForAlgolia(dataMap))) + } + + case Failure(error) => + Future.successful(Some(JsObject("error" -> JsString(error.getMessage)))) + } + + val payloadFuture = queryResultFuture + .map { + case Some(queryResult) => + val formattedPayload = stringifyAndListifyPayload(queryResult) + val event = algoliaEventFor(formattedPayload).toJson.compactPrint + val publisherResult = algoliaSyncPublisher.putRecord(event) + logMutaction(publisherResult) + + case None => () + } + + payloadFuture.map(_ => MutactionExecutionSuccess()).recover { + case x => SystemErrors.UnknownExecutionError(x.getMessage, "") + } + } + } + + private def cleanAndAddObjectIdForAlgolia(rawQueryResult: JsValue): JsObject = { + //grabbing "node" here couples us to the AlgoliaSchema, be aware + val resultWithoutNode = rawQueryResult.asJsObject.fields.get("node").toJson.asJsObject + val algoliaId = JsObject("objectID" -> JsString(nodeId)) + val combinedFields = resultWithoutNode.fields ++ algoliaId.fields + + JsObject(combinedFields) + } + + private def stringifyAndListifyPayload(value: JsValue): String = s"[${value.compactPrint}]" + + private def performQueryWith(queryAst: Document)(implicit ec: ExecutionContext): Future[Option[JsValue]] = { + Executor + .execute( + schema = new AlgoliaSchema( + project = project, + model = model, + modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project) + ).build(), + queryAst = queryAst, + userContext = AlgoliaContext( + project = project, + requestId = "", + nodeId = nodeId, + log = (x: String) => logger.info(x) + ), + deferredResolver = new DeferredResolverProvider( + new SimpleToManyDeferredResolver, + new SimpleManyModelDeferredResolver, + skipPermissionCheck = true + ) + ) + .map { response => + val JsObject(fields) = response + val payload: JsValue = fields("data") + + val mutationResultValue = + payload.asJsObject.fields.head._2 + + mutationResultValue match { + case JsNull => None + case _ => Some(payload) + } + } + } + private def algoliaEventFor(payload: String): AlgoliaEvent = { + AlgoliaEvent( + indexName = syncQuery.indexName, + applicationId = searchProviderAlgolia.applicationId, + apiKey = searchProviderAlgolia.apiKey, + operation = operation, + nodeId = nodeId, + requestId = requestId, + queryResult = payload + ) + } + + private def logMutaction(result: PutRecordResult) = { + logger.info( + LogData(LogKey.AlgoliaSyncQuery, + requestId, + payload = Some(Map("kinesis" -> Map("sequence_number" -> result.getSequenceNumber, "shard_id" -> result.getShardId)))).json + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncModelToAlgolia.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncModelToAlgolia.scala new file mode 100644 index 0000000000..fe75b0b41c --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/SyncModelToAlgolia.scala @@ -0,0 +1,144 @@ +package cool.graph.client.mutactions + +import com.amazonaws.services.kinesis.model.PutRecordResult +import com.typesafe.scalalogging.LazyLogging +import cool.graph._ +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.client.schema.simple.SimpleSchemaModelObjectTypeBuilder +import cool.graph.shared.algolia.schemas.AlgoliaFullModelSchema +import cool.graph.shared.algolia.{AlgoliaEvent, AlgoliaFullModelContext} +import cool.graph.shared.errors.SystemErrors +import cool.graph.shared.externalServices.KinesisPublisher +import cool.graph.shared.logging.{LogData, LogKey} +import cool.graph.shared.models.{AlgoliaSyncQuery, Model, Project, SearchProviderAlgolia} +import cool.graph.shared.schema.JsonMarshalling._ +import cool.graph.util.json.SprayJsonExtensions +import sangria.ast._ +import sangria.execution.Executor +import sangria.parser.QueryParser +import scaldi.{Injectable, Injector} +import spray.json.{JsString, _} +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +case class SyncModelToAlgolia( + model: Model, + project: Project, + syncQuery: AlgoliaSyncQuery, + searchProviderAlgolia: SearchProviderAlgolia, + requestId: String +)(implicit inj: Injector) + extends Mutaction + with Injectable + with LazyLogging + with SprayJsonExtensions { + + import cool.graph.shared.algolia.AlgoliaEventJsonProtocol._ + import cool.graph.utils.`try`.TryExtensions._ + + val algoliaSyncPublisher: KinesisPublisher = inject[KinesisPublisher](identified by "kinesisAlgoliaSyncQueriesPublisher") + implicit val dispatcher: ExecutionContext = inject[ExecutionContext](identified by "dispatcher") + + override def execute: Future[MutactionExecutionResult] = { + if (!searchProviderAlgolia.isEnabled) { + Future.successful(MutactionExecutionSuccess()) + } else { + syncItemsForQueryToAlgolia(syncQuery.fragment).recover { + case x => SystemErrors.UnknownExecutionError(x.getMessage, "") + } + } + } + + private def syncItemsForQueryToAlgolia(query: String): Future[MutactionExecutionSuccess] = { + for { + enhancedQuery <- parseAndEnhanceSynQuery(query).toFuture + result <- performQueryWith(enhancedQuery) + dataList = result.pathAsSeq("data.node").toList + enhancedList = dataList.map { rawRow => + cleanAndAddObjectIdForAlgolia(rawRow) + } + payload = enhancedList.map { item => + val formattedPayload = stringifyAndListifyPayload(item._2) + algoliaEventFor(formattedPayload, item._1).toJson.compactPrint + } + + } yield { + payload.foreach { payload => + val publisherResult = algoliaSyncPublisher.putRecord(payload) + logMutaction(publisherResult) + } + MutactionExecutionSuccess() + } + } + + private def parseAndEnhanceSynQuery(query: String): Try[Document] = { + QueryParser.parse(syncQuery.fragment).map { queryAst => + val modifiedDefinitions = queryAst.definitions.map { + case x: OperationDefinition => x.copy(selections = addIdFieldToNodeSelections(x.selections)) + case y: FragmentDefinition => y.copy(selections = addIdFieldToNodeSelections(y.selections)) + case z => z + } + val queryWithAddedIdSelection = queryAst.copy(definitions = modifiedDefinitions) + queryWithAddedIdSelection + } + } + + private def addIdFieldToNodeSelections(selections: Vector[Selection]): Vector[Selection] = selections map { + case f: Field if f.name == "node" => + f.copy(selections = f.selections :+ Field(None, "id", Vector.empty, Vector.empty, Vector.empty)) + case x => x + } + + private def performQueryWith(queryAst: Document): Future[JsValue] = { + val schema = new AlgoliaFullModelSchema( + project = project, + model = model, + modelObjectTypes = new SimpleSchemaModelObjectTypeBuilder(project = project) + ).build() + + val userContext = AlgoliaFullModelContext( + project = project, + requestId = "", + log = (x: String) => logger.info(x) + ) + + Executor.execute( + schema, + queryAst, + userContext, + deferredResolver = new DeferredResolverProvider(new SimpleToManyDeferredResolver, new SimpleManyModelDeferredResolver, skipPermissionCheck = true) + ) + } + + private def cleanAndAddObjectIdForAlgolia(rawQueryResult: JsValue): (String, JsObject) = { + val jsObject = rawQueryResult.asJsObject + val nodeId = jsObject.pathAsString("id") + val objectIdField = "objectID" -> JsString(nodeId) + + (nodeId, JsObject(jsObject.fields + objectIdField)) + } + + private def stringifyAndListifyPayload(value: JsValue): String = s"[${value.compactPrint}]" + + private def algoliaEventFor(payload: String, nodeId: String): AlgoliaEvent = { + AlgoliaEvent( + indexName = syncQuery.indexName, + applicationId = searchProviderAlgolia.applicationId, + apiKey = searchProviderAlgolia.apiKey, + operation = "create", + nodeId = nodeId, + requestId = requestId, + queryResult = payload + ) + } + + private def logMutaction(result: PutRecordResult) = { + logger.info( + LogData( + key = LogKey.AlgoliaSyncQuery, + requestId = requestId, + payload = Some(Map("kinesis" -> Map("sequence_number" -> result.getSequenceNumber, "shard_id" -> result.getShardId))) + ).json + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/UpdateDataItem.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/UpdateDataItem.scala new file mode 100644 index 0000000000..1076f999b5 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/UpdateDataItem.scala @@ -0,0 +1,120 @@ +package cool.graph.client.mutactions + +import java.sql.SQLIntegrityConstraintViolationException + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.database.DatabaseMutationBuilder +import cool.graph.client.database.GetFieldFromSQLUniqueException.getField +import cool.graph.client.mutactions.validation.InputValueValidation +import cool.graph.client.mutations.CoolArgs +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.shared.RelationFieldMirrorColumn +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Model, Project, RequestPipelineOperation} +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import scaldi.Injector +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +case class UpdateDataItem(project: Project, + model: Model, + id: Id, + values: List[ArgumentValue], + previousValues: DataItem, + requestId: Option[String] = None, + originalArgs: Option[CoolArgs] = None, + itemExists: Boolean)(implicit val inj: Injector) + extends ClientSqlMutaction { + + val pipelineRunner = new RequestPipelineRunner(requestId.getOrElse("")) + + // TODO filter for fields which actually did change + val namesOfUpdatedFields: List[String] = values.map(_.name) + + private def getFieldMirrors = { + val mirrors = model.fields + .flatMap(_.relation) + .flatMap(_.fieldMirrors) + .filter(mirror => model.fields.map(_.id).contains(mirror.fieldId)) + + mirrors + } + + override def execute: Future[ClientSqlStatementResult[Any]] = { + val mirrorUpdates = getFieldMirrors.flatMap(mirror => { + val relation = project.getRelationById_!(mirror.relationId) + val field = project.getFieldById_!(mirror.fieldId) + + values.find(_.name == field.name).map(_.value) match { + case Some(value) => + List( + DatabaseMutationBuilder.updateRelationRow( + project.id, + mirror.relationId, + relation.fieldSide(project, field).toString, + id, + Map(RelationFieldMirrorColumn.mirrorColumnName(project, field, relation) -> value) + )) + case None => List() + } + + }) + + val valuesIncludingId = values :+ ArgumentValue("id", id, model.getFieldByName_!("id")) + for { + transformedValues <- pipelineRunner + .runTransformArgument(project, model, RequestPipelineOperation.UPDATE, valuesIncludingId, originalArgs) + _ <- pipelineRunner.runPreWrite(project, model, RequestPipelineOperation.UPDATE, transformedValues, originalArgs) + } yield { + ClientSqlStatementResult( + sqlAction = DBIO.seq( + List( + DatabaseMutationBuilder + .updateDataItem(project.id, + model.name, + id, + transformedValues + .map(x => (x.name, x.value)) + .toMap)) ++ mirrorUpdates: _*)) + } + } + + override def handleErrors = { + implicit val anyFormat = JsonFormats.AnyJsonFormat + + Some({ + // https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html#error_er_dup_entry + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1062 => + UserAPIErrors.UniqueConstraintViolation(model.name, getField(values, e)) + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1452 => + UserAPIErrors.NodeDoesNotExist(id) + case e: SQLIntegrityConstraintViolationException if e.getErrorCode == 1048 => + UserAPIErrors.FieldCannotBeNull() + }) + } + + override def verify: Future[Try[MutactionVerificationSuccess]] = { + lazy val (dataItemInputValidation, fieldsWithValues) = InputValueValidation.validateDataItemInputs(model, id, values) + + def isReadonly(field: Field): Boolean = { + // todo: replace with readOnly property on Field + val isReadOnlyFileField = model.name == "File" && List("secret", "url", "contentType", "size").contains(field.name) + field.isReadonly || isReadOnlyFileField + } + + lazy val readonlyFields = fieldsWithValues.filter(isReadonly) + + val checkResult = itemExists match { + case false => Failure(UserAPIErrors.DataItemDoesNotExist(model.name, id)) + case _ if dataItemInputValidation.isFailure => dataItemInputValidation + case _ if readonlyFields.nonEmpty => Failure(UserAPIErrors.ReadonlyField(readonlyFields.mkString(","))) + case _ => Success(MutactionVerificationSuccess()) + + } + Future.successful(checkResult) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/ConstraintValueValidation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/ConstraintValueValidation.scala new file mode 100644 index 0000000000..d582fbc0bc --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/ConstraintValueValidation.scala @@ -0,0 +1,106 @@ +package cool.graph.client.mutactions.validation + +import cool.graph.shared.models._ + +import scala.util.matching.Regex + +object ConstraintValueValidation { + + case class ConstraintError(field: Field, value: Any, constraintType: String, arg: Any) + + def checkConstraintsOnField(f: Field, value: Any): List[ConstraintError] = { + f.constraints.flatMap { constraint => + checkConstraintOnField(f, constraint, value) + } + } + + def checkConstraintOnField(f: Field, constraint: FieldConstraint, value: Any): List[ConstraintError] = { + if (f.isList) { + val values = value.asInstanceOf[Vector[Any]].toList + + constraint match { + case constraint: StringConstraint => values.flatMap(v => checkStringConstraint(f, v, constraint)) + case constraint: NumberConstraint => values.flatMap(v => checkNumberConstraint(f, v, constraint)) + case constraint: BooleanConstraint => values.flatMap(v => checkBooleanConstraint(f, v, constraint)) + case constraint: ListConstraint => checkListConstraint(f, values, constraint) + } + } else { + constraint match { + case constraint: StringConstraint => checkStringConstraint(f, value, constraint) + case constraint: NumberConstraint => checkNumberConstraint(f, value, constraint) + case constraint: BooleanConstraint => checkBooleanConstraint(f, value, constraint) + case constraint: ListConstraint => List(ConstraintError(f, value, "Not a List-Field", "")) + } + } + } + + def checkStringConstraint(f: Field, value: Any, constraint: StringConstraint): List[ConstraintError] = { + def regexFound(regex: String, value: String): Boolean = { (new Regex(regex) findAllIn value).nonEmpty } + + value match { + case v: String => + val oneOfStringError = + if (constraint.oneOfString.nonEmpty && !constraint.oneOfString.contains(v)) + List(ConstraintError(f, v, "oneOfString", constraint.oneOfString.toString)) + else List.empty + + oneOfStringError ++ List( + constraint.equalsString.collect { case x if x != v => ConstraintError(f, v, "equalsString", x) }, + constraint.minLength.collect { case x if x > v.length => ConstraintError(f, v, "minLength", x) }, + constraint.maxLength.collect { case x if x < v.length => ConstraintError(f, v, "maxLength", x) }, + constraint.startsWith.collect { case x if !v.startsWith(x) => ConstraintError(f, v, "startsWith", x) }, + constraint.endsWith.collect { case x if !v.endsWith(x) => ConstraintError(f, v, "endsWith", x) }, + constraint.includes.collect { case x if !v.contains(x) => ConstraintError(f, v, "includes", x) }, + constraint.regex.collect { case x if !regexFound(x, v) => ConstraintError(f, v, "regex", x) } + ).flatten + + case _ => List(ConstraintError(f, value, "not a String", "")) + } + } + + def checkNumberConstraint(field: Field, value: Any, constraint: NumberConstraint): List[ConstraintError] = { + def checkNumConstraint(f: Field, v: Double): List[ConstraintError] = { + val oneOfNumberError = + if (constraint.oneOfNumber.nonEmpty && !constraint.oneOfNumber.contains(v)) + List(ConstraintError(f, v, "oneOfNumber", constraint.oneOfNumber.toString)) + else List.empty + + oneOfNumberError ++ List( + constraint.equalsNumber.collect { case x if x != v => ConstraintError(f, v, "equalsNumber", x) }, + constraint.min.collect { case x if x > v => ConstraintError(f, v, "min", x) }, + constraint.max.collect { case x if x < v => ConstraintError(f, v, "max", x) }, + constraint.exclusiveMin.collect { case x if x >= v => ConstraintError(f, v, "exclusiveMin", x) }, + constraint.exclusiveMax.collect { case x if x <= v => ConstraintError(f, v, "exclusiveMax", x) }, + constraint.multipleOf.collect { case x if v % x != 0 => ConstraintError(f, v, "multipleOf", x) } + ).flatten + } + + value match { + case double: Double => checkNumConstraint(field, double) + case int: Int => checkNumConstraint(field, int.asInstanceOf[Double]) + case _ => List(ConstraintError(field, value, "not an Int or Float/Double", "")) + } + } + + def checkBooleanConstraint(f: Field, value: Any, constraint: BooleanConstraint): List[ConstraintError] = { + value match { + case v: Boolean => + List(constraint.equalsBoolean.collect { case x if x != v => ConstraintError(f, v, "equalsBoolean", x) }).flatten + case _ => List(ConstraintError(f, value, "not a Boolean", "")) + } + } + + def checkListConstraint(f: Field, value: Any, constraint: ListConstraint): List[ConstraintError] = { + def unique(list: List[Any]) = list.toSet.size == list.size + + value match { + case l: List[Any] => + List( + constraint.uniqueItems.collect { case x if !unique(l) => ConstraintError(f, l, "uniqueItems", "") }, + constraint.minItems.collect { case x if x > l.length => ConstraintError(f, l, "minItems", x) }, + constraint.maxItems.collect { case x if x < l.length => ConstraintError(f, l, "maxItems", x) } + ).flatten + case _ => List(ConstraintError(f, value, "not a List", "")) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/InputValueValidation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/InputValueValidation.scala new file mode 100644 index 0000000000..580cc7a5b0 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutactions/validation/InputValueValidation.scala @@ -0,0 +1,174 @@ +package cool.graph.client.mutactions.validation + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.mutactions.validation.ConstraintValueValidation._ +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.errors.UserAPIErrors.ValueTooLong +import cool.graph.shared.errors.UserInputErrors.InvalidValueForScalarType +import cool.graph.shared.models.{Field, Model, TypeIdentifier} +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.shared.schema.CustomScalarTypes +import cool.graph.shared.{DatabaseConstraints, NameConstraints} +import spray.json.JsonParser.ParsingException +import spray.json._ + +import scala.util.{Failure, Success, Try} + +object InputValueValidation { + + def validateDataItemInputs(model: Model, id: Id, values: List[ArgumentValue]): (Try[MutactionVerificationSuccess], List[Field]) = { + + val fieldsWithValues = InputValueValidation.fieldsWithValues(model, values) + val fieldsWithIllegallySizedValue = InputValueValidation.checkValueSize(values, fieldsWithValues) + lazy val extraValues = values.filter(v => !model.fields.exists(_.name == v.name) && v.name != "id") + lazy val constraintErrors = checkConstraints(values, fieldsWithValues.filter(_.constraints.nonEmpty)) + + val validationResult = () match { + case _ if !NameConstraints.isValidDataItemId(id) => Failure(UserAPIErrors.IdIsInvalid(id)) + case _ if extraValues.nonEmpty => Failure(UserAPIErrors.ExtraArguments(extraValues.map(_.name), model.name)) + case _ if fieldsWithIllegallySizedValue.nonEmpty => Failure(UserAPIErrors.ValueTooLong(fieldsWithIllegallySizedValue.head.name)) + case _ if constraintErrors.nonEmpty => Failure(UserAPIErrors.ConstraintViolated(constraintErrors)) + case _ => Success(MutactionVerificationSuccess()) + } + + (validationResult, fieldsWithValues) + } + + def validateRequiredScalarFieldsHaveValues(model: Model, input: List[ArgumentValue]) = { + val requiredFieldNames = model.fields + .filter(_.isRequired) + .filter(_.isScalar) + .filter(_.defaultValue.isEmpty) + .map(_.name) + .filter(name => name != "createdAt" && name != "updatedAt") + + val missingRequiredFieldNames = requiredFieldNames.filter(name => !input.map(_.name).contains(name)) + missingRequiredFieldNames + } + + def argumentValueTypeValidation(field: Field, value: Any): Any = { + + def parseOne(value: Any): Boolean = { + val result = (field.typeIdentifier, value) match { + case (TypeIdentifier.String, _: String) => true + case (TypeIdentifier.Int, x: BigDecimal) => x.isValidLong + case (TypeIdentifier.Int, _: Integer) => true + case (TypeIdentifier.Float, x: BigDecimal) => x.isDecimalDouble + case (TypeIdentifier.Float, _: Double) => true + case (TypeIdentifier.Float, _: Float) => true + case (TypeIdentifier.Boolean, _: Boolean) => true + case (TypeIdentifier.Password, _: String) => true + case (TypeIdentifier.DateTime, x) => CustomScalarTypes.parseDate(x.toString).isRight + case (TypeIdentifier.GraphQLID, x: String) => NameConstraints.isValidDataItemId(x) + case (TypeIdentifier.Enum, x: String) => NameConstraints.isValidEnumValueName(x) + case (TypeIdentifier.Json, x) => validateJson(x) + case _ => false + // relations not handled for now + } + result + } + + val validTypeForField = (field.isList, value) match { + case (_, None) => true + case (true, values: Vector[Any]) => values.map(parseOne).forall(identity) + case (false, singleValue) => parseOne(singleValue) + case _ => false + } + + if (!validTypeForField) throw UserAPIErrors.InputInvalid(value.toString, field.name, field.typeIdentifier.toString) + + } + + def validateJson(input: Any): Boolean = { + Try { input.toString } match { + case Failure(_) => + false + + case Success(string) => + Try { string.parseJson } match { + case Failure(_) => + false + + case Success(json) => + json match { + case _: JsArray => true + case _: JsObject => true + case _ => false + } + } + } + } + + def checkConstraints(values: List[ArgumentValue], updatedFields: List[Field]): String = { + val constraintErrors = updatedFields + .filter(field => values.exists(v => v.name == field.name && v.value != None)) + .flatMap(field => checkConstraintsOnField(field, values.filter(_.name == field.name).head.unwrappedValue)) + + constraintErrors + .map { error => + s" The inputvalue: '${error.value.toString}' violated the constraint '${error.constraintType}' with value: '${error.arg.toString} " + } + .mkString("\n") + } + + def checkValueSize(values: List[ArgumentValue], updatedFields: List[Field]): List[Field] = { + updatedFields + .filter(field => values.exists(v => v.name == field.name && v.value != None)) + .filter(field => !DatabaseConstraints.isValueSizeValid(values.filter(v => v.name == field.name).head.unwrappedValue, field)) + } + + def fieldsWithValues(model: Model, values: List[ArgumentValue]): List[Field] = { + model.fields.filter(field => values.exists(_.name == field.name)).filter(_.name != "id") + } + + def transformStringifiedJson(argValues: List[ArgumentValue], model: Model): List[ArgumentValue] = { + + def isJson(arg: ArgumentValue): Boolean = model.fields.exists(field => field.name == arg.name && field.typeIdentifier == TypeIdentifier.Json) + + def transformJson(argValue: ArgumentValue): ArgumentValue = { + + def tryParsingValueAsJson(x: JsString): JsValue = { + try { + x.value.parseJson + } catch { + case e: ParsingException => throw UserAPIErrors.ValueNotAValidJson(argValue.name, x.prettyPrint) + } + } + + def transformSingleJson(single: Any): JsValue = { + single match { + case x: JsString => tryParsingValueAsJson(x) + case x: JsObject => x + case x: JsArray => x + case x => throw UserAPIErrors.ValueNotAValidJson(argValue.name, x.toString) + } + } + + def transformListJson(list: Vector[Any]): Vector[JsValue] = list.map(transformSingleJson) + + val field = model.fields.find(_.name == argValue.name).getOrElse(sys.error("ArgumentValues need to have a field on the Model")) + val transformedValue = field.isList match { + case true => + argValue.value match { + case Some(x) => Some(transformListJson(x.asInstanceOf[Vector[Any]])) + case None => None + case x => Some(transformListJson(x.asInstanceOf[Vector[Any]])) + } + case false => + argValue.value match { + case Some(x) => Some(transformSingleJson(x)) + case None => None + case x => Some(transformSingleJson(x)) + } + } + argValue.copy(value = transformedValue) + } + + val argsWithoutJson = argValues.filter(!isJson(_)) + val argsWithJson = argValues.filter(isJson) + val argsWithEscapedJson = argsWithJson.map(transformJson) + + argsWithoutJson ++ argsWithEscapedJson + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/ActionWebhooks.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/ActionWebhooks.scala new file mode 100644 index 0000000000..2f8ce24b37 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/ActionWebhooks.scala @@ -0,0 +1,71 @@ +package cool.graph.client.mutations + +import cool.graph.Types.Id +import cool.graph.client.mutactions._ +import cool.graph.shared.models.{ActionTriggerMutationModelMutationType, Project} +import cool.graph.{DataItem, Mutaction} +import scaldi.Injector + +import scala.collection.immutable.Seq + +object ActionWebhooks { + def extractFromCreateMutactions(project: Project, mutactions: Seq[CreateDataItem], mutationId: Id, requestId: String)( + implicit inj: Injector): Seq[Mutaction] = { + for { + newItem <- mutactions + action <- project.actionsFor(newItem.model.id, ActionTriggerMutationModelMutationType.Create) + } yield { + if (action.handlerWebhook.get.isAsync) { + ActionWebhookForCreateDataItemAsync( + model = newItem.model, + project = project, + nodeId = newItem.id, + action = action, + mutationId = mutationId, + requestId = requestId + ) + } else { + ActionWebhookForCreateDataItemSync( + model = newItem.model, + project = project, + nodeId = newItem.id, + action = action, + mutationId = mutationId, + requestId = requestId + ) + } + } + } + + def extractFromUpdateMutactions(project: Project, mutactions: Seq[UpdateDataItem], mutationId: Id, requestId: String, previousValues: DataItem)( + implicit inj: Injector): Seq[Mutaction] = { + for { + updatedItem <- mutactions + action <- project.actionsFor(updatedItem.model.id, ActionTriggerMutationModelMutationType.Update) + } yield { + if (action.handlerWebhook.get.isAsync) { + ActionWebhookForUpdateDataItemAsync( + model = updatedItem.model, + project = project, + nodeId = updatedItem.id, + action = action, + updatedFields = updatedItem.namesOfUpdatedFields, + mutationId = mutationId, + requestId = requestId, + previousValues = previousValues + ) + } else { + ActionWebhookForUpdateDataItemSync( + model = updatedItem.model, + project = project, + nodeId = updatedItem.id, + action = action, + updatedFields = updatedItem.namesOfUpdatedFields, + mutationId = mutationId, + requestId = requestId, + previousValues = previousValues + ) + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/AddToRelation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/AddToRelation.scala new file mode 100644 index 0000000000..aded620b8a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/AddToRelation.scala @@ -0,0 +1,60 @@ +package cool.graph.client.mutations + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.authorization.RelationMutationPermissions +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.AddToRelationDefinition +import cool.graph.shared.models._ +import sangria.schema +import scaldi._ + +import scala.concurrent.Future + +class AddToRelation(relation: Relation, fromModel: Model, project: Project, args: schema.Args, dataResolver: DataResolver, argumentSchema: ArgumentSchema)( + implicit inj: Injector) + extends ClientMutation(fromModel, args, dataResolver, argumentSchema) { + + override val mutationDefinition = AddToRelationDefinition(relation, project, argumentSchema) + + var fromId: Id = extractIdFromScalarArgumentValues_!(args, mutationDefinition.bName) + + val aField: Option[Field] = relation.getModelAField(project) + val bField: Option[Field] = relation.getModelBField(project) + + def prepareMutactions(): Future[List[MutactionGroup]] = { + val toId = extractIdFromScalarArgumentValues_!(args, mutationDefinition.aName) + + var sqlMutactions = List[ClientSqlMutaction]() + + if (aField.isDefined && !aField.get.isList) { + sqlMutactions :+= RemoveDataItemFromRelationByField(project.id, relation.id, aField.get, fromId) + } + + if (bField.isDefined && !bField.get.isList) { + sqlMutactions :+= RemoveDataItemFromRelationByField(project.id, relation.id, bField.get, toId) + } + + sqlMutactions :+= AddDataItemToManyRelation(project, fromModel, relation.getModelAField_!(project), toId, fromId) + + // note: for relations between same model, same field we add a relation row for both directions + if (aField == bField) { + sqlMutactions :+= AddDataItemToManyRelation(project, fromModel, relation.getModelAField_!(project), fromId, toId) + } + + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + Future.successful( + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + // dummy mutaction group for actions to satisfy tests. Please implement actions :-) + MutactionGroup(mutactions = List(), async = true) + )) + } + + override def getReturnValue: Future[ReturnValueResult] = returnValueById(fromModel, fromId) + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/AlgoliaSyncQueries.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/AlgoliaSyncQueries.scala new file mode 100644 index 0000000000..ce529fed87 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/AlgoliaSyncQueries.scala @@ -0,0 +1,35 @@ +package cool.graph.client.mutations + +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions.SyncDataItemToAlgolia +import cool.graph.shared.models._ +import scaldi.Injector + +object AlgoliaSyncQueries { + def extract(dataResolver: DataResolver, project: Project, model: Model, nodeId: String, operation: String)( + implicit inj: Injector): List[SyncDataItemToAlgolia] = { + project.integrations + .filter(_.isEnabled) + .filter(_.integrationType == IntegrationType.SearchProvider) + .filter(_.name == IntegrationName.SearchProviderAlgolia) + .collect { + case searchProviderAlgolia: SearchProviderAlgolia => + searchProviderAlgolia.algoliaSyncQueries + } + .flatten + .filter(_.isEnabled) + .filter(_.model.id == model.id) + .map(syncQuery => + SyncDataItemToAlgolia( + model = model, + project = project, + nodeId = nodeId, + syncQuery = syncQuery, + searchProviderAlgolia = project + .getSearchProviderAlgoliaByAlgoliaSyncQueryId(syncQuery.id) + .get, + requestId = dataResolver.requestContext.map(_.requestId).getOrElse(""), + operation = operation + )) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/CoolArgs.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/CoolArgs.scala new file mode 100644 index 0000000000..46f8169413 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/CoolArgs.scala @@ -0,0 +1,132 @@ +package cool.graph.client.mutations + +import cool.graph.client.authorization.PermissionQueryArg +import cool.graph.client.mutations.definitions.UpdateDefinition +import cool.graph.shared.models._ +import cool.graph.util.coolSangria.Sangria +import cool.graph.{ArgumentSchema, ClientMutationDefinition, CreateOrUpdateMutationDefinition, DataItem} + +import scala.collection.immutable.Seq + +/** + * It's called CoolArgs to easily differentiate from Sangrias Args class. + */ +case class CoolArgs(raw: Map[String, Any], argumentSchema: ArgumentSchema, model: Model, project: Project) { + private val sangriaArgs = Sangria.rawArgs(raw) + + def subArgsList(field: Field): Option[Seq[CoolArgs]] = { + val subModel = field.relatedModel(project).get + val fieldValues: Option[Seq[Map[String, Any]]] = field.isList match { + case true => getFieldValuesAs[Map[String, Any]](field) + case false => getFieldValueAsSeq[Map[String, Any]](field.name) + } + + fieldValues match { + case None => None + case Some(x) => Some(x.map(CoolArgs(_, argumentSchema, subModel, project))) + } + } + + def hasArgFor(field: Field) = raw.get(field.name).isDefined + + def fields: Seq[Field] = { + for { + field <- model.fields + if hasArgFor(field) + } yield field + } + + def fieldsThatRequirePermissionCheckingInMutations = { + fields.filter(_.name != "id") + } + + /** + * The outer option is defined if the field key was specified in the arguments at all. + * The inner option is empty if a null value was sent for this field. If the option is defined it contains a non null value + * for this field. + */ + def getFieldValueAs[T](field: Field, suffix: String = ""): Option[Option[T]] = { + getFieldValueAs(field.name + suffix) + } + + def getFieldValueAs[T](name: String): Option[Option[T]] = { + raw.get(name).map { fieldValue => + try { + fieldValue.asInstanceOf[Option[T]] + } catch { + case _: ClassCastException => + Option(fieldValue.asInstanceOf[T]) + } + } + } + + def getFieldValueAsSeq[T](name: String): Option[Seq[T]] = { + raw.get(name).map { fieldValue => + try { + fieldValue.asInstanceOf[Option[T]] match { + case Some(x) => Seq(x) + case None => Seq.empty + + } + } catch { + case _: ClassCastException => + Seq(fieldValue.asInstanceOf[T]) + } + } + } + + /** + * The outer option is defined if the field key was specified in the arguments at all. + * The inner sequence then contains all the values specified. + */ + def getFieldValuesAs[T](field: Field, suffix: String = ""): Option[Seq[T]] = { + raw.get(field.name + suffix).map { fieldValue => + try { + fieldValue.asInstanceOf[Option[Seq[T]]].getOrElse(Seq.empty) + } catch { + case _: ClassCastException => + fieldValue.asInstanceOf[Seq[T]] + } + } + } + + def permissionQueryArgsForNewAndOldFieldValues(updateDefinition: UpdateDefinition, existingNode: Option[DataItem]): List[PermissionQueryArg] = { + val thePermissionQueryArgsForNewFieldValues = permissionQueryArgsForNewFieldValues(updateDefinition) + + val permissionQueryArgsForOldFieldValues = existingNode match { + case Some(existingNode) => + model.scalarFields.flatMap { field => + List( + PermissionQueryArg(s"$$old_${field.name}", existingNode.getOption(field.name).getOrElse(""), field.typeIdentifier), + PermissionQueryArg(s"$$node_${field.name}", existingNode.getOption(field.name).getOrElse(""), field.typeIdentifier) + ) + } + case None => + List.empty + } + + thePermissionQueryArgsForNewFieldValues ++ permissionQueryArgsForOldFieldValues + } + + def permissionQueryArgsForNewFieldValues(mutationDefinition: CreateOrUpdateMutationDefinition): List[PermissionQueryArg] = { + val scalarArgumentValues = argumentSchema.extractArgumentValues(sangriaArgs, mutationDefinition.getScalarArguments(model)) + + val scalarPermissionQueryArgs = scalarArgumentValues.flatMap { argumentValue => + List( + PermissionQueryArg(s"$$new_${argumentValue.field.get.name}", argumentValue.value, argumentValue.field.get.typeIdentifier), + PermissionQueryArg(s"$$input_${argumentValue.field.get.name}", argumentValue.value, argumentValue.field.get.typeIdentifier) + ) + } + + val relationalArgumentValues = argumentSchema.extractArgumentValues(sangriaArgs, mutationDefinition.getRelationArguments(model)) + + val singleRelationPermissionQueryArgs: Seq[PermissionQueryArg] = relationalArgumentValues.flatMap { argumentValue => + List( + PermissionQueryArg(s"$$new_${argumentValue.field.get.name}Id", argumentValue.value, TypeIdentifier.GraphQLID), + PermissionQueryArg(s"$$input_${argumentValue.field.get.name}Id", argumentValue.value, TypeIdentifier.GraphQLID) + ) + } + + scalarPermissionQueryArgs ++ singleRelationPermissionQueryArgs + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/Create.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/Create.scala new file mode 100644 index 0000000000..f21479e466 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/Create.scala @@ -0,0 +1,120 @@ +package cool.graph.client.mutations + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.authorization.{ModelPermissions, PermissionValidator, RelationMutationPermissions} +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.CreateDefinition +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.cuid.Cuid +import cool.graph.shared.models._ +import sangria.schema +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +class Create(model: Model, + project: Project, + args: schema.Args, + dataResolver: DataResolver, + argumentSchema: ArgumentSchema, + allowSettingManagedFields: Boolean = false)(implicit inj: Injector) + extends ClientMutation(model, args, dataResolver, argumentSchema) + with Injectable { + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + + override val mutationDefinition = CreateDefinition(argumentSchema, project, InputTypesBuilder(project, argumentSchema)) + + val permissionValidator: PermissionValidator = new PermissionValidator(project) + val id: Id = Cuid.createCuid() + val requestId: String = dataResolver.requestContext.map(_.requestId).getOrElse("") + val pipelineRunner = new RequestPipelineRunner(requestId) + + val coolArgs: CoolArgs = { + val argsPointer: Map[String, Any] = args.raw.get("input") match { // TODO: input token is probably relay specific? + case Some(value) => value.asInstanceOf[Map[String, Any]] + case None => args.raw + } + + CoolArgs(argsPointer, argumentSchema, model, project) + } + + def prepareMutactions(): Future[List[MutactionGroup]] = { + val createMutactionsResult = + SqlMutactions(dataResolver).getMutactionsForCreate(project, model, coolArgs, allowSettingManagedFields, id, requestId = requestId) + + val transactionMutaction = Transaction(createMutactionsResult.allMutactions, dataResolver) + val algoliaSyncQueryMutactions = AlgoliaSyncQueries.extract(dataResolver, project, model, id, "create") + val createMutactions = createMutactionsResult.allMutactions.collect { case x: CreateDataItem => x } + + val actionMutactions = ActionWebhooks.extractFromCreateMutactions( + project = project, + mutactions = createMutactions, + mutationId = mutationId, + requestId = requestId + ) + + val subscriptionMutactions = SubscriptionEvents.extractFromSqlMutactions(project, mutationId, createMutactionsResult.allMutactions) + val sssActions = ServerSideSubscription.extractFromMutactions(project, createMutactionsResult.allMutactions, requestId) + + Future.successful( + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + MutactionGroup(mutactions = actionMutactions.toList ++ sssActions ++ algoliaSyncQueryMutactions ++ subscriptionMutactions, async = true) + )) + + } + + override def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] = { + val normalPermissions = ModelPermissions.checkPermissionsForCreate(model, coolArgs, authenticatedRequest, project) + + def checkCustomPermissionsForField(field: Field): Future[Boolean] = { + val filteredPermissions = model.permissions + .filter(_.isActive) + .filter(_.operation == ModelOperation.Create) + .filter(p => p.applyToWholeModel || p.fieldIds.contains(field.id)) + + permissionValidator.checkModelQueryPermissions( + project, + filteredPermissions, + authenticatedRequest, + "not-the-id", + coolArgs.permissionQueryArgsForNewFieldValues(mutationDefinition), + alwaysQueryMasterDatabase = true + ) + } + if (normalPermissions) { + Future.successful(true) + } else { + Future + .sequence(coolArgs.fieldsThatRequirePermissionCheckingInMutations.map(checkCustomPermissionsForField)) + .map { x => + x.nonEmpty && x.forall(identity) + } + } + } + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + override def getReturnValue: Future[ReturnValueResult] = { + for { + returnValue <- returnValueById(model, id) + dataItem = returnValue.asInstanceOf[ReturnValue].dataItem + transformedResult <- pipelineRunner.runTransformPayload(project = project, + model = model, + operation = RequestPipelineOperation.CREATE, + values = RequestPipelineRunner.dataItemToArgumentValues(dataItem, model)) + } yield { + ReturnValue(RequestPipelineRunner.argumentValuesToDataItem(transformedResult, dataItem.id, model)) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/Delete.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/Delete.scala new file mode 100644 index 0000000000..b5785e1a97 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/Delete.scala @@ -0,0 +1,164 @@ +package cool.graph.client.mutations + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.client.authorization.{ModelPermissions, PermissionQueryArg, PermissionValidator, RelationMutationPermissions} +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.DeleteDefinition +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.models.{Action => ModelAction, _} +import sangria.schema +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Success + +class Delete[ManyDataItemType](model: Model, + modelObjectTypes: SchemaModelObjectTypesBuilder[ManyDataItemType], + project: Project, + args: schema.Args, + dataResolver: DataResolver, + argumentSchema: ArgumentSchema)(implicit inj: Injector) + extends ClientMutation(model, args, dataResolver, argumentSchema) + with Injectable { + + override val mutationDefinition = DeleteDefinition(argumentSchema, project) + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val permissionValidator = new PermissionValidator(project) + + val id: Id = extractIdFromScalarArgumentValues_!(args, "id") + + var deletedItem: Option[DataItem] = None + val requestId: Id = dataResolver.requestContext.map(_.requestId).getOrElse("") + + val pipelineRunner = new RequestPipelineRunner(requestId) + + override def prepareMutactions(): Future[List[MutactionGroup]] = { + dataResolver + .resolveByModelAndIdWithoutValidation(model, id) + .andThen { + case Success(x) => deletedItem = x.map(dataItem => dataItem.copy(userData = GraphcoolDataTypes.fromSql(dataItem.userData, model.fields))) + } + .flatMap(_ => { + + val sqlMutactions = SqlMutactions(dataResolver).getMutactionsForDelete(model, project, id, deletedItem.getOrElse(DataItem(id))) + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + + val actionMutactions: List[ActionWebhookForDeleteDataItem] = extractActions + + // beware: ActionWebhookForDeleteDataItem requires prepareData to be awaited before being executed + Future + .sequence(actionMutactions.map(_.prepareData)) + .map(_ => { + val algoliaSyncQueryMutactions = AlgoliaSyncQueries.extract(dataResolver, project, model, id, "delete") + + val nodeData: Map[String, Any] = deletedItem + .map(_.userData) + .getOrElse(Map.empty[String, Option[Any]]) + .collect { + case (key, Some(value)) => (key, value) + } + ("id" -> id) + + val subscriptionMutactions = SubscriptionEvents.extractFromSqlMutactions(project, mutationId, sqlMutactions) + + val sssActions = ServerSideSubscription.extractFromMutactions(project, sqlMutactions, requestId) + + val fileMutaction: List[S3DeleteFIle] = model.name match { + case "File" => List(S3DeleteFIle(model, project, nodeData("secret").asInstanceOf[String])) + case _ => List() + } + + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + MutactionGroup(mutactions = actionMutactions ++ sssActions ++ algoliaSyncQueryMutactions ++ fileMutaction ++ subscriptionMutactions, async = true) + ) + }) + + }) + } + + private def generatePermissionQueryArguments(existingNode: Option[DataItem]) = { + model.scalarFields.flatMap( + field => + List( + PermissionQueryArg(s"$$old_${field.name}", existingNode.flatMap(_.getOption(field.name)).getOrElse(""), field.typeIdentifier), + PermissionQueryArg(s"$$node_${field.name}", existingNode.flatMap(_.getOption(field.name)).getOrElse(""), field.typeIdentifier) + )) + } + + override def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] = { + def normalPermissions = ModelPermissions.checkPermissionsForDelete(model, authenticatedRequest, project) + + def customPermissions = { + val filteredPermissions = model.permissions + .filter(_.isActive) + .filter(_.operation == ModelOperation.Delete) + dataResolver + .resolveByModelAndIdWithoutValidation(model, id) + .flatMap(existingNode => { + permissionValidator.checkModelQueryPermissions(project, + filteredPermissions, + authenticatedRequest, + id, + generatePermissionQueryArguments(existingNode), + alwaysQueryMasterDatabase = true) + }) + } + + normalPermissions match { + case true => Future.successful(true) + case false => customPermissions + } + } + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + override def getReturnValue: Future[ReturnValueResult] = { + val dataItem = deletedItem.get + for { + transformedResult <- pipelineRunner.runTransformPayload(project = project, + model = model, + operation = RequestPipelineOperation.DELETE, + values = RequestPipelineRunner.dataItemToArgumentValues(dataItem, model)) + } yield { + ReturnValue(RequestPipelineRunner.argumentValuesToDataItem(transformedResult, dataItem.id, model)) + } + } + + private def extractActions: List[ActionWebhookForDeleteDataItem] = { + project.actions + .filter(_.isActive) + .filter(_.triggerMutationModel.exists(_.modelId == model.id)) + .filter(_.triggerMutationModel.exists(_.mutationType == ActionTriggerMutationModelMutationType.Delete)) + .map { + case action if action.handlerWebhook.get.isAsync => + ActionWebhookForDeleteDataItemAsync( + model = model, + project = project, + nodeId = id, + action = action, + mutationId = mutationId, + requestId = requestId + ) + case action if !action.handlerWebhook.get.isAsync => + ActionWebhookForDeleteDataItemSync( + model = model, + project = project, + nodeId = id, + action = action, + mutationId = mutationId, + requestId = requestId + ) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/RemoveFromRelation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/RemoveFromRelation.scala new file mode 100644 index 0000000000..88d843b6a6 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/RemoveFromRelation.scala @@ -0,0 +1,63 @@ +package cool.graph.client.mutations + +import cool.graph.Types.Id +import cool.graph.client.authorization.RelationMutationPermissions +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.RemoveFromRelationDefinition +import cool.graph.shared.models._ +import cool.graph.{_} +import sangria.schema +import scaldi._ + +import scala.concurrent.Future + +class RemoveFromRelation(relation: Relation, fromModel: Model, project: Project, args: schema.Args, dataResolver: DataResolver, argumentSchema: ArgumentSchema)( + implicit inj: Injector) + extends ClientMutation(fromModel, args, dataResolver, argumentSchema) { + + override val mutationDefinition = RemoveFromRelationDefinition(relation, project, argumentSchema) + + var aId: Id = extractIdFromScalarArgumentValues_!(args, mutationDefinition.bName) + + def prepareMutactions(): Future[List[MutactionGroup]] = { + + val aField = relation.getModelAField_!(project) + val bField = relation.getModelBField_!(project) + + val bId = extractIdFromScalarArgumentValues_!(args, mutationDefinition.aName) + + var sqlMutactions = List[ClientSqlMutaction]() + + sqlMutactions :+= + RemoveDataItemFromRelationByToAndFromField(project = project, relationId = relation.id, aField = aField, aId = aId, bField = bField, bId = bId) + + // note: for relations between same model, same field we add a relation row for both directions + if (aField == bField) { + sqlMutactions :+= + RemoveDataItemFromRelationByToAndFromField(project = project, relationId = relation.id, aField = bField, aId = bId, bField = aField, bId = aId) + } + + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + + Future.successful( + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + // dummy mutaction group for actions to satisfy tests. Please implement actions :-) + MutactionGroup(mutactions = List(), async = true) + )) + } + + override def getReturnValue: Future[ReturnValueResult] = returnValueById(fromModel, aId) + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + private def extractActions: List[Action] = { + project.actions + .filter(_.isActive) + .filter(_.triggerMutationModel.exists(_.modelId == fromModel.id)) + .filter(_.triggerMutationModel.exists(_.mutationType == ActionTriggerMutationModelMutationType.Create)) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/SetRelation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/SetRelation.scala new file mode 100644 index 0000000000..6e3eaa9544 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/SetRelation.scala @@ -0,0 +1,75 @@ +package cool.graph.client.mutations + +import cool.graph.Types.Id +import cool.graph.shared.errors.UserAPIErrors.RelationIsRequired +import cool.graph._ +import cool.graph.client.authorization.RelationMutationPermissions +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.SetRelationDefinition +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.InvalidInput +import sangria.schema +import scaldi._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class SetRelation(relation: Relation, fromModel: Model, project: Project, args: schema.Args, dataResolver: DataResolver, argumentSchema: ArgumentSchema)( + implicit inj: Injector) + extends ClientMutation(fromModel, args, dataResolver, argumentSchema) { + + override val mutationDefinition = SetRelationDefinition(relation, project, argumentSchema) + + val fromId: Id = extractIdFromScalarArgumentValues_!(args, mutationDefinition.bName) + val toId: Id = extractIdFromScalarArgumentValues_!(args, mutationDefinition.aName) + + def prepareMutactions(): Future[List[MutactionGroup]] = { + + val sqlMutactions = List( + RemoveDataItemFromRelationById(project, relation.id, fromId), + RemoveDataItemFromRelationById(project, relation.id, toId), + AddDataItemToManyRelation(project, fromModel, relation.getModelAField_!(project), toId, fromId) + ) + + val field = project.getModelById_!(fromModel.id).relationFields.find(_.relation.get == relation).get + val relatedField = field.relatedFieldEager(project) + val relatedModel = field.relatedModel_!(project) + + val checkFrom = + InvalidInput(RelationIsRequired(fieldName = relatedField.name, typeName = relatedModel.name), requiredOneRelationCheck(field, relatedField, fromId, toId)) + + val checkTo = + InvalidInput(RelationIsRequired(fieldName = field.name, typeName = fromModel.name), requiredOneRelationCheck(relatedField, field, toId, fromId)) + + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + + Future.successful( + List( + MutactionGroup(mutactions = List(checkFrom, checkTo, transactionMutaction), async = false), + // todo: dummy mutaction group for actions to satisfy tests. Please implement actions :-) + MutactionGroup(mutactions = List(), async = true) + )) + } + + override def getReturnValue: Future[ReturnValueResult] = returnValueById(fromModel, fromId) + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + def requiredOneRelationCheck(field: Field, relatedField: Field, fromId: String, toId: String): Future[Boolean] = { + relatedField.isRequired && !relatedField.isList match { + case true => + dataResolver.resolveByRelation(fromField = field, fromModelId = fromId, args = None).map { resolverResult => + val items = resolverResult.items + items.isEmpty match { + case true => false + case false => items.head.id != toId + } + } + case false => Future.successful(false) + } + } + +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/SqlMutactions.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/SqlMutactions.scala new file mode 100644 index 0000000000..dfc6cebe42 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/SqlMutactions.scala @@ -0,0 +1,282 @@ +package cool.graph.client.mutations + +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.Types.Id +import cool.graph.shared.errors.UserAPIErrors.RelationIsRequired +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.schema.SchemaBuilderConstants +import cool.graph.cuid.Cuid.createCuid +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Field, Model, Project} +import cool.graph.shared.mutactions.InvalidInputClientSqlMutaction +import cool.graph.{ClientSqlMutaction, DataItem} +import scaldi.Injector + +import scala.collection.immutable.Seq +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class SqlMutactions(dataResolver: DataResolver) { + case class ParentInfo(model: Model, field: Field, id: Id) + case class CreateMutactionsResult(createMutaction: CreateDataItem, nestedMutactions: Seq[ClientSqlMutaction]) { + def allMutactions: List[ClientSqlMutaction] = List(createMutaction) ++ nestedMutactions + } + + def getMutactionsForDelete(model: Model, project: Project, id: Id, previousValues: DataItem)(implicit inj: Injector): List[ClientSqlMutaction] = { + + val requiredRelationViolations = model.relationFields.flatMap(field => { checkIfRemovalWouldFailARequiredRelation(field, id, project) }) + val removeFromConnectionMutactions = model.relationFields.map(field => RemoveDataItemFromManyRelationByToId(project.id, field, id)) + val deleteItemMutaction = DeleteDataItem(project, model, id, previousValues) + + requiredRelationViolations ++ removeFromConnectionMutactions ++ List(deleteItemMutaction) + } + + def getMutactionsForUpdate(project: Project, model: Model, args: CoolArgs, id: Id, previousValues: DataItem, requestId: String)( + implicit inj: Injector): List[ClientSqlMutaction] = { + + val updateMutaction = getUpdateMutaction(project, model, args, id, previousValues) + val forFlatManyRelations = getAddToRelationMutactionsForIdListsForUpdate(project, model, args, fromId = id) + val forFlatOneRelation = getAddToRelationMutactionsForIdFieldsForUpdate(project, model, args, fromId = id) + val forComplexMutactions = getComplexMutactions(project, model, args, fromId = id, requestId = requestId) + + updateMutaction.toList ++ forFlatManyRelations ++ forComplexMutactions ++ forFlatOneRelation + } + + def getMutactionsForCreate(project: Project, + model: Model, + args: CoolArgs, + allowSettingManagedFields: Boolean, + id: Id = createCuid(), + parentInfo: Option[ParentInfo] = None, + requestId: String)(implicit inj: Injector): CreateMutactionsResult = { + + val createMutaction = getCreateMutaction(project, model, args, id, allowSettingManagedFields, requestId) + val forFlatManyRelations = getAddToRelationMutactionsForIdListsForCreate(project, model, args, fromId = createMutaction.id) + val forFlatOneRelation = getAddToRelationMutactionsForIdFieldsForCreate(project, model, args, fromId = createMutaction.id) + val forComplexRelations = getComplexMutactions(project, model, args, fromId = createMutaction.id, requestId = requestId) + + val relationToParent = parentInfo.map { parent => + AddDataItemToManyRelation(project = project, fromModel = parent.model, fromField = parent.field, fromId = parent.id, toId = id, toIdAlreadyInDB = false) + } + + val requiredOneRelationFields = model.relationFields.filter(f => f.isRequired && !f.isList) + val requiredRelationViolations = requiredOneRelationFields + .filter { field => + val isRelatedById = args.getFieldValueAs(field, suffix = SchemaBuilderConstants.idSuffix).flatten.isDefined + val isRelatedByComplex = args.getFieldValueAs(field).flatten.isDefined + val isRelatedToParent = parentInfo match { + case None => false + case Some(parent) => parent.field.relation.map(_.id) == field.relation.map(_.id) + } + !isRelatedById && !isRelatedByComplex && !isRelatedToParent + } + .map(field => InvalidInputClientSqlMutaction(RelationIsRequired(field.name, model.name))) + + val nestedMutactions: Seq[ClientSqlMutaction] = forFlatManyRelations ++ forComplexRelations ++ forFlatOneRelation ++ relationToParent + + val correctExecutionOrder = nestedMutactions.sortWith { (x, _) => + x.isInstanceOf[RemoveDataItemFromManyRelationByFromId] + } + + val result = CreateMutactionsResult(createMutaction = createMutaction, nestedMutactions = correctExecutionOrder ++ requiredRelationViolations) + result + } + + def getCreateMutaction(project: Project, model: Model, args: CoolArgs, id: Id, allowSettingManagedFields: Boolean, requestId: String)( + implicit inj: Injector): CreateDataItem = { + val scalarArguments = for { + field <- model.scalarFields + fieldValue <- args.getFieldValueAs[Any](field) + } yield { + ArgumentValue(field.name, fieldValue, field) + } + + def checkNullInputOnRequiredFieldWithDefaultValue(x: ArgumentValue) = + if (x.field.get.isRequired && x.value == None && x.field.get.defaultValue.isDefined) throw UserAPIErrors.InputInvalid("null", x.name, model.name) + scalarArguments.map(checkNullInputOnRequiredFieldWithDefaultValue) + + CreateDataItem( + project = project, + model = model, + values = scalarArguments :+ ArgumentValue("id", id, model.getFieldByName("id")), + allowSettingManagedFields = allowSettingManagedFields, + requestId = Some(requestId), + originalArgs = Some(args) + ) + } + + def getUpdateMutaction(project: Project, model: Model, args: CoolArgs, id: Id, previousValues: DataItem)(implicit inj: Injector): Option[UpdateDataItem] = { + val scalarArguments = for { + field <- model.scalarFields.filter(_.name != "id") + fieldValue <- args.getFieldValueAs[Any](field) + } yield { + ArgumentValue(field.name, fieldValue, field) + } + if (scalarArguments.nonEmpty) { + Some( + UpdateDataItem(project = project, + model = model, + id = id, + values = scalarArguments, + originalArgs = Some(args), + previousValues = previousValues, + itemExists = true)) + } else None + } + + def getAddToRelationMutactionsForIdListsForCreate(project: Project, model: Model, args: CoolArgs, fromId: Id): Seq[ClientSqlMutaction] = { + val x = for { + field <- model.relationFields if field.isList + toIds <- args.getFieldValuesAs[Id](field, SchemaBuilderConstants.idListSuffix) + } yield { + + val removeOldToRelations: List[ClientSqlMutaction] = if (field.isOneToManyRelation(project)) { + toIds.map(toId => Some(RemoveDataItemFromManyRelationByToId(project.id, field, toId))).toList.flatten + } else List() + + val relationsToAdd = toIds.map { toId => + AddDataItemToManyRelation(project = project, fromModel = model, fromField = field, fromId = fromId, toId = toId) + } + removeOldToRelations ++ relationsToAdd + } + x.flatten + } + + def getAddToRelationMutactionsForIdListsForUpdate(project: Project, model: Model, args: CoolArgs, fromId: Id): Seq[ClientSqlMutaction] = { + val x = for { + field <- model.relationFields if field.isList + toIds <- args.getFieldValuesAs[Id](field, SchemaBuilderConstants.idListSuffix) + } yield { + + val removeOldFromRelation = List(checkIfUpdateWouldFailARequiredManyRelation(field, fromId, toIds.toList, project), + Some(RemoveDataItemFromManyRelationByFromId(project.id, field, fromId))).flatten + + val removeOldToRelations: List[ClientSqlMutaction] = if (field.isOneToManyRelation(project)) { + toIds.map(toId => RemoveDataItemFromManyRelationByToId(project.id, field, toId)).toList + } else List() + + val relationsToAdd = toIds.map { toId => + AddDataItemToManyRelation(project = project, fromModel = model, fromField = field, fromId = fromId, toId = toId) + } + removeOldFromRelation ++ removeOldToRelations ++ relationsToAdd + } + x.flatten + } + + def getAddToRelationMutactionsForIdFieldsForCreate(project: Project, model: Model, args: CoolArgs, fromId: Id): Seq[ClientSqlMutaction] = { + val x: Seq[Iterable[ClientSqlMutaction]] = for { + field <- model.relationFields if !field.isList + toIdOpt <- args.getFieldValueAs[String](field, suffix = SchemaBuilderConstants.idSuffix) + } yield { + + val removeOldToRelation: List[ClientSqlMutaction] = if (field.isOneToOneRelation(project)) { + toIdOpt + .map { toId => + List( + Some(RemoveDataItemFromManyRelationByToId(project.id, field, toId)), + checkIfRemovalWouldFailARequiredRelation(field.relatedFieldEager(project), toId, project) + ).flatten + } + .getOrElse(List.empty) + } else List() + + val addToRelation = toIdOpt.map { toId => + AddDataItemToManyRelation(project = project, fromModel = model, fromField = field, fromId = fromId, toId = toId) + } + // FIXME: removes must be first here; How could we make that clearer? + removeOldToRelation ++ addToRelation + } + x.flatten + } + + def getAddToRelationMutactionsForIdFieldsForUpdate(project: Project, model: Model, args: CoolArgs, fromId: Id): Seq[ClientSqlMutaction] = { + val x: Seq[Iterable[ClientSqlMutaction]] = for { + field <- model.relationFields if !field.isList + toIdOpt <- args.getFieldValueAs[String](field, suffix = SchemaBuilderConstants.idSuffix) + } yield { + + val removeOldFromRelation = List(Some(RemoveDataItemFromManyRelationByFromId(project.id, field, fromId)), + checkIfUpdateWouldFailARequiredOneRelation(field, fromId, toIdOpt, project)).flatten + + val removeOldToRelation: List[ClientSqlMutaction] = if (field.isOneToOneRelation(project)) { + toIdOpt + .map { toId => + List( + Some(RemoveDataItemFromManyRelationByToId(project.id, field, toId)), + checkIfUpdateWouldFailARequiredOneRelation(field.relatedFieldEager(project), toId, Some(fromId), project) + ).flatten + } + .getOrElse(List.empty) + } else List() + + val addToRelation = toIdOpt.map { toId => + AddDataItemToManyRelation(project = project, fromModel = model, fromField = field, fromId = fromId, toId = toId) + } + // FIXME: removes must be first here; How could we make that clearer? + removeOldFromRelation ++ removeOldToRelation ++ addToRelation + } + x.flatten + } + + private def checkIfRemovalWouldFailARequiredRelation(field: Field, fromId: String, project: Project): Option[InvalidInputClientSqlMutaction] = { + val isInvalid = () => dataResolver.resolveByRelation(fromField = field, fromModelId = fromId, args = None).map(_.items.nonEmpty) + + runRequiredRelationCheckWithInvalidFunction(field, project, isInvalid) + } + + private def checkIfUpdateWouldFailARequiredOneRelation(field: Field, + fromId: String, + toId: Option[String], + project: Project): Option[InvalidInputClientSqlMutaction] = { + val isInvalid = () => + dataResolver.resolveByRelation(fromField = field, fromModelId = fromId, args = None).map { + _.items match { + case x :: _ => x.id != toId.getOrElse("") + case _ => false + } + } + runRequiredRelationCheckWithInvalidFunction(field, project, isInvalid) + } + + private def checkIfUpdateWouldFailARequiredManyRelation(field: Field, + fromId: String, + toIds: List[String], + project: Project): Option[InvalidInputClientSqlMutaction] = { + val isInvalid = () => + dataResolver + .resolveByRelation(fromField = field, fromModelId = fromId, args = None) + .map(_.items.exists(x => !toIds.contains(x.id))) + + runRequiredRelationCheckWithInvalidFunction(field, project, isInvalid) + } + + private def runRequiredRelationCheckWithInvalidFunction(field: Field, project: Project, isInvalid: () => Future[Boolean]) = { + val relatedField = field.relatedFieldEager(project) + val relatedModel = field.relatedModel_!(project) + if (relatedField.isRequired && !relatedField.isList) { + Some(InvalidInputClientSqlMutaction(RelationIsRequired(fieldName = relatedField.name, typeName = relatedModel.name), isInvalid = isInvalid)) + } else None + } + + def getComplexMutactions(project: Project, model: Model, args: CoolArgs, fromId: Id, requestId: String)(implicit inj: Injector): Seq[ClientSqlMutaction] = { + val x: Seq[List[ClientSqlMutaction]] = for { + field <- model.relationFields + subArgs <- args.subArgsList(field) + subModel = field.relatedModel(project).get + } yield { + + val removeOldFromRelation = + List(checkIfRemovalWouldFailARequiredRelation(field, fromId, project), Some(RemoveDataItemFromManyRelationByFromId(project.id, field, fromId))).flatten + + val allowSettingManagedFields = false + + val itemsToCreate = subArgs.flatMap { subArg => + getMutactionsForCreate(project, subModel, subArg, allowSettingManagedFields, parentInfo = Some(ParentInfo(model, field, fromId)), requestId = requestId).allMutactions + } + + removeOldFromRelation ++ itemsToCreate + } + x.flatten + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/SubscriptionEvents.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/SubscriptionEvents.scala new file mode 100644 index 0000000000..854b836a63 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/SubscriptionEvents.scala @@ -0,0 +1,61 @@ +package cool.graph.client.mutations + +import cool.graph.ClientSqlMutaction +import cool.graph.Types.Id +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.client.mutactions._ +import cool.graph.shared.models.Project +import scaldi.Injector + +import scala.collection.immutable.Seq + +object SubscriptionEvents { + def extractFromSqlMutactions(project: Project, mutationId: Id, mutactions: Seq[ClientSqlMutaction])(implicit inj: Injector): Seq[PublishSubscriptionEvent] = { + mutactions.collect { + case x: UpdateDataItem => fromUpdateMutaction(project, mutationId, x) + case x: CreateDataItem => fromCreateMutaction(project, mutationId, x) + case x: DeleteDataItem => fromDeleteMutaction(project, mutationId, x) + } + } + + def fromDeleteMutaction(project: Project, mutationId: Id, mutaction: DeleteDataItem)(implicit inj: Injector): PublishSubscriptionEvent = { + val nodeData: Map[String, Any] = mutaction.previousValues.userData + .collect { + case (key, Some(value)) => + (key, value match { + case v: Vector[Any] => v.toList // Spray doesn't like Vector and formats it as string ("Vector(something)") + case v => v + }) + } + ("id" -> mutaction.id) + + PublishSubscriptionEvent( + project = project, + value = Map("nodeId" -> mutaction.id, "node" -> nodeData, "modelId" -> mutaction.model.id, "mutationType" -> "DeleteNode"), + mutationName = s"delete${mutaction.model.name}" + ) + } + + def fromCreateMutaction(project: Project, mutationId: Id, mutaction: CreateDataItem)(implicit inj: Injector): PublishSubscriptionEvent = { + PublishSubscriptionEvent( + project = project, + value = Map("nodeId" -> mutaction.id, "modelId" -> mutaction.model.id, "mutationType" -> "CreateNode"), + mutationName = s"create${mutaction.model.name}" + ) + } + + def fromUpdateMutaction(project: Project, mutationId: Id, mutaction: UpdateDataItem)(implicit inj: Injector): PublishSubscriptionEvent = { + PublishSubscriptionEvent( + project = project, + value = Map( + "nodeId" -> mutaction.id, + "changedFields" -> mutaction.namesOfUpdatedFields, + "previousValues" -> GraphcoolDataTypes + .convertToJson(mutaction.previousValues.userData) + .compactPrint, + "modelId" -> mutaction.model.id, + "mutationType" -> "UpdateNode" + ), + mutationName = s"update${mutaction.model.name}" + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/UnsetRelation.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/UnsetRelation.scala new file mode 100644 index 0000000000..20423702c7 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/UnsetRelation.scala @@ -0,0 +1,50 @@ +package cool.graph.client.mutations + +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.authorization.RelationMutationPermissions +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.RemoveFromRelationDefinition +import cool.graph.shared.models._ +import sangria.schema +import scaldi._ + +import scala.concurrent.Future + +class UnsetRelation(relation: Relation, fromModel: Model, project: Project, args: schema.Args, dataResolver: DataResolver, argumentSchema: ArgumentSchema)( + implicit inj: Injector) + extends ClientMutation(fromModel, args, dataResolver, argumentSchema) { + + override val mutationDefinition = RemoveFromRelationDefinition(relation, project, argumentSchema) + + val aId: Id = extractIdFromScalarArgumentValues_!(args, mutationDefinition.bName) + + def prepareMutactions(): Future[List[MutactionGroup]] = { + + val aField = relation.getModelAField_!(project) + val bField = relation.getModelBField_!(project) + + val bId = extractIdFromScalarArgumentValues_!(args, mutationDefinition.aName) + + val sqlMutactions = List(RemoveDataItemFromRelationByToAndFromField(project, relation.id, aField, aId, bField, bId)) +// +// val sqlMutactions = List(RemoveDataItemFromRelationById(project, relation.id, aId), +// RemoveDataItemFromRelationById(project, relation.id, bId)) + + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + + Future.successful( + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + // dummy mutaction group for actions to satisfy tests. Please implement actions :-) + MutactionGroup(mutactions = List(), async = true) + )) + } + + override def getReturnValue: Future[ReturnValueResult] = returnValueById(fromModel, aId) + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/Update.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/Update.scala new file mode 100644 index 0000000000..9cba7cff17 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/Update.scala @@ -0,0 +1,161 @@ +package cool.graph.client.mutations + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.Types.Id +import cool.graph._ +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.client.authorization.{ModelPermissions, PermissionValidator, RelationMutationPermissions} +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions._ +import cool.graph.client.mutations.definitions.UpdateDefinition +import cool.graph.client.requestPipeline.RequestPipelineRunner +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.{Action => ActionModel, _} +import sangria.schema +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class Update(model: Model, project: Project, args: schema.Args, dataResolver: DataResolver, argumentSchema: ArgumentSchema)(implicit inj: Injector) + extends ClientMutation(model, args, dataResolver, argumentSchema) + with Injectable { + + override val mutationDefinition = UpdateDefinition(argumentSchema, project, InputTypesBuilder(project, argumentSchema)) + + implicit val system: ActorSystem = inject[ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[ActorMaterializer](identified by "actorMaterializer") + val permissionValidator = new PermissionValidator(project) + + val coolArgs: CoolArgs = { + val argsPointer: Map[String, Any] = args.raw.get("input") match { // TODO: input token is probably relay specific? + case Some(value) => value.asInstanceOf[Map[String, Any]] + case None => args.raw + } + CoolArgs(argsPointer, argumentSchema, model, project) + } + + val id: Id = coolArgs.getFieldValueAs[Id]("id").get.get + val requestId: String = dataResolver.requestContext.map(_.requestId).getOrElse("") + + val pipelineRunner = new RequestPipelineRunner(requestId) + + def prepareMutactions(): Future[List[MutactionGroup]] = { + dataResolver.resolveByModelAndIdWithoutValidation(model, id) map { + case Some(dataItem) => + val validatedDataItem = dataItem.copy(userData = GraphcoolDataTypes.fromSql(dataItem.userData, model.fields)) + + val sqlMutactions: List[ClientSqlMutaction] = + SqlMutactions(dataResolver).getMutactionsForUpdate(project, model, coolArgs, id, validatedDataItem, requestId) + + val transactionMutaction = Transaction(sqlMutactions, dataResolver) + + val updateMutactionOpt: Option[UpdateDataItem] = sqlMutactions.collect { case x: UpdateDataItem => x }.headOption + + val updateMutactions = sqlMutactions.collect { case x: UpdateDataItem => x } + + val actionMutactions = ActionWebhooks.extractFromUpdateMutactions(project = project, + mutactions = updateMutactions, + mutationId = mutationId, + requestId = requestId, + previousValues = validatedDataItem) + + val fileMutaction: Option[S3UpdateFileName] = for { + updateMutaction <- updateMutactionOpt + if model.name == "File" && updateMutaction.namesOfUpdatedFields.contains("name") + } yield { + val newFileName = updateMutaction.values.find(_.name == "name").get.value.asInstanceOf[Option[String]].get + S3UpdateFileName(model, project, id, newFileName, dataResolver) + } + + val algoliaSyncQueryMutactions = AlgoliaSyncQueries.extract(dataResolver, project, model, id, "update") + + val subscriptionMutactions = SubscriptionEvents.extractFromSqlMutactions(project, mutationId, sqlMutactions) + + val sssActions = ServerSideSubscription.extractFromMutactions(project, sqlMutactions, requestId) + + List( + MutactionGroup(mutactions = List(transactionMutaction), async = false), + MutactionGroup(mutactions = actionMutactions.toList ++ sssActions ++ fileMutaction ++ algoliaSyncQueryMutactions ++ subscriptionMutactions, + async = true) + ) + + case None => + List( + MutactionGroup( + mutactions = List( + UpdateDataItem(project = project, + model = model, + id = id, + values = List.empty, + originalArgs = None, + previousValues = DataItem(id), + itemExists = false)), + async = false + ), + MutactionGroup(mutactions = List.empty, async = true) + ) + } + } + + override def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] = { + def checkCustomPermissionsForField(field: Field): Future[Boolean] = { + dataResolver.resolveByModelAndIdWithoutValidation(model, id).flatMap { existingNode => + val filteredPermissions = model.permissions + .filter(_.isActive) + .filter(_.operation == ModelOperation.Update) + .filter(p => p.applyToWholeModel || p.fieldIds.contains(field.id)) + + permissionValidator.checkModelQueryPermissions( + project, + filteredPermissions, + authenticatedRequest, + id, + coolArgs.permissionQueryArgsForNewAndOldFieldValues(mutationDefinition, existingNode), + alwaysQueryMasterDatabase = true + ) + } + } + + val normalPermissions = ModelPermissions.checkPermissionsForUpdate(model, coolArgs, authenticatedRequest, project) + + if (normalPermissions) { + Future.successful(true) + } else { + Future + .sequence(coolArgs.fieldsThatRequirePermissionCheckingInMutations.map(checkCustomPermissionsForField)) + .map { x => + x.nonEmpty && x.forall(identity) + } + } + } + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + override def getReturnValue: Future[ReturnValue] = { + + def ensureReturnValue(returnValue: ReturnValueResult): ReturnValue = { + returnValue match { + case x: NoReturnValue => throw UserAPIErrors.DataItemDoesNotExist(model.name, id) + case x: ReturnValue => x + } + } + + for { + returnValueResult <- returnValueById(model, id) + dataItem = ensureReturnValue(returnValueResult).dataItem + transformedResult <- pipelineRunner.runTransformPayload( + project = project, + model = model, + operation = RequestPipelineOperation.UPDATE, + values = RequestPipelineRunner.dataItemToArgumentValues(dataItem, model) + ) + } yield { + ReturnValue(RequestPipelineRunner.argumentValuesToDataItem(transformedResult, dataItem.id, model)) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/UpdateOrCreate.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/UpdateOrCreate.scala new file mode 100644 index 0000000000..f97b9d2a0b --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/UpdateOrCreate.scala @@ -0,0 +1,78 @@ +package cool.graph.client.mutations + +import cool.graph._ +import cool.graph.client.authorization.RelationMutationPermissions +import cool.graph.client.database.DataResolver +import cool.graph.client.mutations.definitions.UpdateOrCreateDefinition +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.shared.models.{AuthenticatedRequest, Model, Project} +import cool.graph.util.coolSangria.Sangria +import sangria.schema +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class UpdateOrCreate(model: Model, + project: Project, + args: schema.Args, + dataResolver: DataResolver, + argumentSchema: ArgumentSchema, + allowSettingManagedFields: Boolean = false)(implicit inj: Injector) + extends ClientMutation(model, args, dataResolver, argumentSchema) + with Injectable { + + override val mutationDefinition = UpdateOrCreateDefinition(argumentSchema, project, InputTypesBuilder(project, argumentSchema)) + + val argsPointer: Map[String, Any] = args.raw.get("input") match { + case Some(value) => value.asInstanceOf[Map[String, Any]] + case None => args.raw + } + + val updateMutation: Update = { + val updateArgs = Sangria.rawArgs(argsPointer("update").asInstanceOf[Map[String, Any]]) + new Update(model, project, updateArgs, dataResolver, argumentSchema) + } + val createMutation: Create = { + val createArgs = Sangria.rawArgs(argsPointer("create").asInstanceOf[Map[String, Any]]) + new Create(model, project, createArgs, dataResolver, argumentSchema) + } + + var itemExists = false + + override def prepareMutactions(): Future[List[MutactionGroup]] = { + for { + exists <- dataResolver.existsByModelAndId(model, updateMutation.id) + mutactionGroups <- if (exists) { + itemExists = true + updateMutation.prepareMutactions() + } else { + itemExists = false + createMutation.prepareMutactions() + } + } yield { + mutactionGroups + } + } + + override def checkPermissions(authenticatedRequest: Option[AuthenticatedRequest]): Future[Boolean] = { + // TODO: what's the difference between Update and Create permission checking? + if (itemExists) { + updateMutation.checkPermissions(authenticatedRequest) + } else { + createMutation.checkPermissions(authenticatedRequest) + } + } + + override def checkPermissionsAfterPreparingMutactions(authenticatedRequest: Option[AuthenticatedRequest], mutactions: List[Mutaction]): Future[Unit] = { + RelationMutationPermissions.checkAllPermissions(project, mutactions, authenticatedRequest) + } + + override def getReturnValue: Future[ReturnValueResult] = { + if (itemExists) { + returnValueById(model, updateMutation.id) + } else { + returnValueById(model, createMutation.id) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/CreateDefinition.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/CreateDefinition.scala new file mode 100644 index 0000000000..d81df633e9 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/CreateDefinition.scala @@ -0,0 +1,16 @@ +package cool.graph.client.mutations.definitions + +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.shared.models.{Model, Project} +import cool.graph.{ArgumentSchema, CreateOrUpdateMutationDefinition, SchemaArgument} +import sangria.schema.Argument + +case class CreateDefinition(argumentSchema: ArgumentSchema, project: Project, inputTypesBuilder: InputTypesBuilder) extends CreateOrUpdateMutationDefinition { + + val argumentGroupName = "Create" + + override def getSangriaArguments(model: Model): List[Argument[Any]] = inputTypesBuilder.getSangriaArgumentsForCreate(model) + + override def getRelationArguments(model: Model): List[SchemaArgument] = inputTypesBuilder.cachedRelationalSchemaArguments(model, omitRelation = None) + override def getScalarArguments(model: Model): List[SchemaArgument] = inputTypesBuilder.computeScalarSchemaArgumentsForCreate(model) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/DeleteDefinition.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/DeleteDefinition.scala new file mode 100644 index 0000000000..51f0f67a47 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/DeleteDefinition.scala @@ -0,0 +1,17 @@ +package cool.graph.client.mutations.definitions + +import cool.graph.client.SchemaBuilderUtils +import cool.graph.shared.models.{Model, Project} +import cool.graph.{ArgumentSchema, ClientMutationDefinition, SchemaArgument} + +case class DeleteDefinition(argumentSchema: ArgumentSchema, project: Project) extends ClientMutationDefinition { + + val argumentGroupName = "Delete" + + override def getSchemaArguments(model: Model): List[SchemaArgument] = { + val idField = model.getFieldByName_!("id") + List( + SchemaArgument(idField.name, SchemaBuilderUtils.mapToRequiredInputType(idField), idField.description, idField) + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/RelationDefinitions.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/RelationDefinitions.scala new file mode 100644 index 0000000000..f7fb67ca40 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/RelationDefinitions.scala @@ -0,0 +1,41 @@ +package cool.graph.client.mutations.definitions + +import cool.graph.shared.models.{Model, Project, Relation} +import cool.graph.{ArgumentSchema, ClientMutationDefinition, SchemaArgument} +import sangria.schema + +sealed trait RelationDefinition extends ClientMutationDefinition { + def argumentGroupName: String + def argumentSchema: ArgumentSchema + def relation: Relation + def project: Project + + val aName = relation.aName(project) + "Id" + val bName = relation.bName(project) + "Id" + val scalarArgs = List( + SchemaArgument(aName, schema.IDType, None), + SchemaArgument(bName, schema.IDType, None) + ) + + override def getSchemaArguments(model: Model): List[SchemaArgument] = scalarArgs +} + +case class AddToRelationDefinition(relation: Relation, project: Project, argumentSchema: ArgumentSchema) extends RelationDefinition { + + override val argumentGroupName = s"AddTo${relation.name}" +} + +case class RemoveFromRelationDefinition(relation: Relation, project: Project, argumentSchema: ArgumentSchema) extends RelationDefinition { + + override val argumentGroupName = s"RemoveFrom${relation.name}" +} + +case class SetRelationDefinition(relation: Relation, project: Project, argumentSchema: ArgumentSchema) extends RelationDefinition { + + override val argumentGroupName = s"Set${relation.name}" +} + +case class UnsetRelationDefinition(relation: Relation, project: Project, argumentSchema: ArgumentSchema) extends RelationDefinition { + + override val argumentGroupName = s"Unset${relation.name}" +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateDefinition.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateDefinition.scala new file mode 100644 index 0000000000..6923a17a3a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateDefinition.scala @@ -0,0 +1,16 @@ +package cool.graph.client.mutations.definitions + +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.shared.models.{Model, Project} +import cool.graph.{ArgumentSchema, CreateOrUpdateMutationDefinition, SchemaArgument} +import sangria.schema.Argument + +case class UpdateDefinition(argumentSchema: ArgumentSchema, project: Project, inputTypesBuilder: InputTypesBuilder) extends CreateOrUpdateMutationDefinition { + + val argumentGroupName = "Update" + + override def getSangriaArguments(model: Model): List[Argument[Any]] = inputTypesBuilder.getSangriaArgumentsForUpdate(model) + + override def getRelationArguments(model: Model): List[SchemaArgument] = inputTypesBuilder.cachedRelationalSchemaArguments(model, omitRelation = None) + override def getScalarArguments(model: Model): List[SchemaArgument] = inputTypesBuilder.computeScalarSchemaArgumentsForUpdate(model) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateOrCreateDefinition.scala b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateOrCreateDefinition.scala new file mode 100644 index 0000000000..c4d199d5c9 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/mutations/definitions/UpdateOrCreateDefinition.scala @@ -0,0 +1,20 @@ +package cool.graph.client.mutations.definitions + +import cool.graph.client.schema.InputTypesBuilder +import cool.graph.shared.models.{Model, Project} +import cool.graph.{ArgumentSchema, ClientMutationDefinition, SchemaArgument} +import sangria.schema.Argument + +case class UpdateOrCreateDefinition(argumentSchema: ArgumentSchema, project: Project, inputTypesBuilder: InputTypesBuilder) extends ClientMutationDefinition { + + val argumentGroupName = "UpdateOrCreate" + + val createDefinition = CreateDefinition(argumentSchema, project, inputTypesBuilder) + val updateDefinition = UpdateDefinition(argumentSchema, project, inputTypesBuilder) + + override def getSangriaArguments(model: Model): List[Argument[Any]] = { + inputTypesBuilder.getSangriaArgumentsForUpdateOrCreate(model) + } + + override def getSchemaArguments(model: Model): List[SchemaArgument] = ??? +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/FunctionExecutor.scala b/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/FunctionExecutor.scala new file mode 100644 index 0000000000..4ad28a00ab --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/FunctionExecutor.scala @@ -0,0 +1,329 @@ +package cool.graph.client.requestPipeline + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.model.{DateTime => _, _} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.{ActorMaterializer, StreamTcpException} +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.client.authorization.ClientAuthImpl +import cool.graph.cuid.Cuid +import cool.graph.messagebus.{Conversions, QueuePublisher} +import cool.graph.shared.errors.RequestPipelineErrors._ +import cool.graph.shared.errors.UserInputErrors.ResolverPayloadIsRequired +import cool.graph.shared.functions.{EndpointResolver, FunctionEnvironment, InvokeFailure, InvokeSuccess} +import cool.graph.shared.models +import cool.graph.shared.models._ +import cool.graph.util.collection.ToImmutable._ +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat +import org.scalactic.{Bad, Good, Or} +import scaldi.{Injectable, Injector} +import spray.json._ + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +sealed trait FunctionResult +case class FunctionSuccess(values: FunctionDataValue, result: FunctionExecutionResult) extends FunctionResult + +case class FunctionDataValue(isNull: Boolean, values: Vector[JsObject]) + +sealed trait FunctionError extends FunctionResult +case class FunctionReturnedBadStatus(statusCode: Int, rawResponse: String) extends FunctionError +case class FunctionReturnedBadBody(badBody: String, parseError: String) extends FunctionError +case class FunctionWebhookURLNotValid(url: String) extends FunctionError + +sealed trait FunctionReturnedError extends FunctionError +case class FunctionReturnedStringError(error: String, result: FunctionExecutionResult) extends FunctionReturnedError +case class FunctionReturnedJsonError(json: JsObject, result: FunctionExecutionResult) extends FunctionReturnedError + +case class FunctionExecutionResult(logs: Vector[String], returnValue: Map[String, Any]) + +class FunctionExecutor(implicit val inj: Injector) extends Injectable { + implicit val actorSystem: ActorSystem = inject[_root_.akka.actor.ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[_root_.akka.stream.ActorMaterializer](identified by "actorMaterializer") + + val functionEnvironment: FunctionEnvironment = inject[FunctionEnvironment] + val logsPublisher = inject[QueuePublisher[String]](identified by "logsPublisher") + + def sync(project: Project, function: models.Function, event: String): Future[FunctionSuccess Or FunctionError] = { + function.delivery match { + + // Lambda and Dev function environment + + case delivery: models.ManagedFunction => { + functionEnvironment.invoke(project, function.name, event) flatMap { + case InvokeSuccess(response) => handleSuccessfulResponse(project, response, function, acceptEmptyResponse = false) + case InvokeFailure(exception) => Future.successful(Bad(FunctionReturnedBadStatus(0, exception.getMessage))) + } + } + + // Auth0Extend and Webhooks + + case delivery: models.HttpFunction => + val headers = delivery.headers.map { case (name, value) => RawHeader(name, value) }.toImmutable + + val httpExt = Http(actorSystem) + val request = HttpRequest( + method = HttpMethods.POST, + uri = function.delivery.asInstanceOf[HttpFunction].url, + headers = headers, + entity = HttpEntity(ContentTypes.`application/json`, event) + ) + + val response: Future[HttpResponse] = httpExt.singleRequest(request) + + response.flatMap { serverlessResult => + val statusCode = serverlessResult.status.intValue + if (statusCode >= 200 && statusCode < 300) { + handleSuccessfulResponse(project, serverlessResult, function, acceptEmptyResponse = statusCode == 204) + } else { + Unmarshal(serverlessResult) + .to[String] + .map(bodyString => Bad(FunctionReturnedBadStatus(statusCode, bodyString))) + } + } recover { + // https://[INVALID].algolia.net/1/keys/[VALID] times out, so we simply report a timeout as a wrong appId + case _: StreamTcpException => Bad(FunctionWebhookURLNotValid(request.uri.toString())) + } + case _ => sys.error("only knows how to execute HttpFunctions") + } + } + + def syncWithLogging(function: models.Function, event: String, project: Project, requestId: String): Future[FunctionSuccess Or FunctionError] = { + val start = DateTime.now + + def renderLogPayload(status: String, message: Any): String = { + Map( + "id" -> Cuid.createCuid(), + "projectId" -> project.id, + "functionId" -> function.id, + "requestId" -> requestId, + "status" -> status, + "duration" -> (DateTime.now.getMillis - start.getMillis), + "timestamp" -> FunctionExecutor.dateFormatter.print(start), + "message" -> message + ).toJson.compactPrint + } + + sync(project, function, event) + .andThen({ + case Success(Bad(FunctionReturnedStringError(error, result))) => + logsPublisher.publish(renderLogPayload("FAILURE", Map("event" -> event, "logs" -> result.logs, "returnValue" -> result.returnValue))) + + case Success(Bad(FunctionReturnedJsonError(error, result))) => + logsPublisher.publish(renderLogPayload("FAILURE", Map("event" -> event, "logs" -> result.logs, "returnValue" -> result.returnValue))) + + case Success(Bad(FunctionReturnedBadBody(badBody, parseError))) => + logsPublisher.publish(renderLogPayload("FAILURE", Map("error" -> s"Couldn't parse response: $badBody. Error message: $parseError"))) + + case Success(Bad(FunctionReturnedBadStatus(statusCode, rawResponse))) => + logsPublisher.publish(renderLogPayload("FAILURE", Map("error" -> rawResponse))) //Function returned invalid status code: $statusCode. Raw body: + + case Success(Bad(FunctionWebhookURLNotValid(url))) => + logsPublisher.publish(renderLogPayload("FAILURE", Map("error" -> s"Function called an invalid url: $url"))) + + case Success(Good(FunctionSuccess(values, result))) => + logsPublisher.publish(renderLogPayload("SUCCESS", Map("event" -> event, "logs" -> result.logs, "returnValue" -> result.returnValue))) + }) + } + + def syncWithLoggingAndErrorHandling_!(function: models.Function, event: String, project: Project, requestId: String): Future[FunctionSuccess] = { + syncWithLogging(function, event, project, requestId) map { + case Good(x) => x + case Bad(_: FunctionWebhookURLNotValid) => throw FunctionWebhookURLWasNotValid(executionId = requestId) + case Bad(_: FunctionReturnedBadStatus) => throw UnhandledFunctionError(executionId = requestId) + case Bad(_: FunctionReturnedBadBody) => throw FunctionReturnedInvalidBody(executionId = requestId) + case Bad(FunctionReturnedStringError(errorMsg, _)) => throw FunctionReturnedErrorMessage(errorMsg) + case Bad(FunctionReturnedJsonError(json, _)) => throw FunctionReturnedErrorObject(json) + } + } + + private def handleSuccessfulResponse(project: Project, response: HttpResponse, function: models.Function, acceptEmptyResponse: Boolean)( + implicit actorSystem: ActorSystem, + materializer: ActorMaterializer): Future[FunctionSuccess Or FunctionError] = { + + Unmarshal(response).to[String].flatMap { bodyString => + handleSuccessfulResponse(project, bodyString, function, acceptEmptyResponse) + } + } + + private def handleSuccessfulResponse(project: Project, bodyString: String, function: models.Function, acceptEmptyResponse: Boolean)( + implicit actorSystem: ActorSystem, + materializer: ActorMaterializer): Future[FunctionSuccess Or FunctionError] = { + + import cool.graph.util.json.Json._ + import shapeless._ + import syntax.typeable._ + + def parseResolverResponse(data: Any, f: FreeType): FunctionDataValue = { + def tryParsingAsList = Try { data.asInstanceOf[List[Any]].toVector }.getOrElse(throw DataDoesNotMatchPayloadType()) + + f.isList match { + case _ if data == null => FunctionDataValue(isNull = true, Vector.empty) + case false => FunctionDataValue(isNull = false, Vector(parseDataToJsObject(data))) + case true => FunctionDataValue(isNull = false, tryParsingAsList.map(parseDataToJsObject)) + } + } + + Future.successful { + val bodyOrDefault = acceptEmptyResponse match { + case true => """{}""" + case false => bodyString + } + + bodyOrDefault.tryParseJson.map(myMapFormat.read) match { + case Success(parsed) => + // inline functions are wrapped in {logs:[], response: { this is what we care about }} + // we should make this handling more explicit + val functionExecutionResult: FunctionExecutionResult = parsed.get("response") match { + case Some(response) if response.isInstanceOf[Map[_, _]] => + val logs = parsed.get("logs") match { + case Some(logs) if logs.isInstanceOf[List[_]] => logs.asInstanceOf[List[String]].toVector + case _ => Vector.empty + } + FunctionExecutionResult(logs, response.asInstanceOf[Map[String, Any]]) + + case None => + FunctionExecutionResult(Vector.empty, parsed) + } + + def getResult(data: Any): FunctionDataValue = function match { + case f: CustomQueryFunction => parseResolverResponse(data, f.payloadType) + case f: CustomMutationFunction => parseResolverResponse(data, f.payloadType) + case _ => FunctionDataValue(isNull = false, Vector(parseDataToJsObject(data))) + } + + def resolverPayloadIsRequired: Boolean = function match { + case f: CustomQueryFunction => f.payloadType.isRequired + case f: CustomMutationFunction => f.payloadType.isRequired + case _ => false + } + + val returnedError: Option[Any] = functionExecutionResult.returnValue.get("error") + val stringError: Option[String] = returnedError.flatMap(e => e.cast[String]) + val jsonError: Option[Map[String, Any]] = returnedError.flatMap(e => e.cast[Map[String, Any]]) + + (returnedError, functionExecutionResult.returnValue.get("data")) match { + case (None, None) if resolverPayloadIsRequired => throw ResolverPayloadIsRequired() + case (None, None) => Good(FunctionSuccess(FunctionDataValue(isNull = true, Vector.empty), functionExecutionResult)) + case (Some(null), Some(data)) => Good(FunctionSuccess(getResult(data), functionExecutionResult)) + case (None, Some(data)) => Good(FunctionSuccess(getResult(data), functionExecutionResult)) + case (Some(_), _) if stringError.isDefined => Bad(FunctionReturnedStringError(stringError.get, functionExecutionResult)) + case (Some(_), _) if jsonError.isDefined => Bad(FunctionReturnedJsonError(myMapFormat.write(jsonError.get).asJsObject, functionExecutionResult)) + case (Some(error), _) => Bad(FunctionReturnedBadBody(bodyString, error.toString)) + } + + case Failure(e) => + Bad(FunctionReturnedBadBody(bodyString, e.getMessage)) + } + } + } + + private def parseDataToJsObject(data: Any) = { + Try(data.asInstanceOf[Map[String, Any]].toJson.asJsObject).getOrElse(throw DataDoesNotMatchPayloadType()) + } + + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any) = x match { + case m: Map[_, _] => JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case l: Vector[Any] => JsArray(l.map(write)) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case n: BigDecimal => JsNumber(n) + case n: Double => JsNumber(n) + case n: Float => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(x: JsValue): Any = { + x match { + case l: JsArray => l.elements.map(read).toList + case m: JsObject => m.fields.mapValues(read) + case s: JsString => s.value + case n: JsNumber => n.value + case b: JsBoolean => b.value + case JsNull => null + case _ => sys.error("implement all scalar types!") + } + } + } + + implicit lazy val myMapFormat: JsonFormat[Map[String, Any]] = { + import DefaultJsonProtocol._ + mapFormat[String, Any] + } +} + +object FunctionExecutor { + import scala.concurrent.duration._ + + implicit val marshaller = Conversions.Marshallers.FromString + implicit val bugsnagger = BugSnaggerImpl(sys.env("BUGSNAG_API_KEY")) + + // mysql datetime(3) format + val dateFormatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSS") + val defaultRootTokenExpiration = Some(5.minutes.toSeconds) + + def createEventContext( + project: Project, + sourceIp: String, + headers: Map[String, String], + authenticatedRequest: Option[AuthenticatedRequest], + endpointResolver: EndpointResolver + )(implicit inj: Injector): Map[String, Any] = { + val endpoints = endpointResolver.endpoints(project.id) + val request = Map( + "sourceIp" -> sourceIp, + "headers" -> headers, + "httpMethod" -> "post" + ) + + val tmpRootToken = ClientAuthImpl().generateRootToken("_", project.id, Cuid.createCuid(), defaultRootTokenExpiration) + + val graphcool = Map( + "projectId" -> project.id, + "serviceId" -> project.id, + "alias" -> project.alias.orNull, + "pat" -> tmpRootToken, + "rootToken" -> tmpRootToken, + "endpoints" -> endpoints.toMap + ) + + val environment = Map() + val auth = authenticatedRequest + .map(authenticatedRequest => { + val typeName: String = authenticatedRequest match { + case AuthenticatedUser(_, typeName, _) => typeName + case AuthenticatedRootToken(_, _) => "PAT" + case AuthenticatedCustomer(_, _) => "Customer" + } + + Map( + "nodeId" -> authenticatedRequest.id, + "typeName" -> typeName, + "token" -> authenticatedRequest.originalToken + ) + }) + .orNull + + val sessionCache = Map() + + Map( + "request" -> request, + "graphcool" -> graphcool, + "environment" -> environment, + "auth" -> auth, + "sessionCache" -> sessionCache + ) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/RequestPipelineRunner.scala b/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/RequestPipelineRunner.scala new file mode 100644 index 0000000000..35804537ed --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/requestPipeline/RequestPipelineRunner.scala @@ -0,0 +1,293 @@ +package cool.graph.client.requestPipeline + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.DataItem +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.client.mutactions.validation.InputValueValidation +import cool.graph.client.mutations.CoolArgs +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.shared.errors.UserAPIErrors.FieldCannotBeNull +import cool.graph.shared.functions.EndpointResolver +import cool.graph.shared.models.RequestPipelineOperation.RequestPipelineOperation +import cool.graph.shared.models._ +import cool.graph.shared.mutactions.MutationTypes.{ArgumentValue, ArgumentValueList} +import org.joda.time.{DateTime, DateTimeZone} +import scaldi.{Injectable, Injector} +import spray.json.{DefaultJsonProtocol, JsArray, JsBoolean, JsFalse, JsNull, JsNumber, JsObject, JsString, JsTrue, JsValue, JsonFormat} + +import scala.collection.immutable.ListMap +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +case class RequestPipelineRunner(requestId: String)(implicit val inj: Injector) extends Injectable { + implicit val system: ActorSystem = inject[_root_.akka.actor.ActorSystem](identified by "actorSystem") + implicit val materializer: ActorMaterializer = inject[_root_.akka.stream.ActorMaterializer](identified by "actorMaterializer") + + // Transform arguments by executing function + // original values are returned if no data returned by function + def runTransformArgument(project: Project, + model: Model, + operation: RequestPipelineOperation, + values: List[ArgumentValue], + originalArgs: Option[CoolArgs]): Future[List[ArgumentValue]] = { + val appliedFn = project.requestPipelineFunctionForModel(model, FunctionBinding.TRANSFORM_ARGUMENT, operation) + + val checkRequiredFields = operation == RequestPipelineOperation.CREATE + executeFunction(project, model, appliedFn, values, originalArgs, checkRequiredFields) + } + + // Receives transformed data from TransformArgument + // Returned data is ignored, but errors halts the request + def runPreWrite(project: Project, + model: Model, + operation: RequestPipelineOperation, + values: List[ArgumentValue], + originalArgsOpt: Option[CoolArgs]): Future[Boolean] = { + val function = project.requestPipelineFunctionForModel(model, FunctionBinding.PRE_WRITE, operation) + + val transformedOriginalArgsOpt = originalArgsOpt.map(originalArgs => { + originalArgs.copy(raw = originalArgs.raw.map { + case (key, value) => (key, values.find(_.name == key).map(_.value).getOrElse(value)) + }) + }) + + executeFunction(project, model, function, values, transformedOriginalArgsOpt).map(_ => true) + } + + // Transform arguments by executing function + // original values are returned if no data returned by function + def runTransformPayload(project: Project, model: Model, operation: RequestPipelineOperation, values: List[ArgumentValue]): Future[List[ArgumentValue]] = { + val appliedFn: Option[RequestPipelineFunction] = project.requestPipelineFunctionForModel(model, FunctionBinding.TRANSFORM_PAYLOAD, operation) + executeFunction(project, model, appliedFn, values, originalArgs = None) + } + + def executeFunction(project: Project, + model: Model, + appliedFn: Option[RequestPipelineFunction], + originalValues: List[ArgumentValue], + originalArgs: Option[CoolArgs], + checkRequiredFields: Boolean = false): Future[List[ArgumentValue]] = { + appliedFn match { + case None => Future.successful(originalValues) + case Some(function) => + RpFunctionExecutor(requestId).execute_!(project, model, function, originalValues, originalArgs) map { + case FunctionSuccess(x, _) if x.isNull => + originalValues + + case FunctionSuccess(x, _) => + val graphcoolValues = GraphcoolDataTypes.fromJson(data = x.values.head, fields = model.fields) + val transformedValues = keepOriginalId(originalValues, model, graphcoolValues) + val id = ArgumentValueList.getId_!(originalValues) + + transformedValues.map(arg => { + val field = model.getFieldByName_!(arg.name) + InputValueValidation.argumentValueTypeValidation(field, arg.unwrappedValue) + }) + + val (check, _) = InputValueValidation.validateDataItemInputs(model, id, transformedValues) + if (check.isFailure) throw check.failed.get + + if (checkRequiredFields) { + val missingRequiredFieldNames: List[String] = InputValueValidation.validateRequiredScalarFieldsHaveValues(model, transformedValues) + if (missingRequiredFieldNames.nonEmpty) throw FieldCannotBeNull(missingRequiredFieldNames.head) + } + transformedValues + } + } + } + + private def keepOriginalId(original: List[ArgumentValue], model: Model, returnValue: Map[String, Option[Any]]): List[ArgumentValue] = { + + val newValues: List[ArgumentValue] = returnValue.map(x => ArgumentValue(x._1, x._2, model.getFieldByName(x._1))).toList + + val onlyScalar: List[ArgumentValue] = newValues.filter(arg => arg.field.exists(_.isScalar)) + val fixedDateTimes = onlyScalar.map(argumentValue => { + argumentValue.field.exists(_.typeIdentifier == TypeIdentifier.DateTime) match { + case true => + val value = argumentValue.value match { + case Some(x: String) => Some(new DateTime(x, DateTimeZone.UTC)) + case x => x + } + argumentValue.copy(value = value) + case false => argumentValue + } + }) + + val id = original.find(_.name == "id") + id match { + case Some(id) => fixedDateTimes.filter(_.name != "id") :+ id + case None => fixedDateTimes.filter(_.name != "id") + } + } +} + +case class RpFunctionExecutor(requestId: String)(implicit val inj: Injector) extends Injectable { + + // GraphQL differentiates between null and undefined + // null means explicit null, in our case it sets the value to null in the database + // undefined is an optional argument that was not supplied. + // This distinction is important in UPDATE mutations + // In our domain model explicit nulls are modeled as None, omitted arguments are missing from argument list + private def handleOptionalAndNullValues(value: Any) = { + value match { + case Some(x) => x + case None => null + case x => x + } + } + private def valuesToMap(values: List[ArgumentValue]): ListMap[String, Any] = { + // note: ListMap preserves ordering + ListMap(values.map(x => (x.name, handleOptionalAndNullValues(x.value))).sortBy(_._1): _*) + } + private def coolArgsToMap(rawArgs: Map[String, Any]): ListMap[String, Any] = { + // note: ListMap preserves ordering + ListMap(rawArgs.mapValues(handleOptionalAndNullValues(_)).toList.sortBy(_._1): _*).map { + case (key, value) if value.isInstanceOf[Vector[_]] => + value.asInstanceOf[Vector[Any]] match { + case value if value.nonEmpty && value.head.isInstanceOf[Map[_, _]] => + (key, value.asInstanceOf[Vector[Map[String, Any]]].map(coolArgsToMap)) + case value => (key, value) + } + case (key, value) if value.isInstanceOf[Map[_, _]] => + (key, coolArgsToMap(value.asInstanceOf[Map[String, Any]])) + case (key, value) => (key, value) + } + } + + def execute_!(project: Project, model: Model, function: RequestPipelineFunction, values: List[ArgumentValue], originalArgs: Option[CoolArgs] = None)( + implicit inj: Injector): Future[FunctionSuccess] = { + val functionExecutor = new FunctionExecutor() + + val originalArgsWithId = originalArgs.map { args => + values.find(_.name == "id") match { + case None => args.raw + case Some(idValue) => args.raw + ("id" -> idValue.value) + } + } + + val endpointResolver = inject[EndpointResolver](identified by "endpointResolver") + val context: Map[String, Any] = FunctionExecutor.createEventContext(project, "", headers = Map.empty, None, endpointResolver) + + val argsAndContext = Map( + "data" -> originalArgsWithId.map(coolArgsToMap).getOrElse(valuesToMap(values)), + "context" -> context + ) + + val event = AnyJsonFormat.write(argsAndContext).compactPrint + functionExecutor.syncWithLoggingAndErrorHandling_!(function, event, project, requestId) + } + + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any): JsValue = x match { + case m: Map[_, _] => + JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case l: Vector[Any] => JsArray(l.map(write)) + case l: Seq[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case n: BigDecimal => JsNumber(n) + case n: Double => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(x: JsValue): Any = { + x match { + case l: JsArray => l.elements.map(read).toList + case m: JsObject => m.fields.mapValues(read) + case s: JsString => s.value + case n: JsNumber => n.value + case b: JsBoolean => b.value + case JsNull => null + case _ => sys.error("implement all scalar types!") + } + } + } + + implicit lazy val myMapFormat: JsonFormat[Map[String, Any]] = { + import DefaultJsonProtocol._ + mapFormat[String, Any] + } +} + +object RequestPipelineRunner { + def dataItemToArgumentValues(dataItem: DataItem, model: Model): List[ArgumentValue] = { + val args = dataItem.userData + .flatMap(x => { + model + .getFieldByName(x._1) + .map(field => { + val value = SchemaModelObjectTypesBuilder.convertScalarFieldValueFromDatabase(field, dataItem) + ArgumentValue(name = field.name, value = value, field = field) + }) + }) + .toList :+ ArgumentValue("id", dataItem.id) + args + } + + def argumentValuesToDataItem(argumentValues: List[ArgumentValue], id: String, model: Model): DataItem = { + val dataItem = DataItem( + id = id, + userData = argumentValues.collect { + case x if model.fields.exists(_.name == x.name) => + val field = model.getFieldByName_!(x.name) + (x.name, fromJsValues(normaliseOptions(x.value), field)) + }.toMap, + typeName = Some(model.name) + ) + dataItem + } + + private def normaliseOptions(value: Any): Option[Any] = value match { + case None => None + case null => None + case Some(null) => None + case Some(x) => Some(x) + case x => Some(x) + } + + // For lists: JsArray => String + // For Int: BigDecimal => Int + // For Float: BigDecimal => Double + private def fromJsValues(value: Option[Any], field: Field): Option[Any] = { + def convertNumberToInt(value: Any): Int = value match { + case x: BigDecimal => x.toInt + case x: Float => x.toInt + case x: Double => x.toInt + case x: Int => x + } + def convertNumberToDouble(value: Any): Double = value match { + case x: BigDecimal => x.toDouble + case x: Float => x.toDouble + case x: Double => x + case x: Int => x.toDouble + } + + field.isList match { + case true => + value match { + case Some(x: JsArray) => Some(x.compactPrint) + case x => x + } + + case false => + field.typeIdentifier match { + case TypeIdentifier.String => value + case TypeIdentifier.Int => value.map(convertNumberToInt) + case TypeIdentifier.Float => value.map(convertNumberToDouble) + case TypeIdentifier.Boolean => value + case TypeIdentifier.GraphQLID => value + case TypeIdentifier.Password => value + case TypeIdentifier.DateTime => value + case TypeIdentifier.Enum => value + case TypeIdentifier.Json => value + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/schema/InputTypesBuilder.scala b/server/client-shared/src/main/scala/cool/graph/client/schema/InputTypesBuilder.scala new file mode 100644 index 0000000000..436a58b18a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/schema/InputTypesBuilder.scala @@ -0,0 +1,242 @@ +package cool.graph.client.schema + +import java.lang.{StringBuilder => JStringBuilder} + +import com.github.benmanes.caffeine.cache.{Cache, Caffeine} +import cool.graph.client.SchemaBuilderUtils +import cool.graph.shared.models.{Field, Model, Project, Relation} +import cool.graph.{ArgumentSchema, SchemaArgument} +import sangria.schema.{InputObjectType, _} + +object CaffeineCacheExtensions { + implicit class GetOrElseUpdateExtension[K](val cache: Cache[K, Object]) extends AnyVal { + def getOrElseUpdate[T <: AnyRef](cacheKey: K)(fn: => T): T = { + val cacheEntry = cache.getIfPresent(cacheKey) + if (cacheEntry != null) { + cacheEntry.asInstanceOf[T] + } else { + val result = fn + cache.put(cacheKey, result) + result + } + } + } +} + +case class InputTypesBuilder(project: Project, argumentSchema: ArgumentSchema) { + import CaffeineCacheExtensions._ + + val caffeineCache: Cache[String, Object] = Caffeine.newBuilder().build[String, Object]() + private val oneRelationIdFieldType = OptionInputType(IDType) + private val manyRelationIdsFieldType = OptionInputType(ListInputType(IDType)) + + def getSangriaArgumentsForCreate(model: Model): List[Argument[Any]] = { + getSangriaArguments(inputObjectType = cachedInputObjectTypeForCreate(model), arguments = cachedSchemaArgumentsForCreate(model)) + } + + def getSangriaArgumentsForUpdate(model: Model): List[Argument[Any]] = { + getSangriaArguments(inputObjectType = cachedInputObjectTypeForUpdate(model), arguments = cachedSchemaArgumentsForUpdate(model)) + } + + def getSangriaArgumentsForUpdateOrCreate(model: Model): List[Argument[Any]] = { + getSangriaArguments(inputObjectType = cachedInputObjectTypeForUpdateOrCreate(model), arguments = cachedSchemaArgumentsForUpdateOrCreate(model)) + } + + private def getSangriaArguments(inputObjectType: => InputObjectType[Any], arguments: => List[SchemaArgument]): List[Argument[Any]] = { + argumentSchema.convertSchemaArgumentsToSangriaArguments(inputObjectType.name, arguments) + } + + // UPDATE_OR_CREATE CACHES + private def cachedInputObjectTypeForUpdateOrCreate(model: Model): InputObjectType[Any] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedInputObjectTypeForUpdateOrCreate", model)) { + InputObjectType[Any]( + name = s"UpdateOrCreate${model.name}", + fieldsFn = () => { + val updateField = InputField("update", cachedInputObjectTypeForUpdate(model)) + val createField = InputField("create", cachedInputObjectTypeForCreate(model)) + + if (cachedInputObjectTypeForCreate(model).fields.isEmpty) { + List(updateField) + } else { + + List(updateField, createField) + } + } + ) + } + } + + private def cachedSchemaArgumentsForUpdateOrCreate(model: Model): List[SchemaArgument] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedSchemaArgumentsForUpdateOrCreate", model)) { + val createInputType = cachedInputObjectTypeForCreate(model) + val updateArgument = SchemaArgument("update", cachedInputObjectTypeForUpdate(model)) + val createArgument = SchemaArgument("create", createInputType) + + if (createInputType.fields.isEmpty) { + List(updateArgument) + } else { + List(updateArgument, createArgument) + } + + } + } + + // CREATE CACHES + private def cachedInputObjectTypeForCreate(model: Model, omitRelation: Option[Relation] = None): InputObjectType[Any] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedInputObjectTypeForCreate", model, omitRelation)) { + val inputObjectTypeName = omitRelation match { + case None => + s"Create${model.name}" + + case Some(relation) => + val otherModel = relation.getOtherModel_!(project, model) + val otherField = relation.getOtherField_!(project, model) + + s"${otherModel.name}${otherField.name}${model.name}" + } + + InputObjectType[Any]( + name = inputObjectTypeName, + fieldsFn = () => { + val schemaArguments = cachedSchemaArgumentsForCreate(model, omitRelation = omitRelation) + schemaArguments.map(_.asSangriaInputField) + } + ) + } + } + + private def cachedSchemaArgumentsForCreate(model: Model, omitRelation: Option[Relation] = None): List[SchemaArgument] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedSchemaArgumentsForCreate", model, omitRelation)) { + computeScalarSchemaArgumentsForCreate(model) ++ cachedRelationalSchemaArguments(model, omitRelation = omitRelation) + } + } + + // UPDATE CACHES + private def cachedInputObjectTypeForUpdate(model: Model): InputObjectType[Any] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedInputObjectTypeForUpdate", model)) { + InputObjectType[Any]( + name = s"Update${model.name}", + fieldsFn = () => { + val schemaArguments = cachedSchemaArgumentsForUpdate(model) + schemaArguments.map(_.asSangriaInputField) + } + ) + } + } + + private def cachedSchemaArgumentsForUpdate(model: Model): List[SchemaArgument] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedSchemaArgumentsForUpdate", model)) { + computeScalarSchemaArgumentsForUpdate(model) ++ cachedRelationalSchemaArguments(model, omitRelation = None) + } + } + + // RELATIONAL CACHE + + def cachedRelationalSchemaArguments(model: Model, omitRelation: Option[Relation]): List[SchemaArgument] = { + caffeineCache.getOrElseUpdate(cacheKey("cachedRelationalSchemaArguments", model, omitRelation)) { + computeRelationalSchemaArguments(model, omitRelation) + } + } + + // CACHE KEYS + + private def cacheKey(name: String, model: Model, relation: Option[Relation]): String = { + val sb = new JStringBuilder() + sb.append(name) + sb.append(model.id) + sb.append(relation.orNull) + sb.toString + } + + private def cacheKey(name: String, model: Model): String = { + val sb = new JStringBuilder() + sb.append(name) + sb.append(model.id) + sb.toString + } + + // COMPUTE METHODS + + def computeScalarSchemaArgumentsForCreate(model: Model): List[SchemaArgument] = { + val filteredModel = model.filterFields(_.isWritable) + computeScalarSchemaArguments(filteredModel, FieldToInputTypeMapper.mapForCreateCase) + } + + def computeScalarSchemaArgumentsForUpdate(model: Model): List[SchemaArgument] = { + val filteredModel = model.filterFields(f => f.isWritable || f.name == "id") + computeScalarSchemaArguments(filteredModel, FieldToInputTypeMapper.mapForUpdateCase) + } + + private def computeScalarSchemaArguments(model: Model, mapToInputType: Field => InputType[Any]): List[SchemaArgument] = { + model.scalarFields.map { field => + SchemaArgument(field.name, mapToInputType(field), field.description, field) + } + } + + private def computeRelationalSchemaArguments(model: Model, omitRelation: Option[Relation]): List[SchemaArgument] = { + val oneRelationArguments = model.singleRelationFields.flatMap { field => + val subModel = field.relatedModel_!(project) + val relation = field.relation.get + val relationMustBeOmitted = omitRelation.exists(rel => field.isRelationWithId(rel.id)) + + val idArg = schemaArgumentWithName( + field = field, + name = field.name + SchemaBuilderConstants.idSuffix, + inputType = oneRelationIdFieldType + ) + + if (relationMustBeOmitted) { + List.empty + } else if (project.hasEnabledAuthProvider && subModel.isUserModel) { + List(idArg) + } else if (!subModel.fields.exists(f => f.isWritable && !f.relation.exists(_ => !f.isList && f.isRelationWithId(relation.id)))) { + List(idArg) + } else { + val inputObjectType = OptionInputType(cachedInputObjectTypeForCreate(subModel, omitRelation = Some(relation))) + val complexArg = schemaArgument(field = field, inputType = inputObjectType) + List(idArg, complexArg) + } + } + + val manyRelationArguments = model.listRelationFields.flatMap { field => + val subModel = field.relatedModel_!(project) + val relation = field.relation.get + val idsArg = schemaArgumentWithName( + field = field, + name = field.name + SchemaBuilderConstants.idListSuffix, + inputType = manyRelationIdsFieldType + ) + + if (project.hasEnabledAuthProvider && subModel.isUserModel) { + List(idsArg) + } else if (!subModel.fields.exists(f => f.isWritable && !f.relation.exists(rel => !f.isList && f.isRelationWithId(relation.id)))) { + List(idsArg) + } else { + val inputObjectType = cachedInputObjectTypeForCreate(subModel, omitRelation = Some(relation)) + val complexArg = schemaArgument(field, inputType = OptionInputType(ListInputType(inputObjectType))) + List(idsArg, complexArg) + } + } + oneRelationArguments ++ manyRelationArguments + } + + private def schemaArgument(field: Field, inputType: InputType[Any]): SchemaArgument = { + schemaArgumentWithName(field = field, name = field.name, inputType = inputType) + } + + private def schemaArgumentWithName(field: Field, name: String, inputType: InputType[Any]): SchemaArgument = { + SchemaArgument(name = name, inputType = inputType, description = field.description, field = field) + } +} + +object FieldToInputTypeMapper { + def mapForCreateCase(field: Field): InputType[Any] = field.isRequired && field.defaultValue.isEmpty match { + case true => SchemaBuilderUtils.mapToRequiredInputType(field) + case false => SchemaBuilderUtils.mapToOptionalInputType(field) + } + + def mapForUpdateCase(field: Field): InputType[Any] = field.name match { + case "id" => SchemaBuilderUtils.mapToRequiredInputType(field) + case _ => SchemaBuilderUtils.mapToOptionalInputType(field) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/schema/SchemaBuilder.scala b/server/client-shared/src/main/scala/cool/graph/client/schema/SchemaBuilder.scala new file mode 100644 index 0000000000..1b3cf932b2 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/schema/SchemaBuilder.scala @@ -0,0 +1,555 @@ +package cool.graph.client.schema + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph._ +import cool.graph.client._ +import cool.graph.client.adapters.GraphcoolDataTypes +import cool.graph.client.database.DeferredResolverProvider +import cool.graph.client.mutations._ +import cool.graph.client.mutations.definitions._ +import cool.graph.client.requestPipeline._ +import cool.graph.deprecated.packageMocks.AppliedFunction +import cool.graph.metrics.ClientSharedMetrics +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.errors.UserInputErrors.InvalidSchema +import cool.graph.shared.functions.EndpointResolver +import cool.graph.shared.models.{Field => GCField, _} +import cool.graph.shared.{DefaultApiMatrix, ApiMatrixFactory, models} +import cool.graph.util.coolSangria.FromInputImplicit +import cool.graph.util.performance.TimeHelper +import org.atteo.evo.inflector.English +import sangria.relay._ +import sangria.schema.{Field, _} +import scaldi.{Injectable, Injector} +import spray.json.{DefaultJsonProtocol, JsArray, JsBoolean, JsFalse, JsNull, JsNumber, JsObject, JsString, JsTrue, JsValue, JsonFormat} + +import scala.collection.mutable +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +abstract class SchemaBuilder(project: models.Project, modelPrefix: String = "")(implicit inj: Injector, + actorSystem: ActorSystem, + materializer: ActorMaterializer) + extends Injectable + with TimeHelper { + + type ManyDataItemType + + // TODO - Don't use inheritance here. Maybe we can inject the params from the outside? + val generateGetAll = true + val generateGetAllMeta = true + val generateGetSingle = true + val generateCreate = true + val generateUpdate = true + val generateUpdateOrCreate = true + val generateDelete = true + val generateAddToRelation = true + val generateRemoveFromRelation = true + val generateSetRelation = true + val generateUnsetRelation = true + val generateIntegrationFields = true + val generateCustomMutationFields = true + val generateCustomQueryFields = true + val includeSubscription: Boolean + + val modelObjectTypesBuilder: SchemaModelObjectTypesBuilder[ManyDataItemType] + val argumentSchema: ArgumentSchema + val outputMapper: OutputMapper + val modelObjectTypes: Map[String, ObjectType[UserContext, DataItem]] + val deferredResolverProvider: DeferredResolverProvider[_, UserContext] + + val apiMatrix: DefaultApiMatrix = inject[ApiMatrixFactory].create(project) + val includedModels: List[Model] = project.models.filter(model => apiMatrix.includeModel(model.name)) + + lazy val inputTypesBuilder = InputTypesBuilder(project, argumentSchema) + val pluralsCache = new PluralsCache + + def ifFeatureFlag(predicate: Boolean, fields: => List[Field[UserContext, Unit]], measurementName: String = ""): List[Field[UserContext, Unit]] = { + if (predicate) { +// if(measurementName != ""){ +// time(measurementName)(fields) +// } else { +// fields +// } + fields + } else { + List.empty + } + } + + def build(): Schema[UserContext, Unit] = ClientSharedMetrics.schemaBuilderBuildTimerMetric.time(project.id) { + val query = buildQuery() + val mutation = buildMutation() + + includeSubscription match { + case true => + val subscription = buildSubscription() + Schema( + query = query, + mutation = mutation, + subscription = subscription, + validationRules = SchemaValidationRule.empty + ) + case false => + Schema( + query = query, + mutation = mutation, + validationRules = SchemaValidationRule.empty + ) + } + } + + def buildQuery(): ObjectType[UserContext, Unit] = { + val fields = { + ifFeatureFlag(generateGetAll, includedModels.map(getAllItemsField)) ++ + ifFeatureFlag(generateGetAllMeta, includedModels.flatMap(getAllItemsMetaField)) ++ + ifFeatureFlag(generateGetSingle, includedModels.map(getSingleItemField)) ++ + ifFeatureFlag(generateCustomQueryFields, project.activeCustomQueryFunctions.map(getCustomResolverField)) ++ + userField.toList :+ nodeField + } + + ObjectType("Query", fields) + } + + def buildMutation(): Option[ObjectType[UserContext, Unit]] = { + val oneRelations = apiMatrix.filterRelations(project.getOneRelations) + val oneRelationsWithoutRequiredField = apiMatrix.filterNonRequiredRelations(oneRelations) + + val manyRelations = apiMatrix.filterRelations(project.getManyRelations) + val manyRelationsWithoutRequiredField = apiMatrix.filterNonRequiredRelations(manyRelations) + + val mutationFields: List[Field[UserContext, Unit]] = { + ifFeatureFlag(generateCreate, includedModels.filter(_.name != "User").map(getCreateItemField), measurementName = "CREATE") ++ + ifFeatureFlag(generateUpdate, includedModels.map(getUpdateItemField), measurementName = "UPDATE") ++ + ifFeatureFlag(generateUpdateOrCreate, includedModels.map(getUpdateOrCreateItemField), measurementName = "UPDATE_OR_CREATE") ++ + ifFeatureFlag(generateDelete, includedModels.map(getDeleteItemField)) ++ + ifFeatureFlag(generateSetRelation, oneRelations.map(getSetRelationField)) ++ + ifFeatureFlag(generateUnsetRelation, oneRelationsWithoutRequiredField.map(getUnsetRelationField)) ++ + ifFeatureFlag(generateAddToRelation, manyRelations.map(getAddToRelationField)) ++ + ifFeatureFlag(generateRemoveFromRelation, manyRelationsWithoutRequiredField.map(getRemoveFromRelationField)) ++ + ifFeatureFlag(generateIntegrationFields, getIntegrationFields) ++ + ifFeatureFlag(generateCustomMutationFields, project.activeCustomMutationFunctions.map(getCustomResolverField)) + } + + if (mutationFields.isEmpty) None + else Some(ObjectType("Mutation", mutationFields)) + } + + def buildSubscription(): Option[ObjectType[UserContext, Unit]] = { + val subscriptionFields = { ifFeatureFlag(generateCreate, includedModels.map(getSubscriptionField)) } + + if (subscriptionFields.isEmpty) None + else Some(ObjectType("Subscription", subscriptionFields)) + } + + def getAllItemsField(model: models.Model): Field[UserContext, Unit] = { + Field( + s"all${pluralsCache.pluralName(model)}", + fieldType = createManyFieldTypeForModel(model), + arguments = getConnectionArguments(model), + resolve = (ctx) => { + resolveGetAllItemsQuery(model, ctx) + } + ) + } + + def getAllItemsMetaField(model: models.Model): Option[Field[UserContext, Unit]] = None + + def getSingleItemField(model: models.Model): Field[UserContext, Unit] = { + Field( + model.name, + fieldType = createSingleFieldTypeForModel(model), + arguments = extractUniqueArguments(model), + resolve = (ctx) => { + resolveGetSingleItemQuery(model, ctx) + } + ) + } + + def getCustomResolverField(function: SchemaExtensionFunction): Field[UserContext, Unit] = { + + def getResolve(payloadType: FreeType, + raw: Map[String, Any], + ctx: UserContext, + expPackageMutation: Option[AppliedFunction] = None): Future[FunctionDataItems] = { + + val args = GraphcoolDataTypes.convertToJson(GraphcoolDataTypes.wrapSomes(raw)) + val endpointResolver = inject[EndpointResolver](identified by "endpointResolver") + val context = FunctionExecutor.createEventContext(project, ctx.requestIp, headers = Map.empty, ctx.authenticatedRequest, endpointResolver) + + val argsAndContext = expPackageMutation match { + case None => + Map( + "data" -> args, + "context" -> context + ) + case Some(exp) => + Map( + "data" -> args, + "context" -> (context + ("package" -> exp.context)) + ) + } + + val event = AnyJsonFormat.write(argsAndContext).compactPrint + + val functionExecutor = new FunctionExecutor() + + val functionExecutionResult: Future[FunctionSuccess] = functionExecutor.syncWithLoggingAndErrorHandling_!(function, event, project, ctx.requestId) + + functionExecutionResult.map { res => + res.values.isNull match { + case true => + FunctionDataItems(isNull = true, Vector.empty) + + case false => + FunctionDataItems( + isNull = false, + res.values.values.map(jsObject => + DataItem.fromMap(GraphcoolDataTypes.fromJson(data = jsObject, fields = payloadType.fields, addNoneValuesForMissingFields = true))) + ) + } + } + } + + def getQueryArguments(arguments: List[GCField]) = { + arguments.map(arg => { + + // NOTE needed for Argument types + import FromInputImplicit.DefaultScalaResultMarshaller + + val inputType: InputType[Any] = (arg.isRequired, arg.isList) match { + case (_, _) if arg.typeIdentifier == TypeIdentifier.Relation => throw InvalidSchema(s"argument '${arg.name}' is invalid. Must be a scalar type.") + case (true, false) => TypeIdentifier.toSangriaScalarType(arg.typeIdentifier) + case (false, false) => OptionInputType(TypeIdentifier.toSangriaScalarType(arg.typeIdentifier)) + case (true, true) => ListInputType(TypeIdentifier.toSangriaScalarType(arg.typeIdentifier)) + case (false, true) => OptionInputType(ListInputType(TypeIdentifier.toSangriaScalarType(arg.typeIdentifier))) + } + + Argument(arg.name, inputType) + }) + } + + val field: Field[UserContext, Unit] = function match { + case customMutation: CustomMutationFunction => + val expPackageMutation = project.experimentalAuthProvidersCustomMutations.find(_.name == function.name) + val payloadType = customMutation.payloadType + + Field( + customMutation.mutationName, + fieldType = payloadType.getFieldType(modelObjectTypesBuilder), + description = Some(customMutation.name), + arguments = getQueryArguments(customMutation.arguments), + resolve = (ctx) => getResolve(payloadType, ctx.args.raw, ctx.ctx, expPackageMutation).map((x: FunctionDataItems) => payloadType.adjustResolveType(x)) + ) + case customQuery: CustomQueryFunction => + val payloadType = customQuery.payloadType + + Field( + customQuery.queryName, + fieldType = payloadType.getFieldType(modelObjectTypesBuilder), + description = Some(customQuery.name), + arguments = getQueryArguments(customQuery.arguments), + resolve = (ctx) => getResolve(payloadType, ctx.args.raw, ctx.ctx).map((x: FunctionDataItems) => payloadType.adjustResolveType(x)) + ) + } + field + } + + lazy val NodeDefinition(nodeInterface, nodeField, nodeRes) = Node.definitionById( + resolve = (id: String, ctx: Context[UserContext, Unit]) => { + ctx.ctx.dataResolver.resolveByGlobalId(id) + }, + possibleTypes = { + modelObjectTypes.values.map(o => PossibleNodeObject(o)).toList + } + ) + + def getConnectionArguments(model: models.Model): List[Argument[Option[Any]]] + + def resolveGetAllItemsQuery(model: models.Model, ctx: Context[UserContext, Unit]): sangria.schema.Action[UserContext, ManyDataItemType] + + def createManyFieldTypeForModel(model: models.Model): OutputType[ManyDataItemType] + + def userField: Option[Field[UserContext, Unit]] = { + includedModels + .find(_.name == "User") + .map(userModel => { + Field( + "user", + fieldType = OptionType(modelObjectTypesBuilder.modelObjectTypes(userModel.name)), + arguments = List(), + resolve = (ctx) => { + ctx.ctx.userId + .map(userId => ctx.ctx.dataResolver.resolveByUnique(userModel, "id", userId)) + .getOrElse(Future.successful(None)) + } + ) + }) + } + + def resolveGetSingleItemQuery(model: models.Model, ctx: Context[UserContext, Unit]): sangria.schema.Action[UserContext, Option[DataItem]] = { + val arguments = extractUniqueArguments(model) + val arg = arguments.find(a => ctx.args.argOpt(a.name).isDefined) match { + case Some(value) => value + case None => + throw UserAPIErrors.GraphQLArgumentsException(s"None of the following arguments provided: ${arguments.map(_.name)}") + } + + ctx.ctx.dataResolver + .batchResolveByUnique(model, arg.name, List(ctx.arg(arg).asInstanceOf[Option[_]].get)) + .map(_.headOption) + // todo: Make OneDeferredResolver.dataItemsToToOneDeferredResultType work with Timestamps +// OneDeferred(model, arg.name, ctx.arg(arg).asInstanceOf[Option[_]].get) + } + + def createSingleFieldTypeForModel(model: models.Model) = + OptionType(modelObjectTypes(model.name)) + + def extractUniqueArguments(model: models.Model): List[Argument[_]] = { + + import FromInputImplicit.DefaultScalaResultMarshaller + + apiMatrix + .filterFields(model.fields) + .filter(!_.isList) + .filter(_.isUnique) + .map(field => Argument(field.name, SchemaBuilderUtils.mapToOptionalInputType(field), description = field.description.getOrElse(""))) + } + + def getCreateItemField(model: models.Model): Field[UserContext, Unit] = { + + val definition = CreateDefinition(argumentSchema, project, inputTypesBuilder) + val arguments = definition.getSangriaArguments(model = model) + + Field( + s"create${model.name}", + fieldType = OptionType(outputMapper.mapCreateOutputType(model, modelObjectTypes(model.name))), + arguments = arguments, + resolve = (ctx) => { + ctx.ctx.mutationQueryWhitelist.registerWhitelist(s"create${model.name}", outputMapper.nodePaths(model), argumentSchema.inputWrapper, ctx) + val mutation = new Create(model = model, project = project, args = ctx.args, dataResolver = ctx.ctx.dataResolver, argumentSchema = argumentSchema) + mutation + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + } + ) + } + + def getSubscriptionField(model: models.Model): Field[UserContext, Unit] = { + + val objectType = modelObjectTypes(model.name) + Field( + s"${model.name}", + fieldType = OptionType(outputMapper.mapSubscriptionOutputType(model, objectType)), + arguments = List(SangriaQueryArguments.filterSubscriptionArgument(model = model, project = project)), + resolve = _ => None + ) + + } + + def getSetRelationField(relation: models.Relation): Field[UserContext, Unit] = { + + val fromModel = project.getModelById_!(relation.modelAId) + val fromField = relation.getModelAField_!(project) + val toModel = project.getModelById_!(relation.modelBId) + val definition = AddToRelationDefinition(relation, project, argumentSchema) + val arguments = definition.getSangriaArguments(model = fromModel) + + Field( + name = s"set${relation.name}", + fieldType = + OptionType(outputMapper.mapAddToRelationOutputType(relation, fromModel, fromField, toModel, modelObjectTypes(fromModel.name), s"Set${relation.name}")), + arguments = arguments, + resolve = (ctx) => + new SetRelation(relation = relation, + fromModel = fromModel, + project = project, + args = ctx.args, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + ) + } + + def getAddToRelationField(relation: models.Relation): Field[UserContext, Unit] = { + + val fromModel = project.getModelById_!(relation.modelAId) + val fromField = relation.getModelAField_!(project) + val toModel = project.getModelById_!(relation.modelBId) + val definition = AddToRelationDefinition(relation, project, argumentSchema) + val arguments = definition.getSangriaArguments(model = fromModel) + + Field( + name = s"addTo${relation.name}", + fieldType = OptionType( + outputMapper.mapAddToRelationOutputType(relation, fromModel, fromField, toModel, modelObjectTypes(fromModel.name), s"AddTo${relation.name}")), + arguments = arguments, + resolve = (ctx) => + new AddToRelation(relation = relation, + fromModel = fromModel, + project = project, + args = ctx.args, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + ) + } + + def getRemoveFromRelationField(relation: models.Relation): Field[UserContext, Unit] = { + + val fromModel = project.getModelById_!(relation.modelAId) + val fromField = relation.getModelAField_!(project) + val toModel = project.getModelById_!(relation.modelBId) + + val arguments = RemoveFromRelationDefinition(relation, project, argumentSchema) + .getSangriaArguments(model = fromModel) + + Field( + name = s"removeFrom${relation.name}", + fieldType = OptionType( + outputMapper + .mapRemoveFromRelationOutputType(relation, fromModel, fromField, toModel, modelObjectTypes(fromModel.name), s"RemoveFrom${relation.name}")), + arguments = arguments, + resolve = (ctx) => + new RemoveFromRelation(relation = relation, + fromModel = fromModel, + project = project, + args = ctx.args, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + ) + } + + def getUnsetRelationField(relation: models.Relation): Field[UserContext, Unit] = { + + val fromModel = project.getModelById_!(relation.modelAId) + val fromField = relation.getModelAField_!(project) + val toModel = project.getModelById_!(relation.modelBId) + + val arguments = UnsetRelationDefinition(relation, project, argumentSchema).getSangriaArguments(model = fromModel) + + Field( + name = s"unset${relation.name}", + fieldType = OptionType( + outputMapper + .mapRemoveFromRelationOutputType(relation, fromModel, fromField, toModel, modelObjectTypes(fromModel.name), s"Unset${relation.name}")), + arguments = arguments, + resolve = (ctx) => + new UnsetRelation(relation = relation, + fromModel = fromModel, + project = project, + args = ctx.args, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + ) + } + + val idArgument = Argument("id", IDType) + + def getUpdateItemField(model: models.Model): Field[UserContext, Unit] = { + val arguments = UpdateDefinition(argumentSchema, project, inputTypesBuilder).getSangriaArguments(model = model) + + Field( + s"update${model.name}", + fieldType = OptionType( + outputMapper + .mapUpdateOutputType(model, modelObjectTypes(model.name))), + arguments = arguments, + resolve = (ctx) => { + ctx.ctx.mutationQueryWhitelist + .registerWhitelist(s"update${model.name}", outputMapper.nodePaths(model), argumentSchema.inputWrapper, ctx) + new Update(model = model, project = project, args = ctx.args, dataResolver = ctx.ctx.dataResolver, argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + } + ) + } + + def getUpdateOrCreateItemField(model: models.Model): Field[UserContext, Unit] = { + val arguments = UpdateOrCreateDefinition(argumentSchema, project, inputTypesBuilder).getSangriaArguments(model = model) + + Field( + s"updateOrCreate${model.name}", + fieldType = OptionType(outputMapper.mapUpdateOrCreateOutputType(model, modelObjectTypes(model.name))), + arguments = arguments, + resolve = (ctx) => { + ctx.ctx.mutationQueryWhitelist.registerWhitelist(s"updateOrCreate${model.name}", outputMapper.nodePaths(model), argumentSchema.inputWrapper, ctx) + new UpdateOrCreate(model = model, project = project, args = ctx.args, dataResolver = ctx.ctx.dataResolver, argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + } + ) + } + + def getDeleteItemField(model: models.Model): Field[UserContext, Unit] = { + + val arguments = DeleteDefinition(argumentSchema, project).getSangriaArguments(model = model) + + Field( + s"delete${model.name}", + fieldType = OptionType(outputMapper.mapDeleteOutputType(model, modelObjectTypes(model.name))), + arguments = arguments, + resolve = (ctx) => { + ctx.ctx.mutationQueryWhitelist.registerWhitelist(s"delete${model.name}", outputMapper.nodePaths(model), argumentSchema.inputWrapper, ctx) + new Delete(model = model, + modelObjectTypes = modelObjectTypesBuilder, + project = project, + args = ctx.args, + dataResolver = ctx.ctx.dataResolver, + argumentSchema = argumentSchema) + .run(ctx.ctx.authenticatedRequest, ctx.ctx) + .map(outputMapper.mapResolve(_, ctx.args)) + } + ) + } + + def getIntegrationFields: List[Field[UserContext, Unit]] + + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any): JsValue = x match { + case m: Map[_, _] => JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(x: JsValue): Any = { + x match { + case l: JsArray => l.elements.map(read).toList + case m: JsObject => m.fields.mapValues(write) + case s: JsString => s.value + case n: JsNumber => n.value + case b: JsBoolean => b.value + case JsNull => null + case _ => sys.error("implement all scalar types!") + } + } + } + + lazy val myMapFormat: JsonFormat[Map[String, Any]] = { + import DefaultJsonProtocol._ + mapFormat[String, Any] + } +} + +class PluralsCache { + private val cache = mutable.Map.empty[Model, String] + + def pluralName(model: Model): String = cache.getOrElseUpdate( + key = model, + op = English.plural(model.name).capitalize + ) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelayResolveOutput.scala b/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelayResolveOutput.scala new file mode 100644 index 0000000000..4d42541479 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelayResolveOutput.scala @@ -0,0 +1,6 @@ +package cool.graph.client.schema.relay + +import cool.graph.DataItem +import sangria.schema.Args + +case class RelayResolveOutput(clientMutationId: String, item: DataItem, args: Args) diff --git a/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelaySchemaModelObjectTypeBuilder.scala b/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelaySchemaModelObjectTypeBuilder.scala new file mode 100644 index 0000000000..64edda668b --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/schema/relay/RelaySchemaModelObjectTypeBuilder.scala @@ -0,0 +1,54 @@ +package cool.graph.client.schema.relay + +import cool.graph.DataItem +import cool.graph.client.database.DeferredTypes.{CountManyModelDeferred, CountToManyDeferred, RelayConnectionOutputType} +import cool.graph.client.database.{ConnectionParentElement, IdBasedConnection, IdBasedConnectionDefinition} +import cool.graph.client.schema.SchemaModelObjectTypesBuilder +import cool.graph.client.{SangriaQueryArguments, UserContext} +import cool.graph.shared.models +import sangria.ast.{Argument => _} +import sangria.schema._ +import scaldi.Injector + +class RelaySchemaModelObjectTypeBuilder(project: models.Project, nodeInterface: Option[InterfaceType[UserContext, DataItem]] = None, modelPrefix: String = "")( + implicit inj: Injector) + extends SchemaModelObjectTypesBuilder[RelayConnectionOutputType](project, nodeInterface, modelPrefix, withRelations = true) { + + val modelConnectionTypes = includedModels + .map(model => (model.name, modelToConnectionType(model).connectionType)) + .toMap + + val modelEdgeTypes = includedModels + .map(model => (model.name, modelToConnectionType(model).edgeType)) + .toMap + + def modelToConnectionType(model: models.Model): IdBasedConnectionDefinition[UserContext, IdBasedConnection[DataItem], DataItem] = { + IdBasedConnection.definition[UserContext, IdBasedConnection, DataItem]( + name = modelPrefix + model.name, + nodeType = modelObjectTypes(model.name), + connectionFields = List( + sangria.schema.Field( + "count", + IntType, + Some("Count of filtered result set without considering pagination arguments"), + resolve = ctx => { + val countArgs = ctx.value.parent.args.map(args => SangriaQueryArguments.createSimpleQueryArguments(None, None, None, None, None, args.filter, None)) + + ctx.value.parent match { + case ConnectionParentElement(Some(nodeId), Some(field), _) => + CountToManyDeferred(field, nodeId, countArgs) + case _ => + CountManyModelDeferred(model, countArgs) + } + } + )) + ) + } + + override def resolveConnection(field: models.Field): OutputType[Any] = { + field.isList match { + case true => modelConnectionTypes(field.relatedModel(project).get.name) + case false => modelObjectTypes(field.relatedModel(project).get.name) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/schema/simple/SimpleArgumentSchema.scala b/server/client-shared/src/main/scala/cool/graph/client/schema/simple/SimpleArgumentSchema.scala new file mode 100644 index 0000000000..ca86682a3a --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/schema/simple/SimpleArgumentSchema.scala @@ -0,0 +1,29 @@ +package cool.graph.client.schema.simple + +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.util.coolSangria.FromInputImplicit +import cool.graph.{ArgumentSchema, SchemaArgument} +import sangria.schema.{Args, Argument} + +object SimpleArgumentSchema extends ArgumentSchema { + + implicit val anyFromInput = FromInputImplicit.CoercedResultMarshaller + + override def convertSchemaArgumentsToSangriaArguments(argumentGroupName: String, args: List[SchemaArgument]): List[Argument[Any]] = { + args.map(_.asSangriaArgument) + } + + override def extractArgumentValues(args: Args, argumentDefinitions: List[SchemaArgument]): List[ArgumentValue] = { + argumentDefinitions + .filter(a => args.raw.contains(a.name)) + .map { a => + val value = args.raw.get(a.name) match { + case Some(Some(v)) => v + case Some(v) => v + case v => v + } + val argName = a.field.map(_.name).getOrElse(a.name) + ArgumentValue(argName, value, a.field) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/ClientServer.scala b/server/client-shared/src/main/scala/cool/graph/client/server/ClientServer.scala new file mode 100644 index 0000000000..4c7747a7b8 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/ClientServer.scala @@ -0,0 +1,141 @@ +package cool.graph.client.server + +import akka.actor.ActorSystem +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers.RawHeader +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.PathMatchers.Segment +import akka.http.scaladsl.server._ +import akka.stream.ActorMaterializer +import com.amazonaws.services.kinesis.AmazonKinesis +import com.typesafe.scalalogging.LazyLogging +import cool.graph.bugsnag.{BugSnagger, GraphCoolRequest} +import cool.graph.client.authorization.ClientAuth +import cool.graph.client.finder.ProjectFetcher +import cool.graph.private_api.PrivateClientApi +import cool.graph.shared.errors.CommonErrors.TimeoutExceeded +import cool.graph.shared.errors.UserAPIErrors.ProjectNotFound +import cool.graph.shared.logging.RequestLogger +import cool.graph.util.ErrorHandlerFactory +import scaldi.{Injectable, Injector} +import spray.json.JsValue + +import scala.concurrent.Future + +case class ClientServer(prefix: String)( + implicit system: ActorSystem, + materializer: ActorMaterializer, + inj: Injector, + bugsnagger: BugSnagger +) extends cool.graph.akkautil.http.Server + with Injectable + with LazyLogging { + import system.dispatcher + + val log = (x: String) => logger.info(x) + val kinesis = inject[AmazonKinesis](identified by "kinesis") + val errorHandlerFactory = ErrorHandlerFactory(log) + val projectSchemaFetcher = inject[ProjectFetcher](identified by "project-schema-fetcher") + val graphQlRequestHandler = inject[GraphQlRequestHandler](identified by s"$prefix-gql-request-handler") + val projectSchemaBuilder = inject[ProjectSchemaBuilder](identified by s"$prefix-schema-builder") + val clientAuth = inject[ClientAuth] + val requestPrefix = inject[String](identified by "request-prefix") + val requestIdPrefix = s"$requestPrefix:$prefix" + + private val requestHandler = RequestHandler(errorHandlerFactory, projectSchemaFetcher, projectSchemaBuilder, graphQlRequestHandler, clientAuth, log) + + override def healthCheck: Future[_] = + for { + _ <- graphQlRequestHandler.healthCheck + _ <- Future { + try { kinesis.listStreams() } catch { + case e: com.amazonaws.services.kinesis.model.LimitExceededException => + true + } + } + } yield () + + val innerRoutes: Route = extractRequest { _ => + val requestLogger = new RequestLogger(requestIdPrefix = requestIdPrefix, log = log) + requestLogger.begin + + handleExceptions(toplevelExceptionHandler(requestLogger.requestId)) { + PrivateClientApi().privateRoute ~ pathPrefix("v1") { + pathPrefix(Segment) { projectId => + get { + path("schema.json") { + complete(requestHandler.handleIntrospectionQuery(projectId, requestLogger)) + } ~ { + getFromResource("graphiql.html") + } + } ~ + post { + path("permissions") { + extractRawRequest(requestLogger) { rawRequest => + complete(requestHandler.handleRawRequestForPermissionSchema(projectId = projectId, rawRequest = rawRequest)) + } + } ~ { + extractRawRequest(requestLogger) { rawRequest => + timeoutHandler(requestId = rawRequest.id, projectId = projectId) { + complete(requestHandler.handleRawRequestForProjectSchema(projectId = projectId, rawRequest = rawRequest)) + } + } + } + } + } + } + } + } + + def extractRawRequest(requestLogger: RequestLogger)(fn: RawRequest => Route): Route = { + optionalHeaderValueByName("Authorization") { authorizationHeader => + optionalHeaderValueByName("x-graphcool-source") { graphcoolSourceHeader => + entity(as[JsValue]) { requestJson => + extractClientIP { clientIp => + respondWithHeader(RawHeader("Request-Id", requestLogger.requestId)) { + fn( + RawRequest( + json = requestJson, + ip = clientIp.toString, + sourceHeader = graphcoolSourceHeader, + authorizationHeader = authorizationHeader, + logger = requestLogger + ) + ) + } + } + } + } + } + } + + def timeoutHandler(requestId: String, projectId: String): Directive0 = { + withRequestTimeoutResponse { _ => + val unhandledErrorLogger = errorHandlerFactory.unhandledErrorHandler( + requestId = requestId, + projectId = Some(projectId) + ) + val error = TimeoutExceeded() + val errorResponse = unhandledErrorLogger(error) + HttpResponse(errorResponse._1, entity = errorResponse._2.prettyPrint) + } + } + + def toplevelExceptionHandler(requestId: String) = ExceptionHandler { + case e: Throwable => + val request = GraphCoolRequest( + requestId = requestId, + clientId = None, + projectId = None, + query = "", + variables = "" + ) + + if (!e.isInstanceOf[ProjectNotFound]) { + bugsnagger.report(e, request) + } + + errorHandlerFactory.akkaHttpHandler(requestId)(e) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/GraphQlRequestHandler.scala b/server/client-shared/src/main/scala/cool/graph/client/server/GraphQlRequestHandler.scala new file mode 100644 index 0000000000..6c533960ff --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/GraphQlRequestHandler.scala @@ -0,0 +1,91 @@ +package cool.graph.client.server + +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.StatusCodes.OK +import cool.graph.client.FeatureMetric.FeatureMetric +import cool.graph.client.database.DeferredResolverProvider +import cool.graph.client.metrics.ApiMetricsMiddleware +import cool.graph.client.{ProjectLockdownMiddleware, UserContext} +import cool.graph.util.ErrorHandlerFactory +import sangria.execution.{ErrorWithResolver, Executor, QueryAnalysisError} +import scaldi.Injector +import spray.json.{JsArray, JsValue} + +import scala.collection.immutable.Seq +import scala.concurrent.{ExecutionContext, Future} + +trait GraphQlRequestHandler { + def handle(graphQlRequest: GraphQlRequest): Future[(StatusCode, JsValue)] + + def healthCheck: Future[Unit] +} + +case class GraphQlRequestHandlerImpl[ConnectionOutputType]( + errorHandlerFactory: ErrorHandlerFactory, + log: String => Unit, + apiVersionMetric: FeatureMetric, + apiMetricsMiddleware: ApiMetricsMiddleware, + deferredResolver: DeferredResolverProvider[ConnectionOutputType, UserContext] +)(implicit ec: ExecutionContext, inj: Injector) + extends GraphQlRequestHandler { + import cool.graph.shared.schema.JsonMarshalling._ + + override def handle(graphQlRequest: GraphQlRequest): Future[(StatusCode, JsValue)] = { + val jsonResult = if (!graphQlRequest.isBatch) { + handleQuery(request = graphQlRequest, query = graphQlRequest.queries.head) + } else { + val results: Seq[Future[JsValue]] = graphQlRequest.queries.map(query => handleQuery(graphQlRequest, query)) + Future.sequence(results).map(results => JsArray(results.toVector)) + } + jsonResult.map(OK -> _) + } + + def handleQuery( + request: GraphQlRequest, + query: GraphQlQuery + ): Future[JsValue] = { + val (sangriaErrorHandler, unhandledErrorLogger) = errorHandlerFactory.sangriaAndUnhandledHandlers( + requestId = request.id, + query = query.queryString, + variables = query.variables, + clientId = Some(request.projectWithClientId.clientId), + projectId = Some(request.projectWithClientId.id) + ) + request.logger.query(query.queryString, query.variables.prettyPrint) + + val context = UserContext.fetchUserProjectWithClientId( + authenticatedRequest = request.authorization, + requestId = request.id, + requestIp = request.ip, + project = request.projectWithClientId, + log = log, + queryAst = Some(query.query) + ) + context.addFeatureMetric(apiVersionMetric) + context.graphcoolHeader = request.sourceHeader + + val result = Executor.execute( + schema = request.schema, + queryAst = query.query, + userContext = context, + variables = query.variables, + exceptionHandler = sangriaErrorHandler, + operationName = query.operationName, + deferredResolver = deferredResolver, + middleware = List(apiMetricsMiddleware, ProjectLockdownMiddleware(request.project)) + ) + + result.recover { + case error: QueryAnalysisError => + error.resolveError + case error: ErrorWithResolver => + unhandledErrorLogger(error) + error.resolveError + case error: Throwable ⇒ + unhandledErrorLogger(error)._2 + + } + } + + override def healthCheck: Future[Unit] = Future.successful(()) +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/HealthChecks.scala b/server/client-shared/src/main/scala/cool/graph/client/server/HealthChecks.scala new file mode 100644 index 0000000000..e90872d417 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/HealthChecks.scala @@ -0,0 +1,21 @@ +package cool.graph.client.server + +import cool.graph.shared.database.GlobalDatabaseManager +import slick.jdbc.MySQLProfile.api._ + +import scala.concurrent.{ExecutionContext, Future} + +object HealthChecks { + def checkDatabases(globalDatabaseManager: GlobalDatabaseManager)(implicit ec: ExecutionContext): Future[Unit] = { + Future + .sequence { + globalDatabaseManager.databases.values.map { db => + for { + _ <- db.master.run(sql"SELECT 1".as[Int]) + _ <- db.readOnly.run(sql"SELECT 1".as[Int]) + } yield () + } + } + .map(_ => ()) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/IntrospectionQueryHandler.scala b/server/client-shared/src/main/scala/cool/graph/client/server/IntrospectionQueryHandler.scala new file mode 100644 index 0000000000..4fdda7e565 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/IntrospectionQueryHandler.scala @@ -0,0 +1,38 @@ +package cool.graph.client.server + +import cool.graph.client.UserContext +import cool.graph.shared.models.Project +import sangria.execution.Executor +import sangria.introspection.introspectionQuery +import sangria.schema.Schema +import scaldi.Injector +import spray.json.JsValue + +import scala.concurrent.{ExecutionContext, Future} + +case class IntrospectionQueryHandler( + project: Project, + schema: Schema[UserContext, Unit], + onFailureCallback: PartialFunction[Throwable, Any], + log: String => Unit +)(implicit inj: Injector, ec: ExecutionContext) { + + def handle(requestId: String, requestIp: String, clientId: String): Future[JsValue] = { + import cool.graph.shared.schema.JsonMarshalling._ + val context = UserContext.load( + project = project, + requestId = requestId, + requestIp = requestIp, + clientId = clientId, + log = log + ) + + val result = Executor.execute( + schema = schema, + queryAst = introspectionQuery, + userContext = context + ) + result.onFailure(onFailureCallback) + result + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/ProjectSchemaBuilder.scala b/server/client-shared/src/main/scala/cool/graph/client/server/ProjectSchemaBuilder.scala new file mode 100644 index 0000000000..639d903b05 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/ProjectSchemaBuilder.scala @@ -0,0 +1,15 @@ +package cool.graph.client.server + +import cool.graph.client.UserContext +import cool.graph.shared.models.Project +import sangria.schema.Schema + +trait ProjectSchemaBuilder { + def build(project: Project): Schema[UserContext, Unit] +} + +object ProjectSchemaBuilder { + def apply(fn: Project => Schema[UserContext, Unit]): ProjectSchemaBuilder = new ProjectSchemaBuilder { + override def build(project: Project) = fn(project) + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/RequestHandler.scala b/server/client-shared/src/main/scala/cool/graph/client/server/RequestHandler.scala new file mode 100644 index 0000000000..29866eff4e --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/RequestHandler.scala @@ -0,0 +1,179 @@ +package cool.graph.client.server + +import akka.http.scaladsl.model.StatusCodes.OK +import akka.http.scaladsl.model._ +import cool.graph.bugsnag.{BugSnagger, GraphCoolRequest} +import cool.graph.client.UserContext +import cool.graph.client.authorization.ClientAuth +import cool.graph.client.finder.ProjectFetcher +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.errors.UserAPIErrors.InsufficientPermissions +import cool.graph.shared.logging.RequestLogger +import cool.graph.shared.models.{AuthenticatedRequest, Project, ProjectWithClientId} +import cool.graph.shared.queryPermissions.PermissionSchemaResolver +import cool.graph.util.ErrorHandlerFactory +import cool.graph.utils.`try`.TryExtensions._ +import cool.graph.utils.future.FutureUtils.FutureExtensions +import sangria.schema.Schema +import scaldi.Injector +import spray.json.{JsObject, JsString, JsValue} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +case class RequestHandler( + errorHandlerFactory: ErrorHandlerFactory, + projectSchemaFetcher: ProjectFetcher, + projectSchemaBuilder: ProjectSchemaBuilder, + graphQlRequestHandler: GraphQlRequestHandler, + clientAuth: ClientAuth, + log: Function[String, Unit] +)(implicit + bugsnagger: BugSnagger, + inj: Injector, + ec: ExecutionContext) { + + def handleIntrospectionQuery(projectId: String, requestLogger: RequestLogger): Future[JsValue] = { + for { + project <- fetchProject(projectId) + schema = projectSchemaBuilder.build(project.project) + introspectionQueryHandler = IntrospectionQueryHandler( + project = project.project, + schema = schema, + onFailureCallback = onFailureCallback(requestLogger.requestId, project), + log = log + ) + resultFuture = introspectionQueryHandler.handle(requestId = requestLogger.requestId, requestIp = "not-used", clientId = project.clientId) + _ = resultFuture.onComplete(_ => requestLogger.end(Some(project.project.id), Some(project.clientId))) + result <- resultFuture + } yield result + } + + def onFailureCallback(requestId: String, project: ProjectWithClientId): PartialFunction[Throwable, Any] = { + case t: Throwable => + val request = GraphCoolRequest( + requestId = requestId, + clientId = Some(project.clientId), + projectId = Some(project.project.id), + query = "", + variables = "" + ) + + bugsnagger.report(t, request) + } + + def handleRawRequestForPermissionSchema( + projectId: String, + rawRequest: RawRequest + ): Future[(StatusCode, JsValue)] = { + def checkIfUserMayQueryPermissionSchema(auth: Option[AuthenticatedRequest]): Unit = { + val mayQueryPermissionSchema = auth.exists(_.isAdmin) + if (!mayQueryPermissionSchema) { + throw InsufficientPermissions("Insufficient permissions for this query") + } + } + + handleRawRequest( + projectId = projectId, + rawRequest = rawRequest, + schemaFn = PermissionSchemaResolver.permissionSchema, + checkAuthFn = checkIfUserMayQueryPermissionSchema + ) + } + + def handleRawRequestForProjectSchema( + projectId: String, + rawRequest: RawRequest + ): Future[(StatusCode, JsValue)] = handleRawRequest(projectId, rawRequest, projectSchemaBuilder.build) + + def handleRawRequest( + projectId: String, + rawRequest: RawRequest, + schemaFn: Project => Schema[UserContext, Unit], + checkAuthFn: Option[AuthenticatedRequest] => Unit = _ => () + ): Future[(StatusCode, JsValue)] = { + val graphQlRequestFuture = for { + projectWithClientId <- fetchProject(projectId) + authenticatedRequest <- getAuthContext(projectWithClientId, rawRequest.authorizationHeader) + _ = checkAuthFn(authenticatedRequest) + schema = schemaFn(projectWithClientId.project) + graphQlRequest <- rawRequest.toGraphQlRequest(authenticatedRequest, projectWithClientId, schema).toFuture + } yield graphQlRequest + + graphQlRequestFuture.toFutureTry.flatMap { + case Success(graphQlRequest) => + handleGraphQlRequest(graphQlRequest) + + case Failure(e: InvalidGraphQlRequest) => + Future.successful(OK -> JsObject("error" -> JsString(e.underlying.getMessage))) + + case Failure(e) => + val unhandledErrorLogger = errorHandlerFactory.unhandledErrorHandler( + requestId = rawRequest.id, + query = rawRequest.json.toString, + projectId = Some(projectId) + ) + Future.successful(unhandledErrorLogger(e)) + } + } + + def handleGraphQlRequest(graphQlRequest: GraphQlRequest): Future[(StatusCode, JsValue)] = { + val resultFuture = graphQlRequestHandler.handle(graphQlRequest) + resultFuture.onComplete(_ => graphQlRequest.logger.end(Some(graphQlRequest.project.id), Some(graphQlRequest.projectWithClientId.clientId))) + + resultFuture.recover { + case error: Throwable => + val unhandledErrorLogger = errorHandlerFactory.unhandledErrorHandler( + requestId = graphQlRequest.id, + query = graphQlRequest.json.toString, + clientId = Some(graphQlRequest.projectWithClientId.clientId), + projectId = Some(graphQlRequest.projectWithClientId.id) + ) + unhandledErrorLogger(error) + } + } + + def fetchProject(projectId: String): Future[ProjectWithClientId] = { + val result = projectSchemaFetcher.fetch(projectIdOrAlias = projectId) + + result.onFailure { + case t => + val request = GraphCoolRequest(requestId = "", clientId = None, projectId = Some(projectId), query = "", variables = "") + bugsnagger.report(t, request) + } + + result map { + case None => throw UserAPIErrors.ProjectNotFound(projectId) + case Some(schema) => schema + } + } + + private def getAuthContext( + projectWithClientId: ProjectWithClientId, + authorizationHeader: Option[String] + ): Future[Option[AuthenticatedRequest]] = { + + authorizationHeader match { + case Some(header) if header.startsWith("Bearer") => +// ToDo +// The validation is correct but the error message that the token is valid, but user is not a collaborator seems off +// For now revert to the old state of returning None for a failed Auth Token and no error +// val res = ClientAuth() +// .authenticateRequest(header.stripPrefix("Bearer "), projectWithClientId.project) +// .toFutureTry +// +// res.flatMap { +// case Failure(e: Exception) => Future.failed(InvalidGraphQlRequest(e)) +// case Success(a: AuthenticatedRequest) => Future.successful(Some(a)) +// case _ => Future.successful(None) +// } + + clientAuth + .authenticateRequest(header.stripPrefix("Bearer "), projectWithClientId.project) + .toFutureTry + .map(_.toOption) + case _ => + Future.successful(None) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/client/server/RequestLifecycle.scala b/server/client-shared/src/main/scala/cool/graph/client/server/RequestLifecycle.scala new file mode 100644 index 0000000000..433a9a9f78 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/client/server/RequestLifecycle.scala @@ -0,0 +1,131 @@ +package cool.graph.client.server + +import cool.graph.client.UserContext +import cool.graph.shared.errors.CommonErrors.InputCompletelyMalformed +import cool.graph.shared.errors.UserAPIErrors.VariablesParsingError +import cool.graph.shared.logging.RequestLogger +import cool.graph.shared.models.{AuthenticatedRequest, Project, ProjectWithClientId} +import cool.graph.utils.`try`.TryUtil +import sangria.parser.QueryParser +import sangria.schema.Schema +import spray.json.{JsArray, JsObject, JsValue} +import spray.json.JsonParser.ParsingException + +import scala.util.{Failure, Try} + +trait RawRequestAttributes { + val json: JsValue + val ip: String + val sourceHeader: Option[String] + val logger: RequestLogger +} + +case class RawRequest( + json: JsValue, + ip: String, + sourceHeader: Option[String], + authorizationHeader: Option[String], + logger: RequestLogger +) extends RawRequestAttributes { + + val id = logger.requestId + + def toGraphQlRequest( + authorization: Option[AuthenticatedRequest], + project: ProjectWithClientId, + schema: Schema[UserContext, Unit] + ): Try[GraphQlRequest] = { + val queries: Try[Vector[GraphQlQuery]] = TryUtil.sequence { + json match { + case JsArray(requests) => requests.map(GraphQlQuery.tryFromJson) + case request: JsObject => Vector(GraphQlQuery.tryFromJson(request)) + case malformed => Vector(Failure(InputCompletelyMalformed(malformed.toString))) + } + } + val isBatch = json match { + case JsArray(_) => true + case _ => false + } + queries + .map { queries => + GraphQlRequest( + rawRequest = this, + authorization = authorization, + logger = logger, + projectWithClientId = project, + schema = schema, + queries = queries, + isBatch = isBatch + ) + } + .recoverWith { + case exception => Failure(InvalidGraphQlRequest(exception)) + } + } +} +case class InvalidGraphQlRequest(underlying: Throwable) extends Exception +// To support Apollos transport-level query batching we treat input and output as a list +// If multiple queries are supplied they are all executed individually and in parallel +// See +// https://dev-blog.apollodata.com/query-batching-in-apollo-63acfd859862#.g733sm6bj +// https://github.com/apollostack/graphql-server/blob/master/packages/graphql-server-core/src/runHttpQuery.ts#L69 + +case class GraphQlRequest( + rawRequest: RawRequest, + authorization: Option[AuthenticatedRequest], + logger: RequestLogger, + projectWithClientId: ProjectWithClientId, + schema: Schema[UserContext, Unit], + queries: Vector[GraphQlQuery], + isBatch: Boolean +) extends RawRequestAttributes { + override val json: JsValue = rawRequest.json + override val ip: String = rawRequest.ip + override val sourceHeader: Option[String] = rawRequest.sourceHeader + val id: String = logger.requestId + val project: Project = projectWithClientId.project + +} + +case class GraphQlQuery( + query: sangria.ast.Document, + operationName: Option[String], + variables: JsValue, + queryString: String +) + +object GraphQlQuery { + def tryFromJson(requestJson: JsValue): Try[GraphQlQuery] = { + import spray.json._ + val JsObject(fields) = requestJson + val query = fields.get("query") match { + case Some(JsString(query)) => query + case _ => "" + } + + val operationName = fields.get("operationName") collect { + case JsString(op) if !op.isEmpty ⇒ op + } + + val variables = fields.get("variables") match { + case Some(obj: JsObject) => obj + case Some(JsString(s)) if s.trim.nonEmpty => + (try { s.parseJson } catch { + case e: ParsingException => throw VariablesParsingError(s) + }) match { + case json: JsObject => json + case _ => JsObject.empty + } + case _ => JsObject.empty + } + + QueryParser.parse(query).map { queryAst => + GraphQlQuery( + query = queryAst, + queryString = query, + operationName = operationName, + variables = variables + ) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackSchemaExecutor.scala b/server/client-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackSchemaExecutor.scala new file mode 100644 index 0000000000..a1e45c8bd1 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/deprecated/actions/MutationCallbackSchemaExecutor.scala @@ -0,0 +1,62 @@ +package cool.graph.deprecated.actions + +import com.typesafe.scalalogging.LazyLogging +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.cuid.Cuid.createCuid +import cool.graph.deprecated.actions.schemas.{ActionUserContext, MutationMetaData} +import cool.graph.shared.models.{Model, Project} +import cool.graph.shared.schema.JsonMarshalling._ +import sangria.execution.Executor +import sangria.parser.QueryParser +import sangria.schema.Schema +import scaldi.{Injectable, Injector} +import spray.json.{JsObject, JsString} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.{Failure, Success} + +case class Event(id: String, url: String, payload: Option[JsObject]) + +class MutationCallbackSchemaExecutor(project: Project, + model: Model, + schema: Schema[ActionUserContext, Unit], + nodeId: String, + fragment: String, + url: String, + mutationId: String)(implicit inj: Injector) + extends Injectable + with LazyLogging { + def execute: Future[Event] = { + val dataFut = QueryParser.parse(fragment) match { + case Success(queryAst) => + Executor.execute( + schema, + queryAst, + deferredResolver = new DeferredResolverProvider( + new SimpleToManyDeferredResolver, + new SimpleManyModelDeferredResolver, + skipPermissionCheck = true + ), + userContext = ActionUserContext( + requestId = "", + project = project, + nodeId = nodeId, + mutation = MutationMetaData(id = mutationId, _type = "Create"), + log = (x: String) => logger.info(x) + ) + ) + case Failure(error) => + Future.successful(JsObject("error" -> JsString(error.getMessage))) + } + + dataFut + .map { + case JsObject(dataMap) => + Event(id = createCuid(), url = url, payload = Some(dataMap("data").asJsObject)) + case json => + sys.error(s"Must only receive JsObjects here. But got instead: ${json.compactPrint}") + } + + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/private_api/PrivateClientApi.scala b/server/client-shared/src/main/scala/cool/graph/private_api/PrivateClientApi.scala new file mode 100644 index 0000000000..8629a93e68 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/private_api/PrivateClientApi.scala @@ -0,0 +1,129 @@ +package cool.graph.private_api + +import akka.http.scaladsl.model.StatusCode +import akka.http.scaladsl.model.StatusCodes._ +import akka.http.scaladsl.server.Directives._ +import com.typesafe.config.Config +import cool.graph.client.finder.RefreshableProjectFetcher +import cool.graph.private_api.schema.PrivateSchemaBuilder +import cool.graph.cuid.Cuid +import cool.graph.shared.errors.UserAPIErrors +import cool.graph.shared.models.Project +import cool.graph.util.ErrorHandlerFactory +import cool.graph.util.json.PlaySprayConversions +import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport +import play.api.libs.json.{JsObject, JsValue, Json} +import sangria.ast.Document +import sangria.execution.{ErrorWithResolver, Executor, QueryAnalysisError} +import sangria.parser.QueryParser +import scaldi.{Injectable, Injector} + +import scala.concurrent.Future +import scala.util.{Failure, Success} + +case class GraphQlRequest(query: String, operationName: Option[String] = None, variables: Option[JsValue] = None) + +object GraphQlRequest { + implicit lazy val reads = Json.reads[GraphQlRequest] +} + +object PrivateClientApi extends Injectable { + def apply()(implicit inj: Injector): PrivateClientApi = { + val projectSchemaFetcher = inject[RefreshableProjectFetcher](identified by "project-schema-fetcher") + val config = inject[Config](identified by "config") + val secret = config.getString("privateClientApiSecret") + + new PrivateClientApi(projectSchemaFetcher, secret) + } +} + +class PrivateClientApi(projectSchemaFetcher: RefreshableProjectFetcher, secret: String)(implicit inj: Injector) + extends PlayJsonSupport + with Injectable + with PlaySprayConversions { + import GraphQlRequest.reads + import sangria.marshalling.playJson._ + + import scala.concurrent.ExecutionContext.Implicits.global + + val errorHandlerFactory = ErrorHandlerFactory(println) + + def privateRoute = { + pathPrefix("private") { + pathPrefix(Segment) { projectId => + post { + optionalHeaderValueByName("Authorization") { authHeader => + if (!authHeader.contains(secret)) { + complete(Forbidden) + } else { + entity(as[GraphQlRequest]) { graphQlRequest => + complete { + performQuery(projectId, graphQlRequest) + } + } + } + } + } + } + } + } + + def performQuery(projectId: String, graphqlRequest: GraphQlRequest): Future[(StatusCode, JsValue)] = { + QueryParser.parse(graphqlRequest.query) match { + case Failure(error) => Future.successful(BadRequest -> Json.obj("error" -> error.getMessage)) + case Success(queryAst) => performQuery(projectId, graphqlRequest, queryAst) + } + } + + def performQuery(projectId: String, graphqlRequest: GraphQlRequest, queryAst: Document): Future[(StatusCode, JsValue)] = { + val GraphQlRequest(query, _, variables) = graphqlRequest + val requestId = Cuid.createCuid() + val unhandledErrorHandler = errorHandlerFactory.unhandledErrorHandler( + requestId = requestId, + query = query, + variables = variables.getOrElse(Json.obj()).toSpray, + clientId = None, + projectId = Some(projectId) + ) + + val sangriaHandler = errorHandlerFactory.sangriaHandler( + requestId = requestId, + query = query, + variables = variables.getOrElse(JsObject.empty).toSpray, + clientId = None, + projectId = Some(projectId) + ) + + val result = for { + project <- getProjectByIdRefreshed(projectId) + result <- Executor.execute( + schema = new PrivateSchemaBuilder(project).build(), + queryAst = queryAst, + operationName = graphqlRequest.operationName, + variables = graphqlRequest.variables.getOrElse(JsObject.empty), + exceptionHandler = sangriaHandler + ) + } yield { + (OK: StatusCode, result) + } + + result.recover { + case error: QueryAnalysisError => + (BadRequest, error.resolveError) + + case error: ErrorWithResolver => + (InternalServerError, error.resolveError) + + case error => + val (statusCode, sprayJson) = unhandledErrorHandler(error) + (statusCode, sprayJson.toPlay) + } + } + + def getProjectByIdRefreshed(projectId: String): Future[Project] = { + projectSchemaFetcher.fetchRefreshed(projectIdOrAlias = projectId) map { + case None => throw UserAPIErrors.ProjectNotFound(projectId) + case Some(projectWithClientId) => projectWithClientId.project + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/private_api/mutations/PrivateMutation.scala b/server/client-shared/src/main/scala/cool/graph/private_api/mutations/PrivateMutation.scala new file mode 100644 index 0000000000..8790f65267 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/private_api/mutations/PrivateMutation.scala @@ -0,0 +1,26 @@ +package cool.graph.private_api.mutations + +import cool.graph.Mutaction +import cool.graph.shared.errors.GeneralError + +import scala.concurrent.{ExecutionContext, Future} + +trait PrivateMutation[T] { + def execute()(implicit ec: ExecutionContext): Future[T] = { + for { + mutactions <- prepare + results <- Future.sequence(mutactions.map(_.execute)) + errors = results.collect { case e: GeneralError => e } + } yield { + if (errors.nonEmpty) { + throw errors.head + } else { + result + } + } + } + + def prepare: Future[List[Mutaction]] + + def result: T +} diff --git a/server/client-shared/src/main/scala/cool/graph/private_api/mutations/SyncModelToAlgoliaMutation.scala b/server/client-shared/src/main/scala/cool/graph/private_api/mutations/SyncModelToAlgoliaMutation.scala new file mode 100644 index 0000000000..ef3fc4caf0 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/private_api/mutations/SyncModelToAlgoliaMutation.scala @@ -0,0 +1,43 @@ +package cool.graph.private_api.mutations + +import cool.graph._ +import cool.graph.client.database.DataResolver +import cool.graph.client.mutactions.SyncModelToAlgolia +import cool.graph.shared.models.Project +import sangria.relay.Mutation +import scaldi.Injector + +import scala.concurrent.Future + +case class SyncModelToAlgoliaMutation(project: Project, input: SyncModelToAlgoliaInput, dataResolver: DataResolver)(implicit inj: Injector) + extends PrivateMutation[SyncModelToAlgoliaPayload] { + + val model = project.getModelById_!(input.modelId) + + val searchProvider = project.getSearchProviderAlgoliaByAlgoliaSyncQueryId_!(input.syncQueryId) + val syncQuery = project.getAlgoliaSyncQueryById_!(input.syncQueryId) + + override def prepare(): Future[List[Mutaction]] = { + Future.successful { + List( + SyncModelToAlgolia( + model = model, + project = project, + syncQuery = syncQuery, + searchProviderAlgolia = searchProvider, + requestId = dataResolver.requestContext.map(_.requestId).getOrElse("") + ) + ) + } + } + + override val result = SyncModelToAlgoliaPayload(input.clientMutationId) +} + +case class SyncModelToAlgoliaInput( + clientMutationId: Option[String], + modelId: String, + syncQueryId: String +) + +case class SyncModelToAlgoliaPayload(clientMutationId: Option[String]) extends Mutation diff --git a/server/client-shared/src/main/scala/cool/graph/private_api/schema/PrivateSchemaBuilder.scala b/server/client-shared/src/main/scala/cool/graph/private_api/schema/PrivateSchemaBuilder.scala new file mode 100644 index 0000000000..323ff9cc09 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/private_api/schema/PrivateSchemaBuilder.scala @@ -0,0 +1,50 @@ +package cool.graph.private_api.schema + +import cool.graph.client.database.{DataResolver, ProjectDataresolver} +import cool.graph.private_api.mutations.{SyncModelToAlgoliaInput, SyncModelToAlgoliaMutation, SyncModelToAlgoliaPayload} +import cool.graph.shared.models.Project +import sangria.relay.Mutation +import sangria.schema._ +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext + +class PrivateSchemaBuilder(project: Project)(implicit inj: Injector, ec: ExecutionContext) extends Injectable { + + def build(): Schema[Unit, Unit] = { + val query = ObjectType[Unit, Unit]("Query", List(dummyField)) + val mutation = ObjectType( + "Mutation", + fields[Unit, Unit]( + getSyncModelToAlgoliaField() + ) + ) + Schema(query, Some(mutation)) + } + + def getSyncModelToAlgoliaField(): Field[Unit, Unit] = { + import SyncModelToAlgoliaMutationFields.manual + Mutation.fieldWithClientMutationId[Unit, Unit, SyncModelToAlgoliaPayload, SyncModelToAlgoliaInput]( + fieldName = "syncModelToAlgolia", + typeName = "SyncModelToAlgolia", + inputFields = SyncModelToAlgoliaMutationFields.inputFields, + outputFields = fields( + Field("foo", fieldType = StringType, resolve = _ => "bar") + ), + mutateAndGetPayload = (input, _) => { + for { + payload <- SyncModelToAlgoliaMutation(project, input, dataResolver(project)).execute() + } yield payload + } + ) + } + + val dummyField: Field[Unit, Unit] = Field( + "dummy", + description = Some("This is only a dummy field due to the API of Schema of Sangria, as Query is not optional"), + fieldType = StringType, + resolve = _ => "" + ) + + def dataResolver(project: Project)(implicit inj: Injector): DataResolver = new ProjectDataresolver(project = project, requestContext = None) +} diff --git a/server/client-shared/src/main/scala/cool/graph/private_api/schema/SyncModelToAlgolia.scala b/server/client-shared/src/main/scala/cool/graph/private_api/schema/SyncModelToAlgolia.scala new file mode 100644 index 0000000000..0998da213d --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/private_api/schema/SyncModelToAlgolia.scala @@ -0,0 +1,27 @@ +package cool.graph.private_api.schema + +import cool.graph.private_api.mutations.SyncModelToAlgoliaInput +import sangria.marshalling.{CoercedScalaResultMarshaller, FromInput} +import sangria.schema.{IDType, InputField} + +object SyncModelToAlgoliaMutationFields { + + val inputFields = + List( + InputField("modelId", IDType, description = ""), + InputField("syncQueryId", IDType, description = "") + ).asInstanceOf[List[InputField[Any]]] + + implicit val manual = new FromInput[SyncModelToAlgoliaInput] { + import cool.graph.util.coolSangria.ManualMarshallerHelpers._ + val marshaller = CoercedScalaResultMarshaller.default + + def fromResult(node: marshaller.Node) = { + SyncModelToAlgoliaInput( + clientMutationId = node.clientMutationId, + modelId = node.requiredArgAsString("modelId"), + syncQueryId = node.requiredArgAsString("syncQueryId") + ) + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/relay/schema/RelayArgumentSchema.scala b/server/client-shared/src/main/scala/cool/graph/relay/schema/RelayArgumentSchema.scala new file mode 100644 index 0000000000..a682d13f83 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/relay/schema/RelayArgumentSchema.scala @@ -0,0 +1,52 @@ +package cool.graph.relay.schema + +import cool.graph.shared.mutactions.MutationTypes.ArgumentValue +import cool.graph.util.coolSangria.FromInputImplicit +import cool.graph.{ArgumentSchema, SchemaArgument} +import sangria.schema.{Args, Argument, InputField, InputObjectType} + +object RelayArgumentSchema extends ArgumentSchema { + + implicit val anyFromInput = FromInputImplicit.CoercedResultMarshaller + + val inputObjectName = "input" + val clientMutationIdField = InputField("clientMutationId", sangria.schema.StringType) + + override def inputWrapper: Option[String] = Some(inputObjectName) + + override def convertSchemaArgumentsToSangriaArguments(argumentGroupName: String, arguments: List[SchemaArgument]): List[Argument[Any]] = { + val inputFields = arguments.map(_.asSangriaInputField) + val inputObjectType = InputObjectType(argumentGroupName + "Input", inputFields :+ clientMutationIdField) + val argument = Argument[Any](name = inputObjectName, argumentType = inputObjectType) + List(argument) + } + + override def extractArgumentValues(args: Args, argumentDefinitions: List[SchemaArgument]): List[ArgumentValue] = { + // Unpack input object. + // Per definition, we receive an "input" param that contains an object when using relay. + val argObject: Map[String, Any] = args.raw.get(inputObjectName) match { + case Some(arg) if arg.isInstanceOf[Map[_, _]] => + arg.asInstanceOf[Map[String, Any]] + case Some(arg) => + throw new IllegalArgumentException(s"Expected a map but was: ${arg.getClass}") + // due to the nested mutation api we need to allow this, + // as the nested mutation api is removing the "input" for nested models + case None => + args.raw + } + + val results = argumentDefinitions + .filter(a => argObject.contains(a.name)) + .map(a => { + val value = argObject.get(a.name) match { + case Some(Some(v)) => v + case Some(v) => v + case v => v + } + val argName = a.field.map(_.name).getOrElse(a.name) + ArgumentValue(argName, value, a.field) + }) + + results + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/subscriptions/SubscriptionExecutor.scala b/server/client-shared/src/main/scala/cool/graph/subscriptions/SubscriptionExecutor.scala new file mode 100644 index 0000000000..8e489f0e9b --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/subscriptions/SubscriptionExecutor.scala @@ -0,0 +1,131 @@ +package cool.graph.subscriptions + +import cool.graph.deprecated.actions.schemas.MutationMetaData +import cool.graph.client.database.{DeferredResolverProvider, SimpleManyModelDeferredResolver, SimpleToManyDeferredResolver} +import cool.graph.shared.models.ModelMutationType.ModelMutationType +import cool.graph.shared.models._ +import cool.graph.subscriptions.schemas.{QueryTransformer, SubscriptionSchema} +import cool.graph.util.ErrorHandlerFactory +import cool.graph.{DataItem, FieldMetricsMiddleware} +import sangria.ast.Document +import sangria.execution.{Executor, Middleware} +import sangria.parser.QueryParser +import sangria.renderer.QueryRenderer +import scaldi.Injector +import spray.json._ + +import scala.concurrent.{ExecutionContext, Future} + +object SubscriptionExecutor { + def execute(project: Project, + model: Model, + mutationType: ModelMutationType, + previousValues: Option[DataItem], + updatedFields: Option[List[String]], + query: String, + variables: spray.json.JsValue, + nodeId: String, + clientId: String, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + operationName: Option[String], + skipPermissionCheck: Boolean, + alwaysQueryMasterDatabase: Boolean)(implicit inj: Injector, ec: ExecutionContext): Future[Option[JsValue]] = { + + val queryAst = QueryParser.parse(query).get + + execute( + project = project, + model = model, + mutationType = mutationType, + previousValues = previousValues, + updatedFields = updatedFields, + query = queryAst, + variables = variables, + nodeId = nodeId, + clientId = clientId, + authenticatedRequest = authenticatedRequest, + requestId = requestId, + operationName = operationName, + skipPermissionCheck = skipPermissionCheck, + alwaysQueryMasterDatabase = alwaysQueryMasterDatabase + ) + } + + def execute(project: Project, + model: Model, + mutationType: ModelMutationType, + previousValues: Option[DataItem], + updatedFields: Option[List[String]], + query: Document, + variables: spray.json.JsValue, + nodeId: String, + clientId: String, + authenticatedRequest: Option[AuthenticatedRequest], + requestId: String, + operationName: Option[String], + skipPermissionCheck: Boolean, + alwaysQueryMasterDatabase: Boolean)(implicit inj: Injector, ec: ExecutionContext): Future[Option[JsValue]] = { + import cool.graph.shared.schema.JsonMarshalling._ + import cool.graph.util.json.Json._ + + val schema = SubscriptionSchema(model, project, updatedFields, mutationType, previousValues).build() + val errorHandler = ErrorHandlerFactory(println) + val unhandledErrorLogger = errorHandler.unhandledErrorHandler( + requestId = requestId, + projectId = Some(project.id) + ) + + val actualQuery = { + val mutationInEvaluated = if (mutationType == ModelMutationType.Updated) { + val tmp = QueryTransformer.replaceMutationInFilter(query, mutationType).asInstanceOf[Document] + QueryTransformer.replaceUpdatedFieldsInFilter(tmp, updatedFields.get.toSet).asInstanceOf[Document] + } else { + QueryTransformer.replaceMutationInFilter(query, mutationType).asInstanceOf[Document] + } + QueryTransformer.mergeBooleans(mutationInEvaluated).asInstanceOf[Document] + } + + val context = SubscriptionUserContext( + nodeId = nodeId, + mutation = MutationMetaData(id = "", _type = ""), + authenticatedRequest = authenticatedRequest, + requestId = requestId, + project = project, + clientId = clientId, + log = x => println(x), + queryAst = Some(actualQuery) + ) + if (alwaysQueryMasterDatabase) { + context.dataResolver.enableMasterDatabaseOnlyMode + } + + val sangriaHandler = errorHandler.sangriaHandler( + requestId = requestId, + query = QueryRenderer.render(actualQuery), + variables = spray.json.JsObject.empty, + clientId = None, + projectId = Some(project.id) + ) + + Executor + .execute( + schema = schema, + queryAst = actualQuery, + variables = variables, + userContext = context, + exceptionHandler = sangriaHandler, + operationName = operationName, + deferredResolver = + new DeferredResolverProvider(new SimpleToManyDeferredResolver, new SimpleManyModelDeferredResolver, skipPermissionCheck = skipPermissionCheck), + middleware = List[Middleware[SubscriptionUserContext]](new FieldMetricsMiddleware) + ) + .map { result => + if (result.pathAs[JsValue](s"data.${model.name}") != JsNull) { + Some(result) + } else { + None + } + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/util/PrettyStrings.scala b/server/client-shared/src/main/scala/cool/graph/util/PrettyStrings.scala new file mode 100644 index 0000000000..d7ecc792fc --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/util/PrettyStrings.scala @@ -0,0 +1,26 @@ +package cool.graph.util + +import cool.graph.client.mutactions.AddDataItemToManyRelation +import cool.graph.shared.models.{Field, Model} + +object PrettyStrings { + implicit class PrettyAddDataItemToManyRelation(rel: AddDataItemToManyRelation) { + def pretty: String = { + s"${rel.fromModel.name}.${rel.fromField.name} from id [${rel.fromId}] to id [${rel.toId}]" + } + } + + implicit class PrettyModel(model: Model) { + def prettyFields: String = { + model.fields.foldLeft(s"fields of model ${model.name}") { (acc, field) => + acc + "\n" + field.pretty + } + } + } + + implicit class PrettyField(field: Field) { + def pretty: String = { + s"${field.name} isScalar:${field.isScalar} isList:${field.isList} isRelation:${field.isRelation} isRequired:${field.isRequired}" + } + } +} diff --git a/server/client-shared/src/main/scala/cool/graph/webhook/Webhook.scala b/server/client-shared/src/main/scala/cool/graph/webhook/Webhook.scala new file mode 100644 index 0000000000..c66e6c670b --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/webhook/Webhook.scala @@ -0,0 +1,22 @@ +package cool.graph.webhook + +import cool.graph.messagebus.Conversions +import play.api.libs.json.{Json, Reads, Writes} + +object Webhook { + implicit val mapStringReads = Reads.mapReads[String] + implicit val mapStringWrites = Writes.mapWrites[String] + implicit val webhooksWrites = Json.format[Webhook] + implicit val marshaller = Conversions.Marshallers.FromJsonBackedType[Webhook]() + implicit val unmarshaller = Conversions.Unmarshallers.ToJsonBackedType[Webhook]() +} + +case class Webhook( + projectId: String, + functionId: String, + requestId: String, + url: String, + payload: String, + id: String, + headers: Map[String, String] +) diff --git a/server/client-shared/src/main/scala/cool/graph/webhook/WebhookCaller.scala b/server/client-shared/src/main/scala/cool/graph/webhook/WebhookCaller.scala new file mode 100644 index 0000000000..25cc5fd971 --- /dev/null +++ b/server/client-shared/src/main/scala/cool/graph/webhook/WebhookCaller.scala @@ -0,0 +1,48 @@ +package cool.graph.webhook + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model._ +import akka.stream.ActorMaterializer +import cool.graph.cuid.Cuid +import scaldi.{Injectable, Injector} + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +trait WebhookCaller { + def call(url: String, payload: String): Future[Boolean] +} + +class WebhookCallerMock extends WebhookCaller { + private val _calls = scala.collection.parallel.mutable.ParTrieMap[String, (String, String)]() + + def calls = _calls.values.toList + + var nextCallShouldFail = false + + def clearCalls = _calls.clear + + override def call(url: String, payload: String): Future[Boolean] = { + _calls.put(Cuid.createCuid(), (url, payload)) + + Future.successful(!nextCallShouldFail) + } +} + +class WebhookCallerImplementation(implicit inj: Injector) extends WebhookCaller with Injectable { + + override def call(url: String, payload: String): Future[Boolean] = { + + implicit val system = inject[ActorSystem](identified by "actorSystem") + implicit val materializer = inject[ActorMaterializer](identified by "actorMaterializer") + + println("calling " + url) + + Http() + .singleRequest(HttpRequest(uri = url, method = HttpMethods.POST, entity = HttpEntity(contentType = ContentTypes.`application/json`, string = payload))) + .map(x => { + x.status.isSuccess() + }) + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCDBStringEndToEndSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBStringEndToEndSpec.scala new file mode 100644 index 0000000000..a79a8888eb --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBStringEndToEndSpec.scala @@ -0,0 +1,121 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.scalactic.{Bad, Good} +import org.scalatest.{FlatSpec, Matchers} + +class GCDBStringEndToEndSpec extends FlatSpec with Matchers { + + val string = "{\"testValue\": 1}" + val int = "234" + val float = "2.234324324" + val boolean = "true" + val password = "2424sdfasg234222434sg" + val id = "2424sdfasg234222434sg" + val datetime = "2018" + val enum = "HA" + val json = "{\"testValue\":1}" + val json2 = "[]" + + val strings = "[\"testValue\",\"testValue\"]" + val ints = "[1,2,3,4]" + val ints2 = "[]" + val floats = "[1.23123,2343.2343242]" + val booleans = "[true,false]" + val passwords = "[\"totallysafe\",\"totallysafe2\"]" + val ids = "[\"ctotallywrwqresafe\",\"cwwerwertotallysafe2\"]" + val datetimes = "[\"2018\",\"2019\"]" + val datetimes2 = "[]" + val enums = "[HA,NO]" + val jsons = "[{\"testValue\":1},{\"testValue\":1}]" + val jsons2 = "[]" + + val nullValue = "null" + + "It should take a String Default or MigrationValue for a non-list field and" should "convert it to a DBString and Back" in { + println("Single Values") + forthAndBack(string, TypeIdentifier.String, false) should be(string) + forthAndBack(int, TypeIdentifier.Int, false) should be(int) + forthAndBack(float, TypeIdentifier.Float, false) should be(float) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(boolean) + forthAndBack(password, TypeIdentifier.Password, false) should be(password) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(id) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be("2018-01-01T00:00:00.000") + forthAndBack(enum, TypeIdentifier.Enum, false) should be(enum) + forthAndBack(json, TypeIdentifier.Json, false) should be(json) + forthAndBack(json2, TypeIdentifier.Json, false) should be(json2) + + } + + "It should take list String DefaultValue and" should "convert them to DBString and back without loss if the type and list status are correct." in { + println("List Values") + forthAndBack(strings, TypeIdentifier.String, true) should be(strings) + forthAndBack(ints, TypeIdentifier.Int, true) should be(ints) + forthAndBack(ints2, TypeIdentifier.Int, true) should be(ints2) + forthAndBack(floats, TypeIdentifier.Float, true) should be(floats) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(booleans) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(passwords) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(ids) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be("[\"2018-01-01T00:00:00.000\",\"2019-01-01T00:00:00.000\"]") + forthAndBack(datetimes2, TypeIdentifier.DateTime, true) should be(datetimes2) + forthAndBack(enums, TypeIdentifier.Enum, true) should be(enums) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(jsons) + forthAndBack(jsons2, TypeIdentifier.Json, true) should be(jsons2) // Todo this has wrong GCValues in transition + + } + + "Nullvalue" should "work for every type and cardinality" in { + println("Null Values") + forthAndBack(nullValue, TypeIdentifier.String, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(nullValue) + // lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(nullValue) + } + + def forthAndBack(input: String, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converterStringSangria = StringSangriaValueConverter(typeIdentifier, isList) + val converterSangriaGCValue = GCSangriaValueConverter(typeIdentifier, isList) + val converterStringDBGCValue = GCStringDBConverter(typeIdentifier, isList) + + val stringInput = input + //String to SangriaValue + val sangriaValueForth = converterStringSangria.from(input) + + //SangriaValue to GCValue + val gcValueForth = converterSangriaGCValue.toGCValue(sangriaValueForth.get) + + //GCValue to DBString + val dbString = converterStringDBGCValue.fromGCValue(gcValueForth.get) + + //DBString to GCValue + val gcValueBack = converterStringDBGCValue.toGCValueCanReadOldAndNewFormat(dbString) + + //GCValue to SangriaValue + val sangriaValueBack = converterSangriaGCValue.fromGCValue(gcValueBack.get) + + //SangriaValue to String + val stringOutput = converterStringSangria.to(sangriaValueBack) + + println("In: " + stringInput + " GCForth: " + gcValueForth + " DBString: " + dbString + " GCValueBack: " + gcValueBack + " Out: " + stringOutput) + + stringOutput + } + +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueConverterSpec.scala new file mode 100644 index 0000000000..b5b93a0665 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueConverterSpec.scala @@ -0,0 +1,105 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes.{JsonGCValue, _} +import org.joda.time.{DateTime, DateTimeZone} +import org.scalatest.{FlatSpec, Matchers} +import spray.json.{JsObject, JsString} + +class GCDBValueConverterSpec extends FlatSpec with Matchers { + + val string = StringGCValue("{\"testValue\": 1}") + val int = IntGCValue(234) + val float = FloatGCValue(2.234324324) + val boolean = BooleanGCValue(true) + val password = PasswordGCValue("2424sdfasg234222434sg") + val id = GraphQLIdGCValue("2424sdfasg234222434sg") + val datetime = DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)) + val enum = EnumGCValue("HA") + val json = JsonGCValue(JsObject("hello" -> JsString("there"))) + + val strings = ListGCValue(Vector(StringGCValue("{\"testValue\": 1}"), StringGCValue("{\"testValue\": 1}"))) + val ints = ListGCValue(Vector(IntGCValue(234), IntGCValue(234))) + val floats = ListGCValue(Vector(FloatGCValue(2.234324324), FloatGCValue(2.234324324))) + val booleans = ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(true))) + val passwords = ListGCValue(Vector(PasswordGCValue("2424sdfasg234222434sg"), PasswordGCValue("2424sdfasg234222434sg"))) + val ids = ListGCValue(Vector(GraphQLIdGCValue("2424sdfasg234222434sg"), GraphQLIdGCValue("2424sdfasg234222434sg"))) + val datetimes = ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)))) + val enums = ListGCValue(Vector(EnumGCValue("HA"), EnumGCValue("HA"))) + val jsons = ListGCValue(Vector(JsonGCValue(JsObject("hello" -> JsString("there"))), JsonGCValue(JsObject("hello" -> JsString("there"))))) + + val rootValue = RootGCValue(Map("test" -> strings, "test2" -> datetimes)) + val nullValue = NullGCValue() + + //Work in Progress + +// "It should take non-list GCValues and" should "convert them to Json and back without loss if the type and list status are correct." in { +// forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) +// forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) +// forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) +// forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) +// forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) +// forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) +// forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) +// forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) +// forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) +// +// } +// +// "It should take list GCValues and" should "convert them to Json and back without loss if the type and list status are correct." in { +// +// forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) +// forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) +// forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) +// forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) +// forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) +// forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) +// forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) +// forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) +// forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) +// } +// +// "RootValue" should "not care about type and cardinality" in { +// forthAndBack(rootValue, TypeIdentifier.String, false) should be(Result.BadError) +// } +// +// "Nullvalue" should "work for every type and cardinality" in { +// forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) +// //lists +// forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) +// } +// +// // list GCValue should be one type +// +// def forthAndBack(input: GCValue, typeIdentifier: TypeIdentifier, isList: Boolean) = { +// val converter = GCJsonConverter(typeIdentifier, isList) +// val forth = converter.fromGCValue(input) +// val forthAndBack = converter.toGCValue(forth) +// println(input) +// println(forth) +// println(forthAndBack) +// forthAndBack match { +// case Good(x) => if (x == input) Result.Equal else Result.NotEqual +// case Bad(error) => Result.BadError +// } +// } +// +// object Result extends Enumeration { +// val Equal, BadError, NotEqual = Value +// } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueEndToEndSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueEndToEndSpec.scala new file mode 100644 index 0000000000..46068c4bf0 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCDBValueEndToEndSpec.scala @@ -0,0 +1,120 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.scalatest.{FlatSpec, Matchers} +import sangria.ast.{AstNode, Value} +import spray.json.JsValue + +class GCDBValueEndToEndSpec extends FlatSpec with Matchers { + + val string = "{\"testValue\": 1}" + val int = "234" + val float = "2.234324324" + val boolean = "true" + val password = "2424sdfasg234222434sg" + val id = "2424sdfasg234222434sg" + val datetime = "2018" + val enum = "HA" + val json = "{\"testValue\":1}" + + val strings = "[\"testValue\", \"testValue\"]" + val ints = "[1, 2, 3, 4]" + val floats = "[1.23123, 2343.2343242]" + val booleans = "[true, false]" + val passwords = "[\"totallysafe\", \"totallysafe2\"]" + val ids = "[\"ctotallywrwqresafe\", \"cwwerwertotallysafe2\"]" + val datetimes = "[\"2018\", \"2019\"]" + val enums = "[HA, NO]" + val jsons = "[{\"testValue\":1},{\"testValue\":1}]" + + val nullValue = "null" + + // Work in Progress + +// "It should take a String Default or MigrationValue for a non-list field and" should "convert it into Sangria AST and Back" in { +// forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) +// forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) +// forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) +// forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) +// forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) +// forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) +//// forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) +// forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) +// forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) +// } +// +// "It should take list GCValues and" should "convert them to String and back without loss if the type and list status are correct." in { +// +// forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) +// forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) +// forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) +// forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) +// forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) +// forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) +// //forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) +// forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) +// forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) +// } +// +// "Nullvalue" should "work for every type and cardinality" in { +// forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) +// // lists +// forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) +// forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) +// } +// +// def forthAndBack(input: String, typeIdentifier: TypeIdentifier, isList: Boolean) = { +// val converterStringSangria = StringSangriaValueConverter(typeIdentifier, isList) +// val converterSangriaGCValue = GCSangriaValueConverter(typeIdentifier, isList) +// val converterDBValueGCValue = GCDBValueConverter(typeIdentifier, isList) +// +// val stringInput = input +// //String to SangriaValue +// val sangriaValueForth: Value = converterStringSangria.from(input).get +// +// //SangriaValue to GCValue +// val gcValueForth: GCValue = converterSangriaGCValue.from(sangriaValueForth).get +// +// //GCValue to DBValue +// val dbString: JsValue = converterDBValueGCValue.to(gcValueForth) +// +// //DBValue to GCValue +// val gcValueBack: GCValue = converterDBValueGCValue.from(dbString).get +// +// //GCValue to SangriaValue +// val sangriaValueBack: Value = converterSangriaGCValue.to(gcValueBack) +// println(sangriaValueBack) +// +// //SangriaValue to String +// val stringOutput: String = converterStringSangria.to(sangriaValueBack) +// +// println(s"In: |$stringInput| Out: |$stringOutput|") +// if (stringInput != stringOutput) { +// sys.error(s"In was: |$stringInput| but out was: |$stringOutput|") +// } +// if (stringInput == stringOutput) Result.Equal else Result.NotEqual +// +// } +// +// object Result extends Enumeration { +// val Equal, BadError, NotEqual = Value +// } + +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCJsonConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCJsonConverterSpec.scala new file mode 100644 index 0000000000..38448f1dde --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCJsonConverterSpec.scala @@ -0,0 +1,103 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes.{JsonGCValue, _} +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.joda.time.{DateTime, DateTimeZone} +import org.scalactic.{Bad, Good} +import org.scalatest.{FlatSpec, Matchers} +import spray.json.{JsObject, JsString} + +class GCJsonConverterSpec extends FlatSpec with Matchers { + + val string = StringGCValue("{\"testValue\": 1}") + val int = IntGCValue(234) + val float = FloatGCValue(2.234324324) + val boolean = BooleanGCValue(true) + val password = PasswordGCValue("2424sdfasg234222434sg") + val id = GraphQLIdGCValue("2424sdfasg234222434sg") + val datetime = DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)) + val enum = EnumGCValue("HA") + val json = JsonGCValue(JsObject("hello" -> JsString("there"))) + + val strings = ListGCValue(Vector(StringGCValue("{\"testValue\": 1}"), StringGCValue("{\"testValue\": 1}"))) + val ints = ListGCValue(Vector(IntGCValue(234), IntGCValue(234))) + val floats = ListGCValue(Vector(FloatGCValue(2.234324324), FloatGCValue(2.234324324))) + val booleans = ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(true))) + val passwords = ListGCValue(Vector(PasswordGCValue("2424sdfasg234222434sg"), PasswordGCValue("2424sdfasg234222434sg"))) + val ids = ListGCValue(Vector(GraphQLIdGCValue("2424sdfasg234222434sg"), GraphQLIdGCValue("2424sdfasg234222434sg"))) + val datetimes = ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)))) + val enums = ListGCValue(Vector(EnumGCValue("HA"), EnumGCValue("HA"))) + val jsons = ListGCValue(Vector(JsonGCValue(JsObject("hello" -> JsString("there"))), JsonGCValue(JsObject("hello" -> JsString("there"))))) + + val rootValue = RootGCValue(Map("test" -> strings, "test2" -> datetimes)) + val nullValue = NullGCValue() + + "It should take non-list GCValues and" should "convert them to Json and back without loss" in { + forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) + + } + + "It should take list GCValues and" should "convert them to Json and back without loss" in { + forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) + } + + "RootValue" should "not care about type and cardinality" in { + forthAndBack(rootValue, TypeIdentifier.String, false) should be(Result.BadError) + } + + "Nullvalue" should "work for every type and cardinality" in { + forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) + //lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) + } + + def forthAndBack(input: GCValue, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converter = GCJsonConverter(typeIdentifier, isList) + val forth = converter.fromGCValue(input) + val forthAndBack = converter.toGCValue(forth) + println(input) + println(forth) + println(forthAndBack) + forthAndBack match { + case Good(x) => if (x == input) Result.Equal else Result.NotEqual + case Bad(error) => Result.BadError + } + } + + object Result extends Enumeration { + val Equal, BadError, NotEqual = Value + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCSangriaValuesConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCSangriaValuesConverterSpec.scala new file mode 100644 index 0000000000..cd1374357f --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCSangriaValuesConverterSpec.scala @@ -0,0 +1,103 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes.{JsonGCValue, _} +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.joda.time.{DateTime, DateTimeZone} +import org.scalactic.{Bad, Good} +import org.scalatest.{FlatSpec, Matchers} +import spray.json.{JsObject, JsString} + +class GCSangriaValuesConverterSpec extends FlatSpec with Matchers { + + val string = StringGCValue("{\"testValue\": 1}") + val int = IntGCValue(234) + val float = FloatGCValue(2.234324324) + val boolean = BooleanGCValue(true) + val password = PasswordGCValue("2424sdfasg234222434sg") + val id = GraphQLIdGCValue("2424sdfasg234222434sg") + val datetime = DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)) + val enum = EnumGCValue("HA") + val json = JsonGCValue(JsObject("hello" -> JsString("there"))) + + val strings = ListGCValue(Vector(StringGCValue("{\"testValue\": 1}"), StringGCValue("{\"testValue\": 1}"))) + val ints = ListGCValue(Vector(IntGCValue(234), IntGCValue(234))) + val floats = ListGCValue(Vector(FloatGCValue(2.234324324), FloatGCValue(2.234324324))) + val booleans = ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(true))) + val passwords = ListGCValue(Vector(PasswordGCValue("2424sdfasg234222434sg"), PasswordGCValue("2424sdfasg234222434sg"))) + val ids = ListGCValue(Vector(GraphQLIdGCValue("2424sdfasg234222434sg"), GraphQLIdGCValue("2424sdfasg234222434sg"))) + val datetimes = ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)))) + val enums = ListGCValue(Vector(EnumGCValue("HA"), EnumGCValue("HA"))) + val jsons = ListGCValue(Vector(JsonGCValue(JsObject("hello" -> JsString("there"))), JsonGCValue(JsObject("hello" -> JsString("there"))))) + val jsons2 = ListGCValue(Vector()) + + val rootValue = RootGCValue(Map("test" -> strings, "test2" -> datetimes)) + val nullValue = NullGCValue() + + "It should take non-list GCValues and" should "convert them to SangriaValues and back without loss" in { + println("SingleValues") + forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) + + } + + "It should take list GCValues and" should "convert them to SangriaValues and back without loss" in { + println("ListValues") + forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) + forthAndBack(jsons2, TypeIdentifier.Json, true) should be(Result.Equal) + } + + "Nullvalue" should "work for every type and cardinality" in { + println("NullValues") + forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) + //lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) + } + + def forthAndBack(input: GCValue, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converter = GCSangriaValueConverter(typeIdentifier, isList) + val forth = converter.fromGCValue(input) + val forthAndBack = converter.toGCValue(forth) + + println("Input: " + input + " Forth: " + forth + " Output: " + forthAndBack) + forthAndBack match { + case Good(x) => if (x == input) Result.Equal else Result.NotEqual + case Bad(error) => Result.BadError + } + } + + object Result extends Enumeration { + val Equal, BadError, NotEqual = Value + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCStringConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringConverterSpec.scala new file mode 100644 index 0000000000..1f57c533d2 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringConverterSpec.scala @@ -0,0 +1,108 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.scalatest.{FlatSpec, Matchers} + +class GCStringConverterSpec extends FlatSpec with Matchers { + + val string = "{\"testValue\": 1}" + val int = "234" + val float = "2.234324324" + val boolean = "true" + val password = "2424sdfasg234222434sg" + val id = "2424sdfasg234222434sg" + val datetime = "2018" + val datetime2 = "2018-01-01T00:00:00.000" + + val enum = "HA" + val json = "{\"testValue\":1}" + val json2 = "[]" + + val strings = "[\"testValue\",\"testValue\"]" + val strings2 = "[\" s \\\"a\\\" s\"]" + val ints = "[1,2,3,4]" + val floats = "[1.23123,2343.2343242]" + val booleans = "[true,false]" + val passwords = "[\"totallysafe\",\"totallysafe2\"]" + val ids = "[\"ctotallywrwqresafe\",\"cwwerwertotallysafe2\"]" + val datetimes = "[\"2018\",\"2019\"]" + val datetimes2 = "[\"2018-01-01T00:00:00.000\"]" + val datetimes3 = "[]" + val enums = "[HA,NO]" + val enums2 = "[]" + val jsons = "[{\"testValue\":1},{\"testValue\":1}]" + val jsons2 = "[]" + + val nullValue = "null" + + "It should take a String Default or MigrationValue for a non-list field and" should "convert it into Sangria AST and Back" in { + println("SingleValues") + forthAndBack(string, TypeIdentifier.String, false) should be(string) + forthAndBack(int, TypeIdentifier.Int, false) should be(int) + forthAndBack(float, TypeIdentifier.Float, false) should be(float) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(boolean) + forthAndBack(password, TypeIdentifier.Password, false) should be(password) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(id) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be("2018-01-01T00:00:00.000") + forthAndBack(datetime2, TypeIdentifier.DateTime, false) should be("2018-01-01T00:00:00.000") + forthAndBack(enum, TypeIdentifier.Enum, false) should be(enum) + forthAndBack(json, TypeIdentifier.Json, false) should be(json) + forthAndBack(json2, TypeIdentifier.Json, false) should be(json2) + } + + "It should take list GCValues and" should "convert them to String and back without loss if the type and list status are correct." in { + println("ListValues") + forthAndBack(strings, TypeIdentifier.String, true) should be(strings) + forthAndBack(strings2, TypeIdentifier.String, true) should be(strings2) + forthAndBack(ints, TypeIdentifier.Int, true) should be(ints) + forthAndBack(floats, TypeIdentifier.Float, true) should be(floats) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(booleans) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(passwords) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(ids) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be("[\"2018-01-01T00:00:00.000\",\"2019-01-01T00:00:00.000\"]") + forthAndBack(datetimes2, TypeIdentifier.DateTime, true) should be("[\"2018-01-01T00:00:00.000\"]") + forthAndBack(datetimes3, TypeIdentifier.DateTime, true) should be("[]") + forthAndBack(enums, TypeIdentifier.Enum, true) should be(enums) + forthAndBack(enums2, TypeIdentifier.Enum, true) should be(enums2) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(jsons) + forthAndBack(jsons2, TypeIdentifier.Json, true) should be(jsons2) + } + + "Nullvalue" should "work for every type and cardinality" in { + println("NullValues") + forthAndBack(nullValue, TypeIdentifier.String, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(nullValue) + // lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(nullValue) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(nullValue) + } + + def forthAndBack(input: String, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converterString = GCStringConverter(typeIdentifier, isList) + //String to GCValue -> input + val gcValueForth = converterString.toGCValue(input) + + //GCValue to StringValue -> this goes into the DB + val stringValueForth = converterString.fromGCValue(gcValueForth.get) + + println("IN: " + input + " GCValue: " + gcValueForth + " OUT: " + stringValueForth) + + stringValueForth + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCStringDBConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringDBConverterSpec.scala new file mode 100644 index 0000000000..49b05e9b2f --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringDBConverterSpec.scala @@ -0,0 +1,105 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes.{JsonGCValue, _} +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.joda.time.{DateTime, DateTimeZone} +import org.scalactic.{Bad, Good} +import org.scalatest.{FlatSpec, Matchers} +import spray.json.{JsObject, JsString} + +class GCStringDBConverterSpec extends FlatSpec with Matchers { + + val string = StringGCValue("{\"testValue\": 1}") + val int = IntGCValue(234) + val float = FloatGCValue(2.234324324) + val boolean = BooleanGCValue(true) + val password = PasswordGCValue("2424sdfasg234222434sg") + val id = GraphQLIdGCValue("2424sdfasg234222434sg") + val datetime = DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)) + val enum = EnumGCValue("HA") + val json = JsonGCValue(JsObject("hello" -> JsString("there"))) + + val strings = ListGCValue(Vector(StringGCValue("{\"testValue\": 1}"), StringGCValue("{\"testValue\": 1}"))) + val ints = ListGCValue(Vector(IntGCValue(234), IntGCValue(234))) + val floats = ListGCValue(Vector(FloatGCValue(2.234324324), FloatGCValue(2.234324324))) + val booleans = ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(true))) + val passwords = ListGCValue(Vector(PasswordGCValue("2424sdfasg234222434sg"), PasswordGCValue("2424sdfasg234222434sg"))) + val ids = ListGCValue(Vector(GraphQLIdGCValue("2424sdfasg234222434sg"), GraphQLIdGCValue("2424sdfasg234222434sg"))) + val datetimes = ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)))) + val enums = ListGCValue(Vector(EnumGCValue("HA"), EnumGCValue("HA"))) + val jsons = ListGCValue(Vector(JsonGCValue(JsObject("hello" -> JsString("there"))), JsonGCValue(JsObject("hello" -> JsString("there"))))) + + val rootValue = RootGCValue(Map("test" -> strings, "test2" -> datetimes)) + val nullValue = NullGCValue() + + "It should take non-list GCValues and" should "convert them to DBString and back" in { + println("SingleValues") + forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) + + } + + "It should take list GCValues and" should "convert them to DBString and back" in { + println("ListValues") + forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) + } + + "Nullvalue" should "work for every type and cardinality" in { + println("NullValues") + forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) +// lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) + } + + def forthAndBack(input: GCValue, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converter = GCStringDBConverter(typeIdentifier, isList) + val forth = converter.fromGCValue(input) + val forthAndBack = converter.toGCValueCanReadOldAndNewFormat(forth) + println("Input: " + input + " Forth: " + forth + " Output: " + forthAndBack) + + forthAndBack match { + case Good(x) => + println(forthAndBack.get) + if (x == input) Result.Equal else Result.NotEqual + case Bad(error) => + println(forthAndBack) + Result.BadError + } + } + + object Result extends Enumeration { + val Equal, BadError, NotEqual = Value + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/GCStringEndToEndSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringEndToEndSpec.scala new file mode 100644 index 0000000000..3115be43f2 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/GCStringEndToEndSpec.scala @@ -0,0 +1,112 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.scalatest.{FlatSpec, Matchers} + +class GCStringEndToEndSpec extends FlatSpec with Matchers { + + val string = Some("{\"testValue\": 1}") + val int = Some("234") + val float = Some("2.234324324") + val boolean = Some("true") + val password = Some("2424sdfasg234222434sg") + val id = Some("2424sdfasg234222434sg") + val datetime = Some("2018") + val datetime2 = Some("2018-01-01T00:00:00.000") + + val enum = Some("HA") + val json = Some("{\"testValue\":1}") + val json2 = Some("[]") + + val strings = Some("[\"testValue\",\"testValue\"]") + val strings2 = Some("[\" s \\\"a\\\" s\"]") + val ints = Some("[1,2,3,4]") + val floats = Some("[1.23123,2343.2343242]") + val booleans = Some("[true,false]") + val passwords = Some("[\"totallysafe\",\"totallysafe2\"]") + val ids = Some("[\"ctotallywrwqresafe\",\"cwwerwertotallysafe2\"]") + val datetimes = Some("[\"2018\",\"2019\"]") + val datetimes2 = Some("[\"2018-01-01T00:00:00.000\"]") + val datetimes3 = Some("[]") + val enums = Some("[HA,NO]") + val enums2 = Some("[]") + val jsons = Some("[{\"testValue\":1},{\"testValue\":1}]") + val jsons2 = Some("[]") + + val nullValue: Option[String] = None + + "It should take a String Default or MigrationValue for a non-list field and" should "convert it into Sangria AST and Back" in { + println("SingleValues") + forthAndBackOptional(string, TypeIdentifier.String, false) should be(string) + forthAndBackOptional(int, TypeIdentifier.Int, false) should be(int) + forthAndBackOptional(float, TypeIdentifier.Float, false) should be(float) + forthAndBackOptional(boolean, TypeIdentifier.Boolean, false) should be(boolean) + forthAndBackOptional(password, TypeIdentifier.Password, false) should be(password) + forthAndBackOptional(id, TypeIdentifier.GraphQLID, false) should be(id) + forthAndBackOptional(datetime, TypeIdentifier.DateTime, false) should be(Some("2018-01-01T00:00:00.000")) + forthAndBackOptional(datetime2, TypeIdentifier.DateTime, false) should be(Some("2018-01-01T00:00:00.000")) + forthAndBackOptional(enum, TypeIdentifier.Enum, false) should be(enum) + forthAndBackOptional(json, TypeIdentifier.Json, false) should be(json) + forthAndBackOptional(json2, TypeIdentifier.Json, false) should be(json2) + } + + "It should take list GCValues and" should "convert them to String and back without loss if the type and list status are correct." in { + println("ListValues") + forthAndBackOptional(strings, TypeIdentifier.String, true) should be(strings) + forthAndBackOptional(strings2, TypeIdentifier.String, true) should be(strings2) + forthAndBackOptional(ints, TypeIdentifier.Int, true) should be(ints) + forthAndBackOptional(floats, TypeIdentifier.Float, true) should be(floats) + forthAndBackOptional(booleans, TypeIdentifier.Boolean, true) should be(booleans) + forthAndBackOptional(passwords, TypeIdentifier.Password, true) should be(passwords) + forthAndBackOptional(ids, TypeIdentifier.GraphQLID, true) should be(ids) + forthAndBackOptional(datetimes, TypeIdentifier.DateTime, true) should be(Some("[\"2018-01-01T00:00:00.000\",\"2019-01-01T00:00:00.000\"]")) + forthAndBackOptional(datetimes2, TypeIdentifier.DateTime, true) should be(Some("[\"2018-01-01T00:00:00.000\"]")) + forthAndBackOptional(datetimes3, TypeIdentifier.DateTime, true) should be(Some("[]")) + forthAndBackOptional(enums, TypeIdentifier.Enum, true) should be(enums) + forthAndBackOptional(enums2, TypeIdentifier.Enum, true) should be(enums2) + forthAndBackOptional(jsons, TypeIdentifier.Json, true) should be(jsons) + forthAndBackOptional(jsons2, TypeIdentifier.Json, true) should be(jsons2) + } + + "Nullvalue" should "work for every type and cardinality" in { + println("NullValues") + forthAndBackOptional(nullValue, TypeIdentifier.String, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Int, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Float, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Boolean, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Password, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.GraphQLID, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.DateTime, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Enum, false) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Json, false) should be(nullValue) + // lists + forthAndBackOptional(nullValue, TypeIdentifier.String, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Int, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Float, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Boolean, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Password, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.GraphQLID, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.DateTime, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Enum, true) should be(nullValue) + forthAndBackOptional(nullValue, TypeIdentifier.Json, true) should be(nullValue) + } + + def forthAndBackOptional(input: Option[String], typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converterString = GCStringConverter(typeIdentifier, isList) + var database: Option[String] = None + + val gcValueForth: Option[GCValue] = input.map(x => converterString.toGCValue(x).get) + + database = gcValueForth.flatMap(converterString.fromGCValueToOptionalString) + + val gcValueBack = database.map(x => converterString.toGCValue(x).get) + + val output = gcValueBack.flatMap(converterString.fromGCValueToOptionalString) + + println("IN: " + input + " GCValueForth: " + gcValueForth + " Database: " + database + " GCValueBack: " + gcValueBack + " OUT: " + output) + + output + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/JsStringToGCValueSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/JsStringToGCValueSpec.scala new file mode 100644 index 0000000000..9d75f31b4c --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/JsStringToGCValueSpec.scala @@ -0,0 +1,350 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.SchemaSerializer.CaseClassFormats._ +import cool.graph.shared.models.Field +import org.joda.time.{DateTime, DateTimeZone} +import org.scalatest.{FlatSpec, Matchers} +import spray.json._ + +class JsStringToGCValueSpec extends FlatSpec with Matchers { + + "The SchemaSerializer" should "be able to parse the old and the new format for Enums" in { + + val fieldOld = """{ + | "typeIdentifier": "Enum", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "[HA]", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(ListGCValue(Vector(EnumGCValue("HA")))) + + val fieldNew = """{ + | "typeIdentifier": "Enum", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": ["HA"], + | "relationSide": null + | }""".stripMargin.parseJson + + fieldNew.convertTo[Field].defaultValue.get should be(ListGCValue(Vector(EnumGCValue("HA")))) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for String" in { + + val fieldOld = """{ + | "typeIdentifier": "String", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "[\"HALLO, SIE\"]", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(ListGCValue(Vector(StringGCValue("HALLO, SIE")))) + + val fieldNew = """{ + | "typeIdentifier": "String", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": ["HALLO, SIE"], + | "relationSide": null + | }""".stripMargin.parseJson + + fieldNew.convertTo[Field].defaultValue.get should be(ListGCValue(Vector(StringGCValue("HALLO, SIE")))) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for Json" in { + + val fieldOld = """{ + | "typeIdentifier": "Json", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "[{\"a\":2},{\"a\":2}]", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be( + ListGCValue(Vector(JsonGCValue(JsObject("a" -> JsNumber(2))), JsonGCValue(JsObject("a" -> JsNumber(2)))))) + + val fieldNew = """{ + | "typeIdentifier": "Json", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": [{"a":2},{"a":2}], + | "relationSide": null + | }""".stripMargin.parseJson + + fieldNew.convertTo[Field].defaultValue.get should be( + ListGCValue(Vector(JsonGCValue(JsObject("a" -> JsNumber(2))), JsonGCValue(JsObject("a" -> JsNumber(2)))))) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for DateTime" in { + + val fieldOld = """{ + | "typeIdentifier": "DateTime", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "[\"2018\", \"2019\"]", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be( + ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2019", DateTimeZone.UTC))))) + + val fieldNew = """{ + | "typeIdentifier": "DateTime", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": ["2018-01-01T00:00:00.000Z", "2019-01-01T00:00:00.000Z"], + | "relationSide": null + | }""".stripMargin.parseJson + + val res = fieldNew.convertTo[Field].defaultValue.get + + println(res) + + res should be(ListGCValue(Vector(DateTimeGCValue(new DateTime("2018", DateTimeZone.UTC)), DateTimeGCValue(new DateTime("2019", DateTimeZone.UTC))))) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for Boolean" in { + + val fieldOld = """{ + | "typeIdentifier": "Boolean", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "[true, false]", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(false)))) + + val fieldNew = """{ + | "typeIdentifier": "Boolean", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": [true, false], + | "relationSide": null + | }""".stripMargin.parseJson + + val res = fieldNew.convertTo[Field].defaultValue.get + res should be(ListGCValue(Vector(BooleanGCValue(true), BooleanGCValue(false)))) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for Float" in { + + val fieldOld = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "1.234", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(FloatGCValue(1.234)) + + val fieldNew = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": true, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": 1.234, + | "relationSide": null + | }""".stripMargin.parseJson + + val res = fieldNew.convertTo[Field].defaultValue.get + res should be(FloatGCValue(1.234)) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for Floats that are 0" in { + + val fieldOld = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": false, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "0", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(FloatGCValue(0)) + + val fieldNew = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": false, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": 0, + | "relationSide": null + | }""".stripMargin.parseJson + + val res = fieldNew.convertTo[Field].defaultValue.get + res should be(FloatGCValue(0)) + } + + "The SchemaSerializer" should "be able to parse the old and the new format for Floats that are ints" in { + + val fieldOld = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": false, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": "10", + | "relationSide": null + | }""".stripMargin.parseJson + + fieldOld.convertTo[Field].defaultValue.get should be(FloatGCValue(10)) + + val fieldNew = """{ + | "typeIdentifier": "Float", + | "isSystem": false, + | "name": "canceledPeriods", + | "isReadonly": false, + | "relation": null, + | "isList": false, + | "isUnique": false, + | "isRequired": false, + | "description": null, + | "id": "cj5glw5r630kq0127ocb46v88", + | "enum": null, + | "constraints": [], + | "defaultValue": 1, + | "relationSide": null + | }""".stripMargin.parseJson + + val res = fieldNew.convertTo[Field].defaultValue.get + res should be(FloatGCValue(1)) + } +} diff --git a/server/client-shared/src/test/scala/cool/graph/adapters/StringSangriaValuesConverterSpec.scala b/server/client-shared/src/test/scala/cool/graph/adapters/StringSangriaValuesConverterSpec.scala new file mode 100644 index 0000000000..6287e0b56f --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/adapters/StringSangriaValuesConverterSpec.scala @@ -0,0 +1,107 @@ +package cool.graph.adapters + +import cool.graph.GCDataTypes._ +import cool.graph.shared.models.TypeIdentifier +import cool.graph.shared.models.TypeIdentifier.TypeIdentifier +import org.scalactic.{Bad, Good} +import org.scalatest.{FlatSpec, Matchers} + +class StringSangriaValuesConverterSpec extends FlatSpec with Matchers { + + val string = "{\"testValue\": 1}" + val int = "234" + val float = "2.234324324" + val boolean = "true" + val password = "2424sdfasg234222434sg" + val id = "2424sdfasg234222434sg" + val datetime = "2018" + val enum = "HA" + val json = "{\"testValue\": 1}" + val json2 = "[]" + + val strings = "[\"testValue\",\"testValue\"]" + val ints = "[1,2,3,4]" + val floats = "[1.23123,2343.2343242]" + val booleans = "[true,false]" + val passwords = "[\"totallysafe\",\"totallysafe2\"]" + val ids = "[\"ctotallywrwqresafe\",\"cwwerwertotallysafe2\"]" + val datetimes = "[\"2018\",\"2019\"]" + val enums = "[HA,NO]" + val jsons = "[{\"testValue\":1},{\"testValue\":1}]" + val jsons2 = "[]" + + val nullValue = "null" + + "It should take a String Default or MigrationValue for a non-list field and" should "convert it into Sangria AST and Back" in { + println("SingleValues") + forthAndBack(string, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(int, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(float, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(boolean, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(password, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(id, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(datetime, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(enum, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(json, TypeIdentifier.Json, false) should be(Result.Equal) + forthAndBack(json2, TypeIdentifier.Json, false) should be(Result.Equal) + + } + + "It should take list GCValues and" should "convert them to String and back without loss if the type and list status are correct." in { + println("ListValues") + forthAndBack(strings, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(ints, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(floats, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(booleans, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(passwords, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(ids, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(datetimes, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(enums, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(jsons, TypeIdentifier.Json, true) should be(Result.Equal) + forthAndBack(jsons2, TypeIdentifier.Json, true) should be(Result.Equal) + + } + + "Nullvalue" should "work for every type and cardinality" in { + println("NullValues") + forthAndBack(nullValue, TypeIdentifier.String, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, false) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, false) should be(Result.Equal) + // lists + forthAndBack(nullValue, TypeIdentifier.String, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Int, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Float, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Boolean, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Password, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.GraphQLID, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.DateTime, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Enum, true) should be(Result.Equal) + forthAndBack(nullValue, TypeIdentifier.Json, true) should be(Result.Equal) + } + + def forthAndBack(input: String, typeIdentifier: TypeIdentifier, isList: Boolean) = { + val converter = StringSangriaValueConverter(typeIdentifier, isList) + val forth = converter.fromAbleToHandleJsonLists(input) + forth match { + case Bad(error) => + Result.BadError + + case Good(x) => + val forthAndBack = converter.to(x) + println("IN: " + input + " SangriaValue: " + forth + " OUT: " + forthAndBack) + + if (forthAndBack == input) Result.Equal else Result.NotEqual + } + } + + object Result extends Enumeration { + val Equal, BadError, NotEqual = Value + } + +} diff --git a/server/client-shared/src/test/scala/cool/graph/client/ClientServerSpec.scala b/server/client-shared/src/test/scala/cool/graph/client/ClientServerSpec.scala new file mode 100644 index 0000000000..2d0de877a3 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/client/ClientServerSpec.scala @@ -0,0 +1,150 @@ +package cool.graph.client + +import akka.http.scaladsl.model.StatusCode +import akka.http.scaladsl.model.StatusCodes.OK +import cool.graph.DataItem +import cool.graph.bugsnag.BugSnaggerMock +import cool.graph.client.authorization.ClientAuth +import cool.graph.client.finder.ProjectFetcher +import cool.graph.client.server._ +import cool.graph.cloudwatch.CloudwatchMock +import cool.graph.shared.{ApiMatrixFactory, DefaultApiMatrix} +import cool.graph.shared.logging.RequestLogger +import cool.graph.shared.models._ +import cool.graph.util.ErrorHandlerFactory +import org.scalatest.{FlatSpec, Matchers} +import scaldi.{Identifier, Injector, Module} +import spray.json._ + +import scala.concurrent.{Await, Awaitable, ExecutionContext, Future} + +class ClientServerSpec extends FlatSpec with Matchers { + + ".handleRawRequestForPermissionSchema()" should "fail if no authentication is provided" in { + val clientAuth: ClientAuth = succeedingClientAuthForRootToken + val handler = requestHandler(clientAuth) + val result = await(handler.handleRawRequestForPermissionSchema("projectId", rawRequestWithoutAuth)) + println(result) + result._2.assertError("Insufficient permissions") + } + + ".handleRawRequestForPermissionSchema()" should "fail if authentication is provided, but ClientAuth.authenticateRequest fails" in { + val clientAuth = failingClientAuth + val handler = requestHandler(clientAuth) + val result = await(handler.handleRawRequestForPermissionSchema("projectId", rawRequestWithAuth)) + println(result) + result._2.assertError("Insufficient permissions") + } + + ".handleRawRequestForPermissionSchema()" should "fail if authentication is provided and ClientAuth.authenticateRequest results in a normal User" in { + val clientAuth = succeedingClientAuthForNormalUser + val handler = requestHandler(clientAuth) + val result = await(handler.handleRawRequestForPermissionSchema("projectId", rawRequestWithAuth)) + println(result) + result._2.assertError("Insufficient permissions") + } + + ".handleRawRequestForPermissionSchema()" should "succeed if authentication is provided and ClientAuth.authenticateRequest results in a Root Token" in { + val clientAuth = succeedingClientAuthForRootToken + val handler = requestHandler(clientAuth) + val result = await(handler.handleRawRequestForPermissionSchema("projectId", rawRequestWithAuth)) + println(result) + result._2.assertSuccess + } + + ".handleRawRequestForPermissionSchema()" should "succeed if authentication is provided and ClientAuth.authenticateRequest results in a Customer" in { + val clientAuth = succeedingClientAuthForCustomer + val handler = requestHandler(clientAuth) + val result = await(handler.handleRawRequestForPermissionSchema("projectId", rawRequestWithAuth)) + println(result) + result._2.assertSuccess + } + + val logger = new RequestLogger("", println) + logger.begin // otherwise the ClientServer freaks out + val rawRequestWithoutAuth = RawRequest( + json = """ {"query": "{ foo }"} """.parseJson, + ip = "some.ip", + sourceHeader = None, + authorizationHeader = None, + logger = logger + ) + val rawRequestWithAuth = rawRequestWithoutAuth.copy(authorizationHeader = Some("Bearer super-token")) + + val failingClientAuth = clientAuthStub(token => Future.failed(new Exception(s"this goes wrong for some reason. Token was: $token"))) + val succeedingClientAuthForCustomer = clientAuthStub(token => Future.successful(AuthenticatedCustomer(id = "id", originalToken = token))) + val succeedingClientAuthForRootToken = clientAuthStub(token => Future.successful(AuthenticatedRootToken(id = "id", originalToken = token))) + val succeedingClientAuthForNormalUser = clientAuthStub(token => Future.successful(AuthenticatedUser(id = "id", typeName = "User", originalToken = token))) + + def clientAuthStub(resultFn: String => Future[AuthenticatedRequest]): ClientAuth = { + new ClientAuth { + override def loginUser[T: JsonFormat](project: Project, user: DataItem, authData: Option[T]) = ??? + + override def authenticateRequest(sessionToken: String, project: Project): Future[AuthenticatedRequest] = resultFn(sessionToken) + } + } + + def requestHandler(clientAuth: ClientAuth) = { + + val errorHandlerFactory = ErrorHandlerFactory( + log = println, + cloudwatch = CloudwatchMock, + bugsnagger = BugSnaggerMock + ) + val projectFetcher = new ProjectFetcher { + override def fetch(projectIdOrAlias: String): Future[Option[ProjectWithClientId]] = Future.successful { + val models = List(Model("id", name = "Todo", isSystem = false)) + val testDb = ProjectDatabase(id = "test-project-database-id", region = Region.EU_WEST_1, name = "client1", isDefaultForRegion = true) + val testProject = Project(id = "test-project-id", ownerId = "test-client-id", name = s"Test Project", projectDatabase = testDb, models = models) + Some(ProjectWithClientId(testProject, "id")) + } + } + val ec = ExecutionContext.global + + val graphQlRequestHandler = new GraphQlRequestHandler { + override def handle(graphQlRequest: GraphQlRequest): Future[(StatusCode, JsValue)] = Future.successful { + OK -> """ {"message": "success"} """.parseJson + } + + override def healthCheck = Future.successful(()) + } + + val injector = new Module { + bind[ApiMatrixFactory] toNonLazy ApiMatrixFactory(DefaultApiMatrix(_)) + } + + RequestHandler( + errorHandlerFactory = errorHandlerFactory, + projectSchemaFetcher = projectFetcher, + projectSchemaBuilder = null, + graphQlRequestHandler = graphQlRequestHandler, + clientAuth = clientAuth, + log = println + )(BugSnaggerMock, injector, ec) + } + + implicit class ResultAssertions(json: JsValue) { + def assertSuccess = { + require( + requirement = !hasError, + message = s"The query had to result in a success but it returned an error. Here's the response: \n $json" + ) + } + + def assertError(shouldInclude: String) = { + require( + requirement = hasError, + message = s"The query had to result in an error but it returned no errors. Here's the response: \n $json" + ) + require( + requirement = json.toString.contains(shouldInclude), + message = s"The query did not contain the expected fragment [$shouldInclude]. Here's the response: \n $json" + ) + } + + private def hasError: Boolean = json.asJsObject.fields.get("error").isDefined + } + + import scala.concurrent.duration._ + def await[T](awaitable: Awaitable[T]): T = Await.result(awaitable, 5.seconds) +} diff --git a/server/client-shared/src/test/scala/cool/graph/private_api/finder/CachedProjectFetcherImplSpec.scala b/server/client-shared/src/test/scala/cool/graph/private_api/finder/CachedProjectFetcherImplSpec.scala new file mode 100644 index 0000000000..cfaae6c531 --- /dev/null +++ b/server/client-shared/src/test/scala/cool/graph/private_api/finder/CachedProjectFetcherImplSpec.scala @@ -0,0 +1,122 @@ +package cool.graph.private_api.finder + +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.client.finder.{CachedProjectFetcherImpl, RefreshableProjectFetcher} +import cool.graph.messagebus.Conversions +import cool.graph.messagebus.pubsub.Only +import cool.graph.messagebus.pubsub.rabbit.RabbitAkkaPubSub +import cool.graph.messagebus.testkits.DummyPubSubSubscriber +import cool.graph.shared.models.{Project, ProjectDatabase, ProjectWithClientId, Region} +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{FlatSpec, Matchers} + +import scala.concurrent.{Await, Awaitable, Future} + +class CachedProjectFetcherImplSpec extends FlatSpec with Matchers with ScalaFutures { + implicit val system = SingleThreadedActorSystem("cacheSpec") + implicit val bugsnagger: BugSnaggerImpl = BugSnaggerImpl("") + implicit val unmarshaller = Conversions.Unmarshallers.ToString + implicit val marshaller = Conversions.Marshallers.FromString + + val database = ProjectDatabase(id = "test", region = Region.EU_WEST_1, name = "client1", isDefaultForRegion = true) + val project = Project(id = "", ownerId = "", name = s"Test Project", alias = None, projectDatabase = database) + val rabbitUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI env var required but not found")) + val projectFetcher = new ProjectFetcherMock(project) + val pubSub: RabbitAkkaPubSub[String] = RabbitAkkaPubSub[String](rabbitUri, "project-schema-invalidation", durable = true) + + "it" should "work" in { + + val dummyPubSub: DummyPubSubSubscriber[String] = DummyPubSubSubscriber.standalone[String] + + val projectFetcher = new RefreshableProjectFetcher { + override def fetchRefreshed(projectIdOrAlias: String) = Future.successful(None) + override def fetch(projectIdOrAlias: String) = Future.successful(None) + } + + val cachedProjectFetcher = CachedProjectFetcherImpl( + projectFetcher = projectFetcher, + projectSchemaInvalidationSubscriber = dummyPubSub + ) + val result = await(cachedProjectFetcher.fetch("does-not-matter")) + } + + "Changing the alias of a project" should "remove it from the alias cache" in { + + val cachedProjectFetcher = CachedProjectFetcherImpl(projectFetcher = projectFetcher, projectSchemaInvalidationSubscriber = pubSub) + + projectFetcher.setAlias(firstAlias = Some("FirstAlias"), secondAlias = None) + //fetch first one with id and alias + cachedProjectFetcher.fetch("FirstOne") + + //fetch second one with id and alias + cachedProjectFetcher.fetch("SecondOne") + + //Flush first one from both caches by invalidating schema + projectFetcher.setAlias(firstAlias = None, secondAlias = None) + pubSub.publish(Only("FirstOne"), "FirstOne") + + Thread.sleep(2000) + + //fetch second time with alias -> this should not find anything now + cachedProjectFetcher.fetch("FirstAlias").futureValue should be(None) + } + + "Changing the alias of a project and reusing it on another project" should "return the new project upon fetch" in { + + val cachedProjectFetcher = CachedProjectFetcherImpl(projectFetcher = projectFetcher, projectSchemaInvalidationSubscriber = pubSub) + + projectFetcher.setAlias(firstAlias = Some("FirstAlias"), secondAlias = None) + //fetch first one with id and alias + cachedProjectFetcher.fetch("FirstOne") + + //fetch second one with id and alias + cachedProjectFetcher.fetch("SecondOne") + + //Flush both from both caches by invalidating schema + projectFetcher.setAlias(firstAlias = None, secondAlias = Some("FirstAlias")) + pubSub.publish(Only("FirstOne"), "FirstOne") + pubSub.publish(Only("SecondOne"), "SecondOne") + + Thread.sleep(1000) + + //fetch second time with alias -> this should not find anything now since project needs to be found once by id first + val fetchByAlias = cachedProjectFetcher.fetch("FirstAlias").futureValue + fetchByAlias should be(None) + + Thread.sleep(1000) + //load alias cache by loading by id first once + val fetchById = cachedProjectFetcher.fetch("SecondOne").futureValue + fetchById.get.project.id should be("SecondOne") + + Thread.sleep(1000) + // this should now find the SecondOne + val fetchByAliasAgain = cachedProjectFetcher.fetch("FirstAlias").futureValue + fetchByAliasAgain.get.project.id should be("SecondOne") + } + + import scala.concurrent.duration._ + def await[T](awaitable: Awaitable[T]): T = Await.result(awaitable, 5.seconds) + + class ProjectFetcherMock(project: Project) extends RefreshableProjectFetcher { + var firstProject: Option[ProjectWithClientId] = _ + var secondProject: Option[ProjectWithClientId] = _ + + override def fetchRefreshed(projectIdOrAlias: String) = projectIdOrAlias match { + case "FirstOne" => Future.successful(firstProject) + case "SecondOne" => Future.successful(secondProject) + case _ => Future.successful(None) + } + + override def fetch(projectIdOrAlias: String) = projectIdOrAlias match { + case "FirstOne" => Future.successful(firstProject) + case "SecondOne" => Future.successful(secondProject) + case _ => Future.successful(None) + } + + def setAlias(firstAlias: Option[String], secondAlias: Option[String]) = { + firstProject = Some(ProjectWithClientId(project.copy(id = "FirstOne", alias = firstAlias), clientId = "")) + secondProject = Some(ProjectWithClientId(project.copy(id = "SecondOne", alias = secondAlias), clientId = "")) + } + } +} \ No newline at end of file diff --git a/server/docker-compose/backend-dev.yml b/server/docker-compose/backend-dev.yml new file mode 100644 index 0000000000..7eac8dd39b --- /dev/null +++ b/server/docker-compose/backend-dev.yml @@ -0,0 +1,25 @@ +version: "2" +services: + db: + image: mysql:5.7 + command: mysqld --max-connections=1000 --sql-mode="ANSI,ALLOW_INVALID_DATES,ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,HIGH_NOT_PRECEDENCE,IGNORE_SPACE,NO_AUTO_CREATE_USER,NO_AUTO_VALUE_ON_ZERO,NO_BACKSLASH_ESCAPES,NO_ENGINE_SUBSTITUTION,NO_KEY_OPTIONS,NO_UNSIGNED_SUBTRACTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES,STRICT_TRANS_TABLES,TRADITIONAL" + restart: always + environment: + MYSQL_ROOT_PASSWORD: $SQL_CLIENT_PASSWORD + MYSQL_DATABASE: $SQL_INTERNAL_DATABASE + ports: + - "127.0.0.1:3306:3306" + + kinesis: + image: dlsniper/kinesalite + command: kinesalite --port 7661 + ports: + - "127.0.0.1:7661:7661" + + rabbit: + image: rabbitmq:3-management + restart: always + hostname: rabbit-host + ports: + - "127.0.0.1:5672:5672" + - "127.0.0.1:15672:15672" diff --git a/server/docker-compose/debug-cluster.yml b/server/docker-compose/debug-cluster.yml new file mode 100644 index 0000000000..604f837e12 --- /dev/null +++ b/server/docker-compose/debug-cluster.yml @@ -0,0 +1,30 @@ +# Intended to be used with the single server main. +# Simulates the single server local cluster, allowing for easier debugging of scenarios with the CLI (breakpoints, etc) +# No persistence of functions and db. + +version: "3" +services: + graphcool-db: + image: mysql:5.7 + restart: always + command: mysqld --max-connections=1000 --sql-mode="ALLOW_INVALID_DATES,ANSI_QUOTES,ERROR_FOR_DIVISION_BY_ZERO,HIGH_NOT_PRECEDENCE,IGNORE_SPACE,NO_AUTO_CREATE_USER,NO_AUTO_VALUE_ON_ZERO,NO_BACKSLASH_ESCAPES,NO_DIR_IN_CREATE,NO_ENGINE_SUBSTITUTION,NO_FIELD_OPTIONS,NO_KEY_OPTIONS,NO_TABLE_OPTIONS,NO_UNSIGNED_SUBTRACTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ONLY_FULL_GROUP_BY,PIPES_AS_CONCAT,REAL_AS_FLOAT,STRICT_ALL_TABLES,STRICT_TRANS_TABLES,ANSI,DB2,MAXDB,MSSQL,MYSQL323,MYSQL40,ORACLE,POSTGRESQL,TRADITIONAL" + environment: + MYSQL_ROOT_PASSWORD: $SQL_INTERNAL_PASSWORD + MYSQL_DATABASE: $SQL_INTERNAL_DATABASE + ports: + - "127.0.0.1:3306:3306" + + graphcool-rabbit-host: + image: rabbitmq:3-management + restart: always + ports: + - "5672:5672" + - "15672:15672" + + localfaas: + image: graphcool/localfaas:latest + restart: always + environment: + FUNCTIONS_PORT: $FUNCTIONS_PORT + ports: + - "127.0.0.1:${FUNCTIONS_PORT}:${FUNCTIONS_PORT}" \ No newline at end of file diff --git a/server/env_example b/server/env_example new file mode 100644 index 0000000000..0daf0543a6 --- /dev/null +++ b/server/env_example @@ -0,0 +1,38 @@ +export TEST_SQL_CLIENT_HOST="127.0.0.1" +export TEST_SQL_CLIENT_PORT="3306" +export TEST_SQL_CLIENT_USER="root" +export TEST_SQL_CLIENT_PASSWORD="graphcool" +export TEST_SQL_CLIENT_CONNECTION_LIMIT=10 +export TEST_SQL_INTERNAL_HOST="127.0.0.1" +export TEST_SQL_INTERNAL_PORT="3306" +export TEST_SQL_INTERNAL_USER="root" +export TEST_SQL_INTERNAL_PASSWORD="graphcool" +export TEST_SQL_INTERNAL_DATABASE="graphcool" +export TEST_SQL_INTERNAL_CONNECTION_LIMIT=10 +export TEST_SQL_LOGS_PORT="3306" +export TEST_SQL_LOGS_HOST="127.0.0.1" +export TEST_SQL_LOGS_USER="root" +export TEST_SQL_LOGS_PASSWORD="graphcool" +export TEST_SQL_LOGS_DATABASE="logs" +export SQL_INTERNAL_DATABASE="graphcool" +export SQL_CLIENT_PASSWORD="graphcool" +export SQL_LOGS_HOST="127.0.0.1" +export SQL_LOGS_PORT="3306" +export SQL_LOGS_USER="root" +export SQL_LOGS_PASSWORD="graphcool" +export SQL_LOGS_DATABASE="logs" +export JWT_SECRET="abbaabbaabbaabbaabbaabba" +export AUTH0_CLIENT_SECRET="ZXVmNmFoa29oZzdJZGFlNVF1YWg0b2NoZWVwaG9oY2hhaGdoaWk2ZQ==" +export SYSTEM_API_SECRET="systemApiSecret" +export RABBITMQ_URI="amqp://127.0.0.1:5672" +export GLOBAL_RABBIT_URI="amqp://127.0.0.1:5672" +export INITIAL_PRICING_PLAN="initial-plan" +export BUGSNAG_API_KEY="" +export SCHEMA_MANAGER_ENDPOINT="empty" +export SCHEMA_MANAGER_SECRET="empty" +export AWS_ACCESS_KEY_ID="empty" +export AWS_SECRET_ACCESS_KEY="empty" +export AWS_REGION="eu-west-1" +export CLIENT_API_ADDRESS="http://localhost:8888/" +export PACKAGECLOUD_PW="" +export PRIVATE_CLIENT_API_SECRET="notasecret" \ No newline at end of file diff --git a/server/libs/akka-utils/build.sbt b/server/libs/akka-utils/build.sbt new file mode 100644 index 0000000000..33df29b7a8 --- /dev/null +++ b/server/libs/akka-utils/build.sbt @@ -0,0 +1,11 @@ +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % "2.4.8" % "provided", + "com.typesafe.akka" %% "akka-contrib" % "2.4.8" % "provided", + "com.typesafe.akka" %% "akka-http" % "10.0.5", + "com.typesafe.akka" %% "akka-testkit" % "2.4.8" % "test", + "org.specs2" %% "specs2-core" % "3.8.8" % "test", + "com.github.ben-manes.caffeine" % "caffeine" % "2.4.0", + "com.twitter" %% "finagle-http" % "6.44.0" +) + +fork in Test := true diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandled.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandled.scala new file mode 100644 index 0000000000..98ee7c9615 --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandled.scala @@ -0,0 +1,22 @@ +package cool.graph.akkautil + +import akka.actor.Actor.Receive + +trait LogUnhandled { self => + private val className = self.getClass.getSimpleName + + def logUnhandled(receive: Receive): Receive = receive orElse { + case x => + println(Console.RED + s"[$className] Received unknown message: $x" + Console.RESET) + } + + def logAll(receive: Receive): Receive = { + case x => + if (receive.isDefinedAt(x)) { + receive(x) + println(Console.GREEN + s"[$className] Handled message: $x" + Console.RESET) + } else { + println(Console.RED + s"[$className] Unhandled message: $x" + Console.RESET) + } + } +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandledExceptions.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandledExceptions.scala new file mode 100644 index 0000000000..a94294db91 --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/LogUnhandledExceptions.scala @@ -0,0 +1,14 @@ +package cool.graph.akkautil + +import akka.actor.Actor +import cool.graph.bugsnag.{BugSnagger, MetaData} + +trait LogUnhandledExceptions extends Actor { + + val bugsnag: BugSnagger + + override def preRestart(reason: Throwable, message: Option[Any]): Unit = { + super.preRestart(reason, message) + bugsnag.report(reason, Seq(MetaData("Akka", "message", message))) + } +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/SingleThreadedActorSystem.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/SingleThreadedActorSystem.scala new file mode 100644 index 0000000000..6a0f51067e --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/SingleThreadedActorSystem.scala @@ -0,0 +1,24 @@ +package cool.graph.akkautil + +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.{Executors, ThreadFactory} + +import akka.actor.ActorSystem + +object SingleThreadedActorSystem { + def apply(name: String): ActorSystem = { + val ec = scala.concurrent.ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor(newNamedThreadFactory(name))) + ActorSystem(name, defaultExecutionContext = Some(ec)) + } + + def newNamedThreadFactory(name: String): ThreadFactory = new ThreadFactory { + val count = new AtomicLong(0) + + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable) + thread.setDaemon(true) + thread.setName(s"$name-" + count.getAndIncrement) + thread + } + } +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Routes.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Routes.scala new file mode 100644 index 0000000000..b984a33e8d --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Routes.scala @@ -0,0 +1,5 @@ +package cool.graph.akkautil.http + +object Routes { + val emptyRoute = akka.http.scaladsl.server.Directives.reject +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Server.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Server.scala new file mode 100644 index 0000000000..87bff13cfa --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/Server.scala @@ -0,0 +1,21 @@ +package cool.graph.akkautil.http + +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.Directives.pathPrefix +import scala.concurrent.Future + +trait Server { + val prefix: String + + protected val innerRoutes: Route + + def routes: Route = prefix match { + case prfx if prfx.nonEmpty => pathPrefix(prfx) { innerRoutes } + case _ => innerRoutes + } + + def onStart: Future[_] = Future.successful(()) + def onStop: Future[_] = Future.successful(()) + + def healthCheck: Future[_] +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/ServerExecutor.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/ServerExecutor.scala new file mode 100644 index 0000000000..d8e0c8a8dc --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/http/ServerExecutor.scala @@ -0,0 +1,50 @@ +package cool.graph.akkautil.http + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.Http.ServerBinding +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.stream.ActorMaterializer +import ch.megard.akka.http.cors.scaladsl.CorsDirectives +import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ + +import scala.concurrent.duration.{Duration, _} +import scala.concurrent.{Await, Future} + +/** + * Class that knows how to start and stop servers. Takes one or more servers. + * In case that more than one server is given, the ServerExecutor combines all given servers into one server + * by collecting all their routes. Evaluation order is strictly linear. + */ +case class ServerExecutor(port: Int, servers: Server*)(implicit system: ActorSystem, materializer: ActorMaterializer) { + import system.dispatcher + + val routes: Route = { + handleRejections(CorsDirectives.corsRejectionHandler) { + cors() { + val routes = servers.map(_.routes) :+ statusRoute + routes.reduceLeft(_ ~ _) + } + } + } + + def statusRoute: Route = (get & path("status")) { + val checks = Future.sequence(servers.map(_.healthCheck)) + + onSuccess(checks) { _ => + complete("OK") + } + } + + lazy val serverBinding: Future[ServerBinding] = { + val binding = Http().bindAndHandle(Route.handlerFlow(routes), "0.0.0.0", port) + binding.onSuccess { case b => println(s"Server running on :${b.localAddress.getPort}") } + binding + } + + def start: Future[_] = Future.sequence[Any, Seq](servers.map(_.onStart) :+ serverBinding) + def stop: Future[_] = Future.sequence[Any, Seq](servers.map(_.onStop) :+ serverBinding.map(_.unbind)) + def startBlocking(duration: Duration = 15.seconds): Unit = Await.result(start, duration) + def stopBlocking(duration: Duration = 15.seconds): Unit = Await.result(stop, duration) +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/stream/OnCompleteStage.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/stream/OnCompleteStage.scala new file mode 100644 index 0000000000..b473937c89 --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/stream/OnCompleteStage.scala @@ -0,0 +1,37 @@ +package cool.graph.akkautil.stream + +import akka.stream.ActorAttributes.SupervisionStrategy +import akka.stream.impl.fusing.GraphStages.SimpleLinearGraphStage +import akka.stream.stage.{GraphStageLogic, InHandler, OutHandler} +import akka.stream.{Attributes, Supervision} + +case class OnCompleteStage[T](op: () ⇒ Unit) extends SimpleLinearGraphStage[T] { + override def toString: String = "OnComplete" + + override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = + new GraphStageLogic(shape) with OutHandler with InHandler { + def decider = + inheritedAttributes + .get[SupervisionStrategy] + .map(_.decider) + .getOrElse(Supervision.stoppingDecider) + + override def onPush(): Unit = { + push(out, grab(in)) + } + + override def onPull(): Unit = pull(in) + + override def onDownstreamFinish() = { + op() + super.onDownstreamFinish() + } + + override def onUpstreamFinish() = { + op() + super.onUpstreamFinish() + } + + setHandlers(in, out, this) + } +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/throttler/Throttler.scala b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/throttler/Throttler.scala new file mode 100644 index 0000000000..d142f25092 --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/akkautil/throttler/Throttler.scala @@ -0,0 +1,118 @@ +package cool.graph.akkautil.throttler + +import java.util.concurrent.TimeUnit + +import akka.actor.Status.Failure +import akka.actor.{Actor, ActorRef, ActorSystem, Props, ReceiveTimeout, Terminated} +import akka.contrib.throttle.Throttler.SetTarget +import akka.contrib.throttle.TimerBasedThrottler +import akka.pattern.AskTimeoutException +import cool.graph.akkautil.throttler.ThrottlerManager.Requests.ThrottledCall +import cool.graph.akkautil.throttler.Throttler.{ThrottleBufferFullException, ThrottleCallTimeoutException} + +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.duration.FiniteDuration +import scala.reflect.ClassTag + +object Throttler { + class ThrottleBufferFullException(msg: String) extends Exception(msg) + class ThrottleCallTimeoutException(msg: String) extends Exception(msg) +} + +case class Throttler[A](groupBy: A => Any, amount: Int, per: FiniteDuration, timeout: akka.util.Timeout, maxCallsInFlight: Int)( + implicit actorSystem: ActorSystem) { + + import akka.pattern.ask + implicit val implicitTimeout = timeout + + val throttlerActor = actorSystem.actorOf(ThrottlerManager.props(groupBy, amount, per, maxCallsInFlight)) + @throws[ThrottleCallTimeoutException]("thrown if the throttled call cannot be fulfilled within the given timeout") + @throws[ThrottleBufferFullException]("thrown if the throttled call cannot be fulfilled in the given timeout") + def throttled[B](groupBy: A)(call: () => Future[B])(implicit tag: ClassTag[B]): Future[B] = { + val askResult = throttlerActor ? ThrottledCall(call, groupBy) + + askResult + .mapTo[B] + .recoverWith { + case _: AskTimeoutException => Future.failed(new ThrottleCallTimeoutException(s"The call to the group [$groupBy] timed out.")) + }(actorSystem.dispatcher) + } +} + +object ThrottlerManager { + object Requests { + case class ThrottledCall[A, B](fn: () => Future[B], groupBy: A) + case class ExecutableCall(call: () => Future[Any], sender: ActorRef, groupBy: Any) + case class ExecuteCall(call: () => Future[Any], sender: ActorRef) + } + + def props[A](groupBy: A => Any, numberOfCalls: Int, duration: FiniteDuration, maxCallsInFlight: Int) = { + Props(new ThrottlerManager(groupBy, akka.contrib.throttle.Throttler.Rate(numberOfCalls, duration), maxCallsInFlight)) + } +} + +class ThrottlerManager[A](groupBy: A => Any, rate: akka.contrib.throttle.Throttler.Rate, maxCallsInFlight: Int) extends Actor { + import cool.graph.akkautil.throttler.ThrottlerManager.Requests._ + + val throttlerGroups: mutable.Map[Any, ActorRef] = mutable.Map.empty + + def receive = { + case call @ ThrottledCall(_, _) => + val casted = call.asInstanceOf[ThrottledCall[A, Any]] + val throttler = getThrottler(casted.groupBy) + throttler ! ExecutableCall(call.fn, sender, casted.groupBy) + + case Terminated(terminatedGroup) => + throttlerGroups.find { + case (_, throttlerGroup) => + throttlerGroup == terminatedGroup + } match { + case Some((key, _)) => + throttlerGroups.remove(key) + case None => + println(s"tried to remove ${terminatedGroup} from throttlers but could not find it") + } + } + + def getThrottler(arg: A): ActorRef = { + val groupByResult = groupBy(arg) + throttlerGroups.getOrElseUpdate(groupByResult, { + val ref = context.actorOf(ThrottlerGroup.props(rate, maxCallsInFlight), groupByResult.toString) + context.watch(ref) + ref + }) + } +} + +object ThrottlerGroup { + def props(rate: akka.contrib.throttle.Throttler.Rate, maxCallsInFlight: Int) = Props(new ThrottlerGroup(rate, maxCallsInFlight)) +} + +class ThrottlerGroup(rate: akka.contrib.throttle.Throttler.Rate, maxCallsInFlight: Int) extends Actor { + import cool.graph.akkautil.throttler.ThrottlerManager.Requests._ + import akka.pattern.pipe + import context.dispatcher + + val akkaThrottler = context.actorOf(Props(new TimerBasedThrottler(rate))) + akkaThrottler ! SetTarget(Some(self)) + + context.setReceiveTimeout(FiniteDuration(3, TimeUnit.MINUTES)) + + var requestsInFlight = 0 + + override def receive: Receive = { + case ExecutableCall(call, callSender, groupBy) => + if (requestsInFlight < maxCallsInFlight) { + akkaThrottler ! ExecuteCall(call, callSender) + requestsInFlight += 1 + } else { + callSender ! Failure(new ThrottleBufferFullException(s"Exceeded the limit of $maxCallsInFlight of in flight calls for groupBy [$groupBy]")) + } + case ExecuteCall(call, callSender) => + pipe(call()) to callSender + requestsInFlight -= 1 + case ReceiveTimeout => + context.stop(self) + } +} diff --git a/server/libs/akka-utils/src/main/scala/cool/graph/twitterFutures/TwitterFutureImplicits.scala b/server/libs/akka-utils/src/main/scala/cool/graph/twitterFutures/TwitterFutureImplicits.scala new file mode 100644 index 0000000000..1b5eed532a --- /dev/null +++ b/server/libs/akka-utils/src/main/scala/cool/graph/twitterFutures/TwitterFutureImplicits.scala @@ -0,0 +1,32 @@ +package cool.graph.twitterFutures + +import com.twitter.util.{Future => TwitterFuture, Promise => TwitterPromise, Return, Throw} +import scala.concurrent.{Future => ScalaFuture, Promise => ScalaPromise, ExecutionContext} +import scala.util.{Success, Failure} + +object TwitterFutureImplicits { + + /** Convert from a Twitter Future to a Scala Future */ + implicit class RichTwitterFuture[A](val tf: TwitterFuture[A]) extends AnyVal { + def asScala(implicit e: ExecutionContext): ScalaFuture[A] = { + val promise: ScalaPromise[A] = ScalaPromise() + tf.respond { + case Return(value) => promise.success(value) + case Throw(exception) => promise.failure(exception) + } + promise.future + } + } + + /** Convert from a Scala Future to a Twitter Future */ + implicit class RichScalaFuture[A](val sf: ScalaFuture[A]) extends AnyVal { + def asTwitter(implicit e: ExecutionContext): TwitterFuture[A] = { + val promise: TwitterPromise[A] = new TwitterPromise[A]() + sf.onComplete { + case Success(value) => promise.setValue(value) + case Failure(exception) => promise.setException(exception) + } + promise + } + } +} diff --git a/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/http/ServerExecutorSpec.scala b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/http/ServerExecutorSpec.scala new file mode 100644 index 0000000000..afd5cd1ba5 --- /dev/null +++ b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/http/ServerExecutorSpec.scala @@ -0,0 +1,132 @@ +package cool.graph.akkautil.http + +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, ResponseEntity} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.ActorMaterializer +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.akkautil.specs2.AcceptanceSpecification +import org.specs2.matcher.MatchResult +import org.specs2.specification.AfterAll + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class ServerExecutorSpec extends AcceptanceSpecification with AfterAll { + def is = s2""" + The ServerExecutor must + execute a single server correctly $singleServerExecution + execute a single server with prefix correctly $singleServerPrefixExecution + merge routes from multiple servers correctly $mergeRoutesTest + merge health checks from all servers correctly $healthCheckMerge + """ + + implicit val system = SingleThreadedActorSystem("server-spec") + implicit val materializer = ActorMaterializer() + + case class TestServer( + innerRoutes: Route, + prefix: String = "", + check: Unit => Future[_] = _ => Future.successful(()) + ) extends Server { + override def healthCheck: Future[_] = check(()) + } + + def withServerExecutor(servers: Server*)(checkFn: ServerExecutor => MatchResult[Any]): MatchResult[Any] = { + val server = ServerExecutor(port = 8000 + new scala.util.Random().nextInt(50000), servers: _*) + + try { + server.startBlocking() + checkFn(server) + } finally { + server.stopBlocking() + } + } + + def bodyAsString(entity: ResponseEntity): String = { + Await.result(Unmarshal(entity).to[String], 2.seconds) + } + + implicit class TestServerExecutorExtensions(executor: ServerExecutor) { + def makeRequest(path: String): HttpResponse = { + Await.result(Http().singleRequest(HttpRequest(uri = s"http://localhost:${executor.port}/${path.stripPrefix("/")}")), 2.second) + } + } + + override def afterAll = Await.result(system.terminate(), 15.seconds) + + val route: Route = get { + pathPrefix("firstPrefix") { + complete("firstPrefixRouteResult") + } ~ pathPrefix("secondPrefix") { + complete("secondPrefixRouteResult") + } + } + + def singleServerExecution: MatchResult[Any] = { + val testServer = TestServer(route) + + withServerExecutor(testServer) { server => + val statusResult = server.makeRequest("/status") + statusResult.status.intValue() mustEqual 200 + + val firstRouteResult = server.makeRequest("/firstPrefix") + firstRouteResult.status.intValue() mustEqual 200 + bodyAsString(firstRouteResult.entity) mustEqual "firstPrefixRouteResult" + + val secondRouteResult = server.makeRequest("/secondPrefix") + secondRouteResult.status.intValue() mustEqual 200 + bodyAsString(secondRouteResult.entity) mustEqual "secondPrefixRouteResult" + } + } + + def singleServerPrefixExecution: MatchResult[Any] = { + val testServer = TestServer(route, prefix = "testPrefix") + + withServerExecutor(testServer) { server => + val statusResult = server.makeRequest("/status") + statusResult.status.intValue() mustEqual 200 + + val firstRouteErrorResult = server.makeRequest("/firstPrefix") + firstRouteErrorResult.status.intValue() mustEqual 404 + + val firstRouteSuccessResult = server.makeRequest("/testPrefix/firstPrefix") + firstRouteSuccessResult.status.intValue() mustEqual 200 + bodyAsString(firstRouteSuccessResult.entity) mustEqual "firstPrefixRouteResult" + } + } + + def mergeRoutesTest: MatchResult[Any] = { + val testServer = TestServer(route, prefix = "v1") + val otherTestServer = TestServer(route ~ path("surprise") { complete("much surprise, wow") }, prefix = "v2") + + withServerExecutor(testServer, otherTestServer) { server => + val statusResult = server.makeRequest("/status") + statusResult.status.intValue() mustEqual 200 + + val firstRouteErrorResult = server.makeRequest("/firstPrefix") + firstRouteErrorResult.status.intValue() mustEqual 404 + + val firstRouteSurpriseErrorResult = server.makeRequest("/v1/surprise") + firstRouteSurpriseErrorResult.status.intValue() mustEqual 404 + + val firstRouteSuccessResult = server.makeRequest("/v2/surprise") + firstRouteSuccessResult.status.intValue() mustEqual 200 + bodyAsString(firstRouteSuccessResult.entity) mustEqual "much surprise, wow" + } + } + + def healthCheckMerge: MatchResult[Any] = { + val testServer = TestServer(route, prefix = "v1") + val otherTestServer = TestServer(route, prefix = "v2", check = _ => { + Future.failed(new Exception("QUICK, EVERYBODY DO THE PANIC")) + }) + + withServerExecutor(testServer, otherTestServer) { server => + val statusResult = server.makeRequest("/status") + statusResult.status.intValue() mustNotEqual 200 + } + } +} diff --git a/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AcceptanceSpecification.scala b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AcceptanceSpecification.scala new file mode 100644 index 0000000000..6c0c4c00f0 --- /dev/null +++ b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AcceptanceSpecification.scala @@ -0,0 +1,9 @@ +package cool.graph.akkautil.specs2 + +import org.specs2.Specification +import org.specs2.matcher.ThrownExpectations + +/** + * This trait enables the usage of blocks for examples in the acceptance spec style. + */ +trait AcceptanceSpecification extends Specification with ThrownExpectations diff --git a/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AkkaTestKitSpecs2Context.scala b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AkkaTestKitSpecs2Context.scala new file mode 100644 index 0000000000..bf1648784d --- /dev/null +++ b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/specs2/AkkaTestKitSpecs2Context.scala @@ -0,0 +1,28 @@ +package cool.graph.akkautil.specs2 + +import akka.actor.ActorSystem +import akka.testkit.{ImplicitSender, TestKit} +import com.typesafe.config.ConfigFactory +import org.specs2.mutable.After + +import scala.concurrent.Await + +object TestConfig { + val config = ConfigFactory.parseString(""" + |akka { + | log-dead-letters = 1 + |} + | + """.stripMargin) +} + +/** + * This class is a context for specs2, which allows the usage of the TestKit provided by Akka + * to test actor systems. + */ +class AkkaTestKitSpecs2Context extends TestKit(ActorSystem("test-system", TestConfig.config)) with ImplicitSender with After { + + import scala.concurrent.duration._ + + def after = Await.result(system.terminate(), 10.seconds) +} diff --git a/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/throttler/ThrottlerSpec.scala b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/throttler/ThrottlerSpec.scala new file mode 100644 index 0000000000..a5e6ef6811 --- /dev/null +++ b/server/libs/akka-utils/src/test/scala/cool/graph/akkautil/throttler/ThrottlerSpec.scala @@ -0,0 +1,115 @@ +package cool.graph.akkautil.throttler + +import java.util.concurrent.TimeUnit + +import akka.actor.ActorSystem +import cool.graph.akkautil.specs2.{AcceptanceSpecification, AkkaTestKitSpecs2Context} +import cool.graph.akkautil.throttler.Throttler.{ThrottleBufferFullException, ThrottleCallTimeoutException} + +import scala.concurrent.{Await, Awaitable, Future} +import scala.concurrent.duration.FiniteDuration + +class ThrottlerSpec extends AcceptanceSpecification { + def is = s2""" + The Throttler must + make the call if throttle rate is not reached $rate_not_reached + make the call later if the throttle rate is reached $rate_reached + make the call and result in a ThrottleCallTimeoutException if the call takes too long $timeout_hit + make the call and result in a ThrottleBufferFullException if the call buffer is full $buffer_full + """ + + def rate_not_reached = new AkkaTestKitSpecs2Context { + val throttler = testThrottler() + var callExecuted = false + + val result = throttler + .throttled("group") { () => + callExecuted = true + Future.successful("the-result") + } + .await + + result mustEqual "the-result" + callExecuted must beTrue + } + + def rate_reached = new AkkaTestKitSpecs2Context { + for (_ <- 1 to 10) { + val throttler = testThrottler(ratePer100ms = 1) + val group = "group" + // make one call; rate is reached now + throttler.throttled(group) { () => + Future.successful("the-result") + } + + // second call must be throttled and should take around 1 second + val begin = System.currentTimeMillis + throttler + .throttled(group) { () => + Future.successful("the-result") + } + .await + val end = System.currentTimeMillis + (end - begin) must be_>(100L) + } + } + + def timeout_hit = new AkkaTestKitSpecs2Context { + for (_ <- 1 to 10) { + val throttler = testThrottler(timeoutInMillis = 100) + val group = "group" + + throttler + .throttled(group) { () => + Future { + Thread.sleep(125) + }(system.dispatcher) + } + .await must throwA[ThrottleCallTimeoutException] + } + } + + def buffer_full = new AkkaTestKitSpecs2Context { + for (_ <- 1 to 10) { + val throttler = testThrottler(ratePer100ms = 1, bufferSize = 1) + val group = "group" + + // make one call; rate is reached now + throttler + .throttled(group) { () => + Future.successful("the-result") + } + .await // waits to make sure in flight count is 0 + + // make more calls; buffer is full now + throttler.throttled(group) { () => + Future.successful("the-result") + } + + // next call must result in exception + throttler + .throttled(group) { () => + Future.successful("the-result") + } + .await must throwA[ThrottleBufferFullException] + } + } + + def testThrottler(timeoutInMillis: Int = 10000, ratePer100ms: Int = 10, bufferSize: Int = 100)(implicit as: ActorSystem): Throttler[String] = { + Throttler[String]( + groupBy = identity, + amount = ratePer100ms, + per = FiniteDuration(100, TimeUnit.MILLISECONDS), + timeout = akka.util.Timeout(timeoutInMillis, TimeUnit.MILLISECONDS), + maxCallsInFlight = bufferSize + ) + } + + implicit class AwaitableExtension[T](awaitable: Awaitable[T]) { + import scala.concurrent.duration._ + def await: T = { + Await.result(awaitable, 5.seconds) + } + } + +} diff --git a/server/libs/bugsnag/build.sbt b/server/libs/bugsnag/build.sbt new file mode 100644 index 0000000000..10cc1b95bf --- /dev/null +++ b/server/libs/bugsnag/build.sbt @@ -0,0 +1,9 @@ +libraryDependencies ++= Seq( + "com.bugsnag" % "bugsnag" % "3.0.2", + "org.specs2" %% "specs2-core" % "3.8.8" % "test", + "com.typesafe.play" %% "play" % "2.4.0" % "test", + "com.fasterxml.jackson.core" % "jackson-databind" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-annotations" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-core" % "2.8.4", + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % "2.8.4" +) diff --git a/server/libs/bugsnag/src/main/scala/cool/graph/bugsnag/Bugsnag.scala b/server/libs/bugsnag/src/main/scala/cool/graph/bugsnag/Bugsnag.scala new file mode 100644 index 0000000000..a6049c711c --- /dev/null +++ b/server/libs/bugsnag/src/main/scala/cool/graph/bugsnag/Bugsnag.scala @@ -0,0 +1,95 @@ +package cool.graph.bugsnag + +import com.bugsnag.{Bugsnag => BugsnagClient} + +case class Request(method: String, uri: String, headers: Map[String, String]) +case class MetaData(tabName: String, key: String, value: Any) +case class GraphCoolRequest(requestId: String, query: String, variables: String, clientId: Option[String], projectId: Option[String]) + +trait BugSnagger { + def report(t: Throwable): Unit = report(t, Seq.empty) + + def report(t: Throwable, graphCoolRequest: GraphCoolRequest): Unit + def report(t: Throwable, metaDatas: Seq[MetaData]): Unit + def report(t: Throwable, request: Request): Unit + def report(t: Throwable, request: Request, metaDatas: Seq[MetaData]): Unit + def report(t: Throwable, requestHeader: Option[Request], metaDatas: Seq[MetaData]): Unit +} + +case class BugSnaggerImpl(apiKey: String) extends BugSnagger { + val gitSha = sys.env.get("COMMIT_SHA").getOrElse("commit sha not set") + val environment = sys.env.get("ENVIRONMENT").getOrElse("environment not set") + val service = sys.env.get("SERVICE_NAME").getOrElse("service not set") + val hostName = java.net.InetAddress.getLocalHost.getHostName + private val client = new BugsnagClient(apiKey) + + override def report(t: Throwable): Unit = report(t, Seq.empty) + + override def report(t: Throwable, graphCoolRequest: GraphCoolRequest): Unit = { + val metaDatas = Seq( + MetaData("Ids", "requestId", graphCoolRequest.requestId), + MetaData("Ids", "clientId", graphCoolRequest.clientId.getOrElse("no clientId")), + MetaData("Ids", "projectId", graphCoolRequest.projectId.getOrElse("no projectId")), + MetaData("Query", "query", graphCoolRequest.query), + MetaData("Query", "variables", graphCoolRequest.variables) + ) + report(t, metaDatas) + } + + override def report(t: Throwable, metaDatas: Seq[MetaData]): Unit = report(t, None, metaDatas) + + override def report(t: Throwable, request: Request): Unit = report(t, request, Seq.empty) + + override def report(t: Throwable, request: Request, metaDatas: Seq[MetaData]): Unit = { + report(t, Some(request), metaDatas) + } + + override def report(t: Throwable, requestHeader: Option[Request], metaDatas: Seq[MetaData]): Unit = { + val report = client.buildReport(t) + + // In case we're running in an env without api key (local or testing), just print the messages for debugging + if (apiKey.isEmpty) { + println(s"[Bugsnag - local / testing] Error: $t") + } + + report.addToTab("App", "releaseStage", environment) + report.addToTab("App", "service", service) + report.addToTab("App", "version", gitSha) + report.addToTab("App", "hostname", hostName) + + requestHeader.foreach { headers => + report.addToTab("Request", "uri", headers.uri) + report.addToTab("Request", "method", headers.method) + report.addToTab("Request", "headers", headersAsString(headers)) + } + + metaDatas.foreach { md => + report.addToTab(md.tabName, md.key, md.value) + } + + client.notify(report) + } + + private def headersAsString(request: Request): String = { + request.headers + .map { + case (key, value) => s"$key: $value" + } + .mkString("\n") + } + +} + +object BugSnaggerMock extends BugSnagger { + override def report(t: Throwable): Unit = report(t, Seq.empty) + + override def report(t: Throwable, graphCoolRequest: GraphCoolRequest): Unit = Unit + + override def report(t: Throwable, metaDatas: Seq[MetaData]): Unit = Unit + + override def report(t: Throwable, request: Request): Unit = Unit + + override def report(t: Throwable, request: Request, metaDatas: Seq[MetaData]): Unit = Unit + + override def report(t: Throwable, requestHeader: Option[Request], metaDatas: Seq[MetaData]): Unit = Unit +} diff --git a/server/libs/cache/src/main/scala/cool/graph/cache/Cache.scala b/server/libs/cache/src/main/scala/cool/graph/cache/Cache.scala new file mode 100644 index 0000000000..edb2a7a414 --- /dev/null +++ b/server/libs/cache/src/main/scala/cool/graph/cache/Cache.scala @@ -0,0 +1,41 @@ +package cool.graph.cache + +import scala.concurrent.{ExecutionContext, Future} + +object Cache { + def unbounded[K, V >: Null](): Cache[K, V] = { + CaffeineImplForCache.unbounded[K, V] + } + + def lfu[K, V >: Null](initialCapacity: Int, maxCapacity: Int): Cache[K, V] = { + CaffeineImplForCache.lfu(initialCapacity = initialCapacity, maxCapacity = maxCapacity) + } + + def lfuAsync[K, V >: Null](initialCapacity: Int, maxCapacity: Int)(implicit ec: ExecutionContext): AsyncCache[K, V] = { + CaffeineImplForAsyncCache.lfu(initialCapacity = initialCapacity, maxCapacity = maxCapacity) + } +} + +trait Cache[K, V] { + def get(key: K): Option[V] + + def put(key: K, value: V): Unit + + def remove(key: K): Unit + + def getOrUpdate(key: K, fn: () => V): V + + def getOrUpdateOpt(key: K, fn: () => Option[V]): Option[V] +} + +trait AsyncCache[K, V] { + def get(key: K): Future[Option[V]] + + def put(key: K, value: Future[Option[V]]): Unit + + def remove(key: K): Unit + + def getOrUpdate(key: K, fn: () => Future[V]): Future[V] + + def getOrUpdateOpt(key: K, fn: () => Future[Option[V]]): Future[Option[V]] +} diff --git a/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForAsyncCache.scala b/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForAsyncCache.scala new file mode 100644 index 0000000000..db105d2b84 --- /dev/null +++ b/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForAsyncCache.scala @@ -0,0 +1,67 @@ +package cool.graph.cache + +import java.util.concurrent.{CompletableFuture, Executor} +import java.util.function.BiFunction + +import com.github.benmanes.caffeine.cache.{AsyncCacheLoader, Caffeine, AsyncLoadingCache => AsyncCaffeineCache, Cache => CaffeineCache} +import scala.compat.java8.FunctionConverters._ +import scala.compat.java8.FutureConverters._ + +import scala.concurrent.{ExecutionContext, Future} + +object CaffeineImplForAsyncCache { + def lfu[K, V >: Null](initialCapacity: Int, maxCapacity: Int)(implicit ec: ExecutionContext): AsyncCache[K, V] = { + val caffeineCache = Caffeine + .newBuilder() + .initialCapacity(initialCapacity) + .maximumSize(maxCapacity) + .asInstanceOf[Caffeine[K, V]] + .buildAsync[K, V](dummyLoader[K, V]) + CaffeineImplForAsyncCache(caffeineCache) + } + + //LfuCache requires a loader function on creation - this will not be used. + private def dummyLoader[K, V] = new AsyncCacheLoader[K, V] { + def asyncLoad(k: K, e: Executor) = + Future.failed[V](new RuntimeException("Dummy loader should not be used by LfuCache")).toJava.toCompletableFuture + } +} + +case class CaffeineImplForAsyncCache[K, V >: Null](underlying: AsyncCaffeineCache[K, V])(implicit ec: ExecutionContext) extends AsyncCache[K, V] { + + override def get(key: K): Future[Option[V]] = { + val cacheEntry = underlying.getIfPresent(key) + if (cacheEntry != null) { + cacheEntry.toScala.map(Some(_)) + } else { + Future.successful(None) + } + } + + override def put(key: K, value: Future[Option[V]]): Unit = { + val asCompletableNullableFuture = value.map(_.orNull).toJava.toCompletableFuture + underlying.put(key, asCompletableNullableFuture) + } + + override def remove(key: K): Unit = underlying.synchronous().invalidate(key) + + override def getOrUpdate(key: K, fn: () => Future[V]): Future[V] = { + val javaFn = toCaffeineMappingFunction[K, V](fn) + underlying.get(key, javaFn).toScala + } + + override def getOrUpdateOpt(key: K, fn: () => Future[Option[V]]): Future[Option[V]] = { + val nullable: () => Future[V] = () => fn().map(_.orNull) + val javaFn = toCaffeineMappingFunction[K, V](nullable) + val cacheEntry = underlying.get(key, javaFn) + if (cacheEntry != null) { + cacheEntry.toScala.map(Option(_)) + } else { + Future.successful(None) + } + } + + private def toCaffeineMappingFunction[K, V](genValue: () ⇒ Future[V]): BiFunction[K, Executor, CompletableFuture[V]] = { + asJavaBiFunction[K, Executor, CompletableFuture[V]]((_, _) ⇒ genValue().toJava.toCompletableFuture) + } +} diff --git a/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForCache.scala b/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForCache.scala new file mode 100644 index 0000000000..bb2e05e625 --- /dev/null +++ b/server/libs/cache/src/main/scala/cool/graph/cache/CaffeineImplForCache.scala @@ -0,0 +1,40 @@ +package cool.graph.cache + +import com.github.benmanes.caffeine.cache.{Caffeine, Cache => CaffeineCache} +import scala.compat.java8.FunctionConverters._ + +object CaffeineImplForCache { + def unbounded[K, V >: Null](): CaffeineImplForCache[K, V] = { + val caffeineCache = Caffeine.newBuilder().asInstanceOf[Caffeine[K, V]].build[K, V]() + CaffeineImplForCache(caffeineCache) + } + + def lfu[K, V >: Null](initialCapacity: Int, maxCapacity: Int): CaffeineImplForCache[K, V] = { + val caffeineCache = Caffeine + .newBuilder() + .initialCapacity(initialCapacity) + .maximumSize(maxCapacity) + .asInstanceOf[Caffeine[K, V]] + .build[K, V]() + CaffeineImplForCache(caffeineCache) + } +} + +case class CaffeineImplForCache[K, V >: Null](underlying: CaffeineCache[K, V]) extends Cache[K, V] { + + override def get(key: K): Option[V] = Option(underlying.getIfPresent(key)) + + override def put(key: K, value: V): Unit = underlying.put(key, value) + + override def remove(key: K): Unit = underlying.invalidate(key) + + override def getOrUpdate(key: K, fn: () => V): V = { + val caffeineFunction = (_: K) => fn() + underlying.get(key, asJavaFunction(caffeineFunction)) + } + + override def getOrUpdateOpt(key: K, fn: () => Option[V]): Option[V] = { + val caffeineFunction = (_: K) => fn().orNull + Option(underlying.get(key, asJavaFunction(caffeineFunction))) + } +} diff --git a/server/libs/cache/src/test/scala/cool/graph/cache/AwaitUtil.scala b/server/libs/cache/src/test/scala/cool/graph/cache/AwaitUtil.scala new file mode 100644 index 0000000000..918d0c520e --- /dev/null +++ b/server/libs/cache/src/test/scala/cool/graph/cache/AwaitUtil.scala @@ -0,0 +1,8 @@ +package cool.graph.cache + +import scala.concurrent.{Await, Awaitable} + +trait AwaitUtil { + import scala.concurrent.duration._ + def await[T](awaitable: Awaitable[T]): T = Await.result(awaitable, 5.seconds) +} diff --git a/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForAsyncCacheSpec.scala b/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForAsyncCacheSpec.scala new file mode 100644 index 0000000000..f6d5094a45 --- /dev/null +++ b/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForAsyncCacheSpec.scala @@ -0,0 +1,21 @@ +package cool.graph.cache + +import org.scalatest.{FlatSpec, Matchers} + +import scala.concurrent.Future + +class CaffeineImplForAsyncCacheSpec extends FlatSpec with Matchers with AwaitUtil { + import scala.concurrent.ExecutionContext.Implicits.global + + def newCache = CaffeineImplForAsyncCache.lfu[String, String](initialCapacity = 100, maxCapacity = 100) + + "it" should "handle None results correctly" in { + val cache = newCache + val result = await(cache.getOrUpdateOpt("key", () => Future.successful(None))) + result should be(None) + + val foo = Some("foo") + val result2 = await(cache.getOrUpdateOpt("key", () => Future.successful(foo))) + result2 should be(foo) + } +} diff --git a/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForSyncCacheSpec.scala b/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForSyncCacheSpec.scala new file mode 100644 index 0000000000..0c5736edf4 --- /dev/null +++ b/server/libs/cache/src/test/scala/cool/graph/cache/CaffeineImplForSyncCacheSpec.scala @@ -0,0 +1,20 @@ +package cool.graph.cache + +import org.scalatest.{FlatSpec, Matchers} + +class CaffeineImplForSyncCacheSpec extends FlatSpec with Matchers { + + def newCache = CaffeineImplForCache.lfu[String, String](initialCapacity = 100, maxCapacity = 100) + + "it" should "handle None results correctly" in { + val cache = newCache + val result = cache.getOrUpdateOpt("key", () => None) + result should be(None) + cache.underlying.estimatedSize() should be(0) + + val foo = Some("foo") + val result2 = cache.getOrUpdateOpt("key", () => foo) + result2 should be(foo) + cache.underlying.estimatedSize() should be(1) + } +} diff --git a/server/libs/cloudwatch/build.sbt b/server/libs/cloudwatch/build.sbt new file mode 100644 index 0000000000..fff4740feb --- /dev/null +++ b/server/libs/cloudwatch/build.sbt @@ -0,0 +1,8 @@ +libraryDependencies ++= Seq( + "com.amazonaws" % "aws-java-sdk-cloudwatch" % "1.11.171", + "com.typesafe.akka" %% "akka-actor" % "2.4.8" % "provided", + "com.fasterxml.jackson.core" % "jackson-databind" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-annotations" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-core" % "2.8.4", + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % "2.8.4" +) diff --git a/server/libs/cloudwatch/src/main/scala/cool/graph/cloudwatch/Cloudwatch.scala b/server/libs/cloudwatch/src/main/scala/cool/graph/cloudwatch/Cloudwatch.scala new file mode 100644 index 0000000000..14e23a898d --- /dev/null +++ b/server/libs/cloudwatch/src/main/scala/cool/graph/cloudwatch/Cloudwatch.scala @@ -0,0 +1,163 @@ +package cool.graph.cloudwatch + +import java.util.concurrent.TimeUnit + +import akka.actor.{Actor, ActorSystem, Props} +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.cloudwatch.{AmazonCloudWatchAsyncClient, AmazonCloudWatchAsyncClientBuilder} +import com.amazonaws.services.cloudwatch.model._ + +import scala.collection.mutable +import scala.concurrent.duration.FiniteDuration + +trait CloudwatchMetric { + def name: String + def namespacePostfix: String + def unit: StandardUnit + def value: Double + def dimensionName: String + def dimensionValue: String +} + +case class CountMetric(name: String, + namespacePostfix: String, + intValue: Int, + dimensionName: String = "dummy dimension", + dimensionValue: String = "dummy dimension value") + extends CloudwatchMetric { + override val unit = StandardUnit.Count + override val value = intValue.toDouble +} + +trait Cloudwatch { + def measure(cloudwatchMetric: CloudwatchMetric): Unit +} + +case class CloudwatchImpl()(implicit actorSystem: ActorSystem) extends Cloudwatch { + val actor = actorSystem.actorOf(CloudwatchMetricActorImpl.props) + + def measure(cloudwatchMetric: CloudwatchMetric): Unit = { + actor ! cloudwatchMetric + } +} + +object CloudwatchMock extends Cloudwatch { + def measure(cloudwatchMetric: CloudwatchMetric): Unit = { + // + } +} + +abstract class CloudwatchMetricActor extends Actor + +object CloudwatchMetricActorImpl { + def props = Props(new CloudwatchMetricActorImpl()) +} + +/** + * Stores CloudWatch metrics for up to 60 seconds, then aggregates by dimension and service before pushing + */ +class CloudwatchMetricActorImpl extends CloudwatchMetricActor { + + val credentials = + new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + val cw = AmazonCloudWatchAsyncClientBuilder.standard + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("CLOUDWATCH_ENDPOINT"), sys.env("AWS_REGION"))) + .build + + val environment = sys.env.getOrElse("ENVIRONMENT", "local") + val serviceName = sys.env.getOrElse("SERVICE_NAME", "local") + val namespacePrefix = s"/graphcool/${environment}/" + + case class Metric(name: String, namespace: String, dimensions: List[(String, String)], unit: StandardUnit, value: Double) + + def createMetrics(metric: CloudwatchMetric): List[Metric] = { + List( + Metric( + metric.name, + s"$namespacePrefix${metric.namespacePostfix}", + List(("By Service", serviceName), (metric.dimensionName, metric.dimensionValue)), + metric.unit, + metric.value + ), + Metric( + metric.name, + s"$namespacePrefix${metric.namespacePostfix}", + List(("By Service", "ALL"), (metric.dimensionName, metric.dimensionValue)), + metric.unit, + metric.value + ), + Metric(metric.name, + s"$namespacePrefix${metric.namespacePostfix}", + List(("By Service", serviceName), (metric.dimensionName, "ALL")), + metric.unit, + metric.value), + Metric(metric.name, s"$namespacePrefix${metric.namespacePostfix}", List(("By Service", "ALL"), (metric.dimensionName, "ALL")), metric.unit, metric.value) + ) + } + + val PUSH_TO_CLOUDWATCH = "PUSH_TO_CLOUDWATCH" + + import context.dispatcher + + val tick = + context.system.scheduler + .schedule(FiniteDuration(60, TimeUnit.SECONDS), FiniteDuration(60, TimeUnit.SECONDS), self, PUSH_TO_CLOUDWATCH) + + override def postStop() = tick.cancel() + + val metrics: scala.collection.mutable.MutableList[CloudwatchMetric] = mutable.MutableList() + + def receive = { + case metric: CloudwatchMetric => { + metrics += metric + } + case PUSH_TO_CLOUDWATCH => { + + import collection.JavaConverters._ + + val groups = metrics + .groupBy(m => (m.namespacePostfix, m.unit, m.dimensionValue, m.dimensionName, m.name)) + .values + + val statistics = groups.map(group => { + val max = group.map(_.value).max + val min = group.map(_.value).min + val count = group.length + val sum = group.map(_.value).sum + + (group.head, (max, min, count, sum)) + }) + + statistics.map(statistic => { + val statSet = new StatisticSet() + .withMaximum(statistic._2._1) + .withMinimum(statistic._2._2) + .withSampleCount(statistic._2._3.toDouble) + .withSum(statistic._2._4) + + createMetrics(statistic._1) + .map((m: Metric) => { + val request = new PutMetricDataRequest().withNamespace(m.namespace) + val cwMetric = new MetricDatum() + .withMetricName(m.name) + .withUnit(m.unit) + .withStatisticValues(statSet) + .withDimensions( + m.dimensions + .map(dimension => + new Dimension() + .withName(dimension._1) + .withValue(dimension._2)) + .asJavaCollection) + request.withMetricData(cwMetric) + }) + .foreach(x => println(cw.putMetricData(x))) + }) + + metrics.clear() + } + } +} diff --git a/server/libs/graphql-client/build.sbt b/server/libs/graphql-client/build.sbt new file mode 100644 index 0000000000..747bf9aa8b --- /dev/null +++ b/server/libs/graphql-client/build.sbt @@ -0,0 +1,6 @@ +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-http" % "10.0.5" % "provided", + "com.typesafe.play" %% "play-json" % "2.5.12" +) + +fork in Test := true diff --git a/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClient.scala b/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClient.scala new file mode 100644 index 0000000000..c5bb1445a1 --- /dev/null +++ b/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClient.scala @@ -0,0 +1,73 @@ +package cool.graph.graphql + +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer +import cool.graph.akkautil.SingleThreadedActorSystem +import play.api.libs.json.{JsPath, JsValue, Json, Reads} + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +trait GraphQlClient { + def sendQuery(query: String): Future[GraphQlResponse] +} + +object GraphQlClient { + private implicit lazy val actorSystem = SingleThreadedActorSystem("graphql-client") + private implicit lazy val actorMaterializer = ActorMaterializer()(actorSystem) + private implicit lazy val akkaHttp = Http()(actorSystem) + + def apply(uri: String, headers: Map[String, String] = Map.empty): GraphQlClient = { + GraphQlClientImpl(uri, headers, akkaHttp) + } +} + +case class GraphQlResponse(status: Int, body: String) { + def bodyAs[T](path: String)(implicit reads: Reads[T]): Try[T] = { + def jsPathForElements(pathElements: Seq[String], current: JsPath = JsPath()): JsPath = { + if (pathElements.isEmpty) { + current + } else { + jsPathForElements(pathElements.tail, current \ pathElements.head) + } + } + val jsPath = jsPathForElements(path.split('.')) + val actualReads = jsPath.read(reads) + jsonBody.map(_.as(actualReads)) + } + + val is2xx: Boolean = status >= 200 && status <= 299 + val is200: Boolean = status == 200 + val is404: Boolean = status == 404 + + def isSuccess: Boolean = deserializedBody match { + case Success(x) => x.errors.isEmpty && is200 + case Failure(e) => false + } + + def isFailure: Boolean = !isSuccess + def firstError: GraphQlError = deserializedBody.get.errors.head + + private lazy val deserializedBody: Try[GraphQlResponseJson] = { + for { + body <- jsonBody + response <- Try { body.as(JsonReaders.graphqlResponseReads) } + } yield response + } + + lazy val jsonBody: Try[JsValue] = Try(Json.parse(body)) +} + +case class GraphQlResponseJson(data: JsValue, errors: Seq[GraphQlError]) +case class GraphQlError(message: String, code: Int) + +object JsonReaders { + import play.api.libs.functional.syntax._ + import play.api.libs.json._ + + implicit lazy val graphqlErrorReads = Json.reads[GraphQlError] + implicit lazy val graphqlResponseReads = ( + (JsPath \ "data").read[JsValue] and + (JsPath \ "errors").readNullable[Seq[GraphQlError]].map(_.getOrElse(Seq.empty)) + )(GraphQlResponseJson.apply _) +} diff --git a/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClientImpl.scala b/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClientImpl.scala new file mode 100644 index 0000000000..afe1487b2d --- /dev/null +++ b/server/libs/graphql-client/src/main/scala/cool/graph/graphql/GraphQlClientImpl.scala @@ -0,0 +1,47 @@ +package cool.graph.graphql + +import akka.actor.ActorSystem +import akka.http.scaladsl.HttpExt +import akka.http.scaladsl.model.HttpHeader.ParsingResult.Ok +import akka.http.scaladsl.model._ +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.stream.ActorMaterializer +import play.api.libs.json.Json + +import scala.concurrent.Future + +case class GraphQlClientImpl(uri: String, headers: Map[String, String], akkaHttp: HttpExt)( + implicit system: ActorSystem, + materializer: ActorMaterializer +) extends GraphQlClient { + import system.dispatcher + + def sendQuery(query: String): Future[GraphQlResponse] = { + val body = Json.obj("query" -> query) + val entity = HttpEntity(ContentTypes.`application/json`, body.toString) + val akkaHeaders = headers + .flatMap { + case (key, value) => + HttpHeader.parse(key, value) match { + case Ok(header, _) => Some(header) + case _ => None + } + } + .to[collection.immutable.Seq] + + val akkaRequest = HttpRequest( + uri = uri, + method = HttpMethods.POST, + entity = entity, + headers = akkaHeaders + ) + + akkaHttp.singleRequest(akkaRequest).flatMap(convertResponse) + } + + private def convertResponse(akkaResponse: HttpResponse): Future[GraphQlResponse] = { + Unmarshal(akkaResponse).to[String].map { bodyString => + GraphQlResponse(akkaResponse.status.intValue, bodyString) + } + } +} diff --git a/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlClientSpec.scala b/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlClientSpec.scala new file mode 100644 index 0000000000..c399f5798c --- /dev/null +++ b/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlClientSpec.scala @@ -0,0 +1,52 @@ +package cool.graph.graphql + +import org.scalatest.{FlatSpec, Matchers} + +import scala.concurrent.{Await, Awaitable} + +class GraphQlClientSpec extends FlatSpec with Matchers { + import cool.graph.stub.Import._ + import scala.concurrent.ExecutionContext.Implicits.global + + val stub = Request("POST", "/graphql-endpoint").stub(200, """{"data": {"id": "1234"}}""").ignoreBody + + "sendQuery" should "send the correct the correct JSON structure to the server" in { + withStubServer(List(stub)).withArg { server => + val uri = s"http://localhost:${server.port}${stub.path}" + val client = GraphQlClient(uri) + val query = """ { mutation { createTodo(title:"the title"){id} }} """ + val result = await(client.sendQuery(query)) + + val expectedBody = s"""{"query":"${escapeQuery(query)}"}""" + server.lastRequest.body should equal(expectedBody) + + result.status should equal(stub.stubbedResponse.status) + result.body should equal(stub.stubbedResponse.body) + } + } + + "sendQuery" should "send the specified headers to the server" in { + withStubServer(List(stub)).withArg { server => + val uri = s"http://localhost:${server.port}${stub.path}" + val header1 = "Header1" -> "Header1Value" + val header2 = "Header2" -> "Header2Value" + val headers = Map(header1, header2) + val client = GraphQlClient(uri, headers) + val query = """ { mutation { createTodo(title:"the title"){id} }} """ + val result = await(client.sendQuery(query)) + + server.lastRequest.headers should contain(header1) + server.lastRequest.headers should contain(header2) + + result.status should equal(stub.stubbedResponse.status) + result.body should equal(stub.stubbedResponse.body) + } + } + + def escapeQuery(query: String) = query.replace("\"", "\\\"") + + def await[T](awaitable: Awaitable[T]): T = { + import scala.concurrent.duration._ + Await.result(awaitable, 5.seconds) + } +} diff --git a/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlResponseSpec.scala b/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlResponseSpec.scala new file mode 100644 index 0000000000..ddc2c672d1 --- /dev/null +++ b/server/libs/graphql-client/src/test/scala/cool/graph/graphql/GraphQlResponseSpec.scala @@ -0,0 +1,40 @@ +package cool.graph.graphql + +import org.scalatest.{FlatSpec, Matchers} + +class GraphQlResponseSpec extends FlatSpec with Matchers { + val exampleError = errorJson(code = 1111, message = "something did not workout") + + "isSuccess" should "return true if there are NO errors in the response body" in { + val response = GraphQlResponse(status = 200, body = """ {"data": {"title":"My Todo"} } """) + response.isSuccess should be(true) + } + + "isSuccess" should "return false if there are errors in the response body" in { + val response = GraphQlResponse(status = 200, body = s""" {"data": null, "errors": [$exampleError] } """) + response.isSuccess should be(false) + } + + "isFailure" should "return false if there are NO errors in the response body" in { + val response = GraphQlResponse(status = 200, body = """ {"data": {"title":"My Todo"} } """) + response.isFailure should be(false) + } + + "isFailure" should "return true if there are errors in the response body" in { + val response = GraphQlResponse(status = 200, body = s""" {"data": null, "errors": [$exampleError] } """) + response.isFailure should be(true) + } + + "firstError" should "return the first error in a failed response" in { + val errorCode = 2222 + val errorMessage = "this is the message of the error" + val firstError = errorJson(errorCode, errorMessage) + val response = GraphQlResponse(status = 200, body = s""" {"data": null, "errors": [$firstError, $exampleError] } """) + + val error = response.firstError + error.code should equal(errorCode) + error.message should equal(errorMessage) + } + + def errorJson(code: Int, message: String): String = s"""{"code":$code, "message":"$message"}""" +} diff --git a/server/libs/javascript-engine/build.sbt b/server/libs/javascript-engine/build.sbt new file mode 100644 index 0000000000..7de09a587b --- /dev/null +++ b/server/libs/javascript-engine/build.sbt @@ -0,0 +1,9 @@ +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % "2.4.8" % "provided", + "org.specs2" %% "specs2-core" % "3.8.8" % "test", + "com.typesafe" % "jse_2.11" % "1.2.0", + "cool.graph" % "cuid-java" % "0.1.1", + "org.scalatest" %% "scalatest" % "2.2.6" % "test" +) + +fork in Test := true diff --git a/server/libs/javascript-engine/src/main/resources/application.conf b/server/libs/javascript-engine/src/main/resources/application.conf new file mode 100644 index 0000000000..d71e32e753 --- /dev/null +++ b/server/libs/javascript-engine/src/main/resources/application.conf @@ -0,0 +1,9 @@ +blocking-process-io-dispatcher { + type = Dispatcher + executor = "thread-pool-executor" + thread-pool-executor { + core-pool-size-min = 3 + core-pool-size-factor = 1.0 + core-pool-size-max = 100 + } +} \ No newline at end of file diff --git a/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/JavascriptExecutor.scala b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/JavascriptExecutor.scala new file mode 100644 index 0000000000..1594e33ccb --- /dev/null +++ b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/JavascriptExecutor.scala @@ -0,0 +1,76 @@ +package cool.graph.javascriptEngine + +import akka.actor.ActorSystem +import akka.pattern.ask +import akka.util.Timeout +import com.typesafe.jse.Engine.JsExecutionResult +import cool.graph.cuid.Cuid +import cool.graph.javascriptEngine.lib.{Engine, Trireme} + +import scala.collection.immutable +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration._ + +object JavascriptExecutor { + implicit val system = ActorSystem("jse-system") + implicit val timeout = Timeout(5.seconds) + + def execute(program: String): Future[Result] = { + + // note: probably not the way to do this ... + val engine = system.actorOf(Trireme.props(), s"engine-${Cuid.createCuid()}") + + (engine ? Engine.ExecuteJs(program, immutable.Seq(), timeout.duration)) + .mapTo[JsExecutionResult] + .map(res => Result(result = res.output.utf8String, error = res.error.utf8String)) + } + + def executeFunction(program: String): Future[Map[String, Any]] = { + import spray.json._ + import DefaultJsonProtocol._ + + // todo: copied from shared.Utils. Extract to own module + implicit object AnyJsonFormat extends JsonFormat[Any] { + def write(x: Any) = x match { + case m: Map[_, _] => + JsObject(m.asInstanceOf[Map[String, Any]].mapValues(write)) + case l: List[Any] => JsArray(l.map(write).toVector) + case n: Int => JsNumber(n) + case n: Long => JsNumber(n) + case s: String => JsString(s) + case true => JsTrue + case false => JsFalse + case v: JsValue => v + case null => JsNull + case r => JsString(r.toString) + } + + def read(x: JsValue): Any = { + x match { + case l: JsArray => l.elements.map(read).toList + case m: JsObject => m.fields.mapValues(write) + case s: JsString => s.value + case n: JsNumber => n.value + case b: JsBoolean => b.value + case JsNull => null + case _ => sys.error("implement all scalar types!") + } + } + } + + execute(program).map(res => { + + if (!res.error.trim.isEmpty) { + throw new JsExecutionError(res.error) + } + + res.result.parseJson.asJsObject.convertTo[Map[String, Any]] + }) + } + +} + +case class Result(result: String, error: String) + +class JsExecutionError(message: String) extends Error diff --git a/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Engine.scala b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Engine.scala new file mode 100644 index 0000000000..f101822e03 --- /dev/null +++ b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Engine.scala @@ -0,0 +1,137 @@ +package cool.graph.javascriptEngine.lib + +import java.util.concurrent.TimeUnit + +import akka.actor.{Terminated, ActorRef, Actor} +import com.typesafe.config.Config +import scala.concurrent.duration._ +import akka.util.ByteString +import scala.collection.immutable +import com.typesafe.jse.Engine.JsExecutionResult + +/** + * A JavaScript engine. JavaScript engines are intended to be short-lived and will terminate themselves on + * completion of executing some JavaScript. + */ +abstract class Engine(stdArgs: immutable.Seq[String], stdEnvironment: Map[String, String]) extends Actor { + + /* + * An engineIOHandler is a receiver that aggregates stdout and stderr from JavaScript execution. + * Execution may also be timed out. The contract is that an exit value is always + * only ever sent after all stdio has completed. + */ + def engineIOHandler( + stdinSink: ActorRef, + stdoutSource: ActorRef, + stderrSource: ActorRef, + receiver: ActorRef, + ack: => Any, + timeout: FiniteDuration, + timeoutExitValue: Int + ): Receive = { + + val errorBuilder = ByteString.newBuilder + val outputBuilder = ByteString.newBuilder + + def handleStdioBytes(sender: ActorRef, bytes: ByteString): Unit = { + sender match { + case `stderrSource` => errorBuilder ++= bytes + case `stdoutSource` => outputBuilder ++= bytes + } + sender ! ack + } + + def sendExecutionResult(exitValue: Int): Unit = { + receiver ! JsExecutionResult(exitValue, outputBuilder.result(), errorBuilder.result()) + } + + context.watch(stdinSink) + context.watch(stdoutSource) + context.watch(stderrSource) + + val timeoutTimer = context.system.scheduler.scheduleOnce(timeout, self, timeoutExitValue)(context.dispatcher) + + var openStreams = 3 + + def stopContext(): Unit = { + timeoutTimer.cancel() + context.stop(self) + } + + { + case bytes: ByteString => handleStdioBytes(sender(), bytes) + case exitValue: Int => + if (exitValue != timeoutExitValue) { + context.become { + case bytes: ByteString => handleStdioBytes(sender(), bytes) + case Terminated(`stdinSink` | `stdoutSource` | `stderrSource`) => { + openStreams -= 1 + if (openStreams == 0) { + sendExecutionResult(exitValue) + stopContext() + } + } + } + } else { + stopContext() + } + case Terminated(`stdinSink` | `stdoutSource` | `stderrSource`) => + openStreams -= 1 + if (openStreams == 0) { + context.become { + case exitValue: Int => + sendExecutionResult(exitValue) + stopContext() + } + } + } + } + +} + +object Engine { + + /** + * Execute JS. Execution will result in a JsExecutionResult being replied to the sender. + * @param source The source file to execute. + * @param args The sequence of arguments to pass to the js source. + * @param timeout The amount of time to wait for the js to execute. Recommend at least 1 minute given slow CI servers in particular. + * @param timeoutExitValue The exit value to receive if the above timeout occurs. + * @param environment A mapping of environment variables to use. + */ + case class ExecuteJs( + source: String, + args: immutable.Seq[String], + timeout: FiniteDuration, + timeoutExitValue: Int = Int.MinValue, + environment: Map[String, String] = Map.empty + ) + + /** + * The response of JS execution in the cases where it has been aggregated. A non-zero exit value + * indicates failure as per the convention of stdio processes. The output and error fields are + * aggregated from any respective output and error streams from the process. + */ + case class JsExecutionResult(exitValue: Int, output: ByteString, error: ByteString) + + // Internal types + + case object FinishProcessing + + /** + * Get an "infinite" timeout for Akka's default scheduler. + * + * Of course, there's no such thing as an infinite timeout, so this value is the maximum timeout that the scheduler + * will accept, which is equal to the maximum value of an integer multiplied by the tick duration. + * + * @param config The configuration to read the tick duration from. + */ + def infiniteSchedulerTimeout(config: Config): FiniteDuration = { + val tickNanos = config.getDuration("akka.scheduler.tick-duration", TimeUnit.NANOSECONDS) + + // we subtract tickNanos here because of this bug: + // https://github.com/akka/akka/issues/15598 + (tickNanos * Int.MaxValue - tickNanos).nanos + } + +} diff --git a/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Triteme.scala b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Triteme.scala new file mode 100644 index 0000000000..d6a1cf7efb --- /dev/null +++ b/server/libs/javascript-engine/src/main/scala/cool/graph/javascriptEngine/lib/Triteme.scala @@ -0,0 +1,198 @@ +package cool.graph.javascriptEngine.lib + +import java.io._ +import java.util.concurrent.{AbstractExecutorService, TimeUnit} + +import akka.actor._ +import akka.contrib.process.StreamEvents.Ack +import akka.contrib.process._ +import akka.pattern.AskTimeoutException +import cool.graph.javascriptEngine.lib.Engine.ExecuteJs +import io.apigee.trireme.core._ +import io.apigee.trireme.kernel.streams.{NoCloseInputStream, NoCloseOutputStream} +import org.mozilla.javascript.RhinoException + +import scala.collection.JavaConverters._ +import scala.collection.immutable +import scala.concurrent.blocking +import scala.concurrent.duration._ +import scala.util.Try + +/** + * Declares an in-JVM Rhino based JavaScript engine supporting the Node API. + * The Trireme project provides this capability. + * The actor is expected to be associated with a blocking dispatcher as its use of Jdk streams are blocking. + */ +class Trireme( + stdArgs: immutable.Seq[String], + stdEnvironment: Map[String, String], + ioDispatcherId: String +) extends Engine(stdArgs, stdEnvironment) { + + // The main objective of this actor implementation is to establish actors for both the execution of + // Trireme code (Trireme's execution is blocking), and actors for the source of stdio (which is also blocking). + // This actor is then a conduit of the IO as a result of execution. + + val StdioTimeout = Engine.infiniteSchedulerTimeout(context.system.settings.config) + + def receive = { + case ExecuteJs(source, args, timeout, timeoutExitValue, environment) => + val requester = sender() + + val stdinSink = context.actorOf(BufferingSink.props(ioDispatcherId = ioDispatcherId), "stdin") + val stdinIs = new SourceStream(stdinSink, StdioTimeout) + val stdoutSource = context.actorOf(ForwardingSource.props(self, ioDispatcherId = ioDispatcherId), "stdout") + val stdoutOs = new SinkStream(stdoutSource, StdioTimeout) + val stderrSource = context.actorOf(ForwardingSource.props(self, ioDispatcherId = ioDispatcherId), "stderr") + val stderrOs = new SinkStream(stderrSource, StdioTimeout) + + try { + context.become( + engineIOHandler( + stdinSink, + stdoutSource, + stderrSource, + requester, + Ack, + timeout, + timeoutExitValue + )) + + context.actorOf(TriremeShell.props( + source, + stdArgs ++ args, + stdEnvironment ++ environment, + ioDispatcherId, + stdinIs, + stdoutOs, + stderrOs + ), + "trireme-shell") ! TriremeShell.Execute + + } finally { + // We don't need stdin + blocking(Try(stdinIs.close())) + } + } +} + +object Trireme { + + /** + * Give me a Trireme props. + */ + def props( + stdArgs: immutable.Seq[String] = Nil, + stdEnvironment: Map[String, String] = Map.empty, + ioDispatcherId: String = "blocking-process-io-dispatcher" + ): Props = { + Props(classOf[Trireme], stdArgs, stdEnvironment, ioDispatcherId) + .withDispatcher(ioDispatcherId) + } + +} + +/** + * Manage the execution of the Trireme shell setting up its environment, running the main entry point + * and sending its parent the exit code when we're done. + */ +class TriremeShell( + source: String, + args: immutable.Seq[String], + environment: Map[String, String], + ioDispatcherId: String, + stdinIs: InputStream, + stdoutOs: OutputStream, + stderrOs: OutputStream +) extends Actor + with ActorLogging { + + val AwaitTerminationTimeout = 1.second + + val blockingDispatcher = context.system.dispatchers.lookup(ioDispatcherId) + val executorService = new AbstractExecutorService { + def shutdown() = throw new UnsupportedOperationException + def isTerminated = false + def awaitTermination(l: Long, timeUnit: TimeUnit) = throw new UnsupportedOperationException + def shutdownNow() = throw new UnsupportedOperationException + def isShutdown = false + def execute(runnable: Runnable) = blockingDispatcher.execute(runnable) + } + + val env = (sys.env ++ environment).asJava + val sandbox = new Sandbox() + sandbox.setAsyncThreadPool(executorService) + val nodeEnv = new NodeEnvironment() + nodeEnv.setSandbox(sandbox) + sandbox.setStdin(new NoCloseInputStream(stdinIs)) + sandbox.setStdout(new NoCloseOutputStream(stdoutOs)) + sandbox.setStderr(new NoCloseOutputStream(stderrOs)) + + def receive = { + case TriremeShell.Execute => + if (log.isDebugEnabled) { + log.debug("Invoking Trireme with {}", args) + } + + val script = nodeEnv.createScript("thisIsAJsFile.js", source, args.toArray) + script.setEnvironment(env) + + val senderSel = sender().path + val senderSys = context.system + script.execute.setListener(new ScriptStatusListener { + def onComplete(script: NodeScript, status: ScriptStatus): Unit = { + if (status.hasCause) { + try { + status.getCause match { + case e: RhinoException => + stderrOs.write(e.getLocalizedMessage.getBytes("UTF-8")) + stderrOs.write(e.getScriptStackTrace.getBytes("UTF-8")) + case t => + t.printStackTrace(new PrintStream(stderrOs)) + } + } catch { + case e: Throwable => + if (e.isInstanceOf[AskTimeoutException] || status.getCause.isInstanceOf[AskTimeoutException]) { + log.error( + e, + "Received a timeout probably because stdio sinks and sources were closed early given a timeout waiting for the JS to execute. Increase the timeout." + ) + } else { + log.error(status.getCause, "Problem completing Trireme. Throwing exception, meanwhile here's the Trireme problem") + throw e + } + } + } + // The script holds an NIO selector that needs to be closed, otherwise it leaks. + script.close() + stdoutOs.close() + stderrOs.close() + senderSys.actorSelection(senderSel) ! status.getExitCode + } + }) + } + + override def postStop() = { + // The script pool is a cached thread pool so it should shut itself down, but it's better to clean up immediately, + // and this means that our tests work. + nodeEnv.getScriptPool.shutdown() + nodeEnv.getScriptPool.awaitTermination(AwaitTerminationTimeout.toMillis, TimeUnit.MILLISECONDS) + } +} + +object TriremeShell { + def props( + moduleBase: String, + args: immutable.Seq[String], + environment: Map[String, String], + ioDispatcherId: String = "blocking-process-io-dispatcher", + stdinIs: InputStream, + stdoutOs: OutputStream, + stderrOs: OutputStream + ): Props = { + Props(classOf[TriremeShell], moduleBase, args, environment, ioDispatcherId, stdinIs, stdoutOs, stderrOs) + } + + case object Execute + +} diff --git a/server/libs/javascript-engine/src/tests/scala/JavascriptExecutorSpec.scala b/server/libs/javascript-engine/src/tests/scala/JavascriptExecutorSpec.scala new file mode 100644 index 0000000000..103488cba2 --- /dev/null +++ b/server/libs/javascript-engine/src/tests/scala/JavascriptExecutorSpec.scala @@ -0,0 +1,77 @@ +package cool.graph.javascriptEngine + +import org.scalatest.concurrent.PatienceConfiguration.Timeout +import org.scalatest.{FlatSpec, Matchers} +import org.scalatest.concurrent.ScalaFutures._ + +import scala.concurrent.Future +import scala.concurrent.duration.Duration +import scala.concurrent.ExecutionContext.Implicits.global + +class JavascriptExecutorSpec extends FlatSpec with Matchers { + "engine" should "execute simple script" in { + + val before = System.currentTimeMillis() + + JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin).futureValue(Timeout(Duration.Inf)) should be(Result("42\n45lalala\n", "")) + + println("1 (initial): " + (System.currentTimeMillis() - before)) + + val before2 = System.currentTimeMillis() + + JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin).futureValue(Timeout(Duration.Inf)) should be(Result("42\n45lalala\n", "")) + + println("1 (warm): " + (System.currentTimeMillis() - before2)) + + val before3 = System.currentTimeMillis() + + (1 to 10).foreach(_ => JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin).futureValue(Timeout(Duration.Inf)) should be(Result("42\n45lalala\n", ""))) + + println("10 (seq): " + (System.currentTimeMillis() - before3)) + + val before4 = System.currentTimeMillis() + + Future.sequence((1 to 10).map(_ => JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin))).futureValue(Timeout(Duration.Inf)) + + println("10 (par): " + (System.currentTimeMillis() - before4)) + + val before5 = System.currentTimeMillis() + + Future.sequence((1 to 100).map(_ => JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin))).futureValue(Timeout(Duration.Inf)) + + println("100 (par): " + (System.currentTimeMillis() - before5)) + + val before6 = System.currentTimeMillis() + + Future + .sequence((1 to 1000).map(_ => JavascriptExecutor.execute(""" + |console.log(42) + | + |console.log(43 + 2 + "lalala") + """.stripMargin))) + .futureValue(Timeout(Duration.Inf)) + + println("1000 (par): " + (System.currentTimeMillis() - before6)) + + } +} diff --git a/server/libs/jvm-profiler/README.md b/server/libs/jvm-profiler/README.md new file mode 100644 index 0000000000..16dad5bd29 --- /dev/null +++ b/server/libs/jvm-profiler/README.md @@ -0,0 +1,22 @@ + # JVM Profiler + + This lib aims to provide easy to use profilers for the JVM. It currently only contains the `MemoryProfiler` that captures statistics about heap and off heap memory. + Additionally it collects informations about Garbage Collection times. + + The MemoryProfiler is built on top of our `metrics` lib. In order to schedule the `MemoryProfiler` you have to pass a `MetricsManager`. This `MetricsManager` is used to define the metrics the profiler will use. It will also use the underlying scheduler of the underlying `ActorSytem` of the manager to schedule the profiling. + + Here's an how to use it inside a MetricsManager: + ``` +import cool.graph.profiling.MemoryProfiler + +object MyMetrics extends MetricsManager { + override def serviceName: String = "MyMetrics" + + // use defaults for timings + MemoryProfiler.schedule(this) + + // or use custom timings + import scala.concurrent.duration._ + MemoryProfiler.schedule(this, initialDelay = 10.seconds, interval = 2.seconds) +} +``` \ No newline at end of file diff --git a/server/libs/jvm-profiler/src/main/scala/cool/graph/profiling/MemoryProfiler.scala b/server/libs/jvm-profiler/src/main/scala/cool/graph/profiling/MemoryProfiler.scala new file mode 100644 index 0000000000..c6022ba695 --- /dev/null +++ b/server/libs/jvm-profiler/src/main/scala/cool/graph/profiling/MemoryProfiler.scala @@ -0,0 +1,81 @@ +package cool.graph.profiling + +import java.lang.management.{GarbageCollectorMXBean, ManagementFactory, MemoryUsage} + +import akka.actor.Cancellable +import cool.graph.metrics.MetricsManager + +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.duration._ + +object MemoryProfiler { + def schedule( + metricsManager: MetricsManager, + initialDelay: FiniteDuration = 0.seconds, + interval: FiniteDuration = 5.seconds + ): Cancellable = { + import metricsManager.gaugeFlushSystem.dispatcher + val profiler = MemoryProfiler(metricsManager) + metricsManager.gaugeFlushSystem.scheduler.schedule(initialDelay, interval) { + profiler.profile() + } + } +} + +case class MemoryProfiler(metricsManager: MetricsManager) { + import scala.collection.JavaConversions._ + + val garbageCollectionMetrics = ManagementFactory.getGarbageCollectorMXBeans.map(gcBean => GarbageCollectionMetrics(metricsManager, gcBean)) + val memoryMxBean = ManagementFactory.getMemoryMXBean + val heapMemoryMetrics = MemoryMetrics(metricsManager, initialMemoryUsage = memoryMxBean.getHeapMemoryUsage, prefix = "heap") + val offHeapMemoryMetrics = MemoryMetrics(metricsManager, initialMemoryUsage = memoryMxBean.getNonHeapMemoryUsage, prefix = "off-heap") + + def profile(): Unit = { + heapMemoryMetrics.record(memoryMxBean.getHeapMemoryUsage) + offHeapMemoryMetrics.record(memoryMxBean.getNonHeapMemoryUsage) + garbageCollectionMetrics.foreach(_.record) + } +} + +case class MemoryMetrics(metricsManager: MetricsManager, initialMemoryUsage: MemoryUsage, prefix: String) { + val initialMemory = metricsManager.defineGauge(s"$prefix.initial") + val usedMemory = metricsManager.defineGauge(s"$prefix.used") + val committedMemory = metricsManager.defineGauge(s"$prefix.committed") + val maxMemory = metricsManager.defineGauge(s"$prefix.max") + + // those don't change over time and we don't want to report them again and again + initialMemory.set(Math.max(initialMemoryUsage.getInit, 0)) + maxMemory.set(initialMemoryUsage.getMax) + + def record(memoryUsage: MemoryUsage): Unit = { + committedMemory.set(memoryUsage.getCommitted) + usedMemory.set(memoryUsage.getUsed) + } +} + +case class GarbageCollectionMetrics(metricsManager: MetricsManager, gcBean: GarbageCollectorMXBean) { + var lastCount: Long = 0 + var lastTime: Long = 0 + + val countMetric = metricsManager.defineCounter("gc." + gcBean.getName + ".collectionCount") + val timeMetric = metricsManager.defineTimer("gc." + gcBean.getName + ".collectionTime") + + def record(): Unit = { + recordGcCount + recordGcTime + } + + private def recordGcCount(): Unit = { + val newGcCount = gcBean.getCollectionCount + val lastGcCount = lastCount + countMetric.incBy(newGcCount - lastGcCount) + lastCount = newGcCount + } + + private def recordGcTime(): Unit = { + val newGcTime = gcBean.getCollectionTime + val lastGcTime = lastTime + timeMetric.record(newGcTime - lastGcTime) + lastTime = newGcTime + } +} diff --git a/server/libs/jvm-profiler/src/test/scala/cool/graph/MemoryBeanNamesTest.scala b/server/libs/jvm-profiler/src/test/scala/cool/graph/MemoryBeanNamesTest.scala new file mode 100644 index 0000000000..11d8da56a0 --- /dev/null +++ b/server/libs/jvm-profiler/src/test/scala/cool/graph/MemoryBeanNamesTest.scala @@ -0,0 +1,148 @@ +package cool.graph + +import java.lang.management.ManagementFactory + +import org.scalatest.{FlatSpec, Matchers} + +class MemoryBeanNamesTest extends FlatSpec with Matchers { + + /** + * Serial GC: -XX:+UseSerialGC + * Parallel GC: -XX:+UseParallelGC + * -XX:+UseParallelOldGC + * + * Concurrent Mark Sweep: -XX:+UseConcMarkSweepGC + * G1: -XX:+UseG1GC + */ + import scala.collection.JavaConversions._ + + val gcBeans = ManagementFactory.getGarbageCollectorMXBeans + + println(s"There are ${gcBeans.size()} beans") + gcBeans.toVector.foreach { gcBean => + println("-" * 75) + println(s"name: ${gcBean.getName}") + println(s"ObjectName.canonicalName: ${gcBean.getObjectName.getCanonicalName}") + println(s"ObjectName.domain: ${gcBean.getObjectName.getDomain}") + println(s"ObjectName.canonicalKeyPropertyListString: ${gcBean.getObjectName.getCanonicalKeyPropertyListString}") + println(s"ObjectName.getKeyPropertyList: ${mapAsScalaMap(gcBean.getObjectName.getKeyPropertyList)}") + println(s"memory pool names: ${gcBean.getMemoryPoolNames.toVector.mkString(", ")}") + } + + /** + * Results: + * + * NO ARGUMENTS: + * + * There are 2 beans +--------------------------------------------------------------------------- +name: PS Scavenge +ObjectName.canonicalName: java.lang:name=PS Scavenge,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS Scavenge,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS Scavenge, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space +--------------------------------------------------------------------------- +name: PS MarkSweep +ObjectName.canonicalName: java.lang:name=PS MarkSweep,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS MarkSweep,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS MarkSweep, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space, PS Old Gen + + * + * -XX:+UseSerialGC: + * There are 2 beans +--------------------------------------------------------------------------- +name: Copy +ObjectName.canonicalName: java.lang:name=Copy,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=Copy,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> Copy, type -> GarbageCollector) +memory pool names: Eden Space, Survivor Space +--------------------------------------------------------------------------- +name: MarkSweepCompact +ObjectName.canonicalName: java.lang:name=MarkSweepCompact,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=MarkSweepCompact,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> MarkSweepCompact, type -> GarbageCollector) +memory pool names: Eden Space, Survivor Space, Tenured Gen + + * + * -XX:+UseParallelGC: + * There are 2 beans +--------------------------------------------------------------------------- +name: PS Scavenge +ObjectName.canonicalName: java.lang:name=PS Scavenge,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS Scavenge,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS Scavenge, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space +--------------------------------------------------------------------------- +name: PS MarkSweep +ObjectName.canonicalName: java.lang:name=PS MarkSweep,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS MarkSweep,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS MarkSweep, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space, PS Old Gen + + * + * -XX:+UseParallelOldGC: + * There are 2 beans +--------------------------------------------------------------------------- +name: PS Scavenge +ObjectName.canonicalName: java.lang:name=PS Scavenge,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS Scavenge,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS Scavenge, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space +--------------------------------------------------------------------------- +name: PS MarkSweep +ObjectName.canonicalName: java.lang:name=PS MarkSweep,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=PS MarkSweep,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> PS MarkSweep, type -> GarbageCollector) +memory pool names: PS Eden Space, PS Survivor Space, PS Old Gen + + + * -XX:+UseConcMarkSweepGC: + * There are 2 beans +--------------------------------------------------------------------------- +name: ParNew +ObjectName.canonicalName: java.lang:name=ParNew,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=ParNew,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> ParNew, type -> GarbageCollector) +memory pool names: Par Eden Space, Par Survivor Space +--------------------------------------------------------------------------- +name: ConcurrentMarkSweep +ObjectName.canonicalName: java.lang:name=ConcurrentMarkSweep,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=ConcurrentMarkSweep,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> ConcurrentMarkSweep, type -> GarbageCollector) +memory pool names: Par Eden Space, Par Survivor Space, CMS Old Gen + + * -XX:+UseG1GC: + * --------------------------------------------------------------------------- +name: G1 Young Generation +ObjectName.canonicalName: java.lang:name=G1 Young Generation,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=G1 Young Generation,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> G1 Young Generation, type -> GarbageCollector) +memory pool names: G1 Eden Space, G1 Survivor Space +--------------------------------------------------------------------------- +name: G1 Old Generation +ObjectName.canonicalName: java.lang:name=G1 Old Generation,type=GarbageCollector +ObjectName.domain: java.lang +ObjectName.canonicalKeyPropertyListString: name=G1 Old Generation,type=GarbageCollector +ObjectName.getKeyPropertyList: Map(name -> G1 Old Generation, type -> GarbageCollector) +memory pool names: G1 Eden Space, G1 Survivor Space, G1 Old Gen + * + * + * + * + */ + "bla" should "bla" in { + true should be(true) + } +} diff --git a/server/libs/message-bus/build.sbt b/server/libs/message-bus/build.sbt new file mode 100644 index 0000000000..d1b0ba90cb --- /dev/null +++ b/server/libs/message-bus/build.sbt @@ -0,0 +1,11 @@ +organization := "cool.graph" +name := "message-bus" +scalaVersion := "2.11.8" + + +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % "2.4.8" % "provided", + "com.typesafe.akka" %% "akka-testkit" % "2.4.8" % "test", + "org.specs2" %% "specs2-core" % "3.8.8" % "test", + "com.typesafe.akka" %% "akka-cluster-tools" % "2.4.17" +) diff --git a/server/libs/message-bus/project/build.properties b/server/libs/message-bus/project/build.properties new file mode 100644 index 0000000000..c091b86ca4 --- /dev/null +++ b/server/libs/message-bus/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.16 diff --git a/server/libs/message-bus/src/main/resources/application.conf b/server/libs/message-bus/src/main/resources/application.conf new file mode 100644 index 0000000000..26139e5b95 --- /dev/null +++ b/server/libs/message-bus/src/main/resources/application.conf @@ -0,0 +1,31 @@ +akka { + loglevel = INFO + actor.provider = "akka.cluster.ClusterActorRefProvider" + loglevel = WARNING + remote { + log-remote-lifecycle-events = off + netty.tcp { + hostname = "127.0.0.1" + port = 0 + port = ${?AKKA_CLUSTER_PORT} + } + + default-remote-dispatcher { + type = Dispatcher + executor = "fork-join-executor" + fork-join-executor { + parallelism-min = 1 + parallelism-factor = 0.5 + parallelism-max = 1 + } + throughput = 10 + } + } + + test { + single-expect-default = 6s + } + + +} + diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/Conversions.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/Conversions.scala new file mode 100644 index 0000000000..f7b7db2df1 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/Conversions.scala @@ -0,0 +1,36 @@ +package cool.graph.messagebus + +import java.nio.charset.Charset + +import play.api.libs.json._ + +/** + * Common marshallers and unmarshallers + */ +object Conversions { + type Converter[S, T] = S => T + + type ByteMarshaller[T] = Converter[T, Array[Byte]] + type ByteUnmarshaller[T] = Converter[Array[Byte], T] + + object Marshallers { + val FromString: ByteMarshaller[String] = (msg: String) => msg.getBytes("utf-8") + + def FromJsonBackedType[T]()(implicit writes: Writes[T]): ByteMarshaller[T] = msg => { + val jsonString = Json.toJson(msg).toString() + FromString(jsonString) + } + } + + object Unmarshallers { + val ToString: ByteUnmarshaller[String] = (bytes: Array[Byte]) => new String(bytes, Charset.forName("UTF-8")) + + def ToJsonBackedType[T]()(implicit reads: Reads[T]): ByteUnmarshaller[T] = + msg => { + Json.parse(msg).validate[T] match { + case JsSuccess(v, _) => v + case JsError(err) => throw new Exception(s"Invalid json message format: $err") + } + } + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/MessagingInterfaces.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/MessagingInterfaces.scala new file mode 100644 index 0000000000..062d07e234 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/MessagingInterfaces.scala @@ -0,0 +1,72 @@ +package cool.graph.messagebus + +import akka.actor.ActorRef +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.pubsub._ +import cool.graph.messagebus.queue.{BackoffStrategy, MappingQueueConsumer, MappingQueuePublisher} + +import scala.concurrent.Future + +/** + * A PubSub system allows subscribers to subscribe to messages of type T and publishers to publish messages of type T. + * Routing of messages to interested subscribers is done by topics, which route messages to subscribers. + * + * The original topic (routing key) is retained in the Message[T]. + * + * Subscribers can choose to just invoke a callback on message receive, or choose to pass their own actors that allow + * for more complex message processing scenarios. The actor must be able to handle messages of type Message[T]. + */ +trait PubSub[T] extends PubSubPublisher[T] with PubSubSubscriber[T] + +trait PubSubPublisher[T] extends Stoppable { + def publish(topic: Only, msg: T): Unit + def map[U](converter: Converter[U, T]): PubSubPublisher[U] = MappingPubSubPublisher(this, converter) +} + +trait PubSubSubscriber[T] extends Stoppable { + def subscribe(topic: Topic, onReceive: Message[T] => Unit): Subscription + def subscribe(topic: Topic, subscriber: ActorRef): Subscription + def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]): Subscription + + def unsubscribe(subscription: Subscription): Unit + + def map[U](converter: Converter[T, U]): PubSubSubscriber[U] = MappingPubSubSubscriber(this, converter) +} + +/** + * Queue encapsulates the consumer-producer pattern, where publishers publish work items of type T + * and consumers work off items of type T with the given processing function. + * + * In case an error is encountered, a backoff strategy is utilized. The consumer waits _at least_ the specified amount + * of time before retrying, depending on the underlying implementation details. Hence, the backoff is not a precise tool, + * but a useful approximation. As soon as the tries are exceeded, the message is removed from regular message processing. + * + * The implementation can decide if it wants to discard the erroneous message, or store it somewhere for inspection/debugging. + * Hence, this interface does not guarantee retention of erroneous messages. + */ +trait Queue[T] extends QueuePublisher[T] with QueueConsumer[T] + +trait QueuePublisher[T] extends Stoppable { + def publish(msg: T): Unit + def map[U](converter: Converter[U, T]): QueuePublisher[U] = MappingQueuePublisher(this, converter) +} + +trait QueueConsumer[T] extends Stoppable { + val backoff: BackoffStrategy + + def withConsumer(fn: ConsumeFn[T]): ConsumerRef + def map[U](converter: Converter[T, U]): QueueConsumer[U] = MappingQueueConsumer(this, converter) +} + +object QueueConsumer { + type ConsumeFn[T] = T => Future[_] +} + +trait ConsumerRef { + def stop: Unit +} + +sealed trait Stoppable { + def shutdown: Unit = {} +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Actors.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Actors.scala new file mode 100644 index 0000000000..5c74ffeed3 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Actors.scala @@ -0,0 +1,44 @@ +package cool.graph.messagebus.pubsub + +import akka.actor.{Actor, ActorRef, Terminated} +import akka.cluster.pubsub.DistributedPubSubMediator.Unsubscribe +import cool.graph.messagebus.Conversions.Converter + +/** + * Actor receiving all messages that are published to the specified topic. + * Parses the incoming message body from Array[Byte] to T using the provided unmarshaller and forwards the result as Message[T] + * to the targetActor, which can then do the main processing. + * + * Terminates when the targetActor terminates or an unsubscribe is received. However, doesn NOT stop the targetActor on + * unsubscribe. + */ +case class IntermediateForwardActor[T, U](topic: String, mediator: ActorRef, targetActor: ActorRef)(implicit converter: Converter[T, U]) extends Actor { + context.watch(targetActor) + + mediator ! akka.cluster.pubsub.DistributedPubSubMediator.Subscribe(topic, self) + + override def receive: Receive = { + case Message(t, msg) => targetActor ! Message(t, converter(msg.asInstanceOf[T])) + case Terminated(_) => context.stop(self) + case Unsubscribe => context.stop(self) + } +} + +/** + * Actor receiving all messages that are published to the specified topic. + * Parses the message to T using the provided unmarshaller and invokes the given callback with the result as Message[T]. + * + * Terminates when an unsubscribe is received. + */ +case class IntermediateCallbackActor[T, U](topic: String, mediator: ActorRef, callback: Message[U] => Unit)(implicit converter: Converter[T, U]) extends Actor { + mediator ! akka.cluster.pubsub.DistributedPubSubMediator.Subscribe(topic, self) + + override def receive: Receive = { + case Message(t, msg) => + callback(Message(t, converter(msg.asInstanceOf[T]))) + + case Unsubscribe => + mediator ! Unsubscribe(topic, self) + context.stop(self) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/MappingPubSub.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/MappingPubSub.scala new file mode 100644 index 0000000000..e78c9457a2 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/MappingPubSub.scala @@ -0,0 +1,29 @@ +package cool.graph.messagebus.pubsub + +import akka.actor.ActorRef +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.{PubSubPublisher, PubSubSubscriber} + +/** + * PubSubSubscriber decorator that allows subscribers to transparently subscribe to a different type using the given + * converter to map the original subscription type to the newly expected type. + */ +case class MappingPubSubSubscriber[A, B](pubSubSubscriber: PubSubSubscriber[A], converter: Converter[A, B]) extends PubSubSubscriber[B] { + override def subscribe(topic: Topic, onReceive: (Message[B]) => Unit): Subscription = + pubSubSubscriber.subscribe(topic, onReceive = theA => onReceive(theA.map(converter))) + + override def subscribe(topic: Topic, subscriber: ActorRef): Subscription = pubSubSubscriber.subscribe(topic, subscriber, converter) + + override def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[B, U]) = + pubSubSubscriber.subscribe(topic, subscriber, this.converter.andThen(converter)) + + override def unsubscribe(subscription: Subscription): Unit = pubSubSubscriber.unsubscribe(subscription) +} + +/** + * PubSubPublisher decorator that allows publishers to transparently publish a different type using the given + * converter to map the original publish type to the type of the underlying publisher. + */ +case class MappingPubSubPublisher[B, A](queuePublisher: PubSubPublisher[A], converter: Converter[B, A]) extends PubSubPublisher[B] { + override def publish(topic: Only, msg: B): Unit = queuePublisher.publish(topic: Only, converter(msg)) +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Protocol.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Protocol.scala new file mode 100644 index 0000000000..b40ab7c019 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/Protocol.scala @@ -0,0 +1,49 @@ +package cool.graph.messagebus.pubsub + +import akka.actor.ActorRef +import akka.cluster.pubsub.DistributedPubSubMediator.Unsubscribe + +/** + * A topic describes the messages a subscriber is interested in or a publisher wants to publish to. + * + * Only messages matching the topic will be received by a subscribing actor or will invoke a given callback. + */ +sealed trait Topic { + val topic: String +} + +/** + * Topic describing the "everything" topic, where all messages are published in addition to their regular topic. + * E.g. each message read from rabbit with topic "X" is published to all subscribers of "X" and additionally to the + * special topic "everything". + * + * Messages should never be published to "everything" manually. + */ +object Everything extends Topic { + override val topic: String = "everything" +} + +/** + * Topic to subscribe or publish to. + */ +case class Only(topic: String) extends Topic + +/** + * Subscription describes a specific subscription of one actor or callback to one topic. + * + * @param intermediatePubSubActor The intermediate actor used to parse messages and forward messages to an actor or + * invoke callbacks. + */ +case class Subscription(intermediatePubSubActor: ActorRef) { + def unsubscribe: Unit = intermediatePubSubActor ! Unsubscribe +} + +/** + * Represents a received message in pub sub. This is what is send to the subscriber (actor or callback fn). + * + * @param topic The original topic with which the message arrived. (i.e. can't be ) + * @param payload The message payload + */ +case class Message[T](topic: String, payload: T) { + def map[U](fn: T => U): Message[U] = Message(topic, fn(payload)) +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSub.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSub.scala new file mode 100644 index 0000000000..3429a0fee9 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSub.scala @@ -0,0 +1,34 @@ +package cool.graph.messagebus.pubsub.inmemory + +import akka.actor.{ActorRef, ActorSystem, Props} +import akka.cluster.pubsub.DistributedPubSub +import akka.cluster.pubsub.DistributedPubSubMediator.Publish +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.PubSub +import cool.graph.messagebus.pubsub._ + +/** + * PubSub implementation solely backed by actors, no external queueing or pubsub stack is utilized. + * Useful for the single server solution and tests. + */ +case class InMemoryAkkaPubSub[T](implicit val system: ActorSystem) extends PubSub[T] { + lazy val mediator = DistributedPubSub(system).mediator + + def subscribe(topic: Topic, onReceive: Message[T] => Unit): Subscription = + Subscription(system.actorOf(Props(IntermediateCallbackActor[T, T](topic.topic, mediator, onReceive)(identity)))) + + def subscribe(topic: Topic, subscriber: ActorRef): Subscription = + Subscription(system.actorOf(Props(IntermediateForwardActor[T, T](topic.topic, mediator, subscriber)(identity)))) + + def unsubscribe(subscription: Subscription): Unit = subscription.unsubscribe + + def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]): Subscription = + Subscription(system.actorOf(Props(IntermediateForwardActor(topic.topic, mediator, subscriber)(converter)))) + + def publish(topic: Only, msg: T): Unit = { + val message = Message[T](topic.topic, msg) + + mediator ! Publish(topic.topic, message) + mediator ! Publish(Everything.topic, message) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSub.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSub.scala new file mode 100644 index 0000000000..b8572dfd3c --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSub.scala @@ -0,0 +1,99 @@ +package cool.graph.messagebus.pubsub.rabbit + +import akka.actor.{ActorRef, ActorSystem} +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.{ByteMarshaller, ByteUnmarshaller, Converter} +import cool.graph.messagebus._ +import cool.graph.messagebus.pubsub.{Message, Only, Subscription, Topic} +import cool.graph.messagebus.utils.RabbitUtils + +import scala.concurrent.Await + +/** + * Implementation of the PubSub interface that uses rabbit and intermediate actors to handle subscriptions or invoke callbacks. + * Intermediate actors allow message parsing to be deferred to the latest possible time, and ensures that messages are + * only parsed if there is an existing subscriber, at the cost of potentially multiple parses of one message, as there + * is always one intermediate actor per subscription. + * + * @param amqpUri Rabbit to connect to. + * @param exchangeName Underlying exchange name. + * @param durable Controls whether or not the rabbit exchange is durable. This does NOT control message persistence. + * @param concurrency Number of concurrent consumer threads + * @tparam T The type goes over the wire to and from the Rabbit. + */ +case class RabbitAkkaPubSub[T]( + amqpUri: String, + exchangeName: String, + durable: Boolean = false, + concurrency: Int = 1 +)( + implicit val bugSnagger: BugSnagger, + system: ActorSystem, + marshaller: ByteMarshaller[T], + unmarshaller: ByteUnmarshaller[T] +) extends PubSub[T] { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency, durable) + val publisher = RabbitAkkaPubSubPublisher[T](exchange) + val subscriber = RabbitAkkaPubSubSubscriber[T](exchange) + + def subscribe(topic: Topic, onReceive: Message[T] => Unit): Subscription = subscriber.subscribe(topic, onReceive) + def subscribe(topic: Topic, subscriber: ActorRef): Subscription = this.subscriber.subscribe(topic, subscriber) + def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]) = this.subscriber.subscribe[U](topic, subscriber, converter) + + def publish(topic: Only, msg: T): Unit = publisher.publish(topic, msg) + def unsubscribe(subscription: Subscription): Unit = subscription.unsubscribe + + override def shutdown: Unit = { + subscriber.shutdown() + exchange.channel.close() + } +} + +/** + * Collection of convenience standalone initializers and utilities for Rabbit-based pub sub + */ +object RabbitAkkaPubSub { + def publisher[T]( + amqpUri: String, + exchangeName: String, + concurrency: Int = 1, + durable: Boolean = false + )(implicit bugSnagger: BugSnagger, marshaller: ByteMarshaller[T]): RabbitAkkaPubSubPublisher[T] = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency, durable) + + RabbitAkkaPubSubPublisher[T](exchange, onShutdown = () => { + exchange.channel.close().get + }) + } + + def subscriberWithSystem[T]( + amqpUri: String, + exchangeName: String, + concurrency: Int = 1, + durable: Boolean = false + )(implicit bugSnagger: BugSnagger, unmarshaller: ByteUnmarshaller[T]): RabbitAkkaPubSubSubscriber[T] = { + import scala.concurrent.duration._ + + implicit val system = SingleThreadedActorSystem("rabbitPubSubSubscriberStandalone") + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency, durable) + + RabbitAkkaPubSubSubscriber[T](exchange, onShutdown = () => { + exchange.channel.close().get + Await.result(system.terminate(), 5.seconds) + }) + } + + def subscriber[T]( + amqpUri: String, + exchangeName: String, + concurrency: Int = 1, + durable: Boolean = false + )(implicit bugSnagger: BugSnagger, actorSystem: ActorSystem, unmarshaller: ByteUnmarshaller[T]): RabbitAkkaPubSubSubscriber[T] = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency, durable) + + RabbitAkkaPubSubSubscriber[T](exchange, onShutdown = () => { + exchange.channel.close().get + }) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubPublisher.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubPublisher.scala new file mode 100644 index 0000000000..740d3c52e2 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubPublisher.scala @@ -0,0 +1,15 @@ +package cool.graph.messagebus.pubsub.rabbit + +import cool.graph.messagebus.Conversions.ByteMarshaller +import cool.graph.messagebus.pubsub.Only +import cool.graph.messagebus.PubSubPublisher +import cool.graph.rabbit.Import.Exchange + +case class RabbitAkkaPubSubPublisher[T]( + exchange: Exchange, + onShutdown: () => Unit = () => () +)(implicit marshaller: ByteMarshaller[T]) + extends PubSubPublisher[T] { + def publish(topic: Only, msg: T): Unit = exchange.publish(topic.topic, marshaller(msg)) + override def shutdown: Unit = onShutdown() +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSubscriber.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSubscriber.scala new file mode 100644 index 0000000000..2ccea6942c --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSubscriber.scala @@ -0,0 +1,83 @@ +package cool.graph.messagebus.pubsub.rabbit + +import akka.actor.{ActorRef, ActorSystem, Props} +import akka.cluster.pubsub.DistributedPubSub +import akka.cluster.pubsub.DistributedPubSubMediator.Publish +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.{ByteUnmarshaller, Converter} +import cool.graph.messagebus._ +import cool.graph.messagebus.pubsub._ +import cool.graph.messagebus.utils.Utils +import cool.graph.rabbit.Bindings.FanOut +import cool.graph.rabbit.Import.Exchange + +import scala.util.{Failure, Success} + +/** + * A Subscriber subscribes given actors or callbacks to a specific topic. + * The PubSubSubscriber encapsulates the rabbit consumer required for making our pubsub pattern work: + * - The assumption is that one exchange is responsible for one type T of message that gets published. + * + * - Rabbit acts as a fast, but dumb, pipe: There is one queue bound to the exchange from this code that receives all + * messages published to the exchange ("FanOut" == "#" rabbit routing key == receive all). + * + * - One actor, the mediator, takes all messages and publishes those based on their routing key. + * + * - Additionally, the mediator takes all messages and published them to all "all" subscribers. + * + * - No message parsing takes place, as this would result in massive overhead. This is deferred to the intermediate actors. + * + * - Each subscription has one intermediate actor that parses the messages and then invokes callbacks or forwards to other actors. + */ +case class RabbitAkkaPubSubSubscriber[T]( + exchange: Exchange, + onShutdown: () => Unit = () => () +)( + implicit val bugSnagger: BugSnagger, + val system: ActorSystem, + unmarshaller: ByteUnmarshaller[T] +) extends PubSubSubscriber[T] { + lazy val mediator = DistributedPubSub(system).mediator + + val consumer = { + val queueNamePrefix = (exchange.name, Utils.dockerContainerID) match { + case ("", dockerId) => dockerId + case (exchangeName, "") => exchangeName + case (exchangeName, dockerId) => s"$exchangeName-$dockerId" + } + + for { + queue <- exchange.channel.queueDeclare(queueNamePrefix, randomizeName = true, durable = false, autoDelete = true) + _ <- queue.bindTo(exchange, FanOut) + consumer <- queue.consume { delivery => + val topic = delivery.envelope.getRoutingKey + val message = Message[Array[Byte]](topic, delivery.body) + + mediator ! Publish(topic, message) + mediator ! Publish(Everything.topic, message) + + queue.ack(delivery) + } + } yield consumer + } + + def subscribe(topic: Topic, onReceive: Message[T] => Unit): Subscription = + Subscription(system.actorOf(Props(IntermediateCallbackActor[Array[Byte], T](topic.topic, mediator, onReceive)(unmarshaller)))) + + def subscribe(topic: Topic, subscriber: ActorRef): Subscription = + Subscription(system.actorOf(Props(IntermediateForwardActor[Array[Byte], T](topic.topic, mediator, subscriber)(unmarshaller)))) + + def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]): Subscription = + Subscription(system.actorOf(Props(IntermediateForwardActor[Array[Byte], U](topic.topic, mediator, subscriber)(unmarshaller.andThen(converter))))) + + def unsubscribe(subscription: Subscription): Unit = subscription.unsubscribe + + override def shutdown(): Unit = { + consumer match { + case Success(c) => c.unsubscribe + case Failure(_) => + } + + onShutdown() + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/BackoffStrategy.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/BackoffStrategy.scala new file mode 100644 index 0000000000..7c87d89fc0 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/BackoffStrategy.scala @@ -0,0 +1,37 @@ +package cool.graph.messagebus.queue + +import akka.pattern.after +import cool.graph.akkautil.SingleThreadedActorSystem + +import scala.concurrent.Future +import scala.concurrent.duration.FiniteDuration + +object BackoffStrategy { + import scala.concurrent.ExecutionContext.Implicits.global + val system = SingleThreadedActorSystem("backoff") + + def backoffDurationFor(currentTry: Int, strategy: BackoffStrategy): FiniteDuration = { + strategy match { + case ConstantBackoff(d) => d + case LinearBackoff(d) => d * currentTry + } + } + + def backoff(duration: FiniteDuration): Future[Unit] = after(duration, system.scheduler)(Future.successful(Unit)) +} + +sealed trait BackoffStrategy { + val duration: FiniteDuration +} + +/** + * A constant backoff always stays at the specified duration, for each try. + * E.g. a constant backoff of 5 seconds backs off for 5 seconds after the 1st and 2nd try, totalling to 10 seconds over 2 tries. + */ +case class ConstantBackoff(duration: FiniteDuration) extends BackoffStrategy + +/** + * A linear backoff increases the backoff duration linearly over the number of tries. + * E.g. the backoff of 5 seconds is 5 for the first try and 10 for the second, totalling in 15 seconds over 2 tries. + */ +case class LinearBackoff(duration: FiniteDuration) extends BackoffStrategy diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/MappingQueue.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/MappingQueue.scala new file mode 100644 index 0000000000..31fb305442 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/MappingQueue.scala @@ -0,0 +1,25 @@ +package cool.graph.messagebus.queue + +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.{ConsumerRef, Queue, QueueConsumer, QueuePublisher} + +import scala.concurrent.Future + +/** + * QueueConsumer decorator that allows consumers to transparently consume a different type using the given + * converter to map the original consumer type to the newly expected type. + */ +case class MappingQueueConsumer[A, B](queueConsumer: QueueConsumer[A], converter: Converter[A, B]) extends QueueConsumer[B] { + val backoff = queueConsumer.backoff + + override def withConsumer(fn: B => Future[_]): ConsumerRef = queueConsumer.withConsumer(theA => fn(converter(theA))) + override def shutdown(): Unit = queueConsumer.shutdown +} + +/** + * QueuePublisher decorator that allows publishers to transparently publish a different type using the given + * converter to map the original publish type to the type of the underlying publisher. + */ +case class MappingQueuePublisher[B, A](queuePublisher: QueuePublisher[A], converter: Converter[B, A]) extends QueuePublisher[B] { + override def publish(msg: B): Unit = queuePublisher.publish(converter(msg)) +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/Actors.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/Actors.scala new file mode 100644 index 0000000000..687e86c3b1 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/Actors.scala @@ -0,0 +1,65 @@ +package cool.graph.messagebus.queue.inmemory + +import akka.actor.{Actor, ActorRef, Terminated} +import akka.routing.{ActorRefRoutee, RoundRobinRoutingLogic, Router} +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.BackoffStrategy +import cool.graph.messagebus.queue.inmemory.InMemoryQueueingMessages._ + +/** + * Todos + * - Message protocol? ACK / NACK etc.? + * - Queue actor supervising message ack / nack => actor ask with timeout? + * - Catching deadletters and requeueing items? + */ +object InMemoryQueueingMessages { + case class AddWorker(ref: ActorRef) + object StopWork + + case class Delivery[T](payload: T, tries: Int = 0) { + def nextTry: Delivery[T] = copy(tries = tries + 1) + } + + case class DeferredDelivery[T](item: Delivery[T]) +} + +case class RouterActor[T](backoff: BackoffStrategy) extends Actor { + import context.dispatcher + + var router = Router(RoundRobinRoutingLogic(), Vector.empty) + + override def receive = { + case AddWorker(ref: ActorRef) => + context watch ref + router = router.addRoutee(ActorRefRoutee(ref)) + + case item: Delivery[T] => + router.route(item, sender()) + + case deferred: DeferredDelivery[T] => + val dur = BackoffStrategy.backoffDurationFor(deferred.item.tries, backoff) + BackoffStrategy.backoff(dur).map(_ => router.route(deferred.item, sender())) + + case Terminated(a) => + // todo: Restart worker actor if terminated abnormally? + router = router.removeRoutee(a) + } +} + +case class WorkerActor[T](router: ActorRef, fn: ConsumeFn[T]) extends Actor { + import context.dispatcher + + override def receive = { + case i: Delivery[T] => + if (i.tries < 5) { + fn(i.payload).onFailure { + case _ => router ! DeferredDelivery(i.nextTry) + } + } else { + println(s"Discarding message, tries exceeded: $i") + } + + case StopWork => + context.stop(self) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueue.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueue.scala new file mode 100644 index 0000000000..a929a0563f --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueue.scala @@ -0,0 +1,40 @@ +package cool.graph.messagebus.queue.inmemory + +import akka.actor.{ActorRef, ActorSystem, Props} +import akka.stream.ActorMaterializer +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.inmemory.InMemoryQueueingMessages._ +import cool.graph.messagebus.queue.{BackoffStrategy, LinearBackoff} +import cool.graph.messagebus.{ConsumerRef, Queue} + +import scala.concurrent.duration._ + +/** + * Queue implementation solely backed by actors, no external queueing stack is utilized. + * Useful for the single server solution and tests. + * + * This is not yet a production ready implementation as robustness features for redelivery and ensuring + * that an item is worked off are missing. + */ +case class InMemoryAkkaQueue[T](backoff: BackoffStrategy = LinearBackoff(5.seconds))( + implicit val system: ActorSystem, + materializer: ActorMaterializer +) extends Queue[T] { + + val router = system.actorOf(Props(RouterActor[T](backoff))) + + override def publish(msg: T): Unit = router ! Delivery(msg) + + override def shutdown: Unit = system.stop(router) + + override def withConsumer(fn: ConsumeFn[T]): ConsumerRef = { + val worker = system.actorOf(Props(WorkerActor(router, fn))) + + router ! AddWorker(worker) + ConsumerActorRef(worker) + } +} + +case class ConsumerActorRef(ref: ActorRef) extends ConsumerRef { + override def stop: Unit = ref ! StopWork +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/MessageInfo.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/MessageInfo.scala new file mode 100644 index 0000000000..fe59b711f4 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/MessageInfo.scala @@ -0,0 +1,17 @@ +package cool.graph.messagebus.queue.rabbit + +case class MessageInfo(tries: Int, tryNextAt: Option[Long]) { + def isDelayed: Boolean = { + tryNextAt match { + case Some(processingTime) => + val now = System.currentTimeMillis / 1000 + now < processingTime + + case None => + false + } + } + + // Messages start at 0 and have 5 tries. Tries are incremented after each unsuccessful processing. + def exceededTries: Boolean = tries >= RabbitQueueConsumer.MAX_TRIES +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitPlainQueueConsumer.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitPlainQueueConsumer.scala new file mode 100644 index 0000000000..b17bf8ac3b --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitPlainQueueConsumer.scala @@ -0,0 +1,83 @@ +package cool.graph.messagebus.queue.rabbit + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.ByteUnmarshaller +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.BackoffStrategy +import cool.graph.messagebus.{ConsumerRef, QueueConsumer} +import cool.graph.rabbit.Bindings.RoutingKey +import cool.graph.rabbit.Import.Queue +import cool.graph.rabbit.{Consumer, Delivery, Exchange} + +import scala.collection.mutable.ArrayBuffer +import scala.util.{Failure, Success, Try} + +/** + * A plain rabbit queue consumer without magic that processes messages of type T from the specified queue. + * This consumer is the bare minimum and doesn't do anything besides message parsing and passing it to + * the consume function. + * + * It also doesn't bind the queue to any routing key if None is given, effectively assuming that the queue is already + * bound to something if you want to consume a steady flow of messages. + */ +case class RabbitPlainQueueConsumer[T]( + queueName: String, + exchange: Exchange, + backoff: BackoffStrategy, + autoDelete: Boolean = true, + onShutdown: () => Unit = () => {}, + routingKey: Option[String] = None +)(implicit val bugSnagger: BugSnagger, unmarshaller: ByteUnmarshaller[T]) + extends QueueConsumer[T] { + import scala.concurrent.ExecutionContext.Implicits.global + + private val consumers = ArrayBuffer[Consumer]() + + val queue: Queue = (for { + queue <- exchange.channel.queueDeclare(queueName, durable = false, autoDelete = autoDelete) + _ = routingKey match { + case Some(rk) => queue.bindTo(exchange, RoutingKey(rk)) + case _ => + } + } yield queue) match { + case Success(q) => q + case Failure(e) => sys.error(s"Unable to declare queue: $e") + } + + override def withConsumer(fn: ConsumeFn[T]): ConsumerRef = { + val consumer = queue.consume { delivery => + val payload = parsePayload(queue, delivery) + fn(payload).onComplete { + case Success(_) => queue.ack(delivery) + case Failure(err) => queue.nack(delivery, requeue = true); println(err) + } + } match { + case Success(c) => c + case Failure(e) => sys.error(s"Unable to declare consumer: $e") + } + + RabbitConsumerRef(Seq(consumer)) + } + + def parsePayload(queue: Queue, delivery: Delivery): T = { + Try { unmarshaller(delivery.body) } match { + case Success(parsedPayload) => + parsedPayload + + case Failure(err) => + println(s"[Plain Consumer] Discarding message, invalid message body: $err") + queue.ack(delivery) + throw err + } + } + + override def shutdown: Unit = { + println(s"[Plain Consumer] Stopping...") + consumers.foreach { c => + c.unsubscribe.getOrElse(s"[Plain Consumer] Warn: Unable to unbind consumer: $c") + } + println(s"[Plain Consumer] Stopping... Done.") + + onShutdown() + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueue.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueue.scala new file mode 100644 index 0000000000..32ff69c517 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueue.scala @@ -0,0 +1,96 @@ +package cool.graph.messagebus.queue.rabbit + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.{ByteMarshaller, ByteUnmarshaller} +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.{ConsumerRef, Queue} +import cool.graph.messagebus.queue.{BackoffStrategy, LinearBackoff} +import cool.graph.messagebus.utils.RabbitUtils +import cool.graph.rabbit.Consumer +import cool.graph.rabbit.Import.{Exchange, Queue => RMQueue} + +import scala.concurrent.Future +import scala.concurrent.duration._ + +case class RabbitQueue[T]( + amqpUri: String, + exchangeName: String, + backoff: BackoffStrategy, + durableExchange: Boolean = false, + exchangeConcurrency: Int = 1, + workerConcurrency: Int = 1 +)( + implicit bugSnagger: BugSnagger, + marshaller: ByteMarshaller[T], + unmarshaller: ByteUnmarshaller[T] +) extends Queue[T] { + + val exchange: Exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, exchangeConcurrency, durableExchange) + val publisher: RabbitQueuePublisher[T] = RabbitQueuePublisher[T](exchange) + val consumer: RabbitQueueConsumer[T] = RabbitQueueConsumer[T](exchangeName, exchange, backoff, workerConcurrency) + + def publish(msg: T): Unit = publisher.publish(msg) + + override def shutdown: Unit = { + consumer.shutdown + publisher.shutdown + exchange.channel.close() + } + + override def withConsumer(fn: ConsumeFn[T]): RabbitConsumerRef = consumer.withConsumer(fn) +} + +case class RabbitConsumerRef(consumers: Seq[Consumer]) extends ConsumerRef { + override def stop: Unit = consumers.foreach { consumer => + consumer.unsubscribe.getOrElse(println(s"Warn: Unable to unbind consumer $consumer")) + } +} + +case class RabbitQueuesRef(mainQ: RMQueue, errorQ: RMQueue) + +/** + * Collection of convenience standalone initializers for Rabbit-based queueing + */ +object RabbitQueue { + + def publisher[T]( + amqpUri: String, + exchangeName: String, + concurrency: Int = 1, + durable: Boolean = false + )(implicit bugSnagger: BugSnagger, marshaller: ByteMarshaller[T]): RabbitQueuePublisher[T] = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency, durable) + + RabbitQueuePublisher[T](exchange, onShutdown = () => { + Future.fromTry(exchange.channel.close()) + }) + } + + def consumer[T]( + amqpUri: String, + exchangeName: String, + exchangeConcurrency: Int = 1, + workerConcurrency: Int = 1, + durableExchange: Boolean = false, + backoff: BackoffStrategy = LinearBackoff(5.seconds) + )(implicit bugSnagger: BugSnagger, unmarshaller: ByteUnmarshaller[T]): RabbitQueueConsumer[T] = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, exchangeConcurrency, durableExchange) + + RabbitQueueConsumer[T](exchangeName, exchange, backoff, workerConcurrency, onShutdown = () => exchange.channel.close()) + } + + def plainConsumer[T]( + amqpUri: String, + queueName: String, + exchangeName: String, + exchangeConcurrency: Int = 1, + workerConcurrency: Int = 1, + autoDelete: Boolean = true, + durableExchange: Boolean = false, + backoff: BackoffStrategy = LinearBackoff(5.seconds) + )(implicit bugSnagger: BugSnagger, unmarshaller: ByteUnmarshaller[T]): RabbitPlainQueueConsumer[T] = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, exchangeConcurrency, durableExchange) + + RabbitPlainQueueConsumer[T](queueName, exchange, backoff, autoDelete = autoDelete, onShutdown = () => exchange.channel.close()) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueConsumer.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueConsumer.scala new file mode 100644 index 0000000000..eb38327af4 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueConsumer.scala @@ -0,0 +1,160 @@ +package cool.graph.messagebus.queue.rabbit + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.ByteUnmarshaller +import cool.graph.messagebus.QueueConsumer +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.BackoffStrategy +import cool.graph.messagebus.queue.rabbit.RabbitQueueConsumer.ProcessingFailedError +import cool.graph.rabbit.Bindings.RoutingKey +import cool.graph.rabbit.Import.Queue +import cool.graph.rabbit.{Consumer, Delivery, Exchange} + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +object RabbitQueueConsumer { + // Max tries before a message is deemed failed, dropped, and requeued into the error queue + val MAX_TRIES = 5 + + // Max duration to wait in process, anything else will be requeued + val MAX_DURATION = 60.seconds + + case class ProcessingFailedError(reason: String) extends Exception(reason) +} + +/** + * A rabbit queue consumer processes messages of type T from a specified queue using the provided onMsg function. + * + * The consumer will automatically retry messages exactly MAX_TRIES on failure using the backoff: + * - If a message has exceeded the max tries, it will be queued into the error queue for inspection. + * - The given backoff strategy decides on the duration the consumer backs off: + * - If the duration exceeds the MAX_DURATION threshold, it will be requeued instead with a UNIX timestamp appended that roughly designates the next processing. + * - Else, the processor will wait for the duration ("in process") and then start processing. + * + * There is a fixed number of consumers working off the given queue at any time. + * + * The concurrency param controls how many rabbit consumers will be created per registration of a consumer function with 'withConsumer'. + */ +case class RabbitQueueConsumer[T]( + queueName: String, + exchange: Exchange, + backoff: BackoffStrategy, + concurrency: Int, + onShutdown: () => Unit = () => {} +)(implicit val bugSnagger: BugSnagger, unmarshaller: ByteUnmarshaller[T]) + extends QueueConsumer[T] { + + val consumers: ArrayBuffer[Consumer] = ArrayBuffer[Consumer]() + + private val queues = (for { + queue <- exchange.channel.queueDeclare(queueName, durable = false, autoDelete = true) + errQ <- exchange.channel.queueDeclare(s"$queueName-error", durable = false, autoDelete = false) // declare err queue to make sure error msgs are available + _ <- errQ.bindTo(exchange, RoutingKey("error.#")) + _ <- queue.bindTo(exchange, RoutingKey("msg.#")) + } yield RabbitQueuesRef(queue, errQ)) match { + case Success(qs) => qs + case Failure(e) => sys.error(s"Unable to declare queues: $e") + } + + override def withConsumer(fn: ConsumeFn[T]): RabbitConsumerRef = { + val consumer = queues.mainQ.consume(concurrency) { delivery => + consume(delivery, fn) + } match { + case Success(c) => c + case Failure(e) => sys.error(s"Unable to declare consumer: $e") + } + + synchronized { + consumers ++= consumer + } + + RabbitConsumerRef(consumer) + } + + private def consume(delivery: Delivery, fn: ConsumeFn[T]): Unit = { + val regularQ = queues.mainQ + val info = parseRoutingKey(regularQ, delivery) + val payload = parsePayload(regularQ, delivery) + + if (info.exceededTries) { + val now = System.currentTimeMillis() / 1000 + exchange.publish(s"error.${info.tries}.$now", delivery.body) + regularQ.ack(delivery) + } else if (info.isDelayed) { + regularQ.nack(delivery, requeue = true) + } else { + process(regularQ, delivery, fn, info, payload) + } + } + + def parsePayload(queue: Queue, delivery: Delivery): T = { + Try { unmarshaller(delivery.body) } match { + case Success(parsedPayload) => + parsedPayload + + case Failure(err) => + println(s"[Queue] Discarding message, invalid message body: $err") + queue.ack(delivery) + throw err + } + } + + def parseRoutingKey(queue: Queue, delivery: Delivery): MessageInfo = { + val rk = delivery.envelope.getRoutingKey + val components = rk.split("\\.") + + if (components.head != "msg" || components.length < 1 || components.length > 3) { + println(s"[Queue] Discarding message, invalid routing key: $rk") + queue.ack(delivery) + throw new Exception(s"Invalid routing key: $rk") + } + + val timestamp = if (components.length == 3) { + Some(components.last.toLong) + } else { + None + } + + val tries = components(1) + MessageInfo(tries.toInt, timestamp) + } + + private def process(queue: Queue, delivery: Delivery, fn: T => Future[_], info: MessageInfo, payload: T): Unit = { + val backoffDuration = BackoffStrategy.backoffDurationFor(info.tries, backoff) + + if (backoffDuration >= 60.seconds) { + val now = System.currentTimeMillis() / 1000 + val processAt = now + backoffDuration.toSeconds + + queue.ack(delivery) + exchange.publish(s"msg.${info.tries}.$processAt", delivery.body) + } else { + val processingResult = BackoffStrategy.backoff(backoffDuration).flatMap(_ => fn(payload)) + + processingResult.onComplete({ + case Success(_) => + queue.ack(delivery) + + case Failure(err) => + queue.ack(delivery) + exchange.publish(s"msg.${info.tries + 1}", delivery.body) + bugSnagger.report(ProcessingFailedError(s"Processing in queue '${queue.name}' (payload '$payload') failed with error $err")) + println(err) + }) + } + } + + override def shutdown: Unit = { + println(s"[Queue] Stopping consumers for $queueName...") + consumers.foreach { c => + c.unsubscribe.getOrElse(println(s"Warn: Unable to unbind consumer: $c")) + } + + println(s"[Queue] Stopping consumers for $queueName... Done.") + onShutdown() + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueuePublisher.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueuePublisher.scala new file mode 100644 index 0000000000..da25b65252 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/queue/rabbit/RabbitQueuePublisher.scala @@ -0,0 +1,26 @@ +package cool.graph.messagebus.queue.rabbit + +import cool.graph.messagebus.Conversions.ByteMarshaller +import cool.graph.messagebus.QueuePublisher +import cool.graph.rabbit.Exchange + +import scala.concurrent.Future + +/** + * Publishing messages follows a specific pattern: "[prefix].[tries].[next processing timestamp, optional]" + * The [prefix] decides where the message will be routed. There are 2 queues: + * - The main workoff queue, prefixed with "msg" + * - The error queue, where messages will be routed that have the "error" prefix. The consumer implementation will + * route messages that failed fatally or exceeded the max retries to the error queue. + * + * The [tries] designates how many retries a message already had, starting at 0. + * + * The [next processing timestamp] is used when a message has to back off for a certain amount of time. If the consumer + * implementation sees this timestamp, it will just requeue the message if the timestamp is in the future. + */ +case class RabbitQueuePublisher[T](exchange: Exchange, onShutdown: () => Unit = () => {})(implicit val marshaller: ByteMarshaller[T]) + extends QueuePublisher[T] { + + def publish(msg: T): Unit = exchange.publish(s"msg.0", marshaller(msg)) + override def shutdown: Unit = onShutdown() +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/DummyPubSubSubscriber.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/DummyPubSubSubscriber.scala new file mode 100644 index 0000000000..8b1c59bf75 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/DummyPubSubSubscriber.scala @@ -0,0 +1,29 @@ +package cool.graph.messagebus.testkits + +import akka.actor.{ActorRef, ActorSystem} +import akka.testkit.TestProbe +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.{PubSubPublisher, PubSubSubscriber} +import cool.graph.messagebus.pubsub.{Message, Only, Subscription, Topic} + +object DummyPubSubSubscriber { + // Initializes a minimal actor system to use + def standalone[T]: DummyPubSubSubscriber[T] = { + implicit val system = SingleThreadedActorSystem("DummyPubSubSubscriber") + DummyPubSubSubscriber[T]() + } +} + +case class DummyPubSubSubscriber[T]()(implicit system: ActorSystem) extends PubSubSubscriber[T] { + val testProbe = TestProbe() + + override def subscribe(topic: Topic, onReceive: Message[T] => Unit): Subscription = Subscription(testProbe.ref) + override def subscribe(topic: Topic, subscriber: ActorRef): Subscription = Subscription(testProbe.ref) + override def unsubscribe(subscription: Subscription): Unit = {} + override def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]) = Subscription(testProbe.ref) +} + +case class DummyPubSubPublisher[T]() extends PubSubPublisher[T] { + override def publish(topic: Only, msg: T): Unit = {} +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryPubSubTestKit.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryPubSubTestKit.scala new file mode 100644 index 0000000000..f756ff40a1 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryPubSubTestKit.scala @@ -0,0 +1,134 @@ +package cool.graph.messagebus.testkits + +import akka.actor.{ActorRef, ActorSystem} +import akka.stream.ActorMaterializer +import akka.testkit.TestProbe +import cool.graph.messagebus.Conversions.Converter +import cool.graph.messagebus.PubSub +import cool.graph.messagebus.pubsub.inmemory.InMemoryAkkaPubSub +import cool.graph.messagebus.pubsub.{Message, Only, Subscription, Topic} + +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.language.existentials +import scala.reflect.ClassTag + +/** + * InMemory testkit for simple test cases that requires reasoning over published or received messages. + * Intercepts all messages transparently. + */ +case class InMemoryPubSubTestKit[T]()( + implicit tag: ClassTag[T], + messageTag: ClassTag[Message[T]], + system: ActorSystem, + materializer: ActorMaterializer +) extends PubSub[T] { + + val probe = TestProbe() // Received messages + val publishProbe = TestProbe() // Published messages + val logId = new java.util.Random().nextInt() // For log output correlation + var messagesReceived = Vector.empty[Message[T]] + var messagesPublished = Vector.empty[Message[T]] + val _underlying = InMemoryAkkaPubSub[T]() + + /** + * For expecting a specific message in the queue with given value in the main queue. + */ + def expectMsg(msg: Message[T], maxWait: FiniteDuration = 6.seconds): Message[T] = probe.expectMsg(maxWait, msg) + + def expectPublishedMessage(msg: Message[T], maxWait: FiniteDuration = 6.seconds): Unit = publishProbe.expectMsg(maxWait, msg) + + /** + * For expecting no message in the given timeframe. + */ + def expectNoMsg(maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectNoMsg(maxWait) + } + + def expectNoPublishedMessage(maxWait: FiniteDuration = 6.seconds): Unit = { + publishProbe.expectNoMsg(maxWait) + } + + /** + * Expects a number of messages to arrive. + * Matches the total count received in the time frame, so too many messages is also a failure. + */ + def expectMsgCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectMsgAllClassOf(maxWait, Array.fill(count)(messageTag.runtimeClass): _*) + probe.expectNoMsg(maxWait) + } + + def expectPublishCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + publishProbe.expectMsgAllClassOf(maxWait, Array.fill(count)(messageTag.runtimeClass): _*) + publishProbe.expectNoMsg(maxWait) + } + + def fishForMessage(msg: Message[T], maxWait: FiniteDuration = 6.seconds) = + probe.fishForMessage(maxWait) { + case expected: Message[T] if expected == msg => true + case _ => false + } + + def fishForPublishedMessage(msg: Message[T], maxWait: FiniteDuration = 6.seconds) = + publishProbe.fishForMessage(maxWait) { + case expected: Message[T] if expected == msg => true + case _ => false + } + + /** + * Regular publish of messages. Publishes to the main queue. + */ + def publish(topic: Only, msg: T): Unit = { + val wrapped = Message(topic.topic, msg) + + synchronized { + messagesPublished = messagesPublished :+ wrapped + } + + publishProbe.ref ! wrapped + _underlying.publish(topic, msg) + } + + override def shutdown(): Unit = { + messagesReceived = Vector.empty[Message[T]] + messagesPublished = Vector.empty[Message[T]] + + _underlying.shutdown + + Await.result(system.terminate(), 10.seconds) + } + + override def subscribe(topic: Topic, onReceive: (Message[T]) => Unit): Subscription = { + _underlying.subscribe( + topic, { msg: Message[T] => + println(s"[TestKit][$logId] Received $msg") + + messagesReceived.synchronized { + messagesReceived = messagesReceived :+ msg + } + + probe.ref ! msg + onReceive(msg) + } + ) + } + + override def subscribe(topic: Topic, subscriber: ActorRef): Subscription = { + _underlying.subscribe( + topic, { msg: Message[T] => + println(s"[TestKit][$logId] Received $msg") + + messagesReceived.synchronized { + messagesReceived = messagesReceived :+ msg + } + + probe.ref ! msg + subscriber ! msg + } + ) + } + + override def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]): Subscription = ??? + + override def unsubscribe(subscription: Subscription): Unit = subscription.unsubscribe +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryQueueTestKit.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryQueueTestKit.scala new file mode 100644 index 0000000000..5257131a96 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/InMemoryQueueTestKit.scala @@ -0,0 +1,121 @@ +package cool.graph.messagebus.testkits + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import akka.testkit.TestProbe +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.inmemory.InMemoryAkkaQueue +import cool.graph.messagebus.queue.{BackoffStrategy, ConstantBackoff} +import cool.graph.messagebus.{ConsumerRef, Queue} + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} +import scala.language.existentials +import scala.reflect.ClassTag + +/** + * InMemory testkit for simple test cases that requires reasoning over published or received messages. + */ +case class InMemoryQueueTestKit[T](backoff: BackoffStrategy = ConstantBackoff(1.second))( + implicit tag: ClassTag[T], + system: ActorSystem, + materializer: ActorMaterializer +) extends Queue[T] { + import system.dispatcher + + val probe = TestProbe() // Receives messages + val publishProbe = TestProbe() // Receives published messages + val logId = new java.util.Random().nextInt() // For log output correlation + var messagesReceived = Vector.empty[T] + var messagesPublished = Vector.empty[T] + val _underlying = InMemoryAkkaQueue[T]() + + def withTestConsumer(): Unit = { + _underlying + .withConsumer { msg: T => + Future { + println(s"[TestKit][$logId] Received $msg") + + probe.ref ! msg + + messagesReceived.synchronized { + messagesReceived = messagesReceived :+ msg + } + } + } + } + + override def withConsumer(fn: ConsumeFn[T]): ConsumerRef = { + _underlying.withConsumer { msg: T => + probe.ref ! msg + + messagesReceived.synchronized { + messagesReceived = messagesReceived :+ msg + } + + fn(msg) + } + } + + /** + * For expecting a specific message in the queue with given value in the main queue. + */ + def expectMsg(msg: T, maxWait: FiniteDuration = 6.seconds): T = probe.expectMsg(maxWait, msg) + + def expectPublishedMessage(msg: T, maxWait: FiniteDuration = 6.seconds): Unit = publishProbe.expectMsg(maxWait, msg) + + /** + * For expecting no message in the given timeframe. + */ + def expectNoMsg(maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectNoMsg(maxWait) + } + + def expectNoPublishedMessage(maxWait: FiniteDuration = 6.seconds): Unit = { + publishProbe.expectNoMsg(maxWait) + } + + /** + * Expects a number of messages to arrive. + * Matches the total count received in the time frame, so too many messages is also a failure. + */ + def expectMsgCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectMsgAllClassOf(maxWait, Array.fill(count)(tag.runtimeClass): _*) + probe.expectNoMsg(maxWait) + } + + def expectPublishCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + publishProbe.expectMsgAllClassOf(maxWait, Array.fill(count)(tag.runtimeClass): _*) + publishProbe.expectNoMsg(maxWait) + } + + def fishForMessage(msg: T, maxWait: FiniteDuration = 6.seconds) = + probe.fishForMessage(maxWait) { + case expected: T if expected == msg => true + case _ => false + } + + def fishForPublishedMessage(msg: T, maxWait: FiniteDuration = 6.seconds) = + publishProbe.fishForMessage(maxWait) { + case expected: T if expected == msg => true + case _ => false + } + + /** + * Regular publish of messages. Publishes to the main queue. + */ + def publish(msg: T): Unit = { + synchronized { messagesPublished = messagesPublished :+ msg } + publishProbe.ref ! msg + _underlying.publish(msg) + } + + override def shutdown(): Unit = { + messagesReceived = Vector.empty[T] + messagesPublished = Vector.empty[T] + + _underlying.shutdown + + Await.result(system.terminate(), 10.seconds) + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitAkkaPubSubTestKit.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitAkkaPubSubTestKit.scala new file mode 100644 index 0000000000..0861387c35 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitAkkaPubSubTestKit.scala @@ -0,0 +1,144 @@ +package cool.graph.messagebus.testkits + +import akka.actor.ActorRef +import akka.testkit.TestProbe +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.{ByteMarshaller, ByteUnmarshaller, Converter} +import cool.graph.messagebus.PubSub +import cool.graph.messagebus.pubsub.{Message, Only, Subscription, Topic} +import cool.graph.messagebus.utils.RabbitUtils +import cool.graph.rabbit +import cool.graph.rabbit.Bindings.RoutingKey +import cool.graph.rabbit.Consumer + +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.language.existentials +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +/** + * Rabbit test kit for testing code that uses rabbit pub sub. + * This class is not intended to mirror the behaviour of the actual pub sub implementation. + * It publishes and collects messages and allows reasoning over what messages should be received or not received. + * + * The API is similar to akka testkit, which it uses internally. + * + * >>> PLEASE NOTE: <<< + * If queues are randomized (default false), it won't ack messages off off queues (doesn't interfere with regular + * processing, meaning it only 'observes' messages on the queue). Use randomization for fanout scenarios. + * + * However, a testkit doesn't start ack'ing off messages unless 'start' is called. + */ +case class RabbitAkkaPubSubTestKit[T]( + amqpUri: String, + exchangeName: String, + randomizeQueues: Boolean = false, + exchangeDurable: Boolean = false +)( + implicit tag: ClassTag[Message[T]], + marshaller: ByteMarshaller[T], + unmarshaller: ByteUnmarshaller[T] +) extends PubSub[T] { + + implicit val system = SingleThreadedActorSystem("rabbitPubSubTestKit") + implicit val bugSnagger: BugSnagger = null + + val probe = TestProbe() + val logId = new java.util.Random().nextInt() // For log output correlation + var messages: Vector[Message[T]] = Vector.empty + var queueDef: rabbit.Queue = _ + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, 1, durable = exchangeDurable) + + lazy val consumer: Try[Consumer] = for { + queue <- exchange.channel.queueDeclare(exchangeName, randomizeName = randomizeQueues, durable = false, autoDelete = true) + _ <- queue.bindTo(exchange, RoutingKey("#")) + qConsumer <- queue.consume { delivery => + val msg = Message(delivery.envelope.getRoutingKey, unmarshaller(delivery.body)) + println(s"[PubSub-TestKit][$logId] Received $msg") + + probe.ref ! msg + messages.synchronized { messages = messages :+ msg } + + queue.ack(delivery) + } + } yield { + queueDef = queue + qConsumer + } + + /** + * For expecting a specific message in the queue with given value in the queue. + */ + def expectMsg(msg: Message[T], maxWait: FiniteDuration = 6.seconds): Message[T] = probe.expectMsg[Message[T]](maxWait, msg) + + /** + * For expecting no message in the given timeframe. + */ + def expectNoMsg(maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectNoMsg(maxWait) + } + + /** + * Expects a number of messages to arrive in the main queue. + * Matches the total count received in the time frame, so too many messages is also a failure. + */ + def expectMsgCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectMsgAllClassOf(maxWait, Array.fill(count)(tag.runtimeClass): _*) + probe.expectNoMsg(maxWait) + } + + /** + * Publishes the given message using the supplied routing key. + */ + def publish(routingKey: String, message: String): Unit = exchange.publish(routingKey, message) + + /** + * Start the test kit consumer. Waits a bit before returning as a safety buffer to prevent subsequent test publish calls + * to be too fast for the consumer to bind and consume in time. + */ + def start = { + val bootFuture = Future.fromTry(consumer) + + Thread.sleep(500) + bootFuture + } + + /** + * Stops the test kit. Unbinds all consumers, purges all queues. DOES NOT DELETE ANYTHING on the Rabbit. + * Why? If code that is tested has, for example, a queue in a singleton object, then deleting the exchange + * will gracefully shut down the rabbit channel, which then won't be recovered by the rabbit lib and subsequently + * cause weirdness in the code and tests. Purging and not deleting has the downside that the will be exchanges and + * queues dangling around, but as we use containers for testing this is not an issue. + * + * There are ways of dealing with that in code of course, but at the cost of readability and complexity. + * If you need to delete queues explicitly use the appropriate methods. + */ + def stop = { + purgeQueue(queueDef.name) + + Try { consumer.get.unsubscribe.get } match { + case Failure(err) => println(err) + case _ => + } + + Try { exchange.rabbitChannel.close() } + + system.terminate() + } + + def purgeQueue(queue: String) = Try { exchange.rabbitChannel.queuePurge(queue) } match { + case Success(_) => println(s"[PubSub-TestKit] Purged queue $queue") + case Failure(err) => println(s"[PubSub-TestKit] Failed to purge queue $queue: $err") + } + + def deleteQueue(name: String) = exchange.rabbitChannel.queueDelete(name) + + override def publish(topic: Only, msg: T): Unit = exchange.publish(topic.topic, marshaller(msg)) + + override def subscribe(topic: Topic, onReceive: (Message[T]) => Unit): Subscription = ??? + override def subscribe(topic: Topic, subscriber: ActorRef): Subscription = ??? + override def unsubscribe(subscription: Subscription): Unit = ??? + override def subscribe[U](topic: Topic, subscriber: ActorRef, converter: Converter[T, U]) = ??? +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitQueueTestKit.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitQueueTestKit.scala new file mode 100644 index 0000000000..fde3329712 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/testkits/RabbitQueueTestKit.scala @@ -0,0 +1,192 @@ +package cool.graph.messagebus.testkits + +import akka.testkit.TestProbe +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions.{ByteMarshaller, ByteUnmarshaller} +import cool.graph.messagebus.Queue +import cool.graph.messagebus.QueueConsumer.ConsumeFn +import cool.graph.messagebus.queue.rabbit.{RabbitConsumerRef, RabbitQueuesRef} +import cool.graph.messagebus.queue.{BackoffStrategy, ConstantBackoff} +import cool.graph.messagebus.utils.RabbitUtils +import cool.graph.rabbit.Bindings.RoutingKey +import cool.graph.rabbit.Consumer + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.language.existentials +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + +/** + * Rabbit test kit for testing code that uses rabbit queueing. + * This class is not intended to mirror the behaviour of the actual rabbit queue implementation. + * It publishes and collects messages and allows reasoning over what messages should be received or not received. + * + * The API is similar to akka testkit, which it uses internally. + * + * >>> PLEASE NOTE: <<< + * If queues are randomized (default false), it won't ack messages off off worker queues (doesn't interfere with regular + * processing, meaning it only 'observes' messages on the error and main queue). Use randomization for fanout scenarios. + * + * However, a testkit doesn't work off messages unless 'start' is called. + */ +case class RabbitQueueTestKit[T]( + amqpUri: String, + exchangeName: String, + randomizeQueues: Boolean = false, + backoff: BackoffStrategy = ConstantBackoff(1.second), + exchangeDurable: Boolean = false +)( + implicit tag: ClassTag[T], + marshaller: ByteMarshaller[T], + unmarshaller: ByteUnmarshaller[T] +) extends Queue[T] { + + implicit val system = SingleThreadedActorSystem("rabbitTestKit") + implicit val bugSnagger: BugSnagger = null + + val probe = TestProbe() + val errorProbe = TestProbe() + val logId = new java.util.Random().nextInt() // For log output correlation + var messages: Vector[T] = Vector.empty + var errorMessages: Vector[T] = Vector.empty + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, 1, durable = exchangeDurable) + + val queues: RabbitQueuesRef = (for { + queue <- exchange.channel.queueDeclare(exchangeName, randomizeName = randomizeQueues, durable = false, autoDelete = true) + errQ <- exchange.channel.queueDeclare(s"$exchangeName-error", randomizeName = randomizeQueues, durable = false, autoDelete = false) + _ <- errQ.bindTo(exchange, RoutingKey("error.#")) + _ <- queue.bindTo(exchange, RoutingKey("msg.#")) + } yield RabbitQueuesRef(queue, errQ)) match { + case Success(qs) => qs + case Failure(e) => sys.error(s"Unable to declare queues: $e") + } + + val consumers: ArrayBuffer[Consumer] = ArrayBuffer[Consumer]() + + def withTestConsumers(): Unit = { + queues.mainQ + .consume { delivery => + val msg = unmarshaller(delivery.body) + + println(s"[TestKit][$logId] Received $msg") + probe.ref ! msg + messages.synchronized { messages = messages :+ msg } + queues.mainQ.ack(delivery) + } + .getOrElse(sys.error("Can't declare main queue for test kit")) + + queues.errorQ + .consume { delivery => + val msg = unmarshaller(delivery.body) + + println(s"[TestKit][$logId] Received errorMsg $msg") + errorProbe.ref ! msg + errorMessages.synchronized { errorMessages = errorMessages :+ msg } + queues.errorQ.ack(delivery) + } + .getOrElse(sys.error("Can't declare error queue for test kit")) + } + + override def withConsumer(fn: ConsumeFn[T]): RabbitConsumerRef = { + val consumer = queues.mainQ.consume { delivery => + fn(unmarshaller(delivery.body)) + } match { + case Success(c) => c + case Failure(e) => sys.error(s"Unable to declare consumer: $e") + } + + synchronized { + consumers += consumer + } + + RabbitConsumerRef(Seq(consumer)) + } + + /** + * For expecting a specific message in the queue with given value in the main queue. + */ + def expectMsg(msg: T, maxWait: FiniteDuration = 6.seconds): T = probe.expectMsg(maxWait, msg) + + /** + * For expecting no message in the given timeframe. + */ + def expectNoMsg(maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectNoMsg(maxWait) + } + + /** + * Expects a number of messages to arrive in the main queue. + * Matches the total count received in the time frame, so too many messages is also a failure. + */ + def expectMsgCount(count: Int, maxWait: FiniteDuration = 6.seconds): Unit = { + probe.expectMsgAllClassOf(maxWait, Array.fill(count)(tag.runtimeClass): _*) + probe.expectNoMsg(maxWait) + } + + /** + * For expecting a single error message with given value in the error queue. + */ + def expectErrorMsg(msg: T, maxWait: FiniteDuration = 6.seconds): T = errorProbe.expectMsg(maxWait, msg) + + /** + * For expecting no error message in the given timeframe. + */ + def expectNoErrorMsg(maxWait: FiniteDuration = 6.seconds): Unit = errorProbe.expectNoMsg(maxWait) + + /** + * Expects a number of error messages to arrive in the error queue. + */ + def expectErrorMsgCount[U: ClassTag](count: Int, maxWait: FiniteDuration = 6.seconds) = { + errorProbe.expectMsgAllClassOf(maxWait, Array.fill(count)(tag.runtimeClass): _*) + errorProbe.expectNoMsg(maxWait) + } + + /** + * Allows publishing of messages directly into the queues without any marshalling or routing key magic. + * Useful for testing malformed messages or routing keys + */ + def publishPlain(routingKey: String, message: String): Unit = exchange.publish(routingKey, message) + + /** + * Regular publish of messages. Publishes to the main queue. + */ + def publish(msg: T): Unit = exchange.publish("msg.0", marshaller(msg)) + + /** + * Regular publish of error messages. Publishes to the error queue. + */ + def publishError(msg: T): Unit = exchange.publish("error.5", marshaller(msg)) + + /** + * Stops the test kit. Unbinds all consumers, purges all queues. DOES NOT DELETE ANYTHING on the Rabbit. + * Why? If code that is tested has, for example, a queue in a singleton object, then deleting the exchange + * will gracefully shut down the rabbit channel, which then won't be recovered by the rabbit lib and subsequently + * cause weirdness in the code and tests. Purging and not deleting has the downside that the will be exchanges and + * queues dangling around, but as we use containers for testing this is not an issue. + * + * There are ways of dealing with that in code of course, but at the cost of readability and complexity. + * If you need to delete queues explicitly use the appropriate methods. + */ + override def shutdown(): Unit = { + purgeQueue(queues.mainQ.name) + purgeQueue(queues.errorQ.name) + + consumers.foreach { c => + c.unsubscribe.getOrElse(println(s"Warn: Unable to unbind consumer: $c")) + } + + Try { exchange.rabbitChannel.close() } + + Await.result(system.terminate(), 10.seconds) + } + + def purgeQueue(name: String) = Try { exchange.rabbitChannel.queuePurge(name) } match { + case Success(_) => println(s"[TestKit] Purged queue $name") + case Failure(err) => println(s"[TestKit] Failed to purge queue $name: $err") + } + + def deleteQueue(name: String) = exchange.rabbitChannel.queueDelete(name) +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/RabbitUtils.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/RabbitUtils.scala new file mode 100644 index 0000000000..623a778219 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/RabbitUtils.scala @@ -0,0 +1,21 @@ +package cool.graph.messagebus.utils + +import cool.graph.bugsnag.BugSnagger +import cool.graph.rabbit.Import.{Exchange, Rabbit} + +import scala.util.{Failure, Success} + +object RabbitUtils { + def declareExchange(amqpUri: String, exchangeName: String, concurrency: Int, durable: Boolean)(implicit bugSnagger: BugSnagger): Exchange = { + val exchangeTry = for { + channel <- Rabbit.channel(exchangeName, amqpUri, consumerThreads = concurrency) + exDecl <- channel.exchangeDeclare(s"$exchangeName-exchange", durable = durable) + } yield exDecl + + exchangeTry match { + case Success(ex) => ex + case Failure(err) => + throw new Exception(s"Unable to declare rabbit exchange: $err") + } + } +} diff --git a/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/Utils.scala b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/Utils.scala new file mode 100644 index 0000000000..218c22d933 --- /dev/null +++ b/server/libs/message-bus/src/main/scala/cool/graph/messagebus/utils/Utils.scala @@ -0,0 +1,19 @@ +package cool.graph.messagebus.utils + +import scala.io.Source +import scala.util.{Failure, Success, Try} + +object Utils { + + val dockerContainerID: String = { + Try { + val source = Source.fromFile("/etc/hostname") + val hostname = try { source.mkString.trim } finally source.close() + + hostname + } match { + case Success(hostname) => hostname + case Failure(err) => println("Warning: Unable to read hostname from /etc/hostname"); "" + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSubSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSubSpec.scala new file mode 100644 index 0000000000..4f50a0751b --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/inmemory/InMemoryAkkaPubSubSpec.scala @@ -0,0 +1,169 @@ +package cool.graph.messagebus.pubsub.inmemory + +import akka.testkit.{TestKit, TestProbe} +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.messagebus.{PubSub, PubSubPublisher} +import cool.graph.messagebus.pubsub.{Everything, Message, Only} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} + +class InMemoryAkkaPubSubSpec + extends TestKit(SingleThreadedActorSystem("pubsub-spec")) + with WordSpecLike + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach { + override def afterAll = shutdown(verifySystemShutdown = true) + + val testTopic = Only("testTopic") + val testMsg = "testMsg" + + def withInMemoryAkkaPubSub(checkFn: (PubSub[String], TestProbe) => Unit): Unit = { + val testProbe = TestProbe() + val pubSub = InMemoryAkkaPubSub[String]() + + pubSub.mediator + Thread.sleep(2000) + + checkFn(pubSub, testProbe) + } + + "PubSub" should { + + /** + * Callback tests + */ + "call the specified callback if a message for the subscription arrives" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubsub.subscribe(testTopic, testCallback) + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + probe.expectMsg(Message[String](testTopic.topic, testMsg)) + } + } + + "not call the specified callback if the message doesn't match" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubsub.subscribe(Only("NOPE"), testCallback) + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "not call the specified callback if the subscriber unsubscribed" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubsub.subscribe(testTopic, testCallback) + + Thread.sleep(500) + + pubsub.unsubscribe(subscription) + pubsub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "send messages from different topics to the given callback when subscribed to everything" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubsub.subscribe(Everything, testCallback) + val testMsg2 = "testMsg2" + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + pubsub.publish(Only("testTopic2"), testMsg2) + probe.expectMsgAllOf(Message[String](testTopic.topic, testMsg), Message[String]("testTopic2", testMsg2)) + } + } + + /** + * Actor tests + */ + "send the unmarshalled message to the given actor" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val subscription = pubsub.subscribe(testTopic, probe.ref) + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + probe.expectMsg(Message[String](testTopic.topic, testMsg)) + } + } + + "not send the message to the given actor if the message doesn't match" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val subscription = pubsub.subscribe(Only("NOPE"), probe.ref) + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "not send the message to the given actor if the subscriber unsubscribed" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val subscription = pubsub.subscribe(testTopic, probe.ref) + + Thread.sleep(500) + + pubsub.unsubscribe(subscription) + pubsub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "send the unmarshalled messages from different topics to the given actor when subscribed to everything" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val subscription = pubsub.subscribe(Everything, probe.ref) + val testMsg2 = "testMsg2" + + Thread.sleep(500) + + pubsub.publish(testTopic, testMsg) + pubsub.publish(Only("testTopic2"), testMsg2) + probe.expectMsgAllOf(Message[String](testTopic.topic, testMsg), Message[String]("testTopic2", testMsg2)) + } + } + + "remap the message to a different type if used with a mapping pubsub subscriber" in { + withInMemoryAkkaPubSub { (pubsub, probe) => + val msg = "1234" + val converter = (s: String) => s.toInt + val newPubSub = pubsub.map[Int](converter) + val newProbe = TestProbe() + + pubsub.subscribe(testTopic, probe.ref) + newPubSub.subscribe(testTopic, newProbe.ref) + + Thread.sleep(500) + + pubsub.publish(testTopic, msg) + + probe.expectMsg(Message[String](testTopic.topic, msg)) + newProbe.expectMsg(Message[Int](testTopic.topic, 1234)) + } + } + + "remap the message to a different type if used with a mapping pubsub publisher" in { + withInMemoryAkkaPubSub { (pubsub: PubSub[String], probe) => + val msg = 1234 + val converter = (int: Int) => int.toString + val newPubSub: PubSubPublisher[Int] = pubsub.map[Int](converter) + val newProbe = TestProbe() + + pubsub.subscribe(testTopic, probe.ref) + Thread.sleep(500) + newPubSub.publish(testTopic, msg) + probe.expectMsg(Message[String](testTopic.topic, "1234")) + } + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSpec.scala new file mode 100644 index 0000000000..616c32c754 --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubSpec.scala @@ -0,0 +1,173 @@ +package cool.graph.messagebus.pubsub.rabbit + +import akka.testkit.{TestKit, TestProbe} +import cool.graph.akkautil.SingleThreadedActorSystem +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions +import cool.graph.messagebus.pubsub.{Everything, Message, Only} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} + +class RabbitAkkaPubSubSpec + extends TestKit(SingleThreadedActorSystem("pubsub-spec")) + with WordSpecLike + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach { + override def afterAll = shutdown(verifySystemShutdown = true) + + val amqpUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI required for testing")) + implicit val bugSnagger: BugSnagger = null + + val testTopic = Only("testTopic") + val testMsg = "testMsg" + + implicit val testMarshaller = Conversions.Marshallers.FromString + implicit val testUnmarshaller = Conversions.Unmarshallers.ToString + + def doTest(testFn: (RabbitAkkaPubSub[String], TestProbe) => _) = { + val probe = TestProbe() + val pubSub = RabbitAkkaPubSub[String](amqpUri, "testExchange") + + waitForInit(pubSub) + + try { + testFn(pubSub, probe) + } finally { + pubSub.shutdown + } + } + + // Wait for the consumer to bind and the mediator to init + def waitForInit[T](pubSub: RabbitAkkaPubSub[T]): Unit = { + pubSub.subscriber.consumer.isSuccess + pubSub.subscriber.mediator + } + + // Note: Publish logic is tested implicitly within the subscribe tests. + // Additionally: Thread.sleep is used to give the subscriber time to bind before messages are published + "PubSub" should { + + /** + * Callback tests + */ + "call the specified callback if a message for the subscription arrives" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubSub.subscribe(testTopic, testCallback) + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + probe.expectMsg(Message[String](testTopic.topic, testMsg)) + } + } + + "not call the specified callback if the message doesn't match" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubSub.subscribe(Only("NOPE"), testCallback) + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "not call the specified callback if the subscriber unsubscribed" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubSub.subscribe(testTopic, testCallback) + + Thread.sleep(500) + + pubSub.unsubscribe(subscription) + pubSub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "send the unmarshalled messages from different topics to the given callback when subscribed to everything" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val testCallback = (msg: Message[String]) => probe.ref ! msg + val subscription = pubSub.subscribe(Everything, testCallback) + val testMsg2 = "testMsg2" + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + pubSub.publish(Only("testTopic2"), testMsg2) + probe.expectMsgAllOf(Message[String](testTopic.topic, testMsg), Message[String]("testTopic2", testMsg2)) + } + } + + /** + * Actor tests + */ + "send the unmarshalled message to the given actor" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val subscription = pubSub.subscribe(testTopic, probe.ref) + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + probe.expectMsg(Message[String](testTopic.topic, testMsg)) + } + } + + "not send the message to the given actor if the message doesn't match" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val subscription = pubSub.subscribe(Only("NOPE"), probe.ref) + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "not send the message to the given actor if the subscriber unsubscribed" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val subscription = pubSub.subscribe(testTopic, probe.ref) + + Thread.sleep(500) + + pubSub.unsubscribe(subscription) + pubSub.publish(testTopic, testMsg) + probe.expectNoMsg() + } + } + + "send the unmarshalled messages from different topics to the given actor when subscribed to everything" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val subscription = pubSub.subscribe(Everything, probe.ref) + val testMsg2 = "testMsg2" + + Thread.sleep(500) + + pubSub.publish(testTopic, testMsg) + pubSub.publish(Only("testTopic2"), testMsg2) + probe.expectMsgAllOf(Message[String](testTopic.topic, testMsg), Message[String]("testTopic2", testMsg2)) + } + } + + "remap the message to a different type if used with a mapping pubSub subscriber" in { + doTest { (pubSub: RabbitAkkaPubSub[String], probe: TestProbe) => + val msg = "1234" + val converter = (s: String) => s.toInt + val newPubSub = pubSub.map[Int](converter) + val newProbe = TestProbe() + + pubSub.subscribe(testTopic, probe.ref) + newPubSub.subscribe(testTopic, newProbe.ref) + + Thread.sleep(500) + + pubSub.publish(testTopic, msg) + + probe.expectMsg(Message[String](testTopic.topic, msg)) + newProbe.expectMsg(Message[Int](testTopic.topic, 1234)) + } + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubTestKitSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubTestKitSpec.scala new file mode 100644 index 0000000000..05e167e4ea --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/pubsub/rabbit/RabbitAkkaPubSubTestKitSpec.scala @@ -0,0 +1,106 @@ +package cool.graph.messagebus.pubsub.rabbit + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions +import cool.graph.messagebus.pubsub.{Message, Only} +import cool.graph.messagebus.testkits.RabbitAkkaPubSubTestKit +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} +import play.api.libs.json.Json + +class RabbitAkkaPubSubTestKitSpec extends WordSpecLike with Matchers with BeforeAndAfterAll with BeforeAndAfterEach with ScalaFutures { + + case class TestMessage(id: String, testOpt: Option[Int], testSeq: Seq[String]) + + implicit val bugSnagger: BugSnagger = null + implicit val testMessageFormat = Json.format[TestMessage] + implicit val testMarshaller = Conversions.Marshallers.FromJsonBackedType[TestMessage]() + implicit val testUnmarshaller = Conversions.Unmarshallers.ToJsonBackedType[TestMessage]() + + val amqpUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI required for testing")) + val testRK = Only("SomeRoutingKey") + + var testKit: RabbitAkkaPubSubTestKit[TestMessage] = _ + + override def beforeEach = { + testKit = RabbitAkkaPubSubTestKit[TestMessage](amqpUri, "test") + testKit.start.futureValue + } + + override def afterEach(): Unit = testKit.stop.futureValue + + // Note: Publish logic is tested implicitly within the tests. + "The queue testing kit" should { + + /** + * Message expectation tests + */ + "should expect a message correctly" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should expect a message correctly' test...") + val testMsg = TestMessage("someId1", None, Seq("1", "2")) + + testKit.publish(testRK, testMsg) + testKit.expectMsg(Message[TestMessage](testRK.topic, testMsg)) + } + + "should blow up it expects a message and none arrives" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should blow up it expects a message and none arrives' test...") + val testMsg = TestMessage("someId2", None, Seq("1", "2")) + + an[AssertionError] should be thrownBy { + testKit.expectMsg(Message[TestMessage](testRK.topic, testMsg)) + } + } + + "should expect no message correctly" in { + testKit.expectNoMsg() + } + + "should blow up if no message was expected but one arrives" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should blow up if no message was expected but one arrives' test...") + val testMsg = TestMessage("someId3", None, Seq("1", "2")) + + testKit.publish(testRK, testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectNoMsg() + } + } + + "should expect a message count correctly" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should expect a message count correctly' test...") + + val testMsg = TestMessage("someId4", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId5", Some(123), Seq("2", "1")) + + testKit.publish(testRK, testMsg) + testKit.publish(testRK, testMsg2) + + testKit.expectMsgCount(2) + } + + "should blow up if it expects a message count and less arrive" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should blow up if it expects a message count and less arrive' test...") + val testMsg = TestMessage("someId6", None, Seq("1", "2")) + + testKit.publish(testRK, testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectMsgCount(2) + } + } + + "should blow up if it expects a message count and more arrive" in { + println(s"[PubSubTestKit][${testKit.logId}] Starting 'should blow up if it expects a message count and more arrive' test...") + val testMsg = TestMessage("someId7", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId8", Some(123), Seq("2", "1")) + + testKit.publish(testRK, testMsg) + testKit.publish(testRK, testMsg2) + + an[AssertionError] should be thrownBy { + testKit.expectMsgCount(1) + } + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueueSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueueSpec.scala new file mode 100644 index 0000000000..577c3905da --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/inmemory/InMemoryAkkaQueueSpec.scala @@ -0,0 +1,79 @@ +package cool.graph.messagebus.queue.inmemory + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import akka.testkit.{TestKit, TestProbe} +import cool.graph.messagebus.QueuePublisher +import cool.graph.messagebus.queue.{BackoffStrategy, ConstantBackoff} +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} + +import scala.concurrent.Future +import scala.concurrent.duration._ + +class InMemoryAkkaQueueSpec + extends TestKit(ActorSystem("queueing-spec")) + with WordSpecLike + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with ScalaFutures { + + implicit val materializer = ActorMaterializer() + + def withInMemoryQueue[T](backoff: BackoffStrategy = ConstantBackoff(100.millis))(testFn: (InMemoryAkkaQueue[T], TestProbe) => Unit) = { + val inMemoryQueue = InMemoryAkkaQueue[T](backoff) + val testProbe = TestProbe() + + try { + testFn(inMemoryQueue, testProbe) + } finally { + inMemoryQueue.shutdown + } + } + + override def afterAll = shutdown(verifySystemShutdown = true) + + "Queue" should { + "call the onMsg function if a valid message arrives" in { + withInMemoryQueue[String]() { (queue, probe) => + queue.withConsumer((str: String) => { probe.ref ! str; Future.successful(()) }) + queue.publish("test") + probe.expectMsg("test") + } + } + + "increment the message tries correctly on failure" in { + withInMemoryQueue[String]() { (queue, probe) => + queue.withConsumer((str: String) => { probe.ref ! str; Future.failed(new Exception("Kabooom")) }) + queue.publish("test") + + // 5 tries, 5 times the same message (can't check for the tries explicitly here) + probe.expectMsgAllOf(2.seconds, Vector.fill(5) { "test" }: _*) + probe.expectNoMsg(1.second) + } + } + + "map a type correctly with a MappingQueueConsumer" in { + withInMemoryQueue[String]() { (queue, probe) => + val mapped = queue.map[Int]((str: String) => str.toInt) + + mapped.withConsumer((int: Int) => { probe.ref ! int; Future.successful(()) }) + queue.publish("123") + + probe.expectMsg(123) + } + } + + "map a type correctly with a MappingQueuePublisher" in { + withInMemoryQueue[String]() { (queue: InMemoryAkkaQueue[String], probe) => + val mapped: QueuePublisher[Int] = queue.map[Int]((int: Int) => int.toString) + + queue.withConsumer((str: String) => { probe.ref ! str; Future.successful(()) }) + mapped.publish(123) + + probe.expectMsg("123") + } + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueSpec.scala new file mode 100644 index 0000000000..20ab492c68 --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueSpec.scala @@ -0,0 +1,179 @@ +package cool.graph.messagebus.queue.rabbit + +import java.nio.charset.Charset + +import akka.actor.ActorSystem +import akka.testkit.{TestKit, TestProbe} +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.queue.ConstantBackoff +import cool.graph.messagebus.utils.RabbitUtils +import cool.graph.rabbit.Bindings.RoutingKey +import cool.graph.rabbit.{Consumer, Delivery} +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} + +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.Try + +class RabbitQueueSpec + extends TestKit(ActorSystem("queueing-spec")) + with WordSpecLike + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with ScalaFutures { + + val amqpUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI required for testing")) + implicit val testMarshaller: String => Array[Byte] = str => str.getBytes("utf-8") + implicit val testUnmarshaller: Array[Byte] => String = bytes => new String(bytes, Charset.forName("UTF-8")) + implicit val bugSnagger: BugSnagger = null + + var rabbitQueue: RabbitQueue[String] = _ + var failingRabbitQueue: RabbitQueue[String] = _ + var testProbe: TestProbe = _ + + override def afterAll = shutdown(verifySystemShutdown = true) + + override def beforeEach = { + nukeQueues + testProbe = TestProbe() + + val testConsumeFn = (str: String) => { testProbe.ref ! str; Future.successful(()) } + val testFailingConsumeFn = (str: String) => Future.failed(new Exception("This is expected to happen")) + val testBackoff = ConstantBackoff(0.second) + + rabbitQueue = RabbitQueue[String](amqpUri, "test", testBackoff) + failingRabbitQueue = RabbitQueue[String](amqpUri, "test-failing", testBackoff) + + rabbitQueue.withConsumer(testConsumeFn) + failingRabbitQueue.withConsumer(testFailingConsumeFn) + } + + override def afterEach(): Unit = { + nukeQueues + } + + // Uncool, but it just has to work now. Sorry! + def nukeQueues: Unit = { + Try { rabbitQueue.exchange.rabbitChannel.queueDelete("test-error") } + Try { rabbitQueue.exchange.rabbitChannel.queueDelete("test-failing-error") } + + Try { rabbitQueue.shutdown } + Try { failingRabbitQueue.shutdown } + + Try { rabbitQueue.exchange.rabbitChannel.exchangeDelete(rabbitQueue.exchange.name, false) } + Try { failingRabbitQueue.exchange.rabbitChannel.exchangeDelete(failingRabbitQueue.exchange.name, false) } + } + + // Plain consumer without any magic (only that exchangeName is suffixed with -exchange) to test specific queueing behaviour + def plainQueueConsumer(exchangeName: String, queueName: String, binding: String, autoDelete: Boolean, consumeFn: Delivery => Unit): Consumer = { + val exchange = RabbitUtils.declareExchange(amqpUri, exchangeName, concurrency = 1, durable = false) + (for { + queue <- exchange.channel.queueDeclare(queueName, durable = false, autoDelete = autoDelete) + _ <- queue.bindTo(exchange, RoutingKey(binding)) + consumer <- queue.consume({ d: Delivery => + queue.ack(d) + consumeFn(d) + }) + } yield consumer).get + } + + // Note: Publish logic is tested implicitly within the tests. + "Queue" should { + + "call the onMsg function if a valid message arrives" in { + val testMsg = "test" + + rabbitQueue.publish(testMsg) + testProbe.expectMsg(testMsg) + } + + "increment the message tries correctly on failure" in { + val testMsg = "test" + val errorProbe = TestProbe() + + plainQueueConsumer("test-failing", "test-failing-2", "msg.#", autoDelete = true, (d: Delivery) => { + errorProbe.ref ! d.envelope.getRoutingKey + }) + + failingRabbitQueue.publish(testMsg) + errorProbe.expectMsgAllOf(10.seconds, "msg.0", "msg.1", "msg.2", "msg.3", "msg.4", "msg.5") + } + + "put the message into the error queue if it failed MAX_TRIES (5) times" in { + val testMsg = "test" + val errorQProbe = TestProbe() + + plainQueueConsumer("test-failing", "test-failing-error", "error.#", autoDelete = false, (d: Delivery) => { + errorQProbe.ref ! d.envelope.getRoutingKey + }) + + failingRabbitQueue.publish(testMsg) + errorQProbe.expectMsgPF[String](10.seconds) { + case x: String if x.startsWith("error.5.") => x + } + } + + "not process messages with invalid routing key" in { + rabbitQueue.exchange.publish("not.a.valid.key", "test") + rabbitQueue.exchange.publish("msg.also.not.a.valid.key", "test") + + // process() will never be called in the consumer + testProbe.expectNoMsg() + } + + "requeue with timestamp on backoff > 60s" in { + val testMsg = "test" + val testProbe2 = TestProbe() + + // We don't need that one here, it would only ack off messages and interfere with the setup. + failingRabbitQueue.shutdown + + // First create a new queue consumer that has a > 60s constant backoff and that always fails messages + val longBackoffFailingRabbitQueue = + RabbitQueue[String](amqpUri, "test-failing", ConstantBackoff(61.seconds))(bugSnagger, testMarshaller, testUnmarshaller) + + longBackoffFailingRabbitQueue.withConsumer((str: String) => Future.failed(new Exception("This is expected to happen"))) + + // This one waits for a message (the requeud one that is) on the main queue + plainQueueConsumer("test-failing", "test-failing-2", "msg.#", autoDelete = true, (d: Delivery) => { + testProbe2.ref ! d.envelope.getRoutingKey + }) + + // Publish the test message + longBackoffFailingRabbitQueue.publish(testMsg) + + // Wait for the requeued message + testProbe2.fishForMessage(10.seconds) { + case x: String if rkHasTimestampWithMinDistance(x, 30) => true + case _ => false + } + + longBackoffFailingRabbitQueue.exchange.rabbitChannel.exchangeDelete(longBackoffFailingRabbitQueue.exchange.name, false) + } + + "process messages that have a timestamp in the past" in { + val timestamp = (System.currentTimeMillis() / 1000) - 1000 + val testMsg = "test" + + rabbitQueue.exchange.publish(s"msg.1.$timestamp", testMsg) + testProbe.expectMsg(testMsg) + } + + /** + * Checks if a routing key timestamp is present and if the timestamp is at least [distance] seconds away. + * Positive distance for "in the future", negative for "in the past". + */ + def rkHasTimestampWithMinDistance(rk: String, distance: Long): Boolean = { + val components = rk.split("\\.") + if (components.length != 3) { + false + } else { + val Array(_, _, timestamp) = components + + (timestamp.toLong - (System.currentTimeMillis() / 1000)) > distance + } + } + } +} diff --git a/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueTestKitSpec.scala b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueTestKitSpec.scala new file mode 100644 index 0000000000..5706e87b8f --- /dev/null +++ b/server/libs/message-bus/src/test/scala/cool/graph/messagebus/queue/rabbit/RabbitQueueTestKitSpec.scala @@ -0,0 +1,174 @@ +package cool.graph.messagebus.queue.rabbit + +import cool.graph.bugsnag.BugSnagger +import cool.graph.messagebus.Conversions +import cool.graph.messagebus.testkits.RabbitQueueTestKit +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Matchers, WordSpecLike} +import play.api.libs.json.Json + +class RabbitQueueTestKitSpec extends WordSpecLike with Matchers with BeforeAndAfterAll with BeforeAndAfterEach with ScalaFutures { + + case class TestMessage(id: String, testOpt: Option[Int], testSeq: Seq[String]) + + implicit val bugSnagger: BugSnagger = null + implicit val testMessageFormat = Json.format[TestMessage] + implicit val testMarshaller = Conversions.Marshallers.FromJsonBackedType[TestMessage]() + implicit val testUnmarshaller = Conversions.Unmarshallers.ToJsonBackedType[TestMessage]() + + val amqpUri = sys.env.getOrElse("RABBITMQ_URI", sys.error("RABBITMQ_URI required for testing")) + + var testKit: RabbitQueueTestKit[TestMessage] = _ + + override def beforeEach = { + testKit = RabbitQueueTestKit[TestMessage](amqpUri, "test") + testKit.withTestConsumers() + } + + override def afterEach(): Unit = testKit.shutdown() + + // Note: Publish logic is tested implicitly within the tests. + "The queue testing kit" should { + + /** + * Message expectation tests + */ + "should expect a message correctly" in { + println(s"[TestKit][${testKit.logId}] Starting 'should expect a message correctly' test...") + val testMsg = TestMessage("someId1", None, Seq("1", "2")) + + testKit.publish(testMsg) + testKit.expectMsg(testMsg) + } + + "should blow up it expects a message and none arrives" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up it expects a message and none arrives' test...") + val testMsg = TestMessage("someId2", None, Seq("1", "2")) + + an[AssertionError] should be thrownBy { + testKit.expectMsg(testMsg) + } + } + + "should expect no message correctly" in { + testKit.expectNoMsg() + } + + "should blow up if no message was expected but one arrives" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if no message was expected but one arrives' test...") + val testMsg = TestMessage("someId3", None, Seq("1", "2")) + + testKit.publish(testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectNoMsg() + } + } + + "should expect a message count correctly" in { + println(s"[TestKit][${testKit.logId}] Starting 'should expect a message count correctly' test...") + val testMsg = TestMessage("someId4", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId5", Some(123), Seq("2", "1")) + + testKit.publish(testMsg) + testKit.publish(testMsg2) + + testKit.expectMsgCount(2) + } + + "should blow up if it expects a message count and less arrive" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if it expects a message count and less arrive' test...") + val testMsg = TestMessage("someId6", None, Seq("1", "2")) + + testKit.publish(testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectMsgCount(2) + } + } + + "should blow up if it expects a message count and more arrive" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if it expects a message count and more arrive' test...") + val testMsg = TestMessage("someId7", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId8", Some(123), Seq("2", "1")) + + testKit.publish(testMsg) + testKit.publish(testMsg2) + + an[AssertionError] should be thrownBy { + testKit.expectMsgCount(1) + } + } + + /** + * Error msg expectation tests + */ + "should expect an error message correctly" in { + println(s"[TestKit][${testKit.logId}] Starting 'should expect an error message correctly' test...") + val testMsg = TestMessage("someId9", None, Seq("1", "2")) + + testKit.publishError(testMsg) + testKit.expectErrorMsg(testMsg) + } + + "should blow up it expects an error message and none arrives" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up it expects an error message and none arrives' test...") + val testMsg = TestMessage("someId10", None, Seq("1", "2")) + + an[AssertionError] should be thrownBy { + testKit.expectErrorMsg(testMsg) + } + } + + "should expect no error message correctly" in { + println(s"[TestKit][${testKit.logId}] Starting 'should expect no error message correctly' test...") + testKit.expectNoErrorMsg() + } + + "should blow up if no error message was expected but one arrives" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if no error message was expected but one arrives' test...") + val testMsg = TestMessage("someId11", None, Seq("1", "2")) + + testKit.publishError(testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectNoErrorMsg() + } + } + + "should expect an error message count correctly" in { + println(s"[TestKit][${testKit.logId}] Starting 'should expect an error message count correctly' test...") + val testMsg = TestMessage("someId12", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId13", Some(123), Seq("2", "1")) + + testKit.publishError(testMsg) + testKit.publishError(testMsg2) + + testKit.expectErrorMsgCount(2) + } + + "should blow up if it expects an error message count and less arrive" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if it expects an error message count and less arrive' test...") + val testMsg = TestMessage("someId14", None, Seq("1", "2")) + + testKit.publishError(testMsg) + + an[AssertionError] should be thrownBy { + testKit.expectErrorMsgCount(2) + } + } + + "should blow up if it expects an error message count and more arrive" in { + println(s"[TestKit][${testKit.logId}] Starting 'should blow up if it expects an error message count and more arrive' test...") + val testMsg = TestMessage("someId15", None, Seq("1", "2")) + val testMsg2 = TestMessage("someId16", Some(123), Seq("2", "1")) + + testKit.publishError(testMsg) + testKit.publishError(testMsg2) + + an[AssertionError] should be thrownBy { + testKit.expectErrorMsgCount(1) + } + } + } +} diff --git a/server/libs/metrics/project/build.properties b/server/libs/metrics/project/build.properties new file mode 100644 index 0000000000..c091b86ca4 --- /dev/null +++ b/server/libs/metrics/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.16 diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/ContainerMetadata.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/ContainerMetadata.scala new file mode 100644 index 0000000000..5a50b441e9 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/ContainerMetadata.scala @@ -0,0 +1,19 @@ +package cool.graph.metrics + +import scala.io.Source + +object ContainerMetadata { + + /** + * Fetches the docker container ID of the current container using the hostsname file. + * + * @return The docker container ID of the container running this service. + */ + def fetchContainerId(): String = { + val source = Source.fromFile("/etc/hostname") + val hostname = try source.mkString.trim + finally source.close() + + hostname + } +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/DummyStatsDClient.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/DummyStatsDClient.scala new file mode 100644 index 0000000000..2c486b6fd9 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/DummyStatsDClient.scala @@ -0,0 +1,75 @@ +package cool.graph.metrics + +import com.timgroup.statsd.{Event, ServiceCheck, StatsDClient} + +case class DummyStatsDClient() extends StatsDClient { + override def recordHistogramValue(aspect: String, value: Double, tags: String*): Unit = {} + + override def recordHistogramValue(aspect: String, value: Double, sampleRate: Double, tags: String*): Unit = {} + + override def recordHistogramValue(aspect: String, value: Long, tags: String*): Unit = {} + + override def recordHistogramValue(aspect: String, value: Long, sampleRate: Double, tags: String*): Unit = {} + + override def increment(aspect: String, tags: String*): Unit = {} + + override def increment(aspect: String, sampleRate: Double, tags: String*): Unit = {} + + override def recordGaugeValue(aspect: String, value: Double, tags: String*): Unit = {} + + override def recordGaugeValue(aspect: String, value: Double, sampleRate: Double, tags: String*): Unit = {} + + override def recordGaugeValue(aspect: String, value: Long, tags: String*): Unit = {} + + override def recordGaugeValue(aspect: String, value: Long, sampleRate: Double, tags: String*): Unit = {} + + override def recordEvent(event: Event, tags: String*): Unit = {} + + override def recordSetValue(aspect: String, value: String, tags: String*): Unit = {} + + override def gauge(aspect: String, value: Double, tags: String*): Unit = {} + + override def gauge(aspect: String, value: Double, sampleRate: Double, tags: String*): Unit = {} + + override def gauge(aspect: String, value: Long, tags: String*): Unit = {} + + override def gauge(aspect: String, value: Long, sampleRate: Double, tags: String*): Unit = {} + + override def recordServiceCheckRun(sc: ServiceCheck): Unit = {} + + override def incrementCounter(aspect: String, tags: String*): Unit = {} + + override def incrementCounter(aspect: String, sampleRate: Double, tags: String*): Unit = {} + + override def count(aspect: String, delta: Long, tags: String*): Unit = {} + + override def count(aspect: String, delta: Long, sampleRate: Double, tags: String*): Unit = {} + + override def histogram(aspect: String, value: Double, tags: String*): Unit = {} + + override def histogram(aspect: String, value: Double, sampleRate: Double, tags: String*): Unit = {} + + override def histogram(aspect: String, value: Long, tags: String*): Unit = {} + + override def histogram(aspect: String, value: Long, sampleRate: Double, tags: String*): Unit = {} + + override def decrementCounter(aspect: String, tags: String*): Unit = {} + + override def decrementCounter(aspect: String, sampleRate: Double, tags: String*): Unit = {} + + override def stop(): Unit = {} + + override def serviceCheck(sc: ServiceCheck): Unit = {} + + override def decrement(aspect: String, tags: String*): Unit = {} + + override def decrement(aspect: String, sampleRate: Double, tags: String*): Unit = {} + + override def time(aspect: String, value: Long, tags: String*): Unit = {} + + override def time(aspect: String, value: Long, sampleRate: Double, tags: String*): Unit = {} + + override def recordExecutionTime(aspect: String, timeInMs: Long, tags: String*): Unit = {} + + override def recordExecutionTime(aspect: String, timeInMs: Long, sampleRate: Double, tags: String*): Unit = {} +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/Errors.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/Errors.scala new file mode 100644 index 0000000000..79053537de --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/Errors.scala @@ -0,0 +1,23 @@ +package cool.graph.metrics + +import com.timgroup.statsd.StatsDClientErrorHandler +import cool.graph.bugsnag.BugSnaggerImpl + +/** + * Custom error handler to hook into the statsd library. + * Logs to stdout and reports to bugsnag. + * Doesn't interrupt application execution by just reporting errors and then swallowing them. + */ +case class CustomErrorHandler() extends StatsDClientErrorHandler { + val bugsnag = BugSnaggerImpl(sys.env.getOrElse("BUGSNAG_API_KEY", "")) + + override def handle(exception: java.lang.Exception): Unit = { + bugsnag.report(exception) + println(s"[Metrics] Encountered error: $exception") + } +} + +/** + * Custom error class for easier Bugsnag distinction and to allow the java lib to catch init errors + */ +case class MetricsError(reason: String) extends java.lang.Exception(reason) diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/InstanceMetadata.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/InstanceMetadata.scala new file mode 100644 index 0000000000..d352fd291d --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/InstanceMetadata.scala @@ -0,0 +1,63 @@ +package cool.graph.metrics + +import com.twitter.finagle +import com.twitter.finagle.http.{Method, Request, Response} +import cool.graph.metrics.Utils._ + +import scala.concurrent.Future +import com.twitter.conversions.time._ +import com.twitter.finagle.service.Backoff + +object InstanceMetadata { + import scala.concurrent.ExecutionContext.Implicits.global + + val service = finagle.Http.client.withRetryBackoff(Backoff.const(5.seconds)).withRequestTimeout(15.seconds).newService("169.254.169.254:80") + + /** + * Fetches the EC2 IP of the host VM using the EC2 metadata service. + * + * @return A future containing the host IP as string. + */ + def fetchInstanceIP(): Future[String] = fetch("/latest/meta-data/local-ipv4") + + /** + * Fetches the EC2 ami launch index of the host VM using the EC2 metadata service. + * + * @return A future containing the ami launch index as string. + */ + def fetchInstanceLaunchIndex(): Future[String] = fetch("/latest/meta-data/ami-launch-index") + + /** + * Fetches the EC2 instance ID of the host VM using the EC2 metadata service. + * + * @return A future containing the instance ID as string. + */ + def fetchInstanceId(): Future[String] = fetch("/latest/meta-data/instance-id") + + /** + * Generic fetch for the metadata service. + * + * @param path The path on the ecs metadata service to fetch (including leading slash) + * @return The content as string of the response + */ + private def fetch(path: String): Future[String] = { + val request = Request(Method.Get, path) + val requestFuture = service(request).asScala + + requestFuture.onFailure({ + case e => throw MetricsError(s"Error while fetching request ${request.uri}: $e") + }) + + requestFuture.map { (response: Response) => + response.status match { + case x if x.code >= 200 && x.code < 300 => + val ip = response.contentString + println(s"[Metrics] Request ${request.uri} result: $ip") + ip + + case _ => + throw MetricsError(s"Unable to retrieve EC2 metadata (${request.uri}) - ${response.status} | ${response.contentString}") + } + } + } +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/Metrics.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/Metrics.scala new file mode 100644 index 0000000000..d29f4d521a --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/Metrics.scala @@ -0,0 +1,131 @@ +package cool.graph.metrics + +import java.util.concurrent.atomic.AtomicLong + +import akka.actor.ActorSystem +import com.timgroup.statsd.StatsDClient + +import scala.concurrent.Future +import scala.concurrent.duration._ + +case class CustomTag(name: String, recordingThreshold: Long = 0) { + + /** + * Returns the tag string for this tag + value combination. + * Sets the empty value for the tag ("-") if the value to record is above the threshold. + * This is mostly interesting for timings right now to reduce noise in custom dimensions. + */ + def apply(recordedValue: Long, tagValue: String): String = { + if (recordedValue >= recordingThreshold) { + s"$name=$tagValue" + } else { + s"$name=-" + } + } +} + +trait Metric { + + val name: String + val baseTags: String + val customTags: Seq[CustomTag] + val client: StatsDClient + + // Merges base tags, defined custom tags, and given custom tag values to construct the metric string for statsd. + def constructMetricString(recordedValue: Long, customTagValues: Seq[String]): String = { + val customTagsString = mergeTagsAndValues(recordedValue, customTagValues) + val completeTagString = Seq(baseTags, customTagsString).filter(_.nonEmpty).mkString(",") + + Seq(name, completeTagString).filter(_.nonEmpty).mkString("#") + } + + def mergeTagsAndValues(recordedValue: Long, values: Seq[String]): String = { + if (values.length != customTags.length) { + println( + s"[Metrics] Warning: Metric $name not enough / too many custom tag values given at recording time to fill the defined tags $customTags. Ignoring custom tags.") + "" + } else { + customTags + .zip(values) + .map(tagAndValue => tagAndValue._1(recordedValue, tagAndValue._2)) + .mkString(",") + } + } +} + +/** + * A simple counter metric. Useful for point-in-time measurements like requests/s. + * + * @param name The counter name. + * @param customTags A collection of unique custom tag names that will be filled during recording time. + */ +case class CounterMetric(name: String, baseTags: String, customTags: Seq[CustomTag], client: StatsDClient) extends Metric { + // Counters allow custom tags per occurrence + def inc(customTagValues: String*) = client.incrementCounter(constructMetricString(1, customTagValues)) + + def incBy(delta: Long, customTagValues: String*) = client.count(constructMetricString(1, customTagValues), delta) +} + +/** + * A metric recording a constant value until changed (like turning a valve to a certain position). + * This is useful when long-living things like open socket connections or CPU/memory are measured. + * + * Gauges are tricky because they encapsulate a state over time. TODO: elaborate + * Our statsd is configured to clear a gauge if it hasn't been flushed in the interval (== no metrics reported for gauge). + * + * @param name The name of the Gauge. + * @param predefTags A collection of unique custom tag names with values (!) that will be used for this gauge. + */ +case class GaugeMetric(name: String, baseTags: String, predefTags: Seq[(CustomTag, String)], client: StatsDClient)(implicit flushSystem: ActorSystem) + extends Metric { + import flushSystem.dispatcher + + val value = new AtomicLong(0) + + override val customTags = predefTags.map(_._1) + val constructedMetricName = constructMetricString(0, predefTags.map(_._2)) + + // Important, the interval must be lower than the configured statsd flush interval (curr. 10s), or we see weird metric behaviour. + flushSystem.scheduler.schedule(0.seconds, 5.seconds) { flush() } + + def add(delta: Long): Unit = value.addAndGet(delta) + def set(fixedVal: Long): Unit = value.getAndSet(fixedVal) + def get: Long = value.get + def inc: Unit = add(1) + def dec: Unit = add(-1) + + private def flush() = client.gauge(constructedMetricName, value.get) +} + +/** + * A metric recording a timing and emitting a single measurement to statsd. + * Useful for timing databases, requests, ... you probably get it. + * + * @param name The name of the timer. + * @param customTags A collection of unique custom tag names that will be filled during recording time. + */ +case class TimerMetric(name: String, baseTags: String, customTags: Seq[CustomTag], client: StatsDClient)(implicit flushSystem: ActorSystem) extends Metric { + def timeFuture[T](customTagValues: String*)(f: => Future[T]): Future[T] = { + val startTime = java.lang.System.currentTimeMillis + val result = f + + result.onComplete { _ => + record(java.lang.System.currentTimeMillis - startTime, customTagValues) + }(flushSystem.dispatcher) + + result + } + + def time[T](customTagValues: String*)(f: => T): T = { + val startTime = java.lang.System.currentTimeMillis + val res = f + + record(java.lang.System.currentTimeMillis - startTime, customTagValues) + res + } + + def record(timeMillis: Long, customTagValues: Seq[String] = Seq.empty): Unit = { + val metricName = constructMetricString(timeMillis, customTagValues) + client.recordExecutionTime(metricName, timeMillis) + } +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/MetricsManager.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/MetricsManager.scala new file mode 100644 index 0000000000..8000b77998 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/MetricsManager.scala @@ -0,0 +1,71 @@ +package cool.graph.metrics + +import akka.actor.ActorSystem +import com.timgroup.statsd.{NonBlockingStatsDClient, StatsDClient} +import cool.graph.akkautil.SingleThreadedActorSystem + +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +/** + * Metrics management, should be inherited and instantiated _once per logical service_. + * + * The metrics structure reported from this code to the statsd backend is as follows: + * + * {service_name}.{metric_name}#env={env}container={container_id},instance={instance_id}[,{custom_tag}={custom_value}] + * + * - The basic metrics name that goes to statsd is simply the logical service name plus the metric name, e.g. "ApiSimpleService.OpenSessions" + * - After that, there is a series of tags: + * - The env var "METRICS_PREFIX" is used to denote the env the service is running in, e.g. 'dev' or 'prod'. + * - The EC2 instance this code is run from. Fetched from EC2 + * - The container ID this code is run from. Fetched from /etc/hostname, as it is identical to the container ID in ECS. + * - Custom metric tags. These should be used sparsely and only if it delivers crucial insights, such as per-project distinctions. + * + * The final metric that arrives at Statsd looks for example like this: + * "ApiSimpleService.RequestCount#env=prod,instance=i-0d3c23cdd0c2f5d03,container=e065fc831976,projectId=someCUID + */ +trait MetricsManager { + + def serviceName: String + + // System used to periodically flush the state of individual gauges + implicit val gaugeFlushSystem: ActorSystem = SingleThreadedActorSystem(s"$serviceName-gauges") + + val errorHandler = CustomErrorHandler() + + protected val baseTagsString: String = { + if (sys.env.isDefinedAt("METRICS_PREFIX")) { + Try { + val instanceID = Await.result(InstanceMetadata.fetchInstanceId(), 5.seconds) + val containerId = ContainerMetadata.fetchContainerId() + val region = sys.env.getOrElse("AWS_REGION", "no_region") + val env = sys.env.getOrElse("METRICS_PREFIX", "local") + + s"env=$env,region=$region,instance=$instanceID,container=$containerId" + } match { + case Success(baseTags) => baseTags + case Failure(err) => errorHandler.handle(new Exception(err)); "" + } + } else { + "" + } + } + + protected val client: StatsDClient = { + // As we don't have an 'env' ENV var (prod, dev) this variable suppresses failing metrics output locally / during testing + if (sys.env.isDefinedAt("METRICS_PREFIX")) { + new NonBlockingStatsDClient("", Integer.MAX_VALUE, new Array[String](0), errorHandler, StatsdHostLookup()) + } else { + println("[Metrics] Warning, Metrics can't initialize - no metrics will be recorded.") + DummyStatsDClient() + } + } + + // Gauges DO NOT support custom metric tags per occurrence, only hardcoded custom tags during definition! + def defineGauge(name: String, predefTags: (CustomTag, String)*): GaugeMetric = GaugeMetric(s"$serviceName.$name", baseTagsString, predefTags, client) + def defineCounter(name: String, customTags: CustomTag*): CounterMetric = CounterMetric(s"$serviceName.$name", baseTagsString, customTags, client) + def defineTimer(name: String, customTags: CustomTag*): TimerMetric = TimerMetric(s"$serviceName.$name", baseTagsString, customTags, client) + + def shutdown: Unit = Await.result(gaugeFlushSystem.terminate(), 10.seconds) +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/StatsdHostLookup.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/StatsdHostLookup.scala new file mode 100644 index 0000000000..1fe77a6018 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/StatsdHostLookup.scala @@ -0,0 +1,42 @@ +package cool.graph.metrics + +import java.net.InetSocketAddress +import java.util.concurrent.Callable + +import scala.concurrent.Await + +/** + * As soon as metrics are flushed, this callable is evaluated. + * The IP address + port of the _host_ (EC2 VM) running the statsd container is returned by call(). + * -> The statsd container binds on the host directly, so we need the VM IP. + * + * On error: + * - No data is send by the library, and the callable is evaluated again next flush. + * - This catches transient network errors in resolving the statsd host. + * - Metrics are queued inmemory (defined in the client), nothing is lost on error here. + */ +case class StatsdHostLookup() extends Callable[InetSocketAddress] { + + var lookupCache: Option[InetSocketAddress] = None + + override def call(): InetSocketAddress = { + lookupCache match { + case Some(inetAddr) => inetAddr + case None => resolve() + } + } + + def resolve(): InetSocketAddress = { + import scala.concurrent.duration._ + + println("[Metrics] Fetching instance IP...") + + val fetchIpFuture = InstanceMetadata.fetchInstanceIP() + val ip = Await.result(fetchIpFuture, 5.seconds) + val lookup = new InetSocketAddress(ip, 8125) + + println("[Metrics] Done fetching instance IP.") + lookupCache = Some(lookup) + lookup + } +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/Utils.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/Utils.scala new file mode 100644 index 0000000000..60c9850624 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/Utils.scala @@ -0,0 +1,18 @@ +package cool.graph.metrics + +import com.twitter.util.{Return, Throw, Future => TwitterFuture, Promise => TwitterPromise} +import scala.concurrent.{ExecutionContext, Future => ScalaFuture, Promise => ScalaPromise} + +object Utils { + + implicit class RichTwitterFuture[A](val tf: TwitterFuture[A]) extends AnyVal { + def asScala(implicit e: ExecutionContext): ScalaFuture[A] = { + val promise: ScalaPromise[A] = ScalaPromise() + tf.respond { + case Return(value) => promise.success(value) + case Throw(exception) => promise.failure(exception) + } + promise.future + } + } +} diff --git a/server/libs/metrics/src/main/scala/cool/graph/metrics/extensions/TimeResponseDirective.scala b/server/libs/metrics/src/main/scala/cool/graph/metrics/extensions/TimeResponseDirective.scala new file mode 100644 index 0000000000..4c61d34d81 --- /dev/null +++ b/server/libs/metrics/src/main/scala/cool/graph/metrics/extensions/TimeResponseDirective.scala @@ -0,0 +1,45 @@ +package cool.graph.metrics.extensions + +import akka.event.Logging.LogLevel +import akka.event.{Logging, LoggingAdapter} +import akka.http.scaladsl.model.HttpRequest +import akka.http.scaladsl.server.RouteResult.{Complete, Rejected} +import akka.http.scaladsl.server.directives.{DebuggingDirectives, LoggingMagnet} +import cool.graph.metrics.{CustomTag, MetricsManager, TimerMetric} + +trait TimeResponseDirective { + + /** + * The timer metric to use. + */ + val requestTimer: TimerMetric + + /** + * Captures the time it takes for a request to finish and sends it to a timer metric along with the status. + */ + def captureResponseTimeFunction( + loggingAdapter: LoggingAdapter, + requestTimestamp: Long, + level: LogLevel = Logging.InfoLevel + )(req: HttpRequest)(res: Any): Unit = { + res match { + case Complete(resp) => + val responseTimestamp: Long = System.nanoTime + val elapsedTime: Long = (responseTimestamp - requestTimestamp) / 1000000 + requestTimer.record(elapsedTime, Seq(resp.status.toString())) + + case Rejected(_) => + } + } + + def captureResponseTime(log: LoggingAdapter) = { + val requestTimestamp = System.nanoTime + captureResponseTimeFunction(log, requestTimestamp)(_) + } + + val timeResponse = DebuggingDirectives.logRequestResult(LoggingMagnet(captureResponseTime(_))) +} + +case class TimeResponseDirectiveImpl(metricsManager: MetricsManager) extends TimeResponseDirective { + val requestTimer: TimerMetric = metricsManager.defineTimer("responseTime", CustomTag("status")) +} diff --git a/server/libs/metrics/src/test/scala/cool/graph/metrics/MetricsTagSpec.scala b/server/libs/metrics/src/test/scala/cool/graph/metrics/MetricsTagSpec.scala new file mode 100644 index 0000000000..1587b32f0d --- /dev/null +++ b/server/libs/metrics/src/test/scala/cool/graph/metrics/MetricsTagSpec.scala @@ -0,0 +1,76 @@ +package cool.graph.metrics + +import cool.graph.metrics.utils.{TestLiveMetricsManager, TestMetricsManager} +import org.scalatest.{FlatSpec, Matchers} + +class MetricsTagSpec extends FlatSpec with Matchers { + it should "have the correct metrics tags without extra custom tags" in { + val manager = new TestMetricsManager() + val counter = manager.defineCounter("testCounter") + + counter.constructMetricString(0, Seq("1", "2")) should equal("TestService.testCounter#env=test,instance=local,container=none") + } + + it should "have the correct metrics tags with custom metrics set" in { + val manager = new TestMetricsManager() + val counter = manager.defineCounter("testCounter", CustomTag("testCustomTag1"), CustomTag("testCustomTag2")) + + counter.constructMetricString(0, Seq("1", "2")) should equal( + "TestService.testCounter#env=test,instance=local,container=none,testCustomTag1=1,testCustomTag2=2") + } + + it should "have the correct metrics tags for gauges" in { + val manager = new TestMetricsManager() + val gauge = manager.defineGauge("testCounter", (CustomTag("testCustomTag1"), "1"), (CustomTag("testCustomTag2"), "2")) + + gauge.constructedMetricName should equal("TestService.testCounter#env=test,instance=local,container=none,testCustomTag1=1,testCustomTag2=2") + } + + it should "have the correct metrics tags for timers" in { + val manager = new TestMetricsManager() + val timer = manager.defineTimer("testTimer", CustomTag("projectId")) + + timer.constructMetricString(0, Seq("1234")) should equal("TestService.testTimer#env=test,instance=local,container=none,projectId=1234") + } + + it should "ignore custom metric tags if the number of provided values doesn't match" in { + val manager = new TestMetricsManager() + val counter = manager.defineCounter("testCounter", CustomTag("testCustomTag1"), CustomTag("testCustomTag2")) + + counter.constructMetricString(0, Seq("1")) should equal("TestService.testCounter#env=test,instance=local,container=none") + } + + it should "not record a custom tag value if the recorded value is above the specified threshold" in { + val manager = new TestMetricsManager() + val timer = manager.defineTimer("testTimer", CustomTag("projectId", recordingThreshold = 100)) + + timer.constructMetricString(90, Seq("1234")) should equal("TestService.testTimer#env=test,instance=local,container=none,projectId=-") + } + + // Only run if you want some live metrics in librato + ignore should "do some live metrics against librato" in { + val manager = new TestLiveMetricsManager + + val counter = manager.defineCounter("testCounter") + val counterCustom = manager.defineCounter("testCounterWithTags", CustomTag("tag1"), CustomTag("tag2")) + val gauge = manager.defineGauge("testGauge") + val gaugeCustom = manager.defineGauge("testGaugeWithTags", (CustomTag("tag1"), "constantVal")) + val timer = manager.defineTimer("testTimer") + val timerCustom = manager.defineTimer("testTimerWithTags", CustomTag("tag1")) + + gauge.set(100) + gaugeCustom.set(50) + counter.inc() + counterCustom.inc("val1", "val2") + + timer.time() { + Thread.sleep(500) + } + + timerCustom.time("val1") { + Thread.sleep(800) + } + + Thread.sleep(10000) + } +} diff --git a/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestLiveMetricsManager.scala b/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestLiveMetricsManager.scala new file mode 100644 index 0000000000..e25ef410d5 --- /dev/null +++ b/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestLiveMetricsManager.scala @@ -0,0 +1,11 @@ +package cool.graph.metrics.utils + +import com.timgroup.statsd.{NonBlockingStatsDClient, StatsDClient} +import cool.graph.metrics.MetricsManager + +class TestLiveMetricsManager extends MetricsManager { + def serviceName: String = "TestService" + + override val baseTagsString: String = "env=test,instance=local,container=none" + override val client: StatsDClient = new NonBlockingStatsDClient(serviceName, "127.0.0.1", 8125, new Array[String](0), errorHandler) +} diff --git a/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestMetricsManager.scala b/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestMetricsManager.scala new file mode 100644 index 0000000000..14295c60bc --- /dev/null +++ b/server/libs/metrics/src/test/scala/cool/graph/metrics/utils/TestMetricsManager.scala @@ -0,0 +1,11 @@ +package cool.graph.metrics.utils + +import com.timgroup.statsd.StatsDClient +import cool.graph.metrics.{DummyStatsDClient, MetricsManager} + +class TestMetricsManager extends MetricsManager { + def serviceName: String = "TestService" + + override val baseTagsString: String = "env=test,instance=local,container=none" + override val client: StatsDClient = new DummyStatsDClient +} diff --git a/server/libs/project/build.properties b/server/libs/project/build.properties new file mode 100644 index 0000000000..c091b86ca4 --- /dev/null +++ b/server/libs/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.16 diff --git a/server/libs/rabbit-processor/.gitignore b/server/libs/rabbit-processor/.gitignore new file mode 100644 index 0000000000..639e8c3bee --- /dev/null +++ b/server/libs/rabbit-processor/.gitignore @@ -0,0 +1,7 @@ +/RUNNING_PID +/logs/ +/project/*-shim.sbt +/project/project/ +/project/target/ +/target/ +.idea diff --git a/server/libs/rabbit-processor/README.md b/server/libs/rabbit-processor/README.md new file mode 100644 index 0000000000..4315f4a59d --- /dev/null +++ b/server/libs/rabbit-processor/README.md @@ -0,0 +1,141 @@ +## Rabbit-Processor-Scala +======================= + +This library is intended to help when working with RabbitMQ. It provides a layer above the original RabbitMQ Java client. + +You can find the release notes for the different versions [here](release-notes.md). + +## Design goals of this library + +* Containerize Exceptions instead of throwing them. The original RabbitMQ makes heavy use of throwing. The user of this library is not required to manually try/catch exceptions, which is error prone. Instead the Monad `Try[T]` is used. +* Core concepts of Rabbit shall be represented as proper types, e.g.: + * The concepts `Queue` and `Exchange` are represented as types. + * Binding rules are explicitly modeled as types instead of opaque routing keys. +* Capture best practices around configurations of stuff like thread pools and connections. + +## How to use + +Add the library to your project via your `build.sbt`. Find the latest version in the [release notes](release-notes.md). + +```scala +libraryDependencies ++= Seq( + "cool.graph" %% "rabbit-processor-scala" % "" +) +``` + +You can import all the stuff you need like this: +```scala +import cool.graph.rabbit.Import._ +import cool.graph.rabbit.Import.Bindings._ +``` + +As all "dangerous" method calls return a `Try[T]` it is sensible to make heavy use of for comprehensions. + +### How to declare a Queue or Exchange + +At first you need to create a channel instance to interact with RabbitMQ. Once you have a Channel, you can use it to use create a `Queue` or an `Exchange`. + +```scala +for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + ... +} yield () +``` + +### How to bind a Queue to an Exchange + +To bind a `Queue` to exchange you need to call the `bindTo` method and pass an exchange. You may either pass an `Exchange` object or a `String` with the name to that method. Additionally you must pass a `Binding` object to that method. There are different ones available, but the important ones are probably `Fanout` and `RoutingKey(String)`. + +```scala +for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + _ <- queue.bindTo(exchange, FanOut) // or: channel.bindTo("some-exchange", FanOut) + ... +} yield () +``` + +If you wonder what the `_` is used for: It just means that we are not interested in the returned result of the method call. + +### How to consume from a Queue + +Install a consumer by calling the `consume` method on a `Queue`. You need to provide a function that receives a `Delivery` object, which contains the body and envelope. Once you have processed the message you need to cal `ack` or `nack` on the `Queue`. You may either pass the `Delivery` or a `Long` (the delivery tag) to `ack` and `nack`. +If the passed the consuming function throws an exception, it will be automatically reported via BugSnag. + +```scala +for { + ... + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + ... + _ <- queue.consume { delivery => + // do something with the delivery and ack afterwards + println(delivery.body) + queue.ack(delivery) + } +} yield () +``` + +### How to consume with multiple consumers + +The only difference to the previous is that you need to provide an additional argument to the `consume` method on `Queue`. If you install more than consumer you should have as many consumer threads on the channel as total consumers. Check below how the number is passed both to the `channel` and the `consume` method. + +```scala +val numberOfConsumers = 4 +for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = numberOfConsumers) + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + ... + _ <- queue.consume(numberOfConsumers){ delivery => + // do something with the delivery and ack afterwards + println(delivery.body) + queue.ack(delivery) + } +} yield () +``` + +### How to publish to an Exchange + +Publishing is actually not different. But you might want to do the setup code into a separate method so that you can call publish on the object repeatedly. You then call this method once and just call the `publish` method on the `Exchange`. + +```scala +def setupMyCustomExchange: Exchange = { + val exchange: Try[Exchange] = for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + } yield exchange + exchange match { + case Success(x) => + x + case Failure(e) => + // maybe do something to retry. A naive way could look like this: + Thread.sleep(1000) + setupMyCustomExchange + } +} +val exchange = setupMyCustomExchange +exchange.publish("routingKey", "some message") +``` + +### How to publish directly to a Queue + +```scala +def setupMyQueue: Queue = { + val queue: Try[Queue] = for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + queue <- channel.queueDeclare("my-queue", durable = false, autoDelete = true) + } yield queue + queue match { + case Success(x) => + x + case Failure(e) => + // maybe do something to retry. A naive way could look like this: + Thread.sleep(1000) + setupMyQueue + } +} +val queue = setupMyQueue +queue.publish("some message") +``` diff --git a/server/libs/rabbit-processor/build.sbt b/server/libs/rabbit-processor/build.sbt new file mode 100644 index 0000000000..e76eed29aa --- /dev/null +++ b/server/libs/rabbit-processor/build.sbt @@ -0,0 +1,10 @@ +organization := "cool.graph" +name := "rabbit-processor" + +libraryDependencies ++= Seq( + "com.rabbitmq" % "amqp-client" % "4.1.0", + "com.fasterxml.jackson.core" % "jackson-databind" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-annotations" % "2.8.4", + "com.fasterxml.jackson.core" % "jackson-core" % "2.8.4", + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % "2.8.4" +) diff --git a/server/libs/rabbit-processor/project/build.properties b/server/libs/rabbit-processor/project/build.properties new file mode 100644 index 0000000000..76270b5d74 --- /dev/null +++ b/server/libs/rabbit-processor/project/build.properties @@ -0,0 +1,4 @@ +#Activator-generated Properties +#Tue Jul 28 08:26:26 CEST 2015 +template.uuid=e17acfbb-1ff5-41f5-b8cf-2c40be6a8340 +sbt.version=0.13.8 diff --git a/server/libs/rabbit-processor/release-notes.md b/server/libs/rabbit-processor/release-notes.md new file mode 100644 index 0000000000..a8deb4a27f --- /dev/null +++ b/server/libs/rabbit-processor/release-notes.md @@ -0,0 +1,4 @@ +# Release Notes + +## 0.1.0 +* initial release \ No newline at end of file diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Consumers.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Consumers.scala new file mode 100644 index 0000000000..4d2f0d2296 --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Consumers.scala @@ -0,0 +1,22 @@ +package cool.graph.rabbit + +import com.rabbitmq.client.{AMQP, DefaultConsumer, Envelope, Channel => RabbitChannel} +import cool.graph.bugsnag.{BugSnagger, MetaData} + +import scala.util.{Failure, Try} + +case class DeliveryConsumer(channel: Channel, f: Delivery => Unit)(implicit bugsnagger: BugSnagger) extends DefaultConsumer(channel.rabbitChannel) { + + override def handleDelivery(consumerTag: String, envelope: Envelope, properties: AMQP.BasicProperties, body: Array[Byte]): Unit = { + val delivery = Delivery(body, envelope, properties) + Try { + f(delivery) + } match { + case Failure(e) => + val bodyAsString = Try(new String(body)).getOrElse("Message Bytes could not be converted into a String.") + val metaData = Seq(MetaData("Rabbit", "messageBody", bodyAsString)) + bugsnagger.report(e, metaData) + case _ => {} // NO-OP + } + } +} diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Import.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Import.scala new file mode 100644 index 0000000000..6940b82d42 --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Import.scala @@ -0,0 +1,16 @@ +package cool.graph.rabbit + +object Import { + val Rabbit = cool.graph.rabbit.Rabbit + type Channel = cool.graph.rabbit.Channel + val Channel = cool.graph.rabbit.Channel + type Queue = cool.graph.rabbit.Queue + val Queue = cool.graph.rabbit.Queue + type Exchange = cool.graph.rabbit.Exchange + val Exchange = cool.graph.rabbit.Exchange + type Consumer = cool.graph.rabbit.Consumer + val Consumer = cool.graph.rabbit.Consumer + + val ExchangeTypes = cool.graph.rabbit.ExchangeTypes + val Bindings = cool.graph.rabbit.Bindings +} diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/PlainRabbit.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/PlainRabbit.scala new file mode 100644 index 0000000000..7920724769 --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/PlainRabbit.scala @@ -0,0 +1,30 @@ +package cool.graph.rabbit + +import java.util.concurrent.{Executors, ThreadFactory} + +import scala.util.Try +import com.rabbitmq.client.{ConnectionFactory, Channel => RabbitChannel} +import cool.graph.bugsnag.BugSnagger + +object PlainRabbit { + def connect(name: String, amqpUri: String, numberOfThreads: Int, qos: Option[Int])(implicit bugSnag: BugSnagger): Try[RabbitChannel] = Try { + + val threadFactory: ThreadFactory = Utils.newNamedThreadFactory(name) + val factory = { + val f = new ConnectionFactory() + val timeout = sys.env.getOrElse("RABBIT_TIMEOUT_MS", "500").toInt + f.setUri(amqpUri) + f.setConnectionTimeout(timeout) + f.setExceptionHandler(RabbitExceptionHandler(bugSnag)) + f.setThreadFactory(threadFactory) + f.setAutomaticRecoveryEnabled(true) + f + } + val executor = Executors.newFixedThreadPool(numberOfThreads, threadFactory) + val connection = factory.newConnection(executor) + val theQos = qos.orElse(sys.env.get("RABBIT_CHANNEL_QOS").map(_.toInt)).getOrElse(500) + val chan = connection.createChannel() + chan.basicQos(theQos) + chan + } +} diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Queue.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Queue.scala new file mode 100644 index 0000000000..199b5c21b3 --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Queue.scala @@ -0,0 +1,143 @@ +package cool.graph.rabbit + +import java.nio.charset.StandardCharsets + +import com.rabbitmq.client.{Channel => RabbitChannel, Consumer => RabbitConsumer, _} +import cool.graph.bugsnag.BugSnagger +import cool.graph.rabbit.Bindings.Binding +import cool.graph.rabbit.ExchangeTypes._ + +import scala.util.Try + +object Rabbit { + def channel(name: String, amqpUri: String, consumerThreads: Int)(implicit bugSnag: BugSnagger): Try[Channel] = { + channel(name, amqpUri, consumerThreads, None) + } + + def channel(name: String, amqpUri: String, consumerThreads: Int, qos: Int)(implicit bugSnag: BugSnagger): Try[Channel] = { + channel(name, amqpUri, consumerThreads, Some(qos)) + } + + def channel(name: String, amqpUri: String, consumerThreads: Int, qos: Option[Int])(implicit bugSnag: BugSnagger): Try[Channel] = { + PlainRabbit.connect(name, amqpUri, consumerThreads, qos).map { channel => + Channel(channel) + } + } +} + +case class Channel(rabbitChannel: RabbitChannel) { + def queueDeclare(name: String, randomizeName: Boolean = false, durable: Boolean, autoDelete: Boolean): Try[Queue] = + Try { + val exclusive = false + val queueName = if (randomizeName) { + s"$name-" + Utils.timestampWithRandom + } else { + name + } + rabbitChannel.queueDeclare(queueName, durable, exclusive, autoDelete, null) + Queue(queueName, this) + } + + def exchangeDeclare(name: String, durable: Boolean, autoDelete: Boolean = false, confirm: Boolean = false): Try[Exchange] = Try { + import collection.JavaConversions.mapAsJavaMap + val internal = false + rabbitChannel + .exchangeDeclare(name, BuiltinExchangeType.TOPIC, durable, autoDelete, mapAsJavaMap(Map.empty[String, Object])) + if (confirm) { + rabbitChannel.confirmSelect() + } + Exchange(name, this) + } + + def close(alsoCloseConnection: Boolean = true): Try[Unit] = Try { + rabbitChannel.close + if (alsoCloseConnection) { + rabbitChannel.getConnection.close + } + } +} + +case class Queue(name: String, channel: Channel) { + val rabbitChannel: RabbitChannel = channel.rabbitChannel + + def bindTo(exchange: Exchange, binding: Binding): Try[Unit] = bindTo(exchange.name, binding) + + def bindTo(exchangeName: String, binding: Binding): Try[Unit] = Try { + rabbitChannel.queueBind(name, exchangeName, binding.routingKey) + } + + def consume(f: Delivery => Unit)(implicit bugSnag: BugSnagger): Try[Consumer] = consume(1)(f).map(_.head) + + def consume(numberOfConsumers: Int = 1)(f: Delivery => Unit)(implicit bugSnag: BugSnagger): Try[Seq[Consumer]] = + Try { + (1 to numberOfConsumers).map { _ => + consume(DeliveryConsumer(channel, f)).get // get the result so we get the exception if something fails + } + } + def consume(consumer: RabbitConsumer): Try[Consumer] = Try { + val autoAck = false + val consumerTag = s"$name-queue-" + Utils.timestampWithRandom + rabbitChannel.basicConsume(name, autoAck, consumerTag, consumer) + Consumer(consumerTag, channel) + } + + def publish(body: String): Unit = publish(body.getBytes) + def publish(body: Array[Byte]): Unit = rabbitChannel.basicPublish("", name, null, body) + + def ack(delivery: Delivery): Unit = ack(delivery.envelope.getDeliveryTag) + def ack(deliveryTag: Long): Unit = { + val multiple = false + rabbitChannel.basicAck(deliveryTag, multiple) + } + + def nack(delivery: Delivery, requeue: Boolean): Unit = nack(delivery.envelope.getDeliveryTag, requeue) + def nack(deliveryTag: Long, requeue: Boolean): Unit = { + val multiple = false + rabbitChannel.basicNack(deliveryTag, multiple, requeue) + } +} +case class Delivery(body: Array[Byte], envelope: Envelope, properties: AMQP.BasicProperties) { + lazy val bodyAsString: String = new String(body, StandardCharsets.UTF_8) +} +case class Consumer(consumerTag: String, channel: Channel) { + val rabbitChannel = channel.rabbitChannel + + def unsubscribe: Try[Unit] = Try { + rabbitChannel.basicCancel(consumerTag) + } +} + +case class Exchange(name: String, channel: Channel) { + val rabbitChannel = channel.rabbitChannel + + def publish(routingKey: String, body: String): Unit = { + val bytes: Array[Byte] = body.getBytes + this.publish(routingKey, bytes) + } + + def publish(routingKey: String, body: Array[Byte]): Unit = { + rabbitChannel.basicPublish(name, routingKey, null, body) + } +} + +object ExchangeTypes { + sealed trait ExchangeType { + def rabbitTypeString: String + } + object Topic extends ExchangeType { + def rabbitTypeString = "topic" + } +} + +object Bindings { + // "The binding key must also be in the same form. The logic behind the topic exchange is similar to a direct one - a message sent with a particular routing key will be delivered to all the queues that are bound with a matching binding key." + // * (star) can substitute for exactly one word. + // # (hash) can substitute for zero or more words. + sealed trait Binding { + def routingKey: String + } + case class RoutingKey(routingKey: String) extends Binding + case object FanOut extends Binding { + override def routingKey = "#" + } +} diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/RabbitExceptionHandler.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/RabbitExceptionHandler.scala new file mode 100644 index 0000000000..4ad4c08f75 --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/RabbitExceptionHandler.scala @@ -0,0 +1,64 @@ +package cool.graph.rabbit + +import com.rabbitmq.client.impl.DefaultExceptionHandler +import com.rabbitmq.client.{Connection, TopologyRecoveryException, Channel => RabbitChannel, Consumer => RabbitConsumer} +import cool.graph.bugsnag.BugSnagger + +case class RabbitExceptionHandler(bugSnag: BugSnagger) extends DefaultExceptionHandler { + + override def handleConsumerException(channel: RabbitChannel, exception: Throwable, consumer: RabbitConsumer, consumerTag: String, methodName: String): Unit = { + + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleConsumerException", exception)) + super.handleConsumerException(channel, exception, consumer, consumerTag, methodName) + } + + override def handleUnexpectedConnectionDriverException(conn: Connection, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleUnexpectedConnectionDriverException", exception)) + super.handleUnexpectedConnectionDriverException(conn, exception) + } + + override def handleBlockedListenerException(connection: Connection, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleBlockedListenerException", exception)) + super.handleBlockedListenerException(connection, exception) + } + + override def handleChannelRecoveryException(ch: RabbitChannel, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleChannelRecoveryException", exception)) + super.handleChannelRecoveryException(ch, exception) + } + + override def handleFlowListenerException(channel: RabbitChannel, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleFlowListenerException", exception)) + super.handleFlowListenerException(channel, exception) + } + + override def handleReturnListenerException(channel: RabbitChannel, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleReturnListenerException", exception)) + super.handleReturnListenerException(channel, exception) + } + + override def handleTopologyRecoveryException(conn: Connection, ch: RabbitChannel, exception: TopologyRecoveryException): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleTopologyRecoveryException", exception)) + super.handleTopologyRecoveryException(conn, ch, exception) + } + + override def handleConfirmListenerException(channel: RabbitChannel, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleConfirmListenerException", exception)) + super.handleConfirmListenerException(channel, exception) + } + + override def handleConnectionRecoveryException(conn: Connection, exception: Throwable): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleConnectionRecoveryException", exception)) + super.handleConnectionRecoveryException(conn, exception) + } + + override def handleChannelKiller(channel: RabbitChannel, exception: Throwable, what: String): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleChannelKiller", exception)) + super.handleChannelKiller(channel, exception, what) + } + + override def handleConnectionKiller(connection: Connection, exception: Throwable, what: String): Unit = { + bugSnag.report(new RuntimeException("Rabbit error occurred. -> handleConnectionKiller", exception)) + super.handleConnectionKiller(connection, exception, what) + } +} diff --git a/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Utils.scala b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Utils.scala new file mode 100644 index 0000000000..eb098e9cfb --- /dev/null +++ b/server/libs/rabbit-processor/src/main/scala/cool/graph/rabbit/Utils.scala @@ -0,0 +1,27 @@ +package cool.graph.rabbit + +import java.text.SimpleDateFormat +import java.util.{Date, UUID} +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicLong + +object Utils { + def timestamp: String = { + val formatter = new SimpleDateFormat("HH:mm:ss.SSS-dd.MM.yyyy") + val now = new Date() + formatter.format(now) + } + + def timestampWithRandom: String = timestamp + "-" + UUID.randomUUID() + + def newNamedThreadFactory(name: String): ThreadFactory = new ThreadFactory { + val count = new AtomicLong(0) + + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable) + thread.setName(s"$name-" + count.getAndIncrement) + thread.setDaemon(true) + thread + } + } +} diff --git a/server/libs/rabbit-processor/src/test/scala/cool/graph/package/CompileSpec.scala b/server/libs/rabbit-processor/src/test/scala/cool/graph/package/CompileSpec.scala new file mode 100644 index 0000000000..305f8351b9 --- /dev/null +++ b/server/libs/rabbit-processor/src/test/scala/cool/graph/package/CompileSpec.scala @@ -0,0 +1,78 @@ +package cool.graph + +import cool.graph.bugsnag.BugSnaggerMock + +import scala.util.{Failure, Success, Try} + +object CompileSpec { + import cool.graph.rabbit.Import._ + import cool.graph.rabbit.Import.ExchangeTypes._ + import cool.graph.rabbit.Import.Bindings._ + + implicit val bugsnag = BugSnaggerMock + val amqpUri = "amqp://localhost" + val queueName = "some-name" + + // Consume with 1 consumer + for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + _ <- queue.bindTo(exchange, FanOut) + _ <- queue.consume { delivery => + // do something with the delivery and ack afterwards + println(delivery.body) + queue.ack(delivery) + } + } yield () + + // Consume with multiple consumers + val numberOfConsumers = 4 + for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = numberOfConsumers) + queue <- channel.queueDeclare(queueName, durable = false, autoDelete = true) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + _ <- queue.bindTo(exchange, Bindings.FanOut) + _ <- queue.consume(numberOfConsumers) { delivery => + // do something with the delivery and ack afterwards + println(delivery.body) + queue.ack(delivery) + } + } yield () + + // Publishing + def setupMyCustomExchange: Exchange = { + val exchange: Try[Exchange] = for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + exchange <- channel.exchangeDeclare("some-exchange", durable = false) + } yield exchange + exchange match { + case Success(x) => + x + case Failure(e) => + // maybe do something to retry. A naive way could look like this: + Thread.sleep(1000) + setupMyCustomExchange + } + } + val exchange = setupMyCustomExchange + exchange.publish("routingKey", "some message") + + // Publish to a Queue + def setupMyQueue: Queue = { + val queue: Try[Queue] = for { + channel <- Rabbit.channel(queueName, amqpUri, consumerThreads = 1) + queue <- channel.queueDeclare("my-queue", durable = false, autoDelete = true) + } yield queue + queue match { + case Success(x) => + x + case Failure(e) => + // maybe do something to retry. A naive way could look like this: + Thread.sleep(1000) + setupMyQueue + } + } + val queue = setupMyQueue + queue.publish("some message") +} diff --git a/server/libs/rabbit-processor/version.sbt b/server/libs/rabbit-processor/version.sbt new file mode 100644 index 0000000000..aab7f9b814 --- /dev/null +++ b/server/libs/rabbit-processor/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1.0-SNAPSHOT" \ No newline at end of file diff --git a/server/libs/scala-utils/project/build.properties b/server/libs/scala-utils/project/build.properties new file mode 100644 index 0000000000..c091b86ca4 --- /dev/null +++ b/server/libs/scala-utils/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.16 diff --git a/server/libs/scala-utils/src/main/scala/cool/graph/utils/future/FutureUtils.scala b/server/libs/scala-utils/src/main/scala/cool/graph/utils/future/FutureUtils.scala new file mode 100644 index 0000000000..95525fcd8a --- /dev/null +++ b/server/libs/scala-utils/src/main/scala/cool/graph/utils/future/FutureUtils.scala @@ -0,0 +1,79 @@ +package cool.graph.utils.future + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.util.{Failure, Success, Try} + +object FutureUtils { + + /** + * Executes callbacks for either failure or success after a future completes, and _after those complete_ will return + * the original future, which either contains the successful value or the error it failed with in the first place. + * If the futures returned by the callbacks fail, those are returned instead. + */ + implicit class FutureChainer[A](val f: Future[A]) extends AnyVal { + def andThenFuture(handleSuccess: A => Future[_], handleFailure: Throwable => Future[_])(implicit executor: ExecutionContext): Future[A] = { + for { + _ <- f.toFutureTry.flatMap { + case Success(x) => handleSuccess(x) + case Failure(e) => handleFailure(e) + } + r <- f + } yield r + } + } + + /** + * Ensures that a list of () => Future[T] ("deferred future") is run / called and completed sequentially. + * Returns a future containing a list of the result values of all futures. + */ + implicit class DeferredFutureCollectionExtensions[T](val futures: List[() => Future[T]]) extends AnyVal { + + def runSequentially(implicit executor: ExecutionContext): Future[List[T]] = { + val accumulator = Future.successful(List.empty[T]) + + futures.foldLeft(accumulator)((prevFutures, nextFuture) => { + for { + list <- prevFutures + next <- nextFuture() + } yield list :+ next + }) + } + + def runInChunksOf(maxParallelism: Int)(implicit executor: ExecutionContext): Future[List[T]] = { + require(maxParallelism >= 1, "parallelism must be >= 1") + futures match { + case Nil => + Future.successful(List.empty) + + case _ => + val (firstFutures, nextFutures) = futures.splitAt(maxParallelism) + val firstFuturesTriggered = Future.sequence(firstFutures.map(fn => fn())) + + for { + firstResults <- firstFuturesTriggered + nextResults <- nextFutures.runInChunksOf(maxParallelism) + } yield { + firstResults ++ nextResults + } + } + } + } + + /** + * Maps a completed future to a successful future containing a try which can then be handled in a subsequent calls. + * For example for handling expected errors: + * + * ftr.toFutureTry.flatMap { + * case Success(x) => Future.successful(x) + * case Failure(e) => Future.successful(someFallbackValue) + * } + */ + implicit class FutureExtensions[T](val future: Future[T]) extends AnyVal { + def toFutureTry(implicit ec: ExecutionContext): Future[Try[T]] = { + val promise = Promise[Try[T]] + + future.onComplete(promise.success) + promise.future + } + } +} diff --git a/server/libs/scala-utils/src/main/scala/cool/graph/utils/try/TryExtensions.scala b/server/libs/scala-utils/src/main/scala/cool/graph/utils/try/TryExtensions.scala new file mode 100644 index 0000000000..fb3973b354 --- /dev/null +++ b/server/libs/scala-utils/src/main/scala/cool/graph/utils/try/TryExtensions.scala @@ -0,0 +1,25 @@ +package cool.graph.utils.`try` + +import scala.concurrent.Future +import scala.util.{Failure, Success, Try} + +object TryExtensions { + implicit class TryExtensions[T](val theTry: Try[T]) extends AnyVal { + def toFuture: Future[T] = theTry match { + case Success(value) => Future.successful(value) + case Failure(exception) => Future.failed(exception) + } + } +} + +object TryUtil { + def sequence[T](trys: Vector[Try[T]]): Try[Vector[T]] = { + val successes = trys.collect { case Success(x) => x } + val failures = trys.collect { case f @ Failure(_) => f } + if (successes.length == trys.length) { + Success(successes) + } else { + failures.head.asInstanceOf[Try[Vector[T]]] + } + } +} diff --git a/server/libs/scala-utils/src/test/scala/cool/graph/utils/future/FutureUtilSpec.scala b/server/libs/scala-utils/src/test/scala/cool/graph/utils/future/FutureUtilSpec.scala new file mode 100644 index 0000000000..4c69da23d8 --- /dev/null +++ b/server/libs/scala-utils/src/test/scala/cool/graph/utils/future/FutureUtilSpec.scala @@ -0,0 +1,52 @@ +package cool.graph.utils.future + +import org.scalatest.{Matchers, WordSpec} +import cool.graph.utils.future.FutureUtils._ +import org.scalatest.concurrent.ScalaFutures._ +import org.scalatest.time.{Millis, Seconds, Span} +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class FutureUtilSpec extends WordSpec with Matchers { + val patienceConfig = PatienceConfig(timeout = Span(5, Seconds), interval = Span(5, Millis)) + + "runSequentially" should { + "run all given futures in sequence" in { + + val testList = List[() => Future[Long]]( + () => { Thread.sleep(500); Future.successful(System.currentTimeMillis()) }, + () => { Thread.sleep(250); Future.successful(System.currentTimeMillis()) }, + () => { Thread.sleep(100); Future.successful(System.currentTimeMillis()) } + ) + + val values: Seq[Long] = testList.runSequentially.futureValue(patienceConfig) + (values, values.tail).zipped.forall((a, b) => a < b) + } + } + + "andThenFuture" should { + + "Should work correctly in error and success cases" in { + val f1 = Future.successful(100) + val f2 = Future.failed(new Exception("This is a test")) + + whenReady( + f1.andThenFuture( + handleSuccess = x => Future.successful("something"), + handleFailure = e => Future.successful("another something") + )) { res => + res should be(100) + } + + whenReady( + f2.andThenFuture( + handleSuccess = (x: Int) => Future.successful("something"), + handleFailure = e => Future.successful("another something") + ) + .failed) { res => + res shouldBe a[Exception] + } + } + } + +} diff --git a/server/libs/stub-server/.gitignore b/server/libs/stub-server/.gitignore new file mode 100644 index 0000000000..639e8c3bee --- /dev/null +++ b/server/libs/stub-server/.gitignore @@ -0,0 +1,7 @@ +/RUNNING_PID +/logs/ +/project/*-shim.sbt +/project/project/ +/project/target/ +/target/ +.idea diff --git a/server/libs/stub-server/README.md b/server/libs/stub-server/README.md new file mode 100644 index 0000000000..bf026c5548 --- /dev/null +++ b/server/libs/stub-server/README.md @@ -0,0 +1,99 @@ +## stub-server + +This library is supposed to help you when writing tests for an application. It allows you to write your tests in a black box fashion. The idea is to boot up your full application in a Test and then run your Tests against the HTTP interface. As our applications are usually very small, this should not be too complicated. The problem is that your application will talk to other Services to do its work (see left picture below). + +This is where the `stub-server` lib is going to help you. You can start the StubServer inside your Test and have it serve content you specify. So you can provide stubs for other services for your Test! + +
+                                                                           
+        without stub-server                       with stub-server
+                                                                           
+          ┌───────────────┐                       ┌───────────────┐        
+          │   Your Test   │                       │   Your Test   │        
+          └───────────────┘                       └───────────────┘        
+                  │                                       │                
+                  ▼                                       ▼                
+     ┌─────────────────────────┐             ┌─────────────────────────┐   
+     │                         │             │                         │   
+     │       Application       │             │       Application       │   
+     │                         │             │                         │   
+     └─────────────────────────┘             └─────────────────────────┘   
+                  │                                       │                
+       ┌──────────┴────────┐                              │                
+       ▼                   ▼                              ▼                
+┌─────────────┐     ┌─────────────┐          ┌────────────────────────┐    
+│  Service 1  │     │  Service 2  │          │      stub-server       │    
+└─────────────┘     └─────────────┘          └────────────────────────┘    
+
+ +### How to setup + +The library is inside our central `libs` directory. Just add add a dependency from your project to the `stub-server` in the root `build.sbt` of the project. You may not need to do this, because the lib is included by `backend-shared`. + +```scala +lazy val myProject = + Project(id = "my-project", base = file("./my-project")) + .dependsOn(stubServer % "compile") +``` + +And add the following import to your test: +```scala +import cool.graph.stub.Import._ // this import is all you need +```` + +### How to suppress verbose log output +We use the Jetty Server underneath. This server has the super annoying property to configure it's logging via class loading magic. This leads to very verbose logging in Play projects. Here's the fix: + +1. Place a file `jetty-logging.properties` in the `src/test/resources` or `src/main/resources` folder of your project. +2. Add the line `org.eclipse.jetty.util.log.class=cool.graph.stub.JustWarningsLogger` to this file. + +### How to use + +In order to configure the stub server you need to configure the stubs. A Stub declares what to return on a matching request. The following line declares a stub for a `GET` call for the URl path `/path`. All calls to the stub server at the `/path` with the `GET` Http method will be answered by this stub. The stub specifies that the answer should have a status code of 200 and an empty JSON object as answer. + +```scala +val myStub = Request("GET", "/path").stub(200, "{}") +``` + +You probably need to create multiple stubs for your tests. You can then use `withStubServer` to boot up a stub server: + +```scala +withStubServer(List(myStub)) { // configure the stub server + // in this block make sure all your HTTP calls are sent to the stub server + // if a stub matches the request, its configured response is returned + // if no stub matches, a 999 is returned +} +``` + +### Details on Stub Matching + +When a request hits the stub server, it has to decide with which stub to respond. There may be multiple matching stubs or none at all. The matching algorithm works like this: + +1. A stub has to exactly match the Http method, path and body of the request, otherwise it is removed from the list of candidates. +2. From the list of candidates the stub is selected like this: + 1. If the list of candidates list is empty, respond with a 999 and message that no stub was found for this request. + 2. If there's exactly one candidate left, use this stub. + 3. If there is more than one candidate left, use the query parameters to rank them. A stub may include query parameters. For each stub parameter matching a parameter in the request, the stub is awarded one point rank score. The stub with highest ranking score (== greatest number of matching parameters) is chosen. + +Consider the following example: + +```scala +val myStub = Request("GET", "/path").stub(200, "{}") +val myStubWithParams = Request("GET", "/path", Map("param1" -> "foo").stub(500, "{}") + +withStubServer(List(myStub,myStubWithParams)) { + // in this block a call to /path?param1=foo will result in a 500, as this matches the 2nd stub + // other calls to this endpoint will result in a 200 +} +``` + +Sometimes you do want to deactivate the matching on request bodies, you can this by calling `ignoreBody`: +```scala +val myStub = Request("POST", "/path").stub(200, "{}").ignoreBody +``` + +#### Important hint on debugging + +It might be necessary to do some debugging of the Stubs. This might be necessary because you forget to stub one call your app is making. Or you are relying on details of the more sophisticated Stub Matching rules mentioned above. So you want to know why the stub server returned a 999. + +**Therefore it is a good idea to print the responses that you are getting from the stub server.** If something fails, the body will include the response from the stub server, which will tell you more details. In the example below if one of the calls to the stub server fails, the response body will contain something like: `{ "message": "Stub not found for request [URL: /some-path?param1=value1] [METHOD: POST]" }`. This will give you quick insight on what stub you might have missed to declare. diff --git a/server/libs/stub-server/build.sbt b/server/libs/stub-server/build.sbt new file mode 100644 index 0000000000..7a76f4b7a1 --- /dev/null +++ b/server/libs/stub-server/build.sbt @@ -0,0 +1,20 @@ +organization := "cool.graph" +name := """stub-server""" + +scalaVersion := "2.11.6" + +// Change this to another test framework if you prefer +libraryDependencies ++= Seq( + "org.eclipse.jetty" % "jetty-server" % "9.3.0.v20150612", + "com.netaporter" %% "scala-uri" % "0.4.16", + "org.scala-lang.modules" %% "scala-parser-combinators" % "1.0.4", + "org.scalaj" %% "scalaj-http" % "1.1.4" % "test", + "org.scalatest" %% "scalatest" % "2.2.4" % "test", + "org.specs2" %% "specs2-core" % "3.6.1" % "test" +) + +resolvers += "scalaz-bintray" at "http://dl.bintray.com/scalaz/releases" + +parallelExecution in Test := false + +scalacOptions in Test ++= Seq("-Yrangepos") diff --git a/server/libs/stub-server/project/build.properties b/server/libs/stub-server/project/build.properties new file mode 100644 index 0000000000..5f04f6c936 --- /dev/null +++ b/server/libs/stub-server/project/build.properties @@ -0,0 +1,4 @@ +#Activator-generated Properties +#Wed Jun 17 10:54:41 CEST 2015 +template.uuid=7faf8e1e-4e8d-4387-8159-642b50383096 +sbt.version=0.13.13 diff --git a/server/libs/stub-server/release-notes.md b/server/libs/stub-server/release-notes.md new file mode 100644 index 0000000000..a8deb4a27f --- /dev/null +++ b/server/libs/stub-server/release-notes.md @@ -0,0 +1,4 @@ +# Release Notes + +## 0.1.0 +* initial release \ No newline at end of file diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/JustWarningsLogger.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/JustWarningsLogger.scala new file mode 100644 index 0000000000..d851559198 --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/JustWarningsLogger.scala @@ -0,0 +1,42 @@ +package cool.graph.stub + +import org.eclipse.jetty.util.log.Logger + +class JustWarningsLogger extends Logger { + override def warn(msg: String, args: AnyRef*): Unit = { + println(msg, args) + } + + override def warn(thrown: Throwable): Unit = { + thrown.printStackTrace + } + + override def warn(msg: String, thrown: Throwable): Unit = { + println(msg) + thrown.printStackTrace() + } + + override def getName: String = { "" } + + override def isDebugEnabled: Boolean = { false } + + override def getLogger(name: String): Logger = { this } + + override def ignore(ignored: Throwable): Unit = {} + + override def debug(msg: String, args: AnyRef*): Unit = {} + + override def debug(msg: String, value: Long): Unit = {} + + override def debug(thrown: Throwable): Unit = {} + + override def debug(msg: String, thrown: Throwable): Unit = {} + + override def setDebugEnabled(enabled: Boolean): Unit = {} + + override def info(msg: String, args: AnyRef*): Unit = {} + + override def info(thrown: Throwable): Unit = {} + + override def info(msg: String, thrown: Throwable): Unit = {} +} diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/QueryString.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/QueryString.scala new file mode 100644 index 0000000000..4de318d8a3 --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/QueryString.scala @@ -0,0 +1,28 @@ +package cool.graph.stub + +import com.netaporter.uri.Uri + +import scala.collection.SortedMap + +object QueryString { + def queryStringToMap(asNullableString: String): Map[String, String] = { + Option(asNullableString) match { + case Some(string) => + Map(Uri.parse(s"?$string").query.params: _*).filterKeys(_.trim != "").mapValues(_.getOrElse("")) + case None => + Map.empty + } + + } + + def queryMapToString(queryMap: Map[String, Any]): String = { + queryMap.isEmpty match { + case false => "?" + queryMap.map { case (k, v) => s"$k=$v" }.mkString("&") + case true => "" + } + } + + def mapToSortedMap(map: Map[String, Any]): SortedMap[String, Any] = { + SortedMap(map.toSeq: _*) + } +} diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/SpecHelper.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/SpecHelper.scala new file mode 100644 index 0000000000..aaf550295f --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/SpecHelper.scala @@ -0,0 +1,38 @@ +package cool.graph.stub + +import scala.util.Random + +object Import { + val Request = StubDsl.Default.Request + + def withStubServer[T](stubs: List[Stub], port: Int = 8000 + Random.nextInt(1000), stubNotFoundStatusCode: Int = 999): WithStubServer = { + WithStubServer(stubs, port, stubNotFoundStatusCode) + } + + case class WithStubServer(stubs: List[Stub], port: Int, stubNotFoundStatusCode: Int) { + def apply[T](block: => T): T = { + withArg { stubServer => + block + } + } + + def withArg[T](block: StubServer => T): T = { + val stubServer = StubServer(stubs, port, stubNotFoundStatusCode) + // We need to synchronize as the following block contains mutating global state - the Java System Properties + // In case two tests run in parallel - one test might end up talking to the wrong port. + "stub-server".intern.synchronized { + try { + // These sys props expose required information to the tests + sys.props += "STUB_SERVER_RUNNING" -> "true" + sys.props += "STUB_SERVER_PORT" -> port.toString + stubServer.start + block(stubServer) + } finally { + sys.props -= "STUB_SERVER_RUNNING" + sys.props -= "STUB_SERVER_PORT" + stubServer.stop + } + } + } + } +} diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/Stub.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/Stub.scala new file mode 100644 index 0000000000..3c42ba8801 --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/Stub.scala @@ -0,0 +1,106 @@ +package cool.graph.stub + +import java.util.function.BinaryOperator +import javax.servlet.http.HttpServletRequest + +import scala.collection.SortedMap + +trait RequestLike { + def httpMethod: String + def path: String + def queryMap: Map[String, Any] + def body: String + + val querySortedMap: SortedMap[String, Any] = QueryString.mapToSortedMap(queryMap) + val queryString: String = QueryString.queryMapToString(queryMap) + + def isPostOrPatch: Boolean = httpMethod.equalsIgnoreCase("POST") || httpMethod.equalsIgnoreCase("PATCH") +} + +case class Stub( + httpMethod: String, + path: String, + queryMap: Map[String, Any], + body: String, + stubbedResponse: StubResponse, + shouldCheckBody: Boolean = true +) extends RequestLike { + def ignoreBody: Stub = copy(shouldCheckBody = false) +} + +trait StubResponse { + def getStaticStubResponse(stubRequest: StubRequest): StaticStubResponse + + def status: Int + def body: String + def headers: Map[String, String] +} + +case class StaticStubResponse(status: Int, body: String, headers: Map[String, String] = Map.empty) extends StubResponse { + override def getStaticStubResponse(stubRequest: StubRequest): StaticStubResponse = this +} +case class DynamicStubResponse(fn: (StubRequest) => StaticStubResponse) extends StubResponse { + + def getStaticStubResponse(stubRequest: StubRequest) = fn(stubRequest) + + override def status: Int = sys.error("not implemented... please use getStaticStubResponse") + override def body: String = sys.error("not implemented... please use getStaticStubResponse") + override def headers: Map[String, String] = sys.error("not implemented... please use getStaticStubResponse") +} + +object JavaServletRequest { + def body(request: HttpServletRequest): String = { + val reduceFn = new BinaryOperator[String] { + override def apply(acc: String, actual: String): String = acc + actual + } + request.getReader().lines().reduce("", reduceFn) + } + + def headers(request: HttpServletRequest): Map[String, String] = { + import scala.collection.mutable + val map: mutable.Map[String, String] = mutable.Map.empty + val headerNames = request.getHeaderNames; + + while (headerNames.hasMoreElements) { + val key = Option(headerNames.nextElement()); + val value = for { + k <- key + v <- Option(request.getHeader(k)) + } yield v + + (key, value) match { + case (Some(k), Some(v)) => map.put(k, v); + case _ => () + } + } + map.toMap + } +} + +object StubRequest { + def fromHttpRequest(servletRequest: HttpServletRequest): StubRequest = { + val body = JavaServletRequest.body(servletRequest) + StubRequest( + servletRequest.getMethod, + servletRequest.getPathInfo, + QueryString.queryStringToMap(servletRequest.getQueryString), + body, + JavaServletRequest.headers(servletRequest) + ) + } + + def apply( + httpMethod: String, + path: String, + queryMap: Map[String, Any], + body: String + ): StubRequest = StubRequest(httpMethod, path, queryMap, body, Map.empty) +} + +case class StubRequest( + httpMethod: String, + path: String, + queryMap: Map[String, Any], + body: String, + headers: Map[String, String] +) extends RequestLike diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/StubDsl.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubDsl.scala new file mode 100644 index 0000000000..48bbeb7555 --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubDsl.scala @@ -0,0 +1,30 @@ +package cool.graph.stub + +object StubDsl { + object Default { + object Request { + def apply(httpMethod: String, pathAndQuery: String): Request = withBody(httpMethod, pathAndQuery, "") + + def withBody(httpMethod: String, pathAndQuery: String, body: String): Request = { + val path = pathAndQuery.takeWhile(_ != '?') + val queryString = pathAndQuery.dropWhile(_ != '?').drop(1) + val queryParams = QueryString.queryStringToMap(queryString) + Request(httpMethod, path, queryParams, body) + } + } + + case class Request(httpMethod: String, path: String, queryParams: Map[String, Any] = Map.empty, body: String = "") { + def stub(response: StubResponse): Stub = { + Stub(httpMethod, path, queryParams, body, response) + } + + def stub(status: Int, body: String, headers: Map[String, String] = Map.empty): Stub = { + stub(StaticStubResponse(status, body, headers)) + } + + def stub(fn: (StubRequest) => StaticStubResponse): Stub = { + stub(DynamicStubResponse(fn)) + } + } + } +} diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/StubMatching.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubMatching.scala new file mode 100644 index 0000000000..54355e97bb --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubMatching.scala @@ -0,0 +1,136 @@ +package cool.graph.stub + +import scala.collection.SortedMap +import scala.util.parsing.json.{JSONType, JSON} + +object StubMatching { + sealed trait MatchResult { + def isMatch: Boolean + def rank: Int + def stub: Stub + def noMatchMessage: String + } + case class Match(rank: Int, stub: Stub) extends MatchResult { + def isMatch = rank > 0 + + override def noMatchMessage: String = throw new NoSuchElementException("Match.noMatchMessage") + } + case class DoesNotMatch(rank: Int, reason: NoMatchReason) extends MatchResult { + def isMatch = false + def stub = throw new NoSuchElementException("DoesNotMatch.stub") + + override def noMatchMessage: String = reason.message + } + trait NoMatchReason { + def message: String + } + case class MethodDoesNotMatch(stub: Stub, request: StubRequest) extends NoMatchReason { + override def message: String = s"expected request method [${stub.httpMethod}], but got: [${request.httpMethod}}]" + } + case class PathDoesNotMatch(stub: Stub, request: StubRequest) extends NoMatchReason { + override def message: String = s"expected request path [${stub.path}], but got: [${request.path}]" + } + case class QueryStringDoesNotMatch(missingParams: SortedMap[String, Any]) extends NoMatchReason { + override def message: String = s"request is missing the following params ${missingParams.toList}" + } + case class BodyDoesNotMatch(stub: Stub, request: StubRequest) extends NoMatchReason { + override def message: String = s"expected request body [${stub.body}], but got: [${request.body}]" + } + + def matchStubs(stubRequest: StubRequest, stubs: List[Stub]): List[MatchResult] = { + val sortedCandidates: List[MatchResult] = stubs + .map { stub => + StubMatching.matchStub(stub, stubRequest) + } + .sortBy(_.rank) + .reverse + sortedCandidates + } + + def matchStub(stub: Stub, request: StubRequest): MatchResult = { + val methodMatches = doesMethodMatch(stub, request) + val pathMatches = doesPathMatch(stub, request) + val queryParamsMatch = doesQueryStringMatch(stub, request) + val bodyMatches = doesBodyMatch(stub, request) + val matches = List(methodMatches, pathMatches, queryParamsMatch, bodyMatches) + + val minimalRequirements = matches.forall(_.isLeft) + val score = matches.map(_.left.getOrElse(0)).sum + + if (minimalRequirements) { + Match(rank = score, stub = stub) + } else { + val firstNoMatchReason = matches.find(_.isRight).get.right.get + DoesNotMatch(rank = score, reason = firstNoMatchReason) + } + } + + def doesMethodMatch(stub: Stub, request: StubRequest): Either[Int, MethodDoesNotMatch] = { + if (stub.httpMethod.equalsIgnoreCase(request.httpMethod)) { + Left(1) + } else { + Right(MethodDoesNotMatch(stub, request)) + } + } + + def doesPathMatch(stub: Stub, request: StubRequest): Either[Int, PathDoesNotMatch] = { + if (stub.path.equalsIgnoreCase(request.path)) { + Left(1) + } else { + Right(PathDoesNotMatch(stub, request)) + } + } + + def doesQueryStringMatch(stub: Stub, request: StubRequest): Either[Int, QueryStringDoesNotMatch] = { + val score = request.queryMap.foldLeft(0) { + case (acc, requestQueryParam) => + val stubContainsQueryParam = queryContainsPair(stub.querySortedMap, requestQueryParam) + if (stubContainsQueryParam) { + acc + 1 + } else { + acc + } + } + if (requestContainsAllStubParams(stub, request)) { + Left(score) + } else { + val missingParams = stub.querySortedMap -- request.querySortedMap.keys + Right(QueryStringDoesNotMatch(missingParams)) + } + } + + def requestContainsAllStubParams(stub: Stub, request: StubRequest): Boolean = { + stub.queryMap.forall { stubQueryParam => + queryContainsPair(request.querySortedMap, stubQueryParam) + } + } + + def queryContainsPair(queryPairs: Iterable[(String, Any)], testPair: (String, Any)): Boolean = { + queryPairs.exists { currentPair => + currentPair.toString == testPair.toString + } + } + + def doesBodyMatch(stub: Stub, request: StubRequest): Either[Int, BodyDoesNotMatch] = { + request.isPostOrPatch && stub.shouldCheckBody match { + case true => + val simpleEquals = request.body == stub.body + lazy val jsonEquals = { + val requestJson: Option[JSONType] = JSON.parseRaw(request.body) + val stubJson: Option[JSONType] = JSON.parseRaw(stub.body) + + (requestJson, stubJson) match { + case (Some(a), Some(b)) => a == b + case _ => false + } + } + if (simpleEquals || jsonEquals) { + Left(1) + } else { + Right(BodyDoesNotMatch(stub, request)) + } + case _ => // We only check the body if it's a post or patch + Left(0) + } + } +} diff --git a/server/libs/stub-server/src/main/scala/cool/graph/stub/StubServer.scala b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubServer.scala new file mode 100644 index 0000000000..cae9747ff8 --- /dev/null +++ b/server/libs/stub-server/src/main/scala/cool/graph/stub/StubServer.scala @@ -0,0 +1,87 @@ +package cool.graph.stub + +import javax.servlet.http.{HttpServletRequest, HttpServletResponse} + +import cool.graph.stub.StubMatching.MatchResult +import org.eclipse.jetty.server.handler.AbstractHandler +import org.eclipse.jetty.server.{Server, Request => JettyRequest} + +import scala.collection.mutable + +case class StubServer(stubs: List[Stub], port: Int, stubNotFoundStatusCode: Int) { + org.eclipse.jetty.util.log.Log.setLog(new JustWarningsLogger) + val server = new Server(port) + def createHandler = StubServerHandler(stubs, stubNotFoundStatusCode) + + def start: Unit = server.start + def stop: Unit = server.stop + + def requests = handler.requests + def lastRequest = handler.requests.head + def lastRequest(path: String) = handler.requests.filter(_.path == path).head + def requestCount(stub: Stub): Int = handler.requestCount(stub) + val handler = createHandler + server.setHandler(handler) +} + +case class StubServerHandler(stubs: List[Stub], stubNotFoundStatusCode: Int) extends AbstractHandler { + var requests: List[StubRequest] = List() + + def handle(target: String, baseRequest: JettyRequest, servletRequest: HttpServletRequest, response: HttpServletResponse): Unit = { + val stubResponse = try { + val stubRequest = StubRequest.fromHttpRequest(servletRequest) + requests = stubRequest :: requests + stubResponseForRequest(stubRequest) + } catch { + case e: Throwable => failedResponse(e) + } + response.setContentType("application/json") + response.setStatus(stubResponse.status) + stubResponse.headers.foreach(kv => response.setHeader(kv._1, kv._2)) + response.getWriter.print(stubResponse.body) + baseRequest.setHandled(true) + } + + def stubResponseForRequest(stubRequest: StubRequest): StaticStubResponse = { + val matches = StubMatching.matchStubs(stubRequest, stubs) + val topCandidate = matches.find(_.isMatch) + topCandidate match { + case Some(result) => + recordStubHit(result.stub) + result.stub.stubbedResponse.getStaticStubResponse(stubRequest) + case None => + noMatchResponse(stubRequest, matches) + } + } + + def failedResponse(e: Throwable) = { + e.printStackTrace() + StaticStubResponse(stubNotFoundStatusCode, s"Stub Matching failed with the following exception: ${e.toString}") + } + + def noMatchResponse(request: StubRequest, notMatches: List[MatchResult]) = { + val queryString = request.queryMap.map { case (k, v) => s"$k=$v" }.foldLeft("?") { case (acc, x) => s"$acc&$x" } + val noMatchReasons = if (stubs.isEmpty) { + """ "There are no registered stubs in the server!" """ + } else { + notMatches.map(x => s""" "${x.noMatchMessage}" """).mkString(",\n") + } + val responseJson = { + s"""{ + | "message": "No stub found for request [URL: ${request.path}$queryString] [METHOD: ${request.httpMethod}}] [BODY: ${request.body}]", + | "noMatchReasons" : [ + | $noMatchReasons + | ] + |}""".stripMargin + } + StaticStubResponse(stubNotFoundStatusCode, responseJson) + } + + def requestCount(stub: Stub): Int = requestCountMap.getOrElse(stub, 0) + + private def recordStubHit(stub: Stub): Unit = { + val numberOfRequests = requestCountMap.getOrElse(stub, 0) + requestCountMap.update(stub, numberOfRequests + 1) + } + private val requestCountMap: mutable.Map[Stub, Int] = mutable.Map.empty +} diff --git a/server/libs/stub-server/src/test/scala/cool/graph/stub/StubDslSpec.scala b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubDslSpec.scala new file mode 100644 index 0000000000..2b4d23b77c --- /dev/null +++ b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubDslSpec.scala @@ -0,0 +1,21 @@ +package cool.graph.stub + +import cool.graph.stub.StubDsl.Default.Request +import org.specs2.mutable.Specification + +class StubDslSpec extends Specification { + + val path = "some/freaking/path" + val params = Map("a" -> 1) + + val responseBody = "the body" + val response = StaticStubResponse(333, responseBody) + + "using the default stub DSL" should { + "produce a stub response with headers" in { + val stub: Stub = Request("POST", path).stub(200, responseBody, Map("X-Test" -> "Test")) + stub.stubbedResponse.headers("X-Test") must equalTo("Test") + stub.stubbedResponse.body must equalTo(responseBody) + } + } +} diff --git a/server/libs/stub-server/src/test/scala/cool/graph/stub/StubMatchingSpec.scala b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubMatchingSpec.scala new file mode 100644 index 0000000000..cab27a1742 --- /dev/null +++ b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubMatchingSpec.scala @@ -0,0 +1,126 @@ +package cool.graph.stub + +import org.specs2.mutable.Specification + +class StubMatchingSpec extends Specification { + sequential + + val em = Map.empty[String, String] + + "a stub that does not match Path and Method" should { + "return a DoesNotMatch if the Method does not match" in { + val aufgabenStub = Stub("GET", "/some/path", em, body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("POST", "/some/path", Map("list_id" -> "1"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beFalse + matchResult.rank mustEqual 2 // path + query string + } + "return a DoesNotMatch if the Path does not match" in { + val aufgabenStub = Stub("GET", "/some/pathZZ", em, body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beFalse + matchResult.rank mustEqual 1 // method + } + "return a DoesNotMatch event if the queryString matches" in { + val aufgabenStub = + Stub("GET", "/some/pathZZ", Map("list_id" -> "1"), body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beFalse + matchResult.rank mustEqual 2 // method + query string + } + } + + "a stub that matches Path and Method, but NOT QueryString" should { + "return a MatchResult with rank 2 if Path and Method match" in { + val aufgabenStub = Stub("GET", "/some/path", em, body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beTrue + matchResult.rank mustEqual 2 + } + "return a MatchResult with rank 2 if Path and Method match" in { + val aufgabenStub = Stub("GET", "/some/path", em, body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", em, body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beTrue + matchResult.rank mustEqual 2 + } + "return a DoesNotMatch if path Path and Method match and QueryString does not match" in { + val aufgabenStub = + Stub("GET", "/some/path?param=value", em, body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path?blub=bla", em, body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beFalse + } + } + "a stub that matches Path, Method and the QueryString at least partially" should { + "return a MatchResult with rank 3, when the QueryString matches on one param" in { + val aufgabenStub = + Stub("GET", "/some/path", Map("list_id" -> "1"), body = "", StaticStubResponse(200, "less-matching")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1", "foo" -> "bar"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beTrue + matchResult.rank mustEqual 3 // method + path + 1 query param + } + "return a Match, where the rank equals the number of params matching + 1" in { + val aufgabenStub = Stub("GET", "/some/path", Map("list_id" -> "1", "foo" -> "bar"), body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1", "foo" -> "bar"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beTrue + matchResult.rank mustEqual 4 // method + path + 2 query params + } + "return a Match even if one query value is the string representation of the other" in { + val aufgabenStub = + Stub("GET", "/some/path", Map("list_id" -> 1), body = "", StaticStubResponse(200, """aufgaben-response""")) + val request = StubRequest("GET", "/some/path", Map("list_id" -> "1"), body = "") + val matchResult = StubMatching.matchStub(aufgabenStub, request) + matchResult.isMatch must beTrue + matchResult.rank mustEqual 3 // method + path + 1 query param + } + } + + "a stub for a PATCH/POST request should" should { + "return a MatchResult with rank 1, when the body matches" in { + val body = """{ "foo" : "bar", "x" : "y" }""" + val bodyInDifferentOrder = """{ "x" : "y", "foo" : "bar" }""" + val stub = Stub("POST", "/path", em, body = body, StaticStubResponse(200, "less-matching")) + val request = StubRequest("POST", "/path", em, body = bodyInDifferentOrder) + val matchResult = StubMatching.matchStub(stub, request) + matchResult.isMatch must beTrue + } + "return DoesNotMatch, when body does NOT match" in { + val stub = Stub("POST", "/path", em, body = "foo", StaticStubResponse(200, "less-matching")) + val request = StubRequest("POST", "/path", em, body = "bar") + val matchResult = StubMatching.matchStub(stub, request) + matchResult.isMatch must beFalse + } + } + + "StubMatching.matchStubs" should { + val stubRequest = StubRequest("GET", "/path", Map("foo_id" -> "1"), body = "") + + val matchingStubRank2 = Stub("GET", "/path", queryMap = Map("foo_id" -> "1"), body = "", StaticStubResponse(200, "this-must-match-with-rank-2")) + val matchingStubRank1 = + Stub("GET", "/path", queryMap = em, body = "", StaticStubResponse(200, "this-must-match-with-rank-1")) + val nonMatchingStub = + Stub("POST", "/path", queryMap = em, body = "", StaticStubResponse(200, "this-must-not-match")) + + val matchResults = + StubMatching.matchStubs(stubRequest, List(nonMatchingStub, matchingStubRank1, matchingStubRank2)) + + "sort the MatchResult according to their rank" in { + val first = matchResults(0) + val second = matchResults(1) + val third = matchResults(2) + first.rank must beGreaterThan(second.rank) + second.rank must beGreaterThan(third.rank) + } + "retain stubs that are not matching" in { + matchResults must haveSize(3) + matchResults.last.isMatch must beFalse + matchResults.last.noMatchMessage must contain("expected request method") + } + } +} diff --git a/server/libs/stub-server/src/test/scala/cool/graph/stub/StubServerSpec.scala b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubServerSpec.scala new file mode 100644 index 0000000000..b8ad6ec890 --- /dev/null +++ b/server/libs/stub-server/src/test/scala/cool/graph/stub/StubServerSpec.scala @@ -0,0 +1,245 @@ +package cool.graph.stub + +import javax.servlet.http.HttpServletRequest + +import org.specs2.mutable.Specification + +import scala.collection.SortedMap +import scala.util.Random +import scalaj.http.{Http, HttpResponse} + +class StubServerSpec extends Specification { + import cool.graph.stub.Import._ + + def mustBeNotFoundResponse(response: HttpResponse[String]) = { + response.code must equalTo(999) + response.body must contain(""" "message": "No stub found for request [URL: """) + } + def mustBeNoStubsResponse(response: HttpResponse[String]) = { + response.code must equalTo(999) + response.body must contain(""" "There are no registered stubs in the server!" """) + } + + val em = SortedMap.empty[String, String] + import cool.graph.stub.StubDsl.Default.Request + + "StubServer" should { + "respond with the default 'not found' stub if there are no stubs" in { + withStubServer(List.empty).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("q", "monkeys").asString + mustBeNoStubsResponse(response) + } + } + + "respond with the desired stubNotFoundStatusCode" in { + withStubServer(List.empty, stubNotFoundStatusCode = 101).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("q", "monkeys").asString + response.code mustEqual 101 + } + } + + "respond with the default 'not found' stub if there are stubs, but no one matches the request" in { + val stubs = List(Request("GET", "/path").stub(200, "whatever")) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/another-path").method("GET").asString + mustBeNotFoundResponse(response) + } + } + + "respond with the stub that is matching the request by Method and Path" in { + val stubs = List(Request("GET", "/path").stub(200, "response")) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path").asString + response.code mustEqual 200 + response.body mustEqual "response" + val response2: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path?param=value").asString + response.body mustEqual response2.body + } + } + + "respond with the stub that matches HTTP method AND Path, when multiple stubs differ in the method" in { + val stubs = List( + Request("GET", "/path").stub(StaticStubResponse(200, "get-response")), + Request("POST", "/path").stub(StaticStubResponse(200, "post-response")) + ) + withStubServer(stubs).withArg { server => + val getResponse: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path").method("GET").asString + getResponse.code must equalTo(200) + getResponse.body mustEqual "get-response" + val postResponse: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path").method("POST").asString + postResponse.code must equalTo(200) + postResponse.body mustEqual "post-response" + } + } + + "respond with the stub that only matches HTTP method AND Path even though the request includes query params" in { + val successfulExistenz = Request("GET", "/path").stub(200, "path-response") + val anotherStub = Request("GET", "/another-path").stub(200, "another-path-response") + withStubServer(List(successfulExistenz, anotherStub)).withArg { server => + val response: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path") + .param("q", "monkeys") + .param("list_id", "123") + .method("GET") + .asString + response.code mustEqual 200 + response.body mustEqual "path-response" + } + } + + "respond with the stub that matches more Query Params if Method AND Path match" in { + val aufgabenStub = Request("GET", "/path", Map("list_id" -> "123")).stub(200, "more-matching") + val lessMatchingAufgabenStub = Request("GET", "/path").stub(200, "less-matching") + + val stubs = List(lessMatchingAufgabenStub, aufgabenStub) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("list_id", "123").asString + response.code mustEqual 200 + response.body mustEqual "more-matching" + } + } + + "respond with the correct stub if the request has multiple query params" in { + val aufgabenStub = + Request("GET", "/path", Map("list_id" -> "123", "user_id" -> "123")).stub(200, "more-matching") + val tasksStub = Request("GET", "/path", Map("list_id" -> "123")).stub(200, "less-matching") + + val stubs = List(tasksStub, aufgabenStub) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("list_id", "123").param("user_id", "123").asString + response.code must equalTo(200) + response.body mustEqual "more-matching" + } + } + + "respond with the correct stub if the request query params and the stub query params are NOT in the same order" in { + val aufgabenStub = + Request("GET", "/path", Map("list_id" -> "123", "user_id" -> "123")).stub(200, "more-matching") + val tasksStub = Request("GET", "/path").stub(200, "less-matching") + + val stubs = List(tasksStub, aufgabenStub) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("user_id", "123").param("list_id", "123").asString + response.code mustEqual 200 + response.body mustEqual "more-matching" + } + } + "respond with the stub that matches HTTP method AND Path and a PART of the QueryString" in { + val lessMatchingAufgabenStub = Request("GET", "/path").stub(200, "less-matching") + val aufgabenStub = Request("GET", "/path", Map("list_id" -> "123")).stub(200, "more-matching") + + val stubs = List(lessMatchingAufgabenStub, aufgabenStub) + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").param("list_id", "123").param("foo", "bar").asString + println(response.body) + response.code mustEqual 200 + response.body mustEqual "more-matching" + } + } + + "respond with no Stub, if the stub matches Path, Method and QueryString BUT NOT Body " in { + val body = """{ "foo" : "bar" }""" + val stub = Request("POST", "/path", Map("a" -> "b"), body).stub(200, "response") + withStubServer(List(stub)).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").postData("Not the same body").param("a", "b").asString + println(response.body) + response.code mustEqual 999 + } + } + + "respond with a Stub, if the stub matches Path, Method and QueryString BUT NOT Body, but ignoreBody is used" in { + val body = """{ "foo" : "bar" }""" + val stub = Request("POST", "/path", Map("a" -> "b"), body).stub(200, "response").ignoreBody + withStubServer(List(stub)).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").postData("Not the same body").param("a", "b").asString + println(response.body) + response.code mustEqual 200 + response.body mustEqual "response" + } + } + + "respond with Stub, if the stub matches Path, Method and QueryString and Body " in { + val body = """{ "foo" : "bar", "x" : "y" }""" + val bodyInDifferentOrder = + """{ + |"x" : "y", + |"foo" : "bar" + |}""".stripMargin + val stub = Request("POST", "/path", Map("a" -> "b"), body).stub(200, "response") + withStubServer(List(stub)).withArg { server => + val response: HttpResponse[String] = + Http(s"http://127.0.0.1:${server.port}/path").postData(bodyInDifferentOrder).param("a", "b").asString + println(response.body) + response.code mustEqual 200 + } + } + + "with requestCount one can check the number of calls to a particular stub" in { + val stub1 = Request("GET", "/path").stub(200, "some-response") + val stub2 = Request("POST", "/path").stub(200, "another-response") + + withStubServer(List(stub1, stub2)).withArg { server => + server.requestCount(stub1) mustEqual 0 + server.requestCount(stub2) mustEqual 0 + + Http(s"http://127.0.0.1:${server.port}/path").asString + server.requestCount(stub1) mustEqual 1 + Http(s"http://127.0.0.1:${server.port}/path").asString + server.requestCount(stub1) mustEqual 2 + server.requestCount(stub2) mustEqual 0 + } + } + + "respond with a given header in the stub response" in { + val stubs = List(Request("GET", "/path").stub(200, "response", Map("X-Test-Header" -> "value"))) + + withStubServer(stubs).withArg { server => + val response: HttpResponse[String] = Http(s"http://127.0.0.1:${server.port}/path").asString + response.code mustEqual 200 + response.body mustEqual "response" + response.headers.get("X-Test-Header").get must equalTo("value") + } + } + + "withStubServer should compile just fine for the apply without arguments" in { + val stub1 = Request("GET", "/path").stub(200, "some-response") + + withStubServer(List(stub1)) { + true must beTrue + } + } + + class FailingStubHandler extends StubServerHandler(List.empty, 999) { + override def stubResponseForRequest(request: StubRequest): StaticStubResponse = + throw new Exception("this goes boom!") + } + class FailingServer extends StubServer(stubs = List.empty, port = 8000 + Random.nextInt(1000), stubNotFoundStatusCode = 999) { + override def createHandler: StubServerHandler = new FailingStubHandler + } + + def withFailingServer[T](block: FailingServer => T): T = { + val server = new FailingServer + try { + server.start + block(server) + } finally { + server.stop + } + } + "the StubHandler should respond with a proper description if the stub matching fails" in { + withFailingServer { server => + val response = Http(s"http://127.0.0.1:${server.port}/path").asString + response.body must contain("Stub Matching failed") + response.code mustEqual 999 + } + } + } +} diff --git a/server/libs/stub-server/version.sbt b/server/libs/stub-server/version.sbt new file mode 100644 index 0000000000..aab7f9b814 --- /dev/null +++ b/server/libs/stub-server/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1.0-SNAPSHOT" \ No newline at end of file diff --git a/server/localfaas/project/build.properties b/server/localfaas/project/build.properties new file mode 100644 index 0000000000..c091b86ca4 --- /dev/null +++ b/server/localfaas/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.16 diff --git a/server/localfaas/src/main/resources/application.conf b/server/localfaas/src/main/resources/application.conf new file mode 100644 index 0000000000..8ccc68af91 --- /dev/null +++ b/server/localfaas/src/main/resources/application.conf @@ -0,0 +1,16 @@ +akka { + daemonic = on + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + request-timeout = 120s // Deploy mutation is too slow for default 20s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasMain.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasMain.scala new file mode 100644 index 0000000000..1ad27d05c4 --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasMain.scala @@ -0,0 +1,26 @@ +package cool.graph.localfaas + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import better.files.File.root +import cool.graph.akkautil.http.ServerExecutor + +import scala.concurrent.Await +import scala.concurrent.duration.Duration + +object LocalFaasMain extends App { + implicit val system = ActorSystem("functions-runtime") + implicit val materializer = ActorMaterializer() + + val port = sys.env.getOrElse("FUNCTIONS_PORT", sys.error("FUNCTIONS_PORT env var required but not found.")).toInt + val workingDir = (root / "var" / "faas").createIfNotExists(asDirectory = true, createParents = true) + + val executor = ServerExecutor( + port = port, + FunctionRuntimeServer("functions", workingDir) + ) + + executor.start + Await.result(system.whenTerminated, Duration.Inf) + executor.stop +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasServer.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasServer.scala new file mode 100644 index 0000000000..b4466a7e24 --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/LocalFaasServer.scala @@ -0,0 +1,223 @@ +package cool.graph.localfaas + +import java.io._ + +import akka.actor.{ActorSystem, Props} +import akka.http.scaladsl.model.{StatusCodes, Uri} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.ExceptionHandler +import akka.pattern.ask +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.FileIO +import akka.util.Timeout +import better.files.Cmds._ +import cool.graph.akkautil.http.Server +import cool.graph.localfaas.actors.MappingActor +import cool.graph.localfaas.actors.MappingActor.{GetHandler, SaveMapping} +import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport +import play.api.libs.json.{JsError, JsSuccess, Json} +import better.files.File +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.sys.process.{Process, _} +import scala.util.{Failure, Success, Try} + +/** + * TODOs: + * - Prevent concurrent deployment of the same function. + * - Support multiple node versions. nvm has good concepts for that. + * - Have a notion of different langs. + * - Cleanup in error cases. + * - Jail the subprocesses to their deployment. + * - Tests. + */ +case class BadRequestException(reason: String) extends Exception(reason) + +case class FunctionRuntimeServer(prefix: String = "", workingDir: File)(implicit system: ActorSystem, materializer: ActorMaterializer) + extends Server + with PlayJsonSupport { + import Conversions._ + import system.dispatcher + + val functionHandlerFile = (workingDir / "handlers.json").createIfNotExists() // persistence file for handlers + val functionsDir = (workingDir / "functions").createIfNotExists(asDirectory = true, createParents = true) + val deploymentsDir = (workingDir / "deployments").createIfNotExists(asDirectory = true, createParents = true) + + implicit val timeout = Timeout(5.seconds) + + val exceptionHandler = ExceptionHandler { + case e: BadRequestException => println(e.getMessage); complete(StatusCodes.BadRequest -> StatusResponse(success = false, Some(e.getMessage))) + case e => println(e.getMessage); complete(StatusCodes.InternalServerError -> StatusResponse(success = false, Some(e.getMessage))) + } + + // Actor responsible for persisting the mapping of functions to their handlers + val mappingActor = system.actorOf(Props(MappingActor(functionHandlerFile))) + + val innerRoutes = handleExceptions(exceptionHandler) { + ((put | post) & pathPrefix("files")) { + withoutSizeLimit { + extractRequest { req => + pathPrefix(Segment) { projectId => + pathPrefix(Segment) { deploymentId => + val deployDirForProject = (deploymentsDir / projectId / deploymentId).createIfNotExists(asDirectory = true, createParents = true).clear() + val destFile = deployDirForProject / s"$deploymentId.zip" + + println(s"Writing to ${destFile.path}") + + val sink = FileIO.toPath(destFile.path) + val writeResult = req.entity.dataBytes.runWith(sink) + + onSuccess(writeResult) { result => + result.status match { + case Success(_) => + println(s"Wrote ${result.count} bytes to disk. Unzipping...") + + Try { + Utils.unzip(destFile, deployDirForProject) + } match { + case Success(_) => + Try { destFile.delete() } + println("Done unzipping.") + + case Failure(e) => + Try { deployDirForProject.clear() } + println(s"Error while unzipping: $e") + throw e + } + + complete(StatusResponse(success = true)) + + case Failure(e) => + throw e + } + } + } + } + } + } + } ~ + post { + pathPrefix("deploy") { + pathPrefix(Segment) { projectId => + entity(as[DeploymentInput]) { input => + println(s"Deploying function ${input.functionName} for project $projectId...") + + // Extract deployment ID + val segments = Uri(input.zipUrl).path.toString().stripPrefix("/").split("/") + + if (segments.length != 4 || segments.take(3).toSeq != Seq("functions", "files", projectId)) { + throw BadRequestException(s"Invalid zip URL '${input.zipUrl}', expected path '/functions/files/$projectId/'.") + } + + val deploymentId = segments.last + val functionArtifacts = deploymentsDir / projectId / deploymentId + + if (!functionArtifacts.exists || functionArtifacts.isEmpty) { + throw BadRequestException( + s"Deployment '$deploymentId' does not exist. Make sure to deploy the necessary files first before deploying the function.") + } + + // Check handler validity - if there are windows backslashes, try converting and check again + val inputHandler = input.handlerPath + val handlerPath = ((functionArtifacts / inputHandler).exists, inputHandler.contains("\\")) match { + case (true, _) => + inputHandler + + case (false, true) => + val convertedHandler = inputHandler.replaceAllLiterally("""\""", "/") + if ((functionArtifacts / convertedHandler).exists) { + convertedHandler + } else { + throw BadRequestException(s"Handler '$inputHandler' does not exist in the given archive.") + } + + case _ => + throw BadRequestException(s"Handler '$inputHandler' does not exist in the given archive.") + } + + println(s"Using handler '$handlerPath'...") + + val functionDeploymentPath = (functionsDir / projectId / input.functionName).createIfNotExists(asDirectory = true, createParents = true).clear() + cp(functionArtifacts, functionDeploymentPath) + + mappingActor ! SaveMapping(projectId, input.functionName, handlerPath) + + println(s"Deploying function ${input.functionName} for project $projectId... Done.") + complete(StatusResponse(success = true)) + } + } + } ~ + pathPrefix("invoke") { + pathPrefix(Segment) { projectId => + entity(as[FunctionInvocation]) { invocation => + val input = Json.parse(invocation.input).toString + val handlerPath = mappingActor ? GetHandler(projectId, invocation.functionName) + + val invocationResult = handlerPath.mapTo[String].map { path => + val handlerFile = functionsDir / projectId / invocation.functionName / path + + if (path.isEmpty || !handlerFile.exists) { + throw BadRequestException(s"Function can not be invoked - no handler found. Function is likely not (fully) deployed.") + } + + var stdout: String = "" + var stderr: String = "" + + // todo set CWD to handler root? (somehow not required for node, but for other langs) + val io = new ProcessIO( + (out: OutputStream) => { + out.write(input.getBytes("UTF-8")) + out.flush() + out.close() + }, + (in: InputStream) => { + stdout = scala.io.Source.fromInputStream(in).mkString + in.close() + }, + (errIn: InputStream) => { + stderr = scala.io.Source.fromInputStream(errIn).mkString + errIn.close() + } + ) + + val startTime = System.currentTimeMillis() + val process = Process("node", Seq(handlerFile.path.toString)).run(io) + val exitCode = process.exitValue() + val duration = System.currentTimeMillis() - startTime + + // For now only the stdout of the wrapper process is really interesting. + val parsedResult = Json.parse(stdout).validate[FunctionInvocationResult] match { + case JsSuccess(res, _) => res + case JsError(e) => println(e); FunctionInvocationResult(None, None, None, stdout, stderr) + } + + println(stdout) + + val error = parsedResult.error + val success = (error.isEmpty || error.exists(e => e.isEmpty || e == "null" || e == "{}")) && exitCode == 0 + + parsedResult.printSummary(duration, success, projectId, invocation.functionName) + parsedResult.copy( + success = Some(success), + stdout = parsedResult.stdout.stripLineEnd.trim, + stderr = parsedResult.stderr.stripLineEnd.trim + ) + } + + complete(invocationResult) + } + } + } + } ~ + delete { + pathPrefix(Segment) { projectId => + pathPrefix(Segment) { functionName => + // We currently have no undeploy concept in the backend, WIP + complete("RIP") + } + } + } + } + + override def healthCheck = Future.successful(()) +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/Protocol.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/Protocol.scala new file mode 100644 index 0000000000..d0abaee794 --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/Protocol.scala @@ -0,0 +1,36 @@ +package cool.graph.localfaas + +import play.api.libs.json.{JsObject, Json} + +object Conversions { + implicit val deploymentInputFormat = Json.format[DeploymentInput] + implicit val statusResponseFormat = Json.format[StatusResponse] + implicit val functionInvocationFormat = Json.format[FunctionInvocation] + implicit val invocationResultFormat = Json.format[FunctionInvocationResult] +} + +case class DeploymentInput(zipUrl: String, handlerPath: String, functionName: String) +case class StatusResponse(success: Boolean, error: Option[String] = None) +case class FunctionInvocation(functionName: String, input: String) + +// PARSE the stdout and then fill the fields! +case class FunctionInvocationResult( + success: Option[Boolean], + error: Option[String], + value: Option[JsObject], + stdout: String, + stderr: String +) { + def printSummary(duration: Long, success: Boolean, projectId: String, name: String): Unit = { + println( + s"""Function invocation summary for project $projectId and function $name: + |\tDuration: ${duration}ms + |\tSuccess: $success + |\tFunction return value: '${value.getOrElse("")}' + |\tError: '${error.getOrElse("").stripLineEnd.trim}' + |\tProcess stdout: '${stdout.stripLineEnd.trim}' + |\tProcess stderr: '${stderr.stripLineEnd.trim}' + """.stripMargin + ) + } +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/Utils.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/Utils.scala new file mode 100644 index 0000000000..1dacc831dd --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/Utils.scala @@ -0,0 +1,41 @@ +package cool.graph.localfaas + +import java.io.FileInputStream + +import better.files.File +import org.apache.commons.compress.archivers.{ArchiveEntry, ArchiveStreamFactory} +import org.apache.commons.compress.utils.IOUtils + +import scala.util.{Failure, Try} + +object Utils { + def unzip(source: File, target: File): Unit = { + val inputStream = new FileInputStream(source.path.toFile) + val archiveStream = new ArchiveStreamFactory().createArchiveInputStream(ArchiveStreamFactory.ZIP, inputStream) + + def stream: Stream[ArchiveEntry] = archiveStream.getNextEntry match { + case null => Stream.empty + case entry => entry #:: stream + } + + def closeStreams = { + archiveStream.close() + inputStream.close() + } + + Try { + for (entry <- stream if !entry.isDirectory) { + val outFile = (target / entry.getName).createIfNotExists(asDirectory = false, createParents = true).clear() + val os = outFile.newOutputStream + + Try { IOUtils.copy(archiveStream, os) } match { + case Failure(e) => os.close(); throw e + case _ => os.close() + } + } + } match { + case Failure(e) => closeStreams; throw e + case _ => closeStreams + } + } +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/actors/Conversions.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/actors/Conversions.scala new file mode 100644 index 0000000000..b818197261 --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/actors/Conversions.scala @@ -0,0 +1,39 @@ +package cool.graph.localfaas.actors + +import cool.graph.localfaas.actors.MappingActor.HandlerMap +import play.api.libs.json._ + +import scala.collection.mutable + +object Conversions { + implicit val mapStringReads = Reads.mapReads[String] + implicit val mapStringWrites = Writes.mapWrites[String] + + implicit val mapMapStringReads = Reads.mapReads[Map[String, String]] + implicit val mapMapStringWrites = Writes.mapWrites[Map[String, String]] + + implicit val mapReads: Reads[HandlerMap] = new Reads[HandlerMap] { + def reads(jv: JsValue): JsResult[HandlerMap] = { + val result = new HandlerMap + + jv.as[Map[String, Map[String, String]]].map { + case (k: String, v) => + val innerMap = new mutable.HashMap[String, String]() + v.foreach(entry => innerMap += (entry._1 -> entry._2)) + result += k -> innerMap + } + + JsSuccess(result) + } + } + + implicit val mapWrites: Writes[HandlerMap] = new Writes[HandlerMap] { + def writes(handlers: HandlerMap): JsValue = { + val entries = handlers.toMap.map { + case (k, v) => k -> Map(v.toSeq: _*) + } + + Json.toJson(entries) + } + } +} diff --git a/server/localfaas/src/main/scala/cool/graph/localfaas/actors/MappingActor.scala b/server/localfaas/src/main/scala/cool/graph/localfaas/actors/MappingActor.scala new file mode 100644 index 0000000000..bc177555a4 --- /dev/null +++ b/server/localfaas/src/main/scala/cool/graph/localfaas/actors/MappingActor.scala @@ -0,0 +1,52 @@ +package cool.graph.localfaas.actors + +import akka.actor.Actor +import better.files.File +import cool.graph.localfaas.actors.MappingActor.{GetHandler, HandlerMap, SaveMapping} +import play.api.libs.json._ + +import scala.collection.mutable + +object MappingActor { + case class SaveMapping(projectId: String, functionName: String, handlerPath: String) + case class GetHandler(projectId: String, functionName: String) + + type HandlerMap = mutable.HashMap[String, mutable.HashMap[String, String]] +} + +case class MappingActor(handlerFile: File) extends Actor { + import Conversions._ + + // projectId -> functionName -> handlerPath + val handlers = loadHandlers + + // load handlers on creation + def loadHandlers: HandlerMap = { + val content = handlerFile.contentAsString + + if (handlerFile.contentAsString.isEmpty) { + new HandlerMap + } else { + Json.parse(content).validate[HandlerMap] match { + case JsSuccess(result, _) => println("Using mapping from file."); result + case JsError(_) => println("Unable to parse handler map from file, using empty map."); new HandlerMap + } + } + } + + def flush(): Unit = { + val compactJson: String = Json.stringify(Json.toJson(handlers)) + handlerFile.overwrite(compactJson) + } + + override def receive: Receive = { + case GetHandler(pid, fnName) => + val projectHandlerMap = handlers.getOrElse(pid, new mutable.HashMap[String, String]()) + sender ! projectHandlerMap.getOrElse(fnName, "") + + case SaveMapping(pid, fnName, handlerPath) => + val projectHandlerMap = handlers.getOrElseUpdate(pid, new mutable.HashMap[String, String]()) + projectHandlerMap += fnName -> handlerPath + flush() + } +} diff --git a/server/project/UpdateGitRepo.scala b/server/project/UpdateGitRepo.scala new file mode 100644 index 0000000000..f092e9db25 --- /dev/null +++ b/server/project/UpdateGitRepo.scala @@ -0,0 +1,70 @@ +import play.api.libs.json.{JsSuccess, JsValue, Json} + +import scalaj.http.{Base64, Http, HttpRequest} + +object GithubClient { + def apply(): GithubClient = GithubClient(Env.read("GITHUB_ACCESS_TOKEN")) +} + +case class GithubClient(accessToken: String) { + import JsonFormatting._ + + val host = "https://api.github.com" + val authHeader = "Authorization" -> s"token $accessToken" + + def updateFile(owner: String, repo: String, filePath: String, newContent: String, branch: String): Unit = { + getCurrentSha(owner, repo, filePath, branch) match { + case Some(currentSha) => + updateContentsOfFile(owner, repo, filePath, currentSha, newContent, branch) + println(s"Updated file $filePath in other repo successfully.") + case None => + println(s"Branch $branch in other repo does not seem to exist. Won't update file.") + } + } + + def getCurrentSha(owner: String, repo: String, filePath: String, branch: String): Option[String] = { + val request = baseRequest(urlPath(owner, repo, filePath, branch)) + request.asJson(200, 404).validate[GetContentResponse](getContentReads) match { + case JsSuccess(parsed, _) => Some(parsed.sha) + case _ => None + } + } + + def updateContentsOfFile(owner: String, repo: String, filePath: String, sha: String, newContent: String, branch: String): JsValue = { + val request = baseRequest(urlPath(owner, repo, filePath)) + val payload = UpdateContentsRequest( + message = s"Updated by the SBT Task in the open source repo to: $newContent", + content = Base64.encodeString(newContent), + sha = sha, + branch = branch + ) + request.put(Json.toJson(payload)(updateContentsWrites).toString).asJson(200) + } + + def urlPath(owner: String, repo: String, filePath: String, branch: String): String = urlPath(owner, repo, filePath) + s"?ref=$branch" + def urlPath(owner: String, repo: String, filePath: String): String = s"/repos/$owner/$repo/contents/$filePath" + def baseRequest(path: String) = Http(s"$host$path").headers(authHeader).header("content-type", "application/json") + + implicit class HttpRequestExtensions(httpRequest: HttpRequest) { + def asJson(allowedStatusCodes: Int*): JsValue = { + val response = httpRequest.asString + val isAllowedResponse = allowedStatusCodes.contains(response.code) + require(isAllowedResponse, s"The request did not result in an expected status code. Allowed status are $allowedStatusCodes. The response was: $response") + Json.parse(response.body) + } + } +} + +object JsonFormatting { + import play.api.libs.json._ + + case class GetContentResponse(sha: String) + case class UpdateContentsRequest(message: String, content: String, sha: String, branch: String) + + implicit val getContentReads = Json.reads[GetContentResponse] + implicit val updateContentsWrites = Json.writes[UpdateContentsRequest] +} + +object Env { + def read(name: String) = sys.env.getOrElse(name, sys.error(s"Env var $name must be set")) +} diff --git a/server/project/build.properties b/server/project/build.properties new file mode 100644 index 0000000000..cddd489cd5 --- /dev/null +++ b/server/project/build.properties @@ -0,0 +1 @@ +sbt.version = 0.13.16 diff --git a/server/project/dependencies.scala b/server/project/dependencies.scala new file mode 100644 index 0000000000..456881fcf9 --- /dev/null +++ b/server/project/dependencies.scala @@ -0,0 +1,63 @@ +import sbt._ + +object Dependencies { + lazy val common = Seq( + "org.sangria-graphql" %% "sangria" % "1.2.3-SNAPSHOT", + "org.sangria-graphql" %% "sangria" % "1.2.2", + "org.sangria-graphql" %% "sangria-spray-json" % "1.0.0", + "org.sangria-graphql" %% "sangria-relay" % "1.2.2", + "com.google.guava" % "guava" % "19.0", + "com.typesafe.akka" %% "akka-http" % "10.0.5", + "com.typesafe.akka" %% "akka-testkit" % "2.4.17", + "com.typesafe.akka" %% "akka-http-testkit" % "10.0.5", + "com.typesafe.akka" %% "akka-http-spray-json" % "10.0.5", + "com.typesafe.akka" %% "akka-contrib" % "2.4.17", + "ch.megard" %% "akka-http-cors" % "0.2.1", + "com.typesafe.slick" %% "slick" % "3.2.0", + "com.typesafe.slick" %% "slick-hikaricp" % "3.2.0", + "com.github.tototoshi" %% "slick-joda-mapper" % "2.3.0", + "joda-time" % "joda-time" % "2.9.4", + "org.joda" % "joda-convert" % "1.7", + "org.scalaj" %% "scalaj-http" % "2.3.0", + "io.spray" %% "spray-json" % "1.3.3", + "org.scaldi" %% "scaldi" % "0.5.8", + "org.scaldi" %% "scaldi-akka" % "0.5.8", + "com.typesafe.scala-logging" %% "scala-logging" % "3.4.0", + "ch.qos.logback" % "logback-classic" % "1.1.7", + "org.atteo" % "evo-inflector" % "1.2", + "com.amazonaws" % "aws-java-sdk-kinesis" % "1.11.171", + "com.amazonaws" % "aws-java-sdk-s3" % "1.11.171", + "com.amazonaws" % "aws-java-sdk-cloudwatch" % "1.11.171", + "com.amazonaws" % "aws-java-sdk-sns" % "1.11.171", + "software.amazon.awssdk" % "lambda" % "2.0.0-preview-4", + "org.scala-lang.modules" % "scala-java8-compat_2.11" % "0.8.0", + "software.amazon.awssdk" % "s3" % "2.0.0-preview-4", + "org.mariadb.jdbc" % "mariadb-java-client" % "2.1.2", + "com.github.t3hnar" %% "scala-bcrypt" % "2.6", + "org.scalactic" %% "scalactic" % "2.2.6", + "com.pauldijou" %% "jwt-core" % "0.7.1", + "cool.graph" % "cuid-java" % "0.1.1", + "com.jsuereth" %% "scala-arm" % "2.0", + "com.google.code.findbugs" % "jsr305" % "3.0.1", + "com.stripe" % "stripe-java" % "3.9.0", + "org.yaml" % "snakeyaml" % "1.17", + "net.jcazevedo" %% "moultingyaml" % "0.4.0", + "net.logstash.logback" % "logstash-logback-encoder" % "4.7", + "org.sangria-graphql" %% "sangria-play-json" % "1.0.3", + "de.heikoseeberger" %% "akka-http-play-json" % "1.17.0", + finagle, + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % "2.8.4", + scalaTest + ) + + val akka = "com.typesafe.akka" %% "akka-actor" % "2.4.8" + val finagle = "com.twitter" %% "finagle-http" % "6.44.0" + val scalaTest = "org.scalatest" %% "scalatest" % "2.2.6" % Test + + val apiServer = Seq.empty + val clientShared = Seq(scalaTest) + + val caffeine = "com.github.ben-manes.caffeine" % "caffeine" % "2.5.5" + val java8Compat = "org.scala-lang.modules" %% "scala-java8-compat" % "0.7.0" + val jsr305 = "com.google.code.findbugs" % "jsr305" % "3.0.0" +} diff --git a/server/project/libs/maven-packagecloud-wagon-idempotent.jar b/server/project/libs/maven-packagecloud-wagon-idempotent.jar new file mode 100644 index 0000000000000000000000000000000000000000..0a980e64d4d8e398f0d68a8e653a4f0bf24e7759 GIT binary patch literal 12041 zcmb_i1ymeM)}Ek)yA#~q-QC?GNN{(D5Zv9}-95P55ZnWS1Sd#v5By}`?#tV^Z+Fk0 z^Vgi|?m2V6E7jd~Z&iItvfvP~fS+y9S6udQ7k@v%yqx95)r9G#6(ksy{w@Xt47`XH zczCNjz5E^a@163efY94T8cCS6y<{G3Su(87GA`LP)#y%WkqNSqiRY@UPM&R9kt%4iDyqGxF_7V7 zIOVY2;il!Du)|)%ULnY~)b0AzD@2)em++hNa-MA-ZGeCH2mo|>{&OhcFFsn@{~pA@ z3LyU`;9z8IZDek0Y-8_g@*kuz|3cc<$j#L5512pqCjP(0xEq<<{}I{wC+C0q_WONs zurc*;b^hHB@Nai*iOCwi01N<-gaiP%{+pdYgs5WbVDD_{V(;Y1U~FUL?3}FnMs-dJ znLo8eUV1Z+oZL_{O0Jh^Tpv*>UV(Chf<;#7)a`(tx>8pu@$+^X`0^oS zva++2_7ZjjtK}&JFQ)q8OU42BiLP(g!R{O${A@#`Jiixg3H{=?eU37T*k z!gE=xu)0c`Glkq5p6uA!ZZumdQJy%Y@GznthZ8@pN@tA_e5NByfHE`X9j0famrG3e z?*=VSu{u#F9i&N?G_M=SRwkOnLwztx+<1nSKWc?|)E1Xxwluhs>4K7)G}$;3k&@krqU^wN zecwCYTk-Bo>Okt-84?oh_MYcHyyslg^*#^vhN=kYNuYpfj4C@}4 z)*R$WY`8=?n~-X`#mNp;dM782mq+D~Izn`XzAq~DN%1>8x)-fjnh>RrZ--+}$gD*k zx5zg0dkpwZa)!9Qt-u)|D-dEG*%W)BW{YIuPSPvDC`##BHiyiD;rD z#{T!SHc4hT7n5u{QoIQtu}$AP971Hc0Mb|Wp8#y(wZWK@idfdfL-1R#zTFG{;}!qy zxz~-VH%)zc8Piz+0M`HEx!3&p#{KrlXY0TiqVHh5vv0^umkY7>{}Rye1Z?931|W-x z0?Cj?KKetfexpbyqs{#M0UEi!Ikd$}QMdW+LIHnW#A2acug4{7^>x!tb#p1djWtqp z^`$p!qh^DD-snfc=jW%Jp6Aya-p@UJ7sI5nXfxU%Zl0+!#%cS8-(@F00 zE{UCH7WeSsX58Au8fSL>+A~kw+*-%%VLY~))RSl)&QpqL&hZXq0(l<$uhr)kDI*-w z-4!#5xUbR9F{4`#*FabnYZP!z)@%8WrNg{x-AbhkovLN++x4y+F*!T|ZwARRUZGN$JIz%|I9d$>EM=@H$cWq((yz46=^UWPfJZRz3|hR4aQ1cp*H3J}cImG0kvZ9Z`hZ2h z;5?dV$U~ZWV~|ku&Mf;n>>dfYLr>#Z5XP{lyW^NS+yOBSa>|@UC7a>T0 zBtRNFW#uEa)xq|ZHS8WKeKK=f2fC5D0P|dChz* zm^i8$)-+j)Z-iI5HNo55(B8mzP|jpo)O&ZrBD)P*6G(Qcyp>&>GOs* zg)71{6i7V+UT>BIZ%SU(Z7$>r=y zDH1i2A|rrW=MEx}AdKanb`1w(P2HgHCvud?W)2DAkC+FZy8NG{mNzok9r7;?)B9cf z9GJRu%JZ6Rpyxgsa+yhN=}vro?XT2-6lNoJ$(*hE$;Z-Av6m1w=;6olnFZ5Y?2`bi z4sVU1V(cmXKBhpa{2&xW$ArW3A%6Mhod#HEyKUoK2eXx+kiVjp;3RE_L_AQDAK zG896Y!PgHH1cIw6JeaX8@|Llpj;mmzF*UMvPS_=gnfzylWNYX#nKKSV5TW}B60O=A zbN2znb9U_yh4F*Pb5S;pT89;nWK0M#%XPIQ_^2Ap$+6{+=t%kYr2O=q?hcTU{{R1BMrAe8U{6zk0J)4hxbfaa$V5zGgtGZn>mrROr&dCzZwT7 zgn$%DmCGL4i8vi}cFglc(yGd9ht{n73sbV@opeC}YWQ4{_5v^utKl{3ghvKmPHgLM z?9ibkj=(i;^auzqV+5o4W~_Sx;2=ku+42deH)on}eAxk)xb@oxg;z3;u*^b%#z#Y2 zyDN5v;op|=+klp%8jzjbc6VDVp1m{LPE@$zl3paleo$E1mSEld)QGg}^N$5WBmzo| z&S{wPgZT{Nq-gEn^@g#RO!VWid;POL!lR#&mVyW**2BFxpFcu*Ta8$uT1;C*S@SSQiox`v;w{iVp(zs0>(rusmNJk-dH z8W^H5RcJgD9;Fk>6#@2Wtl*Yn!5T^u2K_PDMn4{>-jEM98CwuR0xXEaYtwbcqmFSJ z&7PG}HjQL%)+a6#>4$8{xs$oh-L44@KCBpcARzAG+A7?GDX7WPQsh_xLNq0t!bOHS z7KX;QPFlQwEr^dVNd`1NQdMV%KbO**XG#8PQHV*WqkBR zewDuWlZn5<*oP>6dfJLd`M{o`4Kp3=C^hrSvQH|?&BY`J@~-k%G|4^2r_PUW0(&HH za4sRemh-%euejS+^St+&oQh8_vT<}Hd&}33A?IU5azbJ~ip+ zm`$UxoCJ`jLp>+taxRZM*FQYs5LzF!>ytlgSKhkKH#d)C1u-U2FM!7j&*hEmJDNyV z8c!LzVy8&?Wz3`{o0pbIRv$awA2pwpU5Gx4UQarz9eTFQN&^y6{`i|}L*wE*P!J+SMjzEv+ zYwTuzx)IbnnuJ(vO2s>jjx?(oM8!yt76^7qnMpFl*==#UafcE3+*eB!K&dH!&4+Q) zD_(_niQ83kE{O1i^^C_o`$hAP-9$RjVDFhIhcb?gymvSZ$2xov@0vJab_j4CDPUkg zhH}z;t;+ift8Z6|&BIg3OuIMd3G|kuM<4-sb>DADjRq0t4dyu4%85+~@D6+)e^$Li z2#g(&fu*jZiO5H%Vo-M!eU>3sy@CL95d;5Y6$Y0HD-u)OI-(@s5(k);;+9E4H z@*ZcX-#?8J`I@|cx0@Jy`sk|f^A{fhR@`VYtb3WMnsRDYj0GjOVqhbM)|wbAf$aD+ zQEo^9?NG?u>47+6#n)z3ssJdETK!dLl>R0nl7FCwSWh|Y2%MFZwhA)Qsbr_m}sn37)l%N)oO@Bjm}<*P?>PKVJK zO|SH*|GEgsdMgBv9+Ba{Ze+kILuHL_JSZ^`rfgvop`jTJ03$}Jc z)Lw6?37RWZqzGRbO^<^D*0?67_!Q~EtxYNf`d*Sr172Ko^_>cPOkc+B=41UMy|UPs zYl0%6=-EtJK3$PAnRq1yS|Q!riZR|=RtjyCX*qRC?FQy}xi?V>Af*&4b6k*$X?lJV z&HGXDa&(oYm6_$~*%>v1rJ1I#p{41?mab~h7{&NEa3f|#1LSMjV=yiBs~s{h{4>68 zL+p6GSmW{fZ@z)~!D-#6WN`cK`AM=CZKk};V{vHcjv*?2&+%PqB=R8OoOY=`K1?9Z zzF$GXgaQr}9v^uywuYrDRy0&darfAkN1z#9NU}}OXt`UKNhZ(am!d7rWGm6Li;L+a zvPwqLa^;i(ojip58@d}^ubW)UAGr}7T35x)!i|j(4Vt6I#ZDZc*QUu8Rb~}D#dv60 zpDErix7$%6jB}gJjbKu?fyQ#ur6;xDd!E3YrzNJSgwtK5v-{~KDeM9xx3&h&ACe}j z`CZ|~NWHUZZjxdRq@XEz9d;ax!;xe>AIb3&Sq@1LH(_UbQP z-?Vt^-Z*kmjIPYIB@b{@kWh0u_svBdI5IJV2R3V8NocgH0nRaC$H`|+Vc_ze?RHi0 zR!j*U8<7Ip+ZI1oo2Z&RWd$1UluwWI=it}}6qqINYLbQU7IKLwVz8&p^cl(_0#)L3 z3M{k}``oWGvtZGLzgjo?a$7F9ta7fQpA7i-_`Hp;W(^mV z*Ui+8&3=!%D{mlWaV76!Ru@AZ*{Fc4_*rzJvs7iW^wW^y^6mSPr+AH<&3&(NpsHUV{kQULHk zri0F5ig|6R$3q$^sc7PW{H;Y%JX^)%OOaBB`&)f6t+?D&u8IL$j&Cbp=>%S*bfD^F z_$8lp3&8m^I<5xN$igzQ+DI6`?dHT2l3!F$bT#c|vmJ#ppBLid0tI`~XdRLQiRU6} z-cpw7Ry`#4$Kjl$;;Gy&VNQ?C>e#zM=@si%8IX(kYvD7YAHl(ypDTysiILr^OxJq&_p;k;g~^>Nd2W@sgU zZjvZCrybd5qoqs9B}zG8z(|pBuJuJh@YiokiG^tqN1<}0m9v%|Sr^I$RW}~VTgmdC zMr3=9KB-hEldVl=tBrA=(afnT9q1j+m&Djka?%%xx?rJDZ^zRbSGpp-9$pPx>~bMw z_9ZYlJPQWr#E}f+mq~z4*lyn_NZC`P>1_;HQuwJfr&et7HG_5}-SB70hNBJR3UPu*RSTfkwrO?)wmZoM(8uXLM8MK(4>4iOEEQ`i3V3!Pf zi7_U5o5f7iP1)F#v~6Phw)U$g6mdiEr#opTDB!&;bO0wlYaFCg!qV4XW(kOxe_`Kv zM_*?k@X$Q{j=sSlvOb<%ng@-ie@W%RI^9RRde+#bdrn40Tee-hdokHgv&z-j$~1YR z0Cd=Zx1irTG3*y*_N7B2Ls}5JZBKJ_N1Hi(s)y2@^>VrNK<6PKI=OinN8mulsA1Y zQSN(wnUm#c99TE%mmXZg^9(8G6xRlB<57Fht|<7F@8l%c?P-;+MN8*b8LDJ21ZBhV zPi@{!_1LPfp?Ic9O{sg)w1P_SO_+%mYie$*n#-Ww#z6n93t zg%M?iX+-@X4SOCRJ7^lTA%74I*C|2Xs=kigfSSa^t13;RxOIOaPfCZQH+df-*5x(l zY=Q4P4sWS%_(B%Tnn8!=J z65SbO7hpoT2i1;~u-5OJw}Eg^wU3s!0e4R;3pQp0(Oz6Pp9y-eIYDFjZ< zhiIhP6BL8l81w`e27p;Zmgh%gFQfSZide@|zi*c5Q9fv;`4MBW5|OZEaR8P4&R{ug zgq-HbD?bAs#Jk90xq7f8a+1hfE|qF;K7X+sC>)(K0>H?nb#$r?owcFDv{^Cj6`-f~<>P z8f*L^feaE6b@aq7~)J^AktP`{<+C$Tx=fK2I{@efVClveSI*zwFhl007WCOdCn6F9B11X z?#P>VV+09+<5u7*i;F{$Z^ifh6$?HT%bIMYl5mP~5fniS*t=$^a8uTb7C5EWH}LWj z%*KKdmDWmHpF{5V?24u+<0*0nF2u=tHfHBet7~VhFX=k=@yhhN2gdig^%XYu*e{*+ z6?=q=1g3Nk#HAjCAlx2IDu}8t{ZTy#aauAoL8_Cj|R} z(jHABg z2K2ZYQnqsQ#FC#6!*E2qR2 zXYCRP=QYrlJ?BJkzIr|YyNr`+j?UZpzvJh-zbCFowhynn01_d>?@-p*W4g zEjEOP6s9>6DIF+k>*Z`$2Ccm#wLtIHkvN;X2rc&*V#49|pzcY`ZJkl~*L5d=3B2MP z_mynIIs?K>Jx&qAc0tAG&|*PbibK5-@gk#m96k82B_=Q$shBnh!z#hG5|0KnX!11Y zTg9YnG~A<7pSfiS>lg9!4_PTyXlwIhu>DZgw+WlLpghD?%8~|ma$1;DVRG8EIp$qK{{t`Tbhw z{<#}e*QR+{^s>Hf{R#m1xf^6-Z_Z}L;NWEMVCv*zY3iIE(*fHrge?BxGxXlZyk(X} z+{Yg|Do?|zK5YzF+dXAW0{v`5UJgWL;XTFgZU$iesBX$fR^)2LE;qItT%&atU}Gp1 z%a9Q6GZ0_6V-=5>e~F{sT?pcC!fCLX3)5Ok=gC%X3HE9r3v^sMTq;tI)%ULMlx!J; z_H)e?<=k#~*O^$*#Nm_gz_oG71h)N+9p=5QpmM*Qep8NdF|5jC_5s8_y3 z;{UR1MeuU|^Zu5xy{&`2ovEFRGlPe%&4P+B>;MsRC)R!FdgLy6ALr%bMFN9FLCtAFQ=I1whw%JZmT@T4-(?w9AU zGCDiEH@az)qJ^R;Hbw(r*bq~8S3OM-;as(Ae6|9_Q~CW7JWZuq+M_p$wbj07-#af* zLQR$Qk~l(0{7`#USSL1@^?V+zQn-Ju{m7Gb*?N$~;+o09ZhX87_s?P4!}h~@yll$N zBLM(EH(6~hZA}?mJRMA(lQqXv)~Mh=?`1s(Dl}v{x)NlIr(C>h%v?C4>gB*N2KL8( ztIef7`c~0E>&ydZG@UFo`o1Xk`-X3kpU#L{$0TZZwS0vU znDcmCM622FBL|k22bYYq32EXC%d9NOm}6d;*rk)KU@z~)P5RB6gxf;#I;@BcKPuL% z+A;6`;Kg3&u4d8j;VD-}IZ$B!?k_|BxqSvTTdh5`Yj$DgsyaJY5T#krJil7X&i7{I zh_|4)r_U313^_gTyMJEe1je38GG#x_Y24>9Z1FsVMoXZ}R8$GYbO;VtqXL5&$-M1aR*b z#U?$pXc;1Mp$n_tLJ-QgRTr{0o#~RT89j=3LvfZyx20>6hwi6)Fge^>&U)#1sB1lL z@8v-AWN=_6U3JId>d92M7`Sx>_Z37t$r0}VbS%?4c^lnK;Tp(*7XZ0o*`8w#FE zpv6+|k>*nxUi5>!yAwWg{?o=NROzvg=jyQMLxVg%IMg0wPt=L&t_*kV$cX)l?)vjjp zx~zK!4f4eVmsS=hIy0JM%<~!vd|G( zsEQp!Tu83baZzC%fx~;7XIBqE1upGz_sEDyLN2bu7$IH`y~|!CXJ93J0CWg$fC0%!f))-dwlQp zE!8b)>tT;2@+J<|#bQAgq(pJZ-~B-*V&k375gna+`UjtlojwU0A`S;r28otRBMeF{ zhuF2B?z|i5`tY?mE~TV9U>!&D5`0}G78Le_p1>A-hcq!ZSJ*(vKsW+I`6fX0+o<2` z8o9-KQ5n^Qu;gGg`MjI&GSl8X_fjImuNkYLQaGF%Q}P?x{TT*SnSu1l>(m@rWIXiT zsJNFw=d`n&5bf}uHW8|47+!6s&W`9h`Ad)ngeJ_1MO{Q5?T<>u?7LsQAr&^8l4{|% z%axPK5c#p4!G}1u->(wHKcwTiNl7{J=$1c4;S=&az4CBrdp{ycnXnY!)EsUS#;!r*VpM?u zg6*Jc$P_h&r~)7q4E&J(xBO;(9Zqc;(sBnSVD<+naeOfop$3%|e{9CaeymCbr*?X6 z70?B!zqWc=A{n_!j=jPTInzVWhg6^3% zgY<6`nDfH$YK_r}BX$l%3q88QDFh>q-Dis>Wt?K*Sg8Xg>AnKL++;B?c(5XH)&wBb zXl)_~nNfT3kSbGPjF5Ak{Jw$wuXLA}A^KMu`fqFw+J6TzRRq$ z$3{r6K5qFwy4-YZk<&o>k%XULi%)LHn&TCN$VDL6Q8B-uQsSXH#OKEn+Rca~Lw(h* zZd;SJYIg5{%`d;(NU#a(<3YNi$1d<@p<2Rf6{ewOpjqla*{`fkzI*+Ce(x zw(o-rV!4zZszNIfuV+z#u=mIWcKe zY+5Ciy8Ak++gziCbn289Cv>Rc}<=t#5q3HN-FLYG|JRf)8;`WL-tQ zl8}{(+@{LiK-n2wE z@bQC^=CotkHWtQ4w?~?ir zUp+`Ub4Cg12u!nKl2K}bAz4}tQHXfYnt4JFD7m51I*SLw%&=+jgYb|7rHzgyjQMF< zds=X&5UdvvqdgsFGKpefPj}9pX$gUX1Nq?K5!3c!-$B=D)SjG!*WgPDvj%t>~$b!qOZ&Q_0Y0v%jjgV z5AsJaI*QKCyX7lA!))1k`(*s3W!yzOeo5abJBO$h6(+9u;CG*`+B0vTLw#bMN(ogg zd>wVpCj*Dnb`dA{%gjhz=$fo9R=YH`OvDzJChYgXDL-HL)N}40g|YGyC(BGjC+6vX zG-Ri057uGP10iVk#3#sBeDH8HFk8+ZD!F0k&;_pX$a2}#0?;U;L<(G?tN~V}qw9#g zud%Lk!3__0TFB_g=?+pbX}=jNvL!%OjgRBp;<_h{Ll$PtjXh&#of|;x^&x?S?eA2_ z?uvPg5QLpw02Jp_N_tiGW(%=h{rs@Rq_ki0?;P4b0_il5sj|LW_~B&WHy)AkHE$}5 z4VXJeS-{RlN^z8E3&WiwshUNYk;YA~u!X#t+gjQ9RdxSl{@GP5ZM=UO^%7pbD*un8 z->(Ums+;13EWHe!w7jxZh1#h+o$SPx3gQ@*`l%v=G82E)U^ zX*szg$y#a&=_zS?U}{j9i{cvPaLV8>OvhJX=-~gJw)qnDOG5@63;#U-KKlQH{WXpA zr|{2K{2lfOQs*xX|H`5KNwWN{{WLuOa``vP3Pm26o{+l2Ffx`JK+P|jA{jyT?QXl+vkNrK`KUmTHMVR}efxlXt z_+=mk?_XCc{*30Zq#ml|`1ca> rkB0why#8f)i0Xgp!+#s /dev/null; do + echo "$(date) - waiting for mysql (client)" + sleep 1 +done +until docker-compose $DC_ARGS run ping-db mysqladmin ping -h internal-db -u root --protocol=TCP > /dev/null; do + echo "$(date) - waiting for mysql (internal)" + sleep 1 +done + +# script is invoked with a service parameter +echo "Starting tests for $SERVICE..." +docker-compose $DC_ARGS run app sbt -mem 3072 "$SERVICE/testOnly $TEST_PACKAGE" + +EXIT_CODE=$? + +echo "Stopping dependency services..." +docker-compose $DC_ARGS kill + +exit $EXIT_CODE diff --git a/server/single-server/src/main/resources/application.conf b/server/single-server/src/main/resources/application.conf new file mode 100644 index 0000000000..38ce647a94 --- /dev/null +++ b/server/single-server/src/main/resources/application.conf @@ -0,0 +1,153 @@ +akka { + daemonic = on + loglevel = INFO + http.server { + parsing.max-uri-length = 50k + parsing.max-header-value-length = 50k + request-timeout = 600s // Deploy mutation is too slow for default 20s + } + http.host-connection-pool { + // see http://doc.akka.io/docs/akka-http/current/scala/http/client-side/pool-overflow.html + // and http://doc.akka.io/docs/akka-http/current/java/http/configuration.html + // These settings are relevant for Region Proxy Synchronous Request Pipeline functions and ProjectSchemaFetcher + max-connections = 64 // default is 4, but we have multiple servers behind lb, so need many connections to single host + max-open-requests = 2048 // default is 32, but we need to handle spikes + } +} + +jwtSecret = ${?JWT_SECRET} +auth0jwtSecret = ${?AUTH0_CLIENT_SECRET} +auth0Domain = ${?AUTH0_DOMAIN} +auth0ApiToken = ${?AUTH0_API_TOKEN} +systemApiSecret = ${?SYSTEM_API_SECRET} +stripeApiKey = ${?STRIPE_API_KEY} +initialPricingPlan = ${?INITIAL_PRICING_PLAN} +awsAccessKeyId = ${AWS_ACCESS_KEY_ID} +awsSecretAccessKey = ${AWS_SECRET_ACCESS_KEY} +awsRegion = ${AWS_REGION} +clientApiAddress = ${CLIENT_API_ADDRESS} +privateClientApiSecret = ${PRIVATE_CLIENT_API_SECRET} +schemaManagerEndpoint = ${SCHEMA_MANAGER_ENDPOINT} +schemaManagerSecret = ${SCHEMA_MANAGER_SECRET} + +logs { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"/"${?SQL_LOGS_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +logsRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + connectionInitSql="set names utf8mb4" + properties { + url = "jdbc:mysql:aurora://"${?SQL_LOGS_HOST}":"${?SQL_LOGS_PORT}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000&useUnicode=true" + user = ${?SQL_LOGS_USER} + password = ${?SQL_LOGS_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +internal { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"/"${?SQL_INTERNAL_DATABASE}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +internalRoot { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_INTERNAL_HOST}":"${?SQL_INTERNAL_PORT}"?autoReconnect=true&useSSL=false&serverTimeZone=UTC" + user = ${?SQL_INTERNAL_USER} + password = ${?SQL_INTERNAL_PASSWORD} + } + numThreads = 2 + connectionTimeout = 5000 +} + +clientDatabases { + client1 { + master { + connectionInitSql = "set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + + readonly { + connectionInitSql = "set names utf8mb4" + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql://"${?SQL_CLIENT_HOST_READONLY_CLIENT1}":"${?SQL_CLIENT_PORT}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&useUnicode=true&characterEncoding=UTF-8&socketTimeout=60000" + user = ${?SQL_CLIENT_USER} + password = ${?SQL_CLIENT_PASSWORD} + } + readOnly = true + numThreads = ${?SQL_CLIENT_CONNECTION_LIMIT} + connectionTimeout = 5000 + } + } +} + +allClientDatabases { + eu-west-1 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_EU_WEST_1_CLIENT1}":"${?SQL_CLIENT_PORT_EU_WEST_1}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000" + user = ${?SQL_CLIENT_USER_EU_WEST_1} + password = ${?SQL_CLIENT_PASSWORD_EU_WEST_1} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } + + us-west-2 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_US_WEST_2_CLIENT1}":"${?SQL_CLIENT_PORT_US_WEST_2}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000" + user = ${?SQL_CLIENT_USER_US_WEST_2} + password = ${?SQL_CLIENT_PASSWORD_US_WEST_2} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } + + ap-northeast-1 { + client1 { + master { + dataSourceClass = "slick.jdbc.DriverDataSource" + properties { + url = "jdbc:mysql:aurora://"${?SQL_CLIENT_HOST_AP_NORTHEAST_1_CLIENT1}":"${?SQL_CLIENT_PORT_AP_NORTHEAST_1}"/?autoReconnect=true&useSSL=false&serverTimeZone=UTC&socketTimeout=60000" + user = ${?SQL_CLIENT_USER_AP_NORTHEAST_1} + password = ${?SQL_CLIENT_PASSWORD_AP_NORTHEAST_1} + } + numThreads = 2 + connectionTimeout = 5000 + } + } + } +} \ No newline at end of file diff --git a/server/single-server/src/main/resources/graphiql.html b/server/single-server/src/main/resources/graphiql.html new file mode 100644 index 0000000000..e788b78238 --- /dev/null +++ b/server/single-server/src/main/resources/graphiql.html @@ -0,0 +1 @@ + Graphcool Playground
Loading GraphQL Playground
\ No newline at end of file diff --git a/server/single-server/src/main/resources/logback.xml b/server/single-server/src/main/resources/logback.xml new file mode 100644 index 0000000000..f640063cc1 --- /dev/null +++ b/server/single-server/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/server/single-server/src/main/scala/cool/graph/singleserver/Converters.scala b/server/single-server/src/main/scala/cool/graph/singleserver/Converters.scala new file mode 100644 index 0000000000..fcf41ce01f --- /dev/null +++ b/server/single-server/src/main/scala/cool/graph/singleserver/Converters.scala @@ -0,0 +1,31 @@ +package cool.graph.singleserver + +import cool.graph.messagebus.Conversions.Converter +import cool.graph.subscriptions.protocol.SubscriptionRequest +import cool.graph.webhook.Webhook +import cool.graph.websockets.protocol.Request +import cool.graph.worker.payloads.{LogItem, Webhook => WorkerWebhook} +import play.api.libs.json.{JsError, JsSuccess, Json} + +/** + * Necessary converters to make queueing and pubsub possible inmemory. + */ +object Converters { + + import cool.graph.worker.payloads.JsonConversions.logItemFormat + + val apiWebhook2WorkerWebhook: Converter[Webhook, WorkerWebhook] = { wh: Webhook => + WorkerWebhook(wh.projectId, wh.functionId, wh.requestId, wh.url, wh.payload, wh.id, wh.headers) + } + + val string2LogItem = { str: String => + Json.parse(str).validate[LogItem] match { + case JsSuccess(logItem, _) => logItem + case JsError(e) => sys.error(s"Invalid log item $str, ignoring message.") + } + } + + val websocketRequest2SubscriptionRequest = { req: Request => + SubscriptionRequest(req.sessionId, req.projectId, req.body) + } +} diff --git a/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerDependencies.scala b/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerDependencies.scala new file mode 100644 index 0000000000..b3cfa6f0fe --- /dev/null +++ b/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerDependencies.scala @@ -0,0 +1,144 @@ +package cool.graph.singleserver + +import akka.actor.{ActorSystem, Props} +import akka.stream.ActorMaterializer +import com.amazonaws.auth.{AWSStaticCredentialsProvider, BasicAWSCredentials} +import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration +import com.amazonaws.services.kinesis.{AmazonKinesis, AmazonKinesisClientBuilder} +import com.typesafe.config.ConfigFactory +import cool.graph.bugsnag.BugSnaggerImpl +import cool.graph.client.FeatureMetricActor +import cool.graph.client.authorization.ClientAuthImpl +import cool.graph.client.finder.{CachedProjectFetcherImpl, ProjectFetcherImpl, RefreshableProjectFetcher} +import cool.graph.client.schema.simple.SimpleApiClientDependencies +import cool.graph.messagebus._ +import cool.graph.messagebus.pubsub.inmemory.InMemoryAkkaPubSub +import cool.graph.messagebus.queue.inmemory.InMemoryAkkaQueue +import cool.graph.relay.RelayApiClientDependencies +import cool.graph.shared.database.GlobalDatabaseManager +import cool.graph.schemamanager.SchemaManagerApiDependencies +import cool.graph.shared.externalServices.{KinesisPublisherImplementation, TestableTimeImplementation} +import cool.graph.shared.functions.dev.DevFunctionEnvironment +import cool.graph.shared.functions.{EndpointResolver, FunctionEnvironment, LocalEndpointResolver} +import cool.graph.subscriptions.SimpleSubscriptionApiDependencies +import cool.graph.subscriptions.protocol.SubscriptionProtocolV05.Responses.SubscriptionSessionResponseV05 +import cool.graph.subscriptions.protocol.SubscriptionProtocolV07.Responses.SubscriptionSessionResponse +import cool.graph.subscriptions.protocol.SubscriptionRequest +import cool.graph.subscriptions.resolving.SubscriptionsManagerForProject.{SchemaInvalidated, SchemaInvalidatedMessage} +import cool.graph.subscriptions.websockets.services.{WebsocketDevDependencies, WebsocketServices} +import cool.graph.system.SystemApiDependencies +import cool.graph.system.database.finder.{CachedProjectResolver, CachedProjectResolverImpl, UncachedProjectResolver} +import cool.graph.webhook.Webhook +import cool.graph.websockets.protocol.{Request => WebsocketRequest} +import cool.graph.worker.payloads.{LogItem, Webhook => WorkerWebhook} +import cool.graph.worker.services.{WorkerDevServices, WorkerServices} +import play.api.libs.json.Json + +trait SingleServerApiDependencies + extends SystemApiDependencies + with SimpleApiClientDependencies + with RelayApiClientDependencies + with SchemaManagerApiDependencies + with SimpleSubscriptionApiDependencies { + + override lazy val internalDb = setupAndGetInternalDatabase() + override lazy val kinesis = createKinesis() + override lazy val config = ConfigFactory.load() + override lazy val testableTime = new TestableTimeImplementation + override lazy val apiMetricsFlushInterval = 10 + override lazy val apiMetricsPublisher = new KinesisPublisherImplementation(streamName = sys.env("KINESIS_STREAM_API_METRICS"), kinesis) + override lazy val featureMetricActor = system.actorOf(Props(new FeatureMetricActor(apiMetricsPublisher, apiMetricsFlushInterval))) + override lazy val globalDatabaseManager = GlobalDatabaseManager.initializeForSingleRegion(config) + override lazy val clientAuth = ClientAuthImpl() + + override implicit lazy val bugsnagger = BugSnaggerImpl("") + + override protected def createKinesis(): AmazonKinesis = { + val credentials = new BasicAWSCredentials(sys.env("AWS_ACCESS_KEY_ID"), sys.env("AWS_SECRET_ACCESS_KEY")) + + AmazonKinesisClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withEndpointConfiguration(new EndpointConfiguration(sys.env("KINESIS_ENDPOINT"), sys.env("AWS_REGION"))) + .build() + } +} + +case class SingleServerDependencies(implicit val system: ActorSystem, val materializer: ActorMaterializer) extends SingleServerApiDependencies { + val pubSub: InMemoryAkkaPubSub[String] = InMemoryAkkaPubSub[String]() + val projectSchemaInvalidationSubscriber: PubSubSubscriber[String] = pubSub + val invalidationSubscriber: PubSubSubscriber[SchemaInvalidatedMessage] = pubSub.map[SchemaInvalidatedMessage]((str: String) => SchemaInvalidated) + val invalidationPublisher: PubSubPublisher[String] = pubSub + val functionEnvironment = DevFunctionEnvironment() + val blockedProjectIds: Vector[String] = Vector.empty + val endpointResolver = LocalEndpointResolver() + val uncachedProjectResolver = UncachedProjectResolver(internalDb) + val cachedProjectResolver: CachedProjectResolver = CachedProjectResolverImpl(uncachedProjectResolver)(system.dispatcher) + val requestPrefix = "local" + val sssEventsPubSub = InMemoryAkkaPubSub[String]() + val sssEventsPublisher: PubSubPublisher[String] = sssEventsPubSub + val sssEventsSubscriber: PubSubSubscriber[String] = sssEventsPubSub + + // API webhooks -> worker webhooks + val webhooksQueue: Queue[Webhook] = InMemoryAkkaQueue[Webhook]() + + // Worker LogItems -> String (API "LogItems" - easier in this direction) + val logsQueue: Queue[LogItem] = InMemoryAkkaQueue[LogItem]() + + // Consumer for worker webhook + val webhooksWorkerConsumer: QueueConsumer[WorkerWebhook] = webhooksQueue.map[WorkerWebhook](Converters.apiWebhook2WorkerWebhook) + + // Log item publisher for APIs (they use strings at the moment) + val logsPublisher: QueuePublisher[String] = logsQueue.map[String](Converters.string2LogItem) + + // Webhooks publisher for the APIs + val webhooksPublisher: Queue[Webhook] = webhooksQueue + + val workerServices: WorkerServices = WorkerDevServices(webhooksWorkerConsumer, logsQueue) + + val projectSchemaFetcher: RefreshableProjectFetcher = CachedProjectFetcherImpl( + projectFetcher = ProjectFetcherImpl(blockedProjectIds, config), + projectSchemaInvalidationSubscriber = projectSchemaInvalidationSubscriber + ) + + // Websocket deps + val requestsQueue = InMemoryAkkaQueue[WebsocketRequest]() + val requestsQueueConsumer = requestsQueue.map[SubscriptionRequest](Converters.websocketRequest2SubscriptionRequest) + val responsePubSub = InMemoryAkkaPubSub[String]() + + val websocketServices = WebsocketDevDependencies(requestsQueue, responsePubSub) + + // Simple subscription deps + val converterResponse07ToString = (response: SubscriptionSessionResponse) => { + import cool.graph.subscriptions.protocol.ProtocolV07.SubscriptionResponseWriters._ + Json.toJson(response).toString + } + + val converterResponse05ToString = (response: SubscriptionSessionResponseV05) => { + import cool.graph.subscriptions.protocol.ProtocolV05.SubscriptionResponseWriters._ + Json.toJson(response).toString + } + + val responsePubSubPublisherV05 = responsePubSub.map[SubscriptionSessionResponseV05](converterResponse05ToString) + val responsePubSubPublisherV07 = responsePubSub.map[SubscriptionSessionResponse](converterResponse07ToString) + + bind[QueueConsumer[SubscriptionRequest]] identifiedBy "subscription-requests-consumer" toNonLazy requestsQueueConsumer + bind[PubSubPublisher[SubscriptionSessionResponseV05]] identifiedBy "subscription-responses-publisher-05" toNonLazy responsePubSubPublisherV05 + bind[PubSubPublisher[SubscriptionSessionResponse]] identifiedBy "subscription-responses-publisher-07" toNonLazy responsePubSubPublisherV07 + bind[WorkerServices] identifiedBy "worker-services" toNonLazy workerServices + bind[WebsocketServices] identifiedBy "websocket-services" toNonLazy websocketServices + bind[PubSubPublisher[String]] identifiedBy "schema-invalidation-publisher" toNonLazy invalidationPublisher + bind[QueuePublisher[String]] identifiedBy "logsPublisher" toNonLazy logsPublisher + bind[PubSubSubscriber[SchemaInvalidatedMessage]] identifiedBy "schema-invalidation-subscriber" toNonLazy invalidationSubscriber + bind[FunctionEnvironment] toNonLazy functionEnvironment + bind[EndpointResolver] identifiedBy "endpointResolver" toNonLazy endpointResolver + bind[QueuePublisher[Webhook]] identifiedBy "webhookPublisher" toNonLazy webhooksPublisher + bind[PubSubPublisher[String]] identifiedBy "sss-events-publisher" toNonLazy sssEventsPublisher + bind[PubSubSubscriber[String]] identifiedBy "sss-events-subscriber" toNonLazy sssEventsSubscriber + bind[String] identifiedBy "request-prefix" toNonLazy requestPrefix + + binding identifiedBy "project-schema-fetcher" toNonLazy projectSchemaFetcher + binding identifiedBy "projectResolver" toNonLazy cachedProjectResolver + binding identifiedBy "cachedProjectResolver" toNonLazy cachedProjectResolver + binding identifiedBy "uncachedProjectResolver" toNonLazy uncachedProjectResolver +} diff --git a/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerMain.scala b/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerMain.scala new file mode 100644 index 0000000000..ff53828740 --- /dev/null +++ b/server/single-server/src/main/scala/cool/graph/singleserver/SingleServerMain.scala @@ -0,0 +1,39 @@ +package cool.graph.singleserver + +import akka.actor.ActorSystem +import akka.stream.ActorMaterializer +import cool.graph.akkautil.http.ServerExecutor +import cool.graph.bugsnag.BugSnagger +import cool.graph.client.server.ClientServer +import cool.graph.schemamanager.SchemaManagerServer +import cool.graph.subscriptions.SimpleSubscriptionsServer +import cool.graph.subscriptions.websockets.services.WebsocketServices +import cool.graph.system.SystemServer +import cool.graph.websockets.WebsocketServer +import cool.graph.worker.WorkerServer +import cool.graph.worker.services.WorkerServices +import scaldi.Injectable + +object SingleServerMain extends App with Injectable { + implicit val system = ActorSystem("single-server") + implicit val materializer = ActorMaterializer() + implicit val inj = SingleServerDependencies() + implicit val bugsnagger = inject[BugSnagger] + + val workerServices = inject[WorkerServices](identified by "worker-services") + val websocketServices = inject[WebsocketServices](identified by "websocket-services") + val port = sys.env.getOrElse("PORT", sys.error("PORT env var required but not found.")).toInt + + Version.check() + + ServerExecutor( + port = port, + SystemServer(inj.schemaBuilder, "system"), + SchemaManagerServer("schema-manager"), + ClientServer("simple"), + ClientServer("relay"), + WebsocketServer(websocketServices, "subscriptions"), + SimpleSubscriptionsServer(), + WorkerServer(workerServices) + ).startBlocking() +} diff --git a/server/single-server/src/main/scala/cool/graph/singleserver/Version.scala b/server/single-server/src/main/scala/cool/graph/singleserver/Version.scala new file mode 100644 index 0000000000..172c9979ed --- /dev/null +++ b/server/single-server/src/main/scala/cool/graph/singleserver/Version.scala @@ -0,0 +1,52 @@ +package cool.graph.singleserver + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer +import cool.graph.graphql.GraphQlClientImpl +import spray.json._ + +import scala.concurrent.Future + +object Version { + import DefaultJsonProtocol._ + + def check()(implicit system: ActorSystem, materializer: ActorMaterializer): Future[_] = { + import system.dispatcher + val client = GraphQlClientImpl("https://check-update.graph.cool", Map.empty, Http()(system)) + + client + .sendQuery(""" + |mutation { + | checkUpdate(version: "1.0.0") { + | newestVersion, + | isUpToDate + | } + |} + """.stripMargin) + .flatMap { resp => + if (resp.is200) { + val json = resp.body.parseJson + val updateStatus = json.asJsObject + .fields("data") + .asJsObject() + .fields("checkUpdate") + .asJsObject() + .fields("isUpToDate") + .convertTo[Boolean] + + if (updateStatus) println("Version is up to date.") + else println("Update available.") + } else { + println("Unable to fetch version info.") + } + + Future.successful(()) + } + .recoverWith { + case _ => + println("Unable to fetch version info.") + Future.successful(()) + } + } +}