From 3bcf58711d74f0e847bf867a03d37177f521a28d Mon Sep 17 00:00:00 2001 From: Konstantin Kolmogortsev Date: Sun, 11 Aug 2024 23:01:13 +0300 Subject: [PATCH] Add file api support --- .../src/main/scala/muffin/api/ApiClient.scala | 52 +++++++++++++++-- .../scala/muffin/codec/CodecSupport.scala | 20 +++++++ .../main/scala/muffin/http/HttpClient.scala | 11 +++- .../src/main/scala/muffin/model/Files.scala | 19 +++++++ .../muffin/interop/http/sttp/SttpClient.scala | 57 ++++++++++++++----- .../muffin/interop/http/zio/ZioClient.scala | 42 ++++++++++---- .../scala/muffin/interop/json/zio/codec.scala | 5 ++ 7 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 modules/core/src/main/scala/muffin/model/Files.scala diff --git a/modules/core/src/main/scala/muffin/api/ApiClient.scala b/modules/core/src/main/scala/muffin/api/ApiClient.scala index 244c78a..f60f961 100644 --- a/modules/core/src/main/scala/muffin/api/ApiClient.scala +++ b/modules/core/src/main/scala/muffin/api/ApiClient.scala @@ -21,18 +21,21 @@ trait ApiClient[F[_], To[_], From[_]] { def postToDirect( userId: UserId, message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): F[Post] def postToChat( userIds: List[UserId], message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): F[Post] def postToChannel( channelId: ChannelId, message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): F[Post] @@ -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]] } @@ -198,34 +210,42 @@ object ApiClient { def postToDirect( userId: UserId, message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): F[Post] = for { id <- botId info <- channel(id :: userId :: Nil) - res <- postToChannel(info.id, message, props) + res <- postToChannel(info.id, message, fileIds, props) } yield res def postToChat( userIds: List[UserId], message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): F[Post] = for { id <- botId info <- channel(id :: userIds) - res <- postToChannel(info.id, message, props) + res <- postToChannel(info.id, message, fileIds, props) } yield res def postToChannel( channelId: ChannelId, message: Option[String] = None, + fileIds: List[FileId] = Nil, props: Props = Props.empty ): 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}") ) @@ -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 @@ -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 diff --git a/modules/core/src/main/scala/muffin/codec/CodecSupport.scala b/modules/core/src/main/scala/muffin/codec/CodecSupport.scala index 1c2ec0a..788e22a 100644 --- a/modules/core/src/main/scala/muffin/codec/CodecSupport.scala +++ b/modules/core/src/main/scala/muffin/codec/CodecSupport.scala @@ -304,6 +304,26 @@ 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]("extension") + .field[String]("mime_type") + .field[String]("name") + .field[UserId]("user_id") + .field[FileId]("id") + .build(FileInfo.apply.tupled) + + // Files + given DialogTo: To[Dialog] = json[Dialog] .field("callback_id", _.callbackId) diff --git a/modules/core/src/main/scala/muffin/http/HttpClient.scala b/modules/core/src/main/scala/muffin/http/HttpClient.scala index 1c141f3..b076caf 100644 --- a/modules/core/src/main/scala/muffin/http/HttpClient.scala +++ b/modules/core/src/main/scala/muffin/http/HttpClient.scala @@ -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[_]] { @@ -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, @@ -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 { diff --git a/modules/core/src/main/scala/muffin/model/Files.scala b/modules/core/src/main/scala/muffin/model/Files.scala new file mode 100644 index 0000000..4ae804e --- /dev/null +++ b/modules/core/src/main/scala/muffin/model/Files.scala @@ -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") + +} diff --git a/modules/integration/sttp-http-interop/src/main/scala/muffin/interop/http/sttp/SttpClient.scala b/modules/integration/sttp-http-interop/src/main/scala/muffin/interop/http/sttp/SttpClient.scala index 053fcfa..7de0589 100644 --- a/modules/integration/sttp-http-interop/src/main/scala/muffin/interop/http/sttp/SttpClient.scala +++ b/modules/integration/sttp-http-interop/src/main/scala/muffin/interop/http/sttp/SttpClient.scala @@ -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 @@ -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 @@ -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, diff --git a/modules/integration/zio-http-interop/src/main/scala/muffin/interop/http/zio/ZioClient.scala b/modules/integration/zio-http-interop/src/main/scala/muffin/interop/http/zio/ZioClient.scala index 60825dc..088888a 100644 --- a/modules/integration/zio-http-interop/src/main/scala/muffin/interop/http/zio/ZioClient.scala +++ b/modules/integration/zio-http-interop/src/main/scala/muffin/interop/http/zio/ZioClient.scala @@ -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 { @@ -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) ) } )) @@ -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, diff --git a/modules/integration/zio-json-interop/src/main/scala/muffin/interop/json/zio/codec.scala b/modules/integration/zio-json-interop/src/main/scala/muffin/interop/json/zio/codec.scala index 26fabcd..c6b1b27 100644 --- a/modules/integration/zio-json-interop/src/main/scala/muffin/interop/json/zio/codec.scala +++ b/modules/integration/zio-json-interop/src/main/scala/muffin/interop/json/zio/codec.scala @@ -1,6 +1,7 @@ package muffin.interop.json.zio import java.time.{Instant, LocalDateTime, ZoneId} +import scala.reflect.ClassTag import zio.json.* import zio.json.JsonDecoder.{JsonError, UnsafeJson} @@ -58,6 +59,10 @@ trait CodecLow extends CodecSupport[JsonEncoder, JsonDecoder] { given IntFrom: JsonDecoder[Int] = JsonDecoder.int + given ByteTo: JsonEncoder[Byte] = JsonEncoder.byte + + given ByteFrom: JsonDecoder[Byte] = JsonDecoder.byte + given LongTo: JsonEncoder[Long] = JsonEncoder.long given LongFrom: JsonDecoder[Long] = JsonDecoder.long