diff --git a/README.md b/README.md index b2fda89..c26232e 100644 --- a/README.md +++ b/README.md @@ -55,44 +55,21 @@ Each transition is a UI action. A state is defined by the set of all visible and interactable windows together with their enabled widgets. ## REST API -Some suggestions on how the REST API could look like. +Some suggestions on how the REST API could look like: -state/create -Parameter: -List(windows): -- window: -- id -- parent -- List(InteractableComponent): --- label - -action/create -Parameter: -- state from -- state to -There is a special ID for the unknown state. - -action/update -- state to - -action/delete -Infeasible actions must be deleted before their actual execution. - -Edges to the unknown state might lead to another state after some tests. - -Simplified REST API for automatic tests which discover new states and transitions: -state/add -Parameter: -- State-Parameter -- Source State - -Automatically, a transition to the unknown state. +* `/applications` GET queries all registered GUI applications +* `/create-application` POST registers a new GUI application +* `/application/` GET queries a registered GUI application +* `/application//test-suites` GET queries all test suites for an existing GUI application +* `/application//create-test-suite` POST registers a new test suite for an existing GUI application +* `/application//test-suite/` GET queries a registered test suite for an existing GUI application ## APIs suggested in the project proposal of Surili * [Selenium WebDriver](http://seleniumhq.org/docs/03_webdriver.jsp) * [Gherkin](https://github.com/cucumber/cucumber/wiki/Gherkin) ## REST Frameworks for Scala +* Good example of a REST service with Akka: * * * diff --git a/build.sbt b/build.sbt index c3ea824..aee92b6 100644 --- a/build.sbt +++ b/build.sbt @@ -7,7 +7,10 @@ organization := "tdauth" scalaVersion := "2.12.7" libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.1.5" +libraryDependencies += "com.typesafe.akka" %% "akka-http-core" % "10.1.5" libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.5.12" +libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.5" +libraryDependencies += "com.typesafe.akka" %% "akka-http-xml" % "10.1.5" libraryDependencies += "com.typesafe.akka" %% "akka-http-testkit" % "10.1.5" % "test" libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.5" % "test" diff --git a/src/main/scala/de/retest/guistatemachine/RestService.scala b/src/main/scala/de/retest/guistatemachine/RestService.scala new file mode 100644 index 0000000..e98d168 --- /dev/null +++ b/src/main/scala/de/retest/guistatemachine/RestService.scala @@ -0,0 +1,132 @@ +package de.retest.guistatemachine + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer +import akka.Done +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import spray.json.DefaultJsonProtocol._ + +import java.util.LinkedList + +// domain model +// TODO Id should use Long and the REST paths as well. Use concurrent hash maps with the IDs and generate new IDs for new items. +final case class Id(id: Int) +final case class GuiApplication() { + val testSuites = TestSuites() +} +final case class GuiApplications() { + val applications = new LinkedList[GuiApplication] +} +final case class TestSuite() +final case class TestSuites() { + val testSuites = new LinkedList[TestSuite] +} + +trait RestService { + implicit val system: ActorSystem + implicit val materializer: ActorMaterializer + + // database + val guiApplications = GuiApplications() + + def addApplication(): Id = { + val apps = guiApplications.applications + apps.synchronized { + apps.add(new GuiApplication) + Id(apps.size() - 1) + } + } + def getApplication(id: Id): Option[GuiApplication] = { + val apps = guiApplications.applications + apps.synchronized { + val index = id.id + if (index >= 0 && index < apps.size()) Some(apps.get(index)) else None + } + } + + def getTestSuites(applicationId: Id): Option[TestSuites] = { + val app = getApplication(applicationId) + app match { + case Some(x) => Some(x.testSuites) + case None => None + } + } + + def addTestSuite(applicationId: Id): Option[Id] = { + val app = getApplication(applicationId) + app match { + case Some(x) => { + val testSuites = x.testSuites.testSuites + testSuites.synchronized { + testSuites.add(new TestSuite) + Some(Id(testSuites.size() - 1)) + } + } + case None => None + } + } + def getTestSuite(applicationId: Id, testSuiteId: Id): Option[TestSuite] = { + val app = getApplication(applicationId) + app match { + case Some(x) => { + val testSuites = x.testSuites.testSuites + testSuites.synchronized { + val index = testSuiteId.id + if (index >= 0 && index < testSuites.size()) Some(testSuites.get(index)) else None + } + } + case None => None + } + } + + // formats for unmarshalling and marshalling + implicit val idFormat = jsonFormat1(Id) + implicit val applicationFormat = jsonFormat0(GuiApplication) + implicit val applicationsFormat = jsonFormat0(GuiApplications) + implicit val testSuiteFormat = jsonFormat0(TestSuite) + implicit val testSuitesFormat = jsonFormat0(TestSuites) + + val route: Route = + get { + path("applications") { + complete(guiApplications) + } ~ + pathPrefix("application" / IntNumber) { id => + val app = getApplication(Id(id)) + app match { + case Some(x) => complete(x) + case None => complete(StatusCodes.NotFound) + } + } ~ + pathPrefix("application" / IntNumber / "test-suites") { id => + val testSuites = getTestSuites(Id(id)) + testSuites match { + case Some(x) => complete(x) + case None => complete(StatusCodes.NotFound) + } + } ~ + pathPrefix("application" / IntNumber / "test-suite" / IntNumber) { (appId, suiteId) => + val suite = getTestSuite(Id(appId), Id(suiteId)) + suite match { + case Some(x) => complete(x) + case None => complete(StatusCodes.NotFound) + } + } + } ~ + post { + path("create-application") { + val id = addApplication() + complete(id) + } ~ + pathPrefix("application" / IntNumber / "create-test-suite") { appId => + { + val id = addTestSuite(Id(appId)) + complete(id) + } + } + } +} \ No newline at end of file diff --git a/src/main/scala/de/retest/guistatemachine/WebServer.scala b/src/main/scala/de/retest/guistatemachine/WebServer.scala index 6dbd7c7..e301088 100644 --- a/src/main/scala/de/retest/guistatemachine/WebServer.scala +++ b/src/main/scala/de/retest/guistatemachine/WebServer.scala @@ -7,23 +7,17 @@ import akka.http.scaladsl.server.Directives._ import akka.stream.ActorMaterializer import scala.io.StdIn -object WebServer extends App { +object WebServer extends App with RestService { implicit val system = ActorSystem("gui-state-machine-api-system") implicit val materializer = ActorMaterializer() // needed for the future flatMap/onComplete in the end implicit val executionContext = system.dispatcher - val bindingFuture = Http().bindAndHandle(getRoute, "localhost", 8080) + val bindingFuture = Http().bindAndHandle(route, "localhost", 8080) println(s"Server online at http://localhost:8080/\nPress RETURN to stop...") StdIn.readLine() // let it run until user presses return bindingFuture .flatMap(_.unbind()) // trigger unbinding from the port .onComplete(_ => system.terminate()) // and shutdown when done - - def getRoute = path("hello") { - get { - complete("Hello!") - } - } } \ No newline at end of file diff --git a/src/main/scala/de/retest/guistatemachine/model/GuiApplication.scala b/src/main/scala/de/retest/guistatemachine/model/GuiApplication.scala index 2c0df14..9961e6e 100644 --- a/src/main/scala/de/retest/guistatemachine/model/GuiApplication.scala +++ b/src/main/scala/de/retest/guistatemachine/model/GuiApplication.scala @@ -2,8 +2,10 @@ package de.retest.guistatemachine.model /** * The tested GUI application with an initial state and a number of test suites. + * + * @param id This ID is for the REST API only. */ -class GuiApplication(initialState: State, testSuites: Seq[TestSuite]) { +class GuiApplication(val id : Long, initialState: State, testSuites: Seq[TestSuite]) { def getTestSuites: Seq[TestSuite] = testSuites diff --git a/src/main/scala/de/retest/guistatemachine/model/State.scala b/src/main/scala/de/retest/guistatemachine/model/State.scala index ecbe744..b294cd7 100644 --- a/src/main/scala/de/retest/guistatemachine/model/State.scala +++ b/src/main/scala/de/retest/guistatemachine/model/State.scala @@ -1,6 +1,6 @@ package de.retest.guistatemachine.model -trait State { +class State { private val windows = Set[GuiWindow]() /** * Actions which can be executed by the user in this state. diff --git a/src/main/scala/de/retest/guistatemachine/model/TestCase.scala b/src/main/scala/de/retest/guistatemachine/model/TestCase.scala index c23fdaf..10e95a1 100644 --- a/src/main/scala/de/retest/guistatemachine/model/TestCase.scala +++ b/src/main/scala/de/retest/guistatemachine/model/TestCase.scala @@ -1,11 +1,11 @@ package de.retest.guistatemachine.model -class TestCase(app: GuiApplication) { +class TestCase(initialState: State) { private val actions = Seq[UIAction]() def length = actions.size def isValid = true // it is valid if all GUI actions can be executed - def getUiPath = new UIPath(new PathState(app.getInitialState)) // TODO generate the correct path, with the common initial state + def getUiPath = new UIPath(new PathState(initialState)) // TODO generate the correct path, with the common initial state } \ No newline at end of file diff --git a/src/main/scala/de/retest/guistatemachine/model/TestSuite.scala b/src/main/scala/de/retest/guistatemachine/model/TestSuite.scala index 304e688..435f304 100644 --- a/src/main/scala/de/retest/guistatemachine/model/TestSuite.scala +++ b/src/main/scala/de/retest/guistatemachine/model/TestSuite.scala @@ -1,6 +1,6 @@ package de.retest.guistatemachine.model -class TestSuite(app : GuiApplication) { +class TestSuite { private val cases = Set[TestCase]() def size = cases.size diff --git a/src/test/scala/de/retest/guistatemachine/RestServiceSpec.scala b/src/test/scala/de/retest/guistatemachine/RestServiceSpec.scala new file mode 100644 index 0000000..9cb00e1 --- /dev/null +++ b/src/test/scala/de/retest/guistatemachine/RestServiceSpec.scala @@ -0,0 +1,68 @@ +package de.retest.guistatemachine + +import org.scalatest.{ Matchers, WordSpec } +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.testkit.ScalatestRouteTest +import akka.http.scaladsl.server._ +import Directives._ +import akka.http.scaladsl.model.HttpEntity +import akka.http.scaladsl.model.ContentTypes +import akka.http.scaladsl.model.HttpCharset +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.stream.ActorMaterializer +import akka.Done +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._ +import spray.json.DefaultJsonProtocol._ + +class RestServiceSpec extends WordSpec with Matchers with ScalatestRouteTest with RestService { + + lazy val sut = route + + "The service" should { + "return an empty list for the GET request with the path /applications" in { + Get("/applications") ~> sut ~> check { + val r = responseAs[GuiApplications] + r.applications.size shouldEqual 0 + } + } + + "allow POST for path /create-application" in { + Post("/create-application") ~> sut ~> check { + responseAs[Id] shouldEqual Id(0) + } + } + + "return an empty application for the GET request with the path /application/0" in { + Get("/applications/0") ~> sut ~> check { + val r = responseAs[GuiApplication] + r.testSuites.testSuites.size shouldEqual 0 + } + } + + "return an empty list for the GET request with the path /application/0/test-suites" in { + Get("/applications/0/test-suites") ~> sut ~> check { + val r = responseAs[TestSuites] + r.testSuites.size shouldEqual 0 + } + } + + "allow POST for path /application/0/create-test-suite" in { + Post("/application/0/create-test-suite") ~> sut ~> check { + responseAs[Id] shouldEqual Id(0) + } + } + + "not find any root path" in { + Get() ~> Route.seal(sut) ~> check { + status shouldEqual StatusCodes.NotFound + responseAs[String] shouldEqual "The requested resource could not be found." + } + } + } +} diff --git a/src/test/scala/de/retest/guistatemachine/WebServerSpec.scala b/src/test/scala/de/retest/guistatemachine/WebServerSpec.scala deleted file mode 100644 index 84a79a1..0000000 --- a/src/test/scala/de/retest/guistatemachine/WebServerSpec.scala +++ /dev/null @@ -1,45 +0,0 @@ -package de.retest.guistatemachine - -import org.scalatest.{ Matchers, WordSpec } -import akka.http.scaladsl.model.StatusCodes -import akka.http.scaladsl.testkit.ScalatestRouteTest -import akka.http.scaladsl.server._ -import Directives._ -import akka.http.scaladsl.model.HttpEntity -import akka.http.scaladsl.model.ContentTypes -import akka.http.scaladsl.model.HttpCharset - -class WebServerSpec extends WordSpec with Matchers with ScalatestRouteTest { - - lazy val sut = WebServer.getRoute - - "The service" should { - - "return a greeting for GET requests to the path /hello" in { - // tests: - Get("/hello") ~> sut ~> check { - responseAs[String] shouldEqual "Hello!" - } - } - - "not allow POST for path /hello" in { - Post("/hello") ~> Route.seal(sut) ~> check { - status shouldEqual StatusCodes.MethodNotAllowed - responseAs[String] shouldEqual "HTTP method not allowed, supported methods: GET" - } - } - - "leave GET requests to other paths unhandled" in { - Get("/kermit") ~> sut ~> check { - handled shouldBe false - } - } - - "not find any root path" in { - Get() ~> Route.seal(sut) ~> check { - status shouldEqual StatusCodes.NotFound - responseAs[String] shouldEqual "The requested resource could not be found." - } - } - } -}