Skip to content

Commit

Permalink
Merge pull request #17 from theBird2Bones/files_api
Browse files Browse the repository at this point in the history
Add file api support
  • Loading branch information
little-inferno authored Aug 19, 2024
2 parents 0f65f53 + 4409685 commit 068713b
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 37 deletions.
64 changes: 54 additions & 10 deletions modules/core/src/main/scala/muffin/api/ApiClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,22 @@ trait ApiClient[F[_], To[_], From[_]] {
def postToDirect(
userId: UserId,
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post]

def postToChat(
userIds: List[UserId],
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post]

def postToChannel(
channelId: ChannelId,
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post]

def postToThread(
Expand Down Expand Up @@ -175,6 +178,15 @@ trait ApiClient[F[_], To[_], From[_]] {

def getRoles(names: List[String]): F[List[RoleInfo]]

// Roles
// Files

def file(id: FileId): F[Array[Byte]]

def uploadFile(req: UploadFileRequest): F[UploadFileResponse]

// Files

def websocket(): F[WebsocketBuilder[F, To, From]]
}

Expand All @@ -198,34 +210,42 @@ object ApiClient {
def postToDirect(
userId: UserId,
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post] =
for {
id <- botId
info <- channel(id :: userId :: Nil)
res <- postToChannel(info.id, message, props)
res <- postToChannel(info.id, message, props, fileIds)
} yield res

def postToChat(
userIds: List[UserId],
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post] =
for {
id <- botId
info <- channel(id :: userIds)
res <- postToChannel(info.id, message, props)
res <- postToChannel(info.id, message, props, fileIds)
} yield res

def postToChannel(
channelId: ChannelId,
message: Option[String] = None,
props: Props = Props.empty
props: Props = Props.empty,
fileIds: List[FileId] = Nil
): F[Post] =
http.request(
cfg.baseUrl + "/posts",
Method.Post,
jsonRaw.field("channel_id", channelId).field("message", message).field("props", props).build,
jsonRaw
.field("channel_id", channelId)
.field("message", message)
.field("file_ids", fileIds)
.field("props", props)
.build,
Map("Authorization" -> s"Bearer ${cfg.auth}")
)

Expand Down Expand Up @@ -396,7 +416,7 @@ object ApiClient {
cfg.baseUrl + s"/emoji",
Method.Post,
Body.Multipart(
MultipartElement.FileElement("image", req.image) ::
MultipartElement.FileElement("image", FilePayload.fromBytes(req.image)) ::
MultipartElement.StringElement(
"emoji",
jsonRaw.field("creator_id", req.creatorId).field("name", req.emojiName).build.value
Expand Down Expand Up @@ -805,6 +825,30 @@ object ApiClient {
)

// Roles
// Files

def file(id: FileId): F[Array[Byte]] =
http.requestRawData[Nothing](
cfg.baseUrl + s"/files/$id",
Method.Get,
Body.Empty,
Map("Authorization" -> s"Bearer ${cfg.auth}")
)

def uploadFile(req: UploadFileRequest): F[UploadFileResponse] =
http.request[Nothing, UploadFileResponse](
cfg.baseUrl + "/files",
Method.Post,
Body.Multipart(
List(
MultipartElement.FileElement("files", req.payload),
MultipartElement.StringElement("channel_id", req.channelId.value)
)
),
Map("Authorization" -> s"Bearer ${cfg.auth}")
)

// Files
// WebSocket
/*
Every call is a new websocket connection
Expand Down
18 changes: 18 additions & 0 deletions modules/core/src/main/scala/muffin/codec/CodecSupport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,24 @@ trait CodecSupportLow[To[_], From[_]] extends PrimitivesSupport[To, From] {
.build(RoleInfo.apply.tupled)
// Roles

// Files

given UploadFileResponseFrom: From[UploadFileResponse] =
parsing
.field[List[FileInfo]]("file_infos")
.build {
case infos *: EmptyTuple => UploadFileResponse(infos)
}

given FileInfoFrom: From[FileInfo] =
parsing
.field[String]("name")
.field[UserId]("user_id")
.field[FileId]("id")
.build(FileInfo.apply)

// Files

given DialogTo: To[Dialog] =
json[Dialog]
.field("callback_id", _.callbackId)
Expand Down
11 changes: 10 additions & 1 deletion modules/core/src/main/scala/muffin/http/HttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import cats.Show
import cats.syntax.all.given

import muffin.api.BackoffSettings
import muffin.model.FilePayload
import muffin.model.websocket.domain.*

trait HttpClient[F[_], -To[_], From[_]] {
Expand All @@ -18,6 +19,14 @@ trait HttpClient[F[_], -To[_], From[_]] {
params: Params => Params = identity
): F[Out]

def requestRawData[In: To](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params = identity
): F[Array[Byte]]

def websocketWithListeners(
uri: URI,
headers: Map[String, String] = Map.empty,
Expand All @@ -44,7 +53,7 @@ sealed trait MultipartElement

object MultipartElement {
case class StringElement(name: String, value: String) extends MultipartElement
case class FileElement(name: String, value: Array[Byte]) extends MultipartElement
case class FileElement(name: String, value: FilePayload) extends MultipartElement
}

enum Method {
Expand Down
19 changes: 19 additions & 0 deletions modules/core/src/main/scala/muffin/model/Files.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package muffin.model

import muffin.internal.NewType

type FileId = FileId.Type
object FileId extends NewType[String]

case class UploadFileRequest(payload: FilePayload, channelId: ChannelId)
case class UploadFileResponse(fileInfos: List[FileInfo])

case class FileInfo(id: FileId, userId: UserId, name: String)

case class FilePayload(content: Array[Byte], name: String)

object FilePayload {

def fromBytes(content: Array[Byte]): FilePayload = FilePayload(content, "payload")

}
3 changes: 2 additions & 1 deletion modules/core/src/test/resources/http/test/posts/Post.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"fallback": "Duis aute irure dolor in reprehenderit"
}
]
}
},
"file_ids": []
}
},
"response": {
Expand Down
9 changes: 9 additions & 0 deletions modules/core/src/test/scala/muffin/api/ApiTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,13 @@ trait ApiTest[To[_], From[_]](integration: String, codecSupport: CodecSupport[To
}
}

Feature("file api") {
Scenario(s"get file content") {
apiClient
.file(FileId("id"))
.map(bytes => new String(bytes))
.map(res => assert(res.contains("wubba lubba dub dub")))
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ class CirceApiTest extends ApiTest[Encoder, Decoder]("circe", codec) {
case Body.Multipart(parts) => ???
}).flatMap(parseJson(_))

def requestRawData[In: Encoder](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params
): IO[Array[Byte]] = IO("wubba lubba dub dub".getBytes)

def websocketWithListeners(
uri: URI,
headers: Map[String, String],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import fs2.*
import sttp.capabilities.WebSockets
import sttp.capabilities.fs2.Fs2Streams
import sttp.client3.*
import sttp.model.{Method as SMethod, Uri}
import sttp.model.{MediaType, Method as SMethod, Uri}
import sttp.ws.WebSocketFrame

import muffin.api.BackoffSettings
Expand All @@ -36,7 +36,44 @@ class SttpClient[F[_]: Temporal: Parallel, To[_], From[_]](
headers: Map[String, String],
params: Params => Params
): F[Out] = {
val req = basicRequest
val req = mkRequest(url, method, body, headers, params)
.response(asString.mapLeft(MuffinError.Http.apply))
.mapResponse(_.flatMap(Decode[Out].apply))

backend.send(req)
.map(_.body)
.flatMap {
case Left(error) => MonadThrow[F].raiseError(error)
case Right(value) => value.pure[F]
}
}

def requestRawData[In: To](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params
): F[Array[Byte]] = {
val req = mkRequest(url, method, body, headers, params)
.response(asByteArray.mapLeft(MuffinError.Http.apply))

backend.send(req)
.map(_.body)
.flatMap {
case Left(error) => MonadThrow[F].raiseError(error)
case Right(value) => value.pure[F]
}
}

private def mkRequest[In: To, Out: From, R >: Fs2Streams[F] & WebSockets](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params
) =
basicRequest
.method(
method match {
case Method.Get => SMethod.GET
Expand Down Expand Up @@ -65,22 +102,14 @@ class SttpClient[F[_]: Temporal: Parallel, To[_], From[_]](
.multipartBody(
parts.map {
case MultipartElement.StringElement(name, value) => multipart(name, value)
case MultipartElement.FileElement(name, value) => multipart(name, value)

case MultipartElement.FileElement(name, payload) =>
multipart(name, payload.content).fileName(payload.name)
}
)
.header("Content-Type", "multipart/form-data")
.contentType(MediaType.MultipartFormData)
}
}
.response(asString.mapLeft(MuffinError.Http.apply))
.mapResponse(_.flatMap(Decode[Out].apply))

backend.send(req)
.map(_.body)
.flatMap {
case Left(error) => MonadThrow[F].raiseError(error)
case Right(value) => value.pure[F]
}
}

def websocketWithListeners(
uri: URI,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ class ZioClient[R, To[_], From[_]](codec: CodecSupport[To, From])
headers: Map[String, String],
params: Params => Params
): RIO[R with Client, Out] =
for {
response <- mkRequest(url, method, body, headers, params)

stringResponse <- response.body.asString(Charset.defaultCharset())
res <-
Decode[Out].apply(stringResponse) match {
case Left(value) => ZIO.fail(value)
case Right(value) => ZIO.succeed(value)
}
} yield res

def requestRawData[In: To](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params
): RIO[R with Client with Scope, Array[Byte]] = mkRequest(url, method, body, headers, params).flatMap(_.body.asArray)

private def mkRequest[In: To](
url: String,
method: Method,
body: Body[In],
headers: Map[String, String],
params: Params => Params
) =
for {
requestBody <-
body match {
Expand All @@ -43,11 +69,12 @@ class ZioClient[R, To[_], From[_]](codec: CodecSupport[To, From])
form = Form.apply(Chunk.fromIterable(
parts.map {
case MultipartElement.StringElement(name, value) => FormField.textField(name, value)
case MultipartElement.FileElement(name, value) =>
case MultipartElement.FileElement(name, payload) =>
FormField.binaryField(
name,
Chunk.fromArray(value),
MediaType.apply("application", "octet-stream", false, true)
Chunk.fromArray(payload.content),
MediaType.apply("application", "octet-stream", false, true),
filename = Some(payload.name)
)
}
))
Expand All @@ -67,14 +94,7 @@ class ZioClient[R, To[_], From[_]](codec: CodecSupport[To, From])
Headers(headers.map(Header.Custom.apply).toList),
content = requestBody
)

stringResponse <- response.body.asString(Charset.defaultCharset())
res <-
Decode[Out].apply(stringResponse) match {
case Left(value) => ZIO.fail(value)
case Right(value) => ZIO.succeed(value)
}
} yield res
} yield response

def websocketWithListeners(
uri: URI,
Expand Down
Loading

0 comments on commit 068713b

Please sign in to comment.