FF-210: W2W via Thrift (#286)

This commit is contained in:
Alexey 2020-09-03 19:13:58 +03:00 committed by GitHub
parent 6e93682e1b
commit 1d94dd0ff6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 530 additions and 2 deletions

View File

@ -129,6 +129,7 @@ start_app(wapi_woody_client = AppName) ->
fistful_wallet => "http://localhost:8022/v1/wallet",
fistful_identity => "http://localhost:8022/v1/identity",
fistful_destination => "http://localhost:8022/v1/destination",
w2w_transfer => "http://localhost:8022/v1/w2w_transfer",
fistful_withdrawal => "http://localhost:8022/v1/withdrawal"
}},
{service_retries, #{

View File

@ -3,13 +3,15 @@
-include_lib("fistful_proto/include/ff_proto_identity_thrift.hrl").
-include_lib("fistful_proto/include/ff_proto_wallet_thrift.hrl").
-include_lib("fistful_proto/include/ff_proto_destination_thrift.hrl").
-include_lib("fistful_proto/include/ff_proto_w2w_transfer_thrift.hrl").
-include_lib("fistful_proto/include/ff_proto_withdrawal_thrift.hrl").
-export([check_resource/3]).
-export([check_resource_by_id/3]).
-type id() :: binary().
-type resource_type() :: identity | wallet | destination | withdrawal.
-type resource_type() :: identity | wallet | destination | w2w_transfer | withdrawal.
-type handler_context() :: wapi_handler:context().
-type data() ::
ff_proto_identity_thrift:'IdentityState'() |
@ -66,6 +68,14 @@ get_context_by_id(destination, DestinationID, WoodyCtx) ->
{exception, #fistful_DestinationNotFound{}} ->
{error, notfound}
end;
get_context_by_id(w2w_transfer, W2WTransferID, WoodyCtx) ->
Request = {w2w_transfer, 'GetContext', [W2WTransferID]},
case wapi_handler_utils:service_call(Request, WoodyCtx) of
{ok, Context} ->
Context;
{exception, #fistful_W2WNotFound{}} ->
{error, notfound}
end;
get_context_by_id(withdrawal, WithdrawalID, WoodyCtx) ->
Request = {fistful_withdrawal, 'GetContext', [WithdrawalID]},
case wapi_handler_utils:service_call(Request, WoodyCtx) of
@ -81,6 +91,8 @@ get_context_from_state(wallet, #wlt_WalletState{context = Context}) ->
Context;
get_context_from_state(destination, #dst_DestinationState{context = Context}) ->
Context;
get_context_from_state(w2w_transfer, #w2w_transfer_W2WTransferState{context = Context}) ->
Context;
get_context_from_state(withdrawal, #wthd_WithdrawalState{context = Context}) ->
Context.

View File

@ -0,0 +1,165 @@
-module(wapi_w2w_backend).
-type req_data() :: wapi_handler:req_data().
-type handler_context() :: wapi_handler:context().
-type response_data() :: wapi_handler:response_data().
-type id() :: binary().
-type external_id() :: id().
-export([create_transfer/2]).
-export([get_transfer/2]).
-include_lib("fistful_proto/include/ff_proto_w2w_transfer_thrift.hrl").
-spec create_transfer(req_data(), handler_context()) ->
{ok, response_data()} | {error, CreateError}
when
CreateError ::
{external_id_conflict, external_id()} |
{wallet_from, unauthorized} |
{wallet_from | wallet_to, notfound} |
bad_w2w_transfer_amount |
not_allowed_currency |
inconsistent_currency.
create_transfer(Params = #{<<"sender">> := SenderID}, HandlerContext) ->
case wapi_access_backend:check_resource_by_id(wallet, SenderID, HandlerContext) of
ok ->
case wapi_backend_utils:gen_id(w2w_transfer, Params, HandlerContext) of
{ok, ID} ->
Context = wapi_backend_utils:make_ctx(Params, HandlerContext),
create_transfer(ID, Params, Context, HandlerContext);
{error, {external_id_conflict, _}} = Error ->
Error
end;
{error, unauthorized} ->
{error, {wallet_from, unauthorized}}
end.
create_transfer(ID, Params, Context, HandlerContext) ->
TransferParams = marshal(transfer_params, Params#{<<"id">> => ID}),
Request = {w2w_transfer, 'Create', [TransferParams, marshal(context, Context)]},
case service_call(Request, HandlerContext) of
{ok, Transfer} ->
{ok, unmarshal(transfer, Transfer)};
{exception, #fistful_WalletNotFound{id = ID}} ->
{error, wallet_not_found_error(unmarshal(id, ID), Params)};
{exception, #fistful_ForbiddenOperationCurrency{}} ->
{error, not_allowed_currency};
{exception, #w2w_transfer_InconsistentW2WTransferCurrency{}} ->
{error, inconsistent_currency};
{exception, #fistful_InvalidOperationAmount{}} ->
{error, bad_w2w_transfer_amount}
end.
-spec get_transfer(req_data(), handler_context()) ->
{ok, response_data()} | {error, GetError}
when
GetError ::
{w2w_transfer, unauthorized} |
{w2w_transfer, {unknown_w2w_transfer, id()}}.
get_transfer(ID, HandlerContext) ->
EventRange = #'EventRange'{},
Request = {w2w_transfer, 'Get', [ID, EventRange]},
case service_call(Request, HandlerContext) of
{ok, TransferThrift} ->
case wapi_access_backend:check_resource(w2w_transfer, TransferThrift, HandlerContext) of
ok ->
{ok, unmarshal(transfer, TransferThrift)};
{error, unauthorized} ->
{error, {w2w_transfer, unauthorized}}
end;
{exception, #fistful_W2WNotFound{}} ->
{error, {w2w_transfer, {unknown_w2w_transfer, ID}}}
end.
%%
%% Internal
%%
service_call(Params, Ctx) ->
wapi_handler_utils:service_call(Params, Ctx).
wallet_not_found_error(WalletID, #{<<"sender">> := WalletID}) ->
{wallet_from, notfound};
wallet_not_found_error(WalletID, #{<<"receiver">> := WalletID}) ->
{wallet_to, notfound}.
%% Marshaling
marshal(transfer_params, #{
<<"id">> := ID,
<<"sender">> := SenderID,
<<"receiver">> := ReceiverID,
<<"body">> := Body
} = Params) ->
#w2w_transfer_W2WTransferParams{
id = marshal(id, ID),
wallet_from_id = marshal(id, SenderID),
wallet_to_id = marshal(id, ReceiverID),
body = marshal(body, Body),
external_id = maps:get(<<"externalId">>, Params, undefined)
};
marshal(body, #{
<<"amount">> := Amount,
<<"currency">> := Currency
}) ->
#'Cash'{
amount = marshal(amount, Amount),
currency = marshal(currency_ref, Currency)
};
marshal(context, Ctx) ->
ff_codec:marshal(context, Ctx);
marshal(T, V) ->
ff_codec:marshal(T, V).
unmarshal(transfer, #w2w_transfer_W2WTransferState{
id = ID,
wallet_from_id = SenderID,
wallet_to_id = ReceiverID,
body = Body,
created_at = CreatedAt,
status = Status,
external_id = ExternalID
}) ->
genlib_map:compact(#{
<<"id">> => unmarshal(id, ID),
<<"createdAt">> => CreatedAt,
<<"body">> => unmarshal(body, Body),
<<"sender">> => unmarshal(id, SenderID),
<<"receiver">> => unmarshal(id, ReceiverID),
<<"status">> => unmarshal(transfer_status, Status),
<<"externalID">> => maybe_unmarshal(id, ExternalID)
});
unmarshal(body, #'Cash'{
amount = Amount,
currency = Currency
}) ->
#{
<<"amount">> => unmarshal(amount, Amount),
<<"currency">> => unmarshal(currency_ref, Currency)
};
unmarshal(transfer_status, {pending, _}) ->
#{<<"status">> => <<"Pending">>};
unmarshal(transfer_status, {succeeded, _}) ->
#{<<"status">> => <<"Succeeded">>};
unmarshal(transfer_status, {failed, #w2w_status_Failed{failure = Failure}}) ->
#{
<<"status">> => <<"Failed">>,
<<"failure">> => unmarshal(failure, Failure)
};
unmarshal(T, V) ->
ff_codec:unmarshal(T, V).
maybe_unmarshal(_T, undefined) ->
undefined;
maybe_unmarshal(T, V) ->
unmarshal(T, V).

View File

@ -366,6 +366,42 @@ process_request('ListDeposits', Params, Context, _Opts) ->
})
end;
%% W2W
process_request('CreateW2WTransfer', #{'W2WTransferParameters' := Params}, Context, _Opts) ->
case wapi_w2w_backend:create_transfer(Params, Context) of
{ok, W2WTransfer} ->
wapi_handler_utils:reply_ok(202, W2WTransfer);
{error, {wallet_from, notfound}} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"No such wallet sender">>));
{error, {wallet_from, unauthorized}} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"No such wallet sender">>));
{error, {wallet_to, notfound}} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"No such wallet receiver">>));
{error, not_allowed_currency} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"Currency not allowed">>));
{error, bad_w2w_transfer_amount} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"Bad transfer amount">>));
{error, inconsistent_currency} ->
wapi_handler_utils:reply_ok(422,
wapi_handler_utils:get_error_msg(<<"Inconsistent currency">>))
end;
process_request('GetW2WTransfer', #{w2wTransferID := ID}, Context, _Opts) ->
case wapi_w2w_backend:get_transfer(ID, Context) of
{ok, W2WTransfer} ->
wapi_handler_utils:reply_ok(200, W2WTransfer);
{error, {w2w_transfer, unauthorized}} ->
wapi_handler_utils:reply_ok(404);
{error, {w2w_transfer, {unknown_w2w_transfer, _ID}}} ->
wapi_handler_utils:reply_ok(404)
end;
process_request(OperationID, Params, Context, Opts) ->
wapi_wallet_handler:process_request(OperationID, Params, Context, Opts).

View File

@ -17,6 +17,7 @@
-export([identity_check_test/1]).
-export([identity_challenge_check_test/1]).
-export([destination_check_test/1]).
-export([w2w_transfer_check_test/1]).
-export([withdrawal_check_test/1]).
% common-api is used since it is the domain used in production RN
@ -46,6 +47,7 @@ groups() ->
identity_challenge_check_test,
wallet_check_test,
destination_check_test,
w2w_transfer_check_test,
withdrawal_check_test
]}
].
@ -169,6 +171,24 @@ destination_check_test(C) ->
DestinationID2 = create_destination(IdentityID2, C),
?assertEqual(Keys, maps:keys(get_destination(DestinationID2, C))).
-spec w2w_transfer_check_test(config()) -> test_return().
w2w_transfer_check_test(C) ->
Name = <<"Keyn Fawkes">>,
Provider = ?ID_PROVIDER,
Class = ?ID_CLASS,
IdentityID1 = create_identity(Name, Provider, Class, C),
WalletID11 = create_wallet(IdentityID1, C),
WalletID12 = create_wallet(IdentityID1, C),
W2WTransferID1 = create_w2w_transfer(WalletID11, WalletID12, C),
Keys = maps:keys(get_w2w_transfer(W2WTransferID1, C)),
ok = application:set_env(wapi, transport, thrift),
IdentityID2 = create_identity(Name, Provider, Class, C),
WalletID21 = create_wallet(IdentityID2, C),
WalletID22 = create_wallet(IdentityID2, C),
W2WTransferID2 = create_w2w_transfer(WalletID21, WalletID22, C),
?assertEqual(Keys, maps:keys(get_w2w_transfer(W2WTransferID2, C))).
-spec withdrawal_check_test(config()) -> test_return().
withdrawal_check_test(C) ->
@ -357,6 +377,30 @@ get_destination(DestinationID, C) ->
),
Destination.
create_w2w_transfer(WalletID1, WalletID2, C) ->
DefaultParams = #{
<<"sender">> => WalletID1,
<<"receiver">> => WalletID2,
<<"body">> => #{
<<"amount">> => 1000,
<<"currency">> => ?RUB
}
},
{ok, W2WTransfer} = call_api(
fun swag_client_wallet_w2_w_api:create_w2_w_transfer/3,
#{body => DefaultParams},
ct_helper:cfg(context, C)
),
maps:get(<<"id">>, W2WTransfer).
get_w2w_transfer(W2WTransferID2, C) ->
{ok, W2WTransfer} = call_api(
fun swag_client_wallet_w2_w_api:get_w2_w_transfer/3,
#{binding => #{<<"w2wTransferID">> => W2WTransferID2}},
ct_helper:cfg(context, C)
),
W2WTransfer.
await_destination(DestID) ->
authorized = ct_helper:await(
authorized,
@ -466,6 +510,39 @@ get_default_termset() ->
]}
}
]}
},
w2w = #domain_W2WServiceTerms{
currencies = {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>)])},
allow = {constant, true},
cash_limit = {decisions, [
#domain_CashLimitDecision{
if_ = {condition, {currency_is, ?cur(<<"RUB">>)}},
then_ = {value, ?cashrng(
{inclusive, ?cash( 0, <<"RUB">>)},
{exclusive, ?cash(10001, <<"RUB">>)}
)}
}
]},
cash_flow = {decisions, [
#domain_CashFlowDecision{
if_ = {condition, {currency_is, ?cur(<<"RUB">>)}},
then_ = {value, [
?cfpost(
{wallet, sender_settlement},
{wallet, receiver_settlement},
?share(1, 1, operation_amount)
)
]}
}
]},
fees = {decisions, [
#domain_FeeDecision{
if_ = {condition, {currency_is, ?cur(<<"RUB">>)}},
then_ = {value, #domain_Fees{
fees = #{surplus => ?share(1, 1, operation_amount)}
}}
}
]}
}
}
}.

