-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
218 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
85
src/main/scala/com/github/okapies/akka/mastodon/package.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
|
||
} |
94 changes: 94 additions & 0 deletions
94
src/main/scala/com/github/okapies/akka/mastodon/scaladsl/MastodonClient.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
|
||
} |