diff --git a/build.gradle b/build.gradle
index 604b9de..c9a3426 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,92 +2,60 @@ plugins {
id "com.github.maiflai.scalatest" version "0.6-5-g9065d91"
}
-apply plugin: "scala"
-apply plugin: "eclipse"
-apply plugin: "maven"
-apply plugin: "signing"
-
-apply from: "gradle/credentials.gradle"
-
group = "com.github.vergenzt"
version = "0.1.4"
-repositories {
- mavenCentral()
-}
+subprojects {
+ apply plugin: "scala"
+ apply plugin: "eclipse"
-dependencies {
- compile "org.scala-lang:scala-library:2.11.5"
+ repositories {
+ mavenCentral()
+ }
- compile "org.scala-lang.modules:scala-xml_2.11:1.0.3"
- compile "org.scalaj:scalaj-http_2.11:1.1.4"
- compile "com.github.nscala-time:nscala-time_2.11:1.8.0"
+ configurations {
+ scalaCompilerPlugin
+ }
- testCompile "org.scalatest:scalatest_2.11:2.2.4"
- testCompile "org.mockito:mockito-core:1.10.19"
- testCompile "com.squareup.okhttp:mockwebserver:2.3.0"
+ dependencies {
+ compile "org.scala-lang:scala-library:2.11.7"
+ compile "org.scala-lang:scala-compiler:2.11.7"
- // for scalatest gradle plugin
- testRuntime "org.pegdown:pegdown:1.1.0"
-}
+ compile "org.scala-lang.modules:scala-xml_2.11:1.0.3"
+ compile "org.scalaj:scalaj-http_2.11:1.1.4"
+ compile "com.github.nscala-time:nscala-time_2.11:1.8.0"
-task sourcesJar(type: Jar) {
- classifier = "sources"
- from sourceSets.main.allSource
-}
+ scalaCompilerPlugin "org.scalamacros:paradise_2.11.7:2.1.0"
-task scaladocJar(type: Jar, dependsOn: scaladoc) {
- classifier = "javadoc"
- from scaladoc.destinationDir
-}
+ testCompile "org.scalatest:scalatest_2.11:2.2.4"
+ testCompile "org.mockito:mockito-core:1.10.19"
+ testCompile "com.squareup.okhttp:mockwebserver:2.3.0"
-artifacts {
- archives jar
- archives sourcesJar
- archives scaladocJar
-}
+ // for scalatest gradle plugin
+ testRuntime "org.pegdown:pegdown:1.1.0"
+ }
-signing {
- sign configurations.archives
+ tasks.withType(ScalaCompile) {
+ scalaCompileOptions.additionalParameters = [
+ "-Xplugin:" + configurations.scalaCompilerPlugin.asPath,
+ "-Ymacro-debug-lite"
+ ]
+ options.compilerArgs = ["-Xdebug", "-agentlib:jdwp=transport=dt_socket,server=n,suspend=y,address=9999"]
+ }
}
-uploadArchives {
- repositories.mavenDeployer {
- beforeDeployment { deployment -> signing.signPom(deployment) }
-
- repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") {
- authentication(userName: "", password: "") // set in credentials.gradle
- }
+project(":rtm-scala") {
+ apply from: "$rootProject.rootDir/gradle/publishing.gradle"
- pom.project {
- name "rtm-scala_2.11"
- packaging "jar"
- description "rtm-scala is a Scala wrapper for the Remember the Milk API."
- url "https://github.com/vergenzt/rtm-scala"
-
- scm {
- url "scm:git@github.com:vergenzt/rtm-scala.git"
- connection "scm:git@github.com:vergenzt/rtm-scala.git"
- developerConnection "scm:git@github.com:vergenzt/rtm-scala.git"
- }
-
- licenses {
- license {
- name "MIT License"
- url "http://opensource.org/licenses/MIT"
- distribution "repo"
- }
- }
+ dependencies {
+ compile project(":rtm-scala-lib")
+ compile project(":rtm-scala-meta")
+ }
+}
- developers {
- developer {
- id "vergenzt"
- name "Tim Vergenz"
- email "vergenzt@gmail.com"
- url "https://github.com/vergenzt"
- }
- }
- }
+project(":rtm-scala-meta") {
+ dependencies {
+ compile project(":rtm-scala-lib")
}
}
diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle
new file mode 100644
index 0000000..4c9f483
--- /dev/null
+++ b/gradle/publishing.gradle
@@ -0,0 +1,64 @@
+apply plugin: "maven"
+apply plugin: "signing"
+apply from: "$rootProject.rootDir/gradle/credentials.gradle"
+
+task sourcesJar(type: Jar) {
+ classifier = "sources"
+ from sourceSets.main.allSource
+}
+
+task scaladocJar(type: Jar, dependsOn: scaladoc) {
+ classifier = "javadoc"
+ from scaladoc.destinationDir
+}
+
+artifacts {
+ archives jar
+ archives sourcesJar
+ archives scaladocJar
+}
+
+signing {
+ sign configurations.archives
+}
+
+uploadArchives {
+ repositories.mavenDeployer {
+ beforeDeployment { deployment -> signing.signPom(deployment) }
+
+ repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") {
+ authentication(userName: "", password: "") // set in credentials.gradle
+ }
+
+ pom.project {
+ name "rtm-scala_2.11"
+ packaging "jar"
+ description "rtm-scala is a Scala wrapper for the Remember the Milk API."
+ url "https://github.com/vergenzt/rtm-scala"
+
+ scm {
+ url "scm:git@github.com:vergenzt/rtm-scala.git"
+ connection "scm:git@github.com:vergenzt/rtm-scala.git"
+ developerConnection "scm:git@github.com:vergenzt/rtm-scala.git"
+ }
+
+ licenses {
+ license {
+ name "MIT License"
+ url "http://opensource.org/licenses/MIT"
+ distribution "repo"
+ }
+ }
+
+ developers {
+ developer {
+ id "vergenzt"
+ name "Tim Vergenz"
+ email "vergenzt@gmail.com"
+ url "https://github.com/vergenzt"
+ }
+ }
+ }
+ }
+}
+
diff --git a/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/RtmApiBase.scala b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/RtmApiBase.scala
new file mode 100644
index 0000000..e301178
--- /dev/null
+++ b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/RtmApiBase.scala
@@ -0,0 +1,104 @@
+package com.github.vergenzt.rtmscala
+
+import scalaj.http.Http
+import scalaj.http.HttpRequest
+import util._
+import util.ParamConversions._
+import util.XmlConversions._
+
+object RtmApiBase extends RtmApiBase
+
+/**
+ * Base trait for the Remember the Milk API.
+ *
+ * @define authToken @param authToken An authentication token.
+ * @define creds @param creds Your API credentials.
+ * @define method @param method The API method to call, without the prefix "rtm."
+ * (e.g."rtm.auth.getFrob" would be passed as "auth.getFrob")
+ * @define params @param params The method-specific parameters.
+ * @define timeline @param timeline A timeline obtained from `rtm.timelines.create`
+ */
+trait RtmApiBase {
+
+ val BASE_URL = "http://api.rememberthemilk.com/services"
+ val AUTH_URL = BASE_URL + "/auth/"
+
+ // for testing
+ private[rtmscala] var REST_URL = BASE_URL + "/rest/"
+
+ /**
+ * Construct an RTM API request.
+ * @param url The url to request.
+ * $params
+ * $creds
+ */
+ protected[rtmscala]
+ def baseRequest(url: String, params: (String, String)*)
+ (implicit creds: ApiCreds): HttpRequest = {
+ Http(url).param("api_key", creds.apiKey).params(params)
+ }
+
+ /**
+ * Construct an unsigned RTM API request.
+ * $method
+ * $params
+ * $creds
+ */
+ protected[rtmscala]
+ def unsignedRequest(method: String, params: (String, String)*)
+ (implicit creds: ApiCreds): HttpRequest = {
+ baseRequest(REST_URL, ("method" -> ("rtm." + method)) :: params.toList: _*)
+ }
+
+ /**
+ * Construct a signed RTM API request.
+ * $method
+ * $params
+ * $creds
+ */
+ protected[rtmscala]
+ def request(method: String, params: (String, String)*)
+ (implicit creds: ApiCreds): HttpRequest = {
+ unsignedRequest(method, params: _*).signed
+ }
+
+ /**
+ * Construct an authenticated RTM API request.
+ * $method
+ * $params
+ * $creds
+ * $authToken
+ */
+ protected[rtmscala]
+ def authedRequest(method: String, params: (String, String)*)
+ (implicit creds: ApiCreds, authToken: AuthToken) = {
+ request(method, ("auth_token" -> authToken.token) :: params.toList: _*)
+ }
+
+ /**
+ * Construct an authenticated RTM API request with a timeline.
+ * $method
+ * $params
+ * $creds
+ * $authToken
+ * $timeline
+ */
+ protected[rtmscala]
+ def timelinedRequest(method: String, params: (String, String)*)
+ (implicit creds: ApiCreds, authToken: AuthToken, timeline: Timeline) = {
+ authedRequest(method, ("timeline" -> timeline.id) :: params.toList: _*)
+ }
+
+ /* Reflection methods */
+
+ object reflection {
+ def getMethodNames()(implicit creds: ApiCreds) =
+ request("reflection.getMethods").as[Seq[String]]
+
+ def getMethod(methodName: String)(implicit creds: ApiCreds) =
+ request("reflection.getMethodInfo", "method_name" -> methodName).as[MethodDesc]
+
+ def getMethods()(implicit creds: ApiCreds) =
+ getMethodNames().map(getMethod)
+ }
+}
diff --git a/src/main/scala/com/github/vergenzt/rtmscala/domain.scala b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/domain.scala
similarity index 81%
rename from src/main/scala/com/github/vergenzt/rtmscala/domain.scala
rename to rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/domain.scala
index 94bfcad..4bb862e 100644
--- a/src/main/scala/com/github/vergenzt/rtmscala/domain.scala
+++ b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/domain.scala
@@ -23,6 +23,9 @@ object Permission {
case object Read extends Permission("read", 1)
case object Write extends Permission("write", 2)
case object Delete extends Permission("delete", 3)
+
+ val ALL = Seq(None, Read, Write, Delete)
+ def fromInt(value: Int) = ALL.find(_.value == value)
}
case class User(id: String, username: String, fullname: Option[String])
@@ -118,6 +121,33 @@ case class Transaction(
request: HttpRequest
)
+/* Reflection */
+
+case class MethodDesc(
+ fullName: String,
+ needsLogin: Boolean,
+ needsSigning: Boolean,
+ requiredPerms: Permission,
+ description: String,
+ exampleResponse: String,
+ arguments: Seq[ArgumentDesc],
+ errors: Seq[ErrorDesc]
+) {
+ val Array("rtm", group, name) = fullName.split("\\.", 3)
+}
+
+case class ArgumentDesc(
+ name: String,
+ optional: Boolean,
+ description: String
+)
+
+case class ErrorDesc(
+ code: Int,
+ message: String,
+ description: String
+)
+
/* Exceptions */
case class RtmException(val message: String, val code: Int) extends Exception {
diff --git a/src/main/scala/com/github/vergenzt/rtmscala/util/ParamConversions.scala b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/ParamConversions.scala
similarity index 100%
rename from src/main/scala/com/github/vergenzt/rtmscala/util/ParamConversions.scala
rename to rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/ParamConversions.scala
diff --git a/src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala
similarity index 83%
rename from src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala
rename to rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala
index beb6375..6645df1 100644
--- a/src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala
+++ b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/XmlConversions.scala
@@ -160,4 +160,37 @@ object XmlConversions {
implicit def xml2Timeline(xml: NodeSeq): Timeline = xml match {
case Seq(Node("timeline", _, id)) => Timeline(id.text)
}
+
+ /* Reflection */
+
+ implicit def xml2MethodList(xml: NodeSeq): Seq[String] = {
+ for {
+ method <- xml \\ "method"
+ } yield method.text
+ }
+
+ implicit def xml2MethodDesc(xml: NodeSeq): MethodDesc = xml match {
+ case Seq(method @ Node("method", _, body @ _*)) => MethodDesc(
+ fullName = method \@ "name",
+ needsLogin = method \@ "needslogin",
+ needsSigning = method \@ "needssigning",
+ requiredPerms = Permission.fromInt((method \@ "requiredperms").toInt).get,
+ description = (method \ "description").text,
+ exampleResponse = (method \ "response").text,
+ arguments = (method \\ "argument") map { arg =>
+ ArgumentDesc(
+ name = arg \@ "name",
+ optional = arg \@ "optional",
+ description = arg.text
+ )
+ },
+ errors = (method \\ "error") map { err =>
+ ErrorDesc(
+ code = (err \@ "code").toInt,
+ message = err \@ "message",
+ description = err.text
+ )
+ }
+ )
+ }
}
diff --git a/src/main/scala/com/github/vergenzt/rtmscala/util/package.scala b/rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/package.scala
similarity index 100%
rename from src/main/scala/com/github/vergenzt/rtmscala/util/package.scala
rename to rtm-scala-lib/src/main/scala/com/github/vergenzt/rtmscala/util/package.scala
diff --git a/src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala b/rtm-scala-lib/src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala
similarity index 79%
rename from src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala
rename to rtm-scala-lib/src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala
index ce175d6..a92828d 100644
--- a/src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala
+++ b/rtm-scala-lib/src/test/scala/com/github/vergenzt/rtmscala/XmlConversionsTest.scala
@@ -227,4 +227,64 @@ class XmlConversionsTest extends FlatSpec with Matchers {
xml2Timeline("""12741021""") should be
(Timeline("12741021"))
}
+
+ /* Reflection */
+
+ "xml2MethodList" should "work" in {
+ xml2MethodList("""
+
+ rtm.tasks.add
+ rtm.tasks.addTags
+ rtm.tasks.complete
+ rtm.tasks.delete
+
+ """) should be (
+ Seq("rtm.tasks.add", "rtm.tasks.addTags", "rtm.tasks.complete", "rtm.tasks.delete")
+ )
+ }
+
+ "xml2MethodDesc" should "work" in {
+ xml2MethodDesc("""
+
+ A testing method which checks if the caller is logged in.
+ <user id="987654321">
+ <username>bob<username>
+ </user>
+
+
+ Your API application key.
+ <a href="/services/api/api_key.rtm">See here</a> for more details.
+ ...
+
+
+ The passed signature was invalid.
+ ...
+
+
+ """) should be (
+ MethodDesc(
+ fullName = "rtm.test.login",
+ needsLogin = true,
+ needsSigning = true,
+ requiredPerms = Permission.Read,
+ description = "A testing method which checks if the caller is logged in.",
+ exampleResponse = """ bob """.trim,
+ arguments = Seq(
+ ArgumentDesc(
+ name = "api_key",
+ optional = false,
+ description = """Your API application key. See here for more details."""
+ )
+ ),
+ errors = Seq(
+ ErrorDesc(
+ code = 96,
+ message = "Invalid signature",
+ description = "The passed signature was invalid."
+ )
+ )
+ )
+ )
+ }
+
}
diff --git a/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/meta/GenerateRtmApi.scala b/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/meta/GenerateRtmApi.scala
new file mode 100644
index 0000000..72f421d
--- /dev/null
+++ b/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/meta/GenerateRtmApi.scala
@@ -0,0 +1,100 @@
+package com.github.vergenzt.rtmscala
+package meta
+
+import scala.annotation.StaticAnnotation
+import scala.language.experimental.macros
+import scala.reflect.macros.whitebox.Context
+import java.net._
+import scala.annotation.compileTimeOnly
+
+import util._
+import util.XmlConversions._
+
+@compileTimeOnly("This is a macro requiring macro paradise.")
+class GenerateRtmApi extends StaticAnnotation {
+ def macroTransform(annottees: Any*): Any = macro GenerateRtmApiImpl.generateRtmApiImpl
+}
+
+class GenerateRtmApiImpl(val c: Context) {
+ import c.universe._
+ implicit val creds = ApiCreds(System.getProperty("rtm.api_key"), System.getProperty("rtm.api_secret"))
+
+ def implementMethod(method: MethodDesc): c.Tree = {
+ val args = method.arguments
+ .filter(arg => arg.name != "api_key" && arg.name != "timeline")
+ .map({
+ case arg if !arg.optional => q"val ${TermName(arg.name)}: String"
+ case arg if arg.optional => q"val ${TermName(arg.name)}: Option[String] = None"
+ })
+
+ val implicitArgs = Seq(
+ Some(q"implicit val creds: ApiCreds"),
+ Some(q"implicit val authToken: AuthToken")
+ .filter(_ => method.needsLogin),
+ Some(q"implicit val timeline: Timeline")
+ .filter(_ => method.arguments.exists(_.name == "timeline"))
+ ).flatten
+
+ val body = q"""
+ val request = unsignedRequest(${method.fullName.stripPrefix("rtm.")},
+ ..${method.arguments
+ .filter(arg => arg.name != "api_key" && arg.name != "timeline")
+ .map {
+ // convert each arg into `"" -> value
+ case arg if !arg.optional => q"${arg.name} -> ${TermName(arg.name)}"
+ case arg if arg.optional => q"${arg.name} -> ${TermName(arg.name)}.get"
+ }
+ },
+ ..${Seq(
+ Some(q"""("auth_token" -> authToken.token)""")
+ .filter(_ => method.needsLogin),
+ Some(q"""("timeline" -> timeline.id)""")
+ .filter(_ => method.arguments.exists(_.name == "timeline"))
+ ).flatten}
+ )
+
+ ${if (method.needsSigning) q"request.signed" else q"request"}
+ """
+
+ q"def ${TermName(method.name)} (..$args)(..$implicitArgs) = { $body }"
+ }
+
+ /** Generate an implementation for RtmApi. */
+ def generateRtmApiImpl(annottees: c.Expr[Any]*): c.Expr[Any] = {
+ HttpCaching.setUp()
+
+ annottees.map(_.tree) match {
+ case scala.List(q"object $rtm extends RtmApiBase { ..$customImpls }") =>
+
+ val methods = RtmApiBase.reflection.getMethods().filter(_.group != "reflection")
+
+ c.Expr[Any](q"""
+ object $rtm extends RtmApiBase {
+
+ // generated method groups
+ ..${ methods.groupBy(_.group).map { case (group, methods) => q"""
+ object ${TermName(group)} {
+
+ // generated methods
+ ..${methods.map(implementMethod)}
+
+ // custom methods inside generated groups
+ ..${customImpls.flatMap {
+ case q"object ${TermName(group)} { ..$customMethods }" => customMethods
+ case _ => None
+ }}
+ }
+ """}}
+
+ // custom methods outside generated groups
+ ..${customImpls.filter {
+ case q"object ${TermName(group)} { ..$impl }" =>
+ !methods.exists(_.group == group)
+ case _ =>
+ true
+ }}
+ }
+ """)
+ }
+ }
+}
diff --git a/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/util/HttpCaching.scala b/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/util/HttpCaching.scala
new file mode 100644
index 0000000..d420bfb
--- /dev/null
+++ b/rtm-scala-meta/src/main/scala/com/github/vergenzt/rtmscala/util/HttpCaching.scala
@@ -0,0 +1,37 @@
+package com.github.vergenzt.rtmscala.util
+
+import java.net._
+import java.util._
+import java.io._
+
+object HttpCaching {
+ /** Set up caching of http responses to decrease build times */
+ def setUp() = {
+ ResponseCache.setDefault(new ResponseCache() {
+ val cacheDir = System.getProperty("user.home") + "/.rtm-scala-meta/build-cache"
+ new File(cacheDir).mkdirs()
+ def get(uri: URI, method: String, headers: Map[String, List[String]]) =
+ try {
+ val reader = new FileInputStream(cacheDir + "/" + uri.getQuery())
+ val headers = new ObjectInputStream(reader).readObject().asInstanceOf[Map[String,List[String]]]
+ new CacheResponse {
+ def getBody(): InputStream = reader
+ def getHeaders(): Map[String,List[String]] = headers
+ }
+ } catch {
+ case _: IOException => null
+ }
+ def put(uri: URI, conn: URLConnection) =
+ try {
+ val writer = new FileOutputStream(cacheDir + "/" + uri.getQuery(), true)
+ new ObjectOutputStream(writer).writeObject(conn.getHeaderFields)
+ new CacheRequest {
+ def abort(): Unit = writer.close()
+ def getBody(): OutputStream = writer
+ }
+ } catch {
+ case _: IOException => null
+ }
+ })
+ }
+}
diff --git a/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/RtmApi.scala b/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/RtmApi.scala
new file mode 100644
index 0000000..240d0e5
--- /dev/null
+++ b/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/RtmApi.scala
@@ -0,0 +1,30 @@
+package com.github.vergenzt.rtmscala
+
+import scala.concurrent.ExecutionContext
+import scala.concurrent.Future
+import org.joda.time.DateTime
+import org.joda.time.LocalDate
+import org.joda.time.LocalDateTime
+import scalaj.http.Http
+import scalaj.http.HttpRequest
+
+import util._
+import util.ParamConversions._
+import util.XmlConversions._
+import meta._
+
+/**
+ * Scala wrapper for the Remember the Milk API.
+ *
+ * @define authToken @param authToken An authentication token.
+ * @define creds @param creds Your API credentials.
+ * @define frob @param frob A frob obtained from `rtm.auth.getFrob`
+ * @define method @param method The API method to call, without the prefix "rtm."
+ * (e.g."rtm.auth.getFrob" would be passed as "auth.getFrob")
+ * @define params @param params The method-specific parameters.
+ * @define perms @param perms The requested permission.
+ * @define timeline @param timeline A timeline obtained from `rtm.timelines.create`
+ */
+@GenerateRtmApi
+object RtmApi extends RtmApiBase {
+}
diff --git a/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/package.scala b/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/package.scala
new file mode 100644
index 0000000..37cddbe
--- /dev/null
+++ b/rtm-scala/src/main/scala/com/github/vergenzt/rtmscala/package.scala
@@ -0,0 +1,8 @@
+package com.github.vergenzt
+
+package object rtmscala {
+
+ /** Alias to RtmApi to make calls shorter. **/
+ val rtm = RtmApi
+
+}
diff --git a/rtm-scala/src/test/scala/com/github/vergenzt/rtmscala/RtmApiTest.scala b/rtm-scala/src/test/scala/com/github/vergenzt/rtmscala/RtmApiTest.scala
new file mode 100644
index 0000000..50f0597
--- /dev/null
+++ b/rtm-scala/src/test/scala/com/github/vergenzt/rtmscala/RtmApiTest.scala
@@ -0,0 +1,76 @@
+package com.github.vergenzt.rtmscala
+
+import java.util.logging.Level
+import java.util.logging.Logger
+import org.scalatest.BeforeAndAfter
+import org.scalatest.FunSpec
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatest.mock.MockitoSugar
+import com.squareup.okhttp.mockwebserver.MockResponse
+import com.squareup.okhttp.mockwebserver.MockWebServer
+import com.squareup.okhttp.mockwebserver.RecordedRequest
+import scalaj.http.HttpConstants
+
+class RtmApiTest extends FunSpec with BeforeAndAfter with MockitoSugar with ScalaFutures {
+
+ var server: MockWebServer = _
+
+ // Turn off webserver logging. A reference has to be kept around so it doesn't
+ // get garbage-collected, causing a new logger to be created. (I think. I had
+ // issues with the logger not actually turning off when I didn't keep the
+ // reference.
+ val _logger = Logger.getLogger(classOf[MockWebServer].getName)
+ _logger.setLevel(Level.OFF)
+
+ before {
+ server = new MockWebServer()
+ server.start()
+ RtmApi.REST_URL = server.getUrl("/rest/").toExternalForm()
+ }
+ after {
+ server.shutdown()
+ }
+
+ def enqueueResponse(response: String) = {
+ server.enqueue(new MockResponse().setBody(response))
+ }
+
+ def checkParamsIncluded(request: RecordedRequest, _params: Map[String,String]) = {
+ val params = request.getPath.split('?').last.split('&')
+ .map(_.split('=').map(HttpConstants.urlDecode(_, HttpConstants.utf8)))
+ .map(kv => (kv(0) -> kv(1))).toMap
+ _params foreach { case (k, v) =>
+ assert ((k, params.get(k)) == (k, Some(v)))
+ }
+ }
+
+ describe("rtm") {
+
+ implicit val apiCreds = ApiCreds("abc123", "BANANAS")
+ implicit val authToken = AuthToken("314159", Permission.Delete, User("1", "bob", Some("Bob T. Monkey")))
+ implicit val timeline = Timeline("54321")
+
+ describe("auth") {
+ it("checkToken") {
+ import util._
+ import XmlConversions._
+
+ enqueueResponse(s"""
+
+ ${authToken.token}
+ delete
+
+
+ """.trim)
+
+ assert (rtm.auth.checkToken(authToken.token).as[AuthToken] == authToken)
+
+ checkParamsIncluded(server.takeRequest, Map(
+ "api_key" -> apiCreds.apiKey,
+ "auth_token" -> authToken.token,
+ "method" -> "rtm.auth.checkToken"
+ ))
+ }
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index 7374dd6..ee856e3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,6 @@
+include 'rtm-scala'
+include 'rtm-scala-lib'
+include 'rtm-scala-meta'
+
rootProject.name = "rtm-scala_2.11"
diff --git a/src/main/scala/com/github/vergenzt/rtmscala/rtm.scala b/src/main/scala/com/github/vergenzt/rtmscala/rtm.scala
deleted file mode 100644
index 3334fe6..0000000
--- a/src/main/scala/com/github/vergenzt/rtmscala/rtm.scala
+++ /dev/null
@@ -1,410 +0,0 @@
-package com.github.vergenzt.rtmscala
-
-import scala.concurrent.ExecutionContext
-import scala.concurrent.Future
-import scala.xml.NodeSeq
-
-import org.joda.time.DateTime
-import org.joda.time.LocalDate
-import org.joda.time.LocalDateTime
-
-import scalaj.http.Http
-import scalaj.http.HttpRequest
-import util._
-import util.ParamConversions._
-import util.XmlConversions._
-
-/**
- * Scala wrapper for the Remember the Milk API.
- *
- * @define authToken @param authToken An authentication token.
- * @define creds @param creds Your API credentials.
- * @define frob @param frob A frob obtained from `rtm.auth.getFrob`
- * @define method @param method The API method to call, without the prefix "rtm."
- * (e.g."rtm.auth.getFrob" would be passed as "auth.getFrob")
- * @define params @param params The method-specific parameters.
- * @define perms @param perms The requested permission.
- * @define timeline @param timeline A timeline obtained from `rtm.timelines.create`
- */
-object rtm {
-
- val BASE_URL = "http://api.rememberthemilk.com/services"
- val AUTH_URL = BASE_URL + "/auth/"
-
- // for testing
- private[rtmscala] var REST_URL = BASE_URL + "/rest/"
-
- /**
- * Construct an RTM API request.
- * @param url The url to request.
- * $params
- * $creds
- */
- protected[rtmscala]
- def baseRequest(url: String, params: (String, String)*)
- (implicit creds: ApiCreds): HttpRequest = {
- Http(url).param("api_key", creds.apiKey).params(params)
- }
-
- /**
- * Construct an unsigned RTM API request.
- * $method
- * $params
- * $creds
- */
- protected[rtmscala]
- def unsignedRequest(method: String, params: (String, String)*)
- (implicit creds: ApiCreds): HttpRequest = {
- baseRequest(REST_URL, ("method" -> ("rtm." + method)) :: params.toList: _*)
- }
-
- /**
- * Construct a signed RTM API request.
- * $method
- * $params
- * $creds
- */
- protected[rtmscala]
- def request(method: String, params: (String, String)*)
- (implicit creds: ApiCreds): HttpRequest = {
- unsignedRequest(method, params: _*).signed
- }
-
- /**
- * Construct an authenticated RTM API request.
- * $method
- * $params
- * $creds
- * $authToken
- */
- protected[rtmscala]
- def authedRequest(method: String, params: (String, String)*)
- (implicit creds: ApiCreds, authToken: AuthToken) = {
- request(method, ("auth_token" -> authToken.token) :: params.toList: _*)
- }
-
- /**
- * Construct an authenticated RTM API request with a timeline.
- * $method
- * $params
- * $creds
- * $authToken
- * $timeline
- */
- protected[rtmscala]
- def timelinedRequest(method: String, params: (String, String)*)
- (implicit creds: ApiCreds, authToken: AuthToken, timeline: Timeline) = {
- authedRequest(method, ("timeline" -> timeline.id) :: params.toList: _*)
- }
-
- /*****************************
- * Begin API implementation. *
- *****************************/
-
- object auth {
- /**
- * Get a Remember the Milk authentication URL to direct a user to.
- * $perms
- * $creds
- */
- def getURL(perms: Permission)(implicit creds: ApiCreds) =
- baseRequest(AUTH_URL, perms).signed.fullURL
-
- /**
- * Get a Remember the Milk authentication URL to direct a user to.
- * $perms
- * $frob
- * $creds
- */
- def getURL(perms: Permission, frob: Frob)(implicit creds: ApiCreds) =
- baseRequest(AUTH_URL, perms, frob).signed.fullURL
-
- /**
- * Authenticate the user with Remember the Milk.
- * $perms
- * @param directUserToURL A function to direct the user to the generated
- * authentication URL. Should return a Future that completes once the
- * user has finished authenticating.
- * $creds
- * @param executionContext The context in which to execute the Future.
- * @return A Future[AuthToken] that completes once the user has
- * authenticated and the resulting token has been fetched.
- */
- def authenticate(perms: Permission)(directUserToURL: String => Future[Unit])
- (implicit creds: ApiCreds, executionContext: ExecutionContext): Future[AuthToken] = {
-
- assert (perms.name != "none")
- val frob = getFrob()
- directUserToURL(getURL(perms, frob)).map(_ => auth.getToken(frob))
- }
-
- def checkToken(token: String)(implicit creds: ApiCreds) =
- request("auth.checkToken", "auth_token" -> token).as[AuthToken]
-
- def checkToken(token: AuthToken)(implicit creds: ApiCreds) =
- request("auth.checkToken", token).as[AuthToken]
-
- def getFrob()(implicit creds: ApiCreds) =
- request("auth.getFrob").as[Frob]
-
- def getToken(frob: Frob)(implicit creds: ApiCreds) =
- request("auth.getToken", frob).as[AuthToken]
- }
-
- object contacts {
- def add(usernameOrEmail: String)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("contacts.add", "contact" -> usernameOrEmail).as[Contact]
-
- def delete(contact: Contact)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline): Unit =
- timelinedRequest("contacts.delete", contact).as[Unit]
-
- def getList()(implicit creds: ApiCreds, token: AuthToken): Unit =
- authedRequest("contacts.getList").as[Seq[Contact]]
- }
-
- object groups {
- def add(name: String)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("groups.add", "group" -> name).as[Group]
-
- def addContact(group: Group, contact: Contact)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline): Unit =
- timelinedRequest("groups.addContact", group, contact).as[Unit]
-
- def delete(group: Group)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline): Unit =
- timelinedRequest("groups.delete", group).as[Unit]
-
- def getList()(implicit creds: ApiCreds, token: AuthToken): Unit =
- authedRequest("groups.getList").as[Seq[Group]]
-
- def removeContact(group: Group, contact: Contact)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline): Unit =
- timelinedRequest("groups.removeContact", group, contact).as[Unit]
- }
-
- object lists {
- def add(name: String)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.add", "name" -> name).as[List]
-
- def add(name: String, filter: String)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.add", "name" -> name, "filter" -> filter).as[List]
-
- def archive(list: List)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.archive", list).as[List]
-
- def delete(list: List)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.delete", list).as[List]
-
- def getList()(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("lists.getList").as[Seq[List]]
-
- def setDefaultList(list: List)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline): Unit =
- timelinedRequest("lists.setDefaultList", list).as[Unit]
-
- def setName(list: List, newName: String)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.setName", list).as[List]
-
- def unarchive(list: List)(implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("lists.unarchive", list).as[List]
- }
-
- object locations {
- def getList()(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("locations.getList").as[Seq[Location]]
- }
-
- object reflection {
- // TODO: getMethodInfo
- // TODO: getMethods
- }
-
- object settings {
- // TODO: getList
- }
-
- object tasks {
- def add(name: String, list: List, parse: Boolean)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.add", "name" -> name, list, "parse" -> parse).as[Task]
-
- def add(name: String, parse: Boolean = true)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.add", "name" -> name, "parse" -> parse).as[Task]
-
- def addTags(task: Task, tags: Seq[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.addTags", ("tags" -> tags.mkString(",")) :: task.toList: _*).as[Task]
-
- def complete(task: Task)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.complete", task: _*).as[Task]
-
- def delete(task: Task)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.delete", task: _*).as[Unit]
-
- def getList()(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("tasks.getList").as[Seq[Task]]
-
- def getList(since: DateTime)(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("tasks.getList", "last_sync" -> since.toString(util.rtmDateTimeFormat)).as[Seq[Task]]
-
- def getList(filter: String)(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("tasks.getList", "filter" -> filter).as[Seq[Task]]
-
- def getList(list: List, filter: String)(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("tasks.getList", list, "filter" -> filter).as[Seq[Task]]
-
- def movePriority(task: Task, direction: String)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) = {
- require(direction == "up" || direction == "down")
- timelinedRequest("tasks.addTags", ("direction" -> direction) :: task.toList: _*).as[Task]
- }
-
- def moveTo(task: Task, list: List)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) = {
- timelinedRequest("tasks.moveTo",
- "task_id" -> task.id, "taskseries_id" -> task.seriesId,
- "from_list_id" -> task.listId,
- "to_list_id" -> list.id
- ).as[Task]
-
- }
-
- def postpone(task: Task)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.postpone", task: _*).as[Task]
-
- def removeTags(task: Task, tags: Seq[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.removeTags",
- ("tags" -> tags.mkString(",")) ::
- task.toList: _*
- ).as[Task]
-
- def setDueDate(task: Task, due: String)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setDueDate", ("parse" -> "1") :: ("due" -> due) :: task.toList: _*).as[Task]
-
- def setDueDate(task: Task, due: LocalDate)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setDueDate",
- ("due" -> due.toString(rtmDateTimeFormat)) ::
- task.toList: _*
- ).as[Task]
-
- def setDueDate(task: Task, due: LocalDateTime)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setDueDate",
- ("due" -> due.toString(rtmDateTimeFormat)) ::
- ("has_due_time" -> "1") ::
- task.toList: _*
- ).as[Task]
-
- def unsetDueDate(task: Task)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setDueDate", task: _*).as[Task]
-
- def setEstimate(task: Task, estimate: Option[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setEstimate",
- ("estimate" -> estimate.getOrElse("")) ::
- task.toList: _*
- ).as[Task]
-
- def setLocation(task: Task, location: Option[Location])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setEstimate",
- ("location_id" -> location.map(_.id).getOrElse("")) ::
- task.toList: _*
- ).as[Task]
-
- def setName(task: Task, newName: String)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setName",
- ("name" -> newName) ::
- task.toList: _*
- ).as[Task]
-
- def setPriority(task: Task, newPriority: Int)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) = {
- require (0 <= newPriority && newPriority <= 4)
- timelinedRequest("tasks.setName",
- ("name" -> newPriority.toString) ::
- task.toList: _*
- ).as[Task]
- }
-
- def setRecurrence(task: Task, recurrence: Option[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setName",
- ("repeat" -> recurrence.getOrElse("")) ::
- task.toList: _*
- ).as[Task]
-
- def setTags(task: Task, tags: Seq[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setTags",
- ("tags" -> tags.mkString(",")) ::
- task.toList: _*
- ).as[Task]
-
- def setURL(task: Task, url: Option[String])
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.setName",
- ("url" -> url.getOrElse("")) ::
- task.toList: _*
- ).as[Task]
-
- def uncomplete(task: Task)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.uncomplete", task: _*).as[Task]
-
- object notes {
- def add(task: Task, title: String, body: String)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.notes.add",
- ("note_title" -> title) ::
- ("note_text" -> body) ::
- task.toList: _*
- ).as[Note]
-
- def delete(note: Note)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.notes.delete", note).as[Note]
-
- def edit(note: Note, newTitle: String, newBody: String)
- (implicit creds: ApiCreds, token: AuthToken, timeline: Timeline) =
- timelinedRequest("tasks.notes.edit",
- note, "note_title" -> newTitle, "note_text" -> newBody
- ).as[Note]
- }
- }
-
- object test {
- def echo(params: (String, String)*)(implicit creds: ApiCreds) =
- unsignedRequest("test.echo", params: _*).asString.body
-
- def login(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("test.login").as[AuthToken]
- }
-
- object time {
- // TODO: convert
- // TODO: parse
- }
-
- object timelines {
- def create(implicit creds: ApiCreds, token: AuthToken) =
- authedRequest("timelines.create").as[Timeline]
- }
-
- object timezones {
- // TODO: getList
- }
-
- object transactions {
- def undo(transaction: Transaction)(implicit creds: ApiCreds, token: AuthToken) = {
- authedRequest("transactions.undo", transaction.timeline, transaction).as[Unit]
- // remove the transaction from the timeline
- transaction.timeline._transactions -= transaction
- }
- }
-}
diff --git a/src/test/scala/com/github/vergenzt/rtmscala/RtmTest.scala b/src/test/scala/com/github/vergenzt/rtmscala/RtmTest.scala
deleted file mode 100644
index bde4d3b..0000000
--- a/src/test/scala/com/github/vergenzt/rtmscala/RtmTest.scala
+++ /dev/null
@@ -1,153 +0,0 @@
-package com.github.vergenzt.rtmscala
-
-import java.util.logging.Level
-import java.util.logging.Logger
-
-import scala.concurrent.ExecutionContext.Implicits.global
-import scala.concurrent.Future
-
-import org.mockito.Mockito.doReturn
-import org.mockito.Mockito.spy
-import org.mockito.Mockito.when
-import org.scalatest.BeforeAndAfter
-import org.scalatest.FunSpec
-import org.scalatest.concurrent.ScalaFutures
-import org.scalatest.mock.MockitoSugar
-
-import com.squareup.okhttp.mockwebserver.MockResponse
-import com.squareup.okhttp.mockwebserver.MockWebServer
-import com.squareup.okhttp.mockwebserver.RecordedRequest
-
-import scalaj.http.HttpConstants
-
-class RtmTest extends FunSpec with BeforeAndAfter with MockitoSugar with ScalaFutures {
-
- var server: MockWebServer = _
-
- // Turn off webserver logging. A reference has to be kept around so it doesn't
- // get garbage-collected, causing a new logger to be created. (I think. I had
- // issues with the logger not actually turning off when I didn't keep the
- // reference.
- val _logger = Logger.getLogger(classOf[MockWebServer].getName)
- _logger.setLevel(Level.OFF)
-
- before {
- server = new MockWebServer()
- server.start()
- rtm.REST_URL = server.getUrl("/rest/").toExternalForm()
- }
- after {
- server.shutdown()
- }
-
- def enqueueResponse(response: String) = {
- server.enqueue(new MockResponse().setBody(response))
- }
-
- def checkParamsIncluded(request: RecordedRequest, _params: Map[String,String]) = {
- val params = request.getPath.split('?').last.split('&')
- .map(_.split('=').map(HttpConstants.urlDecode(_, HttpConstants.utf8)))
- .map(kv => (kv(0) -> kv(1))).toMap
- _params foreach { case (k, v) =>
- assert ((k, params.get(k)) == (k, Some(v)))
- }
- }
-
- describe("rtm") {
-
- implicit val apiCreds = ApiCreds("abc123", "BANANAS")
- implicit val authToken = AuthToken("314159", Permission.Delete, User("1", "bob", Some("Bob T. Monkey")))
- implicit val timeline = Timeline("54321")
-
- describe("auth") {
-
- it("signs correctly") {
- import util.RtmHttpRequestOps
-
- val request = rtm.request("sigtest", "param1" -> "xyz", "param2" -> "123")
-
- val params = request.params.toMap
- val _params = Map(
- "api_key" -> apiCreds.apiKey,
- "method" -> "rtm.sigtest",
- "param1" -> "xyz",
- "param2" -> "123",
- "api_sig" -> "02c04e204a9d7f4aad86468e74412f50"
- )
- _params.foreach { case (k,v) =>
- assert (params.get(k) == Some(v))
- }
- }
-
- it("getFrob") {
- enqueueResponse("""123456""")
-
- val frob = rtm.auth.getFrob
- assert (frob == Frob("123456"))
-
- checkParamsIncluded(server.takeRequest, Map(
- "api_key" -> apiCreds.apiKey,
- "method" -> "rtm.auth.getFrob",
- "api_sig" -> "2eb41243b94f6be134b1120623ca6876"
- ))
- }
-
- it("getToken") {
- enqueueResponse("""
-
- 314159
- delete
-
-
- """.trim)
-
- assert (
- rtm.auth.getToken(Frob("123456"))
- ==
- AuthToken("314159", Permission.Delete, User("1", "bob", Some("Bob T. Monkey")))
- )
-
- checkParamsIncluded(server.takeRequest, Map(
- "api_key" -> apiCreds.apiKey,
- "method" -> "rtm.auth.getToken",
- "frob" -> "123456",
- "api_sig" -> "6dd557b1cbb1725334fa513760a75cdd"
- ))
- }
-
- it("checkToken") {
- enqueueResponse(s"""
-
- ${authToken.token}
- delete
-
-
- """.trim)
-
- assert (rtm.auth.checkToken(authToken) == authToken)
-
- checkParamsIncluded(server.takeRequest, Map(
- "api_key" -> apiCreds.apiKey,
- "auth_token" -> authToken.token,
- "method" -> "rtm.auth.checkToken"
- ))
- }
- }
-
- describe("timelines") {
- it("create") {
- enqueueResponse(s"""
- ${timeline.id}
- """.trim)
-
- assert (rtm.timelines.create == timeline)
-
- checkParamsIncluded(server.takeRequest, Map(
- "api_key" -> apiCreds.apiKey,
- "method" -> "rtm.timelines.create",
- "auth_token" -> authToken.token
- ))
- }
- }
- }
-}