View File

@ -0,0 +1,210 @@
-module(wapi_w2w_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("damsel/include/dmsl_domain_config_thrift.hrl").
-include_lib("jose/include/jose_jwk.hrl").
-include_lib("wapi_wallet_dummy_data.hrl").
-include_lib("fistful_proto/include/ff_proto_w2w_transfer_thrift.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([
create/1,
get/1,
fail_unauthorized_wallet/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">>).
-define(badresp(Code), {error, {invalid_response_code, Code}}).
-define(emptyresp(Code), {error, {Code, #{}}}).
-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() ->
[test_case_name()].
all() ->
[
{group, base}
].
-spec groups() ->
[{group_name(), list(), [test_case_name()]}].
groups() ->
[
{base, [],
[
create,
get,
fail_unauthorized_wallet
]
}
].
%%
%% starting/stopping
%%
-spec init_per_suite(config()) ->
config().
init_per_suite(Config) ->
%% TODO remove this after cut off wapi
ok = application:set_env(wapi, transport, thrift),
ct_helper:makeup_cfg([
ct_helper:test_case_name(init),
ct_payment_system:setup(#{
optional_apps => [
bender_client,
wapi_woody_client,
wapi
]
})
], Config).
-spec end_per_suite(config()) ->
_.
end_per_suite(C) ->
%% TODO remove this after cut off wapi
ok = application:unset_env(wapi, transport),
ok = ct_payment_system:shutdown(C).
-spec init_per_group(group_name(), config()) ->
config().
init_per_group(Group, Config) when Group =:= base ->
ok = ff_context:save(ff_context:create(#{
party_client => party_client:create_client(),
woody_context => woody_context:new(<<"init_per_group/", (atom_to_binary(Group, utf8))/binary>>)
})),
Party = create_party(Config),
BasePermissions = [
{[w2w], read},
{[w2w], write}
],
{ok, Token} = wapi_ct_helper:issue_token(Party, BasePermissions, {deadline, 10}, ?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 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C),
ok = ct_helper:set_context(C1),
[{test_sup, wapi_ct_helper:start_mocked_service_sup(?MODULE)} | C1].
-spec end_per_testcase(test_case_name(), config()) ->
config().
end_per_testcase(_Name, C) ->
ok = ct_helper:unset_context(),
wapi_ct_helper:stop_mocked_service_sup(?config(test_sup, C)),
ok.
%%% Tests
-spec create(config()) ->
_.
create(C) ->
PartyID = ?config(party, C),
wapi_ct_helper:mock_services([
{bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end},
{fistful_wallet, fun('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end},
{w2w_transfer, fun('Create', _) -> {ok, ?W2W_TRANSFER(PartyID)} end}
], C),
{ok, _} = call_api(
fun swag_client_wallet_w2_w_api:create_w2_w_transfer/3,
#{
body => #{
<<"sender">> => ?STRING,
<<"receiver">> => ?STRING,
<<"body">> => #{
<<"amount">> => ?INTEGER,
<<"currency">> => ?RUB
}
}
},
ct_helper:cfg(context, C)
).
-spec get(config()) ->
_.
get(C) ->
PartyID = ?config(party, C),
wapi_ct_helper:mock_services([
{w2w_transfer, fun('Get', _) -> {ok, ?W2W_TRANSFER(PartyID)} end}
], C),
{ok, _} = call_api(
fun swag_client_wallet_w2_w_api:get_w2_w_transfer/3,
#{
binding => #{
<<"w2wTransferID">> => ?STRING
}
},
ct_helper:cfg(context, C)
).
-spec fail_unauthorized_wallet(config()) ->
_.
fail_unauthorized_wallet(C) ->
PartyID = ?config(party, C),
wapi_ct_helper:mock_services([
{bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end},
{fistful_wallet, fun('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(<<"someotherparty">>)} end},
{w2w_transfer, fun('Create', _) -> {ok, ?W2W_TRANSFER(PartyID)} end}
], C),
{error, {422, #{
<<"message">> := <<"No such wallet sender">>
}}} = call_api(
fun swag_client_wallet_w2_w_api:create_w2_w_transfer/3,
#{
body => #{
<<"sender">> => ?STRING,
<<"receiver">> => ?STRING,
<<"body">> => #{
<<"amount">> => ?INTEGER,
<<"currency">> => ?RUB
}
}
},
ct_helper:cfg(context, C)
).
%%
-spec call_api(function(), map(), wapi_client_lib:context()) ->
{ok, term()} | {error, term()}.
call_api(F, Params, Context) ->
{Url, PreparedParams, Opts} = wapi_client_lib:make_request(Context, Params),
Response = F(Url, PreparedParams, Opts),
wapi_client_lib:handle_response(Response).
create_party(_C) ->
ID = genlib:bsuuid(),
_ = ff_party:create(ID),
ID.

