Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file api support #17

Merged
merged 1 commit into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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