Skip to content

Commit

Permalink
add MastodonClient
Browse files Browse the repository at this point in the history
  • Loading branch information
okapies committed Apr 16, 2017
1 parent 04e44f2 commit 4f7f0a9
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 0 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# akka-mastodon
[Mastodon](https://github.com/tootsuite/mastodon) client based on [Akka Streams](http://doc.akka.io/docs/akka/current/scala/stream/index.html).

## Usage
```scala
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer
import com.github.okapies.akka.mastodon.Timeline
import com.github.okapies.akka.mastodon.scaladsl.MastodonClient

implicit val system = ActorSystem()
implicit val materializer = ActorMaterializer()
implicit val executionContext = system.dispatcher

val mastodonHost = "https://..."
val accessToken = "..."

MastodonClient(mastodonHost, accessToken)
.timelines(Timeline.home())
.runForeach { status => println(status) }
.andThen { _ =>
Http().shutdownAllConnectionPools().onComplete(_ => system.terminate())
}
```
14 changes: 14 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name := "akka-mastodon"

version := "0.1"

scalaVersion := "2.12.1"

scalacOptions ++= Seq("-encoding", "UTF-8")

libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-http" % "10.0.5",
"io.circe" %% "circe-core" % "0.7.1",
"io.circe" %% "circe-generic" % "0.7.1",
"io.circe" %% "circe-parser" % "0.7.1"
)
85 changes: 85 additions & 0 deletions src/main/scala/com/github/okapies/akka/mastodon/package.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.github.okapies.akka

import java.time.LocalDateTime

import akka.http.scaladsl.model.Uri

package object mastodon {

// requests
case class Timeline(path: Uri, local: Boolean)

object Timeline {
def home(local: Boolean = false) =
Timeline("/api/v1/timelines/home", local)
def public(local: Boolean = false) =
Timeline("/api/v1/timelines/public", local)
def tag(hashtag: String, local: Boolean = false) =
Timeline(s"/api/v1/timelines/home/$hashtag", local)
}

// model
case class Account(
id: Int,
username: String,
acct: String,
display_name: String,
locked: Boolean,
created_at: LocalDateTime,
followers_count: Int,
following_count: Int,
statuses_count: Int,
note: String,
url: Uri,
avatar: Uri,
avatar_static: Uri,
header: Uri,
header_static: Uri
)

case class Status(
id: Int,
created_at: LocalDateTime,
in_reply_to_id: Option[Int],
in_reply_to_account_id: Option[Int],
sensitive: Boolean,
spoiler_text: String,
visibility: String,
application: Option[Application],
account: Account,
media_attachments: Seq[MediaAttachment],
mentions: Seq[Mention],
tags: Seq[String],
uri: Uri,
content: String,
raw_content: Option[String],
url: Uri,
reblogs_count: Int,
favourites_count: Int,
reblog: Option[Status],
favourited: Option[Boolean],
reblogged: Option[Boolean]
)

case class Application(
name: String,
website: Option[Uri]
)

case class Mention(
id: Int,
acct: String,
username: String,
url: Uri
)

case class MediaAttachment(
id: String,
remote_url: Uri,
`type`: String,
url: Uri,
preview_url: Uri,
text_url: Uri
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.github.okapies.akka.mastodon.scaladsl

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

import scala.util.{Failure, Success, Try}
import akka.NotUsed
import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.{HttpRequest, HttpResponse, Uri}
import akka.http.scaladsl.model.headers.RawHeader
import akka.stream.ActorMaterializer
import akka.stream.scaladsl.{Flow, Source}
import akka.util.ByteString
import com.github.okapies.akka.mastodon.Status
import com.github.okapies.akka.mastodon.Timeline
import io.circe.{Decoder, Json, ParsingFailure}

final class MastodonClient(val endpoint: Uri, val accessToken: String)
(implicit system: ActorSystem, materializer: ActorMaterializer) {

import io.circe.parser._
import MastodonClient._

private[this] def authzHeader = RawHeader(name = "Authorization", value = s"Bearer $accessToken")

private[this] val poolClientFlow
: Flow[(HttpRequest, NotUsed), (Try[HttpResponse], NotUsed), Http.HostConnectionPool] = {
val host = endpoint.authority.host.address
val port = endpoint.effectivePort

endpoint.scheme match {
case "http" => Http().cachedHostConnectionPool[NotUsed](host, port)
case "https" => Http().cachedHostConnectionPoolHttps[NotUsed](host, port)
}
}

def timelines(timeline: Timeline): Source[Status, NotUsed] = {
val req = HttpRequest(uri = timeline.path, headers = List(authzHeader)) -> NotUsed

Source.single(req).via(poolClientFlow).flatMapConcat(toContent)
.flatMapConcat(content => toSource(parse(content).flatMap(asTimeline)))
}

}

object MastodonClient {

def apply(
endpoint: String,
accessToken: String)
(implicit system: ActorSystem, materializer: ActorMaterializer) =
new MastodonClient(endpoint, accessToken)

import io.circe.generic.auto._

private[this] val dateFormater = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")

private[this] implicit val decodeDate: Decoder[LocalDateTime] =
Decoder[String].emapTry { s =>
try {
Success(LocalDateTime.parse(s, dateFormater))
} catch {
case t: Throwable => Failure(t)
}
}

private[this] implicit val decodeUri: Decoder[Uri] = Decoder[String].map(p => Uri(p))

private[MastodonClient] def toContent(response: (Try[HttpResponse], _)): Source[String, _] = response._1 match {
case Success(r) => r.entity.dataBytes.map(_.decodeString(ByteString.UTF_8)).fold("")(_ + _)
case Failure(e) => Source.failed(e)
}

private[MastodonClient] def toSource[A](a: Either[ParsingFailure, Source[A, _]]): Source[A, _] = a match {
case Right(as) => as
case Left(e) => Source.failed(e)
}

private[MastodonClient] def asTimeline(json: Json): Either[ParsingFailure, Source[Status, _]] = {
json.asArray match {
case Some(ss) => Right(Source(ss).flatMapConcat(asStatus))
case None => Left(new ParsingFailure("Root node is expected to be an array.", null))
}
}

private[MastodonClient] def asStatus(json: Json): Source[Status, _] = {
json.as[Status] match {
case Right(s) => Source.single(s)
case Left(e) => Source.failed(e)
}
}

}

0 comments on commit 4f7f0a9

Please sign in to comment.