├── 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/.*/' -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 | [](https://img.shields.io/maven-central/v/org.http4s/http4s-armeria-server_2.13?versionPrefix=0.)
4 | 
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 |
--------------------------------------------------------------------------------