diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be0b5243..e4fa81f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: 26.1.x - elixir-version: 1.14.x + elixir-version: 1.15.x - name: Retrieve dependencies cache uses: actions/cache@v3 id: mix-cache # id to use in retrieve action @@ -33,21 +33,11 @@ jobs: name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} strategy: matrix: - otp: [23.x, 24.x, 25.1.x, 26.1.x] - elixir: [1.12.x, 1.13.x, 1.14.x, 1.15.x] - exclude: - - otp: 25.1.x - elixir: 1.12.x - - otp: 26.1.x + otp: [24.x, 25.x, 26.1.x] + elixir: [1.15.x] + include: + - otp: 24.x elixir: 1.12.x - - otp: 25.1.x - elixir: 1.13.x - - otp: 26.1.x - elixir: 1.13.x - - otp: 26.1.x - elixir: 1.14.x - - otp: 23.x - elixir: 1.15.x needs: check_format steps: - uses: actions/checkout@v3 @@ -96,21 +86,11 @@ jobs: if: ${{ github.ref == 'refs/heads/master' }} strategy: matrix: - otp: [23.x, 24.x, 25.1.x, 26.1.x] - elixir: [1.12.x, 1.13.x, 1.14.x, 1.15.x] - exclude: - - otp: 25.1.x - elixir: 1.12.x - - otp: 26.1.x + otp: [24.x, 25.x, 26.1.x] + elixir: [1.15.x] + include: + - otp: 24.x elixir: 1.12.x - - otp: 25.1.x - elixir: 1.13.x - - otp: 26.1.x - elixir: 1.13.x - - otp: 26.1.x - elixir: 1.14.x - - otp: 23.x - elixir: 1.15.x steps: - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 @@ -130,37 +110,6 @@ jobs: run: mix run script/run.exs working-directory: ./interop - dialyzer: - name: Dialyzer - runs-on: ubuntu-20.04 - strategy: - matrix: - otp: [25.1.x, 26.1.x] - elixir: [1.15.x] - env: - MIX_ENV: test - steps: - - uses: actions/checkout@v3 - - id: set_vars - run: | - mix_hash="${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}" - echo "::set-output name=mix_hash::$mix_hash" - - id: cache-plt - uses: actions/cache@v3 - with: - path: | - _build/test/plts/dialyzer.plt - _build/test/plts/dialyzer.plt.hash - key: plt-cache-${{ matrix.otp }}-${{ matrix.elixir }}-${{ steps.set_vars.outputs.mix_hash }} - restore-keys: | - plt-cache-${{ matrix.otp }}-${{ matrix.elixir }}- - - uses: erlef/setup-beam@v1 - with: - otp-version: ${{matrix.otp}} - elixir-version: ${{matrix.elixir}} - - run: mix deps.get 1>/dev/null - - run: mix dialyzer --format short - check_release: runs-on: ubuntu-20.04 name: Check release diff --git a/README.md b/README.md index fc6d3a51..d446249e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # gRPC Elixir +[![GitHub CI](https://github.com/elixir-grpc/grpc/actions/workflows/ci.yml/badge.svg)](https://github.com/elixir-grpc/grpc/actions/workflows/ci.yml) [![Hex.pm](https://img.shields.io/hexpm/v/grpc.svg)](https://hex.pm/packages/grpc) -[![Travis Status](https://app.travis-ci.com/elixir-grpc/grpc.svg?branch=master)](https://app.travis-ci.com/elixir-grpc/grpc) -[![GitHub actions Status](https://github.com/elixir-grpc/grpc/workflows/CI/badge.svg)](https://github.com/elixir-grpc/grpc/actions) +[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/grpc/) [![License](https://img.shields.io/hexpm/l/grpc.svg)](https://github.com/elixir-grpc/grpc/blob/master/LICENSE.md) -[![Last Updated](https://img.shields.io/github/last-commit/elixir-grpc/grpc.svg)](https://github.com/elixir-grpc/grpc/commits/master) [![Total Download](https://img.shields.io/hexpm/dt/grpc.svg)](https://hex.pm/packages/elixir-grpc/grpc) +[![Last Updated](https://img.shields.io/github/last-commit/elixir-grpc/grpc.svg)](https://github.com/elixir-grpc/grpc/commits/master) An Elixir implementation of [gRPC](http://www.grpc.io/). @@ -13,6 +13,9 @@ An Elixir implementation of [gRPC](http://www.grpc.io/). - [Installation](#installation) - [Usage](#usage) + - [Simple RPC](#simple-rpc) + - [HTTP Transcoding](#http-transcoding) + - [Start Application](#start-application) - [Features](#features) - [Benchmark](#benchmark) - [Contributing](#contributing) @@ -24,20 +27,49 @@ The package can be installed as: ```elixir def deps do [ - {:grpc, "~> 0.7"}, - # We don't force protobuf as a dependency for more - # flexibility on which protobuf library is used, - # but you probably want to use it as well - {:protobuf, "~> 0.11"} + {:grpc, "~> 0.8"} ] end ``` ## Usage -1. Generate Elixir code from proto file as [protobuf-elixir](https://github.com/tony612/protobuf-elixir#usage) shows(especially the `gRPC Support` section). +1. Write your protobuf file: + +```protobuf +syntax = "proto3"; + +package helloworld; -2. Implement the server side code like below and remember to return the expected message types. +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greeting +message HelloReply { + string message = 1; +} + +// The greeting service definition. +service Greeter { + // Greeting function + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +``` + +2. Then generate Elixir code from proto file as [protobuf-elixir](https://github.com/tony612/protobuf-elixir#usage) shows (especially the `gRPC Support` section) or using [protobuf_generate](https://hex.pm/packages/protobuf_generate) hex package. Example using `protobuf_generate` lib: + +```shell +mix protobuf.generate --output-path=./lib --include-path=./priv/protos helloworld.proto +``` + +In the following sections you will see how to implement gRPC server logic. + +### **Simple RPC** + +1. Implement the server side code like below and remember to return the expected message types. ```elixir defmodule Helloworld.Greeter.Server do @@ -50,9 +82,7 @@ defmodule Helloworld.Greeter.Server do end ``` -3. Start the server - -You can start the gRPC server as a supervised process. First, add `GRPC.Server.Supervisor` to your supervision tree. +2. Define gRPC endpoints ```elixir # Define your endpoint @@ -62,7 +92,86 @@ defmodule Helloworld.Endpoint do intercept GRPC.Server.Interceptors.Logger run Helloworld.Greeter.Server end +``` + +We will use this module [in the gRPC server startup section](#start-application). + +**__Note:__** For other types of RPC call like streams see [here](interop/lib/interop/server.ex). + +### **HTTP Transcoding** + +1. Adding [grpc-gateway annotations](https://cloud.google.com/endpoints/docs/grpc/transcoding) to your protobuf file definition: + +```protobuf +import "google/api/annotations.proto"; +import "google/protobuf/timestamp.proto"; + +package helloworld; +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + + rpc SayHelloFrom (HelloRequestFrom) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "*" + }; + } +} +``` + +2. Add protoc plugin dependency and compile your protos using [protobuf_generate](https://github.com/drowzy/protobuf_generate) hex [package](https://hex.pm/packages/protobuf_generate): + +In mix.exs: + +```elixir +def deps do + [ + {:grpc, "~> 0.7"}, + {:protobuf_generate, "~> 0.1.1"} + ] +end +``` + +And in your terminal: + +```shell +mix protobuf.generate \ + --include-path=priv/proto \ + --include-path=deps/googleapis \ + --generate-descriptors=true \ + --output-path=./lib \ + --plugins=ProtobufGenerate.Plugins.GRPCWithOptions \ + google/api/annotations.proto google/api/http.proto helloworld.proto +``` + +3. Enable http_transcode option in your Server module +```elixir +defmodule Helloworld.Greeter.Server do + use GRPC.Server, + service: Helloworld.Greeter.Service, + http_transcode: true + + @spec say_hello(Helloworld.HelloRequest.t, GRPC.Server.Stream.t) :: Helloworld.HelloReply.t + def say_hello(request, _stream) do + %Helloworld.HelloReply{message: "Hello #{request.name}"} + end +end +``` + +See full application code in [helloworld_transcoding](examples/helloworld_transcoding) example. + +### **Start Application** + +1. Start gRPC Server in your supervisor tree or Application module: + +```elixir # In the start function of your Application defmodule HelloworldApp do use Application @@ -78,7 +187,7 @@ defmodule HelloworldApp do end ``` -4. Call rpc: +2. Call rpc: ```elixir iex> {:ok, channel} = GRPC.Stub.connect("localhost:50051") @@ -90,7 +199,27 @@ iex> {:ok, channel} = GRPC.Stub.connect("localhost:50051", interceptors: [GRPC.C ... ``` -Check [examples](examples) and [interop](interop)(Interoperability Test) for some examples. +Check the [examples](examples) and [interop](interop) directories in the project's source code for some examples. + +## Client Adapter and Configuration + +The default adapter used by `GRPC.Stub.connect/2` is `GRPC.Client.Adapter.Gun`. Another option is to use `GRPC.Client.Adapters.Mint` instead, like so: + +```elixir +GRPC.Stub.connect("localhost:50051", + # Use Mint adapter instead of default Gun + adapter: GRPC.Client.Adapters.Mint +) +``` + +The `GRPC.Client.Adapters.Mint` adapter accepts custom configuration. To do so, you can configure it from your mix application via: + +```elixir +# File: your application's config file. +config :grpc, GRPC.Client.Adapters.Mint, custom_opts +``` + +The accepted options for configuration are the ones listed on [Mint.HTTP.connect/4](https://hexdocs.pm/mint/Mint.HTTP.html#connect/4-options) ## Features @@ -99,11 +228,13 @@ Check [examples](examples) and [interop](interop)(Interoperability Test) for som - [Server-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc) - [Client-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#client-streaming-rpc) - [Bidirectional-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#bidirectional-streaming-rpc) +- [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) - [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) - [Error handling](https://grpc.io/docs/guides/error/) -- Interceptors(See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) +- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) - [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) - Data compression +- [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) ## Benchmark diff --git a/examples/helloworld/mix.exs b/examples/helloworld/mix.exs index 4b803c6b..497b7b19 100644 --- a/examples/helloworld/mix.exs +++ b/examples/helloworld/mix.exs @@ -22,7 +22,6 @@ defmodule Helloworld.Mixfile do {:jason, "~> 1.3.0"}, {:protobuf, "~> 0.11"}, {:google_protos, "~> 0.3.0"}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} ] end end diff --git a/examples/helloworld/mix.lock b/examples/helloworld/mix.lock index df73eb18..35c1a250 100644 --- a/examples/helloworld/mix.lock +++ b/examples/helloworld/mix.lock @@ -1,11 +1,8 @@ %{ "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "google_protos": {:hex, :google_protos, "0.3.0", "15faf44dce678ac028c289668ff56548806e313e4959a3aaf4f6e1ebe8db83f4", [:mix], [{:protobuf, "~> 0.10", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "1f6b7fb20371f72f418b98e5e48dae3e022a9a6de1858d4b254ac5a5d0b4035f"}, "gun": {:hex, :grpc_gun, "2.0.1", "221b792df3a93e8fead96f697cbaf920120deacced85c6cd3329d2e67f0871f8", [:rebar3], [{:cowlib, "~> 2.11", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "795a65eb9d0ba16697e6b0e1886009ce024799e43bb42753f0c59b029f592831"}, "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "protobuf": {:hex, :protobuf, "0.11.0", "58d5531abadea3f71135e97bd214da53b21adcdb5b1420aee63f4be8173ec927", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "30ad9a867a5c5a0616cac9765c4d2c2b7b0030fa81ea6d0c14c2eb5affb6ac52"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, } diff --git a/examples/helloworld_transcoding/mix.exs b/examples/helloworld_transcoding/mix.exs index f1b13088..6edf51cb 100644 --- a/examples/helloworld_transcoding/mix.exs +++ b/examples/helloworld_transcoding/mix.exs @@ -23,7 +23,6 @@ defmodule Helloworld.Mixfile do {:protobuf_generate, "~> 0.1.1", only: [:dev, :test]}, {:jason, "~> 1.3.0"}, {:google_protos, "~> 0.3.0"}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} ] end end diff --git a/examples/helloworld_transcoding/mix.lock b/examples/helloworld_transcoding/mix.lock index afdfde5e..da6e8b9b 100644 --- a/examples/helloworld_transcoding/mix.lock +++ b/examples/helloworld_transcoding/mix.lock @@ -1,12 +1,9 @@ %{ "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "google_protos": {:hex, :google_protos, "0.3.0", "15faf44dce678ac028c289668ff56548806e313e4959a3aaf4f6e1ebe8db83f4", [:mix], [{:protobuf, "~> 0.10", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "1f6b7fb20371f72f418b98e5e48dae3e022a9a6de1858d4b254ac5a5d0b4035f"}, "gun": {:hex, :grpc_gun, "2.0.1", "221b792df3a93e8fead96f697cbaf920120deacced85c6cd3329d2e67f0871f8", [:rebar3], [{:cowlib, "~> 2.11", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "795a65eb9d0ba16697e6b0e1886009ce024799e43bb42753f0c59b029f592831"}, "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, "protobuf": {:hex, :protobuf, "0.11.0", "58d5531abadea3f71135e97bd214da53b21adcdb5b1420aee63f4be8173ec927", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "30ad9a867a5c5a0616cac9765c4d2c2b7b0030fa81ea6d0c14c2eb5affb6ac52"}, "protobuf_generate": {:hex, :protobuf_generate, "0.1.1", "f6098b85161dcfd48a4f6f1abee4ee5e057981dfc50aafb1aa4bd5b0529aa89b", [:mix], [{:protobuf, "~> 0.11", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "93a38c8e2aba2a17e293e9ef1359122741f717103984aa6d1ebdca0efb17ab9d"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, } diff --git a/examples/route_guide/mix.exs b/examples/route_guide/mix.exs index 8cbcd2d1..f4535b7b 100644 --- a/examples/route_guide/mix.exs +++ b/examples/route_guide/mix.exs @@ -33,7 +33,6 @@ defmodule RouteGuide.Mixfile do {:grpc, path: "../../"}, {:protobuf, "~> 0.11"}, {:jason, "~> 1.2"}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} ] end end diff --git a/examples/route_guide/mix.lock b/examples/route_guide/mix.lock index 3d982bd9..d5503dcc 100644 --- a/examples/route_guide/mix.lock +++ b/examples/route_guide/mix.lock @@ -1,10 +1,7 @@ %{ "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "gun": {:hex, :grpc_gun, "2.0.1", "221b792df3a93e8fead96f697cbaf920120deacced85c6cd3329d2e67f0871f8", [:rebar3], [{:cowlib, "~> 2.11", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "795a65eb9d0ba16697e6b0e1886009ce024799e43bb42753f0c59b029f592831"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "protobuf": {:hex, :protobuf, "0.11.0", "58d5531abadea3f71135e97bd214da53b21adcdb5b1420aee63f4be8173ec927", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "30ad9a867a5c5a0616cac9765c4d2c2b7b0030fa81ea6d0c14c2eb5affb6ac52"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, } diff --git a/guides/pooling.md b/guides/pooling.md new file mode 100644 index 00000000..33a6e031 --- /dev/null +++ b/guides/pooling.md @@ -0,0 +1,9 @@ +# Managing HTTP/2 Connections Efficiently + +When managing large numbers of gRPC HTTP/2 connections, you may benefit from pooling of some sort. + +Currently `elixir-grpc` does not offer pooling functionality, but [here is an excellent guide on using Elixir's `Registry` module to create your own resource pools](https://andrealeopardi.com/posts/process-pools-with-elixirs-registry/). + +It's also worth noting that if using the `Mint` adapter for HTTP/2, you can choose which connection to use by checking the value of each connection's open request count with `open_request_count/1` . + +If you'd prefer to use an existing pool implementation, [check out `conn_grpc` on Hex](https://hexdocs.pm/conn_grpc/ConnGRPC.Pool.html)! diff --git a/lib/grpc/client/adapters/mint.ex b/lib/grpc/client/adapters/mint.ex index eea1c8a2..d82ad2d1 100644 --- a/lib/grpc/client/adapters/mint.ex +++ b/lib/grpc/client/adapters/mint.ex @@ -1,6 +1,6 @@ defmodule GRPC.Client.Adapters.Mint do @moduledoc """ - A client adapter using mint + A client adapter using Mint. """ alias GRPC.Channel @@ -10,12 +10,33 @@ defmodule GRPC.Client.Adapters.Mint do @behaviour GRPC.Client.Adapter - @default_connect_opts [protocols: [:http2]] + @default_connect_opts [ + protocols: [:http2] + ] + @default_client_settings [ + initial_window_size: 8_000_000, + max_frame_size: 8_000_000 + ] @default_transport_opts [timeout: :infinity] + @doc """ + Connects using Mint based on the provided configs. Options + * `:transport_opts`: Defaults to `[timeout: :infinity]`, given the nature of H2 connections (with support to + long-lived streams) this default is set to avoid timeouts while waiting for server streams to complete. The other + options may vary based on the transport used for this connection (tcp or ssl). Check [Mint.HTTP.connect/4](https://hexdocs.pm/mint/Mint.HTTP.html#connect/4) + * `:client_settings`: Defaults to `[initial_window_size: 8_000_000, max_frame_size: 8_000_000]`, a larger default + window size ensures that the number of packages exchanges is smaller, thus speeding up the requests by reducing the + amount of networks round trip, with the cost of having larger packages reaching the server per connection. + Check [Mint.HTTP2.setting() type](https://hexdocs.pm/mint/Mint.HTTP2.html#t:setting/0) for additional configs. + """ @impl true def connect(%{host: host, port: port} = channel, opts \\ []) do - opts = Keyword.merge(@default_connect_opts, connect_opts(channel, opts)) + # Added :config_options to facilitate testing. + {config_opts, opts} = Keyword.pop(opts, :config_options, []) + module_opts = Application.get_env(:grpc, __MODULE__, config_opts) + + opts = connect_opts(channel, opts) |> merge_opts(module_opts) + Process.flag(:trap_exit, true) channel @@ -114,12 +135,27 @@ defmodule GRPC.Client.Adapters.Mint do |> Keyword.get(:transport_opts, []) |> Keyword.merge(ssl) - [transport_opts: Keyword.merge(@default_transport_opts, transport_opts)] + client_settings = Keyword.get(opts, :client_settings, @default_client_settings) + + [ + transport_opts: Keyword.merge(@default_transport_opts, transport_opts), + client_settings: client_settings + ] end defp connect_opts(_channel, opts) do transport_opts = Keyword.get(opts, :transport_opts, []) - [transport_opts: Keyword.merge(@default_transport_opts, transport_opts)] + client_settings = Keyword.get(opts, :client_settings, @default_client_settings) + + [ + transport_opts: Keyword.merge(@default_transport_opts, transport_opts), + client_settings: client_settings + ] + end + + defp merge_opts(opts, module_opts) do + opts = Keyword.merge(opts, module_opts) + Keyword.merge(@default_connect_opts, opts) end defp mint_scheme(%Channel{scheme: "https"} = _channel), do: :https diff --git a/lib/grpc/logger.ex b/lib/grpc/logger.ex new file mode 100644 index 00000000..eff6f27e --- /dev/null +++ b/lib/grpc/logger.ex @@ -0,0 +1,9 @@ +defmodule GRPC.Logger do + @doc """ + Normalizes the exception and stacktrace inputs by its kind to match the format specified for `crash_report` metadata + in [Logger](https://hexdocs.pm/logger/main/Logger.html#module-metadata) + """ + def crash_reason(:throw, reason, stacktrace), do: {{:nocatch, reason}, stacktrace} + def crash_reason(:error, reason, stack), do: {Exception.normalize(:error, reason, stack), stack} + def crash_reason(:exit, reason, stacktrace), do: {reason, stacktrace} +end diff --git a/lib/grpc/server.ex b/lib/grpc/server.ex index cdd51bc3..461d88e1 100644 --- a/lib/grpc/server.ex +++ b/lib/grpc/server.ex @@ -131,7 +131,7 @@ defmodule GRPC.Server do codecs = if http_transcode, do: [GRPC.Codec.JSON | codecs], else: codecs routes = - for {name, _, _, options} = rpc <- service_mod.__rpc_calls__, reduce: [] do + for {name, _, _, options} = rpc <- service_mod.__rpc_calls__(), reduce: [] do acc -> path = "/#{service_name}/#{name}" @@ -147,7 +147,7 @@ defmodule GRPC.Server do [{:grpc, path} | acc] end - Enum.each(service_mod.__rpc_calls__, fn {name, _, _, options} = rpc -> + Enum.each(service_mod.__rpc_calls__(), fn {name, _, _, options} = rpc -> func_name = name |> to_string |> Macro.underscore() |> String.to_atom() path = "/#{service_name}/#{name}" grpc_type = GRPC.Service.grpc_type(rpc) @@ -188,7 +188,7 @@ defmodule GRPC.Server do end end) - def __call_rpc__(_, stream) do + def __call_rpc__(_http_path, _http_method, stream) do raise GRPC.RPCError, status: :unimplemented end @@ -431,14 +431,6 @@ defmodule GRPC.Server do adapter.stop(endpoint, servers) end - @doc """ - DEPRECATED. Use `send_reply/3` instead - """ - @deprecated "Use send_reply/3 instead" - def stream_send(stream, reply) do - send_reply(stream, reply) - end - @doc """ Send streaming reply. diff --git a/lib/grpc/server/adapters/cowboy.ex b/lib/grpc/server/adapters/cowboy.ex index 216aa924..3395c4a8 100644 --- a/lib/grpc/server/adapters/cowboy.ex +++ b/lib/grpc/server/adapters/cowboy.ex @@ -7,9 +7,6 @@ defmodule GRPC.Server.Adapters.Cowboy do @behaviour GRPC.Server.Adapter - # ignore a specific warning generated by the case :ranch.child_spec call - @dialyzer {:no_match, child_spec: 4} - require Logger alias GRPC.Server.Adapters.Cowboy.Handler @@ -226,6 +223,8 @@ defmodule GRPC.Server.Adapters.Cowboy do end dispatch = GRPC.Server.Adapters.Cowboy.Router.compile([{:_, handlers}]) + dispatch_key = Module.concat(endpoint, Dispatch) + :persistent_term.put(dispatch_key, dispatch) idle_timeout = Keyword.get(opts, :idle_timeout) || :infinity num_acceptors = Keyword.get(opts, :num_acceptors) || @default_num_acceptors @@ -235,7 +234,7 @@ defmodule GRPC.Server.Adapters.Cowboy do opts = Map.merge( %{ - env: %{dispatch: dispatch}, + env: %{dispatch: {:persistent_term, dispatch_key}}, idle_timeout: idle_timeout, inactivity_timeout: idle_timeout, settings_timeout: idle_timeout, diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index c4189dc4..eb1e3d88 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -1,22 +1,54 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do - @moduledoc false - - # A cowboy handler accepting all requests and calls corresponding functions - # defined by users. + @moduledoc """ + A cowboy handler accepting all requests and calls corresponding functions defined by users. + """ + alias GRPC.Server.Adapters.ReportException alias GRPC.Transport.HTTP2 alias GRPC.RPCError require Logger + @behaviour :cowboy_loop + @adapter GRPC.Server.Adapters.Cowboy @default_trailers HTTP2.server_trailers() - @spec init( - map(), - state :: - {endpoint :: atom(), server :: {String.t(), module()}, route :: String.t(), - opts :: keyword()} - ) :: {:cowboy_loop, map(), map()} + @type init_state :: { + endpoint :: atom(), + server :: {name :: String.t(), module()}, + route :: String.t(), + opts :: keyword() + } + + @type pending_reader :: { + cowboy_read_ref :: reference, + server_rpc_pid :: pid, + server_rpc_reader_reference :: reference + } + @type stream_state :: %{ + pid: server_rpc_pid :: pid, + handling_timer: timeout_timer_ref :: reference, + pending_reader: nil | pending_reader + } + @type init_result :: + {:cowboy_loop, :cowboy_req.req(), stream_state} | {:ok, :cowboy_req.req(), init_state} + + @type is_fin :: :fin | :nofin + + @type stream_body_opts :: {:code, module()} | {:compress, boolean()} + + @type headers :: %{binary() => binary()} + + @doc """ + This function is meant to be called whenever a new request arrives to an existing connection. + This handler works mainly with two linked processes. + One of them is the process started by cowboy which internally we'll refer to it as `stream_pid`, + this process is responsible to interface the interactions with the open socket. + The second process is the one we start in this function, we'll refer to it as `server_rpc_pid`, + which is the point where we call the functions implemented by users (aka the modules who use + the `GRPC.Server` macro) + """ + @spec init(:cowboy_req.req(), state :: init_state) :: init_result def init(req, {endpoint, {_name, server}, route, opts} = state) do http_method = req @@ -27,11 +59,13 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do with {:ok, sub_type, content_type} <- find_content_type_subtype(req), {:ok, codec} <- find_codec(sub_type, content_type, server), {:ok, compressor} <- find_compressor(req, server) do + stream_pid = self() + stream = %GRPC.Server.Stream{ server: server, endpoint: endpoint, adapter: @adapter, - payload: %{pid: self()}, + payload: %{pid: stream_pid}, local: opts[:local], codec: codec, http_method: http_method, @@ -39,7 +73,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do http_transcode: transcode?(req) } - pid = spawn_link(__MODULE__, :call_rpc, [server, route, stream]) + server_rpc_pid = :proc_lib.spawn_link(__MODULE__, :call_rpc, [server, route, stream]) Process.flag(:trap_exit, true) req = :cowboy_req.set_resp_headers(HTTP2.server_headers(stream), req) @@ -55,7 +89,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do ) end - {:cowboy_loop, req, %{pid: pid, handling_timer: timer_ref, pending_reader: nil}} + {:cowboy_loop, req, %{pid: server_rpc_pid, handling_timer: timer_ref, pending_reader: nil}} else {:error, error} -> Logger.error(fn -> inspect(error) end) @@ -116,54 +150,126 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end # APIs begin + @doc """ + Synchronously reads the whole body content of a given request. + Raise in case of a timeout. + """ + @spec read_full_body(stream_pid :: pid) :: binary() def read_full_body(pid) do sync_call(pid, :read_full_body) end + @doc """ + Synchronously reads a chunk of body content of a given request. + Raise in case of a timeout. + """ + @spec read_body(stream_pid :: pid) :: binary() def read_body(pid) do sync_call(pid, :read_body) end - def stream_body(pid, data, opts, is_fin, http_transcode \\ false) do - send(pid, {:stream_body, data, opts, is_fin, http_transcode}) + @doc """ + Asynchronously send back to client a chunk of `data`, when `http_transcode?` is true, the + data is sent back as it's, with no transformation of protobuf binaries to http2 data frames. + """ + @spec stream_body( + stream_pid :: pid, + data :: iodata, + opts :: list(stream_body_opts), + is_fin, + http_transcode? :: boolean() + ) :: :ok + def stream_body(pid, data, opts, is_fin, http_transcode? \\ false) do + send(pid, {:stream_body, data, opts, is_fin, http_transcode?}) + :ok end + @doc """ + Asynchronously send back to the client the http status and the headers for a given request. + """ + @spec stream_reply(stream_pid :: pid, status :: non_neg_integer(), headers :: headers) :: :ok def stream_reply(pid, status, headers) do send(pid, {:stream_reply, status, headers}) + :ok end + @doc """ + Asynchronously set the headers for a given request. This function does not send any + data back to the client. It simply appends the headers to be used in the response. + """ + @spec set_resp_headers(stream_pid :: pid, headers :: headers) :: :ok def set_resp_headers(pid, headers) do send(pid, {:set_resp_headers, headers}) + :ok end + @doc """ + Asynchronously set the trailer headers for a given request. This function does not send any + data back to the client. It simply appends the trailer headers to be used in the response. + """ + @spec set_resp_trailers(stream_pid :: pid, trailers :: headers) :: :ok def set_resp_trailers(pid, trailers) do send(pid, {:set_resp_trailers, trailers}) + :ok end + @doc """ + Asynchronously set the compressor algorithm to be used for compress the responses. This checks if + the `grpc-accept-encoding` header is present on the original request, otherwise no compression + is applied. + """ + @spec set_compressor(stream_pid :: pid, compressor :: module) :: :ok def set_compressor(pid, compressor) do send(pid, {:set_compressor, compressor}) + :ok end + @doc """ + Asynchronously stream the given trailers of request back to client. + """ + @spec stream_trailers(stream_pid :: pid, trailers :: headers) :: :ok def stream_trailers(pid, trailers) do send(pid, {:stream_trailers, trailers}) + :ok end + @doc """ + Return all request headers. + """ + @spec get_headers(stream_pid :: pid) :: :cowboy.http_headers() def get_headers(pid) do sync_call(pid, :get_headers) end + @doc """ + Return the peer IP address and port number + """ + @spec get_peer(stream_pid :: pid) :: {:inet.ip_address(), :inet.port_number()} def get_peer(pid) do sync_call(pid, :get_peer) end + @doc """ + Return the client TLS certificate. `:undefined` is returned if no certificate was specified + when establishing the connection. + """ + @spec get_cert(stream_pid :: pid) :: binary() | :undefined def get_cert(pid) do sync_call(pid, :get_cert) end + @doc """ + Return the query string for the request URI. + """ + @spec get_qs(stream_pid :: pid) :: binary() def get_qs(pid) do sync_call(pid, :get_qs) end + @doc """ + Return all bindings of a given request. + """ + @spec get_bindings(stream_pid :: pid) :: :cowboy_router.bindings() def get_bindings(pid) do sync_call(pid, :get_bindings) end @@ -204,6 +310,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do def info({:request_body, ref, :nofin, body}, req, %{pending_reader: {ref, pid, reader_ref}} = s) do send(pid, {reader_ref, {:more, body}}) + {:ok, req, %{s | pending_reader: nil}} end @@ -343,33 +450,43 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end end - def info({:EXIT, pid, :normal}, req, state = %{pid: pid}) do - exit_handler(pid, :normal) + def info({:EXIT, server_rpc_pid, reason}, req, state = %{pid: server_rpc_pid}) + when reason in [:normal, :shutdown] do {:stop, req, state} end # expected error raised from user to return error immediately - def info({:EXIT, pid, {%RPCError{} = error, _stacktrace}}, req, state = %{pid: pid}) do + def info({:EXIT, pid, {%RPCError{} = error, stacktrace}}, req, state = %{pid: pid}) do req = send_error(req, error, state, :rpc_error) + + [req: req] + |> ReportException.new(error, stacktrace) + |> log_error(stacktrace) + {:stop, req, state} end # unknown error raised from rpc - def info({:EXIT, pid, {:handle_error, _kind}} = err, req, state = %{pid: pid}) do - Logger.warning("3. #{inspect(state)} #{inspect(err)}") + def info({:EXIT, pid, {:handle_error, error}}, req, state = %{pid: pid}) do + %{kind: kind, reason: reason, stack: stack} = error + rpc_error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"} + req = send_error(req, rpc_error, state, :error) - error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"} - req = send_error(req, error, state, :error) + [req: req] + |> ReportException.new(reason, stack, kind) + |> log_error(stack) {:stop, req, state} end def info({:EXIT, pid, {reason, stacktrace}}, req, state = %{pid: pid}) do - Logger.error(Exception.format(:error, reason, stacktrace)) - error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"} req = send_error(req, error, state, reason) + [req: req] + |> ReportException.new(reason, stacktrace) + |> log_error(stacktrace) + {:stop, req, state} end @@ -394,17 +511,17 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end catch kind, e -> - Logger.error(Exception.format(kind, e, __STACKTRACE__)) + reason = Exception.normalize(kind, e, __STACKTRACE__) - exit({:handle_error, kind}) + {:error, %{kind: kind, reason: reason, stack: __STACKTRACE__}} end case result do {:error, %GRPC.RPCError{} = e} -> - exit({e, ""}) + exit({e, _stacktrace = []}) - {:error, %{kind: kind}} -> - exit({:handle_error, kind}) + {:error, %{kind: _kind, reason: _reason, stack: _stack} = e} -> + exit({:handle_error, e}) other -> other @@ -542,4 +659,12 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do {:wait, ref} end + + defp log_error(%ReportException{kind: kind} = exception, stacktrace) do + crash_reason = GRPC.Logger.crash_reason(kind, exception, stacktrace) + + kind + |> Exception.format(exception, stacktrace) + |> Logger.error(crash_reason: crash_reason) + end end diff --git a/lib/grpc/server/adapters/cowboy/router.ex b/lib/grpc/server/adapters/cowboy/router.ex index 7180cd7a..2ed89b54 100644 --- a/lib/grpc/server/adapters/cowboy/router.ex +++ b/lib/grpc/server/adapters/cowboy/router.ex @@ -8,8 +8,6 @@ defmodule GRPC.Server.Adapters.Cowboy.Router do alias GRPC.Server.Router - @dialyzer {:nowarn_function, compile: 1} - def compile(routes) do for {host, paths} <- routes do [{host_match, _, _}] = :cowboy_router.compile([{host, []}]) diff --git a/lib/grpc/server/adapters/report_exception.ex b/lib/grpc/server/adapters/report_exception.ex new file mode 100644 index 00000000..8403514e --- /dev/null +++ b/lib/grpc/server/adapters/report_exception.ex @@ -0,0 +1,17 @@ +defmodule GRPC.Server.Adapters.ReportException do + @moduledoc """ + Exception raised by server adapter, meant to be used as part of metadata `crash_report` + """ + + defexception [:kind, :reason, :stack, :adapter_extra] + + def new(adapter_extra, %{__exception__: _} = exception, stack \\ [], kind \\ :error) do + exception(kind: kind, reason: exception, stack: stack, adapter_extra: adapter_extra) + end + + def message(%__MODULE__{adapter_extra: [{:req, req}], kind: kind, reason: reason, stack: stack}) do + # Cowboy adapter message builder + path = :cowboy_req.path(req) + "Exception raised while handling #{path}:\n" <> Exception.format_banner(kind, reason, stack) + end +end diff --git a/lib/grpc/stub.ex b/lib/grpc/stub.ex index b96ad51f..2c02c791 100644 --- a/lib/grpc/stub.ex +++ b/lib/grpc/stub.ex @@ -62,8 +62,8 @@ defmodule GRPC.Stub do service_mod = opts[:service] service_name = service_mod.__meta__(:name) - Enum.each(service_mod.__rpc_calls__, fn {name, {_, req_stream}, {_, res_stream}, _options} = - rpc -> + Enum.each(service_mod.__rpc_calls__(), fn {name, {_, req_stream}, {_, res_stream}, _options} = + rpc -> func_name = name |> to_string |> Macro.underscore() path = "/#{service_name}/#{name}" grpc_type = GRPC.Service.grpc_type(rpc) @@ -131,17 +131,62 @@ defmodule GRPC.Stub do """ @spec connect(String.t(), keyword()) :: {:ok, Channel.t()} | {:error, any()} def connect(addr, opts \\ []) when is_binary(addr) and is_list(opts) do - {host, port} = - case String.split(addr, ":") do - [host, port] -> {host, port} - [socket_path] -> {{:local, socket_path}, 0} - end + # This works because we only accept `http` and `https` schemes (allowlisted below explicitly) + # addresses like "localhost:1234" parse as if `localhost` is the scheme for URI, and this falls through to + # the base case. Accepting only `http/https` is a trait of `connect/3`. + + case URI.parse(addr) do + %URI{scheme: @secure_scheme, host: host, port: port} -> + opts = Keyword.put_new_lazy(opts, :cred, &default_ssl_option/0) + connect(host, port, opts) + + %URI{scheme: @insecure_scheme, host: host, port: port} -> + if opts[:cred] do + raise ArgumentError, "invalid option for insecure (http) address: :cred" + end + + connect(host, port, opts) + + # For compatibility with previous versions, we accept URIs in + # the "#{address}:#{port}" format + _ -> + case String.split(addr, ":") do + [socket_path] -> + connect({:local, socket_path}, 0, opts) + + [address, port] -> + port = String.to_integer(port) + connect(address, port, opts) + end + end + end - connect(host, port, opts) + if {:module, CAStore} == Code.ensure_loaded(CAStore) do + defp default_ssl_option do + %GRPC.Credential{ + ssl: [ + verify: :verify_peer, + depth: 99, + cacert_file: CAStore.file_path() + ] + } + end + else + defp default_ssl_option do + raise """ + no GRPC credentials provided. Please either: + + - Pass the `:cred` option to `GRPC.Stub.connect/2,3` + - Add `:castore` to your list of dependencies in `mix.exs` + """ + end end - @spec connect(String.t(), binary() | non_neg_integer(), keyword()) :: - {:ok, Channel.t()} | {:error, any()} + @spec connect( + String.t() | {:local, String.t()}, + binary() | non_neg_integer(), + keyword() + ) :: {:ok, Channel.t()} | {:error, any()} def connect(host, port, opts) when is_binary(port) do connect(host, String.to_integer(port), opts) end @@ -307,14 +352,6 @@ defmodule GRPC.Stub do next.(stream, req) end - @doc """ - DEPRECATED. Use `send_request/3` instead - """ - @deprecated "Use send_request/3 instead" - def stream_send(stream, request, opts \\ []) do - send_request(stream, request, opts) - end - @doc """ Send streaming requests. diff --git a/mix.exs b/mix.exs index 650a1a21..a3265990 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule GRPC.Mixfile do use Mix.Project - @version "0.7.0" + @version "0.8.1" def project do [ @@ -21,12 +21,6 @@ defmodule GRPC.Mixfile do source_ref: "v#{@version}", source_url: "https://github.com/elixir-grpc/grpc" ], - dialyzer: [ - plt_add_deps: :app_tree, - plt_add_apps: [:iex, :mix, :ex_unit], - list_unused_filters: true, - plt_file: {:no_warn, "_build/#{Mix.env()}/plts/dialyzer.plt"} - ], xref: [exclude: [IEx]] ] end @@ -45,6 +39,7 @@ defmodule GRPC.Mixfile do {:gun, "~> 2.0"}, {:jason, ">= 0.0.0", optional: true}, {:cowlib, "~> 2.12"}, + {:castore, "~> 0.1 or ~> 1.0", optional: true}, {:protobuf, "~> 0.11"}, {:protobuf_generate, "~> 0.1.1", only: [:dev, :test]}, {:googleapis, @@ -55,7 +50,6 @@ defmodule GRPC.Mixfile do only: [:dev, :test]}, {:mint, "~> 1.5"}, {:ex_doc, "~> 0.29", only: :dev}, - {:dialyxir, "~> 1.4.0", only: [:dev, :test], runtime: false}, {:ex_parameterized, "~> 1.3.7", only: :test}, {:telemetry, "~> 1.0"} ] @@ -63,10 +57,10 @@ defmodule GRPC.Mixfile do defp package do %{ - maintainers: ["Bing Han", "Paulo Valente"], + maintainers: ["Adriano Santos", "Dave Lucia", "Bing Han", "Paulo Valente"], licenses: ["Apache 2"], links: %{"GitHub" => "https://github.com/elixir-grpc/grpc"}, - files: ~w(mix.exs README.md lib src config LICENSE .formatter.exs) + files: ~w(mix.exs README.md lib src config priv/templates LICENSE .formatter.exs) } end diff --git a/mix.lock b/mix.lock index ff6e50e0..22d1fbca 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,8 @@ %{ + "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, "cowboy": {:hex, :cowboy, "2.11.0", "356bf784599cf6f2cdc6ad12fdcfb8413c2d35dab58404cf000e1feaed3f5645", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0fa395437f1b0e104e0e00999f39d2ac5f4082ac5049b67a5b6d56ecc31b1403"}, "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, - "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, "ex_parameterized": {:hex, :ex_parameterized, "1.3.7", "801f85fc4651cb51f11b9835864c6ed8c5e5d79b1253506b5bb5421e8ab2f050", [:mix], [], "hexpm", "1fb0dc4aa9e8c12ae23806d03bcd64a5a0fc9cd3f4c5602ba72561c9b54a625c"}, "googleapis": {:git, "https://github.com/googleapis/googleapis.git", "75c0ed03df97edf6b9c8191d9b61642863d00b61", [branch: "master"]}, diff --git a/test/grpc/channel_test.exs b/test/grpc/channel_test.exs index 098ea010..9e7d2e5f 100644 --- a/test/grpc/channel_test.exs +++ b/test/grpc/channel_test.exs @@ -3,20 +3,69 @@ defmodule GRPC.ChannelTest do alias GRPC.Test.ClientAdapter alias GRPC.Channel - test "connect/2 works for insecure" do - {:ok, channel} = GRPC.Stub.connect("10.1.0.0:50051", adapter: ClientAdapter) - assert %Channel{host: "10.1.0.0", port: 50051, scheme: "http", cred: nil} = channel - end + for {kind, addr} <- [{"ip", "10.0.0.1"}, {"hostname", "example.com"}] do + describe "connect/2 with http and #{kind}" do + test "works" do + {:ok, channel} = + GRPC.Stub.connect("http://#{unquote(addr)}:50051", adapter: ClientAdapter) + + assert %Channel{host: unquote(addr), port: 50051, scheme: "http", cred: nil} = channel + end + + test "errors if credential is provided" do + cred = %GRPC.Credential{ssl: []} + + assert_raise ArgumentError, "invalid option for insecure (http) address: :cred", fn -> + GRPC.Stub.connect("http://#{unquote(addr)}:50051", adapter: ClientAdapter, cred: cred) + end + end + end + + describe "connect/2 with https and #{kind}" do + test "sets default credential" do + {:ok, channel} = + GRPC.Stub.connect("https://#{unquote(addr)}:50051", adapter: ClientAdapter) + + assert %Channel{host: unquote(addr), port: 50051, scheme: "https", cred: cred} = channel + + assert Keyword.has_key?(cred.ssl, :verify) + assert Keyword.has_key?(cred.ssl, :depth) + assert Keyword.has_key?(cred.ssl, :cacert_file) + end + + test "allows overriding default credentials" do + cred = %GRPC.Credential{ssl: []} - test "connect/2 works for ssl" do - cred = %{ssl: []} - {:ok, channel} = GRPC.Stub.connect("10.1.0.0:50051", adapter: ClientAdapter, cred: cred) - assert %Channel{host: "10.1.0.0", port: 50051, scheme: "https", cred: ^cred} = channel + {:ok, channel} = + GRPC.Stub.connect("https://#{unquote(addr)}:50051", adapter: ClientAdapter, cred: cred) + + assert %Channel{host: unquote(addr), port: 50051, scheme: "https", cred: ^cred} = channel + end + end + + describe "connect/2 with no scheme, #{kind} and" do + test "no cred uses http" do + {:ok, channel} = GRPC.Stub.connect("#{unquote(addr)}:50051", adapter: ClientAdapter) + assert %Channel{host: unquote(addr), port: 50051, scheme: "http", cred: nil} = channel + end + + test "cred uses https" do + cred = %{ssl: []} + + {:ok, channel} = + GRPC.Stub.connect("#{unquote(addr)}:50051", adapter: ClientAdapter, cred: cred) + + assert %Channel{host: unquote(addr), port: 50051, scheme: "https", cred: ^cred} = channel + end + end end test "connect/2 allows setting default headers" do headers = [{"authorization", "Bearer TOKEN"}] - {:ok, channel} = GRPC.Stub.connect("10.1.0.0:50051", adapter: ClientAdapter, headers: headers) - assert %Channel{host: "10.1.0.0", port: 50051, headers: ^headers} = channel + + {:ok, channel} = + GRPC.Stub.connect("http://10.0.0.1:50051", adapter: ClientAdapter, headers: headers) + + assert %Channel{host: "10.0.0.1", port: 50051, headers: ^headers} = channel end end diff --git a/test/grpc/client/adapters/mint_test.exs b/test/grpc/client/adapters/mint_test.exs index 6b849be0..261235fd 100644 --- a/test/grpc/client/adapters/mint_test.exs +++ b/test/grpc/client/adapters/mint_test.exs @@ -15,14 +15,14 @@ defmodule GRPC.Client.Adapters.MintTest do end test "connects insecurely (default options)", %{port: port} do - channel = build(:channel, port: port, host: "localhost") + channel = build(:channel, adapter: Mint, port: port, host: "localhost") assert {:ok, result} = Mint.connect(channel, []) assert %{channel | adapter_payload: %{conn_pid: result.adapter_payload.conn_pid}} == result end test "connects insecurely (custom options)", %{port: port} do - channel = build(:channel, port: port, host: "localhost") + channel = build(:channel, adapter: Mint, port: port, host: "localhost") assert {:ok, result} = Mint.connect(channel, transport_opts: [ip: :loopback]) assert %{channel | adapter_payload: %{conn_pid: result.adapter_payload.conn_pid}} == result @@ -32,5 +32,51 @@ defmodule GRPC.Client.Adapters.MintTest do assert message == "Error while opening connection: {:error, :badarg}" end + + test "accepts config_options for application specific configuration", %{port: port} do + channel = build(:channel, adapter: Mint, port: port, host: "localhost") + + assert {:ok, result} = + Mint.connect(channel, config_options: [transport_opts: [ip: :loopback]]) + + assert %{channel | adapter_payload: %{conn_pid: result.adapter_payload.conn_pid}} == result + + # Ensure that changing one of the options via config_options also breaks things + assert {:error, message} = + Mint.connect(channel, config_options: [transport_opts: [ip: "256.0.0.0"]]) + + assert message == "Error while opening connection: {:error, :badarg}" + end + + test "defaults client settings when none is passed", %{port: port} do + channel = build(:channel, adapter: Mint, port: port, host: "localhost") + + assert {:ok, result} = Mint.connect(channel, []) + # wait for settings to be pushed + Process.sleep(50) + state = :sys.get_state(result.adapter_payload.conn_pid) + + assert %{initial_window_size: 8_000_000, max_frame_size: 8_000_000} = + Map.get(state.conn, :client_settings) + end + + test "allow client settings to be passed", %{port: port} do + channel = build(:channel, adapter: Mint, port: port, host: "localhost") + + assert {:ok, result} = + Mint.connect(channel, + client_settings: [ + initial_window_size: 50_000, + max_frame_size: 50_000 + ] + ) + + # wait for settings to be pushed + Process.sleep(50) + state = :sys.get_state(result.adapter_payload.conn_pid) + + assert %{initial_window_size: 50_000, max_frame_size: 50_000} = + Map.get(state.conn, :client_settings) + end end end diff --git a/test/grpc/integration/server_test.exs b/test/grpc/integration/server_test.exs index 1f1ef30f..6dd76900 100644 --- a/test/grpc/integration/server_test.exs +++ b/test/grpc/integration/server_test.exs @@ -7,6 +7,12 @@ defmodule GRPC.Integration.ServerTest do def get_feature(point, _stream) do %Routeguide.Feature{location: point, name: "#{point.latitude},#{point.longitude}"} end + + def route_chat(_ex_stream, stream) do + GRPC.Server.send_headers(stream, %{}) + Process.exit(self(), :shutdown) + Process.sleep(500) + end end defmodule TranscodeErrorServer do @@ -218,27 +224,37 @@ defmodule GRPC.Integration.ServerTest do end test "returns appropriate error for unary requests" do - run_server([HelloErrorServer], fn port -> - {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") - req = %Helloworld.HelloRequest{name: "Elixir"} - {:error, reply} = channel |> Helloworld.Greeter.Stub.say_hello(req) + logs = + ExUnit.CaptureLog.capture_log(fn -> + run_server([HelloErrorServer], fn port -> + {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") + req = %Helloworld.HelloRequest{name: "Elixir"} + {:error, reply} = channel |> Helloworld.Greeter.Stub.say_hello(req) + + assert %GRPC.RPCError{ + status: GRPC.Status.unauthenticated(), + message: "Please authenticate" + } == reply + end) + end) - assert %GRPC.RPCError{ - status: GRPC.Status.unauthenticated(), - message: "Please authenticate" - } == reply - end) + assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello" end test "return errors for unknown errors" do - run_server([HelloErrorServer], fn port -> - {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") - req = %Helloworld.HelloRequest{name: "unknown error"} + logs = + ExUnit.CaptureLog.capture_log(fn -> + run_server([HelloErrorServer], fn port -> + {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") + req = %Helloworld.HelloRequest{name: "unknown error"} + + assert {:error, + %GRPC.RPCError{message: "Internal Server Error", status: GRPC.Status.unknown()}} == + channel |> Helloworld.Greeter.Stub.say_hello(req) + end) + end) - assert {:error, - %GRPC.RPCError{message: "Internal Server Error", status: GRPC.Status.unknown()}} == - channel |> Helloworld.Greeter.Stub.say_hello(req) - end) + assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello" end test "returns appropriate error for stream requests" do @@ -320,6 +336,21 @@ defmodule GRPC.Integration.ServerTest do end) end + test "gracefully handles server shutdown disconnects" do + logs = + ExUnit.CaptureLog.capture_log(fn -> + run_server(FeatureServer, fn port -> + {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") + client_stream = Routeguide.RouteGuide.Stub.route_chat(channel) + assert %GRPC.Client.Stream{} = client_stream + {:ok, ex_stream} = GRPC.Stub.recv(client_stream, timeout: :infinity) + assert [{:error, %GRPC.RPCError{status: 13}}] = Enum.into(ex_stream, []) + end) + end) + + assert logs == "" + end + describe "http/json transcode" do test "grpc method can be called using json when http_transcode == true" do run_server([TranscodeServer], fn port -> diff --git a/test/grpc/integration/stub_test.exs b/test/grpc/integration/stub_test.exs index d24cdc68..8489a621 100644 --- a/test/grpc/integration/stub_test.exs +++ b/test/grpc/integration/stub_test.exs @@ -69,7 +69,7 @@ defmodule GRPC.Integration.StubTest do end test "invalid channel function clause error" do - req = Helloworld.HelloRequest.new(name: "GRPC") + req = %Helloworld.HelloRequest{name: "GRPC"} assert_raise FunctionClauseError, ~r/Helloworld.Greeter.Stub.say_hello/, fn -> Helloworld.Greeter.Stub.say_hello(nil, req) diff --git a/test/support/google/api/annotations.pb.ex b/test/support/google/api/annotations.pb.ex deleted file mode 100644 index 374877d3..00000000 --- a/test/support/google/api/annotations.pb.ex +++ /dev/null @@ -1,8 +0,0 @@ -defmodule Google.Api.PbExtension do - @moduledoc false - use Protobuf, protoc_gen_elixir_version: "0.11.0", syntax: :proto3 - - extend Google.Protobuf.MethodOptions, :http, 72_295_728, - optional: true, - type: Google.Api.HttpRule -end diff --git a/test/support/google/api/http.pb.ex b/test/support/google/api/http.pb.ex deleted file mode 100644 index ebd043a6..00000000 --- a/test/support/google/api/http.pb.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Google.Api.Http do - @moduledoc false - use Protobuf, protoc_gen_elixir_version: "0.11.0", syntax: :proto3 - - field :rules, 1, repeated: true, type: Google.Api.HttpRule - - field :fully_decode_reserved_expansion, 2, - type: :bool, - json_name: "fullyDecodeReservedExpansion" -end - -defmodule Google.Api.HttpRule do - @moduledoc false - use Protobuf, protoc_gen_elixir_version: "0.11.0", syntax: :proto3 - - oneof :pattern, 0 - - field :selector, 1, type: :string - field :get, 2, type: :string, oneof: 0 - field :put, 3, type: :string, oneof: 0 - field :post, 4, type: :string, oneof: 0 - field :delete, 5, type: :string, oneof: 0 - field :patch, 6, type: :string, oneof: 0 - field :custom, 8, type: Google.Api.CustomHttpPattern, oneof: 0 - field :body, 7, type: :string - field :response_body, 12, type: :string, json_name: "responseBody" - - field :additional_bindings, 11, - repeated: true, - type: Google.Api.HttpRule, - json_name: "additionalBindings" -end - -defmodule Google.Api.CustomHttpPattern do - @moduledoc false - use Protobuf, protoc_gen_elixir_version: "0.11.0", syntax: :proto3 - - field :kind, 1, type: :string - field :path, 2, type: :string -end