From 6020817fce2b446c5b9920dba540c88ea131beb1 Mon Sep 17 00:00:00 2001 From: Anton Sviridov Date: Wed, 19 Jun 2024 16:45:26 +0100 Subject: [PATCH] First commit --- .github/workflows/ci.yml | 39 +++++ .gitignore | 30 ++++ .scalafix.conf | 3 + .scalafmt.conf | 2 + Makefile | 18 +++ README.md | 82 ++++++++++ project.scala | 16 ++ smithy4s-fetch.scala | 332 ++++++++++++++++++++++++++++++++++++++ smithy4s-fetch.test.scala | 188 +++++++++++++++++++++ 9 files changed, 710 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .scalafix.conf create mode 100644 .scalafmt.conf create mode 100644 Makefile create mode 100644 README.md create mode 100644 project.scala create mode 100644 smithy4s-fetch.scala create mode 100644 smithy4s-fetch.test.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff1e9f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI +on: + push: + branches: ["main"] + tags: ["v*"] + pull_request: + branches: ["*"] + +jobs: + build: + strategy: + fail-fast: false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: coursier/cache-action@v6.3 + - uses: VirtusLab/scala-cli-setup@main + with: + power: true + + - name: Check formatting + run: make code-check || echo "Run `make pre-ci`" + + - name: Test + run: make test + + - name: Check documentation compiles and runs + run: make check-docs && make run-example + + - name: Publish + if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main') + run: make publish + env: + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + PGP_SECRET: ${{ secrets.PGP_SECRET }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d256eb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.class +.scala-build +.metals +.bsp +scalajs-frontend.js diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..f144930 --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,3 @@ +OrganizeImports.groupedImports = Merge +OrganizeImports.targetDialect = Scala3 +OrganizeImports.removeUnused = false diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..3b9e943 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,2 @@ +version = "3.8.2" +runner.dialect = scala3 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a4e9e69 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +check-docs: + scala-cli compile README.md smithy4s-fetch.scala project.scala + +test: + scala-cli test . + +publish: + scala-cli config publish.credentials s01.oss.sonatype.org env:SONATYPE_USER env:SONATYPE_PASSWORD + scala-cli publish . -S 3.3.3 + +code-check: + scala-cli fmt . --check + +run-example: + scala-cli run README.md project.scala smithy4s-fetch.scala -M helloWorld + +pre-ci: + scala-cli fmt . diff --git a/README.md b/README.md new file mode 100644 index 0000000..809e6c0 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# smithy4s-fetch + + +- [smithy4s-fetch](#smithy4s-fetch) + - [Installation](#installation) + - [Getting started](#getting-started) + - [Contributing](#contributing) + + +A [Smithy4s](https://disneystreaming.github.io/smithy4s/) client backend for [Scala.js](https://www.scala-js.org/), utilising [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) directly, without introducing a http4s/cats dependency. + +The purpose of this library is to provide users of Smithy4s backend services a more lightweight client for the frontend – if your Scala.js frontend is not using Cats/Cats-Effect based libraries, you can communicate with your Smithy4s backend directly using Fetch, **reducing bundle size by as much as 50% in some instances**. + +The library is currently only available for Scala 3, but we will welcome contributions cross-compiling it to 2.13 – it should be very easy. + +## Installation + +- **SBT**: `libraryDependencies += "tech.neander" %%% "smithy4s-fetch" % ""` +- **Scala CLI**: `//> using dep tech.neander::smithy4s-fetch::` +- **Mill**: `ivy"tech.neander::smithy4s-fetch::"` + +## Getting started + +For example's sake, let's say we have a smithy4s service that models one of the endpoints from https://httpbin.org, defined using [smithy4s-deriving](https://github.com/neandertech/smithy4s-deriving) (note we're using [Scala CLI](https://scala-cli.virtuslab.org) for this demo): + +```scala +//> using dep "tech.neander::smithy4s-deriving::0.0.2" +//> using platform scala-js +//> using scala 3.4.2 +//> using option -Wunused:all + +import smithy4s.* + +import scala.annotation.experimental +import scala.scalajs.js.Promise + +import deriving.{given, *} +import aliases.* + +case class Response(headers: Map[String, String], origin: String, url: String) + derives Schema + +@experimental +trait HttpbinService derives API: + @readonly + @httpGet("/get") + def get(): Promise[Response] +``` + +***Note** that we only need to use `@experimental` annotation because we are using smithy4s-deriving.* +*If you're creating clients for services generated by standard Smithy4s codegen, just remove all `@experimental` annotations* +*you see.* + +To create a Fetch client for this service all we need to do is this: + +```scala +import smithy4s_fetch.* + +@main @experimental +def helloWorld = + val service: HttpbinService = + SimpleRestJsonFetchClient( + API.service[HttpbinService], + "https://httpbin.org" + ).make.unliftService + + service.get().`then`(v => println(v)) +``` + +If your locally installed Node.js version is higher than 18 (version where `fetch` was added into Node.js), then you can run this example for youreself by running `make run-example` + +## Contributing + +If you see something that can be improved in this library – please contribute! + +This is a relatively is + +Here are some useful commands: + +- `make test` – run tests +- `make check-docs` – verify that snippets in `README.md` (this file) compile +- `make pre-ci` – format the code so that it passes CI check diff --git a/project.scala b/project.scala new file mode 100644 index 0000000..fe64eaf --- /dev/null +++ b/project.scala @@ -0,0 +1,16 @@ +//> using dep com.disneystreaming.smithy4s::smithy4s-core::0.18.22 +//> using dep com.disneystreaming.smithy4s::smithy4s-json::0.18.22 +//> using dep org.scala-js::scalajs-dom::2.8.0 +//> using option -Wunused:all +//> using platform scala-js +//> using publish.computeVersion git:tag +//> using publish.name smithy4s-fetch +//> using publish.organization tech.neander +//> using publish.repository "central" +//> using publish.secretKey env:PGP_SECRET +//> using publish.secretKeyPassword env:PGP_PASSPHRASE +//> using publish.license Apache-2.0 +//> using publish.developer "velvetbaldmime|Anton Sviridov|https://indoorvivants.com" +//> using publish.vcs github:neandertech/smithy4s-fetch +//> using scala 3.3.3 +//> using jsModuleKind es diff --git a/smithy4s-fetch.scala b/smithy4s-fetch.scala new file mode 100644 index 0000000..8362b43 --- /dev/null +++ b/smithy4s-fetch.scala @@ -0,0 +1,332 @@ +package smithy4s_fetch + +import org.scalajs.dom.{Fetch, Headers, Request, RequestInfo, Response, URL} +import smithy4s.Endpoint.Middleware +import smithy4s.capability.MonadThrowLike +import smithy4s.client.* +import smithy4s.codecs.BlobEncoder +import smithy4s.http.HttpUriScheme.{Http, Https} +import smithy4s.http.{ + CaseInsensitive, + HttpMethod, + HttpRequest, + HttpUnaryClientCodecs, + Metadata +} +import smithy4s.json.Json +import smithy4s.{Blob, Endpoint} + +import scala.scalajs.js.Promise +import scala.scalajs.js.typedarray.Int8Array + +import scalajs.js.JSConverters.* +import smithy4s.http.HttpDiscriminator +import org.scalajs.dom.RequestInit + +class SimpleRestJsonFetchClient[ + Alg[_[_, _, _, _, _]] +] private[smithy4s_fetch] ( + service: smithy4s.Service[Alg], + uri: URL, + middleware: Endpoint.Middleware[SimpleRestJsonFetchClient.Client], + codecs: SimpleRestJsonCodecs +) { + + def withMaxArity(maxArity: Int): SimpleRestJsonFetchClient[Alg] = + changeCodecs(_.copy(maxArity = maxArity)) + + def withExplicitDefaultsEncoding( + explicitDefaultsEncoding: Boolean + ): SimpleRestJsonFetchClient[Alg] = + changeCodecs(_.copy(explicitDefaultsEncoding = explicitDefaultsEncoding)) + + def withHostPrefixInjection( + hostPrefixInjection: Boolean + ): SimpleRestJsonFetchClient[Alg] = + changeCodecs(_.copy(hostPrefixInjection = hostPrefixInjection)) + + def make: Alg[[I, E, O, SI, SO] =>> Promise[O]] = + service.impl[Promise]( + UnaryClientCompiler[ + Alg, + Promise, + SimpleRestJsonFetchClient.Client, + RequestInfo, + Response + ]( + service = service, + toSmithy4sClient = SimpleRestJsonFetchClient.lowLevelClient(_), + client = Fetch.fetch(_), + middleware = middleware, + makeClientCodecs = codecs.makeClientCodecs(uri), + isSuccessful = resp => resp.ok + ) + ) + + private def changeCodecs( + f: SimpleRestJsonCodecs => SimpleRestJsonCodecs + ): SimpleRestJsonFetchClient[Alg] = + new SimpleRestJsonFetchClient( + service, + uri, + middleware, + f(codecs) + ) + +} + +object SimpleRestJsonFetchClient { + type Client = RequestInfo => Promise[Response] + + def apply[Alg[_[_, _, _, _, _]]]( + service: smithy4s.Service[Alg], + url: String + ) = + new SimpleRestJsonFetchClient( + service = service, + uri = new URL(url), + codecs = SimpleRestJsonCodecs, + middleware = Endpoint.Middleware.noop + ) + + private def lowLevelClient(fetch: Client) = + new UnaryLowLevelClient[Promise, RequestInfo, Response] { + override def run[Output](request: RequestInfo)( + responseCB: Response => Promise[Output] + ): Promise[Output] = + fetch(request).`then`(resp => responseCB(resp)) + } +} + +private[smithy4s_fetch] object SimpleRestJsonCodecs + extends SimpleRestJsonCodecs(1024, false, false) + +private[smithy4s_fetch] case class SimpleRestJsonCodecs( + maxArity: Int, + explicitDefaultsEncoding: Boolean, + hostPrefixInjection: Boolean +) { + private val hintMask = + alloy.SimpleRestJson.protocol.hintMask + + def unsafeFromSmithy4sHttpMethod( + method: smithy4s.http.HttpMethod + ): org.scalajs.dom.HttpMethod = + import smithy4s.http.HttpMethod.* + import org.scalajs.dom.HttpMethod as FetchMethod + method match + case GET => FetchMethod.GET + case PUT => FetchMethod.PUT + case POST => FetchMethod.POST + case DELETE => FetchMethod.DELETE + case PATCH => FetchMethod.PATCH + case OTHER(nm) => nm.asInstanceOf[FetchMethod] + + def toHeaders(smithyHeaders: Map[CaseInsensitive, Seq[String]]): Headers = { + + val h = new Headers() + + smithyHeaders.foreach { case (name, values) => + values.foreach { value => + h.append(name.toString, value) + } + } + + h + } + + def fromSmithy4sHttpUri(uri: smithy4s.http.HttpUri): String = { + val qp = uri.queryParams + val newValue = { + uri.scheme match + case Http => "http" + case Https => "https" + } + val hostName = uri.host + val port = + uri.port + .filterNot(p => uri.host.endsWith(s":$p")) + .map(":" + _.toString) + .getOrElse("") + + val path = "/" + uri.path.mkString("/") + val query = + if qp.isEmpty then "" + else + var b = "?" + qp.zipWithIndex.map: + case ((key, values), idx) => + if idx != 0 then b += "&" + b += key + for + i <- 0 until values.length + value = values(i) + do + if i == 0 then b += "=" + value + else b += s"&$key=$value" + + b + + s"$newValue://$hostName$port$path$query" + } + + def toSmithy4sHttpResponse( + resp: Response + ): Promise[smithy4s.http.HttpResponse[Blob]] = { + resp + .arrayBuffer() + .`then`: body => + val headers = Map.newBuilder[CaseInsensitive, Seq[String]] + + resp.headers.foreach: + case arr if arr.size >= 2 => + val header = arr(0) + val values = arr.tail.toSeq + headers += CaseInsensitive(header) -> values + case _ => + + smithy4s.http.HttpResponse( + resp.status, + headers.result(), + Blob(new Int8Array(body).toArray) + ) + + } + + def fromSmithy4sHttpRequest( + req: smithy4s.http.HttpRequest[Blob] + ): Request = { + val m = unsafeFromSmithy4sHttpMethod(req.method) + val h = toHeaders(req.headers) + val ri = new RequestInit {} + if (req.body.size != 0) { + val arr = new Int8Array(req.body.size) + arr.set( + req.body.toArray.toJSArray, + 0 + ) + ri.body = arr + h.append("Content-Length", req.body.size.toString) + } + + ri.method = m + ri.headers = h + + new Request(fromSmithy4sHttpUri(req.uri), ri) + } + + def toSmithy4sHttpUri( + uri: URL, + pathParams: Option[smithy4s.http.PathParams] = None + ): smithy4s.http.HttpUri = { + import smithy4s.http.* + val uriScheme = uri.protocol match { + case "https:" => HttpUriScheme.Https + case "http:" => HttpUriScheme.Http + case _ => + throw UnsupportedOperationException( + s"Protocol `${uri.protocol}` is not supported" + ) + } + + HttpUri( + uriScheme, + uri.host, + uri.port.toIntOption, + uri.pathname.split("/"), + uri.searchParams + .entries() + .toIterator + .toSeq + .groupMap(_._1)(_._2) + .toMap, + pathParams + ) + } + + val jsonCodecs = Json.payloadCodecs + .withJsoniterCodecCompiler( + Json.jsoniter + .withHintMask(hintMask) + .withMaxArity(maxArity) + .withExplicitDefaultsEncoding(explicitNulls = true) + ) + + val payloadEncoders: BlobEncoder.Compiler = + jsonCodecs.encoders + + val payloadDecoders = + jsonCodecs.decoders + + val errorHeaders = List( + smithy4s.http.errorTypeHeader + ) + + def makeClientCodecs( + uri: URL + ): UnaryClientCodecs.Make[Promise, RequestInfo, Response] = { + val baseRequest = HttpRequest( + HttpMethod.POST, + toSmithy4sHttpUri(uri, None), + Map.empty, + Blob.empty + ) + + HttpUnaryClientCodecs.builder + .withBodyEncoders(payloadEncoders) + .withSuccessBodyDecoders(payloadDecoders) + .withErrorBodyDecoders(payloadDecoders) + .withErrorDiscriminator(resp => + Promise.resolve(HttpDiscriminator.fromResponse(errorHeaders, resp)) + ) + .withMetadataDecoders(Metadata.Decoder) + .withMetadataEncoders( + Metadata.Encoder.withExplicitDefaultsEncoding( + explicitDefaultsEncoding + ) + ) + .withBaseRequest(_ => Promise.resolve(baseRequest)) + .withRequestMediaType("application/json") + .withRequestTransformation(req => + Promise.resolve(fromSmithy4sHttpRequest(req)) + ) + .withResponseTransformation[Response](resp => + Promise.resolve(toSmithy4sHttpResponse(resp)) + ) + .withHostPrefixInjection(hostPrefixInjection) + .build() + + } +} + +given MonadThrowLike[Promise] with + override def map[A, B](fa: Promise[A])(f: A => B): Promise[B] = fa.`then`(f) + + override def flatMap[A, B](fa: Promise[A])(f: A => Promise[B]): Promise[B] = + fa.`then`(f) + + override def handleErrorWith[A](fa: Promise[A])( + f: Throwable => Promise[A] + ): Promise[A] = fa.`catch`: + case ex: Throwable => f(ex) // TODO: does this make sense? + + override def pure[A](a: A): Promise[A] = Promise.resolve(a) + + override def raiseError[A](e: Throwable): Promise[A] = Promise.reject(e) + + override def zipMapAll[A](seq: IndexedSeq[Promise[Any]])( + f: IndexedSeq[Any] => A + ): Promise[A] = + Promise.all(seq.toJSIterable).`then`(res => Promise.resolve(f(res.toArray))) + + override def zipMap[A, B, C](fa: Promise[A], fb: Promise[B])( + f: (A, B) => C + ): Promise[C] = Promise + .all[Either[A, B]]( + Seq(fa.`then`(Left(_)), fb.`then`(Right(_))).toJSIterable + ) + .`then`: arr => + (arr(0), arr(1)) match + case (Left(x), Right(y)) => f(x, y) + case (Right(y), Left(x)) => f(x, y) + case _ => ??? diff --git a/smithy4s-fetch.test.scala b/smithy4s-fetch.test.scala new file mode 100644 index 0000000..0e217f3 --- /dev/null +++ b/smithy4s-fetch.test.scala @@ -0,0 +1,188 @@ +//> using test.dep com.disneystreaming::weaver-cats::0.8.4 +//> using test.dep "tech.neander::smithy4s-deriving::0.0.2" +//> using test.dep com.disneystreaming.smithy4s::smithy4s-http4s::0.18.22 +//> using test.dep org.http4s::http4s-ember-server::0.23.27 +//> using test.dep org.http4s::http4s-ember-client::0.23.27 +//> using testFramework "weaver.framework.CatsEffect" +//> using scala 3.4.2 + +package smithy4s_fetch.tests + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.syntax.all.* +import com.comcast.ip4s.port +import org.http4s.Uri +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.ember.server.EmberServerBuilder +import smithy4s.deriving.API +import smithy4s.http.HttpUriScheme +import smithy4s.http4s.SimpleRestJsonBuilder +import smithy4s_fetch.SimpleRestJsonFetchClient +import weaver.{FunSuiteIO, IOSuite} + +import scala.concurrent.duration.* +import scala.scalajs.js.Promise +import smithy4s.http.HttpUri + +object UnitTest extends FunSuiteIO: + val uri = + smithy4s.http.HttpUri( + scheme = HttpUriScheme.Https, + path = Vector("hello", "world"), + queryParams = Map( + "k" -> Seq.empty, + "k2" -> Seq("hello"), + "k3" -> Seq("hello", "world", "!") + ), + host = "localhost", + pathParams = None, + port = Some(9999) + ) + + def enc(uri: HttpUri): String = + smithy4s_fetch.SimpleRestJsonCodecs.fromSmithy4sHttpUri(uri) + + test("URI encoding"): + expect.same( + enc(uri), + "https://localhost:9999/hello/world?k&k2=hello&k3=hello&k3=world&k3=!" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty)), + "https://localhost:9999/hello/world" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty, scheme = HttpUriScheme.Http)), + "http://localhost:9999/hello/world" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty, host = "hello.com")), + "https://hello.com:9999/hello/world" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty, port = None)), + "https://localhost/hello/world" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty, path = Vector.empty)), + "https://localhost:9999/" + ) && + expect.same( + enc(uri.copy(queryParams = Map.empty, path = Vector("1", "2", "3"))), + "https://localhost:9999/1/2/3" + ) + +@annotation.experimental +object IntegrationTest extends IOSuite: + val service = API.service[IOService] + val promiseService = API.service[PromiseService] + + val routesResource = + SimpleRestJsonBuilder + .routes(IOService().liftService[IO]) + .resource + .map(_.orNotFound) + + case class Probe( + serverUri: Uri, + ioClient: IOService, + fetchClient: PromiseService + ) + + override type Res = Probe + override def sharedResource: Resource[IO, Res] = + + val serverUri = routesResource.flatMap: app => + EmberServerBuilder + .default[IO] + .withPort(port"0") + .withHttpApp(app) + .withShutdownTimeout(0.seconds) + .build + .map(_.baseUri) + + serverUri.flatMap: uri => + + val http4sClient = EmberClientBuilder + .default[IO] + .build + .flatMap: httpClient => + SimpleRestJsonBuilder(service) + .client[IO](httpClient) + .uri(uri) + .resource + .map(_.unliftService) + + val fetchClient = + IO.pure( + SimpleRestJsonFetchClient( + promiseService, + uri.renderString + ).make.unliftService + ).toResource + + (http4sClient, fetchClient).mapN((io, fetch) => Probe(uri, io, fetch)) + + end sharedResource + + test("hello response"): res => + for + ioResp <- res.ioClient.hello() + fetchResp <- IO.fromFuture(IO(res.fetchClient.hello().toFuture)) + yield expect.same(ioResp, fetchResp) + + test("stub response"): res => + for + ioResp <- res.ioClient.stub(IP("yo"), "bruh") + fetchResp <- IO.fromFuture( + IO(res.fetchClient.stub(IP("yo"), "bruh").toFuture) + ) + yield expect.same(ioResp, fetchResp) + +end IntegrationTest + +import smithy4s.*, deriving.{given, *}, aliases.* + +import scala.annotation.experimental // the derivation of API uses experimental metaprogramming features, at this time. + +trait Routes[F[_]]: + def hello(): F[IP] + + def stub(ip: IP, name: String): F[StubResponse] + +@experimental +@simpleRestJson +class IOService() extends Routes[IO] derives API: + @readonly + @httpGet("/httpbin/ip") + override def hello(): IO[IP] = IO.pure(IP("127.0.0.1")) + + @httpDelete("/httpbin/delete") + override def stub( + ip: IP, + @httpQuery("username") name: String + ): IO[StubResponse] = + IO.pure( + StubResponse(s"hello, $name", "http://localhost", Map.empty, myIP = ip) + ) + +@experimental +trait PromiseService extends Routes[Promise] derives API: + @readonly + @httpGet("/httpbin/ip") + override def hello(): Promise[IP] + + @httpDelete("/httpbin/delete") + override def stub( + ip: IP, + @httpQuery("username") name: String + ): Promise[StubResponse] + +case class IP(origin: String) derives Schema +case class StubResponse( + origin: String, + url: String, + headers: Map[String, String], + myIP: IP +) derives Schema