├── project ├── build.properties └── plugins.sbt ├── .scalafix.conf ├── .scala-steward.conf ├── .git-blame-ignore-revs ├── .gitignore ├── CONTRIBUTING.md ├── .mergify.yml ├── .scalafmt.conf ├── examples ├── armeria-fs2grpc │ └── src │ │ ├── main │ │ ├── protobuf │ │ │ └── hello.proto │ │ └── scala │ │ │ └── com │ │ │ └── example │ │ │ └── fs2grpc │ │ │ └── armeria │ │ │ ├── HelloServiceImpl.scala │ │ │ ├── ExampleService.scala │ │ │ └── Main.scala │ │ └── test │ │ └── scala │ │ └── com │ │ └── example │ │ └── fs2grpc │ │ └── armeria │ │ └── HelloServiceTest.scala ├── armeria-scalapb │ └── src │ │ ├── main │ │ ├── protobuf │ │ │ └── hello.proto │ │ └── scala │ │ │ └── com │ │ │ └── example │ │ │ └── scalapb │ │ │ └── armeria │ │ │ ├── HelloServiceImpl.scala │ │ │ ├── ExampleService.scala │ │ │ └── Main.scala │ │ └── test │ │ └── scala │ │ └── com │ │ └── example │ │ └── scalapb │ │ └── armeria │ │ └── HelloServiceTest.scala └── armeria-http4s │ └── src │ └── main │ └── scala │ └── com │ └── exmaple │ └── http4s │ └── armeria │ ├── NoneShallPass.scala │ ├── Main.scala │ └── ExampleService.scala ├── client └── src │ ├── test │ ├── resources │ │ └── logback-test.xml │ └── scala │ │ └── org │ │ └── http4s │ │ └── armeria │ │ └── client │ │ └── ArmeriaClientSuite.scala │ └── main │ └── scala │ └── org │ └── http4s │ └── armeria │ └── client │ ├── ArmeriaClient.scala │ └── ArmeriaClientBuilder.scala ├── server └── src │ ├── test │ ├── resources │ │ └── logback-test.xml │ └── scala │ │ └── org │ │ └── http4s │ │ └── armeria │ │ └── server │ │ ├── ServerFixture.scala │ │ └── ArmeriaServerBuilderSuite.scala │ └── main │ └── scala │ └── org │ └── http4s │ └── armeria │ └── server │ ├── SSLContextFactory.scala │ ├── ArmeriaHttp4sHandler.scala │ └── ArmeriaServerBuilder.scala ├── CODE_OF_CONDUCT.md ├── .github └── workflows │ ├── clean.yml │ └── ci.yml ├── README.md └── LICENSE /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.7 2 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | RedundantSyntax 3 | ] 4 | 5 | triggered.rules = [ 6 | RedundantSyntax 7 | ] 8 | -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- 1 | updates.pin = [ 2 | { groupId = "ch.qos.logback", artifactId = "logback-classic", version = "1.2." } 3 | ] 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.5.9 2 | 716ee0693927daa572df83716f65eca900293634 3 | 4 | # Scala Steward: Reformat with scalafmt 3.8.1 5 | f0967c355bef3aecb81b5fcc8a8fde5929fcac07 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains 2 | .idea/ 3 | 4 | # sbt specific 5 | .cache 6 | .history 7 | .lib/ 8 | dist/* 9 | target/ 10 | lib_managed/ 11 | src_managed/ 12 | project/boot/ 13 | project/plugins/project/ 14 | test-output/ 15 | 16 | .bsp/ 17 | 18 | # macOS folder meta 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to http4s 2 | 3 | Thank you for your interest! A full [contributors' guide] is at 4 | http4s.org with build steps, coding standards, etc. 5 | 6 | ## tl;dr Grant of License 7 | 8 | http4s-armeria is licensed under the [Apache License 2.0]. Opening a pull 9 | request signifies your consent to license your contributions under the 10 | Apache License 2.0. 11 | 12 | [contributors' guide]: https://http4s.org/contributing/ 13 | [Apache License 2.0]: ./LICENSE 14 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | // http4s organization 2 | addSbtPlugin("org.http4s" % "sbt-http4s-org" % "2.0.3") 3 | 4 | // ScalaDoc API mapping 5 | addSbtPlugin("com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "3.0.2") 6 | 7 | // ScalaPB Reactor 8 | addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.8") 9 | libraryDependencies += "kr.ikhoon.scalapb-reactor" %% "scalapb-reactor-codegen" % "0.3.0" 10 | 11 | // fs2-grpc 12 | addSbtPlugin("org.lyranthe.fs2-grpc" % "sbt-java-gen" % "1.0.1") 13 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: assign and label scala-steward's PRs 3 | conditions: 4 | - author=scala-steward 5 | actions: 6 | assign: 7 | users: [ikhoon] 8 | label: 9 | add: [dependencies] 10 | - name: automatically merge scala-steward's PRs 11 | conditions: 12 | - author=scala-steward 13 | - status-success=test 14 | - body~=labels:.*semver-patch 15 | actions: 16 | merge: 17 | method: squash 18 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.10.2 2 | 3 | style = default 4 | 5 | maxColumn = 100 6 | 7 | runner.dialect = scala212 8 | 9 | // Vertical alignment is pretty, but leads to bigger diffs 10 | align.preset = none 11 | 12 | danglingParentheses.preset = false 13 | 14 | rewrite.rules = [ 15 | AvoidInfix 16 | RedundantBraces 17 | RedundantParens 18 | AsciiSortImports 19 | PreferCurlyFors 20 | ] 21 | 22 | project.excludeFilters = [ 23 | "scalafix-inputs", 24 | "scalafix-outputs" 25 | ] 26 | -------------------------------------------------------------------------------- /examples/armeria-fs2grpc/src/main/protobuf/hello.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package example.armeria.grpc; 4 | 5 | service HelloService { 6 | rpc unary(HelloRequest) returns (HelloReply) {} 7 | rpc serverStreaming(HelloRequest) returns (stream HelloReply) {} 8 | rpc clientStreaming(stream HelloRequest) returns (HelloReply) {} 9 | rpc bidiStreaming(stream HelloRequest) returns (stream HelloReply) {} 10 | } 11 | 12 | message HelloRequest { 13 | string name = 1; 14 | } 15 | 16 | message HelloReply { 17 | string message = 1; 18 | } 19 | -------------------------------------------------------------------------------- /examples/armeria-scalapb/src/main/protobuf/hello.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package example.armeria.grpc; 4 | 5 | service HelloService { 6 | rpc unary(HelloRequest) returns (HelloReply) {} 7 | rpc serverStreaming(HelloRequest) returns (stream HelloReply) {} 8 | rpc clientStreaming(stream HelloRequest) returns (HelloReply) {} 9 | rpc bidiStreaming(stream HelloRequest) returns (stream HelloReply) {} 10 | } 11 | 12 | message HelloRequest { 13 | string name = 1; 14 | } 15 | 16 | message HelloReply { 17 | string message = 1; 18 | } 19 | -------------------------------------------------------------------------------- /examples/armeria-http4s/src/main/scala/com/exmaple/http4s/armeria/NoneShallPass.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2022 http4s.org 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | package com.exmaple.http4s.armeria 8 | 9 | import com.linecorp.armeria.common.{HttpRequest, HttpResponse, HttpStatus} 10 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext, SimpleDecoratingHttpService} 11 | 12 | final class NoneShallPass(delegate: HttpService) extends SimpleDecoratingHttpService(delegate) { 13 | 14 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = 15 | HttpResponse.of(HttpStatus.FORBIDDEN) 16 | } 17 | -------------------------------------------------------------------------------- /client/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n${LOGBACK_EXCEPTION_PATTERN:-%throwable} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /server/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n${LOGBACK_EXCEPTION_PATTERN:-%throwable} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. 4 | 5 | Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. If you are being harassed, please contact us immediately so that we can support you. 6 | 7 | ## Moderation 8 | 9 | For any questions, concerns, or moderation requests, please contact a member of the [community staff](https://http4s.org/code-of-conduct/#moderation) 10 | 11 | [Scala Code of Conduct]: https://http4s.org/code-of-conduct/ 12 | [Community staff]: https://http4s.org/code-of-conduct/#moderation 13 | -------------------------------------------------------------------------------- /examples/armeria-http4s/src/main/scala/com/exmaple/http4s/armeria/Main.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2022 http4s.org 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | package com.exmaple.http4s 8 | package armeria 9 | 10 | import cats.effect._ 11 | import com.linecorp.armeria.common.metric.{MeterIdPrefixFunction, PrometheusMeterRegistries} 12 | import com.linecorp.armeria.server.metric.{MetricCollectingService, PrometheusExpositionService} 13 | import org.http4s.armeria.server.{ArmeriaServer, ArmeriaServerBuilder} 14 | 15 | object ArmeriaExample extends IOApp { 16 | override def run(args: List[String]): IO[ExitCode] = 17 | ArmeriaExampleApp.resource[IO].use(_ => IO.never).as(ExitCode.Success) 18 | } 19 | 20 | object ArmeriaExampleApp { 21 | def builder[F[_]: Async]: ArmeriaServerBuilder[F] = { 22 | val registry = PrometheusMeterRegistries.newRegistry() 23 | val prometheusRegistry = registry.getPrometheusRegistry 24 | ArmeriaServerBuilder[F] 25 | .bindHttp(8080) 26 | .withMeterRegistry(registry) 27 | .withHttpRoutes("/http4s", ExampleService[F].routes()) 28 | .withHttpService("/metrics", PrometheusExpositionService.of(prometheusRegistry)) 29 | .withDecorator( 30 | MetricCollectingService.newDecorator(MeterIdPrefixFunction.ofDefault("server"))) 31 | .withDecoratorUnder("/black-knight", new NoneShallPass(_)) 32 | } 33 | 34 | def resource[F[_]: Async]: Resource[F, ArmeriaServer] = 35 | builder[F].resource 36 | } 37 | -------------------------------------------------------------------------------- /examples/armeria-scalapb/src/main/scala/com/example/scalapb/armeria/HelloServiceImpl.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.scalapb.armeria 18 | 19 | import example.armeria.grpc.hello.ReactorHelloServiceGrpc.ReactorHelloService 20 | import example.armeria.grpc.hello.{HelloReply, HelloRequest} 21 | import reactor.core.scala.publisher.{SFlux, SMono} 22 | 23 | class HelloServiceImpl extends ReactorHelloService { 24 | override def unary(request: HelloRequest): SMono[HelloReply] = 25 | SMono.just(HelloReply(s"Hello ${request.name}!")) 26 | 27 | override def serverStreaming(request: HelloRequest): SFlux[HelloReply] = 28 | SFlux 29 | .range(1, 5) 30 | .map(i => s"Hello ${request.name} $i!") 31 | .map(HelloReply(_)) 32 | 33 | override def clientStreaming(requests: SFlux[HelloRequest]): SMono[HelloReply] = 34 | requests.map(_.name).collectSeq().map(_.mkString(", ")).map(all => HelloReply(s"Hello $all!")) 35 | 36 | override def bidiStreaming(requests: SFlux[HelloRequest]): SFlux[HelloReply] = 37 | requests.map(req => HelloReply(s"Hello ${req.name}!")) 38 | } 39 | -------------------------------------------------------------------------------- /examples/armeria-http4s/src/main/scala/com/exmaple/http4s/armeria/ExampleService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2022 http4s.org 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | package com.exmaple.http4s 8 | package armeria 9 | 10 | import cats.effect._ 11 | import com.linecorp.armeria.common.util.TimeoutMode 12 | import com.linecorp.armeria.server.ServiceRequestContext 13 | import fs2._ 14 | import org.http4s._ 15 | import org.http4s.armeria.server.ServiceRequestContexts 16 | import org.http4s.dsl.Http4sDsl 17 | import scala.concurrent.duration._ 18 | 19 | class ExampleService[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { 20 | 21 | def routes(): HttpRoutes[F] = 22 | HttpRoutes.of[F] { 23 | case GET -> Root / "thread" => 24 | Ok(Thread.currentThread.getName) 25 | 26 | case GET -> Root / "context" / "threadlocal" => 27 | val ctx = ServiceRequestContext.current 28 | Ok(s"context id: ${ctx.id().text()}") 29 | 30 | case req @ GET -> Root / "context" / "attribute" => 31 | req.attributes 32 | .lookup(ServiceRequestContexts.Key) 33 | .fold(Ok("context id: unknown")) { ctx => 34 | Ok(s"context id: ${ctx.id().text()}") 35 | } 36 | 37 | case GET -> Root / "streaming" => 38 | val ctx = ServiceRequestContext.current 39 | ctx.setRequestTimeoutMillis(2.seconds.toMillis) 40 | val stream = 41 | Stream 42 | .fixedDelay(1.second) 43 | .evalMap(_ => 44 | F.delay { 45 | ctx.setRequestTimeoutMillis(TimeoutMode.EXTEND, 2.seconds.toMillis) 46 | "Hello!\n" 47 | }) 48 | .take(10) 49 | Ok(stream) 50 | } 51 | } 52 | 53 | object ExampleService { 54 | def apply[F[_]: Async] = new ExampleService[F] 55 | } 56 | -------------------------------------------------------------------------------- /examples/armeria-fs2grpc/src/main/scala/com/example/fs2grpc/armeria/HelloServiceImpl.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.fs2grpc.armeria 18 | 19 | import cats.effect.IO 20 | import example.armeria.grpc.hello.{HelloReply, HelloRequest, HelloServiceFs2Grpc} 21 | import io.grpc.Metadata 22 | import fs2._ 23 | 24 | class HelloServiceImpl extends HelloServiceFs2Grpc[IO, Metadata] { 25 | 26 | override def unary(request: HelloRequest, ctx: Metadata): IO[HelloReply] = 27 | IO(HelloReply(s"Hello ${request.name}!")) 28 | 29 | override def serverStreaming(request: HelloRequest, ctx: Metadata): Stream[IO, HelloReply] = 30 | Stream 31 | .range(1, 6) 32 | .map(i => s"Hello ${request.name} $i!") 33 | .map(HelloReply(_)) 34 | 35 | override def clientStreaming(request: Stream[IO, HelloRequest], ctx: Metadata): IO[HelloReply] = 36 | request 37 | .map(_.name) 38 | .compile 39 | .toVector 40 | .map(_.mkString(", ")) 41 | .map(all => HelloReply(s"Hello $all!")) 42 | 43 | override def bidiStreaming( 44 | request: Stream[IO, HelloRequest], 45 | ctx: Metadata): Stream[IO, HelloReply] = 46 | request.map(req => HelloReply(s"Hello ${req.name}!")) 47 | } 48 | -------------------------------------------------------------------------------- /examples/armeria-scalapb/src/test/scala/com/example/scalapb/armeria/HelloServiceTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020-2022 http4s.org 3 | * 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | package com.example.scalapb.armeria 8 | 9 | import com.linecorp.armeria.client.Clients 10 | import example.armeria.grpc.hello.ReactorHelloServiceGrpc.ReactorHelloServiceStub 11 | import example.armeria.grpc.hello.{HelloReply, HelloRequest} 12 | import munit.CatsEffectSuite 13 | import reactor.core.scala.publisher.SFlux 14 | 15 | import scala.concurrent.duration.DurationInt 16 | 17 | class HelloServiceTest extends CatsEffectSuite { 18 | private def setUp() = 19 | Main.newServer(0).map { armeriaServer => 20 | val httpPort = armeriaServer.server.activeLocalPort() 21 | 22 | Clients 23 | .builder(s"gproto+http://127.0.0.1:$httpPort/grpc/") 24 | .build(classOf[ReactorHelloServiceStub]) 25 | } 26 | 27 | private val fixture = ResourceSuiteLocalFixture("fixture", setUp()) 28 | 29 | override def munitFixtures = List(fixture) 30 | 31 | val message = "ScalaPB with Reactor" 32 | 33 | test("unary") { 34 | val client = fixture() 35 | val response = client.unary(HelloRequest(message)).block() 36 | assertEquals(response.message, s"Hello $message!") 37 | } 38 | 39 | test("serverStream") { 40 | val client = fixture() 41 | val response = client.serverStreaming(HelloRequest(message)).collectSeq().block() 42 | val expected = (1 to 5).map(i => HelloReply(s"Hello $message $i!")) 43 | assertEquals(response, expected) 44 | } 45 | 46 | test("clientStream") { 47 | val client = fixture() 48 | val response = client 49 | .clientStreaming( 50 | SFlux 51 | .range(1, 5) 52 | .delayElements(100.millis) 53 | .map(i => HelloRequest(i.toString)) 54 | ) 55 | .block() 56 | assertEquals(response.message, "Hello 1, 2, 3, 4, 5!") 57 | } 58 | 59 | test("bidiStream") { 60 | val client = fixture() 61 | val responses = client 62 | .bidiStreaming(SFlux(1, 2, 3).map(i => HelloRequest(i.toString))) 63 | .map(res => res.message) 64 | .collectSeq() 65 | .block() 66 | val expected = (1 to 3).map(i => s"Hello $i!") 67 | assertEquals(responses, expected) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/armeria-fs2grpc/src/main/scala/com/example/fs2grpc/armeria/ExampleService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.fs2grpc.armeria 18 | 19 | import cats.effect._ 20 | import com.linecorp.armeria.common.util.TimeoutMode 21 | import com.linecorp.armeria.server.ServiceRequestContext 22 | import fs2._ 23 | import org.http4s._ 24 | import org.http4s.armeria.server.ServiceRequestContexts 25 | import org.http4s.dsl.Http4sDsl 26 | import scala.concurrent.duration._ 27 | 28 | class ExampleService[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { 29 | 30 | def routes(): HttpRoutes[F] = 31 | HttpRoutes.of[F] { 32 | case GET -> Root / "thread" => 33 | Ok(Thread.currentThread.getName) 34 | 35 | case GET -> Root / "context" / "threadlocal" => 36 | val ctx = ServiceRequestContext.current 37 | Ok(s"context id: ${ctx.id().text()}") 38 | 39 | case req @ GET -> Root / "context" / "attribute" => 40 | req.attributes 41 | .lookup(ServiceRequestContexts.Key) 42 | .fold(Ok("context id: unknown")) { ctx => 43 | Ok(s"context id: ${ctx.id().text()}") 44 | } 45 | 46 | case GET -> Root / "streaming" => 47 | val ctx = ServiceRequestContext.current 48 | ctx.setRequestTimeoutMillis(2.seconds.toMillis) 49 | val stream = 50 | Stream 51 | .fixedDelay(1.second) 52 | .evalMap(_ => 53 | F.delay { 54 | ctx.setRequestTimeoutMillis(TimeoutMode.EXTEND, 2.seconds.toMillis) 55 | "Hello!\n" 56 | }) 57 | .take(10) 58 | Ok(stream) 59 | } 60 | } 61 | 62 | object ExampleService { 63 | def apply[F[_]: Async] = new ExampleService[F] 64 | } 65 | -------------------------------------------------------------------------------- /examples/armeria-scalapb/src/main/scala/com/example/scalapb/armeria/ExampleService.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.scalapb.armeria 18 | 19 | import cats.effect._ 20 | import com.linecorp.armeria.common.util.TimeoutMode 21 | import com.linecorp.armeria.server.ServiceRequestContext 22 | import fs2._ 23 | import org.http4s._ 24 | import org.http4s.armeria.server.ServiceRequestContexts 25 | import org.http4s.dsl.Http4sDsl 26 | import scala.concurrent.duration._ 27 | 28 | class ExampleService[F[_]](implicit F: Async[F]) extends Http4sDsl[F] { 29 | 30 | def routes(): HttpRoutes[F] = 31 | HttpRoutes.of[F] { 32 | case GET -> Root / "thread" => 33 | Ok(Thread.currentThread.getName) 34 | 35 | case GET -> Root / "context" / "threadlocal" => 36 | val ctx = ServiceRequestContext.current 37 | Ok(s"context id: ${ctx.id().text()}") 38 | 39 | case req @ GET -> Root / "context" / "attribute" => 40 | req.attributes 41 | .lookup(ServiceRequestContexts.Key) 42 | .fold(Ok("context id: unknown")) { ctx => 43 | Ok(s"context id: ${ctx.id().text()}") 44 | } 45 | 46 | case GET -> Root / "streaming" => 47 | val ctx = ServiceRequestContext.current 48 | ctx.setRequestTimeoutMillis(2.seconds.toMillis) 49 | val stream = 50 | Stream 51 | .fixedDelay(1.second) 52 | .evalMap(_ => 53 | F.delay { 54 | ctx.setRequestTimeoutMillis(TimeoutMode.EXTEND, 2.seconds.toMillis) 55 | "Hello!\n" 56 | }) 57 | .take(10) 58 | Ok(stream) 59 | } 60 | } 61 | 62 | object ExampleService { 63 | def apply[F[_]: Async] = new ExampleService[F] 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /server/src/test/scala/org/http4s/armeria/server/ServerFixture.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s.armeria.server 18 | 19 | import java.net.URI 20 | 21 | import cats.effect.{IO, Resource} 22 | import com.linecorp.armeria.common.SessionProtocol 23 | import com.linecorp.armeria.server.Server 24 | import munit.{AnyFixture, CatsEffectFunFixtures, CatsEffectSuite} 25 | 26 | import scala.concurrent.duration._ 27 | import scala.util.Try 28 | 29 | /** A fixture that starts and stops an Armeria server automatically before and after executing a 30 | * test or all tests . 31 | */ 32 | trait ServerFixture extends CatsEffectFunFixtures { 33 | this: CatsEffectSuite => 34 | 35 | private var armeriaServerWrapper: ArmeriaServer = _ 36 | private var server: Server = _ 37 | private var releaseToken: IO[Unit] = _ 38 | 39 | /** Configures the [[Server]] with the given [[ArmeriaServerBuilder]]. */ 40 | protected def configureServer(customizer: ArmeriaServerBuilder[IO]): ArmeriaServerBuilder[IO] 41 | 42 | protected def httpPort: Try[Int] = Try(server.activeLocalPort(SessionProtocol.HTTP)) 43 | protected def httpUri: Try[URI] = httpPort.map(port => URI.create(s"http://127.0.0.1:$port")) 44 | 45 | protected def httpsPort: Try[Int] = Try(server.activeLocalPort(SessionProtocol.HTTPS)) 46 | protected def httpsUri: Try[URI] = httpsPort.map(port => URI.create(s"https://127.0.0.1:$port")) 47 | 48 | val armeriaServerFixture: AnyFixture[Unit] = ResourceSuiteLocalFixture( 49 | "armeria-server-fixture", 50 | Resource.make(IO(setUp()))(_ => IO(tearDown())) 51 | ) 52 | 53 | private def setUp(): Unit = { 54 | val serverBuilder = ArmeriaServerBuilder[IO].withGracefulShutdownTimeout(0.seconds, 0.seconds) 55 | val configured = configureServer(serverBuilder) 56 | val allocated = configured.resource.allocated.unsafeRunSync() 57 | armeriaServerWrapper = allocated._1 58 | server = armeriaServerWrapper.server 59 | releaseToken = allocated._2 60 | } 61 | 62 | private def tearDown(): Unit = releaseToken.unsafeRunSync() 63 | } 64 | -------------------------------------------------------------------------------- /server/src/main/scala/org/http4s/armeria/server/SSLContextFactory.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s.armeria.server 18 | 19 | import java.io.ByteArrayInputStream 20 | import java.security.cert.{CertificateFactory, X509Certificate} 21 | 22 | import javax.net.ssl.SSLSession 23 | 24 | import scala.util.Try 25 | 26 | /** Based on SSLContextFactory from jetty. 27 | */ 28 | private[armeria] object SSLContextFactory { 29 | 30 | /** Return X509 certificates for the session. 31 | * 32 | * @param sslSession 33 | * Session from which certificate to be read 34 | * @return 35 | * Empty array if no certificates can be read from {{{sslSession}}} 36 | */ 37 | def getCertChain(sslSession: SSLSession): List[X509Certificate] = 38 | Try { 39 | val cf = CertificateFactory.getInstance("X.509") 40 | sslSession.getPeerCertificates.map { certificate => 41 | val stream = new ByteArrayInputStream(certificate.getEncoded) 42 | cf.generateCertificate(stream).asInstanceOf[X509Certificate] 43 | } 44 | }.toOption.getOrElse(Array.empty[X509Certificate]).toList 45 | 46 | /** Given the name of a TLS/SSL cipher suite, return an int representing it effective stream 47 | * cipher key strength. i.e. How much entropy material is in the key material being fed into the 48 | * encryption routines. 49 | * 50 | * This is based on the information on effective key lengths in RFC 2246 - The TLS Protocol 51 | * Version 1.0, Appendix C. CipherSuite definitions:
 Effective Cipher Type Key Bits
