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 - )) - } - } - } -}