diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d8841..53e118f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## 0.4.0 (09.05.2023) +## 0.6.0 (11.05.2023) + +- [Soroban compound types](https://github.com/kommitters/soroban.ex/issues/43) + +## 0.5.0 (09.05.2023) - [Invoke Contract Function](https://github.com/kommitters/soroban.ex/issues/23) diff --git a/README.md b/README.md index 3a53adb..4baabcf 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add `soroban` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:soroban, "~> 0.5.0"} + {:soroban, "~> 0.6.0"} ] end ``` diff --git a/lib/types/enum.ex b/lib/types/enum.ex new file mode 100644 index 0000000..10383d8 --- /dev/null +++ b/lib/types/enum.ex @@ -0,0 +1,49 @@ +defmodule Soroban.Types.Enum do + @moduledoc """ + `Enum` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Soroban.Types.Symbol + alias Stellar.TxBuild.SCVal + + defstruct [:key, :value] + + @type sc_val :: SCVal.t() + @type t :: %__MODULE__{key: String.t(), value: struct()} + + @impl true + def new(key) when is_binary(key), + do: %__MODULE__{key: key} + + def new({key, value}) when is_binary(key) and is_struct(value), + do: %__MODULE__{key: key, value: value} + + def new(_args), do: {:error, :invalid} + + @impl true + def to_sc_val(%__MODULE__{key: key, value: nil}) do + key + |> Symbol.new() + |> Symbol.to_sc_val() + |> (&SCVal.new(vec: [&1])).() + end + + def to_sc_val(%__MODULE__{key: key, value: value}) do + value = param_to_sc_val(value) + + key + |> Symbol.new() + |> Symbol.to_sc_val() + |> (&SCVal.new(vec: [&1, value])).() + end + + def to_sc_val(_error), do: {:error, :invalid_struct_enum} + + @spec param_to_sc_val(param :: struct()) :: sc_val() + defp param_to_sc_val(param) do + struct = param.__struct__ + struct.to_sc_val(param) + end +end diff --git a/lib/types/map.ex b/lib/types/map.ex new file mode 100644 index 0000000..243484a --- /dev/null +++ b/lib/types/map.ex @@ -0,0 +1,42 @@ +defmodule Soroban.Types.Map do + @moduledoc """ + `Map` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Soroban.Types.MapEntry + alias Stellar.TxBuild.SCVal + + defstruct [:values] + + @type errors :: atom() + @type values :: list(MapEntry.t()) + @type validation :: {:ok, values()} | {:error, errors()} + @type t :: %__MODULE__{values: values} + + @impl true + def new(values) when is_list(values) do + with {:ok, values} <- validate_map_entry_values(values) do + %__MODULE__{values: values} + end + end + + def new(_values), do: {:error, :invalid} + + @impl true + def to_sc_val(%__MODULE__{values: values}) do + values + |> Enum.map(&MapEntry.to_sc_map_entry/1) + |> (&SCVal.new(map: &1)).() + end + + def to_sc_val(_error), do: {:error, :invalid_struct_map} + + @spec validate_map_entry_values(values :: values()) :: validation() + def validate_map_entry_values(values) do + if Enum.all?(values, &is_struct(&1, MapEntry)), + do: {:ok, values}, + else: {:error, :invalid_values} + end +end diff --git a/lib/types/map_entry.ex b/lib/types/map_entry.ex new file mode 100644 index 0000000..63717ce --- /dev/null +++ b/lib/types/map_entry.ex @@ -0,0 +1,36 @@ +defmodule Soroban.Types.MapEntry do + @moduledoc """ + `MapEntry` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + defstruct [:key, :value] + + @type sc_val :: SCVal.t() + @type t :: %__MODULE__{key: struct(), value: struct()} + + @impl true + def new({key, value}) when is_struct(key) and is_struct(value), + do: %__MODULE__{key: key, value: value} + + def new(_args), do: {:error, :invalid} + + @impl true + def to_sc_map_entry(%__MODULE__{key: key, value: value}) do + key = param_to_sc_val(key) + value = param_to_sc_val(value) + + SCMapEntry.new(key, value) + end + + def to_sc_map_entry(_error), do: {:error, :invalid_struct_map_entry} + + @spec param_to_sc_val(param :: struct()) :: sc_val() + defp param_to_sc_val(param) do + struct = param.__struct__ + struct.to_sc_val(param) + end +end diff --git a/lib/types/option.ex b/lib/types/option.ex new file mode 100644 index 0000000..b2b4077 --- /dev/null +++ b/lib/types/option.ex @@ -0,0 +1,32 @@ +defmodule Soroban.Types.Option do + @moduledoc """ + `Option` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Stellar.TxBuild.SCVal + + defstruct [:value] + + @type sc_val :: SCVal.t() + @type value :: struct() | nil + @type t :: %__MODULE__{value: value()} + + @impl true + def new(value \\ nil) + def new(nil), do: %__MODULE__{} + def new(value) when is_struct(value), do: %__MODULE__{value: value} + def new(_value), do: {:error, :invalid_option} + + @impl true + def to_sc_val(%__MODULE__{value: nil}), do: SCVal.new(void: nil) + def to_sc_val(%__MODULE__{value: value}), do: param_to_sc_val(value) + def to_sc_val(_error), do: {:error, :invalid_struct_bool} + + @spec param_to_sc_val(param :: struct()) :: sc_val() + defp param_to_sc_val(param) do + struct = param.__struct__ + struct.to_sc_val(param) + end +end diff --git a/lib/types/spec.ex b/lib/types/spec.ex index ebb2a54..a506a60 100644 --- a/lib/types/spec.ex +++ b/lib/types/spec.ex @@ -2,11 +2,15 @@ defmodule Soroban.Types.Spec do @moduledoc """ Defines base types constructions. """ - alias Stellar.TxBuild.SCVal + alias Stellar.TxBuild.{SCMapEntry, SCVal} @type error :: {:error, atom()} @type sc_val :: SCVal.t() + @type map_entry :: SCMapEntry.t() @callback new(any()) :: struct() | error() @callback to_sc_val(struct()) :: sc_val() + @callback to_sc_map_entry(struct()) :: map_entry() + + @optional_callbacks to_sc_val: 1, to_sc_map_entry: 1 end diff --git a/lib/types/struct.ex b/lib/types/struct.ex new file mode 100644 index 0000000..e64c605 --- /dev/null +++ b/lib/types/struct.ex @@ -0,0 +1,42 @@ +defmodule Soroban.Types.Struct do + @moduledoc """ + `Struct` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Soroban.Types.StructField + alias Stellar.TxBuild.SCVal + + defstruct [:values] + + @type errors :: atom() + @type values :: list(StructField.t()) + @type validation :: {:ok, values()} | {:error, errors()} + @type t :: %__MODULE__{values: values} + + @impl true + def new(values) when is_list(values) do + with {:ok, values} <- validate_struct_field_values(values) do + %__MODULE__{values: values} + end + end + + def new(_values), do: {:error, :invalid} + + @impl true + def to_sc_val(%__MODULE__{values: values}) do + values + |> Enum.map(&StructField.to_sc_map_entry/1) + |> (&SCVal.new(map: &1)).() + end + + def to_sc_val(_error), do: {:error, :invalid_struct} + + @spec validate_struct_field_values(values :: values()) :: validation() + def validate_struct_field_values(values) do + if Enum.all?(values, &is_struct(&1, StructField)), + do: {:ok, values}, + else: {:error, :invalid_values} + end +end diff --git a/lib/types/struct_field.ex b/lib/types/struct_field.ex new file mode 100644 index 0000000..0837891 --- /dev/null +++ b/lib/types/struct_field.ex @@ -0,0 +1,40 @@ +defmodule Soroban.Types.StructField do + @moduledoc """ + `StructField` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Soroban.Types.Symbol + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + defstruct [:key, :value] + + @type key :: String.t() + @type sc_val :: SCVal.t() + @type t :: %__MODULE__{key: key(), value: struct()} + + @impl true + def new({key, value}) when is_binary(key) and is_struct(value), + do: %__MODULE__{key: key, value: value} + + def new(_args), do: {:error, :invalid} + + @impl true + def to_sc_map_entry(%__MODULE__{key: key, value: value}) do + value = param_to_sc_val(value) + + key + |> Symbol.new() + |> Symbol.to_sc_val() + |> SCMapEntry.new(value) + end + + def to_sc_map_entry(_error), do: {:error, :invalid_struct_field} + + @spec param_to_sc_val(param :: struct()) :: sc_val() + defp param_to_sc_val(param) do + struct = param.__struct__ + struct.to_sc_val(param) + end +end diff --git a/lib/types/tuple.ex b/lib/types/tuple.ex new file mode 100644 index 0000000..67e6a9b --- /dev/null +++ b/lib/types/tuple.ex @@ -0,0 +1,29 @@ +defmodule Soroban.Types.Tuple do + @moduledoc """ + `Tuple` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Stellar.TxBuild.SCVal + + defstruct [:values] + + @type values :: list(struct()) + @type errors :: atom() + @type t :: %__MODULE__{values: values()} + + @impl true + def new(values) when is_list(values), do: %__MODULE__{values: values} + + def new(_values), do: {:error, :invalid} + + @impl true + def to_sc_val(%__MODULE__{values: values}) do + values + |> Enum.map(fn %{__struct__: struct} = arg -> struct.to_sc_val(arg) end) + |> (&SCVal.new(vec: &1)).() + end + + def to_sc_val(_error), do: {:error, :invalid_struct_tuple} +end diff --git a/lib/types/vec.ex b/lib/types/vec.ex new file mode 100644 index 0000000..fa8258f --- /dev/null +++ b/lib/types/vec.ex @@ -0,0 +1,41 @@ +defmodule Soroban.Types.Vec do + @moduledoc """ + `Vec` struct definition. + """ + + @behaviour Soroban.Types.Spec + + alias Stellar.TxBuild.SCVal + + defstruct [:values] + + @type values :: list(struct()) + @type errors :: atom() + @type validation :: {:ok, values()} | {:error, errors()} + @type t :: %__MODULE__{values: values()} + + @impl true + def new(values) when is_list(values) do + with {:ok, values} <- validate_vec_values(values) do + %__MODULE__{values: values} + end + end + + def new(_values), do: {:error, :invalid} + + @impl true + def to_sc_val(%__MODULE__{values: values}) do + values + |> Enum.map(fn %{__struct__: struct} = arg -> struct.to_sc_val(arg) end) + |> (&SCVal.new(vec: &1)).() + end + + def to_sc_val(_error), do: {:error, :invalid_struct_vec} + + @spec validate_vec_values(values :: values()) :: validation() + defp validate_vec_values([value | _] = values) do + if Enum.any?(values, fn val -> val.__struct__ != value.__struct__ end), + do: {:error, :invalid_args}, + else: {:ok, values} + end +end diff --git a/mix.exs b/mix.exs index 06bf67e..bc1914b 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Soroban.MixProject do use Mix.Project - @version "0.5.0" + @version "0.6.0" @github_url "https://github.com/kommitters/soroban.ex" def project do @@ -113,7 +113,15 @@ defmodule Soroban.MixProject do Soroban.Types.UInt32, Soroban.Types.UInt64, Soroban.Types.UInt128, - Soroban.Types.UInt256 + Soroban.Types.UInt256, + Soroban.Types.Vec, + Soroban.Types.Tuple, + Soroban.Types.MapEntry, + Soroban.Types.Map, + Soroban.Types.Enum, + Soroban.Types.StructField, + Soroban.Types.Struct, + Soroban.Types.Option ] ] end diff --git a/test/types/enum_test.exs b/test/types/enum_test.exs new file mode 100644 index 0000000..bf6fbf7 --- /dev/null +++ b/test/types/enum_test.exs @@ -0,0 +1,63 @@ +defmodule Soroban.Types.EnumTest do + use ExUnit.Case + + alias Soroban.Types.{Enum, UInt32} + alias Stellar.TxBuild.SCVal + + setup do + key = "key" + value = UInt32.new(100) + enum = Enum.new({key, value}) + enum2 = Enum.new(key) + + %{ + key: key, + value: value, + enum: enum, + enum2: enum2 + } + end + + describe "new/1" do + test "with only key", %{key: key} do + %Enum{key: ^key, value: nil} = Enum.new(key) + end + + test "with key and value", %{key: key, value: value} do + %Enum{key: ^key, value: ^value} = Enum.new({key, value}) + end + + test "with a nil value" do + {:error, :invalid} = Enum.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = Enum.new(:atom) + end + end + + describe "to_sc_val/1" do + test "with a valid enum type without value struct", %{enum2: enum2} do + %SCVal{ + type: :vec, + value: [ + %SCVal{type: :symbol, value: "key"} + ] + } = Enum.to_sc_val(enum2) + end + + test "with a valid enum type struct", %{enum: enum} do + %SCVal{ + type: :vec, + value: [ + %SCVal{type: :symbol, value: "key"}, + %SCVal{type: :u32, value: 100} + ] + } = Enum.to_sc_val(enum) + end + + test "with an invalid value" do + {:error, :invalid_struct_enum} = Enum.to_sc_val(nil) + end + end +end diff --git a/test/types/map_entry_test.exs b/test/types/map_entry_test.exs new file mode 100644 index 0000000..9137845 --- /dev/null +++ b/test/types/map_entry_test.exs @@ -0,0 +1,45 @@ +defmodule Soroban.Types.MapEntryTest do + use ExUnit.Case + + alias Soroban.Types.{MapEntry, Symbol, UInt32} + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + setup do + key = Symbol.new("key") + value = UInt32.new(100) + entry = MapEntry.new({key, value}) + + %{ + key: key, + value: value, + entry: entry + } + end + + describe "new/1" do + test "with a valid value", %{key: key, value: value} do + %MapEntry{key: ^key, value: ^value} = MapEntry.new({key, value}) + end + + test "with a nil value" do + {:error, :invalid} = MapEntry.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = MapEntry.new(:atom) + end + end + + describe "to_sc_map_entry/1" do + test "with a valid vec type struct", %{entry: entry} do + %SCMapEntry{ + key: %SCVal{type: :symbol, value: "key"}, + val: %SCVal{type: :u32, value: 100} + } = MapEntry.to_sc_map_entry(entry) + end + + test "with an invalid value" do + {:error, :invalid_struct_map_entry} = MapEntry.to_sc_map_entry(nil) + end + end +end diff --git a/test/types/map_test.exs b/test/types/map_test.exs new file mode 100644 index 0000000..b063c6e --- /dev/null +++ b/test/types/map_test.exs @@ -0,0 +1,54 @@ +defmodule Soroban.Types.MapTest do + use ExUnit.Case + + alias Soroban.Types.{Map, MapEntry, Symbol, UInt32} + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + setup do + key = Symbol.new("key") + value = UInt32.new(100) + entry = MapEntry.new({key, value}) + map = Map.new([entry, entry]) + + %{ + entry: entry, + map: map + } + end + + describe "new/1" do + test "with a valid value", %{entry: entry} do + %Map{values: [^entry]} = Map.new([entry]) + end + + test "with a nil value" do + {:error, :invalid} = Map.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = Map.new(:atom) + end + end + + describe "to_sc_val/1" do + test "with a valid vec type struct", %{map: map} do + %SCVal{ + type: :map, + value: [ + %SCMapEntry{ + key: %SCVal{type: :symbol, value: "key"}, + val: %SCVal{type: :u32, value: 100} + }, + %SCMapEntry{ + key: %SCVal{type: :symbol, value: "key"}, + val: %SCVal{type: :u32, value: 100} + } + ] + } = Map.to_sc_val(map) + end + + test "with an invalid value" do + {:error, :invalid_struct_map} = Map.to_sc_val(nil) + end + end +end diff --git a/test/types/option_test.exs b/test/types/option_test.exs new file mode 100644 index 0000000..a2bb42f --- /dev/null +++ b/test/types/option_test.exs @@ -0,0 +1,41 @@ +defmodule Soroban.Types.OptionTest do + use ExUnit.Case + + alias Soroban.Types.{Option, UInt32} + alias Stellar.TxBuild.SCVal + + setup do + value = UInt32.new(100) + empty_option = Option.new() + option = Option.new(value) + %{value: value, empty_option: empty_option, option: option} + end + + describe "new/1" do + test "with the default value" do + %Option{value: nil} = Option.new() + end + + test "with a valid value", %{value: value} do + %Option{value: ^value} = Option.new(value) + end + + test "with an invalid value" do + {:error, :invalid_option} = Option.new("invalid") + end + end + + describe "to_sc_val/1" do + test "with a valid struct", %{option: option} do + %SCVal{type: :u32, value: 100} = Option.to_sc_val(option) + end + + test "with a valid empty struct", %{empty_option: empty_option} do + %SCVal{type: :void, value: nil} = Option.to_sc_val(empty_option) + end + + test "with an invalid value" do + {:error, :invalid_struct_bool} = Option.to_sc_val(nil) + end + end +end diff --git a/test/types/struct_field_test.exs b/test/types/struct_field_test.exs new file mode 100644 index 0000000..b3a23e5 --- /dev/null +++ b/test/types/struct_field_test.exs @@ -0,0 +1,45 @@ +defmodule Soroban.Types.StructFieldTest do + use ExUnit.Case + + alias Soroban.Types.{StructField, UInt32} + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + setup do + key = "foo" + value = UInt32.new(100) + struct = StructField.new({key, value}) + + %{ + key: key, + value: value, + struct: struct + } + end + + describe "new/1" do + test "with a valid value", %{key: key, value: value} do + %StructField{key: ^key, value: ^value} = StructField.new({key, value}) + end + + test "with a nil value" do + {:error, :invalid} = StructField.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = StructField.new(:atom) + end + end + + describe "to_sc_map_entry/1" do + test "with a valid vec type struct", %{struct: struct} do + %SCMapEntry{ + key: %SCVal{type: :symbol, value: "foo"}, + val: %SCVal{type: :u32, value: 100} + } = StructField.to_sc_map_entry(struct) + end + + test "with an invalid value" do + {:error, :invalid_struct_field} = StructField.to_sc_map_entry(nil) + end + end +end diff --git a/test/types/struct_test.exs b/test/types/struct_test.exs new file mode 100644 index 0000000..9ea79bc --- /dev/null +++ b/test/types/struct_test.exs @@ -0,0 +1,50 @@ +defmodule Soroban.Types.StructTest do + use ExUnit.Case + + alias Soroban.Types.{Struct, StructField, UInt32} + alias Stellar.TxBuild.{SCMapEntry, SCVal} + + setup do + key = "key" + value = UInt32.new(100) + field = StructField.new({key, value}) + struct = Struct.new([field]) + + %{ + field: field, + struct: struct + } + end + + describe "new/1" do + test "with a valid value", %{field: field} do + %Struct{values: [^field]} = Struct.new([field]) + end + + test "with a nil value" do + {:error, :invalid} = Struct.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = Struct.new(:atom) + end + end + + describe "to_sc_val/1" do + test "with a valid struct type struct", %{struct: struct} do + %SCVal{ + type: :map, + value: [ + %SCMapEntry{ + key: %SCVal{type: :symbol, value: "key"}, + val: %SCVal{type: :u32, value: 100} + } + ] + } = Struct.to_sc_val(struct) + end + + test "with an invalid value" do + {:error, :invalid_struct} = Struct.to_sc_val(nil) + end + end +end diff --git a/test/types/tuple_test.exs b/test/types/tuple_test.exs new file mode 100644 index 0000000..459b6a3 --- /dev/null +++ b/test/types/tuple_test.exs @@ -0,0 +1,47 @@ +defmodule Soroban.Types.TupleTest do + use ExUnit.Case + + alias Soroban.Types.{Int32, Symbol, Tuple} + alias Stellar.TxBuild.SCVal + + setup do + values = [Symbol.new("A"), Symbol.new("B"), Int32.new(1)] + tuple = Tuple.new(values) + + %{ + tuple: tuple, + values: values + } + end + + describe "new/1" do + test "with a valid value", %{values: values} do + %Tuple{values: ^values} = Tuple.new(values) + end + + test "with a nil value" do + {:error, :invalid} = Tuple.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = Tuple.new(:atom) + end + end + + describe "to_sc_val/1" do + test "with a valid vec type struct", %{tuple: tuple} do + %SCVal{ + type: :vec, + value: [ + %SCVal{type: :symbol, value: "A"}, + %SCVal{type: :symbol, value: "B"}, + %SCVal{type: :i32, value: 1} + ] + } = Tuple.to_sc_val(tuple) + end + + test "with an invalid value" do + {:error, :invalid_struct_tuple} = Tuple.to_sc_val(nil) + end + end +end diff --git a/test/types/vec_test.exs b/test/types/vec_test.exs new file mode 100644 index 0000000..bace23c --- /dev/null +++ b/test/types/vec_test.exs @@ -0,0 +1,52 @@ +defmodule Soroban.Types.VecTest do + use ExUnit.Case + + alias Soroban.Types.{Int32, Symbol, Vec} + alias Stellar.TxBuild.SCVal + + setup do + value = [Int32.new(1)] + values = [Symbol.new("A"), Symbol.new("B")] + valid_vec = Vec.new(values) + + %{ + valid_vec: valid_vec, + values: values, + invalid_values: values ++ value + } + end + + describe "new/1" do + test "with a valid value", %{values: values} do + %Vec{values: ^values} = Vec.new(values) + end + + test "with an invalid value", %{invalid_values: invalid_values} do + {:error, :invalid_args} = Vec.new(invalid_values) + end + + test "with a nil value" do + {:error, :invalid} = Vec.new(nil) + end + + test "with an atom value" do + {:error, :invalid} = Vec.new(:atom) + end + end + + describe "to_sc_val/1" do + test "with a valid vec type struct", %{valid_vec: valid_vec} do + %SCVal{ + type: :vec, + value: [ + %SCVal{type: :symbol, value: "A"}, + %SCVal{type: :symbol, value: "B"} + ] + } = Vec.to_sc_val(valid_vec) + end + + test "with an invalid value" do + {:error, :invalid_struct_vec} = Vec.to_sc_val(nil) + end + end +end