APM-28: Add payment service ref to digital wallet (#1)

* Drop proprietary CI stuff

* Fix README

* Vendor in swagger-codegen generated code

Based upon swag-wallets @ f1c178db

* Build and push images w/ GH action (#1)

* Switch deps to public upstreams

* Upgrade to damsel @ 625100e

* Fix io encoding (#2)

* BACKLOG-11: Fix error type mapping issue (#3)

* TECHDEBT-16: Drop identity class leftovers @ stat backend (#4)

In line with valitydev/fistful-proto#8.

* Bump to fistful-proto@8c9aa310

* Drop few unused includes / macros

* added swag sub module

* added swag

* fixed links

* added payment service to digital wallet

* fixed tests

* removed swag

* fixed link

* fixed format

* removed old build

* added digital wallet stat test

* added map error test

* fixed linter

* added wapi suite

Co-authored-by: Andrew Mayorov <encube.ul@gmail.com>
Co-authored-by: ndiezel0 <ndiezel0@gmail.com>
This commit is contained in:
Артем 2022-02-11 11:05:14 +03:00 committed by GitHub
parent d1d440af58
commit 3b953c370b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 231 additions and 106 deletions

View File

@ -1,51 +1,3 @@
# Fistful
# Wapi @ v0
> Wallet Processing Service
## Development plan
### Бизнес-функционал
* [x] Минимальный тестсьют для кошельков
* [x] Реализовать честный identity challenge
* [x] Запилить payment provider interface
* [ ] Запилить контактные данные личности
* [x] Запилить нормально трансферы
* [ ] Заворачивать изменения в единственный ивент в рамках операции
* [.] Компактизировать состояние сессий
* [ ] Запилить контроль лимитов по кошелькам
* [ ] Запилить авторизацию по активной идентификации
* [ ] Запилить отмену identity challenge
* [ ] Запускать выводы через оплату инвойса провайдеру выводов
* [ ] Обслуживать выводы по факту оплаты инвойса
### Корректность
* [.] Схема хранения моделей
* [ ] [Дегидратация](#дегидратация)
* [ ] [Поддержка checkout](#поддержка-checkout)
* [ ] [Коммуналка](#коммуналка)
### Удобство поддержки
* [ ] Добавить [служебные лимиты](#служебные-лимиты) в рамках одного party
* [ ] Добавить ручную прополку для всех асинхронных процессов
* [ ] Вынести _ff_withdraw_ в отдельный сервис
* [ ] Разделить _development_, _release_ и _test_ зависимости
* [ ] Вынести части _ff_core_ в _genlib_
## Поддержка checkout
Каждая машина, на которую мы можем сослаться в рамках асинхронной операции, должно в идеале давать возможность афиксировать версию_ своего состояния посредством некой _ревизии_. Получение состояния по _ревизии_ осуществляется с помощью вызова операции _checkout_. В тривиальном случае _ревизия_ может быть выражена еткой времени_, в идеале омером ревизии_.
## Коммуналка
Сервис должен давать возможность работать ескольким_ клиентам, которые возможно не знают ничего друг о друге кроме того, что у них разные _tenant id_. В идеале _tenant_ должен иметь возможность давать знать о себе _динамически_, в рантайме, однако это довольно трудоёмкая задача. Если приводить аналогию с _Riak KV_, клиенты к нему могут: создать новый _bucket type_ с необходимыми характеристиками, создать новый _bucket_ с требуемыми параметрами N/R/W и так далее.
## Дегидратация
В итоге как будто бы не самая здравая идея. Есть ощущение, что проще и дешевле хранить и оперировать идентификаторами, и разыменовывать их каждый раз по необходимости.
## Служебные лимиты
Нужно уметь _ограничивать_ максимальное _ожидаемое_ количество тех или иных объектов, превышение которого может негативно влиять на качество обслуживания системы. Например, мы можем считать количество _выводов_ одним участником неограниченным, однако при этом неограниченное количество созданных _личностей_ мы совершенно не ожидаем. В этом случае возможно будет разумно ограничить их количество сверху труднодостижимой для подавляющего большинства планкой, например, в 1000 объектов. В идеале подобное должно быть точечно конфигурируемым.
> Wallet API

View File

@ -128,10 +128,11 @@ marshal(crypto_wallet, #{id := ID, data := Data}) ->
data = marshal(crypto_data, Data),
currency = marshal(crypto_currency, Data)
};
marshal(digital_wallet, #{id := ID, data := Data}) ->
marshal(digital_wallet, Wallet = #{id := ID, payment_service := PaymentService}) ->
#'DigitalWallet'{
id = marshal(string, ID),
data = marshal(digital_data, Data)
token = maybe_marshal(string, maps:get(token, Wallet, undefined)),
payment_service = marshal(payment_service, PaymentService)
};
marshal(exp_date, {Month, Year}) ->
#'BankCardExpDate'{
@ -156,12 +157,14 @@ marshal(crypto_data, {ripple, Data}) ->
{ripple, #'CryptoDataRipple'{
tag = maybe_marshal(string, maps:get(tag, Data, undefined))
}};
marshal(digital_data, {webmoney, #{}}) ->
{webmoney, #'DigitalDataWebmoney'{}};
marshal(payment_system, #{id := Ref}) when is_binary(Ref) ->
#'PaymentSystemRef'{
id = Ref
};
marshal(payment_service, #{id := Ref}) when is_binary(Ref) ->
#'PaymentServiceRef'{
id = Ref
};
marshal(payment_system_deprecated, V) when is_atom(V) ->
V;
marshal(issuer_country, V) when is_atom(V) ->

View File

@ -139,14 +139,15 @@ construct_resource(
construct_resource(
#{
<<"type">> := <<"DigitalWalletDestinationResource">>,
<<"id">> := DigitalWalletID
} = Resource
<<"id">> := DigitalWalletID,
<<"provider">> := Provider
}
) ->
ConstructedResource =
{digital_wallet, #{
digital_wallet => #{
id => DigitalWalletID,
data => marshal_digital_wallet_data(Resource)
id => marshal(string, DigitalWalletID),
payment_service => #{id => marshal(string, Provider)}
}
}},
{ok, wapi_codec:marshal(resource, ConstructedResource)}.
@ -263,14 +264,14 @@ unmarshal(
{digital_wallet, #'ResourceDigitalWallet'{
digital_wallet = #'DigitalWallet'{
id = DigitalWalletID,
data = Data
payment_service = #'PaymentServiceRef'{id = Provider}
}
}}
) ->
#{
<<"type">> => <<"DigitalWalletDestinationResource">>,
<<"id">> => unmarshal(string, DigitalWalletID),
<<"provider">> => unmarshal_digital_wallet_data(Data)
<<"provider">> => unmarshal(string, Provider)
};
unmarshal(context, Context) ->
wapi_codec:unmarshal(context, Context);
@ -323,14 +324,3 @@ unmarshal_crypto_currency_params(ripple, #'CryptoDataRipple'{tag = Tag}) ->
});
unmarshal_crypto_currency_params(_Other, _Params) ->
#{}.
marshal_digital_wallet_data(Resource) ->
#{
<<"provider">> := Provider
} = Resource,
marshal_digital_wallet_provider(Provider).
unmarshal_digital_wallet_data({webmoney, #'DigitalDataWebmoney'{}}) ->
<<"Webmoney">>.
marshal_digital_wallet_provider(<<"Webmoney">>) -> {webmoney, #{}}.

View File

@ -267,9 +267,6 @@ unmarshal_response(identities, Response) ->
<<"name">> => Response#fistfulstat_StatIdentity.name,
<<"createdAt">> => Response#fistfulstat_StatIdentity.created_at,
<<"provider">> => Response#fistfulstat_StatIdentity.provider,
<<"class">> => Response#fistfulstat_StatIdentity.identity_class,
<<"level">> => Response#fistfulstat_StatIdentity.identity_level,
<<"effectiveChallenge">> => Response#fistfulstat_StatIdentity.effective_challenge,
<<"isBlocked">> => Response#fistfulstat_StatIdentity.is_blocked,
<<"externalID">> => Response#fistfulstat_StatIdentity.external_id
});
@ -411,13 +408,10 @@ unmarshal_crypto_currency_name({zcash, _}) -> <<"Zcash">>.
unmarshal_digital_wallet(#'DigitalWallet'{
id = DigitalWalletID,
data = Data
payment_service = #'PaymentServiceRef'{id = Provider}
}) ->
#{
<<"type">> => <<"DigitalWalletDestinationResource">>,
<<"id">> => DigitalWalletID,
<<"provider">> => unmarshal_digital_wallet_data(Data)
<<"provider">> => Provider
}.
unmarshal_digital_wallet_data({webmoney, #'DigitalDataWebmoney'{}}) ->
<<"Webmoney">>.

View File

@ -51,6 +51,7 @@ map_error_type(wrong_length) -> <<"WrongLength">>;
map_error_type(wrong_size) -> <<"WrongSize">>;
map_error_type(schema_violated) -> <<"SchemaViolated">>;
map_error_type(wrong_type) -> <<"WrongType">>;
map_error_type(wrong_body) -> <<"WrongBody">>;
map_error_type(wrong_array) -> <<"WrongArray">>.
mask_notfound(Resolution) ->
@ -183,8 +184,6 @@ prepare(OperationID = 'CreateIdentity', #{'Identity' := Params}, Context, Opts)
wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"Party does not exist">>));
{error, {provider, notfound}} ->
wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such provider">>));
{error, {identity_class, notfound}} ->
wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"No such identity class">>));
{error, inaccessible} ->
wapi_handler_utils:reply_ok(422, wapi_handler_utils:get_error_msg(<<"Identity inaccessible">>));
{error, {external_id_conflict, ID}} ->

View File

@ -391,10 +391,11 @@ build_resource_spec({crypto_wallet, R}) ->
<<"id">> => (R#'ResourceCryptoWallet'.crypto_wallet)#'CryptoWallet'.id
};
build_resource_spec({digital_wallet, R}) ->
Spec = build_digital_wallet_spec((R#'ResourceDigitalWallet'.digital_wallet)#'DigitalWallet'.data),
Spec#{
#{
<<"type">> => <<"DigitalWalletDestinationResource">>,
<<"id">> => (R#'ResourceDigitalWallet'.digital_wallet)#'DigitalWallet'.id
<<"id">> => (R#'ResourceDigitalWallet'.digital_wallet)#'DigitalWallet'.id,
<<"provider">> =>
((R#'ResourceDigitalWallet'.digital_wallet)#'DigitalWallet'.payment_service)#'PaymentServiceRef'.id
};
build_resource_spec(Token) ->
#{
@ -420,9 +421,6 @@ build_crypto_cyrrency_spec({usdt, #'CryptoDataUSDT'{}}) ->
build_crypto_cyrrency_spec({zcash, #'CryptoDataZcash'{}}) ->
#{<<"currency">> => <<"Zcash">>}.
build_digital_wallet_spec({webmoney, #'DigitalDataWebmoney'{}}) ->
#{<<"provider">> => <<"Webmoney">>}.
uniq() ->
genlib:bsuuid().
@ -505,7 +503,7 @@ generate_resource(ResourceType) when ResourceType =:= webmoney ->
{digital_wallet, #'ResourceDigitalWallet'{
digital_wallet = #'DigitalWallet'{
id = uniq(),
data = generate_digital_wallet_data(webmoney)
payment_service = #'PaymentServiceRef'{id = generate_digital_wallet_provider(ResourceType)}
}
}}.
@ -526,8 +524,8 @@ generate_crypto_wallet_data(usdt) ->
generate_crypto_wallet_data(zcash) ->
{zcash, #'CryptoDataZcash'{}}.
generate_digital_wallet_data(webmoney) ->
{webmoney, #'DigitalDataWebmoney'{}}.
generate_digital_wallet_provider(webmoney) ->
<<"Webmoney">>.
make_destination(C, ResourceType) ->
PartyID = ?config(party, C),

View File

@ -0,0 +1,144 @@
-module(wapi_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("stdlib/include/assert.hrl").
-include_lib("wapi_wallet_dummy_data.hrl").
-export([all/0]).
-export([groups/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
-export([init_per_group/2]).
-export([end_per_group/2]).
-export([init_per_testcase/2]).
-export([end_per_testcase/2]).
-export([init/1]).
-export([
map_schema_violated_error_ok/1,
map_wrong_body_error_ok/1
]).
% common-api is used since it is the domain used in production RN
% TODO: change to wallet-api (or just omit since it is the default one) when new tokens will be a thing
-define(DOMAIN, <<"common-api">>).
-type test_case_name() :: atom().
-type config() :: [{atom(), any()}].
-type group_name() :: atom().
-behaviour(supervisor).
-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}.
init([]) ->
{ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}.
-spec all() -> [{group, test_case_name()}].
all() ->
[
{group, base}
].
-spec groups() -> [{group_name(), list(), [test_case_name()]}].
groups() ->
[
{base, [], [
map_schema_violated_error_ok,
map_wrong_body_error_ok
]}
].
%%
%% starting/stopping
%%
-spec init_per_suite(config()) -> config().
init_per_suite(C) ->
wapi_ct_helper:init_suite(?MODULE, C).
-spec end_per_suite(config()) -> _.
end_per_suite(C) ->
_ = wapi_ct_helper:stop_mocked_service_sup(?config(suite_test_sup, C)),
_ = [application:stop(App) || App <- ?config(apps, C)],
ok.
-spec init_per_group(group_name(), config()) -> config().
init_per_group(Group, Config) when Group =:= base ->
Party = genlib:bsuuid(),
{ok, Token} = wapi_ct_helper:issue_token(Party, [{[party], write}], unlimited, ?DOMAIN),
Config1 = [{party, Party} | Config],
[{context, wapi_ct_helper:get_context(Token)} | Config1];
init_per_group(_, Config) ->
Config.
-spec end_per_group(group_name(), config()) -> _.
end_per_group(_Group, _C) ->
ok.
-spec init_per_testcase(test_case_name(), config()) -> config().
init_per_testcase(Name, C) ->
C1 = wapi_ct_helper:makeup_cfg([wapi_ct_helper:test_case_name(Name), wapi_ct_helper:woody_ctx()], C),
[{test_sup, wapi_ct_helper:start_mocked_service_sup(?MODULE)} | C1].
-spec end_per_testcase(test_case_name(), config()) -> ok.
end_per_testcase(_Name, C) ->
_ = wapi_ct_helper:stop_mocked_service_sup(?config(test_sup, C)),
ok.
%%% Tests
-spec map_schema_violated_error_ok(config()) -> _.
map_schema_violated_error_ok(C) ->
Context = wapi_ct_helper:cfg(context, C),
Params = #{},
{Endpoint, PreparedParams, Opts0} = wapi_client_lib:make_request(Context, Params),
Url = swag_client_wallet_utils:get_url(Endpoint, "/wallet/v0/w2w/transfers"),
Headers = maps:to_list(maps:get(header, PreparedParams)),
Body = <<"{}">>,
Opts = Opts0 ++ [with_body],
{ok, 400, _, Error} = hackney:request(
post,
Url,
Headers,
Body,
Opts
),
ExpectedError = make_mapped_error(
"W2WTransferParameters", "SchemaViolated", ", description: Missing required property: body."
),
?assertEqual(
ExpectedError,
Error
).
-spec map_wrong_body_error_ok(config()) -> _.
map_wrong_body_error_ok(C) ->
Context = wapi_ct_helper:cfg(context, C),
Params = #{},
{Endpoint, PreparedParams, Opts0} = wapi_client_lib:make_request(Context, Params),
Url = swag_client_wallet_utils:get_url(Endpoint, "/wallet/v0/w2w/transfers"),
Headers = maps:to_list(maps:get(header, PreparedParams)),
LongBinary =
<<
"LongBinaryLongBinaryLongBinaryLongBinaryLongBinaryLong\n"
" BinaryLongBinaryLongBinaryLongBinaryLongBinaryLongBinary"
>>,
Body = <<"{", LongBinary/binary, LongBinary/binary, LongBinary/binary, LongBinary/binary, LongBinary/binary, "}">>,
Opts = Opts0 ++ [with_body],
{ok, 400, _, Error} = hackney:request(
post,
Url,
Headers,
Body,
Opts
),
ExpectedError = make_mapped_error("W2WTransferParameters", "WrongBody", ", description: Invalid json"),
?assertEqual(
ExpectedError,
Error
).
make_mapped_error(Name, Type, Desc) ->
Format = <<"{\"description\":\"Request parameter: ~s, error type: ~s~s\",\"errorType\":\"~s\",\"name\":\"~s\"}">>,
genlib:to_binary(io_lib:format(Format, [Name, Type, Desc, Type, Name])).

View File

@ -0,0 +1,7 @@
{
"use": "enc",
"kty": "oct",
"kid": "1",
"alg": "dir",
"k": "M3VKOExvQVdhWUtXekduVGt1eDdrUmtwTTNBSko1a2M"
}

View File

@ -0,0 +1,10 @@
{
"use": "enc",
"kty": "EC",
"kid": "kxdD0orVPGoAxWrqAMTeQ0U5MRoK47uZxWiSJdgo0t0",
"crv": "P-256",
"alg": "ECDH-ES",
"x": "nHi7TCgBwfrPuNTf49bGvJMczk6WZOI-mCKAghbrOlM",
"y": "_8kiXGOIWkfz57m8K5dmTfbYzCJVYHZZZisCfbYicr0",
"d": "i45qDiARZ5qbS_uzeT-CiKnPUe64qHitKaVdAvcN6TI"
}

View File

@ -0,0 +1,9 @@
{
"use": "enc",
"kty": "EC",
"kid": "kxdD0orVPGoAxWrqAMTeQ0U5MRoK47uZxWiSJdgo0t0",
"crv": "P-256",
"alg": "ECDH-ES",
"x": "nHi7TCgBwfrPuNTf49bGvJMczk6WZOI-mCKAghbrOlM",
"y": "_8kiXGOIWkfz57m8K5dmTfbYzCJVYHZZZisCfbYicr0"
}

View File

@ -0,0 +1,9 @@
-----BEGIN RSA PRIVATE KEY-----
MIIBOwIBAAJBAK9fx7qOJT7Aoseu7KKgaLagBh3wvDzg7F/ZMtGbPFikJnnvRWvF
B5oEGbMPblvtF0/fjqfu+eqjP3Z1tUSn7TkCAwEAAQJABUY5KIgr4JZEjwLYxQ9T
9uIbLP1Xe/E7yqoqmBk2GGhSrPY0OeRkYnUVLcP96UPQhF63iuG8VF6uZ7oAPsq+
gQIhANZy3jSCzPjXYHRU1kRqQzpt2S+OqoEiqQ6YG1HrC/VxAiEA0Vq6JlQK2tOX
37SS00dK0Qog4Qi8dN73GliFQNP18EkCIQC4epSA48zkfJMzQBAbRraSuxDNApPX
BzQbo+pMrEDbYQIgY4AncQgIkLB4Qk5kah48JNYXglzQlQtTjiX8Ty9ueGECIQCM
GD3UbQKiA0gf5plBA24I4wFVKxxa4wXbW/7SfP6XmQ==
-----END RSA PRIVATE KEY-----

View File

@ -41,11 +41,6 @@
}
}).
-define(IDENTITY_CLASS, #'provider_IdentityClass'{
id = ?STRING,
name = ?STRING
}).
-define(PROVIDER, #provider_Provider{
id = ?STRING,
name = ?STRING,
@ -149,7 +144,14 @@
}}
).
-define(DIGITAL_WALLET, #'DigitalWallet'{
id = ?STRING,
token = ?STRING,
payment_service = #'PaymentServiceRef'{id = <<"Webmoney">>}
}).
-define(RESOURCE, {bank_card, ?BANK_CARD}).
-define(RESOURCE_DIGITAL_WALLET, {digital_wallet, ?DIGITAL_WALLET}).
-define(BIN(CardNumber), string:slice(CardNumber, 0, 6)).
@ -255,6 +257,17 @@
resource = ?RESOURCE,
external_id = ?STRING,
status = {unauthorized, #fistfulstat_Unauthorized{}}
},
#fistfulstat_StatDestination{
id = ?STRING,
name = ?STRING,
created_at = ?TIMESTAMP,
is_blocked = ?BOOLEAN,
identity = ?STRING,
currency_symbolic_code = ?RUB,
resource = ?RESOURCE_DIGITAL_WALLET,
external_id = ?STRING,
status = {unauthorized, #fistfulstat_Unauthorized{}}
}
]}
).
@ -266,9 +279,6 @@
name = ?STRING,
created_at = ?TIMESTAMP,
provider = ?STRING,
identity_class = ?STRING,
identity_level = ?STRING,
effective_challenge = ?STRING,
is_blocked = ?BOOLEAN,
external_id = ?STRING
}

View File

@ -18,7 +18,7 @@
{<<"cache">>,{pkg,<<"cache">>,<<"2.3.3">>},1},
{<<"certifi">>,{pkg,<<"certifi">>,<<"2.6.1">>},2},
{<<"cg_mon">>,
{git,"https://github.com/rbkmoney/cg_mon.git",
{git,"https://github.com/valitydev/cg_mon.git",
{ref,"5a87a37694e42b6592d3b4164ae54e0e87e24e18"}},
1},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"2.9.0">>},1},
@ -44,7 +44,7 @@
{ref,"3f66402843ffeb488010f707a193858cb09325e0"}},
0},
{<<"dmt_core">>,
{git,"https://github.com/rbkmoney/dmt_core.git",
{git,"https://github.com/valitydev/dmt_core.git",
{ref,"5a0ff399dee3fd606bb864dd0e27ddde539345e2"}},
1},
{<<"email_validator">>,{pkg,<<"email_validator">>,<<"1.1.0">>},1},
@ -58,7 +58,7 @@
0},
{<<"fistful_proto">>,
{git,"https://github.com/valitydev/fistful-proto.git",
{ref,"519551dba6aa3618e879f0f81244ff3208d67edd"}},
{ref,"cfaaf3d32f02fcc52ea9be563409a815c5d65e18"}},
0},
{<<"fistful_reporter_proto">>,
{git,"https://github.com/valitydev/fistful-reporter-proto.git",
@ -95,7 +95,7 @@
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},2},
{<<"msgpack_proto">>,
{git,"https://github.com/rbkmoney/msgpack-proto.git",
{git,"https://github.com/valitydev/msgpack-proto.git",
{ref,"ec15d5e854ea60c58467373077d90c2faf6273d8"}},
1},
{<<"org_management_proto">>,
@ -105,7 +105,7 @@
{<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},1},
{<<"quickrand">>,
{git,"https://github.com/okeuday/quickrand.git",
{ref,"036f1a2037de541302438f7d0a31d5122aae98e2"}},
{ref,"7fe89e9cfcc1378b7164e9dac4e7f02119110b68"}},
1},
{<<"ranch">>,{pkg,<<"ranch">>,<<"1.8.0">>},2},
{<<"scoper">>,
@ -113,7 +113,7 @@
{ref,"7f3183df279bc8181efe58dafd9cae164f495e6f"}},
0},
{<<"snowflake">>,
{git,"https://github.com/rbkmoney/snowflake.git",
{git,"https://github.com/valitydev/snowflake.git",
{ref,"de159486ef40cec67074afe71882bdc7f7deab72"}},
1},
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.6">>},2},
@ -134,7 +134,7 @@
{ref,"27158ea5d3e3c74f0090f8d2ac3f2ca52ab39584"}},
0},
{<<"token_keeper_proto">>,
{git,"https://github.com/rbkmoney/token-keeper-proto.git",
{git,"https://github.com/valitydev/token-keeper-proto.git",
{ref,"15781716691a72de8c8f065c11c5b08173fc8434"}},
1},
{<<"uac">>,
@ -144,7 +144,7 @@
{<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},2},
{<<"uuid">>,
{git,"https://github.com/okeuday/uuid.git",
{ref,"8e8a34e52817ab9e2a9378cf3b8ddeeed7b3cbae"}},
{ref,"965c76b7343530cf940a808f497eef37d0a332e6"}},
0},
{<<"woody">>,
{git,"https://github.com/valitydev/woody_erlang.git",