├── example ├── test │ ├── test_helper.exs │ └── calculator_test.exs ├── config │ └── config.exs ├── lib │ └── calculator │ │ ├── generated │ │ ├── service │ │ │ └── handler.ex │ │ ├── vector_product_type.ex │ │ ├── divide_by_zero_error.ex │ │ ├── vector_product_result.ex │ │ └── vector.ex │ │ ├── application.ex │ │ └── service_handler.ex ├── mix.exs ├── thrift │ └── calculator.thrift └── README.md ├── test ├── data │ ├── binary │ │ ├── containers │ │ │ ├── unset.thriftbin │ │ │ ├── empty_list.thriftbin │ │ │ ├── strings_set.thriftbin │ │ │ ├── enums_list.thriftbin │ │ │ ├── enums_map.thriftbin │ │ │ ├── structs_list.thriftbin │ │ │ └── structs_map.thriftbin │ │ ├── enums │ │ │ ├── banned.thriftbin │ │ │ └── evil.thriftbin │ │ ├── scalars │ │ │ ├── bool.thriftbin │ │ │ ├── byte.thriftbin │ │ │ ├── string.thriftbin │ │ │ ├── i16.thriftbin │ │ │ ├── i32.thriftbin │ │ │ ├── i64.thriftbin │ │ │ ├── binary.thriftbin │ │ │ └── double.thriftbin │ │ └── across │ │ │ └── across.thriftbin │ ├── README.md │ ├── generate.py │ └── test_data.py ├── fixtures │ └── app │ │ ├── thrift │ │ ├── numbers.thrift │ │ ├── include │ │ │ └── Include.thrift │ │ ├── StressTest.thrift │ │ └── AnnotationTest.thrift │ │ └── mix.exs ├── thrift │ ├── transport │ │ └── ssl_test.exs │ ├── generator │ │ ├── utils_test.exs │ │ └── behaviour_test.exs │ ├── parser │ │ ├── file_group_test.exs │ │ ├── annotation_test.exs │ │ ├── parse_error_test.exs │ │ └── resolver_test.exs │ ├── exceptions_test.exs │ └── binary │ │ └── framed │ │ ├── client_test.exs │ │ ├── ssl_test.exs │ │ └── server_test.exs ├── support │ └── lib │ │ ├── stub_stats.ex │ │ └── thrift_test_case.ex ├── test_helper.exs └── mix │ └── tasks │ ├── thrift.generate_test.exs │ └── compile.thrift_test.exs ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── .formatter.exs ├── coveralls.json ├── .gitattributes ├── ADOPTERS.md ├── .gitignore ├── lib ├── thrift │ ├── parser │ │ ├── literals.ex │ │ ├── types.ex │ │ ├── conversions.ex │ │ ├── resolver.ex │ │ └── file_group.ex │ ├── generator │ │ ├── constant_generator.ex │ │ ├── service.ex │ │ ├── enum_generator.ex │ │ ├── binary │ │ │ └── framed │ │ │ │ ├── client.ex │ │ │ │ └── server.ex │ │ ├── struct_generator.ex │ │ └── behaviour.ex │ ├── protocol │ │ ├── binary │ │ │ └── type.ex │ │ └── binary.ex │ ├── transport │ │ └── ssl.ex │ ├── exceptions.ex │ ├── binary │ │ └── framed │ │ │ └── server.ex │ ├── parser.ex │ └── generator.ex ├── mix │ └── tasks │ │ ├── thrift.generate.ex │ │ └── compile.thrift.ex └── thrift.ex ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── mix.exs ├── .credo.exs ├── src ├── thrift_lexer.xrl └── thrift_parser.yrl ├── mix.lock └── README.md /example/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/data/binary/containers/unset.thriftbin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/data/binary/enums/banned.thriftbin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /test/data/binary/enums/evil.thriftbin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /test/data/binary/scalars/bool.thriftbin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /test/data/binary/scalars/byte.thriftbin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /test/data/binary/containers/empty_list.thriftbin: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /test/data/binary/scalars/string.thriftbin: -------------------------------------------------------------------------------- 1 |  I am a string -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global 2 | * @fishcakez @jparise @scohen 3 | -------------------------------------------------------------------------------- /test/data/binary/containers/strings_set.thriftbin: -------------------------------------------------------------------------------- 1 |   pguilloryscohen -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{example,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /test/data/binary/containers/enums_list.thriftbin: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :calculator, 4 | port: 9090 5 | -------------------------------------------------------------------------------- /test/data/binary/scalars/i16.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/scalars/i16.thriftbin -------------------------------------------------------------------------------- /test/data/binary/scalars/i32.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/scalars/i32.thriftbin -------------------------------------------------------------------------------- /test/data/binary/scalars/i64.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/scalars/i64.thriftbin -------------------------------------------------------------------------------- /test/data/binary/across/across.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/across/across.thriftbin -------------------------------------------------------------------------------- /test/data/binary/scalars/binary.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/scalars/binary.thriftbin -------------------------------------------------------------------------------- /test/data/binary/scalars/double.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/scalars/double.thriftbin -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "src/thrift_lexer.erl", 4 | "src/thrift_parser.erl", 5 | "test/support" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /test/data/binary/containers/enums_map.thriftbin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinterest/elixir-thrift/HEAD/test/data/binary/containers/enums_map.thriftbin -------------------------------------------------------------------------------- /test/data/binary/containers/structs_list.thriftbin: -------------------------------------------------------------------------------- 1 |   2 |  scohen 3 |   pguillory 4 |   dantswain -------------------------------------------------------------------------------- /test/fixtures/app/thrift/numbers.thrift: -------------------------------------------------------------------------------- 1 | // making sure that a file of constants will compile 2 | namespace elixir Tutorial 3 | 4 | const i32 one = 1 5 | -------------------------------------------------------------------------------- /test/data/binary/containers/structs_map.thriftbin: -------------------------------------------------------------------------------- 1 |  scohen 2 |  scohen pguillory 3 |   pguillory dantswain 4 |   dantswain -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Static serialized thrift data 2 | *.thriftbin binary 3 | 4 | # Generated thrift files in example project 5 | /example/lib/calculator/generated/** linguist-generated=true 6 | -------------------------------------------------------------------------------- /ADOPTERS.md: -------------------------------------------------------------------------------- 1 | # Adopters 2 | 3 | This is an alphabetical list of people and organizations who are using this 4 | project. If you'd like to be included here, please send a Pull Request that 5 | adds your information to this file. 6 | 7 | - [Pinterest](https://www.pinterest.com/) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ranch 11 | versions: 12 | - ">= 2.a, < 3" 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /.elixir_ls 3 | /cover 4 | /deps 5 | /doc 6 | /example/_build 7 | /lib/generated 8 | /src/thrift_lexer.erl 9 | /src/thrift_parser.erl 10 | /test/fixtures/app/_build 11 | /test/fixtures/app/lib 12 | /tmp 13 | erl_crash.dump 14 | *.ez 15 | log.* 16 | *.log 17 | *.*~ 18 | .*~ 19 | -------------------------------------------------------------------------------- /test/fixtures/app/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule App.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :app, 7 | version: "1.0.0", 8 | thrift: [ 9 | files: Path.wildcard("thrift/*.thrift"), 10 | namespace: "Generated" 11 | ] 12 | ] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/thrift/parser/literals.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.Literals do 2 | @moduledoc false 3 | 4 | defmodule Primitive do 5 | @moduledoc false 6 | @type t :: integer | boolean | String.t() | float 7 | end 8 | 9 | defmodule List do 10 | @moduledoc false 11 | @type t :: [Thrift.Parser.Literals.t()] 12 | end 13 | 14 | defmodule Map do 15 | @moduledoc false 16 | @type t :: %{Thrift.Parser.Literals.t() => Thrift.Parser.Literals.t()} 17 | end 18 | 19 | defmodule Container do 20 | @moduledoc false 21 | @type t :: Map.t() | List.t() 22 | end 23 | 24 | @type t :: Container.t() | Primitive.t() 25 | @type s :: atom 26 | end 27 | -------------------------------------------------------------------------------- /test/thrift/transport/ssl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Transport.SSLTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Thrift.Transport.SSL 5 | 6 | describe "configuration/1" do 7 | test "handles configure/0 errors" do 8 | error = RuntimeError.exception("test") 9 | 10 | assert {:error, error} == 11 | SSL.configuration(enabled: true, configure: fn -> {:error, error} end) 12 | end 13 | 14 | test "it properly handles the :optional flag" do 15 | assert {:optional, []} == SSL.configuration(enabled: true, optional: true) 16 | assert {:required, []} == SSL.configuration(enabled: true, optional: false) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/lib/calculator/generated/service/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule(Calculator.Generated.Service.Handler) do 2 | @moduledoc false 3 | ( 4 | @callback add(left :: Thrift.i64(), right :: Thrift.i64()) :: Thrift.i64() 5 | @callback divide(left :: Thrift.i64(), right :: Thrift.i64()) :: Thrift.i64() 6 | @callback multiply(left :: Thrift.i64(), right :: Thrift.i64()) :: Thrift.i64() 7 | @callback subtract(left :: Thrift.i64(), right :: Thrift.i64()) :: Thrift.i64() 8 | @callback vector_product( 9 | left :: %Calculator.Generated.Vector{}, 10 | right :: %Calculator.Generated.Vector{}, 11 | type :: non_neg_integer 12 | ) :: %Calculator.Generated.VectorProductResult{} 13 | ) 14 | end 15 | -------------------------------------------------------------------------------- /lib/thrift/generator/constant_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.ConstantGenerator do 2 | @moduledoc false 3 | 4 | alias Thrift.AST.{Constant, Schema} 5 | alias Thrift.Generator.Utils 6 | 7 | @spec generate(atom, [Constant.t()], Schema.t()) :: Macro.t() 8 | def generate(full_name, constants, schema) do 9 | macro_defs = 10 | Enum.map(constants, fn constant -> 11 | name = Utils.underscore(constant.name) 12 | value = Utils.quote_value(constant.value, constant.type, schema) 13 | 14 | quote do 15 | defmacro unquote(Macro.var(name, nil)), do: Macro.escape(unquote(value)) 16 | end 17 | end) 18 | 19 | quote do 20 | defmodule unquote(full_name) do 21 | (unquote_splicing(macro_defs)) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/data/README.md: -------------------------------------------------------------------------------- 1 | # Static test data 2 | 3 | This directory contains serialized thrift binary data that the tests use for 4 | validation. 5 | 6 | ## Generating test data 7 | 8 | To generate the test data, you will need to have Python 3.5+ and `thrift` 9 | installed. You should create a virtual environment and install the Python 10 | thrift library, after which you can run the `generate.py` script. Here is a 11 | full example, which you may need to alter depending on your setup: 12 | 13 | python3 -m venv .venv 14 | source .venv/bin/activate 15 | pip install thrift 16 | ./generate.py 17 | 18 | ## Adding new test data 19 | 20 | The file `test_data.py` contains a `write()` method, which creates thrift 21 | structures and serializes them to disk. Any new serialization/deserialization 22 | test cases should have corresponding entries added in here. 23 | -------------------------------------------------------------------------------- /test/thrift/generator/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.UtilsTest do 2 | use ExUnit.Case, async: true 3 | import Thrift.Generator.Utils 4 | 5 | defmacro check(input, expected_output) do 6 | input_source = Macro.to_string(optimize_iolist(input)) 7 | expected_output_source = Macro.to_string(expected_output) 8 | assert input_source == expected_output_source 9 | end 10 | 11 | test "optimize_iolist" do 12 | check(<<0>>, <<0>>) 13 | check([<<0>>], <<0>>) 14 | check([<<1>>, <<2>>], <<1, 2>>) 15 | check([<<1>>, [<<2>>]], <<1, 2>>) 16 | check([[<<1>>], <<2>>], <<1, 2>>) 17 | check([[[[<<1>>]], [<<2>>]]], <<1, 2>>) 18 | check([<<1>>, x, [<<2>>, y]], [<<1>>, x, <<2>> | y]) 19 | check([x, <<1>>, [<<2>>, y]], [x, <<1, 2>> | y]) 20 | check([<<1, 2>>, <<0>>], <<1, 2, 0>>) 21 | check([<<1, 2>>, "foo"], <<1, 2, "foo">>) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Calculator.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :calculator, 7 | version: "0.1.0", 8 | elixir: "~> 1.5", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | deps_path: "../deps", 12 | lockfile: "../mix.lock", 13 | 14 | # Thrift configuration 15 | compilers: [:thrift | Mix.compilers()], 16 | thrift: [ 17 | files: Path.wildcard("thrift/*.thrift"), 18 | output_path: "lib/" 19 | ] 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | extra_applications: [:logger], 26 | mod: {Calculator.Application, []} 27 | ] 28 | end 29 | 30 | defp deps do 31 | [ 32 | # Note: you will want to replace the next line to get elixir-thrift from either 33 | # Hex or GitHub in your own project. 34 | {:thrift, path: ".."} 35 | ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/thrift/parser/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.Types do 2 | @moduledoc false 3 | 4 | defmodule Primitive do 5 | @moduledoc false 6 | @type t :: :bool | :i8 | :i16 | :i32 | :i64 | :binary | :double | :byte | :string 7 | end 8 | 9 | defmodule Ident do 10 | @moduledoc false 11 | @type t :: String.t() 12 | end 13 | 14 | defmodule Standalone do 15 | @moduledoc false 16 | @type t :: Ident.t() | Primitive.t() 17 | end 18 | 19 | defmodule List do 20 | @moduledoc false 21 | @type t :: {:list, Thrift.Parser.Types.t()} 22 | end 23 | 24 | defmodule Map do 25 | @moduledoc false 26 | @type t :: {:map, {Thrift.Parser.Types.t(), Thrift.Parser.Types.t()}} 27 | end 28 | 29 | defmodule Set do 30 | @moduledoc false 31 | @type t :: {:set, Thrift.Parser.Types.t()} 32 | end 33 | 34 | defmodule Container do 35 | @moduledoc false 36 | @type t :: List.t() | Map.t() | Set.t() 37 | end 38 | 39 | @type t :: Container.t() | Standalone.t() 40 | end 41 | -------------------------------------------------------------------------------- /lib/thrift/protocol/binary/type.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Protocol.Binary.Type do 2 | @moduledoc false 3 | 4 | @typedoc "Binary protocol field type identifier" 5 | @type t :: 2 | 3 | 4 | 6 | 8 | 10 | 11 | 12 | 13 | 14 | 15 6 | 7 | defmacro bool, do: 2 8 | defmacro byte, do: 3 9 | defmacro double, do: 4 10 | defmacro i8, do: 3 11 | defmacro i16, do: 6 12 | defmacro i32, do: 8 13 | defmacro i64, do: 10 14 | defmacro string, do: 11 15 | defmacro struct, do: 12 16 | defmacro map, do: 13 17 | defmacro set, do: 14 18 | defmacro list, do: 15 19 | 20 | @spec of(Thrift.data_type()) :: t 21 | def of(:bool), do: bool() 22 | def of(:byte), do: byte() 23 | def of(:i8), do: i8() 24 | def of(:i16), do: i16() 25 | def of(:i32), do: i32() 26 | def of(:i64), do: i64() 27 | def of(:double), do: double() 28 | def of(:binary), do: string() 29 | def of(:string), do: string() 30 | def of({:map, _}), do: map() 31 | def of({:set, _}), do: set() 32 | def of({:list, _}), do: list() 33 | end 34 | -------------------------------------------------------------------------------- /test/thrift/parser/file_group_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.FileGroupTest do 2 | use ExUnit.Case 3 | use ThriftTestHelpers 4 | 5 | alias Thrift.AST.Constant 6 | alias Thrift.Parser.FileGroup 7 | 8 | test "constant module uses suitable existing name" do 9 | with_thrift_files( 10 | "myservice.thrift": """ 11 | const double PI = 3.14 12 | service MyService {} 13 | """, 14 | as: :file_group, 15 | parse: "myservice.thrift" 16 | ) do 17 | assert :"Elixir.MyService" == FileGroup.dest_module(file_group, Constant) 18 | end 19 | end 20 | 21 | test "destination module supports input names in various casings" do 22 | file_group = FileGroup.new("casing.thrift") 23 | assert :"Elixir.UPPERCASE" == FileGroup.dest_module(file_group, :"module.UPPERCASE") 24 | assert :"Elixir.Lowercase" == FileGroup.dest_module(file_group, :"module.lowercase") 25 | assert :"Elixir.CamelCase" == FileGroup.dest_module(file_group, :"module.CamelCase") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /example/thrift/calculator.thrift: -------------------------------------------------------------------------------- 1 | #@namespace elixir Calculator.Generated 2 | 3 | exception DivideByZeroError { 4 | 1: string message 5 | } 6 | 7 | struct Vector { 8 | 1: double x = 0.0, 9 | 2: double y = 0.0, 10 | 3: double z = 0.0, 11 | } 12 | 13 | enum VectorProductType { 14 | DOT_PRODUCT = 1, 15 | CROSS_PRODUCT = 2, 16 | } 17 | 18 | union VectorProductResult { 19 | 1: double scalar, 20 | 2: Vector vector, 21 | } 22 | 23 | service Service { 24 | # Adds two integers 25 | i64 add(1: i64 left, 2: i64 right), 26 | 27 | # Subtracts two integers 28 | i64 subtract(1: i64 left, 2: i64 right), 29 | 30 | # Multiplies two integers 31 | i64 multiply(1: i64 left, 2: i64 right), 32 | 33 | # Divides two integers, throwing an exception for zero division 34 | i64 divide(1: i64 left, 2: i64 right) throws (1: DivideByZeroError e), 35 | 36 | # Can perform dot products and cross products of vectors 37 | VectorProductResult vectorProduct(1: Vector left, 2: Vector right, 3: VectorProductType type) 38 | } 39 | -------------------------------------------------------------------------------- /test/fixtures/app/thrift/include/Include.thrift: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | include "ThriftTest.thrift" 21 | 22 | namespace elixir Include 23 | 24 | struct IncludeTest { 25 | 1: required ThriftTest.Bools bools 26 | } 27 | -------------------------------------------------------------------------------- /example/lib/calculator/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Calculator.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | alias Calculator.Generated.Service.Binary.Framed.Server 6 | 7 | def start(_type, _args) do 8 | port = Application.get_env(:calculator, :port, 9090) 9 | 10 | children = [ 11 | server_child_spec(port) 12 | ] 13 | 14 | opts = [strategy: :one_for_one, name: Calculator.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | 18 | # Note that when adding a server to your supervision tree, you should always ensure to start 19 | # it as a supervisor instead of a worker. Also, you may be tempted to create a pool of server 20 | # processes, however this is not needed. Instead, you can configure the `:worker_count` 21 | # option in the server. See the docs for `Server.start_link/4` for more details. 22 | defp server_child_spec(port) do 23 | %{ 24 | id: Server, 25 | start: {Server, :start_link, [Calculator.ServiceHandler, port]}, 26 | type: :supervisor 27 | } 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | The [`example/`][ex] directory of elixir-thrift contains an implementation of a simple calculator service. It serves two purposes: 4 | 5 | 1. How to use this library. 6 | 2. Example of what elixir-thrift generated code looks like. 7 | 8 | [ex]: https://github.com/pinterest/elixir-thrift/tree/master/example 9 | 10 | ## Calculator Service 11 | 12 | The service is very simple, and implements the four basic arithmetic operations: addition, subtraction, multiplication, and division. It demonstrates [how to add a Thrift server to a supervision tree][supervisor], and [how to write a handler][handler] for a [service defined in Thrift][thrift-defs]. It also demonstrates how to use exceptions by implementing a division-by-zero exception. 13 | 14 | For client usage, you should consult the [test cases][tests] to see how to make requests to a Thrift service. 15 | 16 | [handler]: https://github.com/pinterest/elixir-thrift/tree/master/example/lib/calculator/service_handler.ex 17 | [supervisor]: https://github.com/pinterest/elixir-thrift/tree/master/example/lib/calculator/application.ex 18 | [tests]: https://github.com/pinterest/elixir-thrift/tree/master/example/test/calculator_test.exs 19 | [thrift-defs]: https://github.com/pinterest/elixir-thrift/tree/master/example/thrift/calculator.thrift 20 | -------------------------------------------------------------------------------- /test/fixtures/app/thrift/StressTest.thrift: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | namespace cpp test.stress 21 | namespace d thrift.test.stress 22 | namespace go stress 23 | 24 | service Service { 25 | 26 | void echoVoid(), 27 | byte echoByte(1: byte arg), 28 | i32 echoI32(1: i32 arg), 29 | i64 echoI64(1: i64 arg), 30 | string echoString(1: string arg), 31 | list echoList(1: list arg), 32 | set echoSet(1: set arg), 33 | map echoMap(1: map arg), 34 | } 35 | 36 | -------------------------------------------------------------------------------- /example/lib/calculator/generated/vector_product_type.ex: -------------------------------------------------------------------------------- 1 | defmodule(Calculator.Generated.VectorProductType) do 2 | @moduledoc false 3 | defmacro(unquote(:dot_product)()) do 4 | 1 5 | end 6 | 7 | defmacro(unquote(:cross_product)()) do 8 | 2 9 | end 10 | 11 | def(value_to_name(v)) do 12 | case(v) do 13 | 1 -> 14 | {:ok, :dot_product} 15 | 16 | 2 -> 17 | {:ok, :cross_product} 18 | 19 | _ -> 20 | {:error, {:invalid_enum_value, v}} 21 | end 22 | end 23 | 24 | def(name_to_value(k)) do 25 | case(k) do 26 | :dot_product -> 27 | {:ok, 1} 28 | 29 | :cross_product -> 30 | {:ok, 2} 31 | 32 | _ -> 33 | {:error, {:invalid_enum_name, k}} 34 | end 35 | end 36 | 37 | def(value_to_name!(value)) do 38 | {:ok, name} = value_to_name(value) 39 | name 40 | end 41 | 42 | def(name_to_value!(name)) do 43 | {:ok, value} = name_to_value(name) 44 | value 45 | end 46 | 47 | def(meta(:names)) do 48 | [:dot_product, :cross_product] 49 | end 50 | 51 | def(meta(:values)) do 52 | [1, 2] 53 | end 54 | 55 | def(member?(v)) do 56 | case(v) do 57 | 1 -> 58 | true 59 | 60 | 2 -> 61 | true 62 | 63 | _ -> 64 | false 65 | end 66 | end 67 | 68 | def(name?(k)) do 69 | case(k) do 70 | :dot_product -> 71 | true 72 | 73 | :cross_product -> 74 | true 75 | 76 | _ -> 77 | false 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/thrift/parser/conversions.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.Conversions do 2 | @moduledoc false 3 | 4 | # convert a charlist to a snake_case atom 5 | # e.g., 'FooBar', 'foo_bar', 'fooBar', and 'FOO_BAR' 6 | # should all produce :foo_bar 7 | @spec atomic_snake(charlist | nil) :: atom 8 | def atomic_snake(nil), do: nil 9 | 10 | def atomic_snake(l) when is_list(l) do 11 | l 12 | |> List.to_string() 13 | |> String.split("_") 14 | |> Enum.map(&Macro.underscore/1) 15 | |> Enum.join("_") 16 | |> String.to_atom() 17 | end 18 | 19 | @spec cast(Thrift.data_type(), any) :: any 20 | def cast(_, nil) do 21 | nil 22 | end 23 | 24 | # We can't match a TypeRef because it would create a circular dependency. 25 | def cast(_, %{referenced_type: _} = ref) do 26 | ref 27 | end 28 | 29 | def cast(_, %{referenced_value: _} = ref) do 30 | ref 31 | end 32 | 33 | def cast(:bool, 0), do: false 34 | def cast(:bool, 1), do: true 35 | 36 | def cast(:string, val) when is_list(val) do 37 | List.to_string(val) 38 | end 39 | 40 | def cast({:set, type}, val) do 41 | MapSet.new(val, &cast(type, &1)) 42 | end 43 | 44 | def cast({:map, {key_type, val_type}}, val) do 45 | Enum.into(val, %{}, fn {k, v} -> 46 | {cast(key_type, k), cast(val_type, v)} 47 | end) 48 | end 49 | 50 | def cast({:list, elem_type}, val) do 51 | Enum.map(val, fn elem -> 52 | cast(elem_type, elem) 53 | end) 54 | end 55 | 56 | def cast(_, val) do 57 | val 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /example/lib/calculator/service_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Calculator.ServiceHandler do 2 | @moduledoc false 3 | @behaviour Calculator.Generated.Service.Handler 4 | 5 | alias Calculator.Generated.Vector 6 | alias Calculator.Generated.VectorProductResult 7 | alias Calculator.Generated.VectorProductType 8 | 9 | @impl true 10 | def add(left, right) do 11 | left + right 12 | end 13 | 14 | @impl true 15 | def subtract(left, right) do 16 | left - right 17 | end 18 | 19 | @impl true 20 | def multiply(left, right) do 21 | left * right 22 | end 23 | 24 | @impl true 25 | def divide(_left, 0) do 26 | raise Calculator.Generated.DivideByZeroError, message: "Cannot divide by zero" 27 | end 28 | 29 | def divide(left, right) do 30 | div(left, right) 31 | end 32 | 33 | @impl true 34 | def vector_product(left, right, type) do 35 | case VectorProductType.value_to_name!(type) do 36 | :dot_product -> 37 | %VectorProductResult{scalar: dot_product(left, right)} 38 | 39 | :cross_product -> 40 | %VectorProductResult{vector: cross_product(left, right)} 41 | end 42 | end 43 | 44 | defp dot_product(%Vector{} = left, %Vector{} = right) do 45 | left.x * right.x + left.y * right.y + left.z * right.z 46 | end 47 | 48 | defp cross_product(%Vector{} = left, %Vector{} = right) do 49 | %Vector{ 50 | x: left.y * right.z - left.z * right.y, 51 | y: left.z * right.x - left.x * right.z, 52 | z: left.x * right.y - left.y * right.x 53 | } 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | At Pinterest, we work hard to ensure that our work environment is welcoming 4 | and inclusive to as many people as possible. We are committed to creating this 5 | environment for everyone involved in our open source projects as well. We 6 | welcome all participants regardless of ability, age, ethnicity, identified 7 | gender, religion (or lack there of), sexual orientation and socioeconomic 8 | status. 9 | 10 | This code of conduct details our expectations for upholding these values. 11 | 12 | ## Good behavior 13 | 14 | We expect members of our community to exhibit good behavior including (but of 15 | course not limited to): 16 | 17 | - Using intentional and empathetic language. 18 | - Focusing on resolving instead of escalating conflict. 19 | - Providing constructive feedback. 20 | 21 | ## Unacceptable behavior 22 | 23 | Some examples of unacceptable behavior (again, this is not an exhaustive 24 | list): 25 | 26 | - Harassment, publicly or in private. 27 | - Trolling. 28 | - Sexual advances (this isn’t the place for it). 29 | - Publishing other’s personal information. 30 | - Any behavior which would be deemed unacceptable in a professional environment. 31 | 32 | ## Recourse 33 | 34 | If you are witness to or the target of unacceptable behavior, it should be 35 | reported to Pinterest at opensource-policy@pinterest.com. All reporters will 36 | be kept confidential and an appropriate response for each incident will be 37 | evaluated. 38 | 39 | If the elixir-thrift maintainers do not uphold and enforce this code of 40 | conduct in good faith, community leadership will hold them accountable. 41 | -------------------------------------------------------------------------------- /lib/thrift/parser/resolver.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.Resolver do 2 | @moduledoc false 3 | 4 | # A resolver for references. During file parsing, all new generated thrift 5 | # concepts flow through this resolver and are added to its global database 6 | # of names. At the end, the database is dumped into the FileGroup so it can 7 | # resolve references. 8 | 9 | alias Thrift.AST.TEnum 10 | 11 | def add(state, name, schema) do 12 | state 13 | |> update(name, schema.constants) 14 | |> update(name, schema.services) 15 | |> update(name, schema.structs) 16 | |> update(name, schema.exceptions) 17 | |> update(name, schema.unions) 18 | |> update(name, schema.enums) 19 | |> update(name, schema.typedefs) 20 | end 21 | 22 | defp update(%{} = resolutions, include_name, %{} = local_mappings) do 23 | new_type_mappings = 24 | Map.new(local_mappings, fn 25 | {name, val} when is_atom(val) or is_tuple(val) -> 26 | {:"#{include_name}.#{name}", val} 27 | 28 | {name, val} when is_map(val) -> 29 | {:"#{include_name}.#{name}", Map.put(val, :name, :"#{include_name}.#{name}")} 30 | end) 31 | 32 | new_value_mappings = 33 | Enum.reduce(local_mappings, %{}, fn 34 | {_, %TEnum{name: enum_name, values: values}}, acc -> 35 | Enum.reduce(values, acc, fn 36 | {value_name, value}, acc -> 37 | Map.put(acc, :"#{enum_name}.#{value_name}", value) 38 | end) 39 | 40 | _, acc -> 41 | acc 42 | end) 43 | 44 | resolutions 45 | |> Map.merge(new_type_mappings) 46 | |> Map.merge(new_value_mappings) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /example/lib/calculator/generated/divide_by_zero_error.ex: -------------------------------------------------------------------------------- 1 | defmodule(Calculator.Generated.DivideByZeroError) do 2 | @moduledoc false 3 | _ = "Auto-generated Thrift exception calculator.DivideByZeroError" 4 | _ = "1: string message" 5 | defexception(message: nil) 6 | @type t :: %__MODULE__{} 7 | def(new) do 8 | %__MODULE__{} 9 | end 10 | 11 | defmodule(BinaryProtocol) do 12 | @moduledoc false 13 | def(deserialize(binary)) do 14 | deserialize(binary, %Calculator.Generated.DivideByZeroError{}) 15 | end 16 | 17 | defp(deserialize(<<0, rest::binary>>, %Calculator.Generated.DivideByZeroError{} = acc)) do 18 | {acc, rest} 19 | end 20 | 21 | defp( 22 | deserialize( 23 | <<11, 1::16-signed, string_size::32-signed, value::binary-size(string_size), 24 | rest::binary>>, 25 | acc 26 | ) 27 | ) do 28 | deserialize(rest, %{acc | message: value}) 29 | end 30 | 31 | defp(deserialize(<>, acc)) do 32 | rest |> Thrift.Protocol.Binary.skip_field(field_type) |> deserialize(acc) 33 | end 34 | 35 | defp(deserialize(_, _)) do 36 | :error 37 | end 38 | 39 | def(serialize(%Calculator.Generated.DivideByZeroError{message: message})) do 40 | [ 41 | case(message) do 42 | nil -> 43 | <<>> 44 | 45 | _ -> 46 | [<<11, 1::16-signed, byte_size(message)::32-signed>> | message] 47 | end 48 | | <<0>> 49 | ] 50 | end 51 | end 52 | 53 | def(serialize(struct)) do 54 | BinaryProtocol.serialize(struct) 55 | end 56 | 57 | def(serialize(struct, :binary)) do 58 | BinaryProtocol.serialize(struct) 59 | end 60 | 61 | def(deserialize(binary)) do 62 | BinaryProtocol.deserialize(binary) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/data/generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Generates static test data. 3 | 4 | See `README.md` for instructions on running this script. 5 | """ 6 | from glob import iglob 7 | import os 8 | import re 9 | import shutil 10 | import subprocess 11 | import sys 12 | import test_data 13 | 14 | def mkdir(dir): 15 | try: 16 | os.mkdir(dir) 17 | except FileExistsError: 18 | pass 19 | 20 | 21 | def find_thrift_definitions(): 22 | thrift_definitions = [] 23 | for filename in iglob('../**/*.exs', recursive=True): 24 | with open(filename, 'r') as f: 25 | file_contents = f.read() 26 | results = re.findall(r'@thrift_file name: "(.+?)", contents: """(.*?)"""', file_contents, re.DOTALL) 27 | thrift_definitions.extend(results) 28 | return thrift_definitions 29 | 30 | 31 | def write_thrift_files(thrift_definitions): 32 | mkdir('thrift') 33 | thrift_files = [] 34 | for filename, definition in thrift_definitions: 35 | full_path = os.path.join('thrift', filename) 36 | with open(full_path, 'w') as f: 37 | f.write(definition) 38 | thrift_files.append(full_path) 39 | return thrift_files 40 | 41 | 42 | def generate_python_files(thrift_files): 43 | mkdir('generated') 44 | for thrift_file in thrift_files: 45 | command = 'thrift -I thrift --gen py --out generated'.split() + [thrift_file] 46 | subprocess.run(command) 47 | sys.path.append('generated') 48 | 49 | 50 | def cleanup(): 51 | shutil.rmtree('thrift', ignore_errors=True) 52 | shutil.rmtree('generated', ignore_errors=True) 53 | 54 | 55 | def main(): 56 | try: 57 | thrift_definitions = find_thrift_definitions() 58 | thrift_files = write_thrift_files(thrift_definitions) 59 | generate_python_files(thrift_files) 60 | test_data.write() 61 | finally: 62 | cleanup() 63 | 64 | 65 | if __name__ == '__main__': 66 | main() 67 | -------------------------------------------------------------------------------- /test/support/lib/stub_stats.ex: -------------------------------------------------------------------------------- 1 | defmodule StubStats do 2 | use GenServer 3 | 4 | defmacro __using__(_) do 5 | quote do 6 | import unquote(__MODULE__), only: [stats: 1, reset_stats: 0] 7 | end 8 | end 9 | 10 | def start_link(opts \\ []) do 11 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 12 | end 13 | 14 | def stats(metric_name) do 15 | GenServer.call(__MODULE__, {:stats, metric_name}) 16 | end 17 | 18 | def reset_stats do 19 | GenServer.call(__MODULE__, :reset_stats) 20 | end 21 | 22 | def init(opts) do 23 | handler_module = Keyword.fetch!(opts, :handler_module) 24 | 25 | events = [ 26 | [Thrift, handler_module, :peek_first_byte], 27 | [Thrift, handler_module, :ssl_handshake], 28 | [Thrift, handler_module, :receive_message], 29 | [Thrift, handler_module, :call], 30 | [Thrift, handler_module, :send_reply], 31 | [Thrift, handler_module, :request_size], 32 | [Thrift, handler_module, :response_size] 33 | ] 34 | 35 | callback = &GenServer.call(__MODULE__, {:metric, &1, &2, &3, &4}) 36 | 37 | :ok = :telemetry.attach_many(inspect(self()), events, callback, nil) 38 | {:ok, []} 39 | end 40 | 41 | def terminate(reason, _state) do 42 | IO.inspect(reason, label: "StubStats terminate reason") 43 | :telemetry.detach(inspect(self())) 44 | end 45 | 46 | def handle_call({:metric, event, _measurements, metadata, nil}, _, state) do 47 | [Thrift, _handler_module, metric_name] = event 48 | state = [{metric_name, metadata} | state] 49 | {:reply, :ok, state} 50 | end 51 | 52 | def handle_call({:stats, metric_name}, _, state) do 53 | metadatas = 54 | state 55 | |> Enum.flat_map(fn 56 | {^metric_name, metadata} -> [metadata] 57 | {_, _} -> [] 58 | end) 59 | |> Enum.reverse() 60 | 61 | {:reply, metadatas, state} 62 | end 63 | 64 | def handle_call(:reset_stats, _, _) do 65 | {:reply, :ok, []} 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/fixtures/app/thrift/AnnotationTest.thrift: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one 3 | * or more contributor license agreements. See the NOTICE file 4 | * distributed with this work for additional information 5 | * regarding copyright ownership. The ASF licenses this file 6 | * to you under the Apache License, Version 2.0 (the 7 | * "License"); you may not use this file except in compliance 8 | * with the License. You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | typedef list ( cpp.template = "std::list" ) int_linked_list 21 | 22 | struct foo { 23 | 1: i32 bar ( presence = "required" ); 24 | 2: i32 baz ( presence = "manual", cpp.use_pointer = "", ); 25 | 3: i32 qux; 26 | 4: i32 bop; 27 | } ( 28 | cpp.type = "DenseFoo", 29 | python.type = "DenseFoo", 30 | java.final = "", 31 | annotation.without.value, 32 | ) 33 | 34 | exception foo_error { 35 | 1: i32 error_code ( foo="bar" ) 36 | 2: string (foo="bar") error_msg 37 | } (foo = "bar") 38 | 39 | typedef string ( unicode.encoding = "UTF-16" ) non_latin_string (foo="bar") 40 | typedef list< double ( cpp.fixed_point = "16" ) > tiny_float_list 41 | typedef map< string ( unicode.encoding = "UTF-16" ), double ( cpp.fixed_point = "16" ) > tiny_float_map 42 | typedef set< double (cpp.fixed_point = "16")> (foo="bar") tiny_float_set 43 | 44 | enum weekdays { 45 | SUNDAY ( weekend = "yes" ), 46 | MONDAY, 47 | TUESDAY, 48 | WEDNESDAY, 49 | THURSDAY, 50 | FRIDAY, 51 | SATURDAY ( weekend = "yes" ) 52 | } (foo.bar="baz") 53 | 54 | service foo_service { 55 | void foo() ( foo = "bar" ) 56 | } (a.b="c") 57 | 58 | -------------------------------------------------------------------------------- /test/thrift/parser/annotation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.AnnotationTest do 2 | use ExUnit.Case, async: true 3 | import Thrift.Parser, only: [parse_file: 1] 4 | alias Thrift.AST.Field 5 | 6 | setup_all do 7 | {:ok, schema} = parse_file("test/fixtures/app/thrift/AnnotationTest.thrift") 8 | {:ok, [schema: schema]} 9 | end 10 | 11 | defp find_field(fields, name) do 12 | Enum.find(fields, &match?(%{name: ^name}, &1)) 13 | end 14 | 15 | test "enum annotations", context do 16 | assert %{enums: %{weekdays: enum}} = context[:schema] 17 | assert enum.annotations == %{:"foo.bar" => "baz"} 18 | end 19 | 20 | test "struct annotations", context do 21 | assert %{structs: %{foo: struct}} = context[:schema] 22 | 23 | assert struct.annotations == %{ 24 | :"cpp.type" => "DenseFoo", 25 | :"python.type" => "DenseFoo", 26 | :"java.final" => "", 27 | :"annotation.without.value" => "1" 28 | } 29 | 30 | assert %Field{name: :bar, annotations: annotations} = find_field(struct.fields, :bar) 31 | assert %{:presence => "required"} = annotations 32 | assert %Field{name: :baz, annotations: baz_annotations} = find_field(struct.fields, :baz) 33 | assert %{:presence => "manual", :"cpp.use_pointer" => ""} = baz_annotations 34 | end 35 | 36 | test "service annotations", context do 37 | assert %{services: %{foo_service: service}} = context[:schema] 38 | assert service.annotations == %{:"a.b" => "c"} 39 | end 40 | 41 | test "function annotations", context do 42 | assert %{services: %{foo_service: service}} = context[:schema] 43 | assert %{functions: %{foo: function}} = service 44 | assert function.annotations == %{:foo => "bar"} 45 | end 46 | 47 | test "exception annotations", context do 48 | assert %{exceptions: %{foo_error: exception}} = context[:schema] 49 | assert exception.annotations == %{:foo => "bar"} 50 | 51 | assert %Field{name: :error_code, annotations: annotations} = 52 | find_field(exception.fields, :error_code) 53 | 54 | assert %{:foo => "bar"} = annotations 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/thrift/parser/parse_error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.ParseErrorTest do 2 | use ExUnit.Case 3 | 4 | @project_root Path.expand("../..", __DIR__) 5 | @test_file_dir Path.join([@project_root, "tmp", "parse_error_test"]) 6 | 7 | import Thrift.Parser, only: [parse_string: 1, parse_file_group: 1] 8 | 9 | setup do 10 | File.rm_rf!(@test_file_dir) 11 | File.mkdir_p!(@test_file_dir) 12 | on_exit(fn -> File.rm_rf!(@test_file_dir) end) 13 | end 14 | 15 | test "a file that throws parser errors raises an exception" do 16 | contents = """ 17 | stract Typo { 18 | 1: optional i32 id 19 | } 20 | """ 21 | 22 | assert {:error, {nil, 1, _}} = parse_string(contents) 23 | 24 | path = Path.join(@test_file_dir, "syntax_error.thrift") 25 | File.write!(path, contents) 26 | 27 | assert {:error, [{^path, 1, "syntax error before: \"stract\""}]} = parse_file_group(path) 28 | 29 | other_path = Path.join(@test_file_dir, "includes_syntax_error.thrift") 30 | 31 | File.write!(other_path, """ 32 | include "syntax_error.thrift" 33 | 34 | struct NoError { 35 | 1: optional i32 id 36 | } 37 | """) 38 | 39 | # should raise an error on the included file, 40 | # since that is where the syntax error is 41 | assert {:error, [{^path, 1, "syntax error before: \"stract\""}]} = 42 | parse_file_group(other_path) 43 | end 44 | 45 | test "a file that throws lexer errors raises an exception" do 46 | contents = """ 47 | // error on the next line 48 | /8 49 | """ 50 | 51 | assert {:error, {nil, 2, _}} = parse_string(contents) 52 | 53 | path = Path.join(@test_file_dir, "lexer_error.thrift") 54 | File.write!(path, contents) 55 | 56 | assert {:error, [{^path, 2, "illegal characters \"/8\""}]} = parse_file_group(path) 57 | 58 | other_path = Path.join(@test_file_dir, "includes_syntax_error.thrift") 59 | 60 | File.write!(other_path, """ 61 | include "lexer_error.thrift" 62 | 63 | struct NoError { 64 | 1: optional i32 id 65 | } 66 | """) 67 | 68 | # should raise an error on the included file, 69 | # since that is where the syntax error is 70 | assert {:error, [{^path, 2, "illegal characters \"/8\""}]} = parse_file_group(other_path) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/thrift/generator/behaviour_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BehaviourTest do 2 | use ThriftTestCase 3 | 4 | @thrift_file name: "behaviour.thrift", 5 | contents: """ 6 | struct S { 7 | 1: string username 8 | } 9 | 10 | struct T { 11 | 1: i64 id 12 | } 13 | 14 | union SorT { 15 | 1: S s_value, 16 | 2: T t_value 17 | } 18 | 19 | enum WaitStates { 20 | WAITING 21 | ACCEPTING 22 | BLOCKED 23 | } 24 | 25 | exception MyEx { 26 | 1: string message 27 | } 28 | 29 | service BehaviourService { 30 | void ping(1: i64 my_int), 31 | void my_bool(1: bool my_bool), 32 | void numbers(1: byte b, 2: i16 i, 3: i32 eye32, 4: i64 eye64, 5: double dub), 33 | void my_set(1: set my_set), 34 | void my_list(1: list my_string), 35 | void my_map(1: map my_map) 36 | map my_map2(1: map> my_map) 37 | void struct_param(1: S my_struct) 38 | void myCamelCasedFunction(1: string camelParam); 39 | WaitStates get_state(); 40 | SorT get_s_or_t(); 41 | MyEx dont_do_this(); 42 | void upload_file(1: string filename, 2: binary data); 43 | } 44 | """ 45 | 46 | thrift_test "that behaviour callbacks exist" do 47 | behaviour_specs = Handler.behaviour_info(:callbacks) 48 | 49 | assert {:ping, 1} in behaviour_specs 50 | assert {:my_bool, 1} in behaviour_specs 51 | assert {:numbers, 5} in behaviour_specs 52 | assert {:my_set, 1} in behaviour_specs 53 | assert {:my_list, 1} in behaviour_specs 54 | assert {:my_map, 1} in behaviour_specs 55 | assert {:my_map2, 1} in behaviour_specs 56 | assert {:struct_param, 1} in behaviour_specs 57 | assert {:my_camel_cased_function, 1} in behaviour_specs 58 | assert {:get_state, 0} in behaviour_specs 59 | assert {:get_s_or_t, 0} in behaviour_specs 60 | assert {:dont_do_this, 0} in behaviour_specs 61 | assert {:upload_file, 2} in behaviour_specs 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mix.start() 2 | Mix.shell(Mix.Shell.Process) 3 | ExUnit.configure(exclude: [pending: true], capture_log: true) 4 | ExUnit.start() 5 | 6 | defmodule ThriftTestHelpers do 7 | defmacro __using__(_) do 8 | quote do 9 | require ThriftTestHelpers 10 | import ThriftTestHelpers 11 | end 12 | end 13 | 14 | def build_thrift_file(base_dir, {file_name, contents}) do 15 | file_relative_path = Atom.to_string(file_name) 16 | file_path = Path.join(base_dir, file_relative_path) 17 | 18 | file_path 19 | |> Path.dirname() 20 | |> File.mkdir_p!() 21 | 22 | File.write!(file_path, contents) 23 | file_relative_path 24 | end 25 | 26 | def tmp_dir do 27 | tmp_path = Path.join(System.tmp_dir!(), Integer.to_string(System.unique_integer())) 28 | 29 | File.mkdir(tmp_path) 30 | tmp_path 31 | end 32 | 33 | def parse(_root_dir, nil) do 34 | nil 35 | end 36 | 37 | def parse(file_path) do 38 | {:ok, group} = Thrift.Parser.parse_file_group(file_path) 39 | group 40 | end 41 | 42 | @spec with_thrift_files(Keyword.t(), String.t()) :: nil 43 | defmacro with_thrift_files(opts, do: block) do 44 | {var_name, opts_1} = Keyword.pop(opts, :as, :file_group) 45 | {parsed_file, specs} = Keyword.pop(opts_1, :parse, nil) 46 | 47 | thrift_var = Macro.var(var_name, nil) 48 | 49 | quote location: :keep do 50 | root_dir = ThriftTestHelpers.tmp_dir() 51 | full_path = Path.join(root_dir, unquote(parsed_file)) 52 | 53 | files = Enum.map(unquote(specs), &ThriftTestHelpers.build_thrift_file(root_dir, &1)) 54 | unquote(thrift_var) = ThriftTestHelpers.parse(full_path) 55 | 56 | try do 57 | unquote(block) 58 | after 59 | File.rm_rf!(root_dir) 60 | end 61 | end 62 | end 63 | end 64 | 65 | defmodule MixTest.Case do 66 | use ExUnit.CaseTemplate 67 | 68 | using do 69 | quote do 70 | import MixTest.Case 71 | end 72 | end 73 | 74 | setup do 75 | on_exit(fn -> 76 | Mix.Shell.Process.flush() 77 | File.rm_rf!(Path.join(fixture_path(), "lib")) 78 | end) 79 | 80 | :ok 81 | end 82 | 83 | def fixture_path do 84 | Path.expand("fixtures/app", __DIR__) 85 | end 86 | 87 | def in_fixture(fun) do 88 | File.cd!(fixture_path(), fun) 89 | end 90 | 91 | def with_project_config(config, fun) do 92 | Mix.Project.in_project(:app, fixture_path(), config, fn _ -> fun.() end) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/thrift/transport/ssl.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Transport.SSL do 2 | @moduledoc """ 3 | SSL configuration helpers. 4 | 5 | Clients and servers support SSL with the `:ssl_opts` start_link option. There are additional options from 6 | the [:`ssl` module](http://erlang.org/doc/man/ssl.html) but otherwise configuration is the same: 7 | 8 | ## Options 9 | * :enabled - Whether ssl is enabled (default: `false`) 10 | * :optional - Whether to accept both SSL and plain connections (default: `false`) 11 | * :configure - Get extra configuration at handshake time (default: `nil`) 12 | 13 | ## Delayed configure option 14 | 15 | The value can be of the form `{module, function, args}` or a zero arity fun. The function should return 16 | `{:ok, opts}` to *add* options or `{:error, Exception.t}` to abort the handshake with an exception. This 17 | option should be used when it is beneficial to delay configuring the client or server, perhaps to protect 18 | credentials or to change the configuration during run time. 19 | """ 20 | 21 | @type configure :: {module, function, list} | (() -> {:ok, [option]} | {:error, Exception.t()}) 22 | @type option :: 23 | :ssl.ssl_option() | {:enabled, boolean} | {:optional, boolean} | {:configure, configure} 24 | 25 | @spec configuration([option]) :: 26 | {:required | :optional, [:ssl.ssl_option()]} | nil | {:error, Exception.t()} 27 | def configuration(opts) do 28 | {enabled, opts} = Keyword.pop(opts, :enabled, false) 29 | 30 | case enabled do 31 | true -> 32 | case handle_configure(opts) do 33 | {:ok, opts} -> 34 | handle_optional(opts) 35 | 36 | {:error, _} = error -> 37 | error 38 | end 39 | 40 | false -> 41 | nil 42 | end 43 | end 44 | 45 | defp handle_configure(opts) do 46 | {configure, opts} = Keyword.pop(opts, :configure) 47 | 48 | case apply_configure(configure) do 49 | {:ok, extra_opts} -> 50 | {:ok, extra_opts ++ opts} 51 | 52 | {:error, %_exception{}} = error -> 53 | error 54 | end 55 | end 56 | 57 | defp handle_optional(opts) do 58 | {optional, opts} = Keyword.pop(opts, :optional, false) 59 | 60 | case optional do 61 | true -> 62 | {:optional, opts} 63 | 64 | false -> 65 | {:required, opts} 66 | end 67 | end 68 | 69 | defp apply_configure({module, fun, args}), do: apply(module, fun, args) 70 | defp apply_configure(fun) when is_function(fun, 0), do: fun.() 71 | defp apply_configure(nil), do: {:ok, []} 72 | end 73 | -------------------------------------------------------------------------------- /lib/thrift/generator/service.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.Service do 2 | @moduledoc false 3 | 4 | alias Thrift.AST.{Field, Function, Struct} 5 | alias Thrift.Generator 6 | alias Thrift.Generator.StructGenerator 7 | alias Thrift.Parser.FileGroup 8 | 9 | # The response struct uses a %Field{} to represent the service function's 10 | # return value. Functions can return :void while fields cannot. Until we can 11 | # sort out that mismatch, disable dialyzer warnings for those functions. 12 | @dialyzer [{:nowarn_function, generate: 2}, {:nowarn_function, generate_response_struct: 2}] 13 | 14 | def generate(schema, service) do 15 | file_group = schema.file_group 16 | dest_module = FileGroup.dest_module(file_group, service) 17 | 18 | functions = Map.values(service.functions) 19 | arg_structs = Enum.map(functions, &generate_args_struct(schema, &1)) 20 | 21 | response_structs = 22 | for function <- functions, !function.oneway do 23 | generate_response_struct(schema, function) 24 | end 25 | 26 | framed_client = Generator.Binary.Framed.Client.generate(service) 27 | framed_server = Generator.Binary.Framed.Server.generate(dest_module, service, file_group) 28 | 29 | service_module = 30 | quote do 31 | defmodule unquote(dest_module) do 32 | @moduledoc false 33 | unquote_splicing(arg_structs) 34 | unquote_splicing(response_structs) 35 | 36 | unquote(framed_client) 37 | 38 | unquote(framed_server) 39 | end 40 | end 41 | 42 | {dest_module, service_module} 43 | end 44 | 45 | defp generate_args_struct(schema, function) do 46 | arg_module_name = module_name(function, :args) 47 | 48 | struct = Struct.new(Atom.to_charlist(arg_module_name), function.params) 49 | 50 | StructGenerator.generate(:struct, schema, struct.name, struct) 51 | end 52 | 53 | defp generate_response_struct(schema, function) do 54 | success = %Field{id: 0, name: :success, required: false, type: function.return_type} 55 | 56 | exceptions = Enum.map(function.exceptions, &Map.put(&1, :required, false)) 57 | 58 | fields = [success | exceptions] 59 | 60 | response_module_name = module_name(function, :response) 61 | response_struct = Struct.new(Atom.to_charlist(response_module_name), fields) 62 | 63 | StructGenerator.generate(:struct, schema, response_struct.name, response_struct) 64 | end 65 | 66 | def module_name(%Function{} = function, suffix) do 67 | struct_name = 68 | "#{function.name}_#{suffix}" 69 | |> Macro.camelize() 70 | |> String.to_atom() 71 | 72 | Module.concat(Elixir, struct_name) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/thrift/exceptions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.ExceptionsTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "ConnectionError" do 5 | alias Thrift.ConnectionError 6 | 7 | test "formats :closed" do 8 | exception = ConnectionError.exception(reason: :closed) 9 | assert Exception.message(exception) == "Connection error: closed" 10 | end 11 | 12 | test "formats :timeout" do 13 | exception = ConnectionError.exception(reason: :timeout) 14 | assert Exception.message(exception) == "Connection error: timeout" 15 | end 16 | 17 | test "formats POSIX errors" do 18 | exception = ConnectionError.exception(reason: :econnrefused) 19 | assert Exception.message(exception) == "Connection error: connection refused (econnrefused)" 20 | end 21 | end 22 | 23 | describe "TApplicationException" do 24 | alias Thrift.TApplicationException 25 | 26 | test "accepts integer type values" do 27 | assert %TApplicationException{type: :invalid_protocol} = 28 | TApplicationException.exception(type: 9) 29 | end 30 | 31 | test "accepts atom type values" do 32 | assert %TApplicationException{type: :unknown_method} = 33 | TApplicationException.exception(type: :unknown_method) 34 | end 35 | 36 | test "defaults to :unknown for unknown integer type values" do 37 | assert %TApplicationException{type: :unknown} = TApplicationException.exception(type: 1000) 38 | end 39 | 40 | test "raises for unknown atom type values" do 41 | assert_raise FunctionClauseError, fn -> 42 | TApplicationException.exception(type: :bogus) 43 | end 44 | end 45 | 46 | test "raises for missing atom type values" do 47 | assert_raise KeyError, fn -> 48 | TApplicationException.exception(Keyword.new()) 49 | end 50 | end 51 | 52 | test "accepts a :message argument" do 53 | exception = TApplicationException.exception(type: :unknown, message: "Message Text") 54 | assert exception.message == "Message Text" 55 | assert Exception.message(exception) == "Message Text" 56 | end 57 | 58 | test "message defaults to the :type string" do 59 | exception = TApplicationException.exception(type: :invalid_protocol) 60 | assert exception.message == "invalid_protocol" 61 | assert Exception.message(exception) == "invalid_protocol" 62 | end 63 | 64 | test "can convert valid atom type values to integer type values" do 65 | assert 9 == TApplicationException.type_id(:invalid_protocol) 66 | 67 | assert_raise FunctionClauseError, fn -> 68 | TApplicationException.type_id(:bogus) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | format: 14 | name: Format (Elixir ${{ matrix.pair.elixir }} / OTP ${{ matrix.pair.otp }}) 15 | runs-on: ubuntu-20.04 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - pair: 21 | elixir: 1.11 22 | otp: 23 23 | env: 24 | MIX_ENV: test 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: erlef/setup-elixir@v1 28 | with: 29 | otp-version: ${{ matrix.pair.otp }} 30 | elixir-version: ${{ matrix.pair.elixir }} 31 | - name: Install dependencies 32 | run: mix deps.get 33 | - name: Check format 34 | run: mix format --check-formatted 35 | test: 36 | name: Test (Elixir ${{ matrix.pair.elixir }} / OTP ${{ matrix.pair.otp }}) 37 | runs-on: ubuntu-20.04 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | include: 42 | - pair: 43 | elixir: 1.7 44 | otp: 22 45 | - pair: 46 | elixir: 1.11 47 | otp: 23 48 | coverage: true 49 | env: 50 | MIX_ENV: test 51 | steps: 52 | - uses: actions/checkout@v2 53 | - uses: erlef/setup-elixir@v1 54 | with: 55 | otp-version: ${{ matrix.pair.otp }} 56 | elixir-version: ${{ matrix.pair.elixir }} 57 | - name: Set up dependency cache 58 | uses: actions/cache@v1 59 | with: 60 | path: deps/ 61 | key: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-deps-${{ hashFiles('**/mix.lock') }} 62 | restore-keys: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-deps- 63 | - name: Set up build cache 64 | uses: actions/cache@v1 65 | with: 66 | path: _build/test/ 67 | key: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-build-${{ hashFiles('**/mix.lock') }} 68 | restore-keys: ${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-build- 69 | - name: Install dependencies 70 | run: | 71 | mix deps.get 72 | - name: Compile 73 | run: | 74 | mix deps.compile 75 | mix compile --force --warnings-as-errors 76 | - name: Run tests 77 | if: ${{ !matrix.coverage }} 78 | run: mix test 79 | - name: Run tests (with coverage) 80 | if: ${{ matrix.coverage }} 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | run: mix coveralls.github 84 | -------------------------------------------------------------------------------- /test/mix/tasks/thrift.generate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Thrift.GenerateTest do 2 | use MixTest.Case 3 | 4 | test "not specifying any Thrift files" do 5 | in_fixture(fn -> 6 | Mix.Tasks.Thrift.Generate.run([]) 7 | refute_received {:mix_shell, :info, [_]} 8 | end) 9 | end 10 | 11 | test "specifying multiple Thrift files" do 12 | in_fixture(fn -> 13 | with_project_config([], fn -> 14 | Mix.Tasks.Thrift.Generate.run( 15 | ~w[--verbose thrift/StressTest.thrift thrift/ThriftTest.thrift] 16 | ) 17 | 18 | assert_received {:mix_shell, :info, ["Parsing thrift/StressTest.thrift"]} 19 | assert_received {:mix_shell, :info, ["Parsing thrift/ThriftTest.thrift"]} 20 | assert_received {:mix_shell, :info, ["Wrote lib/generated/service.ex"]} 21 | assert_received {:mix_shell, :info, ["Wrote lib/thrift_test/thrift_test.ex"]} 22 | 23 | assert File.exists?("lib/generated/service.ex") 24 | assert File.exists?("lib/thrift_test/thrift_test.ex") 25 | end) 26 | end) 27 | end 28 | 29 | test "specifying a non-existent Thrift file" do 30 | in_fixture(fn -> 31 | assert_raise Mix.Error, ~r/no such file or directory/, fn -> 32 | Mix.Tasks.Thrift.Generate.run(["missing.thrift"]) 33 | end 34 | end) 35 | end 36 | 37 | test "specifying an invalid Thrift file" do 38 | in_fixture(fn -> 39 | bad_schema = """ 40 | struct InvalidTest { 41 | 1: i32 cannotDoArithmeticInThrift = 1 + 1, 42 | } 43 | """ 44 | 45 | path = tempfile("invalid.thrift", bad_schema) 46 | 47 | assert_raise Mix.Error, ~r/Parse error/, fn -> 48 | Mix.Tasks.Thrift.Generate.run([path]) 49 | end 50 | end) 51 | end 52 | 53 | test "specifying an alternate output directory (--out)" do 54 | in_fixture(fn -> 55 | with_project_config([], fn -> 56 | Mix.Tasks.Thrift.Generate.run(~w[--out lib/thrift thrift/ThriftTest.thrift]) 57 | assert File.exists?("lib/thrift/thrift_test/thrift_test.ex") 58 | end) 59 | end) 60 | end 61 | 62 | test "specifying an include path (--include)" do 63 | in_fixture(fn -> 64 | with_project_config([], fn -> 65 | Mix.Tasks.Thrift.Generate.run(~w[--include thrift thrift/include/Include.thrift]) 66 | assert File.exists?("lib/thrift_test/thrift_test.ex") 67 | end) 68 | end) 69 | end 70 | 71 | defp tempfile(filename, content) do 72 | temp_dir = Path.join([fixture_path(), "tmp"]) 73 | File.mkdir_p!(temp_dir) 74 | on_exit(fn -> File.rm_rf!(temp_dir) end) 75 | 76 | path = Path.join([temp_dir, filename]) 77 | File.write!(path, content) 78 | 79 | path 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thanks for taking the time to contribute! This guide will answer 4 | some common questions about how this project works. 5 | 6 | While this is a Pinterest open source project, we welcome contributions from 7 | everyone. Several regular outside contributors are also project maintainers. 8 | 9 | ## Making Changes 10 | 11 | 1. Fork this repository to your own account 12 | 2. Make your changes and verify that `mix test` passes 13 | 3. Commit your work and push to a new branch on your fork 14 | 4. Submit a [pull request](https://github.com/pinterest/elixir-thrift/compare/) 15 | 5. Participate in the code review process by responding to feedback 16 | 17 | Once there is agreement that the code is in good shape, one of the project's 18 | maintainers will merge your contribution. 19 | 20 | To increase the chances that your pull request will be accepted: 21 | 22 | - Follow the style guide 23 | - Write tests for your changes 24 | - Write a good commit message 25 | 26 | ## Style 27 | 28 | We use [`mix format`][]-based code formatting. The format will be enforced by 29 | Travis CI, so please make sure your code is well formatted *before* you push 30 | your branch so those checks will pass. 31 | 32 | We also use [Credo][] for static analysis and code consistency. You can run it 33 | locally via `mix credo`. 34 | 35 | [Credo]: https://github.com/rrrene/credo 36 | [`mix format`]: https://hexdocs.pm/mix/Mix.Tasks.Format.html 37 | 38 | ## Testing 39 | 40 | ### Test Coverage 41 | 42 | [![Coverage Status](https://coveralls.io/repos/pinterest/elixir-thrift/badge.svg?branch=master&service=github)](https://coveralls.io/github/pinterest/elixir-thrift?branch=master) 43 | 44 | We think test coverage is important because it makes it less likely that 45 | future changes will break existing functionality. Changes that drop the 46 | project's overall test coverage below 90% will fail to pass CI. 47 | 48 | Test coverage is measured using [Coveralls][]. You can generate a local 49 | coverage report using `mix coveralls.detail` or `mix coveralls.html`. 50 | 51 | [Coveralls]: https://coveralls.io/github/pinterest/elixir-thrift 52 | 53 | ### Static Test Data 54 | 55 | Some serialization tests use static test data to verify their correctness. 56 | These data files live under `test/data/` and have `.thriftbin` extensions. 57 | 58 | If you're changing any of the serialization routines, or adding some new code 59 | paths, you may need to (re)generate some of these data files. If that's the 60 | case, check out [`test/data/README.md`](test/data/README.md) for instructions. 61 | 62 | ## License 63 | 64 | By contributing to this project, you agree that your contributions will be 65 | licensed under its [Apache 2 license](LICENSE). 66 | -------------------------------------------------------------------------------- /test/thrift/binary/framed/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BinaryFramedClientTest do 2 | use ThriftTestCase 3 | 4 | alias Thrift.Binary.Framed.Client 5 | alias Thrift.TApplicationException 6 | 7 | @thrift_file name: "void_return.thrift", 8 | contents: """ 9 | service VoidReturns { 10 | void my_call(1: i64 id) 11 | } 12 | """ 13 | 14 | thrift_test "it should be able to deserialize an invalid message" do 15 | msg = <<128, 1, 0, 2>> 16 | 17 | assert {:error, {:cant_decode_message, ^msg}} = 18 | Client.deserialize_message_reply(msg, "my_call", 2757) 19 | end 20 | 21 | thrift_test "it should be able to read a malformed TApplicationException" do 22 | begin = Thrift.Protocol.Binary.serialize(:message_begin, {:exception, 941, "bad"}) 23 | msg = begin <> <<1, 1, 1, 1>> 24 | 25 | assert {:error, {:exception, ex}} = Client.deserialize_message_reply(msg, "bad", 941) 26 | 27 | assert %TApplicationException{ 28 | message: "Unable to decode exception (<<1, 1, 1, 1>>)", 29 | type: :protocol_error 30 | } = ex 31 | end 32 | 33 | thrift_test "it should be able to deserialize a message with a bad sequence id" do 34 | msg = <<128, 1, 0, 2, 0, 0, 0, 7, "my_call", 0, 0, 10, 197, 0>> 35 | 36 | assert {:error, {:exception, ex}} = Client.deserialize_message_reply(msg, "my_call", 1912) 37 | assert %TApplicationException{type: :bad_sequence_id} = ex 38 | end 39 | 40 | thrift_test "it should be able to deserialize a message with the wrong method name" do 41 | msg = <<128, 1, 0, 2, 0, 0, 0, 8, "bad_call", 0, 0, 10, 197, 0>> 42 | 43 | assert {:error, {:exception, ex}} = Client.deserialize_message_reply(msg, "my_call", 2757) 44 | assert %TApplicationException{type: :wrong_method_name} = ex 45 | end 46 | 47 | thrift_test "it should be able to deserialize a message with the wrong method name and sequence id " do 48 | msg = <<128, 1, 0, 2, 0, 0, 0, 8, "bad_call", 0, 0, 10, 197, 0>> 49 | 50 | assert {:error, {:exception, ex}} = Client.deserialize_message_reply(msg, "my_call", 1234) 51 | assert %TApplicationException{type: :bad_sequence_id} = ex 52 | end 53 | 54 | thrift_test "it should be able to deserialize a void message" do 55 | msg = <<128, 1, 0, 2, 0, 0, 0, 7, "my_call", 0, 0, 10, 197, 0>> 56 | 57 | assert {:ok, <<0>>} = Client.deserialize_message_reply(msg, "my_call", 2757) 58 | end 59 | 60 | thrift_test "it should be able to deserialize a message with an empty struct" do 61 | msg = <<128, 1, 0, 2, 0, 0, 0, 7, "my_call", 0, 0, 10, 197, 12, 0, 0, 0, 0>> 62 | 63 | assert {:ok, <<12, 0, 0, 0, 0>>} = Client.deserialize_message_reply(msg, "my_call", 2757) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/thrift/exceptions.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-next-line 2 | defmodule Thrift.TApplicationException do 3 | @moduledoc """ 4 | Application-level exception 5 | """ 6 | 7 | @enforce_keys [:message, :type] 8 | defexception message: "unknown", type: :unknown 9 | 10 | # This list represents the set of well-known TApplicationException types. 11 | # We primarily use their atom names, but we also need their standardized 12 | # integer values for representing these values in their serialized form. 13 | @exception_types [ 14 | unknown: 0, 15 | unknown_method: 1, 16 | invalid_message_type: 2, 17 | wrong_method_name: 3, 18 | bad_sequence_id: 4, 19 | missing_result: 5, 20 | internal_error: 6, 21 | protocol_error: 7, 22 | invalid_transform: 8, 23 | invalid_protocol: 9, 24 | unsupported_client_type: 10, 25 | loadshedding: 11, 26 | timeout: 12, 27 | injected_failure: 13 28 | ] 29 | 30 | def exception(args) when is_list(args) do 31 | type = normalize_type(Keyword.fetch!(args, :type)) 32 | message = args[:message] || Atom.to_string(type) 33 | %__MODULE__{message: message, type: type} 34 | end 35 | 36 | @doc """ 37 | Converts an exception type to its integer identifier. 38 | """ 39 | @spec type_id(atom) :: non_neg_integer 40 | def type_id(type) 41 | 42 | for {type, id} <- @exception_types do 43 | def type_id(unquote(type)), do: unquote(id) 44 | defp normalize_type(unquote(id)), do: unquote(type) 45 | defp normalize_type(unquote(type)), do: unquote(type) 46 | end 47 | 48 | defp normalize_type(type) when is_integer(type), do: :unknown 49 | end 50 | 51 | defmodule Thrift.ConnectionError do 52 | @enforce_keys [:reason] 53 | defexception [:reason] 54 | 55 | def message(%{reason: reason}) when reason in [:closed, :timeout] do 56 | "Connection error: #{reason}" 57 | end 58 | 59 | def message(%{reason: reason}) do 60 | # :ssl can format both ssl and tcp (posix) errors 61 | "Connection error: #{:ssl.format_error(reason)} (#{reason})" 62 | end 63 | end 64 | 65 | defmodule Thrift.Union.TooManyFieldsSetError do 66 | @moduledoc """ 67 | This exception occurs when a Union is serialized and more than one 68 | field is set. 69 | """ 70 | @enforce_keys [:message, :set_fields] 71 | defexception message: nil, set_fields: nil 72 | end 73 | 74 | defmodule Thrift.FileParseError do 75 | @moduledoc """ 76 | This exception occurs when a thrift file fails to parse 77 | """ 78 | 79 | @enforce_keys [:message] 80 | defexception message: nil 81 | 82 | # Exception callback, should not be called by end user 83 | @doc false 84 | @spec exception(Thrift.Parser.error()) :: Exception.t() 85 | def exception({path, line, message}) do 86 | %__MODULE__{message: "Parse error at #{path}:#{line}: #{message}"} 87 | end 88 | end 89 | 90 | defmodule Thrift.InvalidValueError do 91 | @enforce_keys [:message] 92 | defexception message: nil 93 | end 94 | -------------------------------------------------------------------------------- /example/test/calculator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CalculatorTest do 2 | @moduledoc false 3 | use ExUnit.Case 4 | 5 | require Calculator.Generated.VectorProductType 6 | 7 | alias Calculator.Generated.DivideByZeroError 8 | alias Calculator.Generated.Service.Binary.Framed.Client 9 | alias Calculator.Generated.Vector 10 | alias Calculator.Generated.VectorProductResult 11 | alias Calculator.Generated.VectorProductType 12 | 13 | setup do 14 | port = Application.get_env(:calculator, :port, 9090) 15 | {:ok, client} = Client.start_link("localhost", port) 16 | %{client: client} 17 | end 18 | 19 | test "add", ctx do 20 | assert Client.add(ctx[:client], 10, 11) == {:ok, 21} 21 | assert Client.add!(ctx[:client], 10, 11) == 21 22 | 23 | assert_raise ArgumentError, fn -> 24 | Client.add(ctx[:client], 10, 1.23) 25 | end 26 | end 27 | 28 | test "subtract", ctx do 29 | assert Client.subtract(ctx[:client], 10, 11) == {:ok, -1} 30 | assert Client.subtract!(ctx[:client], 10, 11) == -1 31 | 32 | assert_raise ArgumentError, fn -> 33 | Client.subtract(ctx[:client], 10, 1.23) 34 | end 35 | end 36 | 37 | test "multiply", ctx do 38 | assert Client.multiply(ctx[:client], 10, 11) == {:ok, 110} 39 | assert Client.multiply!(ctx[:client], 10, 11) == 110 40 | 41 | assert_raise ArgumentError, fn -> 42 | Client.multiply(ctx[:client], 10, 1.23) 43 | end 44 | end 45 | 46 | test "divide", ctx do 47 | assert Client.divide(ctx[:client], 22, 7) == {:ok, 3} 48 | assert Client.divide!(ctx[:client], 22, 7) == 3 49 | 50 | assert_raise ArgumentError, fn -> 51 | Client.divide(ctx[:client], 22, 7.0) 52 | end 53 | 54 | assert {:error, {:exception, %DivideByZeroError{}}} = Client.divide(ctx[:client], 22, 0) 55 | 56 | assert_raise DivideByZeroError, fn -> 57 | Client.divide!(ctx[:client], 22, 0) 58 | end 59 | end 60 | 61 | test "dot product", ctx do 62 | left = %Vector{x: 1.0, y: 2.0, z: 5.0} 63 | right = %Vector{x: 3.0, y: 1.0, z: -1.0} 64 | type = VectorProductType.dot_product() 65 | 66 | assert Client.vector_product(ctx[:client], left, left, type) == 67 | {:ok, %VectorProductResult{scalar: 30.0}} 68 | 69 | assert Client.vector_product(ctx[:client], left, right, type) == 70 | {:ok, %VectorProductResult{scalar: 0.0}} 71 | 72 | assert Client.vector_product(ctx[:client], right, right, type) == 73 | {:ok, %VectorProductResult{scalar: 11.0}} 74 | end 75 | 76 | test "cross product", ctx do 77 | i = %Vector{x: 1.0} 78 | j = %Vector{y: 1.0} 79 | k = %Vector{z: 1.0} 80 | type = VectorProductType.cross_product() 81 | 82 | assert Client.vector_product(ctx[:client], i, j, type) == 83 | {:ok, %VectorProductResult{vector: k}} 84 | 85 | assert Client.vector_product(ctx[:client], j, k, type) == 86 | {:ok, %VectorProductResult{vector: i}} 87 | 88 | assert Client.vector_product(ctx[:client], k, i, type) == 89 | {:ok, %VectorProductResult{vector: j}} 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Mixfile do 2 | @moduledoc false 3 | use Mix.Project 4 | 5 | @description """ 6 | Elixir implementation of the Thrift service framework 7 | 8 | This package includes support for parsing Thrift IDL files, working with the 9 | Thrift binary protocol, and building high-performance clients and servers. 10 | """ 11 | 12 | @version "2.0.0-dev" 13 | @project_url "https://github.com/pinterest/elixir-thrift" 14 | 15 | def project do 16 | [ 17 | app: :thrift, 18 | version: @version, 19 | elixir: "~> 1.7", 20 | deps: deps(), 21 | 22 | # Build Environment 23 | elixirc_paths: elixirc_paths(Mix.env()), 24 | compilers: [:leex, :yecc, :erlang, :elixir, :app], 25 | 26 | # Testing 27 | test_coverage: [tool: ExCoveralls], 28 | preferred_cli_env: [ 29 | coveralls: :test, 30 | "coveralls.detail": :test, 31 | "coveralls.html": :test, 32 | "coveralls.post": :test 33 | ], 34 | 35 | # URLs 36 | source_url: @project_url, 37 | homepage_url: @project_url, 38 | 39 | # Hex 40 | description: @description, 41 | package: package(), 42 | 43 | # Dialyzer 44 | dialyzer: [ 45 | plt_add_deps: :app_tree, 46 | plt_add_apps: [:mix] 47 | ], 48 | 49 | # Docs 50 | name: "Thrift", 51 | docs: [ 52 | main: "Thrift", 53 | extra_section: "Guides", 54 | extras: [ 55 | "ADOPTERS.md": [title: "Adopters"], 56 | "CONTRIBUTING.md": [title: "Contributing"], 57 | "example/README.md": [filename: "example", title: "Example Project"] 58 | ], 59 | source_ref: "master", 60 | source_url: @project_url, 61 | groups_for_modules: [ 62 | "Abstract Syntax Tree": ~r"Thrift.AST.*", 63 | Clients: ["Thrift.Binary.Framed.Client"], 64 | Servers: ["Thrift.Binary.Framed.Server"] 65 | ] 66 | ] 67 | ] 68 | end 69 | 70 | def application do 71 | [ 72 | extra_applications: [:logger] 73 | ] 74 | end 75 | 76 | defp elixirc_paths(:test), do: ["lib", "test/support/lib"] 77 | defp elixirc_paths(_), do: ["lib"] 78 | 79 | defp deps do 80 | [ 81 | # Development 82 | {:ex_doc, "~> 0.20", only: :dev, runtime: false}, 83 | {:excoveralls, "~> 0.12", only: :test, runtime: false}, 84 | {:credo, "~> 1.0", only: :dev, runtime: false}, 85 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 86 | 87 | # Runtime 88 | {:connection, "~> 1.0"}, 89 | {:ranch, "~> 1.6"}, 90 | {:telemetry, "~> 1.0"} 91 | ] 92 | end 93 | 94 | defp package do 95 | [ 96 | maintainers: [ 97 | "Steve Cohen", 98 | "James Fish", 99 | "Preston Guillory", 100 | "Michael Oliver", 101 | "Jon Parise", 102 | "Dan Swain" 103 | ], 104 | licenses: ["Apache 2.0"], 105 | links: %{"GitHub" => @project_url}, 106 | files: 107 | ~w(README.md ADOPTERS.md CONTRIBUTING.md LICENSE mix.exs lib) ++ 108 | ~w(src/thrift_lexer.xrl src/thrift_parser.yrl) 109 | ] 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{configs: [ 2 | %{name: "default", 3 | files: %{ 4 | included: ["example/lib/", "example/test/", "lib/", "src/", "test/"], 5 | excluded: [~r"/_build/", ~r"/deps/", ~r"/generated/", "test/fixtures/app/lib/"] 6 | }, 7 | requires: [], 8 | check_for_updates: false, 9 | color: true, 10 | strict: true, 11 | 12 | # You can customize the parameters of any check by adding a second element 13 | # to the tuple. 14 | # 15 | # To disable a check put `false` as second element: 16 | # 17 | # {Credo.Check.Design.DuplicatedCode, false} 18 | # 19 | checks: [ 20 | {Credo.Check.Consistency.ExceptionNames}, 21 | {Credo.Check.Consistency.LineEndings}, 22 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 23 | {Credo.Check.Consistency.SpaceAroundOperators}, 24 | {Credo.Check.Consistency.SpaceInParentheses}, 25 | {Credo.Check.Consistency.TabsOrSpaces}, 26 | 27 | {Credo.Check.Design.AliasUsage, false}, 28 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 29 | 30 | {Credo.Check.Design.TagTODO}, 31 | {Credo.Check.Design.TagFIXME}, 32 | 33 | {Credo.Check.Readability.FunctionNames}, 34 | {Credo.Check.Readability.LargeNumbers, only_greater_than: 99999}, 35 | {Credo.Check.Readability.MaxLineLength, false}, 36 | {Credo.Check.Readability.ModuleAttributeNames}, 37 | {Credo.Check.Readability.ModuleDoc}, 38 | {Credo.Check.Readability.ModuleNames}, 39 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, false}, # Crashes credo; needs investigation 40 | {Credo.Check.Readability.ParenthesesInCondition}, 41 | {Credo.Check.Readability.PredicateFunctionNames}, 42 | {Credo.Check.Readability.SinglePipe}, 43 | {Credo.Check.Readability.Specs, false}, 44 | {Credo.Check.Readability.StringSigils}, 45 | {Credo.Check.Readability.TrailingBlankLine}, 46 | {Credo.Check.Readability.TrailingWhiteSpace}, 47 | {Credo.Check.Readability.VariableNames}, 48 | {Credo.Check.Readability.RedundantBlankLines}, 49 | 50 | {Credo.Check.Refactor.ABCSize, false}, 51 | {Credo.Check.Refactor.CondStatements}, 52 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 53 | {Credo.Check.Refactor.FunctionArity, max_arity: 6}, 54 | {Credo.Check.Refactor.MatchInCondition}, 55 | {Credo.Check.Refactor.PipeChainStart, false}, 56 | {Credo.Check.Refactor.CyclomaticComplexity}, 57 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 58 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 59 | {Credo.Check.Refactor.Nesting}, 60 | {Credo.Check.Refactor.UnlessWithElse}, 61 | {Credo.Check.Refactor.VariableRebinding, false}, 62 | 63 | {Credo.Check.Warning.BoolOperationOnSameValues}, 64 | {Credo.Check.Warning.IExPry}, 65 | {Credo.Check.Warning.IoInspect, false}, 66 | {Credo.Check.Warning.OperationOnSameValues}, 67 | {Credo.Check.Warning.OperationWithConstantResult}, 68 | {Credo.Check.Warning.UnusedEnumOperation}, 69 | {Credo.Check.Warning.UnusedKeywordOperation}, 70 | {Credo.Check.Warning.UnusedListOperation}, 71 | {Credo.Check.Warning.UnusedStringOperation}, 72 | {Credo.Check.Warning.UnusedTupleOperation}, 73 | ] 74 | } 75 | ]} 76 | -------------------------------------------------------------------------------- /lib/thrift/generator/enum_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.EnumGenerator do 2 | @moduledoc false 3 | 4 | def generate(name, enum) do 5 | macro_defs = 6 | Enum.map(enum.values, fn {key, value} -> 7 | macro_name = 8 | key 9 | |> to_name 10 | |> Macro.pipe( 11 | quote do 12 | unquote 13 | end, 14 | 0 15 | ) 16 | 17 | quote do 18 | defmacro unquote(macro_name)(), do: unquote(value) 19 | end 20 | end) 21 | 22 | member_defs = 23 | Enum.flat_map(enum.values, fn {_key, value} -> 24 | quote do 25 | unquote(value) -> true 26 | end 27 | end) 28 | 29 | name_member_defs = 30 | Enum.flat_map(enum.values, fn {key, _value} -> 31 | enum_name = to_name(key) 32 | 33 | quote do 34 | unquote(enum_name) -> true 35 | end 36 | end) 37 | 38 | value_to_name_defs = 39 | Enum.flat_map(enum.values, fn {key, value} -> 40 | enum_name = to_name(key) 41 | 42 | quote do 43 | unquote(value) -> {:ok, unquote(enum_name)} 44 | end 45 | end) 46 | 47 | name_to_value_defs = 48 | Enum.flat_map(enum.values, fn {key, value} -> 49 | enum_name = to_name(key) 50 | 51 | quote do 52 | unquote(enum_name) -> {:ok, unquote(value)} 53 | end 54 | end) 55 | 56 | names = 57 | enum.values 58 | |> Keyword.keys() 59 | |> Enum.map(&to_name/1) 60 | 61 | quote do 62 | defmodule unquote(name) do 63 | @moduledoc false 64 | unquote_splicing(macro_defs) 65 | 66 | def value_to_name(v) do 67 | case v do 68 | unquote( 69 | value_to_name_defs ++ 70 | quote do 71 | _ -> {:error, {:invalid_enum_value, v}} 72 | end 73 | ) 74 | end 75 | end 76 | 77 | def name_to_value(k) do 78 | case k do 79 | unquote( 80 | name_to_value_defs ++ 81 | quote do 82 | _ -> {:error, {:invalid_enum_name, k}} 83 | end 84 | ) 85 | end 86 | end 87 | 88 | def value_to_name!(value) do 89 | {:ok, name} = value_to_name(value) 90 | name 91 | end 92 | 93 | def name_to_value!(name) do 94 | {:ok, value} = name_to_value(name) 95 | value 96 | end 97 | 98 | def meta(:names), do: unquote(names) 99 | def meta(:values), do: unquote(Keyword.values(enum.values)) 100 | 101 | def member?(v) do 102 | case v do 103 | unquote( 104 | member_defs ++ 105 | quote do 106 | _ -> false 107 | end 108 | ) 109 | end 110 | end 111 | 112 | def name?(k) do 113 | case k do 114 | unquote( 115 | name_member_defs ++ 116 | quote do 117 | _ -> false 118 | end 119 | ) 120 | end 121 | end 122 | end 123 | end 124 | end 125 | 126 | defp to_name(key) do 127 | key 128 | |> to_string() 129 | |> String.downcase() 130 | |> String.to_atom() 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/thrift/generator/binary/framed/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.Binary.Framed.Client do 2 | @moduledoc false 3 | 4 | alias Thrift.AST.Function 5 | alias Thrift.Generator.{Service, Utils} 6 | 7 | def generate(service) do 8 | functions = 9 | service.functions 10 | |> Map.values() 11 | |> Enum.map(&generate_handler_function(&1)) 12 | |> Utils.merge_blocks() 13 | 14 | quote do 15 | defmodule Binary.Framed.Client do 16 | @moduledoc false 17 | 18 | alias Thrift.Binary.Framed.Client, as: ClientImpl 19 | 20 | defdelegate close(conn), to: ClientImpl 21 | defdelegate connect(conn, opts), to: ClientImpl 22 | defdelegate start_link(host, port, opts \\ []), to: ClientImpl 23 | 24 | unquote_splicing(functions) 25 | end 26 | end 27 | end 28 | 29 | defp generate_handler_function(function) do 30 | args_module = Service.module_name(function, :args) 31 | args_binary_module = Module.concat(args_module, :BinaryProtocol) 32 | response_module = Service.module_name(function, :response) 33 | rpc_name = Atom.to_string(function.name) 34 | 35 | # Make two Elixir-friendly function names: an underscored version of the 36 | # Thrift function name and a "bang!" exception-raising variant. 37 | function_name = 38 | function.name 39 | |> Atom.to_string() 40 | |> Macro.underscore() 41 | |> String.to_atom() 42 | 43 | bang_name = :"#{function_name}!" 44 | 45 | # Apply some macro magic to the names to avoid conflicts with Elixir 46 | # reserved symbols like "and". 47 | function_name = 48 | Macro.pipe( 49 | function_name, 50 | quote do 51 | unquote 52 | end, 53 | 0 54 | ) 55 | 56 | bang_name = 57 | Macro.pipe( 58 | bang_name, 59 | quote do 60 | unquote 61 | end, 62 | 0 63 | ) 64 | 65 | vars = Enum.map(function.params, &Macro.var(&1.name, nil)) 66 | 67 | assignments = 68 | function.params 69 | |> Enum.zip(vars) 70 | |> Enum.map(fn {param, var} -> 71 | quote do 72 | {unquote(param.name), unquote(var)} 73 | end 74 | end) 75 | 76 | quote do 77 | def(unquote(function_name)(client, unquote_splicing(vars), rpc_opts \\ [])) do 78 | args = %unquote(args_module){unquote_splicing(assignments)} 79 | serialized_args = unquote(args_binary_module).serialize(args) 80 | unquote(build_response_handler(function, rpc_name, response_module)) 81 | end 82 | 83 | def(unquote(bang_name)(client, unquote_splicing(vars), rpc_opts \\ [])) do 84 | case unquote(function_name)(client, unquote_splicing(vars), rpc_opts) do 85 | {:ok, rsp} -> 86 | rsp 87 | 88 | {:error, {:exception, ex}} -> 89 | raise ex 90 | 91 | {:error, reason} -> 92 | raise Thrift.ConnectionError, reason: reason 93 | end 94 | end 95 | end 96 | end 97 | 98 | defp build_response_handler(%Function{oneway: true}, rpc_name, _response_module) do 99 | quote do 100 | :ok = ClientImpl.oneway(client, unquote(rpc_name), serialized_args, rpc_opts) 101 | {:ok, nil} 102 | end 103 | end 104 | 105 | defp build_response_handler(%Function{oneway: false}, rpc_name, response_module) do 106 | module = Module.concat(response_module, :BinaryProtocol) 107 | 108 | quote do 109 | ClientImpl.call(client, unquote(rpc_name), serialized_args, unquote(module), rpc_opts) 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/mix/tasks/thrift.generate.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Thrift.Generate do 2 | use Mix.Task 3 | 4 | @shortdoc "Generates Elixir source files from Thrift IDL files" 5 | 6 | @moduledoc """ 7 | Generate Elixir source files from Thrift IDL files (`.thrift`). 8 | 9 | A list of files should be given after the task name in order to select 10 | the specific Thrift IDL files to parse: 11 | 12 | mix thrift.generate file1.thrift file2.thrift 13 | 14 | ## Command line options 15 | 16 | * `-I dir` / `--include dir` - add a directory to the list of 17 | directory paths in which to search for included files, overriding 18 | the `:include_paths` configuration value. This option can be repeated 19 | in order to add multiple directories to the search list. 20 | * `--namespace namespace` - set the default namespace for generated 21 | modules, overriding the `:namespace` configuration value 22 | * `-o dir` / `--out dir` - set the output directory, overriding the 23 | `:output_path` configuration value 24 | * `-v` / `--verbose` - enable verbose task logging 25 | 26 | ## Configuration 27 | 28 | * `:include_paths` - list of additional directory paths in which to 29 | search for included files. Defaults to `[]`. 30 | * `:namespace` - default namespace for generated modules, which will 31 | be used when Thrift files don't specify their own `elixir` namespace. 32 | * `:output_path` - output directory into which the generated Elixir 33 | source files will be generated. Defaults to `"lib"`. 34 | 35 | ``` 36 | # example mix.exs 37 | defmodule MyProject.Mixfile do 38 | # ... 39 | 40 | def project do 41 | [ 42 | # other settings... 43 | thrift: [ 44 | include_paths: ["./extra_thrift"], 45 | output_path: "lib/generated" 46 | ] 47 | ] 48 | end 49 | end 50 | ``` 51 | """ 52 | 53 | @spec run(OptionParser.argv()) :: :ok 54 | def run(args) do 55 | {opts, files} = 56 | OptionParser.parse!( 57 | args, 58 | switches: [include: :keep, namespace: :string, out: :string, verbose: :boolean], 59 | aliases: [I: :include, o: :out, v: :verbose] 60 | ) 61 | 62 | config = Keyword.get(Mix.Project.config(), :thrift, []) 63 | output_path = opts[:out] || Keyword.get(config, :output_path, "lib") 64 | namespace = opts[:namespace] || Keyword.get(config, :namespace) 65 | 66 | include_paths = 67 | (opts[:include] && Keyword.get_values(opts, :include)) || 68 | Keyword.get(config, :include_paths, []) 69 | 70 | parser_opts = 71 | Keyword.new() 72 | |> Keyword.put(:include_paths, include_paths) 73 | |> Keyword.put(:namespace, namespace) 74 | 75 | unless Enum.empty?(files) do 76 | File.mkdir_p!(output_path) 77 | Enum.each(files, &generate!(&1, output_path, parser_opts, opts)) 78 | end 79 | end 80 | 81 | defp parse!(thrift_file, opts) do 82 | Thrift.Parser.parse_file_group!(thrift_file, opts) 83 | rescue 84 | e -> Mix.raise("#{thrift_file}: #{Exception.message(e)}") 85 | end 86 | 87 | defp generate!(thrift_file, output_path, parser_opts, opts) do 88 | Mix.shell().info("Parsing #{thrift_file}") 89 | 90 | generated_files = 91 | thrift_file 92 | |> parse!(parser_opts) 93 | |> Thrift.Generator.generate!(output_path) 94 | 95 | if opts[:verbose] do 96 | generated_files 97 | |> Enum.uniq() 98 | |> Enum.sort() 99 | |> Enum.each(fn file -> 100 | Mix.shell().info("Wrote #{Path.join(output_path, file)}") 101 | end) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /example/lib/calculator/generated/vector_product_result.ex: -------------------------------------------------------------------------------- 1 | defmodule(Calculator.Generated.VectorProductResult) do 2 | @moduledoc false 3 | _ = "Auto-generated Thrift union calculator.VectorProductResult" 4 | _ = "1: double scalar" 5 | _ = "2: calculator.Vector vector" 6 | defstruct(scalar: nil, vector: nil) 7 | @type t :: %__MODULE__{} 8 | def(new) do 9 | %__MODULE__{} 10 | end 11 | 12 | defmodule(BinaryProtocol) do 13 | @moduledoc false 14 | def(deserialize(binary)) do 15 | deserialize(binary, %Calculator.Generated.VectorProductResult{}) 16 | end 17 | 18 | defp(deserialize(<<0, rest::binary>>, %Calculator.Generated.VectorProductResult{} = acc)) do 19 | {acc, rest} 20 | end 21 | 22 | defp(deserialize(<<4, 1::16-signed, 0::1, 2047::11, 0::52, rest::binary>>, acc)) do 23 | deserialize(rest, %{acc | scalar: :inf}) 24 | end 25 | 26 | defp(deserialize(<<4, 1::16-signed, 1::1, 2047::11, 0::52, rest::binary>>, acc)) do 27 | deserialize(rest, %{acc | scalar: :"-inf"}) 28 | end 29 | 30 | defp(deserialize(<<4, 1::16-signed, sign::1, 2047::11, frac::52, rest::binary>>, acc)) do 31 | deserialize(rest, %{acc | scalar: %Thrift.NaN{sign: sign, fraction: frac}}) 32 | end 33 | 34 | defp(deserialize(<<4, 1::16-signed, value::float-signed, rest::binary>>, acc)) do 35 | deserialize(rest, %{acc | scalar: value}) 36 | end 37 | 38 | defp(deserialize(<<12, 2::16-signed, rest::binary>>, acc)) do 39 | case(Calculator.Generated.Vector.BinaryProtocol.deserialize(rest)) do 40 | {value, rest} -> 41 | deserialize(rest, %{acc | vector: value}) 42 | 43 | :error -> 44 | :error 45 | end 46 | end 47 | 48 | defp(deserialize(<>, acc)) do 49 | rest |> Thrift.Protocol.Binary.skip_field(field_type) |> deserialize(acc) 50 | end 51 | 52 | defp(deserialize(_, _)) do 53 | :error 54 | end 55 | 56 | def(serialize(%Calculator.Generated.VectorProductResult{scalar: nil, vector: nil})) do 57 | <<0>> 58 | end 59 | 60 | def(serialize(%Calculator.Generated.VectorProductResult{scalar: scalar, vector: nil})) do 61 | [ 62 | <<4, 1::16-signed>>, 63 | case(scalar) do 64 | :inf -> 65 | <<0::1, 2047::11, 0::52>> 66 | 67 | :"-inf" -> 68 | <<1::1, 2047::11, 0::52>> 69 | 70 | %Thrift.NaN{sign: sign, fraction: frac} -> 71 | <> 72 | 73 | _ -> 74 | <> 75 | end 76 | | <<0>> 77 | ] 78 | end 79 | 80 | def(serialize(%Calculator.Generated.VectorProductResult{scalar: nil, vector: vector})) do 81 | [<<12, 2::16-signed>>, Calculator.Generated.Vector.serialize(vector) | <<0>>] 82 | end 83 | 84 | def(serialize(%Calculator.Generated.VectorProductResult{} = value)) do 85 | set_fields = 86 | value 87 | |> Map.from_struct() 88 | |> Enum.flat_map(fn 89 | {_, nil} -> 90 | [] 91 | 92 | {key, _} -> 93 | [key] 94 | end) 95 | 96 | raise(%Thrift.Union.TooManyFieldsSetError{ 97 | message: "Thrift union has more than one field set", 98 | set_fields: set_fields 99 | }) 100 | end 101 | end 102 | 103 | def(serialize(struct)) do 104 | BinaryProtocol.serialize(struct) 105 | end 106 | 107 | def(serialize(struct, :binary)) do 108 | BinaryProtocol.serialize(struct) 109 | end 110 | 111 | def(deserialize(binary)) do 112 | BinaryProtocol.deserialize(binary) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/thrift/generator/struct_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.StructGenerator do 2 | @moduledoc false 3 | 4 | alias Thrift.AST.{ 5 | Exception, 6 | Field, 7 | Struct, 8 | TEnum, 9 | TypeRef, 10 | Union 11 | } 12 | 13 | alias Thrift.Generator.{StructBinaryProtocol, Utils} 14 | alias Thrift.Parser.FileGroup 15 | 16 | def generate(label, schema, name, struct) when label in [:struct, :union, :exception] do 17 | struct_parts = 18 | Enum.map(struct.fields, fn %Field{name: name, type: type, default: default} -> 19 | {name, Utils.quote_value(default, type, schema)} 20 | end) 21 | 22 | binary_protocol_defs = 23 | [ 24 | StructBinaryProtocol.struct_serializer(struct, name, schema.file_group), 25 | StructBinaryProtocol.struct_deserializer(struct, name, schema.file_group) 26 | ] 27 | |> Utils.merge_blocks() 28 | |> Utils.sort_defs() 29 | 30 | define_block = 31 | case label do 32 | :exception -> 33 | quote do: defexception(unquote(struct_parts)) 34 | 35 | _ -> 36 | quote do: defstruct(unquote(struct_parts)) 37 | end 38 | 39 | extra_defs = 40 | if label == :exception and not Keyword.has_key?(struct_parts, :message) do 41 | quote do 42 | @spec message(Exception.t()) :: String.t() 43 | def message(exception), do: inspect(exception) 44 | end 45 | end 46 | 47 | quote do 48 | defmodule unquote(name) do 49 | @moduledoc false 50 | _ = unquote("Auto-generated Thrift #{label} #{struct.name}") 51 | 52 | unquote_splicing( 53 | for field <- struct.fields do 54 | quote do 55 | _ = 56 | unquote("#{field.id}: #{to_thrift(field.type, schema.file_group)} #{field.name}") 57 | end 58 | end 59 | ) 60 | 61 | unquote(define_block) 62 | @type t :: %__MODULE__{} 63 | def new, do: %__MODULE__{} 64 | unquote_splicing(List.wrap(extra_defs)) 65 | 66 | defmodule BinaryProtocol do 67 | @moduledoc false 68 | unquote_splicing(binary_protocol_defs) 69 | end 70 | 71 | def serialize(struct) do 72 | BinaryProtocol.serialize(struct) 73 | end 74 | 75 | def serialize(struct, :binary) do 76 | BinaryProtocol.serialize(struct) 77 | end 78 | 79 | def deserialize(binary) do 80 | BinaryProtocol.deserialize(binary) 81 | end 82 | end 83 | end 84 | end 85 | 86 | defp to_thrift(base_type, _file_group) when is_atom(base_type) do 87 | Atom.to_string(base_type) 88 | end 89 | 90 | defp to_thrift({:map, {key_type, val_type}}, file_group) do 91 | "map<#{to_thrift(key_type, file_group)},#{to_thrift(val_type, file_group)}>" 92 | end 93 | 94 | defp to_thrift({:set, element_type}, file_group) do 95 | "set<#{to_thrift(element_type, file_group)}>" 96 | end 97 | 98 | defp to_thrift({:list, element_type}, file_group) do 99 | "list<#{to_thrift(element_type, file_group)}>" 100 | end 101 | 102 | defp to_thrift(%TEnum{name: name}, _file_group) do 103 | "#{name}" 104 | end 105 | 106 | defp to_thrift(%Struct{name: name}, _file_group) do 107 | "#{name}" 108 | end 109 | 110 | defp to_thrift(%Exception{name: name}, _file_group) do 111 | "#{name}" 112 | end 113 | 114 | defp to_thrift(%Union{name: name}, _file_group) do 115 | "#{name}" 116 | end 117 | 118 | defp to_thrift(%TypeRef{referenced_type: type}, file_group) do 119 | to_thrift(FileGroup.resolve(file_group, type), file_group) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/thrift/binary/framed/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Binary.Framed.Server do 2 | @moduledoc """ 3 | A server implementation of Thrift's Binary Framed protocol. 4 | 5 | See `start_link/4` for the various options. 6 | 7 | {:ok, pid} = Server.start_link(ServiceHandler, 2345, []) 8 | """ 9 | 10 | @type server_option :: 11 | {:worker_count, pos_integer} 12 | | {:name, atom} 13 | | {:tcp_opts, :ranch_tcp.opts()} 14 | | {:ssl_opts, [Thrift.Transport.SSL.option()]} 15 | | {:transport_opts, :ranch.opts()} 16 | 17 | @type server_opts :: [server_option] 18 | 19 | @doc """ 20 | Starts the server using the specified handler module. 21 | 22 | The following server options can be specified: 23 | 24 | `:worker_count`: The number of acceptor workers to accept on the socket. 25 | 26 | `:name`: (Optional) The name of the server. The server's pid becomes 27 | registered under this name. If not specified, the handler module's name 28 | is used. 29 | 30 | `tcp_opts`: A keyword list that controls how the underlying connection is 31 | handled. All options are sent directly to `:ranch_tcp`. 32 | 33 | `ssl_opts`: A keyword list of SSL/TLS options: 34 | 35 | - `:enabled`: A boolean indicating whether to upgrade the connection to 36 | the SSL protocol 37 | - `:optional`: A boolean indicating whether to accept both SSL and plain 38 | connections 39 | - `:configure`: A 0-arity function to provide additional SSL options at 40 | runtime 41 | - Additional `:ssl.ssl_option/0` values specifying other `:ssl` options 42 | 43 | `transport_opts` can be used to specify any additional options to pass 44 | to `:ranch.child_spec/6`. 45 | """ 46 | @spec start_link(module, port :: 1..65_535, module, [server_option]) :: GenServer.on_start() 47 | def start_link(server_module, port, handler_module, opts) do 48 | name = Keyword.get(opts, :name, handler_module) 49 | worker_count = Keyword.get(opts, :worker_count, 1) 50 | tcp_opts = Keyword.get(opts, :tcp_opts, []) 51 | ssl_opts = Keyword.get(opts, :ssl_opts, []) 52 | on_connect = Keyword.get(opts, :on_connect) 53 | 54 | transport_opts = 55 | opts 56 | |> Keyword.get(:transport_opts, []) 57 | |> add_port_to_transport_opts(port) 58 | 59 | validate_ssl_configuration!(ssl_opts) 60 | 61 | listener = 62 | :ranch.child_spec( 63 | name, 64 | worker_count, 65 | :ranch_tcp, 66 | transport_opts, 67 | Thrift.Binary.Framed.ProtocolHandler, 68 | {server_module, handler_module, on_connect, tcp_opts, ssl_opts} 69 | ) 70 | 71 | Supervisor.start_link( 72 | [listener], 73 | strategy: :one_for_one, 74 | max_restarts: 0 75 | ) 76 | end 77 | 78 | defp add_port_to_transport_opts(transport_opts, port) when is_list(transport_opts) do 79 | # Before Ranch 2.0, transport_opts is a keyword list with a mixture of 80 | # Ranch and socket opts. 81 | Keyword.put(transport_opts, :port, port) 82 | end 83 | 84 | defp add_port_to_transport_opts(transport_opts, port) when is_map(transport_opts) do 85 | # After Ranch 2.0, transport_opts is a map with socket opts under its own 86 | # key. 87 | Map.update(transport_opts, :socket_opts, [port: port], &Keyword.put(&1, :port, port)) 88 | end 89 | 90 | @doc """ 91 | Stops the server. 92 | """ 93 | def stop(pid) do 94 | Supervisor.stop(pid) 95 | end 96 | 97 | # Ensure that SSL certs are available before starting server. 98 | def validate_ssl_configuration!(ssl_opts) do 99 | case Thrift.Transport.SSL.configuration(ssl_opts) do 100 | {:error, %_exception{} = err} -> 101 | raise err 102 | 103 | nil -> 104 | :ok 105 | 106 | {optional, _ssl_opts} when optional in [:required, :optional] -> 107 | :ok 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/thrift/generator/behaviour.ex: -------------------------------------------------------------------------------- 1 | # Takes a thrift service definition and creates a behavoiur module for users 2 | # to implement. Thrift types are converted into Elixir typespecs that are 3 | # equivalent to their thrift counterparts. 4 | defmodule Thrift.Generator.Behaviour do 5 | @moduledoc false 6 | 7 | alias Thrift.AST.{ 8 | Exception, 9 | Field, 10 | Struct, 11 | TEnum, 12 | TypeRef, 13 | Union 14 | } 15 | 16 | alias Thrift.Generator.Utils 17 | alias Thrift.Parser.FileGroup 18 | 19 | require Logger 20 | 21 | def generate(schema, service) do 22 | file_group = schema.file_group 23 | dest_module = Module.concat(FileGroup.dest_module(file_group, service), Handler) 24 | 25 | callbacks = 26 | service.functions 27 | |> Map.values() 28 | |> Enum.map(&create_callback(file_group, &1)) 29 | 30 | behaviour_module = 31 | quote do 32 | defmodule unquote(dest_module) do 33 | @moduledoc false 34 | (unquote_splicing(callbacks)) 35 | end 36 | end 37 | 38 | {dest_module, behaviour_module} 39 | end 40 | 41 | defp create_callback(file_group, function) do 42 | callback_name = Utils.underscore(function.name) 43 | 44 | return_type = typespec(function.return_type, file_group) 45 | 46 | params = 47 | function.params 48 | |> Enum.map(&FileGroup.resolve(file_group, &1)) 49 | |> Enum.map(&to_arg_spec(&1, file_group)) 50 | 51 | quote do 52 | @callback unquote(callback_name)(unquote_splicing(params)) :: unquote(return_type) 53 | end 54 | end 55 | 56 | def to_arg_spec(%Field{name: name, type: type}, file_group) do 57 | quote do 58 | unquote(Macro.var(name, nil)) :: unquote(typespec(type, file_group)) 59 | end 60 | end 61 | 62 | defp typespec(:void, _), do: quote(do: no_return()) 63 | defp typespec(:bool, _), do: quote(do: boolean()) 64 | defp typespec(:string, _), do: quote(do: String.t()) 65 | defp typespec(:binary, _), do: quote(do: binary) 66 | defp typespec(:i8, _), do: quote(do: Thrift.i8()) 67 | defp typespec(:i16, _), do: quote(do: Thrift.i16()) 68 | defp typespec(:i32, _), do: quote(do: Thrift.i32()) 69 | defp typespec(:i64, _), do: quote(do: Thrift.i64()) 70 | defp typespec(:double, _), do: quote(do: Thrift.double()) 71 | 72 | defp typespec(%TypeRef{} = ref, file_group) do 73 | file_group 74 | |> FileGroup.resolve(ref) 75 | |> typespec(file_group) 76 | end 77 | 78 | defp typespec(%TEnum{}, _) do 79 | quote do 80 | non_neg_integer 81 | end 82 | end 83 | 84 | defp typespec(%Union{name: name}, file_group) do 85 | dest_module = FileGroup.dest_module(file_group, name) 86 | 87 | quote do 88 | %unquote(dest_module){} 89 | end 90 | end 91 | 92 | defp typespec(%Exception{name: name}, file_group) do 93 | dest_module = FileGroup.dest_module(file_group, name) 94 | 95 | quote do 96 | %unquote(dest_module){} 97 | end 98 | end 99 | 100 | defp typespec(%Struct{name: name}, file_group) do 101 | dest_module = FileGroup.dest_module(file_group, name) 102 | 103 | quote do 104 | %unquote(dest_module){} 105 | end 106 | end 107 | 108 | defp typespec({:set, _t}, _) do 109 | quote do 110 | %MapSet{} 111 | end 112 | end 113 | 114 | defp typespec({:list, t}, file_group) do 115 | quote do 116 | [unquote(typespec(t, file_group))] 117 | end 118 | end 119 | 120 | defp typespec({:map, {k, v}}, file_group) do 121 | key_type = typespec(k, file_group) 122 | val_type = typespec(v, file_group) 123 | 124 | quote do 125 | %{unquote(key_type) => unquote(val_type)} 126 | end 127 | end 128 | 129 | defp typespec(unknown_typespec, _) do 130 | Logger.error("Unknown type: #{inspect(unknown_typespec)}. Falling back to any()") 131 | 132 | quote do 133 | any 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/mix/tasks/compile.thrift_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Compile.ThriftTest do 2 | use MixTest.Case 3 | 4 | test "compiling default :files" do 5 | in_fixture(fn -> 6 | with_project_config([], fn -> 7 | assert Mix.Tasks.Compile.Thrift.run(["--verbose"]) == {:ok, []} 8 | 9 | assert_received {:mix_shell, :info, ["Compiling 4 files (.thrift)"]} 10 | assert_received {:mix_shell, :info, ["Compiled thrift/AnnotationTest.thrift"]} 11 | assert_received {:mix_shell, :info, ["Compiled thrift/StressTest.thrift"]} 12 | assert_received {:mix_shell, :info, ["Compiled thrift/ThriftTest.thrift"]} 13 | assert_received {:mix_shell, :info, ["Compiled thrift/numbers.thrift"]} 14 | 15 | assert File.exists?("lib/generated/service.ex") 16 | assert File.exists?("lib/thrift_test/thrift_test.ex") 17 | assert File.exists?("lib/tutorial/numbers.ex") 18 | end) 19 | end) 20 | end 21 | 22 | test "recompiling unchanged targets" do 23 | in_fixture(fn -> 24 | with_project_config([], fn -> 25 | assert Mix.Tasks.Compile.Thrift.run([]) == {:ok, []} 26 | assert Mix.Tasks.Compile.Thrift.run([]) == {:noop, []} 27 | end) 28 | end) 29 | end 30 | 31 | test "recompiling stale targets" do 32 | in_fixture(fn -> 33 | with_project_config([], fn -> 34 | assert Mix.Tasks.Compile.Thrift.run([]) == {:ok, []} 35 | File.rm_rf!("lib") 36 | assert Mix.Tasks.Compile.Thrift.run([]) == {:ok, []} 37 | end) 38 | end) 39 | end 40 | 41 | test "forcing compilation" do 42 | in_fixture(fn -> 43 | with_project_config([], fn -> 44 | assert Mix.Tasks.Compile.Thrift.run([]) == {:ok, []} 45 | assert Mix.Tasks.Compile.Thrift.run(["--force"]) == {:ok, []} 46 | end) 47 | end) 48 | end 49 | 50 | test "cleaning generated files" do 51 | in_fixture(fn -> 52 | with_project_config([], fn -> 53 | Mix.Tasks.Compile.Thrift.run([]) 54 | assert File.exists?("lib/thrift_test/thrift_test.ex") 55 | assert Enum.all?(Mix.Tasks.Compile.Thrift.manifests(), &File.exists?/1) 56 | 57 | Mix.Tasks.Compile.Thrift.clean() 58 | refute File.exists?("lib/thrift_test/thrift_test.ex") 59 | refute Enum.any?(Mix.Tasks.Compile.Thrift.manifests(), &File.exists?/1) 60 | end) 61 | end) 62 | end 63 | 64 | test "specifying an empty :files list" do 65 | in_fixture(fn -> 66 | with_project_config([thrift: [files: []]], fn -> 67 | assert Mix.Tasks.Compile.Thrift.run([]) == {:noop, []} 68 | end) 69 | end) 70 | end 71 | 72 | test "specifying a non-existent Thrift file" do 73 | in_fixture(fn -> 74 | with_project_config([thrift: [files: ~w("missing.thrift")]], fn -> 75 | assert {:error, [_]} = Mix.Tasks.Compile.Thrift.run([]) 76 | assert_received {:mix_shell, :error, [_]} 77 | end) 78 | end) 79 | end 80 | 81 | test "specifying an invalid Thrift file" do 82 | in_fixture(fn -> 83 | with_project_config([thrift: [files: [__ENV__.file]]], fn -> 84 | assert {:error, [_]} = Mix.Tasks.Compile.Thrift.run([]) 85 | assert_received {:mix_shell, :error, [_]} 86 | end) 87 | end) 88 | end 89 | 90 | test "specifying an additional include path" do 91 | config = [ 92 | files: ~w(thrift/include/Include.thrift), 93 | include_paths: ~w(thrift) 94 | ] 95 | 96 | in_fixture(fn -> 97 | with_project_config([thrift: config], fn -> 98 | assert Mix.Tasks.Compile.Thrift.run(["--verbose"]) == {:ok, []} 99 | assert_received {:mix_shell, :info, ["Compiled thrift/include/Include.thrift"]} 100 | end) 101 | end) 102 | end 103 | 104 | test "specifying an unknown option" do 105 | in_fixture(fn -> 106 | with_project_config([], fn -> 107 | assert Mix.Tasks.Compile.Thrift.run(["--unknown-option"]) == {:ok, []} 108 | assert_received {:mix_shell, :info, ["Compiling 4 files (.thrift)"]} 109 | end) 110 | end) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/data/test_data.py: -------------------------------------------------------------------------------- 1 | from thrift.protocol import TBinaryProtocol 2 | from thrift.transport import TTransport 3 | 4 | 5 | def serialize(thrift_obj, filename): 6 | with open(filename, 'wb') as f: 7 | transport = TTransport.TFileObjectTransport(f) 8 | protocol = TBinaryProtocol.TBinaryProtocolAccelerated(transport) 9 | thrift_obj.write(protocol) 10 | transport.flush() 11 | 12 | 13 | def write(): 14 | """ 15 | Writes static test data. The thrift structures created and serialized here 16 | should match up with the ones in the Elixir test cases. 17 | """ 18 | from generated.across.ttypes import User 19 | from generated.containers.ttypes import Containers, Friend, Weather 20 | from generated.enums.ttypes import Status, StructWithEnum 21 | from generated.scalars.ttypes import Scalars 22 | 23 | # 24 | # across.thrift 25 | # 26 | 27 | # across.thriftbin 28 | user = User(id=1234, best_friend=Friend(id=3282, username='stinkypants')) 29 | serialize(user, 'binary/across/across.thriftbin') 30 | 31 | 32 | # 33 | # containers.thrift 34 | # 35 | 36 | # empty_list.thriftbin 37 | containers = Containers(users=[]) 38 | serialize(containers, 'binary/containers/empty_list.thriftbin') 39 | 40 | # enums_list.thriftbin 41 | containers = Containers(weekly_forecast=[ 42 | Weather.SUNNY, 43 | Weather.SUNNY, 44 | Weather.SUNNY, 45 | Weather.SUNNY, 46 | Weather.CLOUDY, 47 | Weather.SUNNY, 48 | Weather.SUNNY 49 | ]) 50 | serialize(containers, 'binary/containers/enums_list.thriftbin') 51 | 52 | # enums_map.thriftbin 53 | containers = Containers(user_forecasts={ 54 | 1: Weather.SUNNY, 55 | -1: Weather.SUNNY, 56 | 12345: Weather.CLOUDY 57 | }) 58 | serialize(containers, 'binary/containers/enums_map.thriftbin') 59 | 60 | # strings_set.thriftbin 61 | containers = Containers(taken_usernames={'scohen', 'pguillory'}) 62 | serialize(containers, 'binary/containers/strings_set.thriftbin') 63 | 64 | # structs_list.thriftbin 65 | containers = Containers(friends=[ 66 | Friend(id=1, username='scohen'), 67 | Friend(id=2, username='pguillory'), 68 | Friend(id=3, username='dantswain') 69 | ]) 70 | serialize(containers, 'binary/containers/structs_list.thriftbin') 71 | 72 | # structs_map.thriftbin 73 | containers = Containers(friends_by_username={ 74 | 'scohen': Friend(id=1, username='scohen'), 75 | 'pguillory': Friend(id=2, username='pguillory'), 76 | 'dantswain': Friend(id=3, username='dantswain') 77 | }) 78 | serialize(containers, 'binary/containers/structs_map.thriftbin') 79 | 80 | # unset.thriftbin 81 | containers = Containers() 82 | serialize(containers, 'binary/containers/unset.thriftbin') 83 | 84 | 85 | # 86 | # enums.thrift 87 | # 88 | 89 | # banned.thriftbin 90 | struct_with_enum = StructWithEnum(status=Status.BANNED) 91 | serialize(struct_with_enum, 'binary/enums/banned.thriftbin') 92 | 93 | # evil.thriftbin 94 | struct_with_enum = StructWithEnum(status=Status.EVIL) 95 | serialize(struct_with_enum, 'binary/enums/evil.thriftbin') 96 | 97 | 98 | # 99 | # scalars.thrift 100 | # 101 | 102 | # bool.thriftbin 103 | scalars = Scalars(is_true=True) 104 | serialize(scalars, 'binary/scalars/bool.thriftbin') 105 | 106 | # byte.thriftbin 107 | scalars = Scalars(byte_value=127) 108 | serialize(scalars, 'binary/scalars/byte.thriftbin') 109 | 110 | # i16.thriftbin 111 | scalars = Scalars(sixteen_bits=12723) 112 | serialize(scalars, 'binary/scalars/i16.thriftbin') 113 | 114 | # i32.thriftbin 115 | scalars = Scalars(thirty_two_bits=18362832) 116 | serialize(scalars, 'binary/scalars/i32.thriftbin') 117 | 118 | # i64.thriftbin 119 | scalars = Scalars(sixty_four_bits=8872372) 120 | serialize(scalars, 'binary/scalars/i64.thriftbin') 121 | 122 | # double.thriftbin 123 | scalars = Scalars(double_value=2.37219) 124 | serialize(scalars, 'binary/scalars/double.thriftbin') 125 | 126 | # string.thriftbin 127 | scalars = Scalars(string_value='I am a string') 128 | serialize(scalars, 'binary/scalars/string.thriftbin') 129 | 130 | # binary.thriftbin 131 | scalars = Scalars(raw_binary=b'\xE0\xBA\x02\x01\x00') 132 | serialize(scalars, 'binary/scalars/binary.thriftbin') 133 | -------------------------------------------------------------------------------- /lib/thrift/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser do 2 | @moduledoc """ 3 | This module provides functions for parsing [Thrift IDL][idl] (`.thrift`) 4 | files. 5 | 6 | [idl]: https://thrift.apache.org/docs/idl 7 | """ 8 | 9 | alias Thrift.Parser.FileGroup 10 | 11 | @typedoc "A Thrift IDL line number" 12 | @type line :: pos_integer | nil 13 | 14 | @typedoc "A map of Thrift annotation keys to values" 15 | @type annotations :: %{required(String.t()) => String.t()} 16 | 17 | @typedoc "Available parser options" 18 | @type opt :: 19 | {:include_paths, [Path.t()]} 20 | | {:namespace, module | String.t()} 21 | @type opts :: [opt] 22 | 23 | @typedoc "Parse error (path, line, message)" 24 | @type error :: {Path.t() | nil, line(), message :: String.t()} 25 | 26 | @doc """ 27 | Parses a Thrift IDL string into its AST representation. 28 | """ 29 | @spec parse_string(String.t()) :: {:ok, Thrift.AST.Schema.t()} | {:error, error} 30 | def parse_string(doc) do 31 | doc = String.to_charlist(doc) 32 | 33 | with {:ok, tokens, _} <- :thrift_lexer.string(doc), 34 | {:ok, _} = result <- :thrift_parser.parse(tokens) do 35 | result 36 | else 37 | {:error, {line, :thrift_lexer, error}, _} -> 38 | {:error, {nil, line, List.to_string(:thrift_lexer.format_error(error))}} 39 | 40 | {:error, {line, :thrift_parser, error}} -> 41 | {:error, {nil, line, List.to_string(:thrift_parser.format_error(error))}} 42 | end 43 | end 44 | 45 | @doc """ 46 | Parses a Thrift IDL file into its AST representation. 47 | """ 48 | @spec parse_file(Path.t()) :: {:ok, Thrift.AST.Schema.t()} | {:error, error} 49 | def parse_file(path) do 50 | with {:ok, contents} <- read_file(path), 51 | {:ok, _schema} = result <- parse_string(contents) do 52 | result 53 | else 54 | {:error, {nil, line, message}} -> 55 | {:error, {path, line, message}} 56 | 57 | {:error, message} -> 58 | {:error, {path, nil, message}} 59 | end 60 | end 61 | 62 | @doc """ 63 | Parses a Thrift IDL file and its included files into a file group. 64 | """ 65 | @spec parse_file_group(Path.t(), opts) :: {:ok, FileGroup.t()} | {:error, [error, ...]} 66 | def parse_file_group(path, opts \\ []) do 67 | group = FileGroup.new(path, normalize_opts(opts)) 68 | 69 | with {:ok, schema} <- parse_file(path), 70 | {group, [] = _errors} <- FileGroup.add(group, path, schema) do 71 | {:ok, FileGroup.set_current_module(group, module_name(path))} 72 | else 73 | {:error, error} -> 74 | {:error, [error]} 75 | 76 | {%FileGroup{}, errors} -> 77 | {:error, Enum.reverse(errors)} 78 | end 79 | end 80 | 81 | @doc """ 82 | Parses a Thrift IDL file and its included files into a file group. 83 | 84 | A `Thrift.FileParseError` will be raised if an error occurs. 85 | """ 86 | @spec parse_file_group!(Path.t(), opts) :: FileGroup.t() 87 | def parse_file_group!(path, opts \\ []) do 88 | case parse_file_group(path, opts) do 89 | {:ok, group} -> 90 | group 91 | 92 | {:error, [first_error | _errors]} -> 93 | raise Thrift.FileParseError, first_error 94 | end 95 | end 96 | 97 | defp read_file(path) do 98 | case File.read(path) do 99 | {:ok, contents} -> 100 | # We include the __file__ here to hack around the fact that leex and 101 | # yecc don't operate on files and lose the file info. This is relevant 102 | # because the filename is turned into the thrift module, and is 103 | # necessary for resolution. 104 | {:ok, contents <> "\n__file__ \"#{path}\""} 105 | 106 | {:error, reason} -> 107 | {:error, :file.format_error(reason)} 108 | end 109 | end 110 | 111 | defp module_name(path) do 112 | path 113 | |> Path.basename() 114 | |> Path.rootname() 115 | |> String.to_atom() 116 | end 117 | 118 | # normalize various type permutations that we could get options as 119 | defp normalize_opts(opts) do 120 | Keyword.update(opts, :namespace, nil, &namespace_string/1) 121 | end 122 | 123 | # namespace can be an atom or a binary 124 | # - convert an atom to a binary and remove the "Elixir." we get from atoms 125 | # like `Foo` 126 | # - make sure values are valid module names (CamelCase) 127 | defp namespace_string(""), do: nil 128 | defp namespace_string(nil), do: nil 129 | defp namespace_string(b) when is_binary(b), do: Macro.camelize(b) 130 | 131 | defp namespace_string(a) when is_atom(a) do 132 | a 133 | |> Atom.to_string() 134 | |> String.trim_leading("Elixir.") 135 | |> namespace_string 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/thrift/binary/framed/ssl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Servers.Binary.Framed.SSLTest do 2 | use ThriftTestCase 3 | use StubStats 4 | 5 | @thrift_file name: "ssl_test.thrift", 6 | contents: """ 7 | service SSLTest { 8 | bool ping() 9 | } 10 | """ 11 | 12 | alias Servers.Binary.Framed.SSLTest.SSLTest.Binary.Framed.{Client, Server} 13 | 14 | def define_handler do 15 | defmodule SSLTestHandler do 16 | alias Servers.Binary.Framed.SSLTest.SSLTest.Handler 17 | @behaviour Handler 18 | 19 | @impl Handler 20 | def ping, do: true 21 | end 22 | end 23 | 24 | setup_all do 25 | {:module, mod_name, _, _} = define_handler() 26 | 27 | {:ok, handler_name: mod_name} 28 | end 29 | 30 | def build_ssl_required_server(ctx) do 31 | {:ok, _} = 32 | Server.start_link( 33 | ctx[:handler_name], 34 | 0, 35 | name: ctx.test, 36 | ssl_opts: [enabled: true, configure: &get_certs/0] 37 | ) 38 | 39 | server_port = :ranch.get_port(ctx.test) 40 | 41 | {:ok, _} = start_supervised({StubStats, handler_module: ctx[:handler_name]}) 42 | 43 | {:ok, port: server_port} 44 | end 45 | 46 | def build_ssl_optional_server(ctx) do 47 | {:ok, _} = 48 | Server.start_link( 49 | ctx[:handler_name], 50 | 0, 51 | name: ctx.test, 52 | ssl_opts: [enabled: true, configure: &get_certs/0, optional: true] 53 | ) 54 | 55 | server_port = :ranch.get_port(ctx.test) 56 | 57 | {:ok, _} = start_supervised({StubStats, handler_module: ctx[:handler_name]}) 58 | 59 | {:ok, port: server_port} 60 | end 61 | 62 | def build_ssl_client(ctx) do 63 | {:ok, ssl_client} = 64 | Client.start_link("localhost", ctx.port, ssl_opts: ctx[:ssl_opts] ++ [enabled: true]) 65 | 66 | {:ok, ssl_client: ssl_client} 67 | end 68 | 69 | def build_plain_client(ctx) do 70 | {:ok, plain_client} = Client.start_link("localhost", ctx.port) 71 | 72 | {:ok, plain_client: plain_client} 73 | end 74 | 75 | describe "Required-SSL communication" do 76 | setup [:build_ssl_required_server, :build_ssl_client, :build_plain_client] 77 | 78 | @tag ssl_opts: [] 79 | thrift_test "it can return a simple boolean value", ctx do 80 | assert {:ok, true} == Client.ping(ctx.ssl_client) 81 | 82 | assert %{result: "ssl"} in stats(:peek_first_byte) 83 | assert %{result: "success"} in stats(:ssl_handshake) 84 | end 85 | 86 | @tag ssl_opts: [configure: {__MODULE__, :test_configure, []}] 87 | thrift_test "it can handle live configuration", ctx do 88 | assert {:ok, true} == Client.ping(ctx.ssl_client) 89 | assert_received :configured 90 | 91 | assert %{result: "ssl"} in stats(:peek_first_byte) 92 | assert %{result: "success"} in stats(:ssl_handshake) 93 | end 94 | 95 | @tag ssl_opts: [] 96 | thrift_test "plain client will be rejected", %{plain_client: client} do 97 | Process.flag(:trap_exit, true) 98 | assert {:error, :closed} == Client.ping(client) 99 | assert_receive {:EXIT, ^client, {:error, :closed}} 100 | 101 | assert %{result: "tcp_rejected"} in stats(:peek_first_byte) 102 | end 103 | end 104 | 105 | describe "Optional-SSL communication" do 106 | setup [:build_ssl_optional_server, :build_ssl_client, :build_plain_client] 107 | 108 | @tag ssl_opts: [] 109 | thrift_test "ssl client can receive a simple boolean value", ctx do 110 | assert {:ok, true} == Client.ping(ctx.ssl_client) 111 | 112 | assert %{result: "ssl"} in stats(:peek_first_byte) 113 | assert %{result: "success"} in stats(:ssl_handshake) 114 | end 115 | 116 | @tag ssl_opts: [] 117 | thrift_test "plain client can receive a simple boolean value", ctx do 118 | assert {:ok, true} == Client.ping(ctx.plain_client) 119 | 120 | assert %{result: "tcp"} in stats(:peek_first_byte) 121 | end 122 | end 123 | 124 | # @tag ssl_opts: [configure: {__MODULE__, :bad_configure, []}] 125 | thrift_test "it refuses to start with bad SSL configuration", ctx do 126 | configure = fn -> 127 | try do 128 | raise "uh oh" 129 | rescue 130 | exception -> {:error, exception} 131 | end 132 | end 133 | 134 | assert_raise(RuntimeError, "uh oh", fn -> 135 | Server.start_link( 136 | ctx[:handler_name], 137 | 0, 138 | ssl_opts: [enabled: true, configure: configure, optional: true] 139 | ) 140 | end) 141 | end 142 | 143 | ## Helpers 144 | 145 | defp get_certs() do 146 | certs_path = Application.app_dir(:ssl, "examples/certs/etc/server") 147 | cacerts = Path.join(certs_path, "cacerts.pem") 148 | cert = Path.join(certs_path, "cert.pem") 149 | key = Path.join(certs_path, "key.pem") 150 | 151 | {:ok, [cacertfile: cacerts, certfile: cert, keyfile: key]} 152 | end 153 | 154 | def test_configure() do 155 | [parent | _] = Process.get(:"$ancestors") 156 | send(parent, :configured) 157 | get_certs() 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /example/lib/calculator/generated/vector.ex: -------------------------------------------------------------------------------- 1 | defmodule(Calculator.Generated.Vector) do 2 | @moduledoc false 3 | _ = "Auto-generated Thrift struct calculator.Vector" 4 | _ = "1: double x" 5 | _ = "2: double y" 6 | _ = "3: double z" 7 | defstruct(x: 0.0, y: 0.0, z: 0.0) 8 | @type t :: %__MODULE__{} 9 | def(new) do 10 | %__MODULE__{} 11 | end 12 | 13 | defmodule(BinaryProtocol) do 14 | @moduledoc false 15 | def(deserialize(binary)) do 16 | deserialize(binary, %Calculator.Generated.Vector{}) 17 | end 18 | 19 | defp(deserialize(<<0, rest::binary>>, %Calculator.Generated.Vector{} = acc)) do 20 | {acc, rest} 21 | end 22 | 23 | defp(deserialize(<<4, 1::16-signed, 0::1, 2047::11, 0::52, rest::binary>>, acc)) do 24 | deserialize(rest, %{acc | x: :inf}) 25 | end 26 | 27 | defp(deserialize(<<4, 1::16-signed, 1::1, 2047::11, 0::52, rest::binary>>, acc)) do 28 | deserialize(rest, %{acc | x: :"-inf"}) 29 | end 30 | 31 | defp(deserialize(<<4, 1::16-signed, sign::1, 2047::11, frac::52, rest::binary>>, acc)) do 32 | deserialize(rest, %{acc | x: %Thrift.NaN{sign: sign, fraction: frac}}) 33 | end 34 | 35 | defp(deserialize(<<4, 1::16-signed, value::float-signed, rest::binary>>, acc)) do 36 | deserialize(rest, %{acc | x: value}) 37 | end 38 | 39 | defp(deserialize(<<4, 2::16-signed, 0::1, 2047::11, 0::52, rest::binary>>, acc)) do 40 | deserialize(rest, %{acc | y: :inf}) 41 | end 42 | 43 | defp(deserialize(<<4, 2::16-signed, 1::1, 2047::11, 0::52, rest::binary>>, acc)) do 44 | deserialize(rest, %{acc | y: :"-inf"}) 45 | end 46 | 47 | defp(deserialize(<<4, 2::16-signed, sign::1, 2047::11, frac::52, rest::binary>>, acc)) do 48 | deserialize(rest, %{acc | y: %Thrift.NaN{sign: sign, fraction: frac}}) 49 | end 50 | 51 | defp(deserialize(<<4, 2::16-signed, value::float-signed, rest::binary>>, acc)) do 52 | deserialize(rest, %{acc | y: value}) 53 | end 54 | 55 | defp(deserialize(<<4, 3::16-signed, 0::1, 2047::11, 0::52, rest::binary>>, acc)) do 56 | deserialize(rest, %{acc | z: :inf}) 57 | end 58 | 59 | defp(deserialize(<<4, 3::16-signed, 1::1, 2047::11, 0::52, rest::binary>>, acc)) do 60 | deserialize(rest, %{acc | z: :"-inf"}) 61 | end 62 | 63 | defp(deserialize(<<4, 3::16-signed, sign::1, 2047::11, frac::52, rest::binary>>, acc)) do 64 | deserialize(rest, %{acc | z: %Thrift.NaN{sign: sign, fraction: frac}}) 65 | end 66 | 67 | defp(deserialize(<<4, 3::16-signed, value::float-signed, rest::binary>>, acc)) do 68 | deserialize(rest, %{acc | z: value}) 69 | end 70 | 71 | defp(deserialize(<>, acc)) do 72 | rest |> Thrift.Protocol.Binary.skip_field(field_type) |> deserialize(acc) 73 | end 74 | 75 | defp(deserialize(_, _)) do 76 | :error 77 | end 78 | 79 | def(serialize(%Calculator.Generated.Vector{x: x, y: y, z: z})) do 80 | [ 81 | case(x) do 82 | nil -> 83 | <<>> 84 | 85 | _ -> 86 | [ 87 | <<4, 1::16-signed>> 88 | | case(x) do 89 | :inf -> 90 | <<0::1, 2047::11, 0::52>> 91 | 92 | :"-inf" -> 93 | <<1::1, 2047::11, 0::52>> 94 | 95 | %Thrift.NaN{sign: sign, fraction: frac} -> 96 | <> 97 | 98 | _ -> 99 | <> 100 | end 101 | ] 102 | end, 103 | case(y) do 104 | nil -> 105 | <<>> 106 | 107 | _ -> 108 | [ 109 | <<4, 2::16-signed>> 110 | | case(y) do 111 | :inf -> 112 | <<0::1, 2047::11, 0::52>> 113 | 114 | :"-inf" -> 115 | <<1::1, 2047::11, 0::52>> 116 | 117 | %Thrift.NaN{sign: sign, fraction: frac} -> 118 | <> 119 | 120 | _ -> 121 | <> 122 | end 123 | ] 124 | end, 125 | case(z) do 126 | nil -> 127 | <<>> 128 | 129 | _ -> 130 | [ 131 | <<4, 3::16-signed>> 132 | | case(z) do 133 | :inf -> 134 | <<0::1, 2047::11, 0::52>> 135 | 136 | :"-inf" -> 137 | <<1::1, 2047::11, 0::52>> 138 | 139 | %Thrift.NaN{sign: sign, fraction: frac} -> 140 | <> 141 | 142 | _ -> 143 | <> 144 | end 145 | ] 146 | end 147 | | <<0>> 148 | ] 149 | end 150 | end 151 | 152 | def(serialize(struct)) do 153 | BinaryProtocol.serialize(struct) 154 | end 155 | 156 | def(serialize(struct, :binary)) do 157 | BinaryProtocol.serialize(struct) 158 | end 159 | 160 | def(deserialize(binary)) do 161 | BinaryProtocol.deserialize(binary) 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /test/support/lib/thrift_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ThriftTestCase do 2 | @moduledoc false 3 | @project_root Path.expand("../../../", __DIR__) 4 | 5 | use ExUnit.CaseTemplate 6 | 7 | using(opts) do 8 | dir_prefix = Path.join([@project_root, "tmp", inspect(__MODULE__)]) 9 | 10 | quote do 11 | Module.register_attribute(__MODULE__, :thrift_test_opts, persist: true) 12 | @thrift_test_opts unquote(opts) 13 | Module.register_attribute(__MODULE__, :thrift_test_dir, persist: true) 14 | @thrift_test_dir Path.join(unquote(dir_prefix), inspect(__MODULE__)) 15 | File.rm_rf!(@thrift_test_dir) 16 | File.mkdir_p!(@thrift_test_dir) 17 | import unquote(__MODULE__), only: [thrift_test: 2, thrift_test: 3] 18 | Module.register_attribute(__MODULE__, :thrift_file, accumulate: true) 19 | Module.register_attribute(__MODULE__, :thrift_elixir_modules, accumulate: true) 20 | Module.register_attribute(__MODULE__, :thrift_record_modules, accumulate: true) 21 | end 22 | end 23 | 24 | def implement?(module) do 25 | tag = 26 | module 27 | |> Module.get_attribute(:moduletag) 28 | |> Map.new(fn tag -> {tag, true} end) 29 | 30 | config = ExUnit.configuration() 31 | 32 | case ExUnit.Filters.eval(config[:include], config[:exclude], tag, []) do 33 | :ok -> 34 | true 35 | 36 | {:error, _} -> 37 | false 38 | end 39 | end 40 | 41 | def quoted_contents(module, contents) do 42 | compile_and_build_helpers(module) 43 | directives = quoted_directives(module) 44 | [directives, contents] 45 | end 46 | 47 | defp compile_and_build_helpers(module) do 48 | files = get_thrift_files(module) 49 | dir = Module.get_attribute(module, :thrift_test_dir) 50 | generate_files(files, module, dir) 51 | end 52 | 53 | defp get_thrift_files(module) do 54 | files = Module.get_attribute(module, :thrift_file) 55 | Module.delete_attribute(module, :thrift_file) 56 | Module.register_attribute(module, :thrift_file, accumulate: true) 57 | 58 | Enum.reverse(files) 59 | end 60 | 61 | defp write_thrift_file(config, namespace, dir) do 62 | filename = 63 | config 64 | |> Keyword.fetch!(:name) 65 | |> Path.expand(dir) 66 | 67 | contents = Keyword.fetch!(config, :contents) 68 | 69 | File.write!(filename, "namespace elixir #{inspect(namespace)}\n" <> contents) 70 | 71 | filename 72 | end 73 | 74 | defp require_file(file, dir) do 75 | case Code.require_file(file, dir) do 76 | nil -> 77 | [] 78 | 79 | modules -> 80 | parts = Enum.map(modules, fn {module, _} -> Module.split(module) end) 81 | for part <- parts, alias?(part, parts), do: Module.concat(part) 82 | end 83 | end 84 | 85 | defp alias?(module, modules) do 86 | # alias when parent module in namespace does not exist 87 | not Enum.any?(modules, &(:lists.prefix(&1, module) and &1 != module)) 88 | end 89 | 90 | defp generate_files(files, namespace, dir) do 91 | files 92 | |> Enum.map(&write_thrift_file(&1, namespace, dir)) 93 | |> Enum.map(&Thrift.Parser.parse_file_group!(&1)) 94 | |> Enum.flat_map(&Thrift.Generator.generate!(&1, dir)) 95 | |> Enum.flat_map(&require_file(&1, dir)) 96 | |> Enum.each(&Module.put_attribute(namespace, :thrift_elixir_modules, &1)) 97 | end 98 | 99 | defp quoted_directives(namespace) do 100 | elixir_modules = Module.get_attribute(namespace, :thrift_elixir_modules) 101 | record_modules = Module.get_attribute(namespace, :thrift_record_modules) 102 | 103 | quote do 104 | unquote_splicing( 105 | Enum.map(elixir_modules, fn module -> 106 | quote do: alias(unquote(module)) 107 | end) 108 | ) 109 | 110 | unquote_splicing( 111 | Enum.map(elixir_modules ++ record_modules, fn module -> 112 | quote do: require(unquote(module)) 113 | end) 114 | ) 115 | end 116 | end 117 | 118 | setup_all context do 119 | module = context[:module] || context[:case] 120 | attributes = module.__info__(:attributes) 121 | opts = attributes[:thrift_test_opts] 122 | [dir] = attributes[:thrift_test_dir] 123 | 124 | on_exit(fn -> 125 | if Keyword.get(opts, :cleanup, true) do 126 | File.rm_rf!(dir) 127 | else 128 | IO.puts(IO.ANSI.format([:yellow, "Leaving files in #{inspect(dir)}"])) 129 | end 130 | end) 131 | 132 | :ok 133 | end 134 | 135 | defmacro thrift_test(message, var \\ quote(do: _), do: block) do 136 | var = Macro.escape(var) 137 | block = Macro.escape(block, unquote: true) 138 | 139 | quote bind_quoted: [module: __MODULE__, message: message, var: var, block: block] do 140 | if module.implement?(__MODULE__) do 141 | contents = module.quoted_contents(__MODULE__, block) 142 | name = ExUnit.Case.register_test(__ENV__, :test, message, []) 143 | def unquote(name)(unquote(var)), do: unquote(contents) 144 | else 145 | ExUnit.Case.test message do 146 | flunk("not implemented") 147 | end 148 | end 149 | end 150 | end 151 | 152 | def inspect_quoted(block) do 153 | block 154 | |> Macro.to_string() 155 | |> IO.puts() 156 | 157 | block 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /src/thrift_lexer.xrl: -------------------------------------------------------------------------------- 1 | %% Copyright 2017 Pinterest, Inc. 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | Definitions. 16 | 17 | WHITESPACE = [\s\t\r\n]+ 18 | COMMENT = //[^\n]* 19 | CCOMMENT = /\*/*([^*/]|[^*]/|\*[^/])*\**\*/ 20 | COMMENTS = {COMMENT}|{CCOMMENT} 21 | UNIXCOMMENT = #[^\n]* 22 | 23 | INT = [+-]?[0-9]+ 24 | HEX = [+-]?0x[0-9A-Fa-f]+ 25 | BADDOUBLE = [+-]?[0-9]+[eE][+-]?[0-9]+ 26 | DOUBLE = [+-]?[0-9]+\.[0-9]+([eE][+-]?[0-9]+)? 27 | PUNCTUATOR = [\{\}\[\]\(\)<>,:;=\*] 28 | STRING = '(\\'|[^\'])*'|"(\\"|[^\"])*" 29 | BOOLEAN = true|false 30 | 31 | IDENTIFIER = [a-zA-Z_](\.[a-zA-Z_0-9]|[a-zA-Z_0-9])* 32 | 33 | KEYWORDS1 = namespace|include|cpp_include 34 | KEYWORDS2 = typedef|enum|union|struct|exception 35 | KEYWORDS3 = void|bool|byte|i8|i16|i32|i64|double|string|binary|list|map|set 36 | KEYWORDS4 = const|oneway|extends|throws|service|required|optional 37 | KEYWORD = {KEYWORDS1}|{KEYWORDS2}|{KEYWORDS3}|{KEYWORDS4} 38 | 39 | RESERVED1 = BEGIN|END|__CLASS__|__DIR__|__FILE__|__FUNCTION__|__LINE__ 40 | RESERVED2 = __METHOD__|__NAMESPACE__|abstract|alias|and|args|as|assert|begin 41 | RESERVED3 = break|case|catch|class|clone|continue|declare|def|default|del 42 | RESERVED4 = delete|do|dynamic|elif|else|elseif|elsif|end|enddeclare|endfor 43 | RESERVED5 = endforeach|endif|endswitch|endwhile|ensure|except|exec|finally 44 | RESERVED6 = float|for|foreach|from|function|global|goto|if|implements|import 45 | RESERVED7 = in|inline|instanceof|interface|is|lambda|module|native|new|next 46 | RESERVED8 = nil|not|or|package|pass|public|print|private|protected|raise|redo 47 | RESERVED9 = rescue|retry|register|return|self|sizeof|static|super|switch 48 | RESERVED10 = synchronized|then|this|throw|transient|try|undef|unless|unsigned 49 | RESERVED11 = until|use|var|virtual|volatile|when|while|with|xor|yield 50 | RESERVED12 = {RESERVED1}|{RESERVED2}|{RESERVED3}|{RESERVED4}|{RESERVED5} 51 | RESERVED13 = {RESERVED6}|{RESERVED7}|{RESERVED8}|{RESERVED9}|{RESERVED10} 52 | RESERVED = {RESERVED11}|{RESERVED12}|{RESERVED13} 53 | 54 | Rules. 55 | 56 | {WHITESPACE} : skip_token. 57 | {COMMENTS} : skip_token. 58 | {UNIXCOMMENT} : process_unix_comment(TokenChars, TokenLine). 59 | 60 | __file__ : {token, {file, TokenLine}}. 61 | {PUNCTUATOR} : {token, {list_to_atom(TokenChars), TokenLine}}. 62 | {KEYWORD} : {token, {list_to_atom(TokenChars), TokenLine}}. 63 | 64 | {INT} : {token, {int, TokenLine, list_to_integer(TokenChars)}}. 65 | {HEX} : {token, {int, TokenLine, hex_to_integer(TokenChars)}}. 66 | {DOUBLE} : {token, {double, TokenLine, list_to_float(TokenChars)}}. 67 | {BADDOUBLE} : {token, {double, TokenLine, bad_list_to_float(TokenChars)}}. 68 | {STRING} : {token, {string, TokenLine, process_string(TokenChars, TokenLen)}}. 69 | {BOOLEAN} : {token, {list_to_atom(TokenChars), TokenLine}}. 70 | 71 | {RESERVED} : reserved_keyword_error(TokenChars, TokenLine). 72 | 73 | {IDENTIFIER} : {token, {ident, TokenLine, TokenChars}}. 74 | 75 | Erlang code. 76 | 77 | process_unix_comment("#@namespace" ++ Rest, Line) -> {token, {namespace, Line}, Rest}; 78 | process_unix_comment("#" ++ _Rest, _Line) -> skip_token. 79 | 80 | hex_to_integer([$+|Chars]) -> hex_to_integer(Chars); 81 | hex_to_integer([$-|Chars]) -> -hex_to_integer(Chars); 82 | hex_to_integer([$0,$x|Chars]) -> list_to_integer(Chars, 16). 83 | 84 | % Erlang/Elixir can not parse integer significand in a float, so handle this case. 85 | 86 | bad_list_to_float(BadFloat) -> 87 | {Significand, Rest} = string:to_integer(BadFloat), 88 | GoodFloat = io_lib:format("~b.0~s", [Significand, Rest]), 89 | list_to_float(lists:flatten(GoodFloat)). 90 | 91 | % Process a quoted string by stripping its surrounding quote characters and 92 | % expanding any escape sequences (prefixed by a \). To keep things simple, 93 | % we're very lenient in that we allow any character to be escaped, and if the 94 | % character isn't "special" (like \n), we just return the unescaped character. 95 | % It might be nicer in the future to report "bad" escape characters, but that 96 | % would involve complicating this logic to allow a top-level {error, Reason} 97 | % result that could be returned to leex above. 98 | 99 | process_string(S,Len) -> process_chars(lists:sublist(S, 2, Len-2)). 100 | process_chars([$\\,$n|Chars]) -> [$\n|process_chars(Chars)]; 101 | process_chars([$\\,$r|Chars]) -> [$\r|process_chars(Chars)]; 102 | process_chars([$\\,$t|Chars]) -> [$\t|process_chars(Chars)]; 103 | process_chars([$\\,C|Chars]) -> [C|process_chars(Chars)]; 104 | process_chars([C|Chars]) -> [C|process_chars(Chars)]; 105 | process_chars([]) -> []. 106 | 107 | reserved_keyword_error(Keyword, _Line) -> 108 | Message = io_lib:format( 109 | "cannot use reserved language keyword \"~s\"", [Keyword]), 110 | {error, Message}. -------------------------------------------------------------------------------- /lib/thrift/generator/binary/framed/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator.Binary.Framed.Server do 2 | @moduledoc false 3 | alias Thrift.AST.Function 4 | 5 | alias Thrift.Generator.{ 6 | Service, 7 | Utils 8 | } 9 | 10 | alias Thrift.Parser.FileGroup 11 | 12 | def generate(service_module, service, file_group) do 13 | functions = 14 | service.functions 15 | |> Map.values() 16 | |> Enum.map(&generate_handler_function(file_group, service_module, &1)) 17 | 18 | quote do 19 | defmodule Binary.Framed.Server do 20 | @moduledoc false 21 | require Logger 22 | 23 | alias Thrift.Binary.Framed.Server, as: ServerImpl 24 | defdelegate stop(name), to: ServerImpl 25 | 26 | def start_link(handler_module, port, opts \\ []) do 27 | ServerImpl.start_link(__MODULE__, port, handler_module, opts) 28 | end 29 | 30 | unquote_splicing(functions) 31 | 32 | def handle_thrift(method, _binary_data, _handler_module) do 33 | error = 34 | Thrift.TApplicationException.exception( 35 | type: :unknown_method, 36 | message: "Unknown method: #{method}" 37 | ) 38 | 39 | {:client_error, error} 40 | end 41 | end 42 | end 43 | end 44 | 45 | def generate_handler_function(file_group, service_module, %Function{params: []} = function) do 46 | fn_name = Atom.to_string(function.name) 47 | handler_fn_name = Utils.underscore(function.name) 48 | response_module = Module.concat(service_module, Service.module_name(function, :response)) 49 | handler_args = [] 50 | body = build_responder(function.return_type, handler_fn_name, handler_args, response_module) 51 | handler = wrap_with_try_catch(body, function, file_group, response_module) 52 | 53 | quote do 54 | def handle_thrift(unquote(fn_name), _binary_data, handler_module) do 55 | unquote(handler) 56 | end 57 | end 58 | end 59 | 60 | def generate_handler_function(file_group, service_module, function) do 61 | fn_name = Atom.to_string(function.name) 62 | args_module = Module.concat(service_module, Service.module_name(function, :args)) 63 | response_module = Module.concat(service_module, Service.module_name(function, :response)) 64 | 65 | struct_matches = 66 | Enum.map(function.params, fn param -> 67 | {param.name, Macro.var(param.name, nil)} 68 | end) 69 | 70 | quote do 71 | def handle_thrift(unquote(fn_name), binary_data, handler_module) do 72 | case unquote(Module.concat(args_module, BinaryProtocol)).deserialize(binary_data) do 73 | {%unquote(args_module){unquote_splicing(struct_matches)}, ""} -> 74 | unquote(build_handler_call(file_group, function, response_module)) 75 | 76 | {_, extra} -> 77 | raise Thrift.TApplicationException, 78 | type: :protocol_error, 79 | message: "Could not decode #{inspect(extra)}" 80 | end 81 | end 82 | end 83 | end 84 | 85 | defp build_handler_call(file_group, function, response_module) do 86 | handler_fn_name = Utils.underscore(function.name) 87 | handler_args = Enum.map(function.params, &Macro.var(&1.name, nil)) 88 | body = build_responder(function.return_type, handler_fn_name, handler_args, response_module) 89 | wrap_with_try_catch(body, function, file_group, response_module) 90 | end 91 | 92 | defp wrap_with_try_catch(body, function, file_group, response_module) do 93 | # Quoted clauses for exception types defined by the schema. 94 | exception_clauses = 95 | Enum.flat_map(function.exceptions, fn 96 | exc -> 97 | resolved = FileGroup.resolve(file_group, exc) 98 | dest_module = FileGroup.dest_module(file_group, resolved.type) 99 | error_var = Macro.var(exc.name, nil) 100 | field_setter = quote do: {unquote(exc.name), unquote(error_var)} 101 | 102 | quote do 103 | :error, %unquote(dest_module){} = unquote(error_var) -> 104 | response = %unquote(response_module){unquote(field_setter)} 105 | 106 | {:reply, 107 | unquote(Module.concat(response_module, BinaryProtocol)).serialize(response)} 108 | end 109 | end) 110 | 111 | # Quoted clauses for our standard catch clauses (common to all functions). 112 | catch_clauses = 113 | quote do 114 | kind, reason -> 115 | formatted_exception = Exception.format(kind, reason, __STACKTRACE__) 116 | Logger.error("Exception not defined in thrift spec was thrown: #{formatted_exception}") 117 | 118 | error = 119 | Thrift.TApplicationException.exception( 120 | type: :internal_error, 121 | message: "Server error: #{formatted_exception}" 122 | ) 123 | 124 | {:server_error, error} 125 | end 126 | 127 | quote do 128 | try do 129 | unquote(body) 130 | catch 131 | unquote(Enum.concat(exception_clauses, catch_clauses)) 132 | end 133 | end 134 | end 135 | 136 | defp build_responder(:void, handler_fn_name, handler_args, _response_module) do 137 | quote do 138 | _result = handler_module.unquote(handler_fn_name)(unquote_splicing(handler_args)) 139 | :noreply 140 | end 141 | end 142 | 143 | defp build_responder(_, handler_fn_name, handler_args, response_module) do 144 | quote do 145 | result = handler_module.unquote(handler_fn_name)(unquote_splicing(handler_args)) 146 | response = %unquote(response_module){success: result} 147 | {:reply, unquote(Module.concat(response_module, BinaryProtocol)).serialize(response)} 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, 6 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, 10 | "excoveralls": {:hex, :excoveralls, "0.14.5", "5c685449596e962c779adc8f4fb0b4de3a5b291c6121097572a3aa5400c386d3", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9b4a9bf10e9a6e48b94159e13b4b8a1b05400f17ac16cc363ed8734f26e1f4e"}, 11 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 12 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 13 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 14 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 15 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 22 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 23 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 24 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 26 | } 27 | -------------------------------------------------------------------------------- /test/thrift/parser/resolver_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ResolverTest do 2 | use ExUnit.Case 3 | 4 | alias Thrift.AST.{ 5 | Field, 6 | Service, 7 | Struct, 8 | TEnum, 9 | Union 10 | } 11 | 12 | alias Thrift.Parser.FileGroup 13 | 14 | use ThriftTestHelpers 15 | 16 | test "it should be able resolve Struct Refs and fields" do 17 | with_thrift_files( 18 | "core/shared.thrift": """ 19 | struct User { 20 | 1: i64 id, 21 | 2: string username, 22 | 3: string email 23 | } 24 | """, 25 | "utils.thrift": """ 26 | include "core/shared.thrift" 27 | service Users { 28 | shared.User find_by_id(1: i64 user_id); 29 | void delete_user(1: shared.User user); 30 | } 31 | """, 32 | as: :file_group, 33 | parse: "utils.thrift" 34 | ) do 35 | service = file_group.schemas["utils"].services[:Users] 36 | return_ref = service.functions[:find_by_id].return_type 37 | 38 | refute is_nil(return_ref) 39 | 40 | resolved_struct = FileGroup.resolve(file_group, return_ref) 41 | 42 | assert resolved_struct == file_group.schemas["shared"].structs[:User] 43 | 44 | [field] = service.functions[:delete_user].params 45 | resolved = FileGroup.resolve(file_group, field) 46 | 47 | assert %Field{} = resolved 48 | assert resolved.type == file_group.schemas["shared"].structs[:User] 49 | end 50 | end 51 | 52 | test "resolving non-resolvable types is a no-op" do 53 | with_thrift_files( 54 | "utils.thrift": """ 55 | service NoOp { 56 | } 57 | """, 58 | as: :file_group, 59 | parse: "utils.thrift" 60 | ) do 61 | assert 43 == FileGroup.resolve(file_group, 43) 62 | assert [1, 2, 3] == FileGroup.resolve(file_group, [1, 2, 3]) 63 | end 64 | end 65 | 66 | test "it should be able to resolve services" do 67 | with_thrift_files( 68 | "core/shared.thrift": """ 69 | service Shared { 70 | bool get_shared(1: i64 id); 71 | } 72 | """, 73 | "extendo.thrift": """ 74 | include "core/shared.thrift" 75 | 76 | service Extend extends shared.Shared { 77 | i64 get_extendo_value(); 78 | } 79 | """, 80 | parse: "extendo.thrift" 81 | ) do 82 | shared = FileGroup.resolve(file_group, :"shared.Shared") 83 | 84 | assert %Service{} = shared 85 | assert :get_shared in Map.keys(shared.functions) 86 | 87 | extendo = FileGroup.resolve(file_group, :"extendo.Extend") 88 | assert %Service{} = extendo 89 | assert :get_extendo_value in Map.keys(extendo.functions) 90 | end 91 | end 92 | 93 | test "it should handle following includes through several files" do 94 | with_thrift_files( 95 | "core/states.thrift": """ 96 | enum UserState { 97 | ACTIVE, 98 | LAPSED, 99 | DISABLED 100 | } 101 | """, 102 | "core/models.thrift": """ 103 | include "states.thrift" 104 | 105 | exception UserNotFound { 106 | 1: i64 user_id; 107 | } 108 | 109 | struct User { 110 | 1: i64 user_id, 111 | 2: string username, 112 | 3: string first_name, 113 | 4: string last_name, 114 | 5: states.UserState state; 115 | } 116 | """, 117 | "user_service/user_service.thrift": """ 118 | include "../core/models.thrift" 119 | service UserService { 120 | models.User get_by_id(1: i64 user_id) throws (1: models.UserNotFound unf), 121 | void set_username(1: models.User user, 2: string username); 122 | } 123 | """, 124 | parse: "user_service/user_service.thrift" 125 | ) do 126 | user_state = FileGroup.resolve(file_group, :"states.UserState") 127 | 128 | assert %TEnum{values: [ACTIVE: 0, LAPSED: 1, DISABLED: 2]} = user_state 129 | assert user_state.name == :"states.UserState" 130 | end 131 | end 132 | 133 | test "it should be able to resolve complex includes" do 134 | with_thrift_files( 135 | "includes/enums.thrift": """ 136 | enum JobStatus { 137 | STOPPED, 138 | RUNNING, 139 | FAILED 140 | } 141 | """, 142 | "includes/unions.thrift": """ 143 | include "structs.thrift" 144 | 145 | union JobSubject { 146 | 1: structs.User user, 147 | 2: structs.System sys; 148 | } 149 | """, 150 | "includes/exceptions.thrift": """ 151 | """, 152 | "includes/structs.thrift": """ 153 | struct User { 154 | 1: i64 id, 155 | 2: string username, 156 | 3: string first_name, 157 | 4: string last_name; 158 | } 159 | 160 | struct System { 161 | 1: string name, 162 | 2: string hostname 163 | } 164 | """, 165 | "job_service.thrift": """ 166 | include "includes/unions.thrift" 167 | include "includes/enums.thrift" 168 | include "includes/structs.thrift" 169 | 170 | struct Job { 171 | 1: i64 job_id, 172 | 2: unions.JobSubject subject, 173 | 3: structs.User requester; 174 | } 175 | 176 | service JobService { 177 | i64 submit(1: Job job), 178 | enums.JobStatus get_status(1: i64 job_id), 179 | boolean cancel(1: i64 job_id); 180 | } 181 | 182 | """, 183 | parse: "job_service.thrift" 184 | ) do 185 | job = FileGroup.resolve(file_group, :"job_service.Job") 186 | 187 | assert %Struct{name: :"job_service.Job"} = job 188 | assert %Struct{name: :"structs.User"} = FileGroup.resolve(file_group, :"structs.User") 189 | assert %Union{} = FileGroup.resolve(file_group, :"unions.JobSubject") 190 | assert %TEnum{} = FileGroup.resolve(file_group, :"enums.JobStatus") 191 | end 192 | end 193 | 194 | test "it should be able resolve qualified and non-qualified names" do 195 | with_thrift_files( 196 | "included.thrift": """ 197 | enum SortType { 198 | DESC = 100, 199 | ASC = 110 200 | } 201 | """, 202 | "local.thrift": """ 203 | include "included.thrift" 204 | enum SortType { 205 | DESC, 206 | ASC 207 | } 208 | 209 | const SortType SORT1 = SortType.ASC; 210 | const included.SortType SORT2 = included.SortType.ASC; 211 | """, 212 | as: :file_group, 213 | parse: "local.thrift" 214 | ) do 215 | file_group = FileGroup.set_current_module(file_group, :local) 216 | 217 | sort1 = FileGroup.resolve(file_group, :"local.SORT1") 218 | assert %TEnum{name: :"local.SortType"} = FileGroup.resolve(file_group, sort1.type) 219 | assert 1 == FileGroup.resolve(file_group, sort1.value) 220 | 221 | sort2 = FileGroup.resolve(file_group, :"local.SORT2") 222 | assert %TEnum{name: :"included.SortType"} = FileGroup.resolve(file_group, sort2.type) 223 | assert 110 == FileGroup.resolve(file_group, sort2.value) 224 | 225 | local_sort = FileGroup.resolve(file_group, :SortType) 226 | assert %TEnum{name: :"local.SortType", values: [DESC: 0, ASC: 1], line: 2} == local_sort 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /lib/thrift/generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Generator do 2 | @moduledoc """ 3 | This module provides functions for generating Elixir source code from Thrift 4 | IDL files (`.thrift`). 5 | """ 6 | 7 | alias Thrift.AST.{Constant, Schema} 8 | 9 | alias Thrift.{ 10 | Generator, 11 | Generator.ConstantGenerator, 12 | Generator.EnumGenerator, 13 | Generator.StructGenerator 14 | } 15 | 16 | alias Thrift.Parser.FileGroup 17 | 18 | @doc """ 19 | Returns the list of target paths that would be generated from a Thrift file. 20 | """ 21 | @spec targets(FileGroup.t()) :: [Path.t()] 22 | def targets(%FileGroup{} = file_group) do 23 | Enum.flat_map(file_group.schemas, fn {_, schema} -> 24 | schema 25 | |> Map.put(:file_group, file_group) 26 | |> generate_schema 27 | |> Enum.map(fn {name, _} -> target_path(name) end) 28 | end) 29 | end 30 | 31 | @spec target_path(String.t()) :: Path.t() 32 | defp target_path(module_name) do 33 | module_name 34 | |> inspect 35 | |> String.split(".") 36 | |> Enum.map(&Macro.underscore/1) 37 | |> Path.join() 38 | |> Kernel.<>(".ex") 39 | end 40 | 41 | def generate!(%FileGroup{} = file_group, output_dir) do 42 | Enum.flat_map(file_group.schemas, fn {_, schema} -> 43 | schema 44 | |> Map.put(:file_group, file_group) 45 | |> generate_schema 46 | |> write_schema_to_file(output_dir) 47 | end) 48 | end 49 | 50 | def generate_to_string!(%FileGroup{} = file_group) do 51 | Enum.flat_map(file_group.schemas, fn {_, schema} -> 52 | generate_schema(%Schema{schema | file_group: file_group}) 53 | end) 54 | |> Enum.reverse() 55 | |> Enum.map(fn {_, code} -> 56 | Macro.to_string(code) 57 | end) 58 | |> Enum.join("\n") 59 | end 60 | 61 | def generate_schema(schema) do 62 | current_module_file_group = FileGroup.set_current_module(schema.file_group, schema.module) 63 | schema = %Schema{schema | file_group: current_module_file_group} 64 | 65 | List.flatten([ 66 | generate_enum_modules(schema), 67 | generate_const_modules(schema), 68 | generate_struct_modules(schema), 69 | generate_union_modules(schema), 70 | generate_exception_modules(schema), 71 | generate_services(schema), 72 | generate_behaviours(schema) 73 | ]) 74 | end 75 | 76 | defp write_schema_to_file(generated_modules, output_dir) do 77 | generated_modules 78 | |> resolve_name_collisions 79 | |> Enum.map(fn {name, quoted} -> 80 | filename = target_path(name) 81 | source = Macro.to_string(quoted) 82 | 83 | path = Path.join(output_dir, filename) 84 | 85 | path 86 | |> Path.dirname() 87 | |> File.mkdir_p!() 88 | 89 | File.write!(path, source) 90 | 91 | filename 92 | end) 93 | end 94 | 95 | defp resolve_name_collisions(generated_modules) do 96 | Enum.reduce(generated_modules, [], fn {name, quoted}, acc -> 97 | Keyword.update( 98 | acc, 99 | name, 100 | quoted, 101 | &resolve_name_collision(name, &1, quoted) 102 | ) 103 | end) 104 | end 105 | 106 | # We resolve name collisions (two generated modules with the same name) 107 | # pairwise by inspecting the types of modules generated. 108 | # Most collisions cannot be resolved. Constants can be merged into 109 | # modules that define other types (structs, etc). 110 | defp resolve_name_collision(name, q1, q2) do 111 | # breaks apart the module's ast and gets the parts we need 112 | {meta1, ast1} = get_meta_and_ast(q1) 113 | {meta2, ast2} = get_meta_and_ast(q2) 114 | 115 | # the context will tell us what type (e.g., Enum, Constant, etc.) 116 | # was defined by each module 117 | context1 = Keyword.get(meta1, :context) 118 | context2 = Keyword.get(meta2, :context) 119 | 120 | # only allow constants to be merged into other modules 121 | # but make sure the meta is for the not-constant module, so that 122 | # subsequent collisions are properly dealt with 123 | cond do 124 | context1 == Thrift.Generator.ConstantGenerator -> 125 | combine_module_defs(name, meta2, ast1, ast2) 126 | 127 | context2 == Thrift.Generator.ConstantGenerator -> 128 | combine_module_defs(name, meta1, ast1, ast2) 129 | 130 | true -> 131 | raise "Name collision: #{name}" 132 | end 133 | end 134 | 135 | defp get_meta_and_ast(quoted) do 136 | {:defmodule, meta, [_name, [do: {:__block__, [], ast}]]} = quoted 137 | {meta, ast} 138 | end 139 | 140 | defp combine_module_defs(name, meta, ast1, ast2) do 141 | {:defmodule, meta, [name, [do: {:__block__, [], ast1 ++ ast2}]]} 142 | end 143 | 144 | defp generate_enum_modules(schema) do 145 | for {_, enum} <- schema.enums do 146 | full_name = FileGroup.dest_module(schema.file_group, enum) 147 | {full_name, EnumGenerator.generate(full_name, enum)} 148 | end 149 | end 150 | 151 | defp generate_const_modules(%Schema{constants: constants}) 152 | when constants == %{} do 153 | # no constants => nothing to generate 154 | [] 155 | end 156 | 157 | defp generate_const_modules(schema) do 158 | # schema.constants is a map %{name: constant} but constant includes the 159 | # name and all we really need is the values 160 | # 161 | # we also only want constants that are defined in the main file from this 162 | # file group 163 | constants = 164 | schema.constants 165 | |> Map.values() 166 | |> Enum.filter(&FileGroup.own_constant?(schema.file_group, &1)) 167 | 168 | if Enum.empty?(constants) do 169 | # if we filtered out all of the constants, we don't need to write 170 | # anything 171 | [] 172 | else 173 | # name of the generated module 174 | full_name = FileGroup.dest_module(schema.file_group, Constant) 175 | [{full_name, ConstantGenerator.generate(full_name, constants, schema)}] 176 | end 177 | end 178 | 179 | defp generate_struct_modules(schema) do 180 | for {_, struct} <- schema.structs do 181 | full_name = FileGroup.dest_module(schema.file_group, struct) 182 | {full_name, StructGenerator.generate(:struct, schema, full_name, struct)} 183 | end 184 | end 185 | 186 | defp generate_union_modules(schema) do 187 | for {_, union} <- schema.unions do 188 | full_name = FileGroup.dest_module(schema.file_group, union) 189 | {full_name, StructGenerator.generate(:union, schema, full_name, union)} 190 | end 191 | end 192 | 193 | defp generate_exception_modules(schema) do 194 | for {_, exception} <- schema.exceptions do 195 | full_name = FileGroup.dest_module(schema.file_group, exception) 196 | {full_name, StructGenerator.generate(:exception, schema, full_name, exception)} 197 | end 198 | end 199 | 200 | defp generate_services(schema) do 201 | for {_, service} <- schema.services do 202 | Generator.Service.generate(schema, service) 203 | end 204 | end 205 | 206 | defp generate_behaviours(schema) do 207 | for {_, service} <- schema.services do 208 | Generator.Behaviour.generate(schema, service) 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/mix/tasks/compile.thrift.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Compile.Thrift do 2 | use Mix.Task.Compiler 3 | alias Mix.Task.Compiler.Diagnostic 4 | alias Thrift.Parser.FileGroup 5 | 6 | @recursive true 7 | @manifest ".compile.thrift" 8 | @manifest_vsn :v1 9 | 10 | @moduledoc """ 11 | Generates Elixir source files from Thrift IDL files 12 | 13 | When this task runs, it first checks the modification times of all source 14 | files that were generated by the set of `.thrift` files. If any generated 15 | files are older than the `.thrift` file that generated them, they won't be 16 | regenerated. 17 | 18 | ## Command line options 19 | 20 | * `--force` - forces compilation regardless of modification times 21 | * `--verbose` - enable verbose compile task logging 22 | 23 | ## Configuration 24 | 25 | * `:files` - list of `.thrift` IDL files to compile 26 | * `:include_paths` - list of additional directory paths in which to 27 | search for included files 28 | * `:namespace` - default namespace for generated modules, which will 29 | be used when a Thrift file doesn't define its own `elixir` namespace. 30 | This value may be given as a string or atom. `nil` indicates that the 31 | files should be generated in the `:output_path` root. Defaults to 32 | `"Thrift.Generated"`. 33 | * `:output_path` - output directory into which the generated Elixir 34 | source files will be generated. Defaults to `"lib"`. 35 | 36 | These should be set in you project config in the `:thrift` key as in the 37 | example below. 38 | 39 | ``` 40 | # example mix.exs 41 | defmodule MyProject.Mixfile do 42 | # ... 43 | 44 | def project do 45 | [ 46 | # other settings... 47 | thrift: [ 48 | files: Path.wildcard("thrift/**/*.thrift"), 49 | output_path: "lib/generated" 50 | ] 51 | ] 52 | end 53 | end 54 | ``` 55 | """ 56 | 57 | @switches [force: :boolean, verbose: :boolean] 58 | 59 | @impl true 60 | def run(args) do 61 | {opts, _, _} = OptionParser.parse(args, switches: @switches) 62 | 63 | config = Keyword.get(Mix.Project.config(), :thrift, []) 64 | input_files = Keyword.get(config, :files, []) 65 | output_path = Keyword.get(config, :output_path, "lib") 66 | 67 | parser_opts = 68 | config 69 | |> Keyword.take([:include_paths, :namespace]) 70 | |> Keyword.put_new(:namespace, "Thrift.Generated") 71 | 72 | {_, diagnostics} = 73 | result = 74 | case parse(input_files, parser_opts) do 75 | {[], []} -> 76 | {:noop, []} 77 | 78 | {groups, [] = _diagnostics} -> 79 | groups 80 | |> extract_targets(output_path, opts[:force]) 81 | |> generate(manifest(), output_path, opts) 82 | 83 | {_groups, diagnostics} -> 84 | {:error, diagnostics} 85 | end 86 | 87 | Enum.each(diagnostics, &print_diagnostic/1) 88 | result 89 | end 90 | 91 | @impl true 92 | def manifests, do: [manifest()] 93 | defp manifest, do: Path.join(Mix.Project.manifest_path(), @manifest) 94 | 95 | @impl true 96 | def clean, do: clean(manifest()) 97 | 98 | defp clean(manifest) do 99 | Enum.each(read_manifest(manifest), &File.rm/1) 100 | File.rm(manifest) 101 | end 102 | 103 | @spec parse([Path.t()], Thrift.Parser.opts()) :: {[FileGroup.t()], [Diagnostic.t()]} 104 | defp parse(files, opts) do 105 | files 106 | |> Enum.reverse() 107 | |> Enum.reduce({[], []}, fn file, {groups, diagnostics} -> 108 | case Thrift.Parser.parse_file_group(file, opts) do 109 | {:ok, group} -> 110 | {[group | groups], diagnostics} 111 | 112 | {:error, errors} -> 113 | {groups, Enum.map(errors, &diagnostic/1) ++ diagnostics} 114 | end 115 | end) 116 | end 117 | 118 | @typep mappings :: [{:stale, FileGroup.t(), [Path.t()]} | {:ok, FileGroup.t(), [Path.t()]}] 119 | 120 | @spec extract_targets([FileGroup.t()], Path.t(), boolean) :: mappings 121 | defp extract_targets(groups, output_path, force) when is_list(groups) do 122 | for %FileGroup{initial_file: file} = group <- groups do 123 | targets = 124 | group 125 | |> Thrift.Generator.targets() 126 | |> Enum.map(&Path.join(output_path, &1)) 127 | 128 | if force || Mix.Utils.stale?([file], targets) do 129 | {:stale, group, targets} 130 | else 131 | {:ok, group, targets} 132 | end 133 | end 134 | end 135 | 136 | @spec generate(mappings, Path.t(), Path.t(), OptionParser.parsed()) :: 137 | {:ok | :noop | :error, [Diagnostic.t()]} 138 | defp generate(mappings, manifest, output_path, opts) do 139 | timestamp = :calendar.universal_time() 140 | verbose = opts[:verbose] 141 | 142 | # Load the list of previously-generated files. 143 | previous = read_manifest(manifest) 144 | 145 | # Determine which of our current targets are in need of (re)generation. 146 | stale = for {:stale, group, targets} <- mappings, do: {group, targets} 147 | 148 | # Determine if there are any files that appear in our existing manifest 149 | # that are no longer relevant based on our current target mappings. 150 | removed = 151 | Enum.filter(previous, fn file -> 152 | not Enum.any?(mappings, fn {_, _, targets} -> file in targets end) 153 | end) 154 | 155 | if stale == [] && removed == [] do 156 | {:noop, []} 157 | else 158 | # Ensure we have an output directory and remove old target files. 159 | File.mkdir_p!(output_path) 160 | Enum.each(removed, &File.rm/1) 161 | 162 | unless Enum.empty?(stale) do 163 | Mix.Utils.compiling_n(length(stale), :thrift) 164 | 165 | Enum.each(stale, fn {group, _targets} -> 166 | Thrift.Generator.generate!(group, output_path) 167 | verbose && Mix.shell().info("Compiled #{group.initial_file}") 168 | end) 169 | end 170 | 171 | # Update and rewrite the manifest. 172 | entries = (previous -- removed) ++ Enum.flat_map(stale, &elem(&1, 1)) 173 | write_manifest(manifest, :lists.usort(entries), timestamp) 174 | {:ok, []} 175 | end 176 | end 177 | 178 | defp diagnostic({file, line, message}, severity \\ :error) do 179 | %Diagnostic{ 180 | file: file, 181 | position: line, 182 | message: message, 183 | severity: severity, 184 | compiler_name: "Thrift" 185 | } 186 | end 187 | 188 | defp print_diagnostic(%Diagnostic{file: file, position: pos, message: msg, severity: severity}) do 189 | color = if severity == :error, do: IO.ANSI.light_red(), else: IO.ANSI.yellow() 190 | Mix.shell().error("#{color}#{severity}: #{IO.ANSI.reset()}#{msg}\n #{file}:#{pos}\n\n") 191 | end 192 | 193 | defp package_vsn do 194 | Keyword.get(Mix.Project.config(), :version) 195 | end 196 | 197 | @spec read_manifest(Path.t()) :: [Path.t()] 198 | defp read_manifest(manifest) do 199 | header = {@manifest_vsn, package_vsn()} 200 | 201 | try do 202 | manifest 203 | |> File.read!() 204 | |> :erlang.binary_to_term() 205 | rescue 206 | _ -> [] 207 | else 208 | [^header | paths] -> paths 209 | _ -> [] 210 | end 211 | end 212 | 213 | @spec write_manifest(Path.t(), [Path.t()], :calendar.datetime()) :: :ok 214 | defp write_manifest(manifest, paths, timestamp) do 215 | data = 216 | :erlang.term_to_binary( 217 | [{@manifest_vsn, package_vsn()} | paths], 218 | compressed: 9 219 | ) 220 | 221 | File.mkdir_p!(Path.dirname(manifest)) 222 | File.write!(manifest, data) 223 | File.touch!(manifest, timestamp) 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /src/thrift_parser.yrl: -------------------------------------------------------------------------------- 1 | Header 2 | "%% Copyright 2017 Pinterest, Inc." 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 | Nonterminals 17 | Schema File 18 | Headers Header 19 | Include CppInclude Namespace 20 | Definitions Definition 21 | Typedef Struct Union Exception 22 | Const ConstValue ConstList ConstMap 23 | Enum EnumList EnumValue 24 | Service Extends 25 | FunctionList Function Oneway ReturnType Throws 26 | FieldList Field FieldIdentifier FieldRequired FieldDefault 27 | FieldType BaseType MapType SetType ListType 28 | Annotations Annotation AnnotationList 29 | Separator. 30 | 31 | Terminals 32 | '*' '{' '}' '[' ']' '(' ')' '=' '>' '<' ',' ':' ';' 33 | file 34 | include cpp_include namespace 35 | int ident 36 | bool byte i8 i16 i32 i64 double string binary list map set 37 | true false 38 | typedef const enum struct union service exception 39 | void oneway required optional extends throws. 40 | 41 | Rootsymbol Schema. 42 | 43 | % Schema 44 | 45 | Schema -> Headers Definitions File: 46 | build_node('Schema', ['$3', '$1', '$2']). 47 | 48 | File -> '$empty': nil. 49 | File -> file string: 'Elixir.List':to_string(unwrap('$2')). 50 | 51 | % Headers 52 | 53 | Headers -> '$empty': []. 54 | Headers -> Header Headers: ['$1'|'$2']. 55 | 56 | Header -> Include: '$1'. 57 | Header -> CppInclude: '$1'. 58 | Header -> Namespace: '$1'. 59 | 60 | Include -> include string: 61 | build_node('Include', line('$1'), [unwrap('$2')]). 62 | 63 | CppInclude -> cpp_include string: 64 | build_node('CppInclude', line('$1'), [unwrap('$2')]). 65 | 66 | Namespace -> namespace '*' ident: 67 | build_node('Namespace', line('$1'), ["*", unwrap('$3')]). 68 | Namespace -> namespace ident ident: 69 | build_node('Namespace', line('$1'), [unwrap('$2'), unwrap('$3')]). 70 | 71 | % Definitions 72 | 73 | Definitions -> '$empty': []. 74 | Definitions -> Definition Definitions: ['$1'|'$2']. 75 | 76 | Definition -> Const: '$1'. 77 | Definition -> Typedef: '$1'. 78 | Definition -> Enum: '$1'. 79 | Definition -> Struct: '$1'. 80 | Definition -> Union: '$1'. 81 | Definition -> Exception: '$1'. 82 | Definition -> Service: '$1'. 83 | 84 | % Constants 85 | 86 | Const -> const FieldType ident '=' ConstValue Separator: 87 | build_node('Constant', line('$1'), [unwrap('$3'), '$5', '$2']). 88 | 89 | ConstValue -> ident: build_node('ValueRef', line('$1'), [unwrap('$1')]). 90 | ConstValue -> true: unwrap('$1'). 91 | ConstValue -> false: unwrap('$1'). 92 | ConstValue -> int: unwrap('$1'). 93 | ConstValue -> double: unwrap('$1'). 94 | ConstValue -> string: unwrap('$1'). 95 | 96 | ConstValue -> '{' ConstMap '}': '$2'. 97 | ConstValue -> '[' ConstList ']': '$2'. 98 | 99 | ConstMap -> '$empty': []. 100 | ConstMap -> ConstValue ':' ConstValue Separator ConstMap: [{'$1','$3'}|'$5']. 101 | 102 | ConstList -> '$empty': []. 103 | ConstList -> ConstValue Separator ConstList: ['$1'|'$3']. 104 | 105 | % Typedef 106 | 107 | Typedef -> typedef FieldType Annotations ident Annotations Separator: 108 | {typedef, '$2', unwrap('$4')}. 109 | 110 | % Enum 111 | 112 | Enum -> enum ident '{' EnumList '}' Annotations: 113 | build_node('TEnum', line('$1'), '$6', [unwrap('$2'), '$4']). 114 | 115 | EnumList -> EnumValue Separator: ['$1']. 116 | EnumList -> EnumValue Separator EnumList: ['$1'|'$3']. 117 | 118 | EnumValue -> ident '=' int Annotations: {unwrap('$1'), unwrap('$3')}. 119 | EnumValue -> ident Annotations: unwrap('$1'). 120 | 121 | % Struct 122 | 123 | Struct -> struct ident '{' FieldList '}' Annotations: 124 | build_node('Struct', line('$1'), '$6', [unwrap('$2'), '$4']). 125 | 126 | % Union 127 | 128 | Union -> union ident '{' FieldList '}' Annotations: 129 | build_node('Union', line('$1'), '$6', [unwrap('$2'), '$4']). 130 | 131 | % Exception 132 | 133 | Exception -> exception ident '{' FieldList '}' Annotations: 134 | build_node('Exception', line('$1'), '$6', [unwrap('$2'), '$4']). 135 | 136 | % Service 137 | 138 | Service -> service ident Extends '{' FunctionList '}' Annotations: 139 | build_node('Service', line('$1'), '$7', [unwrap('$2'), '$5', '$3']). 140 | 141 | Extends -> extends ident: list_to_atom(unwrap('$2')). 142 | Extends -> '$empty': nil. 143 | 144 | % Functions 145 | 146 | FunctionList -> '$empty': []. 147 | FunctionList -> Function FunctionList: ['$1'|'$2']. 148 | 149 | Function -> Oneway ReturnType ident '(' FieldList ')' Throws Annotations Separator: 150 | build_node('Function', line('$3'), '$8', ['$1', '$2', unwrap('$3'), '$5', '$7']). 151 | 152 | Oneway -> '$empty': false. 153 | Oneway -> oneway: true. 154 | 155 | ReturnType -> void: void. 156 | ReturnType -> FieldType: '$1'. 157 | 158 | Throws -> '$empty': []. 159 | Throws -> throws '(' FieldList ')': '$3'. 160 | 161 | % Fields 162 | 163 | FieldList -> '$empty': []. 164 | FieldList -> Field FieldList: ['$1'|'$2']. 165 | 166 | Field -> FieldIdentifier FieldRequired FieldType Annotations ident FieldDefault Annotations Separator: 167 | build_node('Field', line('$5'), '$7', ['$1', '$2', '$3', unwrap('$5'), '$6']). 168 | 169 | FieldIdentifier -> int ':': unwrap('$1'). 170 | FieldIdentifier -> '$empty': nil. 171 | 172 | FieldRequired -> required: true. 173 | FieldRequired -> optional: false. 174 | FieldRequired -> '$empty': default. 175 | 176 | FieldDefault -> '$empty': nil. 177 | FieldDefault -> '=' ConstValue: '$2'. 178 | 179 | % Types 180 | 181 | FieldType -> ident: build_node('TypeRef', line('$1'), [unwrap('$1')]). 182 | FieldType -> BaseType: '$1'. 183 | FieldType -> MapType: {map, '$1'}. 184 | FieldType -> SetType: {set, '$1'}. 185 | FieldType -> ListType: {list, '$1'}. 186 | 187 | BaseType -> bool: bool. 188 | BaseType -> byte: i8. 189 | BaseType -> i8: i8. 190 | BaseType -> i16: i16. 191 | BaseType -> i32: i32. 192 | BaseType -> i64: i64. 193 | BaseType -> double: double. 194 | BaseType -> string: string. 195 | BaseType -> binary: binary. 196 | 197 | MapType -> map '<' FieldType Annotations ',' FieldType Annotations '>': {'$3', '$6'}. 198 | SetType -> set '<' FieldType Annotations '>': '$3'. 199 | ListType -> list '<' FieldType Annotations '>': '$3'. 200 | 201 | % Annotations 202 | 203 | Annotations -> '(' AnnotationList ')': '$2'. 204 | Annotations -> '$empty': #{}. 205 | 206 | Annotation -> ident Separator: 207 | #{list_to_atom(unwrap('$1')) => <<"1">>}. 208 | Annotation -> ident '=' string Separator: 209 | #{list_to_atom(unwrap('$1')) => list_to_binary(unwrap('$3'))}. 210 | 211 | AnnotationList -> '$empty': #{}. 212 | AnnotationList -> Annotation AnnotationList: maps:merge('$1', '$2'). 213 | 214 | % Separator 215 | 216 | Separator -> ','. 217 | Separator -> ';'. 218 | Separator -> '$empty'. 219 | 220 | Erlang code. 221 | 222 | % Construct a new AST node of the requested Type. Args are passed to the node 223 | % module's `new` function and, if provided, a line number and annotations are 224 | % assigned to the resulting node. 225 | build_node(Type, Args) when is_list(Args) -> 226 | Module = list_to_atom("Elixir.Thrift.AST." ++ atom_to_list(Type)), 227 | apply(Module, 'new', Args). 228 | build_node(Type, Line, Args) when is_integer(Line) and is_list(Args) -> 229 | Model = build_node(Type, Args), 230 | maps:put(line, Line, Model). 231 | build_node(Type, Line, Annotations, Args) 232 | when is_integer(Line) and is_map(Annotations) and is_list(Args) -> 233 | Model = build_node(Type, Line, Args), 234 | maps:put(annotations, Annotations, Model). 235 | 236 | % Extract the line number from the lexer's expression tuple. 237 | line({_Token, Line}) -> Line; 238 | line({_Token, Line, _Value}) -> Line; 239 | line(_) -> nil. 240 | 241 | % Return either the atom from a 2-tuple lexer expression or the processed 242 | % value from a 3-tuple lexer expression. 243 | unwrap({V, _}) when is_atom(V) -> V; 244 | unwrap({_,_,V}) -> V. 245 | -------------------------------------------------------------------------------- /test/thrift/binary/framed/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Servers.Binary.Framed.IntegrationTest do 2 | use ThriftTestCase 3 | use StubStats 4 | 5 | @thrift_file name: "server_test.thrift", 6 | contents: """ 7 | exception TestException { 8 | 1: string message, 9 | 2: i32 code, 10 | } 11 | 12 | exception UserNotFound { 13 | 1: string message, 14 | } 15 | 16 | exception OtherException { 17 | 2: string message, 18 | } 19 | 20 | struct IdAndName { 21 | 1: i64 id, 22 | 2: string name, 23 | } 24 | 25 | service ServerTest { 26 | void returns_nothing() 27 | oneway void do_async(1: string message); 28 | bool ping(); 29 | bool checked_exception() throws (1: TestException ex); 30 | bool multiple_exceptions(1: i32 exc_type) throws 31 | (1: TestException e, 2: UserNotFound unf, 3: OtherException other); 32 | bool server_exception(); 33 | IdAndName echo_struct(1: IdAndName id_and_name); 34 | i64 myCamelCasedFunction(1: string myUserName); 35 | } 36 | """ 37 | 38 | def define_handler do 39 | defmodule ServerTestHandler do 40 | alias Servers.Binary.Framed.IntegrationTest, as: T 41 | alias Servers.Binary.Framed.IntegrationTest.OtherException 42 | alias Servers.Binary.Framed.IntegrationTest.ServerTest 43 | alias Servers.Binary.Framed.IntegrationTest.TestException 44 | alias Servers.Binary.Framed.IntegrationTest.UserNotFound 45 | @behaviour ServerTest.Handler 46 | 47 | @impl ServerTest.Handler 48 | def do_async(message) do 49 | Agent.update(:server_args, fn _ -> message end) 50 | end 51 | 52 | @impl ServerTest.Handler 53 | def ping, do: true 54 | 55 | @impl ServerTest.Handler 56 | def checked_exception do 57 | raise T.TestException, message: "Oh noes!", code: 400 58 | end 59 | 60 | @impl ServerTest.Handler 61 | def server_exception do 62 | raise "This wasn't supposed to happen" 63 | end 64 | 65 | @impl ServerTest.Handler 66 | def echo_struct(id_and_name), do: id_and_name 67 | 68 | @impl ServerTest.Handler 69 | def returns_nothing, do: nil 70 | 71 | @impl ServerTest.Handler 72 | def multiple_exceptions(1), do: raise(TestException, message: "BOOM", code: 124) 73 | def multiple_exceptions(2), do: raise(UserNotFound, message: "Not here!") 74 | def multiple_exceptions(3), do: raise(OtherException, message: "This is the other") 75 | def multiple_exceptions(_), do: true 76 | 77 | @impl ServerTest.Handler 78 | def my_camel_cased_function(user_name) do 79 | Agent.update(:server_args, fn _ -> user_name end) 80 | 2421 81 | end 82 | end 83 | end 84 | 85 | alias Servers.Binary.Framed.IntegrationTest.ServerTest.Binary.Framed.Client 86 | alias Servers.Binary.Framed.IntegrationTest.ServerTest.Binary.Framed.Server 87 | alias Thrift.TApplicationException 88 | 89 | setup_all do 90 | {:ok, connect_agent} = Agent.start_link(fn -> [] end) 91 | 92 | on_connect = fn socket -> 93 | Agent.update(connect_agent, &[socket | &1]) 94 | end 95 | 96 | {:module, mod_name, _, _} = define_handler() 97 | {:ok, _} = Server.start_link(mod_name, 0, on_connect: on_connect) 98 | server_port = :ranch.get_port(mod_name) 99 | 100 | {:ok, _} = start_supervised({StubStats, handler_module: mod_name}) 101 | 102 | {:ok, handler_name: mod_name, port: server_port, connect_agent: connect_agent} 103 | end 104 | 105 | setup(%{port: port, test: client_name}) do 106 | {:ok, agent} = Agent.start_link(fn -> nil end, name: :server_args) 107 | 108 | on_exit(fn -> 109 | ref = Process.monitor(agent) 110 | 111 | receive do 112 | {:DOWN, ^ref, _, _, _} -> 113 | :ok 114 | end 115 | end) 116 | 117 | {:ok, client} = Client.start_link("localhost", port, name: client_name) 118 | 119 | :ok = reset_stats() 120 | 121 | {:ok, client: client, client_name: client_name} 122 | end 123 | 124 | thrift_test "it can return a simple boolean value", ctx do 125 | assert {:ok, true} == Client.ping(ctx.client) 126 | 127 | assert %{result: "success"} in stats(:receive_message) 128 | assert %{method: "ping", result: "success"} in stats(:call) 129 | assert %{result: "success"} in stats(:send_reply) 130 | assert %{method: "ping"} in stats(:request_size) 131 | assert %{method: "ping", result: "success"} in stats(:response_size) 132 | end 133 | 134 | thrift_test "it can throw checked exceptions", ctx do 135 | expected_exception = TestException.exception(message: "Oh noes!", code: 400) 136 | assert {:error, {:exception, expected_exception}} == Client.checked_exception(ctx.client) 137 | 138 | assert %{result: "success"} in stats(:receive_message) 139 | assert %{method: "checked_exception", result: "success"} in stats(:call) 140 | assert %{result: "success"} in stats(:send_reply) 141 | assert %{method: "checked_exception"} in stats(:request_size) 142 | assert %{method: "checked_exception", result: "success"} in stats(:response_size) 143 | end 144 | 145 | thrift_test "it can throw many checked exceptions", ctx do 146 | e1 = TestException.exception(message: "BOOM", code: 124) 147 | e2 = UserNotFound.exception(message: "Not here!") 148 | e3 = OtherException.exception(message: "This is the other") 149 | 150 | assert {:ok, true} == Client.multiple_exceptions(ctx.client, 0) 151 | assert {:error, {:exception, e1}} == Client.multiple_exceptions(ctx.client, 1) 152 | assert {:error, {:exception, e2}} == Client.multiple_exceptions(ctx.client, 2) 153 | assert {:error, {:exception, e3}} == Client.multiple_exceptions(ctx.client, 3) 154 | end 155 | 156 | thrift_test "it can handle unexpected exceptions", ctx do 157 | {:error, {:exception, %TApplicationException{} = exception}} = 158 | Client.server_exception(ctx.client) 159 | 160 | assert :internal_error == exception.type 161 | assert exception.message =~ "Server error: ** (RuntimeError) This wasn't supposed to happen" 162 | end 163 | 164 | thrift_test "it can return nothing", ctx do 165 | {:ok, nil} = Client.returns_nothing(ctx.client) 166 | 167 | assert %{method: "returns_nothing", result: "success"} in stats(:call) 168 | assert %{result: "success"} in stats(:send_reply) 169 | end 170 | 171 | thrift_test "it can return structs", ctx do 172 | id_and_name = %IdAndName{id: 1234, name: "stinky"} 173 | assert {:ok, ^id_and_name} = Client.echo_struct(ctx.client, id_and_name) 174 | end 175 | 176 | thrift_test "it can handle bogus data", ctx do 177 | {:ok, socket} = :gen_tcp.connect('localhost', ctx.port, [:binary, packet: 4, active: false]) 178 | :ok = :gen_tcp.send(socket, <<1, 2, 3, 4, 5>>) 179 | 180 | assert {:error, :closed} == :gen_tcp.recv(socket, 0) 181 | assert {:ok, true} = Client.ping(ctx.client) 182 | end 183 | 184 | thrift_test "it can handle oneway messages", ctx do 185 | assert {:ok, nil} = Client.do_async(ctx.client, "my message") 186 | 187 | :timer.sleep(100) 188 | 189 | assert "my message" = Agent.get(:server_args, & &1) 190 | end 191 | 192 | thrift_test "camel cased functions are converted to underscore", ctx do 193 | assert {:ok, 2421} == Client.my_camel_cased_function(ctx.client, "username") 194 | 195 | :timer.sleep(100) 196 | assert "username" == Agent.get(:server_args, & &1) 197 | end 198 | 199 | thrift_test "client can be found by name", %{client: client, client_name: name} do 200 | assert client == Process.whereis(name) 201 | end 202 | 203 | thrift_test "client methods can be called by name instead of pid", %{client_name: name} do 204 | assert {:ok, true} == Client.ping(name) 205 | end 206 | 207 | thrift_test "on_connect is called on connect", ctx do 208 | {:ok, true} = Client.ping(ctx.client) 209 | socket = Agent.get(ctx.connect_agent, fn [socket | _] -> socket end) 210 | {:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(socket) 211 | assert port == :sys.get_state(ctx.client).mod_state.port 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/thrift.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift do 2 | @moduledoc ~S""" 3 | [Thrift](https://thrift.apache.org/) implementation for Elixir including a 4 | Thrift IDL parser, a code generator, and an RPC system 5 | 6 | ## Thrift IDL Parsing 7 | 8 | `Thrift.Parser` parses [Thrift IDL](https://thrift.apache.org/docs/idl) into 9 | an abstract syntax tree used for code generation. You can also work with 10 | `Thrift.AST` directly to support additional use cases, such as building 11 | linters or analysis tools. 12 | 13 | ## Code Generation 14 | 15 | `Mix.Tasks.Compile.Thrift` is a Mix compiler task that automates Thrift code 16 | generation. To use it, add `:thrift` to your project's `:compilers` list. 17 | For example: 18 | 19 | compilers: [:thrift | Mix.compilers] 20 | 21 | It's important to add `:thrift` *before* the `:elixir` compiler entry. The 22 | Thrift compiler generates Elixir source files, which are in turn compiled by 23 | the `:elixir` compiler. 24 | 25 | Configure the compiler using a keyword list under the top-level `:thrift` 26 | key. The only required compiler option is `:files`, which defines the list 27 | of Thrift files to compile. See `Mix.Tasks.Compile.Thrift` for the full set 28 | of available options. 29 | 30 | By default, the generated Elixir source files will be written to the `lib` 31 | directory, but you can change that using the `output_path` option. 32 | 33 | In this example, we gather all of the `.thrift` files under the `thrift` 34 | directory and write our output files to the `lib/generated` directory: 35 | 36 | defmodule MyProject.Mixfile do 37 | # ... 38 | def project do 39 | [ 40 | # ... 41 | compilers: [:thrift | Mix.compilers], 42 | thrift: [ 43 | files: Path.wildcard("thrift/**/*.thrift"), 44 | output_path: "lib/generated" 45 | ] 46 | ] 47 | end 48 | end 49 | 50 | You can also use the `Mix.Tasks.Thrift.Generate` Mix task to generate code 51 | on-demand. By default, it uses the same project configuration as the 52 | compiler task above, but options can also be specified using command line 53 | arguments. 54 | 55 | ### Thrift Definitions 56 | 57 | Given some Thrift type definitions: 58 | 59 | ```thrift 60 | namespace elixir Thrift.Test 61 | 62 | exception UserNotFound { 63 | 1: string message 64 | } 65 | 66 | struct User { 67 | 1: i64 id, 68 | 2: string username, 69 | } 70 | 71 | service UserService { 72 | bool ping(), 73 | User getUser(1: i64 id) throws (1: UserNotFound e), 74 | bool delete(1: i64 id), 75 | } 76 | ``` 77 | 78 | ... the generated code will be placed in the following modules under 79 | `lib/thrift/`: 80 | 81 | Definition | Module 82 | ------------------------- | ----------------------------------------------- 83 | `User` struct | `Thrift.Test.User` 84 | *└ binary protocol* | `Thrift.Test.User.BinaryProtocol` 85 | `UserNotFound` exception | `Thrift.Test.UserNotFound` 86 | *└ binary protocol* | `Thrift.Test.UserNotFound.BinaryProtocol` 87 | `UserService` service | `Thrift.Test.UserService.Handler` 88 | *└ binary framed client* | `Thrift.Test.UserService.Binary.Framed.Client` 89 | *└ binary framed server* | `Thrift.Test.UserService.Binary.Framed.Server` 90 | 91 | ### Namespaces 92 | 93 | The generated modules' namespace is determined by the `:namespace` compiler 94 | option, which defaults to `Thrift.Generated`. Individual `.thrift` files can 95 | specify their own namespace using the `namespace` keyword, taking precedence 96 | over the compiler's value. 97 | 98 | ```thrift 99 | namespace elixir Thrift.Test 100 | ``` 101 | 102 | Unfortunately, the Apache Thrift compiler will produce a warning on this line 103 | because it doesn't recognize `elixir` as a supported language. While that 104 | warning is benign, it can be annoying. For that reason, you can also specify 105 | your Elixir namespace as a "magic" namespace comment: 106 | 107 | ```thrift 108 | #@namespace elixir Thrift.Test 109 | ``` 110 | 111 | This alternate syntax is [borrowed from Scrooge][scrooge-namespaces], which 112 | uses the same trick for defining Scala namespaces. 113 | 114 | [scrooge-namespaces]: https://twitter.github.io/scrooge/Namespaces.html 115 | 116 | ## Clients 117 | 118 | Service clients are built on `Thrift.Binary.Framed.Client`. This module 119 | uses the `Connection` behaviour to implement network state handling. In 120 | practice, you won't be interacting with this low-level module directly, 121 | however. 122 | 123 | A client interface module is generated for each service. This is much more 124 | convenient to use from application code because it provides distinct Elixir 125 | functions for each Thrift service function. It also handles argument packing, 126 | return value unpacking, and other high-level conversions. In this example, 127 | this generated module is `Thrift.Test.UserService.Binary.Framed.Client`. 128 | 129 | Each generated client function comes in two flavors: a standard version (e.g. 130 | `Client.get_user/3`) that returns `{:ok, response}` or `{:error, reason}`, 131 | and a *bang!* variant (e.g. `Client.get_user!/3`) that raises an exception on 132 | errors. 133 | 134 | iex> alias Thrift.Test.UserService.Binary.Framed.Client 135 | iex> {:ok, client} = Client.start_link("localhost", 2345, []) 136 | iex> {:ok, user} = Client.get_user(client, 123) 137 | {:ok, %Thrift.Test.User{id: 123, username: "user"}} 138 | 139 | Note that the generated function names use [Elixir's naming conventions] 140 | [naming], so `getUser` becomes `get_user`. 141 | 142 | [naming]: http://elixir-lang.org/docs/stable/elixir/naming-conventions.html 143 | 144 | ## Servers 145 | 146 | Thrift servers are a little more involved because you need to create a module 147 | to handle the work. Fortunately, a `Behaviour` is generated for each server 148 | (complete with typespecs). Use the `@behaviour` module attribute, and the 149 | compiler will tell you about any functions you might have missed. 150 | 151 | defmodule UserServiceHandler do 152 | @behaviour Thrift.Test.UserService.Handler 153 | 154 | def ping, do: true 155 | 156 | def get_user(user_id) do 157 | case Backend.find_user_by_id(user_id) do 158 | {:ok, user} -> 159 | user 160 | {:error, _} -> 161 | raise Thrift.Test.UserNotFound.exception message: "could not find user with id #{user_id}" 162 | end 163 | end 164 | 165 | def delete(user_id) do 166 | Backend.delete_user(user_id) == :ok 167 | end 168 | end 169 | 170 | To start the server: 171 | 172 | {:ok, pid} = Thrift.Test.UserService.Binary.Framed.Server.start_link(UserServiceHandler, 2345, []) 173 | 174 | ... and all RPC calls will be delegated to `UserServiceHandler`. 175 | 176 | The server defines a Supervisor, which can be added to your application's 177 | supervision tree. When adding the server to your applications supervision 178 | tree, use the `supervisor` function rather than the `worker` function. 179 | """ 180 | 181 | @typedoc "Thrift data types" 182 | @type data_type :: 183 | :bool 184 | | :byte 185 | | :i8 186 | | :i16 187 | | :i32 188 | | :i64 189 | | :double 190 | | :string 191 | | :binary 192 | | {:map, data_type, data_type} 193 | | {:set, data_type} 194 | | {:list, data_type} 195 | 196 | @type i8 :: -128..127 197 | @type i16 :: -32_768..32_767 198 | @type i32 :: -2_147_483_648..2_147_483_647 199 | @type i64 :: -9_223_372_036_854_775_808..9_223_372_036_854_775_807 200 | @type double :: float() 201 | 202 | @typedoc "Thrift message types" 203 | @type message_type :: :call | :reply | :exception | :oneway 204 | 205 | @doc """ 206 | Returns a list of atoms, each of which is a name of a Thrift primitive type. 207 | """ 208 | @spec primitive_names() :: [Thrift.Parser.Types.Primitive.t()] 209 | def primitive_names do 210 | [:bool, :i8, :i16, :i32, :i64, :binary, :double, :byte, :string] 211 | end 212 | 213 | defmodule NaN do 214 | @moduledoc """ 215 | A struct that represents [IEEE-754 NaN](https://en.wikipedia.org/wiki/NaN) 216 | values. 217 | """ 218 | 219 | @type t :: %NaN{ 220 | sign: 0 | 1, 221 | # 2^52 - 1 222 | fraction: 1..4_503_599_627_370_495 223 | } 224 | defstruct sign: nil, 225 | fraction: nil 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir Thrift 2 | 3 | [![Hex Version](https://img.shields.io/hexpm/v/thrift.svg)](https://hex.pm/packages/thrift) 4 | [![Hex Docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/thrift/) 5 | [![Build Status](https://travis-ci.org/pinterest/elixir-thrift.svg?branch=master)](https://travis-ci.org/pinterest/elixir-thrift) 6 | [![Coverage Status](https://coveralls.io/repos/pinterest/elixir-thrift/badge.svg?branch=master)](https://coveralls.io/github/pinterest/elixir-thrift?branch=master) 7 | 8 | This package contains an implementation of [Thrift](https://thrift.apache.org/) 9 | for Elixir. It includes a Thrift IDL parser, an Elixir code generator, and 10 | binary framed client and server implementations. 11 | 12 | The generated serialization code is highly optimized and has been measured at 13 | **10 and 25 times faster**[why?](#why-is-it-faster-than-the-apache-implementation) 14 | than the code generated by the Apache Thrift Erlang implementation. 15 | 16 | ## Project Status 17 | 18 | [Version 2.0](https://github.com/pinterest/elixir-thrift/milestone/1) is under 19 | actively development and should be released soon. It is a complete rewrite 20 | that drops the Apache Thrift dependency and implements everything in pure 21 | Elixir. 22 | 23 | ## Getting Started 24 | 25 | Until version 2.0 is released, you'll need to track the master branch 26 | directly: 27 | 28 | ```elixir 29 | {:thrift, github: "pinterest/elixir-thrift"} 30 | ``` 31 | 32 | This package includes a Mix compiler task that automates Thrift code 33 | generation. Prepend `:thrift` to your project's `:compilers` list and add a 34 | new top-level `:thrift` configuration key. The only necessary compiler option 35 | is `:files`, which defines the list of Thrift files that should be compiled. 36 | 37 | ```elixir 38 | # mix.exs 39 | defmodule MyProject.Mixfile do 40 | # ... 41 | def project do 42 | [ 43 | # ... 44 | compilers: [:thrift | Mix.compilers], 45 | thrift: [ 46 | files: Path.wildcard("thrift/**/*.thrift") 47 | ] 48 | ] 49 | end 50 | end 51 | ``` 52 | 53 | ## RPC Service Support 54 | 55 | We provide full client and server support for Thrift RPC services. The examples 56 | below are based on this simplified service definition: 57 | 58 | ```thrift 59 | service Service { 60 | i64 add(1: i64 left, 2: i64 right) 61 | } 62 | ``` 63 | 64 | You can also check out the [full example project](example/) for a complete 65 | client and server implementation of the sample calculator application. 66 | 67 | ### Clients 68 | 69 | You interact with Thrift services using generated, service-specific interface 70 | modules. These modules handle type conversions and make calling the service's 71 | remote functions easier. 72 | 73 | ```elixir 74 | iex> alias Calculator.Generated.Service.Binary.Framed.Client 75 | iex> {:ok, client} = Client.start_link("localhost", 9090, []) 76 | iex> {:ok, result} = Client.add(client, 10, 20) 77 | {:ok, 30} 78 | ``` 79 | 80 | We generate two versions of each function defined by the Thrift service's 81 | interface: one that returns a standard result tuple, and a `!` variant that 82 | returns a single result value but raises an exception if an error occurs. 83 | 84 | ```elixir 85 | @spec add(pid(), integer(), integer(), Client.options()) :: {:ok, integer()} | {:error, any()} 86 | def add(client, left, right, rpc_opts \\ []) 87 | 88 | @spec add!(pid(), integer(), integer(), Client.options()) :: integer() 89 | def add!(client, left, right, rpc_opts \\ []) 90 | ``` 91 | 92 | ### Servers 93 | 94 | In order to start a Thrift server, you will need to provide a callback module 95 | that implements the functions described by its service interface. Fortunately, 96 | a [behaviour] module will be automatically generated for you, complete with 97 | success typing. 98 | 99 | ```elixir 100 | defmodule Calculator.ServiceHandler do 101 | @behaviour Calculator.Generated.Service.Handler 102 | 103 | @impl true 104 | def add(left, right) do 105 | left + right 106 | end 107 | end 108 | ``` 109 | 110 | Then provide your handler module when starting the server process: 111 | 112 | ```elixir 113 | iex> alias Calculator.Generated.Service.Binary.Framed.Server 114 | iex> {:ok, server} = Server.start_link(Calculator.ServiceHandler, 9090, []) 115 | ``` 116 | 117 | All RPC calls to the server will be delegated to the handler module. The server 118 | provides a [supervisor] which can be added to your application's supervision 119 | tree. It's important to add it to your supervision tree with type `:supervisor` 120 | and not `:worker`. 121 | 122 | ```elixir 123 | defmodule Calculator.Application 124 | alias Calculator.Generated.Service.Binary.Framed.Server 125 | 126 | def start(_type, _args) do 127 | children = [ 128 | server_child_spec(9090) 129 | ] 130 | 131 | opts = [strategy: :one_for_one, name: Calculator.Supervisor] 132 | Supervisor.start_link(children, opts) 133 | end 134 | 135 | defp server_child_spec(port) do 136 | %{ 137 | id: Server, 138 | start: {Server, :start_link, [Calculator.ServiceHandler, port]}, 139 | type: :supervisor 140 | } 141 | end 142 | end 143 | ``` 144 | 145 | [behaviour]: https://elixir-lang.org/getting-started/typespecs-and-behaviours.html#behaviours 146 | [supervisor]: https://elixir-lang.org/getting-started/mix-otp/supervisor-and-application.html 147 | 148 | ## Serialization 149 | 150 | A `BinaryProtocol` module is generated for each Thrift struct, union, and 151 | exception type. You can use this interface to easily serialize and deserialize 152 | your own types. 153 | 154 | ```elixir 155 | iex> alias Calculator.Generated.Vector 156 | iex> data = %Vector{x: 1, y: 2, z: 3} 157 | |> Vector.BinaryProtocol.serialize 158 | |> IO.iodata_to_binary 159 | iex> Vector.BinaryProtocol.deserialize(data) 160 | {%Calculator.Generated.Vector{x: 1.0, y: 2.0, z: 3.0}, ""} 161 | ``` 162 | 163 | ## Thrift IDL Parsing 164 | 165 | The `Thrift.Parser` module parses [Thrift IDL][idl] documents and produces an 166 | abstract syntax tree. You can use these features to support additional 167 | languages, protocols, and servers. 168 | 169 | ```elixir 170 | Thrift.Parser.parse("enum Colors { RED, GREEN, BLUE }") 171 | %Thrift.AST.Schema{constants: %{}, 172 | enums: %{Colors: %Thrift.AST.TEnum{name: :Colors, 173 | values: [RED: 1, GREEN: 2, BLUE: 3]}}, exceptions: %{}, includes: [], 174 | namespaces: %{}, services: %{}, structs: %{}, thrift_namespace: nil, 175 | typedefs: %{}, unions: %{}} 176 | ``` 177 | 178 | [idl]: https://thrift.apache.org/docs/idl 179 | 180 | ## Debugging 181 | 182 | In order to debug your Thrift RPC calls, we recommend you use [`thrift-tools`](https://github.com/pinterest/thrift-tools). It is a set of tools to introspect Apache Thrift traffic. 183 | 184 | Try something like: 185 | 186 | ``` 187 | $ pip install thrift-tools 188 | $ sudo thrift-tool --iface eth0 --port 9090 dump --show-all --pretty 189 | ``` 190 | 191 | ## FAQ 192 | 193 | ### Why is it faster than the Apache implementation? 194 | 195 | The Apache Thrift implementation uses C++ to write Erlang modules that describe 196 | Thrift data structures and then uses these descriptions to turn your Thrift 197 | data into bytes. It consults these descriptions every time Thrift data is 198 | serialized/deserialized. This on-the-fly conversion costs CPU time. 199 | 200 | Additionally, this separation of concerns in Apache Thrift prevent the Erlang 201 | VM from doing the best job that it can do during serialization. 202 | 203 | Our implementation uses Elixir to write Elixir code that's specific to _your_ 204 | Thrift structures. This serialization logic is then compiled, and that compiled 205 | code is what converts your data to and from serialized bytes. We've spent a lot 206 | of time making sure that the generated code takes advantage of several of the 207 | optimizations that the Erlang VM provides. 208 | 209 | ### What tradeoffs have you made to get this performance? 210 | 211 | Thrift has the following concepts: 212 | 213 | 1. **Protocols** Define a conversion of data into bytes. 214 | 2. **Transports** Define how bytes move; across a network or in and out of a file. 215 | 3. **Processors** Encapsulate reading from streams and doing something with the data. Processors are generated by the Thrift compiler. 216 | 217 | In Apache Thrift, Protocols and Transports can be mixed and matched. However, 218 | our implementation does the mixing and matching for you and generates a 219 | combination of (Protocol + Transport + Processor). This means that if you need 220 | to support a new Protocol or Transport, you will need to integrate it into this 221 | project. 222 | 223 | Presently, we implement: 224 | 225 | * Binary Protocol, Framed Client 226 | * Binary Protocol, Framed Server 227 | 228 | We are more than willing to accept contributions that add more! 229 | -------------------------------------------------------------------------------- /lib/thrift/parser/file_group.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Parser.FileGroup do 2 | @moduledoc false 3 | 4 | alias Thrift.Parser 5 | 6 | alias Thrift.Parser.{ 7 | FileGroup, 8 | Resolver 9 | } 10 | 11 | alias Thrift.AST.{ 12 | Constant, 13 | Exception, 14 | Field, 15 | Schema, 16 | Service, 17 | Struct, 18 | TEnum, 19 | TypeRef, 20 | Union, 21 | ValueRef 22 | } 23 | 24 | @type t :: %FileGroup{ 25 | initial_file: Path.t(), 26 | schemas: %{Path.t() => %Schema{}}, 27 | namespaces: %{atom => String.t() | nil}, 28 | opts: Parser.opts() 29 | } 30 | 31 | @enforce_keys [:initial_file, :opts] 32 | defstruct initial_file: nil, 33 | schemas: %{}, 34 | resolutions: %{}, 35 | immutable_resolutions: %{}, 36 | namespaces: %{}, 37 | opts: Keyword.new() 38 | 39 | @spec new(Path.t(), Parser.opts()) :: t 40 | def new(initial_file, opts \\ []) do 41 | %FileGroup{initial_file: initial_file, opts: opts} 42 | end 43 | 44 | @spec add(FileGroup.t(), Path.t(), Schema.t()) :: {FileGroup.t(), [Parser.error()]} 45 | def add(group, path, schema) do 46 | name = Path.basename(path, ".thrift") 47 | new_schemas = Map.put(group.schemas, name, schema) 48 | resolutions = Resolver.add(group.resolutions, name, schema) 49 | 50 | group = %{ 51 | group 52 | | schemas: new_schemas, 53 | immutable_resolutions: resolutions, 54 | resolutions: resolutions 55 | } 56 | 57 | add_includes(group, path, schema) 58 | end 59 | 60 | @spec add_includes(FileGroup.t(), Path.t(), Schema.t()) :: {FileGroup.t(), [Parser.error()]} 61 | defp add_includes(%FileGroup{} = group, path, %Schema{} = schema) do 62 | # Search for included files in the current directory (relative to the 63 | # parsed file) as well as any additionally configured include paths. 64 | include_paths = [Path.dirname(path) | Keyword.get(group.opts, :include_paths, [])] 65 | 66 | Enum.reduce(schema.includes, {group, []}, fn include, {group, errors} -> 67 | included_path = find_include(include.path, include_paths) 68 | 69 | case Parser.parse_file(included_path) do 70 | {:ok, schema} -> 71 | add(group, included_path, schema) 72 | 73 | {:error, error} -> 74 | {group, [error | errors]} 75 | end 76 | end) 77 | end 78 | 79 | # Attempt to locate `path` in one of `dirs`, returning the path of the 80 | # first match on success or the original `path` if not match is found. 81 | defp find_include(path, dirs) do 82 | dirs 83 | |> Enum.map(&Path.join(&1, path)) 84 | |> Enum.find(path, &File.exists?/1) 85 | end 86 | 87 | @spec set_current_module(t, atom) :: t 88 | def set_current_module(file_group, module) do 89 | # since in a file, we can refer to things defined in that file in a non-qualified 90 | # way, we add unqualified names to the resolutions map. 91 | 92 | current_module = Atom.to_string(module) 93 | 94 | resolutions = 95 | file_group.immutable_resolutions 96 | |> Enum.flat_map(fn {name, v} = original_mapping -> 97 | case String.split(Atom.to_string(name), ".") do 98 | [^current_module, enum_name, value_name] -> 99 | [{:"#{enum_name}.#{value_name}", v}, original_mapping] 100 | 101 | [^current_module, rest] -> 102 | [{:"#{rest}", v}, original_mapping] 103 | 104 | _ -> 105 | [original_mapping] 106 | end 107 | end) 108 | |> Map.new() 109 | 110 | namespaces = build_namespaces(file_group.schemas, file_group.opts[:namespace]) 111 | 112 | %FileGroup{file_group | resolutions: resolutions, namespaces: namespaces} 113 | end 114 | 115 | @spec resolve(t, any) :: any 116 | for type <- Thrift.primitive_names() do 117 | def resolve(_, unquote(type)), do: unquote(type) 118 | end 119 | 120 | def resolve(%FileGroup{} = group, %Field{type: type} = field) do 121 | %Field{field | type: resolve(group, type)} 122 | end 123 | 124 | def resolve(%FileGroup{resolutions: resolutions} = group, %TypeRef{referenced_type: type_name}) do 125 | resolve(group, resolutions[type_name]) 126 | end 127 | 128 | def resolve(%FileGroup{resolutions: resolutions} = group, %ValueRef{ 129 | referenced_value: value_name 130 | }) do 131 | resolve(group, resolutions[value_name]) 132 | end 133 | 134 | def resolve(%FileGroup{resolutions: resolutions} = group, path) 135 | when is_atom(path) and not is_nil(path) do 136 | # this can resolve local mappings like :Weather or 137 | # remote mappings like :"common.Weather" 138 | resolve(group, resolutions[path]) 139 | end 140 | 141 | def resolve(%FileGroup{} = group, {:list, elem_type}) do 142 | {:list, resolve(group, elem_type)} 143 | end 144 | 145 | def resolve(%FileGroup{} = group, {:set, elem_type}) do 146 | {:set, resolve(group, elem_type)} 147 | end 148 | 149 | def resolve(%FileGroup{} = group, {:map, {key_type, val_type}}) do 150 | {:map, {resolve(group, key_type), resolve(group, val_type)}} 151 | end 152 | 153 | def resolve(_, other) do 154 | other 155 | end 156 | 157 | @spec dest_module(t, any) :: atom 158 | def dest_module(file_group, %Struct{name: name}) do 159 | dest_module(file_group, name) 160 | end 161 | 162 | def dest_module(file_group, %Union{name: name}) do 163 | dest_module(file_group, name) 164 | end 165 | 166 | def dest_module(file_group, %Exception{name: name}) do 167 | dest_module(file_group, name) 168 | end 169 | 170 | def dest_module(file_group, %TEnum{name: name}) do 171 | dest_module(file_group, name) 172 | end 173 | 174 | def dest_module(file_group, %Service{name: name}) do 175 | dest_module(file_group, name) 176 | end 177 | 178 | def dest_module(file_group, Constant) do 179 | # Default to naming the constants module after the namespaced, camelized 180 | # basename of its file. For foo.thrift, this would be `foo.Foo`. 181 | base = Path.basename(file_group.initial_file, ".thrift") 182 | default = base <> "." <> Macro.camelize(base) 183 | 184 | # However, if we're already going to generate an equivalent module name 185 | # (ignoring case), use that instead to avoid generating two modules with 186 | # the same spellings but different cases. 187 | schema = file_group.schemas[base] 188 | 189 | symbols = 190 | [ 191 | Enum.map(schema.enums, fn {_, s} -> s.name end), 192 | Enum.map(schema.exceptions, fn {_, s} -> s.name end), 193 | Enum.map(schema.structs, fn {_, s} -> s.name end), 194 | Enum.map(schema.services, fn {_, s} -> s.name end), 195 | Enum.map(schema.unions, fn {_, s} -> s.name end) 196 | ] 197 | |> List.flatten() 198 | |> Enum.map(&Atom.to_string/1) 199 | 200 | target = String.downcase(default) 201 | name = Enum.find(symbols, default, fn s -> String.downcase(s) == target end) 202 | 203 | dest_module(file_group, String.to_atom(name)) 204 | end 205 | 206 | def dest_module(file_group, name) do 207 | name_parts = 208 | name 209 | |> Atom.to_string() 210 | |> String.split(".", parts: 2) 211 | 212 | module_name = 213 | name_parts 214 | |> Enum.at(0) 215 | |> String.to_atom() 216 | 217 | struct_name = 218 | name_parts 219 | |> Enum.at(1) 220 | |> initialcase() 221 | 222 | case file_group.namespaces[module_name] do 223 | nil -> 224 | Module.concat([struct_name]) 225 | 226 | namespace -> 227 | namespace_parts = 228 | namespace 229 | |> String.split(".") 230 | |> Enum.map(&Macro.camelize/1) 231 | 232 | Module.concat(namespace_parts ++ [struct_name]) 233 | end 234 | end 235 | 236 | # Capitalize just the initial character of a string, leaving the rest of the 237 | # string's characters intact. 238 | @spec initialcase(String.t()) :: String.t() 239 | defp initialcase(string) when is_binary(string) do 240 | {first, rest} = String.next_grapheme(string) 241 | String.upcase(first) <> rest 242 | end 243 | 244 | # check if the given model is defined in the root file of the file group 245 | # this should eventually be replaced if we find a way to only parse files 246 | # once 247 | @spec own_constant?(t, Constant.t()) :: boolean 248 | def own_constant?(%FileGroup{} = file_group, %Constant{} = constant) do 249 | basename = Path.basename(file_group.initial_file, ".thrift") 250 | schema = file_group.schemas[basename] 251 | Enum.member?(Map.keys(schema.constants), constant.name) 252 | end 253 | 254 | defp build_namespaces(schemas, default_namespace) do 255 | Map.new(schemas, fn 256 | {module_name, %Schema{namespaces: %{:elixir => namespace}}} -> 257 | {String.to_atom(module_name), namespace.value} 258 | 259 | {module_name, _} -> 260 | {String.to_atom(module_name), default_namespace} 261 | end) 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/thrift/protocol/binary.ex: -------------------------------------------------------------------------------- 1 | defmodule Thrift.Protocol.Binary do 2 | @moduledoc """ 3 | Provides a set of high-level functions for working with the Thrift binary 4 | protocol. 5 | 6 | The Thrift binary protocol uses a fairly simple binary encoding scheme in 7 | which the length and type of a field are encoded as bytes followed by the 8 | actual value of the field. 9 | """ 10 | 11 | alias Thrift.{NaN, TApplicationException} 12 | 13 | require Thrift.Protocol.Binary.Type, as: Type 14 | 15 | @type serializable :: Thrift.data_type() | :message_begin | :application_exception 16 | @type deserializable :: :message_begin | :application_exception 17 | 18 | @stop 0 19 | 20 | @typedoc "Binary protocol message type identifier" 21 | @type message_type_id :: 1..4 22 | 23 | @spec from_message_type(Thrift.message_type()) :: message_type_id 24 | defp from_message_type(:call), do: 1 25 | defp from_message_type(:reply), do: 2 26 | defp from_message_type(:exception), do: 3 27 | defp from_message_type(:oneway), do: 4 28 | 29 | @spec to_message_type(message_type_id) :: Thrift.message_type() 30 | defp to_message_type(1), do: :call 31 | defp to_message_type(2), do: :reply 32 | defp to_message_type(3), do: :exception 33 | defp to_message_type(4), do: :oneway 34 | 35 | @typedoc "Binary protocol message sequence identifier" 36 | @type message_seq_id :: non_neg_integer 37 | 38 | @doc """ 39 | Serializes a value as an IO list using Thrift's type-specific encoding rules. 40 | """ 41 | @spec serialize(serializable, any) :: iolist 42 | def serialize(:bool, false), do: <<0::8-signed>> 43 | def serialize(:bool, true), do: <<1::8-signed>> 44 | def serialize(:i8, value), do: <> 45 | def serialize(:i16, value), do: <> 46 | def serialize(:i32, value), do: <> 47 | def serialize(:i64, value), do: <> 48 | def serialize(:double, :inf), do: <<0::1, 2047::11, 0::52>> 49 | def serialize(:double, :"-inf"), do: <<1::1, 2047::11, 0::52>> 50 | def serialize(:double, %NaN{sign: sign, fraction: frac}), do: <> 51 | def serialize(:double, value), do: <> 52 | def serialize(:string, value), do: [<>, value] 53 | def serialize(:binary, value), do: [<>, value] 54 | 55 | def serialize({:list, elem_type}, elems) when is_list(elems) do 56 | rest = Enum.map(elems, &serialize(elem_type, &1)) 57 | [<>, rest] 58 | end 59 | 60 | def serialize({:set, elem_type}, %MapSet{} = elems) do 61 | rest = Enum.map(elems, &serialize(elem_type, &1)) 62 | [<>, rest] 63 | end 64 | 65 | def serialize({:map, {key_type, val_type}}, map) when is_map(map) do 66 | elem_count = map_size(map) 67 | 68 | rest = 69 | Enum.map(map, fn {key, value} -> 70 | [serialize(key_type, key), serialize(val_type, value)] 71 | end) 72 | 73 | [<>, rest] 74 | end 75 | 76 | def serialize(:struct, %{__struct__: mod} = struct) do 77 | mod.serialize(struct, :binary) 78 | end 79 | 80 | def serialize(:union, %{__struct__: mod} = struct) do 81 | mod.serialize(struct, :binary) 82 | end 83 | 84 | def serialize(:message_begin, {message_type, sequence_id, name}) do 85 | # Taken from https://erikvanoosten.github.io/thrift-missing-specification/#_message_encoding 86 | 87 | << 88 | 1::size(1), 89 | 1::size(15), 90 | 0::size(8), 91 | # ^^ Strange, I know. We could integrate the 8-bit zero here with the 5 bit zero below. 92 | 0::size(5), 93 | from_message_type(message_type)::size(3), 94 | byte_size(name)::32-signed, 95 | name::binary, 96 | sequence_id::32-signed 97 | >> 98 | end 99 | 100 | def serialize(:application_exception, %TApplicationException{message: message, type: type}) do 101 | type_id = TApplicationException.type_id(type) 102 | 103 | <> 105 | end 106 | 107 | @doc """ 108 | Deserializes a Thrift-encoded binary. 109 | """ 110 | @spec deserialize(deserializable, binary) :: 111 | {:ok, {Thrift.message_type(), message_seq_id, name :: String.t(), binary}} 112 | | {:error, {atom, binary}} 113 | def deserialize( 114 | :message_begin, 115 | <<1::size(1), 1::size(15), _::size(8), 0::size(5), message_type::size(3), 116 | name_size::32-signed, name::binary-size(name_size), sequence_id::32-signed, 117 | rest::binary>> 118 | ) do 119 | {:ok, {to_message_type(message_type), sequence_id, name, rest}} 120 | end 121 | 122 | # the old format, see here: 123 | # https://erikvanoosten.github.io/thrift-missing-specification/#_message_encoding 124 | def deserialize( 125 | :message_begin, 126 | <> 128 | ) do 129 | {:ok, {to_message_type(message_type), sequence_id, name, rest}} 130 | end 131 | 132 | def deserialize(:message_begin, rest) do 133 | {:error, {:cant_decode_message, rest}} 134 | end 135 | 136 | def deserialize(:application_exception, binary) when is_binary(binary) do 137 | do_read_application_exception(binary, Keyword.new()) 138 | end 139 | 140 | defp do_read_application_exception( 141 | <>, 143 | opts 144 | ) do 145 | do_read_application_exception(rest, Keyword.put(opts, :message, message)) 146 | end 147 | 148 | defp do_read_application_exception( 149 | <>, 150 | opts 151 | ) do 152 | do_read_application_exception(rest, Keyword.put(opts, :type, type)) 153 | end 154 | 155 | defp do_read_application_exception(<<@stop>>, opts) do 156 | TApplicationException.exception(opts) 157 | end 158 | 159 | defp do_read_application_exception(error, _) do 160 | TApplicationException.exception( 161 | type: :protocol_error, 162 | message: "Unable to decode exception (#{inspect(error)})" 163 | ) 164 | end 165 | 166 | @doc """ 167 | Skips over the bytes representing a binary-encoded field. 168 | 169 | This is useful for jumping over unrecognized fields in the serialized byte 170 | stream. 171 | """ 172 | @spec skip_field(binary, Type.t()) :: binary | :error 173 | def skip_field(<<_, rest::binary>>, unquote(Type.bool())), do: rest 174 | def skip_field(<<_, rest::binary>>, unquote(Type.byte())), do: rest 175 | def skip_field(<<_::float-signed, rest::binary>>, unquote(Type.double())), do: rest 176 | def skip_field(<<_::16-signed, rest::binary>>, unquote(Type.i16())), do: rest 177 | def skip_field(<<_::32-signed, rest::binary>>, unquote(Type.i32())), do: rest 178 | def skip_field(<<_::64-signed, rest::binary>>, unquote(Type.i64())), do: rest 179 | 180 | def skip_field( 181 | <>, 182 | unquote(Type.string()) 183 | ) do 184 | rest 185 | end 186 | 187 | def skip_field(<>, unquote(Type.struct())) do 188 | skip_struct(rest) 189 | end 190 | 191 | def skip_field(<>, unquote(Type.map())) do 192 | skip_map_entry(rest, key_type, val_type, length) 193 | end 194 | 195 | def skip_field(<>, unquote(Type.set())) do 196 | skip_list_element(rest, elem_type, length) 197 | end 198 | 199 | def skip_field(<>, unquote(Type.list())) do 200 | skip_list_element(rest, elem_type, length) 201 | end 202 | 203 | def skip_field(_, _), do: :error 204 | 205 | defp skip_list_element(<>, _, 0), do: rest 206 | 207 | defp skip_list_element(<>, elem_type, remaining) do 208 | rest 209 | |> skip_field(elem_type) 210 | |> skip_list_element(elem_type, remaining - 1) 211 | end 212 | 213 | defp skip_list_element(:error, _, _), do: :error 214 | 215 | defp skip_map_entry(<>, _, _, 0), do: rest 216 | 217 | defp skip_map_entry(<>, key_type, val_type, remaining) do 218 | rest 219 | |> skip_field(key_type) 220 | |> skip_field(val_type) 221 | |> skip_map_entry(key_type, val_type, remaining - 1) 222 | end 223 | 224 | defp skip_map_entry(:error, _, _, _), do: :error 225 | 226 | defp skip_struct(<<0, rest::binary>>), do: rest 227 | 228 | defp skip_struct(<>) do 229 | rest 230 | |> skip_field(type) 231 | |> skip_struct 232 | end 233 | 234 | defp skip_struct(_), do: :error 235 | end 236 | --------------------------------------------------------------------------------