52 |     *
53 |     * NULL * Stream 0 IDEA_CBC Block 128 RC2_CBC_40 * Block 40 RC4_40 * Stream 40 RC4_128 Stream 128
54 |     * DES40_CBC * Block 40 DES_CBC Block 56 3DES_EDE_CBC Block 168 
55 | * 56 | * @param cipherSuite 57 | * String name of the TLS cipher suite. 58 | * @return 59 | * int indicating the effective key entropy bit-length. 60 | */ 61 | def deduceKeyLength(cipherSuite: String): Int = 62 | if (cipherSuite == null) 0 63 | else if (cipherSuite.contains("WITH_AES_256_")) 256 64 | else if (cipherSuite.contains("WITH_RC4_128_")) 128 65 | else if (cipherSuite.contains("WITH_AES_128_")) 128 66 | else if (cipherSuite.contains("WITH_RC4_40_")) 40 67 | else if (cipherSuite.contains("WITH_3DES_EDE_CBC_")) 168 68 | else if (cipherSuite.contains("WITH_IDEA_CBC_")) 128 69 | else if (cipherSuite.contains("WITH_RC2_CBC_40_")) 40 70 | else if (cipherSuite.contains("WITH_DES40_CBC_")) 40 71 | else if (cipherSuite.contains("WITH_DES_CBC_")) 56 72 | else 0 73 | } 74 | -------------------------------------------------------------------------------- /examples/armeria-fs2grpc/src/test/scala/com/example/fs2grpc/armeria/HelloServiceTest.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.fs2grpc.armeria 18 | 19 | import cats.effect.IO 20 | import cats.effect.std.Dispatcher 21 | import com.linecorp.armeria.client.Clients 22 | import com.linecorp.armeria.client.grpc.{GrpcClientOptions, GrpcClientStubFactory} 23 | import example.armeria.grpc.hello.{HelloReply, HelloRequest, HelloServiceFs2Grpc, HelloServiceGrpc} 24 | import io.grpc.{Channel, Metadata, ServiceDescriptor} 25 | import fs2._ 26 | import munit.CatsEffectSuite 27 | 28 | class HelloServiceTest extends CatsEffectSuite { 29 | private def setUp() = for { 30 | dispatcher <- Dispatcher[IO] 31 | armeriaServer <- Main.newServer(dispatcher, 0) 32 | httpPort = armeriaServer.server.activeLocalPort() 33 | } yield Clients 34 | .builder(s"gproto+http://127.0.0.1:$httpPort/grpc/") 35 | .option(GrpcClientOptions.GRPC_CLIENT_STUB_FACTORY.newValue(new GrpcClientStubFactory { 36 | 37 | override def findServiceDescriptor(clientType: Class[_]): ServiceDescriptor = 38 | HelloServiceGrpc.SERVICE 39 | 40 | override def newClientStub(clientType: Class[_], channel: Channel): AnyRef = 41 | HelloServiceFs2Grpc.stub[IO](dispatcher, channel) 42 | 43 | })) 44 | .build(classOf[HelloServiceFs2Grpc[IO, Metadata]]) 45 | 46 | private val fixture = ResourceSuiteLocalFixture("fixture", setUp()) 47 | 48 | override def munitFixtures = List(fixture) 49 | 50 | val message = "ScalaPB with Reactor" 51 | 52 | test("unary") { 53 | val client = fixture() 54 | val response = client.unary(HelloRequest(message), new Metadata()) 55 | assertIO(response.map(_.message), s"Hello $message!") 56 | } 57 | 58 | test("serverStream") { 59 | val client = fixture() 60 | 61 | val response = client 62 | .serverStreaming(HelloRequest(message), new Metadata()) 63 | .compile 64 | .toVector 65 | 66 | val expected = (1 to 5).map(i => HelloReply(s"Hello $message $i!")).toVector 67 | 68 | assertIO(response, expected) 69 | } 70 | 71 | test("clientStream") { 72 | val client = fixture() 73 | 74 | val response = client 75 | .clientStreaming(Stream.range(1, 6).map(i => HelloRequest(i.toString)), new Metadata()) 76 | 77 | assertIO(response.map(_.message), "Hello 1, 2, 3, 4, 5!") 78 | } 79 | 80 | test("bidiStream") { 81 | val client = fixture() 82 | 83 | val responses = client 84 | .bidiStreaming(Stream(1, 2, 3).map(i => HelloRequest(i.toString)), new Metadata()) 85 | .map(res => res.message) 86 | .compile 87 | .toVector 88 | 89 | val expected = (1 to 3).map(i => s"Hello $i!").toVector 90 | 91 | assertIO(responses, expected) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /examples/armeria-scalapb/src/main/scala/com/example/scalapb/armeria/Main.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.scalapb.armeria 18 | 19 | import cats.effect.{ExitCode, IO, IOApp, Resource} 20 | import com.linecorp.armeria.common.scalapb.ScalaPbJsonMarshaller 21 | import com.linecorp.armeria.server.docs.{DocService, DocServiceFilter} 22 | import com.linecorp.armeria.server.grpc.GrpcService 23 | import com.linecorp.armeria.server.logging.LoggingService 24 | import example.armeria.grpc.hello.ReactorHelloServiceGrpc.ReactorHelloService 25 | import example.armeria.grpc.hello.{HelloRequest, HelloServiceGrpc} 26 | import io.grpc.reflection.v1alpha.ServerReflectionGrpc 27 | import org.http4s.armeria.server.{ArmeriaServer, ArmeriaServerBuilder} 28 | import org.slf4j.LoggerFactory 29 | import scala.concurrent.duration.Duration 30 | 31 | object Main extends IOApp { 32 | 33 | val logger = LoggerFactory.getLogger(getClass) 34 | 35 | override def run(args: List[String]): IO[ExitCode] = 36 | newServer(8080) 37 | .use { armeria => 38 | logger.info( 39 | s"Server has been started. Serving DocService at http://127.0.0.1:${armeria.server.activeLocalPort 40 | ()}/docs" 41 | ) 42 | IO.never 43 | } 44 | .as(ExitCode.Success) 45 | 46 | def newServer(httpPort: Int): Resource[IO, ArmeriaServer] = { 47 | // Build gRPC service 48 | val grpcService = GrpcService 49 | .builder() 50 | // TODO(ikhoon): Support fs2-grpc with Armeria gRPC server and client. 51 | .addService(ReactorHelloService.bindService(new HelloServiceImpl)) 52 | // Register ScalaPbJsonMarshaller to support gRPC JSON format 53 | .jsonMarshallerFactory(_ => ScalaPbJsonMarshaller()) 54 | // .enableUnframedRequests(true) 55 | .build() 56 | 57 | val exampleRequest = HelloRequest("Armeria") 58 | val serviceName = HelloServiceGrpc.SERVICE.getName 59 | 60 | ArmeriaServerBuilder[IO] 61 | .bindHttp(httpPort) 62 | .withIdleTimeout(Duration.Zero) 63 | .withRequestTimeout(Duration.Zero) 64 | .withHttpServiceUnder("/grpc", grpcService) 65 | .withHttpRoutes("/rest", ExampleService[IO].routes()) 66 | .withDecorator(LoggingService.newDecorator()) 67 | // Add DocService for browsing the list of gRPC services and 68 | // invoking a service operation from a web form. 69 | // See https://armeria.dev/docs/server-docservice for more information. 70 | .withHttpServiceUnder( 71 | "/docs", 72 | DocService 73 | .builder() 74 | .exampleRequests(serviceName, "Hello", exampleRequest) 75 | .exampleRequests(serviceName, "LazyHello", exampleRequest) 76 | .exampleRequests(serviceName, "BlockingHello", exampleRequest) 77 | .exclude(DocServiceFilter.ofServiceName(ServerReflectionGrpc.SERVICE_NAME)) 78 | .build() 79 | ) 80 | .withGracefulShutdownTimeout(Duration.Zero, Duration.Zero) 81 | .resource 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/armeria-fs2grpc/src/main/scala/com/example/fs2grpc/armeria/Main.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.example.fs2grpc.armeria 18 | 19 | import cats.effect.std.Dispatcher 20 | import cats.effect.{ExitCode, IO, IOApp, Resource} 21 | import com.linecorp.armeria.common.scalapb.ScalaPbJsonMarshaller 22 | import com.linecorp.armeria.server.docs.{DocService, DocServiceFilter} 23 | import com.linecorp.armeria.server.grpc.GrpcService 24 | import com.linecorp.armeria.server.logging.LoggingService 25 | import example.armeria.grpc.hello.{HelloRequest, HelloServiceFs2Grpc, HelloServiceGrpc} 26 | import io.grpc.reflection.v1alpha.ServerReflectionGrpc 27 | import org.http4s.armeria.server.{ArmeriaServer, ArmeriaServerBuilder} 28 | import org.slf4j.LoggerFactory 29 | 30 | import scala.concurrent.duration.Duration 31 | 32 | object Main extends IOApp { 33 | 34 | val logger = LoggerFactory.getLogger(getClass) 35 | 36 | override def run(args: List[String]): IO[ExitCode] = 37 | Dispatcher[IO] 38 | .flatMap(dispatcher => newServer(dispatcher, 8080)) 39 | .use { armeria => 40 | logger.info( 41 | s"Server has been started. Serving DocService at http://127.0.0.1:${armeria.server.activeLocalPort()}/docs" 42 | ) 43 | IO.never 44 | } 45 | .as(ExitCode.Success) 46 | 47 | def newServer(dispatcher: Dispatcher[IO], httpPort: Int): Resource[IO, ArmeriaServer] = { 48 | // Build gRPC service 49 | val grpcService = GrpcService 50 | .builder() 51 | .addService(HelloServiceFs2Grpc.bindService(dispatcher, new HelloServiceImpl)) 52 | // Register ScalaPbJsonMarshaller to support gRPC JSON format 53 | .jsonMarshallerFactory(_ => ScalaPbJsonMarshaller()) 54 | .enableUnframedRequests(true) 55 | .build() 56 | 57 | val exampleRequest = HelloRequest("Armeria") 58 | val serviceName = HelloServiceGrpc.SERVICE.getName 59 | 60 | ArmeriaServerBuilder[IO] 61 | .bindHttp(httpPort) 62 | .withIdleTimeout(Duration.Zero) 63 | .withRequestTimeout(Duration.Zero) 64 | .withMaxRequestLength(0L) 65 | .withHttpServiceUnder("/grpc", grpcService) 66 | .withHttpRoutes("/rest", ExampleService[IO].routes()) 67 | .withDecorator(LoggingService.newDecorator()) 68 | // Add DocService for browsing the list of gRPC services and 69 | // invoking a service operation from a web form. 70 | // See https://armeria.dev/docs/server-docservice for more information. 71 | .withHttpServiceUnder( 72 | "/docs", 73 | DocService 74 | .builder() 75 | .exampleRequests(serviceName, "Hello", exampleRequest) 76 | .exampleRequests(serviceName, "LazyHello", exampleRequest) 77 | .exampleRequests(serviceName, "BlockingHello", exampleRequest) 78 | .exclude(DocServiceFilter.ofServiceName(ServerReflectionGrpc.SERVICE_NAME)) 79 | .build() 80 | ) 81 | .withGracefulShutdownTimeout(Duration.Zero, Duration.Zero) 82 | .resource 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /client/src/main/scala/org/http4s/armeria/client/ArmeriaClient.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s 18 | package armeria 19 | package client 20 | 21 | import cats.effect.Resource 22 | import cats.implicits._ 23 | import com.linecorp.armeria.client.WebClient 24 | import com.linecorp.armeria.common.{ 25 | HttpData, 26 | HttpHeaders, 27 | HttpMethod, 28 | HttpRequest, 29 | HttpResponse, 30 | RequestHeaders 31 | } 32 | import fs2.interop.reactivestreams._ 33 | import fs2.{Chunk, Stream} 34 | import cats.effect.kernel.{Async, MonadCancel} 35 | import cats.effect.syntax.all._ 36 | import org.http4s.client.Client 37 | import org.http4s.internal.CollectionCompat.CollectionConverters._ 38 | import org.typelevel.ci.CIString 39 | 40 | private[armeria] final class ArmeriaClient[F[_]] private[client] ( 41 | private val client: WebClient 42 | )(implicit val B: MonadCancel[F, Throwable], F: Async[F]) { 43 | 44 | def run(req: Request[F]): Resource[F, Response[F]] = 45 | toHttpRequest(req).map(client.execute).flatMap(r => Resource.eval(toResponse(r))) 46 | 47 | /** Converts http4s' [[Request]] to http4s' [[com.linecorp.armeria.common.HttpRequest]]. */ 48 | private def toHttpRequest(req: Request[F]): Resource[F, HttpRequest] = { 49 | val requestHeaders = toRequestHeaders(req) 50 | 51 | if (req.body == EmptyBody) 52 | Resource.pure(HttpRequest.of(requestHeaders)) 53 | else { 54 | if (req.contentLength.isDefined) { 55 | // A non stream response. ExchangeType.RESPONSE_STREAMING will be inferred. 56 | val request: F[HttpRequest] = 57 | req.body.chunks.compile 58 | .to(Array) 59 | .map { array => 60 | array.map { chunk => 61 | val bytes = chunk.toArraySlice 62 | HttpData.wrap(bytes.values, bytes.offset, bytes.length) 63 | } 64 | } 65 | .map(data => HttpRequest.of(requestHeaders, data: _*)) 66 | Resource.eval(request) 67 | } else { 68 | req.body.chunks 69 | .map { chunk => 70 | val bytes = chunk.toArraySlice 71 | HttpData.copyOf(bytes.values, bytes.offset, bytes.length) 72 | } 73 | .toUnicastPublisher 74 | .map { body => 75 | HttpRequest.of(requestHeaders, body) 76 | } 77 | } 78 | } 79 | } 80 | 81 | /** Converts http4s' [[Request]] to http4s' [[com.linecorp.armeria.common.ResponseHeaders]]. */ 82 | private def toRequestHeaders(req: Request[F]): RequestHeaders = { 83 | val builder = RequestHeaders.builder(HttpMethod.valueOf(req.method.name), req.uri.renderString) 84 | req.headers.foreach { header => 85 | val _ = builder.add(header.name.toString, header.value) 86 | } 87 | builder.build() 88 | } 89 | 90 | /** Converts Armeria's [[com.linecorp.armeria.common.HttpResponse]] to http4s' [[Response]]. */ 91 | private def toResponse(response: HttpResponse): F[Response[F]] = { 92 | val splitResponse = response.split() 93 | for { 94 | headers <- F 95 | .fromCompletableFuture(F.delay(splitResponse.headers)) 96 | .cancelable(F.delay(response.abort())) 97 | status <- F.fromEither(Status.fromInt(headers.status().code())) 98 | body = 99 | splitResponse 100 | .body() 101 | .toStreamBuffered[F](1) 102 | .flatMap(x => Stream.chunk(Chunk.array(x.array()))) 103 | } yield Response(status = status, headers = toHeaders(headers), body = body) 104 | } 105 | 106 | /** Converts Armeria's [[com.linecorp.armeria.common.HttpHeaders]] to http4s' [[Headers]]. */ 107 | private def toHeaders(req: HttpHeaders): Headers = 108 | Headers( 109 | req.asScala 110 | .map(entry => Header.Raw(CIString(entry.getKey.toString()), entry.getValue)) 111 | .toList 112 | ) 113 | } 114 | 115 | object ArmeriaClient { 116 | def apply[F[_]](client: WebClient = WebClient.of())(implicit F: Async[F]): Client[F] = 117 | Client(new ArmeriaClient(client).run) 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http4s-armeria 2 | 3 | [![Maven Central](https://img.shields.io/maven-central/v/org.http4s/http4s-armeria-server_2.13?versionPrefix=0.)](https://img.shields.io/maven-central/v/org.http4s/http4s-armeria-server_2.13?versionPrefix=0.) 4 | ![Build Status](https://github.com/http4s/http4s-armeria/actions/workflows/ci.yml/badge.svg?branch=main) 5 | 6 | 7 | [Http4s] server and client on [Armeria] 8 | 9 | ## Highlights 10 | 11 | - You can run Http4s services on top of Armeria's asynchronous and reactive server. 12 | - You can maximize your service and client with Armeria's [awesome features](https://armeria.dev/docs#features) 13 | such as: 14 | - gRPC server and client 15 | - Circuit Breaker 16 | - Automatic retry 17 | - Dynamic service discovery 18 | - Distributed tracing 19 | - [and](https://armeria.dev/docs/server-docservice) [so](https://armeria.dev/docs/server-thrift) [on](https://armeria.dev/docs/advanced-metrics) 20 | 21 | ## Current status 22 | 23 | Two series are currently under active development: the `0.x` and `1.0-x` release milestone series. 24 | The first depends on the `http4s-core`'s `0.23` series and belongs to the [main branch]. 25 | The latter is for the cutting-edge `http4s-core`'s `1.0-x` release milestone series and belongs to the [series/1.x branch]. 26 | 27 | ## Installation 28 | 29 | Add the following dependencies to `build.sbt` 30 | ```sbt 31 | // For server 32 | libraryDependencies += "org.http4s" %% "http4s-armeria-server" % "" 33 | // For client 34 | libraryDependencies += "org.http4s" %% "http4s-armeria-client" % "" 35 | ``` 36 | 37 | ## Quick start 38 | 39 | ### http4s integration 40 | 41 | #### Run your http4s service with [Armeria server](https://armeria.dev/docs/server-basics) 42 | 43 | You can bind your http4s service using `ArmeriaServerBuilder[F].withHttpRoutes()`. 44 | 45 | ```scala 46 | import cats.effect._ 47 | import com.linecorp.armeria.common.metric.{MeterIdPrefixFunction, PrometheusMeterRegistries} 48 | import com.linecorp.armeria.server.metric.{MetricCollectingService, PrometheusExpositionService} 49 | import org.http4s.armeria.server.{ArmeriaServer, ArmeriaServerBuilder} 50 | 51 | object ArmeriaExample extends IOApp { 52 | override def run(args: List[String]): IO[ExitCode] = 53 | ArmeriaExampleApp.resource[IO].use(_ => IO.never).as(ExitCode.Success) 54 | } 55 | 56 | object ArmeriaExampleApp { 57 | def builder[F[_]: ConcurrentEffect: ContextShift: Timer]: ArmeriaServerBuilder[F] = { 58 | val registry = PrometheusMeterRegistries.newRegistry() 59 | val prometheusRegistry = registry.getPrometheusRegistry 60 | ArmeriaServerBuilder[F] 61 | .bindHttp(8080) 62 | // Sets your own meter registry 63 | .withMeterRegistry(registry) 64 | // Binds HttpRoutes to Armeria server 65 | .withHttpRoutes("/http4s", ExampleService[F].routes()) 66 | // Adds PrometheusExpositionService provided by Armeria for exposing Prometheus metrics 67 | .withHttpService("/metrics", PrometheusExpositionService.of(prometheusRegistry)) 68 | // Decorates your services with MetricCollectingService for collecting metrics 69 | .withDecorator( 70 | MetricCollectingService.newDecorator(MeterIdPrefixFunction.ofDefault("server"))) 71 | } 72 | 73 | def resource[F[_]: ConcurrentEffect: ContextShift: Timer]: Resource[F, ArmeriaServer[F]] = 74 | builder[F].resource 75 | } 76 | ``` 77 | 78 | #### Call your service with http4s-armeria client 79 | 80 | You can create http4s client using `ArmeriaClientBuilder`. 81 | 82 | ```scala 83 | import com.linecorp.armeria.client.circuitbreaker._ 84 | import com.linecopr.armeria.client.logging._ 85 | import com.linecopr.armeria.client.retry._ 86 | import org.http4s.armeria.client.ArmeriaClientBuilder 87 | 88 | val client: Client[IO] = 89 | ArmeriaClientBuilder 90 | .unsafe[IO](s"http://127.0.0.1:${server.activeLocalPort()}") 91 | // Automically retry on unprocessed requests 92 | .withDecorator(RetryingClient.newDecorator(RetryRule.onUnprocessed())) 93 | // Open circuit on 5xx server error status 94 | .withDecorator(CircuitBreakerClient.newDecorator(CircuitBreaker.ofDefaultName(), 95 | CircuitBreakerRule.onServerErrorStatus())) 96 | // Log requests and responses 97 | .withDecorator(LoggingClient.newDecorator()) 98 | .withResponseTimeout(10.seconds) 99 | .build() 100 | 101 | val response = client.expect[String]("Armeria").unsafeRunSync() 102 | ``` 103 | 104 | ### fs2-grpc integration 105 | 106 | #### Run your [fs2-grpc](https://github.com/fiadliel/fs2-grpc) service with Armeria [gRPC server](https://armeria.dev/docs/server-grpc) 107 | 108 | Add the following dependencies to `build.sbt`. 109 | 110 | ```sbt 111 | libraryDependencies += Seq( 112 | "com.linecorp.armeria" % "armeria-grpc" % "1.5.0", 113 | "com.linecorp.armeria" %% "armeria-scalapb" % "1.5.0") 114 | ``` 115 | 116 | Add your fs2-grpc service to `GrpcService`. 117 | 118 | ```scala 119 | import com.linecorp.armeria.server.grpc.GrpcService 120 | import com.linecorp.armeria.common.scalapb.ScalaPbJsonMarshaller 121 | 122 | // Build gRPC service 123 | val grpcService = GrpcService 124 | .builder() 125 | .addService(HelloServiceFs2Grpc.bindService(new HelloServiceImpl)) 126 | // Register `ScalaPbJsonMarshaller` to support gRPC JSON format 127 | .jsonMarshallerFactory(_ => ScalaPbJsonMarshaller()) 128 | .enableUnframedRequests(true) 129 | .build() 130 | ``` 131 | 132 | You can run http4s service and gRPC service together with sharing a single HTTP port. 133 | 134 | ```scala 135 | ArmeriaServerBuilder[IO] 136 | .bindHttp(httpPort) 137 | .withHttpServiceUnder("/grpc", grpcService) 138 | .withHttpRoutes("/rest", ExampleService[IO].routes()) 139 | .resource 140 | ``` 141 | 142 | #### Call your gRPC service using fs2-grpc with Armeria [gRPC client](https://armeria.dev/docs/client-grpc) 143 | 144 | ```scala 145 | import com.linecorp.armeria.client.Clients 146 | import com.linecorp.armeria.client.grpc.{GrpcClientOptions, GrpcClientStubFactory} 147 | 148 | val client: HelloServiceFs2Grpc[IO, Metadata] = 149 | Clients 150 | .builder(s"gproto+http://127.0.0.1:$httpPort/grpc/") 151 | // Add a circuit breaker for your gRPC client 152 | .decorator(CircuitBreakerClient.newDecorator(CircuitBreaker.ofDefaultName(), 153 | CircuitBreakerRule.onServerErrorStatus())) 154 | .option(GrpcClientOptions.GRPC_CLIENT_STUB_FACTORY.newValue(new GrpcClientStubFactory { 155 | // Specify `ServiceDescriptor` of your generated gRPC stub 156 | override def findServiceDescriptor(clientType: Class[_]): ServiceDescriptor = 157 | HelloServiceGrpc.SERVICE 158 | 159 | // Returns a newly created gRPC client stub from the given `Channel` 160 | override def newClientStub(clientType: Class[_], channel: Channel): AnyRef = 161 | HelloServiceFs2Grpc.stub[IO](channel) 162 | 163 | })) 164 | .build(classOf[HelloServiceFs2Grpc[IO, Metadata]]) 165 | ``` 166 | 167 | Visit [examples](./examples) to find a fully working example. 168 | 169 | [http4s]: https://http4s.org/ 170 | [armeria]: https://armeria.dev/ 171 | [main branch]: https://github.com/http4s/http4s-armeria/tree/main 172 | [series/1.x branch]: https://github.com/http4s/http4s-armeria/tree/series/1.x 173 | -------------------------------------------------------------------------------- /server/src/test/scala/org/http4s/armeria/server/ArmeriaServerBuilderSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s.armeria.server 18 | 19 | import java.net.{HttpURLConnection, URL} 20 | import java.nio.charset.StandardCharsets 21 | 22 | import cats.effect.{Deferred, IO} 23 | import cats.implicits._ 24 | import com.linecorp.armeria.client.logging.LoggingClient 25 | import com.linecorp.armeria.client.{ClientFactory, WebClient} 26 | import com.linecorp.armeria.common.{HttpData, HttpStatus} 27 | import com.linecorp.armeria.server.logging.LoggingService 28 | import fs2._ 29 | import munit.CatsEffectSuite 30 | import org.http4s.dsl.io._ 31 | import org.http4s.multipart.Multipart 32 | import org.http4s.{Header, Headers, HttpRoutes} 33 | import org.reactivestreams.{Subscriber, Subscription} 34 | import org.typelevel.ci.CIString 35 | 36 | import scala.collection.mutable 37 | import scala.concurrent.duration._ 38 | import scala.io.Source 39 | import scala.util.Properties 40 | 41 | class ArmeriaServerBuilderSuite extends CatsEffectSuite with ServerFixture { 42 | override def munitFixtures = List(armeriaServerFixture) 43 | 44 | private val service: HttpRoutes[IO] = HttpRoutes.of { 45 | case GET -> Root / "thread" / "routing" => 46 | val thread = Thread.currentThread.getName 47 | Ok(thread) 48 | 49 | case GET -> Root / "thread" / "effect" => 50 | IO(Thread.currentThread.getName).flatMap(Ok(_)) 51 | 52 | case req @ POST -> Root / "echo" => 53 | req.decode[String] { r => 54 | Ok(r) 55 | } 56 | 57 | case GET -> Root / "trailers" => 58 | Ok("Hello").map(response => 59 | response.withTrailerHeaders(IO(Headers(Header.Raw(CIString("my-trailers"), "foo"))))) 60 | 61 | case _ -> Root / "never" => 62 | IO.never 63 | 64 | case GET -> Root / "stream" => 65 | Ok(Stream.range(1, 10).map(_.toString).covary[IO]) 66 | 67 | case req @ POST -> Root / "issue2610" => 68 | req.decode[Multipart[IO]] { mp => 69 | Ok(mp.parts.foldMap(_.body)) 70 | } 71 | 72 | case _ => NotFound() 73 | } 74 | 75 | protected def configureServer(serverBuilder: ArmeriaServerBuilder[IO]): ArmeriaServerBuilder[IO] = 76 | serverBuilder 77 | .withDecorator(LoggingService.newDecorator()) 78 | .bindAny() 79 | .withRequestTimeout(10.seconds) 80 | .withGracefulShutdownTimeout(0.seconds, 0.seconds) 81 | .withMaxRequestLength(1024 * 1024) 82 | .withHttpRoutes("/service", service) 83 | 84 | lazy val client: WebClient = WebClient 85 | .builder(s"http://127.0.0.1:${httpPort.get}") 86 | .decorator(LoggingClient.newDecorator()) 87 | .build() 88 | 89 | // This functionality is desirable, but it's not clear how to achieve it under cats-effect 3 90 | test("route requests on the service executor".ignore) { 91 | // A event loop will serve the service to reduce an extra context switching 92 | assert( 93 | client 94 | .get("/service/thread/routing") 95 | .aggregate() 96 | .join() 97 | .contentUtf8() 98 | .startsWith("armeria-common-worker")) 99 | } 100 | 101 | // This functionality is desirable, but it's not clear how to achieve it under cats-effect 3 102 | test("execute the service task on the service executor".ignore) { 103 | // A event loop will serve the service to reduce an extra context switching 104 | assert( 105 | client 106 | .get("/service/thread/effect") 107 | .aggregate() 108 | .join() 109 | .contentUtf8() 110 | .startsWith("armeria-common-worker")) 111 | } 112 | 113 | test("be able to echo its input") { 114 | val input = """{ "Hello": "world" }""" 115 | assert( 116 | client 117 | .post("/service/echo", input) 118 | .aggregate() 119 | .join() 120 | .contentUtf8() 121 | .startsWith(input)) 122 | } 123 | 124 | test("be able to send trailers") { 125 | val response = client.get("/service/trailers").aggregate().join() 126 | assertEquals(response.status(), HttpStatus.OK) 127 | assertEquals(response.trailers().get("my-trailers"), "foo") 128 | } 129 | 130 | test("return a 503 if the server doesn't respond") { 131 | val noTimeoutClient = WebClient 132 | .builder(s"http://127.0.0.1:${httpPort.get}") 133 | .factory(ClientFactory.builder().idleTimeoutMillis(0).build()) 134 | .responseTimeoutMillis(0) 135 | .decorator(LoggingClient.newDecorator()) 136 | .build() 137 | 138 | assertEquals( 139 | noTimeoutClient.get("/service/never").aggregate().join().status(), 140 | HttpStatus.SERVICE_UNAVAILABLE) 141 | } 142 | 143 | test("reliably handle multipart requests") { 144 | assume(!Properties.isWin, "Does not work on windows, possibly encoding related?") 145 | val body = 146 | """|--aa 147 | |Content-Disposition: form-data; name="a" 148 | |Content-Length: 1 149 | | 150 | |a 151 | |--aa--""".stripMargin.replace("\n", "\r\n") 152 | 153 | assertEquals(postChunkedMultipart("/service/issue2610", "aa", body), "a") 154 | } 155 | 156 | test("reliably handle entity length limiting") { 157 | val input = List.fill(1024 * 1024 + 1)("F").mkString 158 | 159 | val statusIO = IO( 160 | postLargeBody("/service/echo", input) 161 | ) 162 | 163 | assertIO(statusIO, HttpStatus.REQUEST_ENTITY_TOO_LARGE.code()) 164 | } 165 | 166 | test("stream") { 167 | val response = client.get("/service/stream") 168 | val deferred = Deferred.unsafe[IO, Boolean] 169 | val buffer = mutable.Buffer[String]() 170 | response 171 | .split() 172 | .body() 173 | .subscribe(new Subscriber[HttpData] { 174 | override def onSubscribe(s: Subscription): Unit = s.request(Long.MaxValue) 175 | 176 | override def onNext(t: HttpData): Unit = 177 | buffer += t.toStringUtf8 178 | 179 | override def onError(t: Throwable): Unit = {} 180 | 181 | override def onComplete(): Unit = 182 | deferred.complete(true).void.unsafeRunSync() 183 | }) 184 | 185 | for { 186 | _ <- deferred.get 187 | _ <- assertIO(IO(buffer.mkString("")), "123456789") 188 | } yield () 189 | } 190 | 191 | private def postLargeBody(path: String, body: String): Int = { 192 | val url = new URL(s"http://127.0.0.1:${httpPort.get}$path") 193 | val conn = url.openConnection().asInstanceOf[HttpURLConnection] 194 | val bytes = body.getBytes(StandardCharsets.UTF_8) 195 | conn.setRequestMethod("POST") 196 | conn.setRequestProperty("Content-Type", "text/html; charset=utf-8") 197 | conn.setDoOutput(true) 198 | conn.getOutputStream.write(bytes) 199 | val code = conn.getResponseCode 200 | conn.disconnect() 201 | code 202 | } 203 | 204 | private def postChunkedMultipart(path: String, boundary: String, body: String): String = { 205 | val url = new URL(s"http://127.0.0.1:${httpPort.get}$path") 206 | val conn = url.openConnection().asInstanceOf[HttpURLConnection] 207 | val bytes = body.getBytes(StandardCharsets.UTF_8) 208 | conn.setRequestMethod("POST") 209 | conn.setChunkedStreamingMode(-1) 210 | conn.setRequestProperty("Content-Type", s"""multipart/form-data; boundary="$boundary"""") 211 | conn.setDoOutput(true) 212 | conn.getOutputStream.write(bytes) 213 | Source.fromInputStream(conn.getInputStream, StandardCharsets.UTF_8.name).getLines().mkString 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /client/src/main/scala/org/http4s/armeria/client/ArmeriaClientBuilder.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s 18 | package armeria 19 | package client 20 | 21 | import cats.effect.{Async, Resource} 22 | import com.linecorp.armeria.client.{ 23 | ClientFactory, 24 | ClientOptionValue, 25 | ClientOptions, 26 | ClientRequestContext, 27 | HttpClient, 28 | WebClient, 29 | WebClientBuilder 30 | } 31 | import com.linecorp.armeria.common.{HttpRequest, HttpResponse} 32 | import java.net.URI 33 | import java.util.function.{Function => JFunction} 34 | 35 | import org.http4s.client.Client 36 | import org.http4s.internal.BackendBuilder 37 | 38 | import scala.concurrent.duration.FiniteDuration 39 | 40 | sealed class ArmeriaClientBuilder[F[_]] private (clientBuilder: WebClientBuilder)(implicit 41 | protected val F: Async[F]) 42 | extends BackendBuilder[F, Client[F]] { 43 | 44 | type DecoratingFunction = (HttpClient, ClientRequestContext, HttpRequest) => HttpResponse 45 | 46 | /** Configures the Armeria client using the specified 47 | * [[com.linecorp.armeria.client.WebClientBuilder]]. 48 | */ 49 | def withArmeriaBuilder(customizer: WebClientBuilder => Unit): ArmeriaClientBuilder[F] = { 50 | customizer(clientBuilder) 51 | this 52 | } 53 | 54 | /** Sets the [[com.linecorp.armeria.client.ClientFactory]] used for creating a client. The default 55 | * is `com.linecorp.armeria.client.ClientFactory.ofDefault()`. 56 | */ 57 | def withClientFactory(clientFactory: ClientFactory): ArmeriaClientBuilder[F] = { 58 | clientBuilder.factory(clientFactory) 59 | this 60 | } 61 | 62 | /** Sets the timeout of a response. 63 | * 64 | * @param responseTimeout 65 | * the timeout. `scala.concurrent.duration.Duration.Zero` disables the timeout. 66 | */ 67 | def withResponseTimeout(responseTimeout: FiniteDuration): ArmeriaClientBuilder[F] = { 68 | clientBuilder.responseTimeoutMillis(responseTimeout.toMillis) 69 | this 70 | } 71 | 72 | /** Sets the timeout of a socket write attempt. 73 | * 74 | * @param writeTimeout 75 | * the timeout. `scala.concurrent.duration.Duration.Zero` disables the timeout. 76 | */ 77 | def withWriteTimeout(writeTimeout: FiniteDuration): ArmeriaClientBuilder[F] = { 78 | clientBuilder.writeTimeoutMillis(writeTimeout.toMillis) 79 | this 80 | } 81 | 82 | /** Sets the maximum allowed length of a server response in bytes. 83 | * 84 | * @param maxResponseLength 85 | * the maximum length in bytes. `0` disables the limit. 86 | */ 87 | def withMaxResponseLength(maxResponseLength: Long): ArmeriaClientBuilder[F] = { 88 | clientBuilder.maxResponseLength(maxResponseLength) 89 | this 90 | } 91 | 92 | /** Adds the specified [[com.linecorp.armeria.client.ClientOptionValue]]. */ 93 | def withClientOption[A](option: ClientOptionValue[A]): ArmeriaClientBuilder[F] = { 94 | clientBuilder.option(option) 95 | this 96 | } 97 | 98 | /** Adds the specified [[com.linecorp.armeria.client.ClientOptions]]. */ 99 | def withClientOptions[A](options: ClientOptions): ArmeriaClientBuilder[F] = { 100 | clientBuilder.options(options) 101 | this 102 | } 103 | 104 | /** Adds the specified [[com.linecorp.armeria.client.ClientOptionValue]]s. */ 105 | def withClientOptions[A](options: ClientOptionValue[_]*): ArmeriaClientBuilder[F] = { 106 | options.foreach(withClientOption(_)) 107 | this 108 | } 109 | 110 | /** Adds the specified `decorator`. 111 | * 112 | * @param decorator 113 | * the [[DecoratingFunction]] that transforms an [[com.linecorp.armeria.client.HttpClient]] to 114 | * another. 115 | */ 116 | def withDecorator(decorator: DecoratingFunction): ArmeriaClientBuilder[F] = { 117 | clientBuilder.decorator(decorator(_, _, _)) 118 | this 119 | } 120 | 121 | /** Adds the specified `decorator`. 122 | * 123 | * @param decorator 124 | * the `java.util.function.Function` that transforms an 125 | * [[com.linecorp.armeria.client.HttpClient]] to another. 126 | */ 127 | def withDecorator( 128 | decorator: JFunction[_ >: HttpClient, _ <: HttpClient]): ArmeriaClientBuilder[F] = { 129 | clientBuilder.decorator(decorator) 130 | this 131 | } 132 | 133 | /** Returns a newly-created http4s [[org.http4s.client.Client]] based on Armeria 134 | * [[com.linecorp.armeria.client.WebClient]]. 135 | */ 136 | def build(): Client[F] = ArmeriaClient(clientBuilder.build()) 137 | 138 | override def resource: Resource[F, Client[F]] = 139 | Resource.pure(build()) 140 | } 141 | 142 | /** A builder class that builds http4s [[org.http4s.client.Client]] based on Armeria 143 | * [[com.linecorp.armeria.client.WebClient]]. 144 | */ 145 | object ArmeriaClientBuilder { 146 | 147 | /** Returns a new [[ArmeriaClientBuilder]]. */ 148 | def apply[F[_]](clientBuilder: WebClientBuilder = WebClient.builder())(implicit 149 | F: Async[F]): ArmeriaClientBuilder[F] = 150 | new ArmeriaClientBuilder(clientBuilder) 151 | 152 | /** Returns a new [[ArmeriaClientBuilder]] created with the specified base [[java.net.URI]]. 153 | * 154 | * @return 155 | * `Left(IllegalArgumentException)` if the `uri` is not valid or its scheme is not one of the 156 | * values values in `com.linecorp.armeria.common.SessionProtocol.httpValues()` or 157 | * `com.linecorp.armeria.common.SessionProtocol.httpsValues()`, else 158 | * `Right(ArmeriaClientBuilder)`. 159 | */ 160 | def apply[F[_]](uri: String)(implicit 161 | F: Async[F]): Either[IllegalArgumentException, ArmeriaClientBuilder[F]] = 162 | try Right(unsafe(uri)) 163 | catch { 164 | case ex: IllegalArgumentException => Left(ex) 165 | } 166 | 167 | /** Returns a new [[ArmeriaClientBuilder]] created with the specified base [[java.net.URI]]. 168 | * 169 | * @throws scala.IllegalArgumentException 170 | * if the `uri` is not valid or its scheme is not one of the values in 171 | * `com.linecorp.armeria.common.SessionProtocol.httpValues()` or 172 | * `com.linecorp.armeria.common.SessionProtocol.httpsValues()`. 173 | */ 174 | def unsafe[F[_]](uri: String)(implicit F: Async[F]): ArmeriaClientBuilder[F] = 175 | apply(WebClient.builder(uri)) 176 | 177 | /** Returns a new [[ArmeriaClientBuilder]] created with the specified base [[java.net.URI]]. 178 | * 179 | * @return 180 | * `Left(IllegalArgumentException)` if the [[java.net.URI]] is not valid or its scheme is not 181 | * one of the values in `com.linecorp.armeria.common.SessionProtocol.httpValues()` or 182 | * `com.linecorp.armeria.common.SessionProtocol.httpsValues()`, else 183 | * `Right(ArmeriaClientBuilder)`. 184 | */ 185 | def apply[F[_]](uri: URI)(implicit 186 | F: Async[F]): Either[IllegalArgumentException, ArmeriaClientBuilder[F]] = 187 | try Right(apply(WebClient.builder(uri))) 188 | catch { 189 | case ex: IllegalArgumentException => Left(ex) 190 | } 191 | 192 | /** Returns a new [[ArmeriaClientBuilder]] created with the specified base [[java.net.URI]]. 193 | * 194 | * @throws scala.IllegalArgumentException 195 | * if the [[java.net.URI]] is not valid or its scheme is not one of the values values in 196 | * `com.linecorp.armeria.common.SessionProtocol.httpValues()` or 197 | * `com.linecorp.armeria.common.SessionProtocol.httpsValues()`. 198 | */ 199 | def unsafe[F[_]](uri: URI)(implicit F: Async[F]): ArmeriaClientBuilder[F] = 200 | apply(WebClient.builder(uri)) 201 | } 202 | -------------------------------------------------------------------------------- /client/src/test/scala/org/http4s/armeria/client/ArmeriaClientSuite.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s 18 | package armeria 19 | package client 20 | 21 | import cats.effect.{IO, Resource} 22 | import com.linecorp.armeria.client.ClientRequestContext 23 | import com.linecorp.armeria.client.logging.{ContentPreviewingClient, LoggingClient} 24 | import com.linecorp.armeria.common.{ 25 | ExchangeType, 26 | HttpData, 27 | HttpRequest, 28 | HttpResponse, 29 | HttpStatus, 30 | ResponseHeaders 31 | } 32 | import com.linecorp.armeria.server.logging.{ContentPreviewingService, LoggingService} 33 | import com.linecorp.armeria.server.{HttpService, Server, ServiceRequestContext} 34 | import fs2._ 35 | import fs2.interop.reactivestreams._ 36 | import java.util.concurrent.{BlockingQueue, LinkedBlockingQueue} 37 | import munit.CatsEffectSuite 38 | import org.http4s.syntax.all._ 39 | import org.http4s.client.Client 40 | import org.slf4j.{Logger, LoggerFactory} 41 | import scala.concurrent.duration._ 42 | 43 | class ArmeriaClientSuite extends CatsEffectSuite { 44 | private val logger: Logger = LoggerFactory.getLogger(getClass) 45 | 46 | private val clientContexts: BlockingQueue[ClientRequestContext] = new LinkedBlockingQueue 47 | 48 | private def setUp(): IO[(Server, Client[IO])] = { 49 | val server = Server 50 | .builder() 51 | .decorator(ContentPreviewingService.newDecorator(Int.MaxValue)) 52 | .decorator(LoggingService.newDecorator()) 53 | .service( 54 | "/{name}", 55 | new HttpService { 56 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = { 57 | logger.info(s"req = $req") 58 | HttpResponse.of(s"Hello, ${ctx.pathParam("name")}!") 59 | } 60 | } 61 | ) 62 | .service( 63 | "/post", 64 | new HttpService { 65 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = 66 | HttpResponse.of( 67 | req 68 | .aggregate() 69 | .thenApply[HttpResponse](agg => HttpResponse.of(s"Hello, ${agg.contentUtf8()}!"))) 70 | } 71 | ) 72 | .service( 73 | "/delayed", 74 | new HttpService { 75 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = { 76 | val response = HttpResponse.of( 77 | req 78 | .aggregate() 79 | .thenApply[HttpResponse](agg => HttpResponse.of(s"Hello, ${agg.contentUtf8()}!"))) 80 | HttpResponse.delayed(response, java.time.Duration.ofSeconds(1)) 81 | } 82 | } 83 | ) 84 | .service( 85 | "/client-streaming", 86 | new HttpService { 87 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = { 88 | val body: IO[Option[String]] = req 89 | .toStreamBuffered[IO](1) 90 | .collect { case data: HttpData => data.toStringUtf8 } 91 | .reduce(_ + " " + _) 92 | .compile 93 | .last 94 | 95 | val writer = HttpResponse.streaming() 96 | body.unsafeRunAsync { 97 | case Left(ex) => 98 | writer.close(ex) 99 | case Right(value) => 100 | writer.write(ResponseHeaders.of(HttpStatus.OK)) 101 | writer.write(HttpData.ofUtf8(value.getOrElse("none"))) 102 | writer.close() 103 | } 104 | writer 105 | } 106 | } 107 | ) 108 | .service( 109 | "/bidi-streaming", 110 | new HttpService { 111 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = { 112 | val writer = HttpResponse.streaming() 113 | writer.write(ResponseHeaders.of(HttpStatus.OK)) 114 | req 115 | .toStreamBuffered[IO](1) 116 | .collect { case data: HttpData => 117 | writer.write(HttpData.ofUtf8(s"${data.toStringUtf8}!")) 118 | } 119 | .onFinalize(IO(writer.close())) 120 | .compile 121 | .drain 122 | .unsafeRunAsync { 123 | case Right(_) => writer.close() 124 | case Left(ex) => writer.close(ex) 125 | } 126 | writer 127 | } 128 | } 129 | ) 130 | .build() 131 | 132 | IO(server.start().join()) *> IO { 133 | val client = ArmeriaClientBuilder 134 | .unsafe[IO](s"http://127.0.0.1:${server.activeLocalPort()}") 135 | .withDecorator(ContentPreviewingClient.newDecorator(Int.MaxValue)) 136 | .withDecorator(LoggingClient.newDecorator()) 137 | .withDecorator { (delegate, ctx, req) => 138 | clientContexts.offer(ctx) 139 | delegate.execute(ctx, req) 140 | } 141 | .withResponseTimeout(10.seconds) 142 | .build() 143 | 144 | server -> client 145 | } 146 | } 147 | 148 | override def afterEach(context: AfterEach): Unit = 149 | clientContexts.clear() 150 | 151 | private val fixture = 152 | ResourceSuiteLocalFixture("fixture", Resource.make(setUp())(x => IO(x._1.stop()).void)) 153 | 154 | override def munitFixtures = List(fixture) 155 | 156 | test("get") { 157 | val (_, client) = fixture() 158 | val response = client.expect[String]("Armeria").unsafeRunSync() 159 | assertEquals(response, "Hello, Armeria!") 160 | } 161 | 162 | test("absolute-uri") { 163 | val (server, _) = fixture() 164 | val clientWithoutBaseUri = ArmeriaClientBuilder[IO]().resource.allocated.unsafeRunSync()._1 165 | val uri = s"http://127.0.0.1:${server.activeLocalPort()}/Armeria" 166 | val response = clientWithoutBaseUri.expect[String](uri).unsafeRunSync() 167 | assertEquals(response, "Hello, Armeria!") 168 | } 169 | 170 | test("post") { 171 | val (_, client) = fixture() 172 | val body = Stream.emits("Armeria".getBytes).covary[IO] 173 | val req = Request(method = Method.POST, uri = uri"/post", body = body) 174 | val response = client.expect[String](req).unsafeRunSync() 175 | assertEquals(response, "Hello, Armeria!") 176 | } 177 | 178 | test("ExchangeType - disable request-streaming") { 179 | val (_, client) = fixture() 180 | val req = Request[IO](method = Method.POST, uri = uri"/post").withEntity("Armeria") 181 | val response = client.expect[String](req).unsafeRunSync() 182 | assertEquals(response, "Hello, Armeria!") 183 | val exchangeType = clientContexts.take().exchangeType() 184 | assertEquals(exchangeType, ExchangeType.RESPONSE_STREAMING) 185 | } 186 | 187 | test("client-streaming") { 188 | val (_, client) = fixture() 189 | 190 | val body = Stream 191 | .range(1, 6) 192 | .covary[IO] 193 | .map(_.toString) 194 | .through(text.utf8.encode) 195 | 196 | val req = Request(method = Method.POST, uri = uri"/client-streaming", body = body) 197 | val response = client.expect[String](req).unsafeRunSync() 198 | assertEquals(response, "1 2 3 4 5") 199 | } 200 | 201 | test("bidi-streaming") { 202 | val (_, client) = fixture() 203 | 204 | val body = Stream 205 | .range(1, 6) 206 | .covary[IO] 207 | .map(_.toString) 208 | .through(text.utf8.encode) 209 | 210 | val req = Request(method = Method.POST, uri = uri"/bidi-streaming", body = body) 211 | val response = client 212 | .stream(req) 213 | .flatMap(res => res.bodyText) 214 | .compile 215 | .toList 216 | .unsafeRunSync() 217 | .reduce(_ + " " + _) 218 | assertEquals(response, "1! 2! 3! 4! 5!") 219 | } 220 | 221 | test("timeout response") { 222 | val (_, client) = fixture() 223 | 224 | val res = client.expect[String](uri"/delayed") 225 | 226 | res.as(false).timeoutTo(100.millis, IO.pure(true)).timed.flatMap { case (duration, result) => 227 | IO { 228 | assert(clue(duration) < 1.second) 229 | assert(result) 230 | } 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /server/src/main/scala/org/http4s/armeria/server/ArmeriaHttp4sHandler.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s 18 | package armeria 19 | package server 20 | 21 | import cats.effect.{Async, IO} 22 | import cats.effect.std.Dispatcher 23 | import cats.effect.unsafe.implicits.global 24 | import cats.syntax.applicativeError._ 25 | import cats.syntax.flatMap._ 26 | import cats.syntax.functor._ 27 | import cats.syntax.option._ 28 | import com.linecorp.armeria.common.{ 29 | HttpData, 30 | HttpHeaderNames, 31 | HttpHeaders, 32 | HttpMethod, 33 | HttpRequest, 34 | HttpResponse, 35 | HttpResponseWriter, 36 | ResponseHeaders 37 | } 38 | import com.linecorp.armeria.common.util.Version 39 | import com.linecorp.armeria.server.{HttpService, ServiceRequestContext} 40 | import org.typelevel.vault.{Key => VaultKey, Vault} 41 | import fs2._ 42 | import fs2.interop.reactivestreams._ 43 | import org.http4s.internal.CollectionCompat.CollectionConverters._ 44 | import ArmeriaHttp4sHandler.{RightUnit, canHasBody, defaultVault, toHttp4sMethod} 45 | import com.comcast.ip4s.SocketAddress 46 | import org.http4s.server.{ 47 | DefaultServiceErrorHandler, 48 | SecureSession, 49 | ServerRequestKeys, 50 | ServiceErrorHandler 51 | } 52 | import org.typelevel.ci.CIString 53 | import scodec.bits.ByteVector 54 | 55 | /** An [[HttpService]] that handles the specified [[HttpApp]] under the specified `prefix`. */ 56 | private[armeria] class ArmeriaHttp4sHandler[F[_]]( 57 | prefix: String, 58 | service: HttpApp[F], 59 | serviceErrorHandler: ServiceErrorHandler[F], 60 | dispatcher: Dispatcher[F] 61 | )(implicit F: Async[F]) 62 | extends HttpService { 63 | 64 | val prefixLength: Int = Uri.Path.unsafeFromString(prefix).segments.size 65 | // micro-optimization: unwrap the service and call its .run directly 66 | private val serviceFn: Request[F] => F[Response[F]] = service.run 67 | 68 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = { 69 | val responseWriter = HttpResponse.streaming() 70 | dispatcher.unsafeRunAndForget( 71 | toRequest(ctx, req) 72 | .fold(onParseFailure(_, ctx, responseWriter), handleRequest(_, ctx, responseWriter)) 73 | .handleError { ex => 74 | discardReturn(responseWriter.close(ex)) 75 | } 76 | ) 77 | responseWriter 78 | } 79 | 80 | private def handleRequest( 81 | request: Request[F], 82 | ctx: ServiceRequestContext, 83 | writer: HttpResponseWriter): F[Unit] = 84 | serviceFn(request) 85 | .recoverWith(serviceErrorHandler(request)) 86 | .flatMap(toHttpResponse(_, ctx, writer)) 87 | 88 | private def onParseFailure( 89 | parseFailure: ParseFailure, 90 | ctx: ServiceRequestContext, 91 | writer: HttpResponseWriter): F[Unit] = { 92 | val response = Response[F](Status.BadRequest).withEntity(parseFailure.sanitized) 93 | toHttpResponse(response, ctx, writer) 94 | } 95 | 96 | /** Converts http4s' [[Response]] to Armeria's [[HttpResponse]]. */ 97 | private def toHttpResponse( 98 | response: Response[F], 99 | ctx: ServiceRequestContext, 100 | writer: HttpResponseWriter): F[Unit] = { 101 | val headers = toHttpHeaders(response.headers, response.status.some) 102 | writer.write(headers) 103 | val body = response.body 104 | if (body == EmptyBody) { 105 | writer.close() 106 | F.unit 107 | } else if (response.contentLength.isDefined) 108 | // non stream response 109 | response.body.chunks.compile.toVector 110 | .flatMap { vector => 111 | vector.foreach { chunk => 112 | val bytes = chunk.toArraySlice 113 | writer.write(HttpData.wrap(bytes.values, bytes.offset, bytes.length)) 114 | } 115 | maybeWriteTrailersAndClose(writer, response) 116 | } 117 | else 118 | writeOnDemand(writer, ctx, body).stream 119 | .onFinalize(maybeWriteTrailersAndClose(writer, response)) 120 | .compile 121 | .drain 122 | } 123 | 124 | private def maybeWriteTrailersAndClose( 125 | writer: HttpResponseWriter, 126 | response: Response[F]): F[Unit] = 127 | response.trailerHeaders.map { trailers => 128 | if (trailers.headers.nonEmpty) 129 | writer.write(toHttpHeaders(trailers, None)) 130 | writer.close() 131 | } 132 | 133 | private def writeOnDemand( 134 | writer: HttpResponseWriter, 135 | ctx: ServiceRequestContext, 136 | body: Stream[F, Byte]): Pull[F, Nothing, Unit] = 137 | body.pull.uncons.flatMap { 138 | case Some((head, tail)) => 139 | val bytes = head.toArraySlice 140 | writer.write(HttpData.wrap(bytes.values, bytes.offset, bytes.length)) 141 | if (tail == Stream.empty) 142 | Pull.done 143 | else 144 | Pull.eval(F.async[Unit] { cb => 145 | F.delay(discardReturn(writer.whenConsumed().thenRun(() => cb(RightUnit)))) 146 | .as(Some(F.delay( 147 | ctx.cancel() 148 | ))) 149 | }) >> writeOnDemand(writer, ctx, tail) 150 | case None => 151 | Pull.done 152 | } 153 | 154 | /** Converts Armeria's [[HttpRequest]] to http4s' [[Request]]. */ 155 | private def toRequest(ctx: ServiceRequestContext, req: HttpRequest): ParseResult[Request[F]] = { 156 | val path = req.path() 157 | for { 158 | method <- toHttp4sMethod(req.method()) 159 | uri <- Uri.requestTarget(path) 160 | } yield Request( 161 | method = method, 162 | uri = uri, 163 | httpVersion = 164 | if (ctx.sessionProtocol().isMultiplex) 165 | HttpVersion.`HTTP/2` 166 | else if (req.headers().get(HttpHeaderNames.HOST) != null) 167 | HttpVersion.`HTTP/1.1` 168 | else 169 | HttpVersion.`HTTP/1.0`, 170 | headers = toHeaders(req), 171 | body = toBody(req), 172 | attributes = requestAttributes(ctx) 173 | ) 174 | } 175 | 176 | /** Converts http4s' [[Headers]] to Armeria's [[HttpHeaders]]. */ 177 | private def toHttpHeaders(headers: Headers, status: Option[Status]): HttpHeaders = { 178 | val builder = status.fold(HttpHeaders.builder())(s => ResponseHeaders.builder(s.code)) 179 | 180 | for (header <- headers.headers) 181 | builder.add(header.name.toString, header.value) 182 | builder.build() 183 | } 184 | 185 | /** Converts Armeria's [[com.linecorp.armeria.common.HttpHeaders]] to http4s' [[Headers]]. */ 186 | private def toHeaders(req: HttpRequest): Headers = 187 | Headers( 188 | req 189 | .headers() 190 | .asScala 191 | .map(entry => Header.Raw(CIString(entry.getKey.toString()), entry.getValue)) 192 | .toList 193 | ) 194 | 195 | /** Converts an HTTP payload to [[EntityBody]]. */ 196 | private def toBody(req: HttpRequest): EntityBody[F] = 197 | if (canHasBody(req.method())) 198 | req 199 | .toStreamBuffered[F](1) 200 | .flatMap { obj => 201 | val data = obj.asInstanceOf[HttpData] 202 | Stream.chunk(Chunk.array(data.array())) 203 | } 204 | else 205 | EmptyBody 206 | 207 | private def requestAttributes(ctx: ServiceRequestContext): Vault = { 208 | val secure = ctx.sessionProtocol().isTls 209 | defaultVault 210 | .insert(Request.Keys.PathInfoCaret, prefixLength) 211 | .insert(ServiceRequestContexts.Key, ctx) 212 | .insert( 213 | Request.Keys.ConnectionInfo, 214 | Request.Connection( 215 | local = SocketAddress.fromInetSocketAddress(ctx.localAddress), 216 | remote = SocketAddress.fromInetSocketAddress(ctx.remoteAddress), 217 | secure = secure 218 | ) 219 | ) 220 | .insert( 221 | ServerRequestKeys.SecureSession, 222 | if (secure) { 223 | val sslSession = ctx.sslSession() 224 | val cipherSuite = sslSession.getCipherSuite 225 | Some( 226 | SecureSession( 227 | ByteVector(sslSession.getId).toHex, 228 | cipherSuite, 229 | SSLContextFactory.deduceKeyLength(cipherSuite), 230 | SSLContextFactory.getCertChain(sslSession) 231 | )) 232 | } else 233 | None 234 | ) 235 | } 236 | 237 | /** Discards the returned value from the specified `f` and return [[Unit]]. A work around for 238 | * "discarded non-Unit value" error on Java [[Void]] type. 239 | */ 240 | @inline 241 | private def discardReturn(f: => Any): Unit = { 242 | val _ = f 243 | } 244 | } 245 | 246 | private[armeria] object ArmeriaHttp4sHandler { 247 | def apply[F[_]: Async]( 248 | prefix: String, 249 | service: HttpApp[F], 250 | serviceErrorHandler: ServiceErrorHandler[F], 251 | dispatcher: Dispatcher[F] 252 | ): ArmeriaHttp4sHandler[F] = 253 | new ArmeriaHttp4sHandler(prefix, service, serviceErrorHandler, dispatcher) 254 | 255 | @deprecated( 256 | "Use fully specified `org.http4s.armeria.server.ArmeriaHttp4sHandler.apply` instead", 257 | "0.5.5") 258 | def apply[F[_]: Async]( 259 | prefix: String, 260 | service: HttpApp[F], 261 | dispatcher: Dispatcher[F] 262 | ): ArmeriaHttp4sHandler[F] = 263 | new ArmeriaHttp4sHandler(prefix, service, DefaultServiceErrorHandler, dispatcher) 264 | 265 | private val serverSoftware: ServerSoftware = 266 | ServerSoftware("armeria", Some(Version.get("armeria").artifactVersion())) 267 | 268 | private val defaultVault: Vault = Vault.empty.insert(Request.Keys.ServerSoftware, serverSoftware) 269 | 270 | private val OPTIONS: ParseResult[Method] = Right(Method.OPTIONS) 271 | private val GET: ParseResult[Method] = Right(Method.GET) 272 | private val HEAD: ParseResult[Method] = Right(Method.HEAD) 273 | private val POST: ParseResult[Method] = Right(Method.POST) 274 | private val PUT: ParseResult[Method] = Right(Method.PUT) 275 | private val PATCH: ParseResult[Method] = Right(Method.PATCH) 276 | private val DELETE: ParseResult[Method] = Right(Method.DELETE) 277 | private val TRACE: ParseResult[Method] = Right(Method.TRACE) 278 | private val CONNECT: ParseResult[Method] = Right(Method.CONNECT) 279 | 280 | private val RightUnit = Right(()) 281 | 282 | private def toHttp4sMethod(method: HttpMethod): ParseResult[Method] = 283 | method match { 284 | case HttpMethod.OPTIONS => OPTIONS 285 | case HttpMethod.GET => GET 286 | case HttpMethod.HEAD => HEAD 287 | case HttpMethod.POST => POST 288 | case HttpMethod.PUT => PUT 289 | case HttpMethod.PATCH => PATCH 290 | case HttpMethod.DELETE => DELETE 291 | case HttpMethod.TRACE => TRACE 292 | case HttpMethod.CONNECT => CONNECT 293 | case HttpMethod.UNKNOWN => Left(ParseFailure("Invalid method", method.name())) 294 | } 295 | 296 | private def canHasBody(method: HttpMethod): Boolean = 297 | method match { 298 | case HttpMethod.OPTIONS => false 299 | case HttpMethod.GET => false 300 | case HttpMethod.HEAD => false 301 | case HttpMethod.TRACE => false 302 | case HttpMethod.CONNECT => false 303 | case HttpMethod.POST => true 304 | case HttpMethod.PUT => true 305 | case HttpMethod.PATCH => true 306 | case HttpMethod.DELETE => true 307 | case HttpMethod.UNKNOWN => false 308 | } 309 | } 310 | 311 | object ServiceRequestContexts { 312 | val Key: VaultKey[ServiceRequestContext] = 313 | VaultKey.newKey[IO, ServiceRequestContext].unsafeRunSync() 314 | } 315 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**', '!update/**', '!pr/**'] 13 | push: 14 | branches: ['**', '!update/**', '!pr/**'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | 21 | concurrency: 22 | group: ${{ github.workflow }} @ ${{ github.ref }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | build: 27 | name: Test 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | os: [ubuntu-22.04] 32 | scala: [2.13, 2.12, 3] 33 | java: [temurin@8, temurin@11, temurin@17] 34 | exclude: 35 | - scala: 2.12 36 | java: temurin@11 37 | - scala: 2.12 38 | java: temurin@17 39 | - scala: 3 40 | java: temurin@11 41 | - scala: 3 42 | java: temurin@17 43 | runs-on: ${{ matrix.os }} 44 | timeout-minutes: 60 45 | steps: 46 | - name: Checkout current branch (full) 47 | uses: actions/checkout@v5 48 | with: 49 | fetch-depth: 0 50 | 51 | - name: Setup sbt 52 | uses: sbt/setup-sbt@v1 53 | 54 | - name: Setup Java (temurin@8) 55 | id: setup-java-temurin-8 56 | if: matrix.java == 'temurin@8' 57 | uses: actions/setup-java@v5 58 | with: 59 | distribution: temurin 60 | java-version: 8 61 | cache: sbt 62 | 63 | - name: sbt update 64 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 65 | run: sbt +update 66 | 67 | - name: Setup Java (temurin@11) 68 | id: setup-java-temurin-11 69 | if: matrix.java == 'temurin@11' 70 | uses: actions/setup-java@v5 71 | with: 72 | distribution: temurin 73 | java-version: 11 74 | cache: sbt 75 | 76 | - name: sbt update 77 | if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' 78 | run: sbt +update 79 | 80 | - name: Setup Java (temurin@17) 81 | id: setup-java-temurin-17 82 | if: matrix.java == 'temurin@17' 83 | uses: actions/setup-java@v5 84 | with: 85 | distribution: temurin 86 | java-version: 17 87 | cache: sbt 88 | 89 | - name: sbt update 90 | if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' 91 | run: sbt +update 92 | 93 | - name: Check that workflows are up to date 94 | run: sbt githubWorkflowCheck 95 | 96 | - name: Check headers and formatting 97 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 98 | run: sbt '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck 99 | 100 | - name: Test 101 | run: sbt '++ ${{ matrix.scala }}' test 102 | 103 | - name: Check binary compatibility 104 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 105 | run: sbt '++ ${{ matrix.scala }}' mimaReportBinaryIssues 106 | 107 | - name: Generate API documentation 108 | if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' 109 | run: sbt '++ ${{ matrix.scala }}' doc 110 | 111 | - name: Check scalafix lints 112 | if: matrix.java == 'temurin@8' && !startsWith(matrix.scala, '3') 113 | run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' 114 | 115 | - name: Check unused compile dependencies 116 | if: matrix.java == 'temurin@8' 117 | run: sbt '++ ${{ matrix.scala }}' unusedCompileDependenciesTest 118 | 119 | - name: Make target directories 120 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') 121 | run: mkdir -p server/target client/target project/target 122 | 123 | - name: Compress target directories 124 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') 125 | run: tar cf targets.tar server/target client/target project/target 126 | 127 | - name: Upload target directories 128 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') 129 | uses: actions/upload-artifact@v5 130 | with: 131 | name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }} 132 | path: targets.tar 133 | 134 | publish: 135 | name: Publish Artifacts 136 | needs: [build] 137 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') 138 | strategy: 139 | matrix: 140 | os: [ubuntu-22.04] 141 | java: [temurin@8] 142 | runs-on: ${{ matrix.os }} 143 | steps: 144 | - name: Checkout current branch (full) 145 | uses: actions/checkout@v5 146 | with: 147 | fetch-depth: 0 148 | 149 | - name: Setup sbt 150 | uses: sbt/setup-sbt@v1 151 | 152 | - name: Setup Java (temurin@8) 153 | id: setup-java-temurin-8 154 | if: matrix.java == 'temurin@8' 155 | uses: actions/setup-java@v5 156 | with: 157 | distribution: temurin 158 | java-version: 8 159 | cache: sbt 160 | 161 | - name: sbt update 162 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 163 | run: sbt +update 164 | 165 | - name: Setup Java (temurin@11) 166 | id: setup-java-temurin-11 167 | if: matrix.java == 'temurin@11' 168 | uses: actions/setup-java@v5 169 | with: 170 | distribution: temurin 171 | java-version: 11 172 | cache: sbt 173 | 174 | - name: sbt update 175 | if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' 176 | run: sbt +update 177 | 178 | - name: Setup Java (temurin@17) 179 | id: setup-java-temurin-17 180 | if: matrix.java == 'temurin@17' 181 | uses: actions/setup-java@v5 182 | with: 183 | distribution: temurin 184 | java-version: 17 185 | cache: sbt 186 | 187 | - name: sbt update 188 | if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' 189 | run: sbt +update 190 | 191 | - name: Download target directories (2.13) 192 | uses: actions/download-artifact@v6 193 | with: 194 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.13 195 | 196 | - name: Inflate target directories (2.13) 197 | run: | 198 | tar xf targets.tar 199 | rm targets.tar 200 | 201 | - name: Download target directories (2.12) 202 | uses: actions/download-artifact@v6 203 | with: 204 | name: target-${{ matrix.os }}-${{ matrix.java }}-2.12 205 | 206 | - name: Inflate target directories (2.12) 207 | run: | 208 | tar xf targets.tar 209 | rm targets.tar 210 | 211 | - name: Download target directories (3) 212 | uses: actions/download-artifact@v6 213 | with: 214 | name: target-${{ matrix.os }}-${{ matrix.java }}-3 215 | 216 | - name: Inflate target directories (3) 217 | run: | 218 | tar xf targets.tar 219 | rm targets.tar 220 | 221 | - name: Import signing key 222 | if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' 223 | env: 224 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 225 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 226 | run: echo $PGP_SECRET | base64 -d -i - | gpg --import 227 | 228 | - name: Import signing key and strip passphrase 229 | if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' 230 | env: 231 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 232 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 233 | run: | 234 | echo "$PGP_SECRET" | base64 -d -i - > /tmp/signing-key.gpg 235 | echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg 236 | (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) 237 | 238 | - name: Publish 239 | env: 240 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 241 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 242 | SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} 243 | run: sbt tlCiRelease 244 | 245 | dependency-submission: 246 | name: Submit Dependencies 247 | if: github.event.repository.fork == false && github.event_name != 'pull_request' 248 | strategy: 249 | matrix: 250 | os: [ubuntu-22.04] 251 | java: [temurin@8] 252 | runs-on: ${{ matrix.os }} 253 | steps: 254 | - name: Checkout current branch (full) 255 | uses: actions/checkout@v5 256 | with: 257 | fetch-depth: 0 258 | 259 | - name: Setup sbt 260 | uses: sbt/setup-sbt@v1 261 | 262 | - name: Setup Java (temurin@8) 263 | id: setup-java-temurin-8 264 | if: matrix.java == 'temurin@8' 265 | uses: actions/setup-java@v5 266 | with: 267 | distribution: temurin 268 | java-version: 8 269 | cache: sbt 270 | 271 | - name: sbt update 272 | if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' 273 | run: sbt +update 274 | 275 | - name: Setup Java (temurin@11) 276 | id: setup-java-temurin-11 277 | if: matrix.java == 'temurin@11' 278 | uses: actions/setup-java@v5 279 | with: 280 | distribution: temurin 281 | java-version: 11 282 | cache: sbt 283 | 284 | - name: sbt update 285 | if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' 286 | run: sbt +update 287 | 288 | - name: Setup Java (temurin@17) 289 | id: setup-java-temurin-17 290 | if: matrix.java == 'temurin@17' 291 | uses: actions/setup-java@v5 292 | with: 293 | distribution: temurin 294 | java-version: 17 295 | cache: sbt 296 | 297 | - name: sbt update 298 | if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' 299 | run: sbt +update 300 | 301 | - name: Submit Dependencies 302 | uses: scalacenter/sbt-dependency-submission@v2 303 | with: 304 | modules-ignore: http4s-armeria_2.13 http4s-armeria_2.12 http4s-armeria_3 examples-armeria-fs2grpc_2.13 examples-armeria-fs2grpc_2.12 sbt-http4s-org-scalafix-internal_2.13 sbt-http4s-org-scalafix-internal_2.12 sbt-http4s-org-scalafix-internal_3 examples-armeria-http4s_2.13 examples-armeria-http4s_2.12 examples-armeria-scalapb_2.13 examples-armeria-scalapb_2.12 305 | configs-ignore: test scala-tool scala-doc-tool test-internal 306 | 307 | validate-steward: 308 | name: Validate Steward Config 309 | strategy: 310 | matrix: 311 | os: [ubuntu-22.04] 312 | java: [temurin@11] 313 | runs-on: ${{ matrix.os }} 314 | steps: 315 | - name: Checkout current branch (fast) 316 | uses: actions/checkout@v5 317 | 318 | - name: Setup Java (temurin@11) 319 | id: setup-java-temurin-11 320 | if: matrix.java == 'temurin@11' 321 | uses: actions/setup-java@v5 322 | with: 323 | distribution: temurin 324 | java-version: 11 325 | 326 | - uses: coursier/setup-action@v1 327 | with: 328 | apps: scala-steward 329 | 330 | - run: scala-steward validate-repo-config .scala-steward.conf 331 | -------------------------------------------------------------------------------- /server/src/main/scala/org/http4s/armeria/server/ArmeriaServerBuilder.scala: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 http4s.org 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package org.http4s.armeria.server 18 | 19 | import java.io.{File, InputStream} 20 | import java.net.InetSocketAddress 21 | import java.security.PrivateKey 22 | import java.security.cert.X509Certificate 23 | import java.util.function.{Function => JFunction} 24 | import javax.net.ssl.KeyManagerFactory 25 | 26 | import cats.Monad 27 | import cats.effect.{Async, Resource} 28 | import cats.effect.std.Dispatcher 29 | import cats.syntax.all._ 30 | import com.linecorp.armeria.common.util.Version 31 | import com.linecorp.armeria.common.{ 32 | ContentTooLargeException, 33 | HttpRequest, 34 | HttpResponse, 35 | SessionProtocol, 36 | TlsKeyPair 37 | } 38 | import com.linecorp.armeria.server.{ 39 | HttpService, 40 | HttpServiceWithRoutes, 41 | Server => BackendServer, 42 | ServerBuilder => ArmeriaBuilder, 43 | ServerListenerAdapter, 44 | ServiceRequestContext 45 | } 46 | import io.micrometer.core.instrument.MeterRegistry 47 | import io.netty.channel.ChannelOption 48 | import io.netty.handler.ssl.SslContextBuilder 49 | import org.http4s.armeria.server.ArmeriaServerBuilder.AddServices 50 | import org.http4s.headers.{Connection, `Content-Length`} 51 | import org.http4s.{BuildInfo, Headers, HttpApp, HttpRoutes, Request, Response, Status} 52 | import org.http4s.server.{ 53 | DefaultServiceErrorHandler, 54 | Server, 55 | ServerBuilder, 56 | ServiceErrorHandler, 57 | defaults 58 | } 59 | import org.http4s.server.defaults.{IdleTimeout, ResponseTimeout, ShutdownTimeout} 60 | import org.http4s.syntax.all._ 61 | import org.log4s.{Logger, getLogger} 62 | 63 | import scala.collection.immutable 64 | import scala.concurrent.duration.FiniteDuration 65 | 66 | sealed class ArmeriaServerBuilder[F[_]] private ( 67 | addServices: AddServices[F], 68 | socketAddress: InetSocketAddress, 69 | serviceErrorHandler: ServiceErrorHandler[F], 70 | banner: List[String])(implicit protected val F: Async[F]) 71 | extends ServerBuilder[F] { 72 | override type Self = ArmeriaServerBuilder[F] 73 | 74 | type DecoratingFunction = (HttpService, ServiceRequestContext, HttpRequest) => HttpResponse 75 | 76 | private[this] val logger: Logger = getLogger 77 | 78 | override def bindSocketAddress(socketAddress: InetSocketAddress): Self = 79 | copy(socketAddress = socketAddress) 80 | 81 | override def withServiceErrorHandler(serviceErrorHandler: ServiceErrorHandler[F]): Self = 82 | copy(serviceErrorHandler = serviceErrorHandler) 83 | 84 | override def resource: Resource[F, ArmeriaServer] = 85 | Dispatcher.parallel[F].flatMap { dispatcher => 86 | Resource(for { 87 | defaultServerBuilder <- F.delay { 88 | BackendServer 89 | .builder() 90 | .idleTimeoutMillis(IdleTimeout.toMillis) 91 | .requestTimeoutMillis(ResponseTimeout.toMillis) 92 | .gracefulShutdownTimeoutMillis(ShutdownTimeout.toMillis, ShutdownTimeout.toMillis) 93 | } 94 | builderWithServices <- addServices(defaultServerBuilder, dispatcher) 95 | res <- F.delay { 96 | val armeriaServer0 = builderWithServices.http(socketAddress).build() 97 | 98 | armeriaServer0.addListener(new ServerListenerAdapter { 99 | override def serverStarting(server: BackendServer): Unit = { 100 | banner.foreach(logger.info(_)) 101 | 102 | val armeriaVersion = Version.get("armeria").artifactVersion() 103 | 104 | logger.info(s"http4s v${BuildInfo.version} on Armeria v$armeriaVersion started") 105 | } 106 | }) 107 | armeriaServer0.start().join() 108 | 109 | val armeriaServer: ArmeriaServer = new ArmeriaServer { 110 | lazy val address: InetSocketAddress = { 111 | val host = socketAddress.getHostString 112 | val port = server.activeLocalPort() 113 | new InetSocketAddress(host, port) 114 | } 115 | 116 | lazy val server: BackendServer = armeriaServer0 117 | lazy val isSecure: Boolean = server.activePort(SessionProtocol.HTTPS) != null 118 | } 119 | 120 | armeriaServer -> shutdown(armeriaServer.server) 121 | } 122 | } yield res) 123 | } 124 | 125 | /** Binds the specified `service` at the specified path pattern. See 126 | * [[https://armeria.dev/docs/server-basics#path-patterns]] for detailed information of path 127 | * pattens. 128 | */ 129 | def withHttpService( 130 | pathPattern: String, 131 | service: (ServiceRequestContext, HttpRequest) => HttpResponse): Self = 132 | atBuild( 133 | _.service( 134 | pathPattern, 135 | new HttpService { 136 | override def serve(ctx: ServiceRequestContext, req: HttpRequest): HttpResponse = 137 | service(ctx, req) 138 | })) 139 | 140 | /** Binds the specified [[com.linecorp.armeria.server.HttpService]] at the specified path pattern. 141 | * See [[https://armeria.dev/docs/server-basics#path-patterns]] for detailed information of path 142 | * pattens. 143 | */ 144 | def withHttpService(pathPattern: String, service: HttpService): Self = 145 | atBuild(_.service(pathPattern, service)) 146 | 147 | /** Binds the specified [[com.linecorp.armeria.server.HttpServiceWithRoutes]] at multiple 148 | * [[com.linecorp.armeria.server.Route]] s of the default 149 | * [[com.linecorp.armeria.server.VirtualHost]]. 150 | */ 151 | def withHttpService(serviceWithRoutes: HttpServiceWithRoutes): Self = 152 | atBuild(_.service(serviceWithRoutes)) 153 | 154 | /** Binds the specified [[com.linecorp.armeria.server.HttpService]] under the specified directory. 155 | */ 156 | def withHttpServiceUnder(prefix: String, service: HttpService): Self = 157 | atBuild(_.serviceUnder(prefix, service)) 158 | 159 | /** Binds the specified [[org.http4s.HttpRoutes]] under the specified prefix. */ 160 | def withHttpRoutes(prefix: String, service: HttpRoutes[F]): Self = 161 | withHttpApp(prefix, service.orNotFound) 162 | 163 | /** Binds the specified [[org.http4s.HttpApp]] under the specified prefix. */ 164 | def withHttpApp(prefix: String, service: HttpApp[F]): Self = 165 | copy(addServices = (ab, dispatcher) => 166 | addServices(ab, dispatcher).map( 167 | _.serviceUnder( 168 | prefix, 169 | ArmeriaHttp4sHandler(prefix, service, serviceErrorHandler, dispatcher)))) 170 | 171 | /** Decorates all HTTP services with the specified [[DecoratingFunction]]. */ 172 | def withDecorator(decorator: DecoratingFunction): Self = 173 | atBuild(_.decorator((delegate, ctx, req) => decorator(delegate, ctx, req))) 174 | 175 | /** Decorates all HTTP services with the specified `decorator`. */ 176 | def withDecorator(decorator: JFunction[_ >: HttpService, _ <: HttpService]): Self = 177 | atBuild(_.decorator(decorator)) 178 | 179 | /** Decorates HTTP services under the specified directory with the specified 180 | * [[DecoratingFunction]]. 181 | */ 182 | def withDecoratorUnder(prefix: String, decorator: DecoratingFunction): Self = 183 | atBuild(_.decoratorUnder(prefix, (delegate, ctx, req) => decorator(delegate, ctx, req))) 184 | 185 | /** Decorates HTTP services under the specified directory with the specified `decorator`. */ 186 | def withDecoratorUnder( 187 | prefix: String, 188 | decorator: JFunction[_ >: HttpService, _ <: HttpService]): Self = 189 | atBuild(_.decoratorUnder(prefix, decorator)) 190 | 191 | /** Configures the Armeria server using the specified 192 | * [[com.linecorp.armeria.server.ServerBuilder]]. 193 | */ 194 | def withArmeriaBuilder(customizer: ArmeriaBuilder => Unit): Self = 195 | atBuild { ab => customizer(ab); ab } 196 | 197 | /** Sets the idle timeout of a connection in milliseconds for keep-alive. 198 | * 199 | * @param idleTimeout 200 | * the timeout. `scala.concurrent.duration.Duration.Zero` disables the timeout. 201 | */ 202 | def withIdleTimeout(idleTimeout: FiniteDuration): Self = 203 | atBuild(_.idleTimeoutMillis(idleTimeout.toMillis)) 204 | 205 | /** Sets the maximum allowed length of the content decoded at the session layer. 206 | * 207 | * @param limit 208 | * the maximum allowed length. {@code 0} disables the length limit. 209 | */ 210 | def withMaxRequestLength(limit: Long): Self = 211 | atBuild(_.maxRequestLength(limit)) 212 | 213 | /** Sets the timeout of a request. 214 | * 215 | * @param requestTimeout 216 | * the timeout. `scala.concurrent.duration.Duration.Zero` disables the timeout. 217 | */ 218 | def withRequestTimeout(requestTimeout: FiniteDuration): Self = 219 | atBuild(_.requestTimeoutMillis(requestTimeout.toMillis)) 220 | 221 | /** Adds an HTTP port that listens on all available network interfaces. 222 | * 223 | * @param port 224 | * the HTTP port number. 225 | * @see 226 | * [[com.linecorp.armeria.server.ServerBuilder#https(localAddress:java\.net\.InetSocketAddress):com\.linecorp\.armeria\.server\.ServerBuilder*]] 227 | */ 228 | def withHttp(port: Int): Self = atBuild(_.http(port)) 229 | 230 | /** Adds an HTTPS port that listens on all available network interfaces. 231 | * 232 | * @param port 233 | * the HTTPS port number. 234 | * @see 235 | * [[com.linecorp.armeria.server.ServerBuilder#https(localAddress:java\.net\.InetSocketAddress):com\.linecorp\.armeria\.server\.ServerBuilder*]] 236 | */ 237 | def withHttps(port: Int): Self = atBuild(_.https(port)) 238 | 239 | /** Sets the [[io.netty.channel.ChannelOption]] of the server socket bound by 240 | * [[com.linecorp.armeria.server.Server]]. Note that the previously added option will be 241 | * overridden if the same option is set again. 242 | * 243 | * @see 244 | * [[https://armeria.dev/docs/advanced-production-checklist Production checklist]] 245 | */ 246 | def withChannelOption[T](option: ChannelOption[T], value: T): Self = 247 | atBuild(_.channelOption(option, value)) 248 | 249 | /** Sets the [[io.netty.channel.ChannelOption]] of sockets accepted by 250 | * [[com.linecorp.armeria.server.Server]]. Note that the previously added option will be 251 | * overridden if the same option is set again. 252 | * 253 | * @see 254 | * [[https://armeria.dev/docs/advanced-production-checklist Production checklist]] 255 | */ 256 | def withChildChannelOption[T](option: ChannelOption[T], value: T): Self = 257 | atBuild(_.childChannelOption(option, value)) 258 | 259 | /** Configures SSL or TLS of this [[com.linecorp.armeria.server.Server]] from the specified 260 | * `keyCertChainFile`, `keyFile` and `keyPassword`. 261 | * 262 | * @see 263 | * [[withTlsCustomizer]] 264 | */ 265 | def withTls(keyCertChainFile: File, keyFile: File, keyPassword: Option[String]): Self = 266 | atBuild( 267 | _.tls(TlsKeyPair.of(keyFile, keyPassword.orNull, keyCertChainFile)) 268 | ) 269 | 270 | /** Configures SSL or TLS of this [[com.linecorp.armeria.server.Server]] with the specified 271 | * `keyCertChainInputStream`, `keyInputStream` and `keyPassword`. 272 | * 273 | * @see 274 | * [[withTlsCustomizer]] 275 | */ 276 | def withTls( 277 | keyCertChainInputStream: Resource[F, InputStream], 278 | keyInputStream: Resource[F, InputStream], 279 | keyPassword: Option[String]): Self = 280 | copy(addServices = (armeriaBuilder, dispatcher) => 281 | addServices(armeriaBuilder, dispatcher).flatMap { ab => 282 | keyCertChainInputStream 283 | .both(keyInputStream) 284 | .use { case (keyCertChain, key) => 285 | F.delay { 286 | ab.tls( 287 | TlsKeyPair.of(key, keyPassword.orNull, keyCertChain) 288 | ) 289 | } 290 | } 291 | }) 292 | 293 | /** Configures SSL or TLS of this [[com.linecorp.armeria.server.Server]] with the specified 294 | * cleartext [[java.security.PrivateKey]] and [[java.security.cert.X509Certificate]] chain. 295 | * 296 | * @see 297 | * [[withTlsCustomizer]] 298 | */ 299 | def withTls(key: PrivateKey, keyCertChain: X509Certificate*): Self = 300 | atBuild(_.tls(TlsKeyPair.of(key, keyCertChain: _*))) 301 | 302 | /** Configures SSL or TLS of this [[com.linecorp.armeria.server.Server]] with the specified 303 | * [[javax.net.ssl.KeyManagerFactory]]. 304 | * 305 | * @see 306 | * [[withTlsCustomizer]] 307 | */ 308 | def withTls(keyManagerFactory: KeyManagerFactory): Self = 309 | atBuild(_.tls(keyManagerFactory)) 310 | 311 | /** Adds the specified `tlsCustomizer` which can arbitrarily configure the 312 | * [[io.netty.handler.ssl.SslContextBuilder]] that will be applied to the SSL session. 313 | */ 314 | def withTlsCustomizer(tlsCustomizer: SslContextBuilder => Unit): Self = 315 | atBuild(_.tlsCustomizer(ctxBuilder => tlsCustomizer(ctxBuilder))) 316 | 317 | /** Sets the amount of time to wait after calling [[com.linecorp.armeria.server.Server#stop]] for 318 | * requests to go away before actually shutting down. 319 | * 320 | * @param quietPeriod 321 | * the number of milliseconds to wait for active requests to go end before shutting down. 322 | * `scala.concurrent.duration.Duration.Zero` means the server will stop right away without 323 | * waiting. 324 | * @param timeout 325 | * the amount of time to wait before shutting down the server regardless of active requests. 326 | * This should be set to a time greater than `quietPeriod` to ensure the server shuts down even 327 | * if there is a stuck request. 328 | */ 329 | def withGracefulShutdownTimeout(quietPeriod: FiniteDuration, timeout: FiniteDuration): Self = 330 | atBuild(_.gracefulShutdownTimeoutMillis(quietPeriod.toMillis, timeout.toMillis)) 331 | 332 | /** Sets the [[io.micrometer.core.instrument.MeterRegistry]] that collects various stats. */ 333 | def withMeterRegistry(meterRegistry: MeterRegistry): Self = 334 | atBuild(_.meterRegistry(meterRegistry)) 335 | 336 | private def shutdown(armeriaServer: BackendServer): F[Unit] = 337 | F.fromCompletableFuture(F.delay(armeriaServer.stop())).void 338 | 339 | override def withBanner(banner: immutable.Seq[String]): Self = copy(banner = banner.toList) 340 | 341 | private def copy( 342 | addServices: AddServices[F] = addServices, 343 | socketAddress: InetSocketAddress = socketAddress, 344 | serviceErrorHandler: ServiceErrorHandler[F] = serviceErrorHandler, 345 | banner: List[String] = banner 346 | ): Self = 347 | new ArmeriaServerBuilder(addServices, socketAddress, serviceErrorHandler, banner) 348 | 349 | private def atBuild(f: ArmeriaBuilder => ArmeriaBuilder): Self = 350 | copy(addServices = (armeriaBuilder, dispatcher) => 351 | addServices(armeriaBuilder, dispatcher).map(f)) 352 | } 353 | 354 | trait ArmeriaServer extends Server { 355 | def server: BackendServer 356 | } 357 | 358 | /** A builder that builds Armeria server for Http4s. */ 359 | object ArmeriaServerBuilder { 360 | type AddServices[F[_]] = (ArmeriaBuilder, Dispatcher[F]) => F[ArmeriaBuilder] 361 | 362 | /** Returns a newly created [[org.http4s.armeria.server.ArmeriaServerBuilder]]. */ 363 | def apply[F[_]: Async]: ArmeriaServerBuilder[F] = 364 | new ArmeriaServerBuilder( 365 | (armeriaBuilder, _) => armeriaBuilder.pure, 366 | socketAddress = defaults.IPv4SocketAddress, 367 | serviceErrorHandler = defaultServiceErrorHandler[F], 368 | banner = defaults.Banner 369 | ) 370 | 371 | /** Incorporates the default service error handling from Http4s' 372 | * [[org.http4s.server.DefaultServiceErrorHandler DefaultServiceErrorHandler]] and adds handling 373 | * for some errors propagated from the Armeria side. 374 | */ 375 | def defaultServiceErrorHandler[F[_]](implicit 376 | F: Monad[F]): Request[F] => PartialFunction[Throwable, F[Response[F]]] = { 377 | val contentLengthErrorHandler: Request[F] => PartialFunction[Throwable, F[Response[F]]] = 378 | req => { case _: ContentTooLargeException => 379 | Response[F]( 380 | Status.PayloadTooLarge, 381 | req.httpVersion, 382 | Headers( 383 | Connection.close, 384 | `Content-Length`.zero 385 | ) 386 | ).pure[F] 387 | } 388 | 389 | req => contentLengthErrorHandler(req).orElse(DefaultServiceErrorHandler(F)(req)) 390 | } 391 | } 392 | --------------------------------------------------------------------------------