View File

@ -35,6 +35,13 @@
undefined
}).
-define(GENERATE_ID_RESULT, {
'bender_GenerationResult',
?STRING,
undefined,
undefined
}).
-define(WITHDRAWAL_STATUS, {pending, #wthd_status_Pending{}}).
-define(WITHDRAWAL(PartyID), #wthd_WithdrawalState{
@ -264,6 +271,24 @@
enabled = false
}).
-define(W2W_TRANSFER(PartyID), #w2w_transfer_W2WTransferState{
id = ?STRING,
wallet_from_id = ?STRING,
wallet_to_id = ?STRING,
body = ?CASH,
created_at = ?TIMESTAMP,
domain_revision = ?INTEGER,
party_revision = ?INTEGER,
status = {pending, #w2w_status_Pending{}},
external_id = ?STRING,
metadata = ?DEFAULT_METADATA(),
context = ?DEFAULT_CONTEXT(PartyID),
effective_final_cash_flow = #cashflow_FinalCashFlow{
postings = []
},
adjustments = []
}).
-define(SNAPSHOT, #'Snapshot'{
version = ?INTEGER,
domain = #{

View File

@ -91,7 +91,9 @@ get_service_modname(fistful_destination) ->
get_service_modname(fistful_withdrawal) ->
{ff_proto_withdrawal_thrift, 'Management'};
get_service_modname(webhook_manager) ->
{ff_proto_webhooker_thrift, 'WebhookManager'}.
{ff_proto_webhooker_thrift, 'WebhookManager'};
get_service_modname(w2w_transfer) ->
{ff_proto_w2w_transfer_thrift, 'Management'}.
-spec get_service_deadline(service_name()) -> undefined | woody_deadline:deadline().