diff --git a/build.sbt b/build.sbt index fe019dc09..8d1fab8d2 100644 --- a/build.sbt +++ b/build.sbt @@ -605,7 +605,9 @@ lazy val http4s = projectMatrix Dependencies.Http4s.client.value, Dependencies.Alloy.core % Test, Dependencies.Http4s.circe.value % Test, - Dependencies.Weaver.cats.value % Test + Dependencies.Weaver.cats.value % Test, + Dependencies.Http4s.emberClient.value % Test, + Dependencies.Http4s.emberServer.value % Test ) }, moduleName := { diff --git a/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sClientEndpoint.scala b/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sClientEndpoint.scala index 3bcf5b6a7..2097b074f 100644 --- a/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sClientEndpoint.scala +++ b/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sClientEndpoint.scala @@ -25,9 +25,10 @@ import org.http4s.Request import org.http4s.Response import org.http4s.Uri import org.http4s.client.Client +import scodec.bits.ByteVector +import smithy4s.kinds._ import smithy4s.http._ import smithy4s.schema.SchemaAlt -import smithy4s.kinds._ /** * A construct that encapsulates interprets and a low-level @@ -115,7 +116,7 @@ private[http4s] class SmithyHttp4sClientEndpointImpl[F[_], Op[_, _, _, _, _], I, val baseRequest = Request[F](method, uri, headers = headers) if (inputHasBody) { baseRequest.withEntity(input) - } else baseRequest + } else baseRequest.withEntity(ByteVector.empty) } private def outputFromResponse(response: Response[F]): F[O] = diff --git a/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sServerEndpoint.scala b/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sServerEndpoint.scala index 629e3e761..0333a758c 100644 --- a/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sServerEndpoint.scala +++ b/modules/http4s/src/smithy4s/http4s/internals/SmithyHttp4sServerEndpoint.scala @@ -100,7 +100,7 @@ private[http4s] class SmithyHttp4sServerEndpointImpl[F[_], Op[_, _, _, _, _], I, def run(pathParams: PathParams, request: Request[F]): F[Response[F]] = { val run: F[O] = for { metadata <- getMetadata(pathParams, request) - input <- extractInput.run((metadata, request)) + input <- extractInput(metadata, request) output <- (impl(endpoint.wrap(input)): F[O]) } yield output @@ -132,26 +132,24 @@ private[http4s] class SmithyHttp4sServerEndpointImpl[F[_], Op[_, _, _, _, _], I, errorTransformation(other).flatMap(F.raiseError) } - - - // format: off - private val extractInput: (Metadata, Request[F]) ==> I = { + private val extractInput: (Metadata, Request[F]) => F[I] = { inputMetadataDecoder.total match { case Some(totalDecoder) => - Kleisli(totalDecoder.decode(_: Metadata).liftTo[F]).local(_._1) + (metadata, request) => + request.body.compile.drain *> + totalDecoder.decode(metadata).liftTo[F] case None => // NB : only compiling the input codec if the data cannot be // totally extracted from the metadata. - implicit val inputCodec = entityCompiler.compilePartialEntityDecoder(inputSchema, entityCache) - Kleisli { case (metadata, request) => + implicit val inputCodec = + entityCompiler.compilePartialEntityDecoder(inputSchema, entityCache) + (metadata, request) => for { metadataPartial <- inputMetadataDecoder.decode(metadata).liftTo[F] bodyPartial <- request.as[BodyPartial[I]] } yield metadataPartial.combine(bodyPartial) - } } } - // format: on private def putHeaders(m: Message[F], headers: Headers) = m.putHeaders(headers.headers) diff --git a/modules/http4s/test/src-jvm-ce3/smithy4s/http4s/Http4sEmberPizzaClientSpec.scala b/modules/http4s/test/src-jvm-ce3/smithy4s/http4s/Http4sEmberPizzaClientSpec.scala new file mode 100644 index 000000000..4d41d3306 --- /dev/null +++ b/modules/http4s/test/src-jvm-ce3/smithy4s/http4s/Http4sEmberPizzaClientSpec.scala @@ -0,0 +1,99 @@ +/* + * Copyright 2021-2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.http4s + +import cats.effect.IO +import cats.effect.Resource +import cats.effect.syntax.resource._ +import cats.implicits._ +import com.comcast.ip4s._ +import com.comcast.ip4s.Port +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.HttpApp +import org.http4s.Uri +import smithy4s.example._ +import smithy4s.example.PizzaAdminService +import weaver._ + +object Http4sEmberPizzaClientSpec extends IOSuite { + type Res = PizzaAdminService[IO] + + override def sharedResource: Resource[IO, Res] = { + SimpleRestJsonBuilder + .routes(dummyImpl) + .resource + .flatMap(r => retryResource(server(r.orNotFound))) + .flatMap { port => makeClient(port) } + } + + def makeClient(port: Int): Resource[IO, PizzaAdminService[IO]] = + EmberClientBuilder.default[IO].build.flatMap { client => + SimpleRestJsonBuilder(PizzaAdminService) + .client(client) + .uri(Uri.unsafeFromString(s"http://localhost:$port")) + .resource + } + + def server(app: HttpApp[IO]): Resource[IO, Int] = + cats.effect.std.Random + .scalaUtilRandom[IO] + .flatMap(_.betweenInt(50000, 60000)) + .toResource + .flatMap(port => + Port + .fromInt(port) + .toRight(new Exception(s"Invalid port: $port")) + .liftTo[IO] + .toResource + ) + .flatMap { port => + EmberServerBuilder + .default[IO] + .withHost(host"localhost") + .withPort(port) + .withHttpApp(app) + .build + .map(_ => port.value) + } + + test("empty body") { client => + (client.book("name") *> client.book("name2")).as(success) + } + + private val dummyImpl = new PizzaAdminService[IO]() { + // format: off + override def addMenuItem(restaurant: String, menuItem: MenuItem): IO[AddMenuItemResult] = IO.stub + override def getMenu(restaurant: String): IO[GetMenuResult] = IO.stub + override def version(): IO[VersionOutput] = IO.stub + override def health(query: Option[String]): IO[HealthResponse] = IO.pure(HealthResponse("good")) + override def headerEndpoint(uppercaseHeader: Option[String], capitalizedHeader: Option[String], lowercaseHeader: Option[String], mixedHeader: Option[String]): IO[HeaderEndpointData] = IO.stub + override def roundTrip(label: String, header: Option[String], query: Option[String], body: Option[String]): IO[RoundTripData] = IO.stub + override def getEnum(aa: TheEnum): IO[GetEnumOutput] = IO.stub + override def getIntEnum(aa: EnumResult): IO[GetIntEnumOutput] = IO.stub + override def customCode(code: Int): IO[CustomCodeOutput] = IO.stub + override def book(name: String, town: Option[String]): IO[BookOutput] = IO.pure(BookOutput("name")) + // format: on + } + + def retryResource[A]( + resource: Resource[IO, A], + max: Int = 10 + ): Resource[IO, A] = + if (max <= 0) resource + else resource.orElse(retryResource(resource, max - 1)) +} diff --git a/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala b/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala index af888562a..8e15ce8ff 100644 --- a/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala +++ b/modules/tests/src/smithy4s/tests/PizzaAdminServiceImpl.scala @@ -37,6 +37,9 @@ object PizzaAdminServiceImpl { class PizzaAdminServiceImpl(ref: Compat.Ref[IO, State]) extends PizzaAdminService[IO] { + def book(name: String, town: Option[String]): IO[BookOutput] = + IO.pure(BookOutput(message = s"Booked for $name")) + def getEnum(theEnum: TheEnum): IO[GetEnumOutput] = IO.pure(GetEnumOutput(result = Some(theEnum.value))) diff --git a/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala b/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala index 2a65f4dd9..80e9b386c 100644 --- a/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala +++ b/modules/tests/src/smithy4s/tests/PizzaClientSpec.scala @@ -19,16 +19,16 @@ package smithy4s.tests import cats.data.Chain import cats.effect._ import cats.effect.std.UUIDGen +import cats.Show import cats.syntax.all._ import io.circe.Json -import org.http4s.HttpApp import org.http4s._ import org.http4s.circe._ import org.http4s.dsl.io._ +import org.http4s.HttpApp import org.typelevel.ci.CIString -import smithy4s.Timestamp import smithy4s.example._ -import cats.Show +import smithy4s.Timestamp import weaver._ abstract class PizzaClientSpec extends IOSuite { @@ -302,6 +302,9 @@ abstract class PizzaClientSpec extends IOSuite { case request @ (GET -> Root / "custom-code" / IntVar(code)) => storeAndReturn(s"customCode$code", request) + case POST -> Root / "book" / _ => + val body = Json.obj("message" -> Json.fromString("test")) + Ok(body) } .orNotFound } diff --git a/sampleSpecs/pizza.smithy b/sampleSpecs/pizza.smithy index c07da81da..ed049fd48 100644 --- a/sampleSpecs/pizza.smithy +++ b/sampleSpecs/pizza.smithy @@ -8,7 +8,7 @@ use alloy#simpleRestJson service PizzaAdminService { version: "1.0.0", errors: [GenericServerError, GenericClientError], - operations: [AddMenuItem, GetMenu, Version, Health, HeaderEndpoint, RoundTrip, GetEnum, GetIntEnum, CustomCode] + operations: [AddMenuItem, GetMenu, Version, Health, HeaderEndpoint, RoundTrip, GetEnum, GetIntEnum, CustomCode, Book] } @http(method: "POST", uri: "/restaurant/{restaurant}/menu/item", code: 201) @@ -301,3 +301,19 @@ structure CustomCodeOutput { @httpResponseCode code: Integer } + +@http(method: "POST", uri: "/book/{name}", code: 200) +operation Book { + input := { + @httpLabel + @required + name: String, + + @httpQuery("town") + town: String + }, + output := { + @required + message: String + } +}