CAPI-23 Add initial project structure (#1)

* CAPI-23 Add initial project structure. Add mock-backend and basic tests
This commit is contained in:
Artem Ocheredko 2016-08-31 20:56:24 +03:00 committed by GitHub
parent 9185ca8366
commit 64d7c30607
18 changed files with 704 additions and 11 deletions

28
.gitignore vendored
View File

@ -1,10 +1,20 @@
.eunit
deps
*.o
*.beam
*.plt
# general
log
/_build/
*~
erl_crash.dump
ebin
rel/example_project
.concrete/DEV_MODE
.rebar
/*.config
.tags*
*.sublime-workspace
.DS_Store
# wercker
/_builds/
/_cache/
/_projects/
/_steps/
/_temp/
/.wercker/
# compiled
swagger

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "schemes/swag"]
path = schemes/swag
url = git@github.com:rbkmoney/swag.git

80
Makefile Normal file
View File

@ -0,0 +1,80 @@
REBAR := $(shell which rebar3 2>/dev/null || which ./rebar3)
RELNAME = capi
SUBMODULES = schemes/swag
SUBTARGETS = $(patsubst %,%/.git,$(SUBMODULES))
SWAGGER_SCHEME = schemes/swag/swagger.yaml
SWAGGER_APP_PATH = apps/swagger
SWAGGER_APP_TARGET = $(SWAGGER_APP_PATH)/rebar.config
which = $(if $(shell which $(1) 2>/dev/null),\
$(shell which $(1) 2>/dev/null),\
$(error "Error: could not locate $(1)!"))
DOCKER = $(call which, docker)
PACKER = $(call which, packer)
SWAGGER_CODEGEN = $(call which, SWAGGER_CODEGEN)
.PHONY: all submodules compile devrel start test clean distclean dialyze release containerize swagger_regenerate
all: compile
rebar-update:
$(REBAR) update
$(SUBTARGETS): %/.git: %
git submodule update --init $<
touch $@
submodules: $(SUBTARGETS) $(SWAGGER_APP_TARGET)
compile: submodules
$(REBAR) compile
devrel: submodules
$(REBAR) release
start: submodules
$(REBAR) run
test: submodules
$(REBAR) ct
lint: compile
elvis rock
xref: submodules
$(REBAR) xref
clean:
$(REBAR) clean
distclean:
$(REBAR) clean -a
rm -rfv _build _builds _cache _steps _temp
dialyze:
$(REBAR) dialyzer
BASE_DIR := $(shell pwd)
release: ~/.docker/config.json distclean
$(DOCKER) run --rm -v $(BASE_DIR):$(BASE_DIR) --workdir $(BASE_DIR) rbkmoney/build rebar3 as prod release
containerize: release ./packer.json
$(PACKER) build packer.json
~/.docker/config.json:
test -f ~/.docker/config.json || (echo "Please run: docker login" ; exit 1)
# Shitty generation. Will be replaced when a container with swagger-codegen appear
define swagger_regenerate
rm -rf $(SWAGGER_APP_PATH)
$(SWAGGER_CODEGEN) generate -i $(SWAGGER_SCHEME) -l erlang-server -o $(SWAGGER_APP_PATH);
endef
$(SWAGGER_APP_TARGET): $(SWAGGER_SCHEME)
$(call swagger_regenerate)
swagger_regenerate:
$(call swagger_regenerate)

View File

@ -1,2 +1,30 @@
# erlang_capi
Erlang CAPI version
# capi
A service that does something
## Сборка
Для запуска процесса сборки достаточно выполнить просто:
make
Чтобы запустить полученную сборку в режиме разработки и получить стандартный [Erlang shell][2], нужно всего лишь:
make start
> _Хозяйке на заметку._ При этом используется стандартный Erlang релиз, собранный при помощи [relx][3] в режиме разработчика.
Рекомендуется вести разработку и сборку проекта в рамках локальной виртуальной среды, предоставляемой [wercker][1]. Настоятельно рекомендуется прогоны тестовых сценариев проводить только в этой среде.
$ wercker dev
> _Хозяйке на заметку._ В зависимости от вашего окружения и операционной системы вам может понадобиться [Docker Machine][4].
## Документация
Дальнейшую документацию можно почерпнуть, пройдясь по ссылкам в [соответствующем документе](doc/index.md).
[1]: http://devcenter.wercker.com/learn/basics/the-wercker-cli.html
[2]: http://erlang.org/doc/man/shell.html
[3]: https://github.com/erlware/relx
[4]: https://docs.docker.com/machine/install-machine/

View File

@ -0,0 +1,19 @@
{application, capi , [
{description, "A service that does something"},
{vsn, "1"},
{registered, []},
{mod, { capi , []}},
{applications, [
kernel,
stdlib,
genlib,
swagger
]},
{env, []},
{modules, []},
{maintainers, [
"Artem Ocheredko <galaxie.stern@gmail.com>"
]},
{licenses, []},
{links, []}
]}.

21
apps/capi/src/capi.erl Normal file
View File

@ -0,0 +1,21 @@
%% @doc Public API and application startup.
%% @end
-module(capi).
-behaviour(application).
%% Application callbacks
-export([start/2]).
-export([stop/1]).
%%
-spec start(normal, any()) -> {ok, pid()} | {error, any()}.
start(_StartType, _StartArgs) ->
capi_sup:start_link().
-spec stop(any()) -> ok.
stop(_State) ->
ok.

View File

@ -0,0 +1,25 @@
-module(capi_auth).
-export([auth_api_key/2]).
-type context() :: #{binary() => any()}.
-spec auth_api_key(ApiKey :: binary(), OperationID :: atom()) -> {true, Context :: context()} | false.
auth_api_key(ApiKey, OperationID) ->
{ok, Type, Credentials} = parse_auth_token(ApiKey),
{ok, Context} = process_auth(Type, Credentials, OperationID),
{true, Context}.
-spec parse_auth_token(ApiKey :: binary()) -> {ok, bearer, Credentials :: binary()} | {error, Reason :: atom()}.
parse_auth_token(ApiKey) ->
case ApiKey of
<<"Bearer ", Credentials/binary>> ->
{ok, bearer, Credentials};
_ ->
{error, unsupported_auth_scheme}
end.
-spec process_auth(Type :: atom(), AuthToken :: binary(), OperationID :: atom()) -> {ok, Context :: context()} | {error, Reason :: atom()}.
process_auth(bearer, _AuthToken, _OperationID) ->
%% @TODO find a jwt library :(
{ok, #{}}.

View File

@ -0,0 +1,185 @@
-module(capi_mock_handler).
-behaviour(swagger_logic_handler).
-behaviour(gen_server).
%% API callbacks
-export([start_link/0]).
-export([handle_request/2]).
-export([authorize_api_key/2]).
%% gen_server callbacks
-export([init/1]).
-export([handle_call/3]).
-export([handle_cast/2]).
-export([handle_info/2]).
-export([terminate/2]).
-export([code_change/3]).
-record(state, {
tid :: ets:tid(),
last_id = 0 ::integer()
}).
-spec start_link() -> {ok, Pid :: pid()} | ignore | {error, Error :: any()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec authorize_api_key(ApiKey :: binary(), OperationID :: atom()) -> Result :: boolean() | {boolean(), #{binary() => any()}}.
authorize_api_key(ApiKey, OperationID) -> capi_auth:auth_api_key(ApiKey, OperationID).
-spec handle_request(OperationID :: atom(), Req :: #{}) -> {Code :: integer, Headers :: [], Response :: #{}}.
handle_request('CreateInvoice', Req) ->
InvoiceParams = maps:get('CreateInvoiceArgs', Req),
ID = new_id(),
Invoice = #{
<<"id">> => ID,
<<"amount">> => maps:get(<<"amount">>, InvoiceParams),
<<"context">> => maps:get(<<"context">>, InvoiceParams),
<<"currency">> => maps:get(<<"currency">>, InvoiceParams),
<<"description">> => maps:get(<<"description">>, InvoiceParams),
<<"dueDate">> => maps:get(<<"dueDate">>, InvoiceParams),
<<"product">> => maps:get(<<"product">>, InvoiceParams),
<<"shopID">> => maps:get(<<"shopID">>, InvoiceParams)
},
put_data(ID, invoice, Invoice),
Resp = #{
<<"id">> => ID
},
{201, [], Resp};
handle_request('CreatePayment', Req) ->
InvoiceID = maps:get('invoice_id', Req),
PaymentParams = maps:get('CreatePaymentArgs', Req),
PaymentSession = maps:get(<<"paymentSession">>, PaymentParams),
case match_data({{'$1', session}, PaymentSession}) of
[[_SessionID]] ->
delete_data({{'$1', session}, '_'}),
PaymentID = new_id(),
Payment = #{
<<"id">> => PaymentID ,
<<"invoiceID">> => InvoiceID,
<<"createdAt">> => <<"2016-12-12 17:00:00">>,
<<"status">> => <<"pending">>,
<<"paymentToolToken">> => maps:get(<<"paymentToolToken">>, PaymentParams)
},
put_data(PaymentID, payment, Payment),
Resp = #{
<<"id">> => PaymentID
},
{201, [], Resp};
_ ->
Resp = logic_error(<<"expired_session">>, <<"Payment session is not valid">>),
{400, [], Resp}
end;
handle_request('CreatePaymentToolToken', Req) ->
Params = maps:get('PaymentTool', Req),
Token = tokenize_payment_tool(Params),
put_data(new_id(), token, Token),
Session = generate_session(),
put_data(new_id(), session, Session),
Resp = #{
<<"token">> => Token,
<<"session">> => Session
},
{201, [], Resp};
handle_request('GetInvoiceByID', Req) ->
InvoiceID = maps:get(invoice_id, Req),
[{_, Invoice}] = get_data(InvoiceID, invoice),
{200, [], Invoice};
handle_request('GetInvoiceEvents', _Req) ->
Events = [],
{200, [], Events};
handle_request('GetPaymentByID', Req) ->
PaymentID = maps:get(payment_id, Req),
[{_, Payment}] = get_data(PaymentID, payment),
{200, [], Payment};
handle_request(OperationID, Req) ->
io:format(user, "Got request to process: ~p~n", [{OperationID, Req}]),
{501, [], <<"Not implemented">>}.
%%%
-type callref() :: {pid(), Tag :: reference()}.
-type st() :: #state{}.
-spec init( Args :: any()) -> {ok, st()}.
init(_Args) ->
TID = ets:new(mock_storage, [ordered_set, private, {heir, none}]),
{ok, #state{tid = TID}}.
-spec handle_call(Request :: any(), From :: callref(), st()) -> {reply, term(), st()} | {noreply, st()}.
handle_call({put, ID, Type, Data}, _From, State = #state{tid = TID}) ->
Result = ets:insert(TID, {wrap_id(ID, Type), Data}),
{reply, Result, State};
handle_call({get, ID, Type}, _From, State = #state{tid = TID}) ->
Result = ets:lookup(TID, wrap_id(ID, Type)),
{reply, Result, State};
handle_call({match, Pattern}, _From, State = #state{tid = TID}) ->
Result = ets:match(TID, Pattern),
{reply, Result, State};
handle_call({delete, Pattern}, _From, State = #state{tid = TID}) ->
Result = ets:match_delete(TID, Pattern),
{reply, Result, State};
handle_call(id, _From, State = #state{last_id = ID}) ->
NewID = ID + 1,
{reply, NewID, State#state{last_id = NewID}}.
-spec handle_cast(Request :: any(), st()) -> {noreply, st()}.
handle_cast(_Request, State) ->
{noreply, State}.
-spec handle_info(any(), st()) -> {noreply, st()}.
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(any(), st()) -> ok.
terminate(_Reason, _State) ->
ok.
-spec code_change(Vsn :: term() | {down, Vsn :: term()}, st(), term()) -> {error, noimpl}.
code_change(_OldVsn, _State, _Extra) ->
{error, noimpl}.
put_data(ID, Type, Data) ->
gen_server:call(?MODULE, {put, ID, Type, Data}).
get_data(ID, Type) ->
gen_server:call(?MODULE, {get, ID, Type}).
match_data(Pattern) ->
gen_server:call(?MODULE, {match, Pattern}).
delete_data(Pattern) ->
gen_server:call(?MODULE, {delete, Pattern}).
new_id() ->
ID = gen_server:call(?MODULE, id),
genlib:to_binary(ID).
tokenize_payment_tool(Params = #{<<"paymentToolType">> := <<"cardData">>}) ->
CardNumber = genlib:to_binary(maps:get(<<"cardNumber">>, Params)),
ExpDate = maps:get(<<"expDate">>, Params),
erlang:md5(<<CardNumber/binary, ExpDate/binary>>);
tokenize_payment_tool(_) ->
error(unsupported_payment_tool). %%@TODO move this error to upper level
generate_session() ->
integer_to_binary(rand:uniform(100000)).
logic_error(Code, Message) ->
#{code => Code, message => Message}.
wrap_id(ID, Type) ->
{ID, Type}.

View File

@ -0,0 +1,46 @@
%% @doc Top level supervisor.
%% @end
-module(capi_sup).
-behaviour(supervisor).
%% API
-export([start_link/0]).
%% Supervisor callbacks
-export([init/1]).
%%
-spec start_link() -> {ok, pid()} | {error, {already_started, pid()}}.
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
%%
-spec init([]) -> {ok, tuple()}.
init([]) ->
{LogicHandler, LogicHandlerSpec} = get_logic_handler_info(),
SwaggerSpec = swagger_server:child_spec(swagger, #{
ip => capi_utils:get_hostname_ip(genlib_app:env(capi, host, "0.0.0.0")),
port => genlib_app:env(capi, port, 8080),
net_opts => [],
logic_handler => LogicHandler
}),
{ok, {
{one_for_all, 0, 1}, [LogicHandlerSpec, SwaggerSpec]
}}.
-spec get_logic_handler_info() -> {Handler :: atom(), Spec :: supervisor:child_spec()}.
get_logic_handler_info() ->
case genlib_app:env(capi, service_type) of
mock ->
Spec = genlib_app:permanent(
{capi_mock_handler, capi_mock_handler, start_link},
none,
[]
),
{capi_mock_handler, Spec};
undefined -> exit(undefined_service_type)
end.

View File

@ -0,0 +1,16 @@
-module(capi_utils).
-export([get_hostname_ip/1]).
-spec get_hostname_ip(Hostname | IP) -> IP when
Hostname :: string(),
IP :: inet:ip_address().
get_hostname_ip(Host) ->
% TODO: respect preferred address family
case inet:getaddr(Host, inet) of
{ok, IP} ->
IP;
{error, Error} ->
exit(Error)
end.

View File

@ -0,0 +1,180 @@
-module(capi_tests_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([all/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).
%% test cases
-export([
authorization_error_test/1,
create_invoice_badard_test/1,
create_invoice_ok_test/1,
create_payment_ok_test/1,
create_payment_tool_token_ok_test/1,
get_invoice_by_id_ok_test/1,
get_invoice_events_ok_test/1,
get_payment_by_id_ok_test/1
]).
-define(CAPI_HOST, "0.0.0.0").
-define(CAPI_PORT, 8080).
-define(CAPI_SERVICE_TYPE, mock).
all() ->
[
authorization_error_test,
create_invoice_badard_test,
create_invoice_ok_test,
create_payment_ok_test,
create_payment_tool_token_ok_test,
get_invoice_by_id_ok_test,
get_invoice_events_ok_test,
get_payment_by_id_ok_test
].
%%
%% starting/stopping
%%
init_per_suite(C) ->
{_, Seed} = calendar:local_time(),
random:seed(Seed),
test_configuration(),
{ok, Apps1} = application:ensure_all_started(capi),
{ok, Apps2} = application:ensure_all_started(hackney),
[{apps, Apps1 ++ Apps2} | C].
end_per_suite(C) ->
[application_stop(App) || App <- proplists:get_value(apps, C)].
application_stop(App = sasl) ->
%% hack for preventing sasl deadlock
%% http://erlang.org/pipermail/erlang-questions/2014-May/079012.html
error_logger:delete_report_handler(cth_log_redirect),
_ = application:stop(App),
error_logger:add_report_handler(cth_log_redirect),
ok;
application_stop(App) ->
application:stop(App).
%% tests
authorization_error_test(_) ->
{ok, 401, _RespHeaders, _Body} = call(get, "/invoices/22?limit=22", #{}, []).
create_invoice_badard_test(_) ->
{ok, 400, _RespHeaders, _Body} = default_call(post, "/invoices", #{}).
create_invoice_ok_test(_) ->
#{<<"id">> := _InvoiceID} = default_create_invoice().
create_payment_ok_test(_) ->
#{<<"id">> := InvoiceID} = default_create_invoice(),
#{
<<"session">> := PaymentSession,
<<"token">> := PaymentToolToken
} = default_tokenize_card(),
#{<<"id">> := _PaymentID} = default_create_payment(InvoiceID, PaymentSession, PaymentToolToken).
create_payment_tool_token_ok_test(_) ->
#{<<"token">> := _Token, <<"session">> := _Session} = default_tokenize_card().
get_invoice_by_id_ok_test(_) ->
#{<<"id">> := InvoiceID} = default_create_invoice(),
Path = "/invoices/" ++ genlib:to_list(InvoiceID),
{ok, 200, _RespHeaders, _Body} = default_call(get, Path, #{}).
get_invoice_events_ok_test(_) ->
#{<<"id">> := InvoiceID} = default_create_invoice(),
#{
<<"session">> := PaymentSession,
<<"token">> := PaymentToolToken
} = default_tokenize_card(),
#{<<"id">> := _PaymentID} = default_create_payment(InvoiceID, PaymentSession, PaymentToolToken),
timer:sleep(1000),
Path = "/invoices/" ++ genlib:to_list(InvoiceID) ++ "/events/?limit=100",
{ok, 200, _RespHeaders, _Body} = default_call(get, Path, #{}).
get_payment_by_id_ok_test(_) ->
#{<<"id">> := InvoiceID} = default_create_invoice(),
#{
<<"session">> := PaymentSession,
<<"token">> := PaymentToolToken
} = default_tokenize_card(),
#{<<"id">> := PaymentID} = default_create_payment(InvoiceID, PaymentSession, PaymentToolToken),
Path = "/invoices/" ++ genlib:to_list(InvoiceID) ++ "/payments/" ++ genlib:to_list(PaymentID),
{ok, 200, _RespHeaders, _Body} = default_call(get, Path, #{}).
%% helpers
test_configuration() ->
application:set_env(capi, host, ?CAPI_HOST),
application:set_env(capi, port, ?CAPI_PORT),
application:set_env(capi, service_type, ?CAPI_SERVICE_TYPE).
default_call(Method, Path, Body) ->
call(Method, Path, Body, [x_request_id_header(), auth_header(), json_content_type_header()]).
call(Method, Path, Body, Headers) ->
Url = get_url(Path),
PreparedBody = jsx:encode(Body),
{ok, Code, RespHeaders, ClientRef} = hackney:request(Method, Url, Headers, PreparedBody),
{ok, Code, RespHeaders, get_body(ClientRef)}.
get_url(Path) ->
?CAPI_HOST ++ ":" ++ integer_to_list(?CAPI_PORT) ++ Path.
x_request_id_header() ->
{<<"X-Request-ID">>, integer_to_binary(rand:uniform(100000))}.
auth_header() ->
{<<"Authorization">>, <<"Bearer ", (auth_token())/binary>>} .
auth_token() ->
<<"I can't find JWT library :(">>.
json_content_type_header() ->
{<<"Content-Type">>, <<"application/json">>}.
default_create_invoice() ->
Req = #{
<<"shopID">> => <<"test_shop_id">>,
<<"amount">> => 100000,
<<"currency">> => <<"RUB">>,
<<"context">> => #{
<<"invoice_dummy_context">> => <<"test_value">>
},
<<"dueDate">> => <<"2017-07-11 10:00:00">>,
<<"product">> => <<"test_product">>,
<<"description">> => <<"test_invoice_description">>
},
{ok, 201, _RespHeaders, Body} = default_call(post, "/invoices", Req),
decode_body(Body).
default_tokenize_card() ->
Req = #{
<<"paymentToolType">> => <<"cardData">>,
<<"cardHolder">> => <<"Alexander Weinerschnitzel">>,
<<"cardNumber">> => 4111111111111111,
<<"expDate">> => <<"08/27">>,
<<"cvv">> => <<"232">>
},
{ok, 201, _RespHeaders, Body} = default_call(post, "/payment_tools", Req),
decode_body(Body).
default_create_payment(InvoiceID, PaymentSession, PaymentToolToken) ->
Req = #{
<<"paymentSession">> => PaymentSession,
<<"paymentToolToken">> => PaymentToolToken
},
Path = "/invoices/" ++ genlib:to_list(InvoiceID) ++ "/payments",
{ok, 201, _RespHeaders, Body} = default_call(post, Path, Req),
decode_body(Body).
get_body(ClientRef) ->
{ok, Body} = hackney:body(ClientRef),
Body.
decode_body(Body) ->
jsx:decode(Body, [return_maps]).

7
config/sys.config Normal file
View File

@ -0,0 +1,7 @@
[
{ capi , [
{host, "0.0.0.0"},
{port, 8080},
{service_type, mock}
]}
].

6
config/vm.args Normal file
View File

@ -0,0 +1,6 @@
-sname capi
-setcookie capi_cookie
+K true
+A 10

5
doc/index.md Normal file
View File

@ -0,0 +1,5 @@
# Документация
1. [Общее описание](overview.md)
1. [Установка](install.md)
1. [Первоначалная настройка](configuration.md)

24
packer.json Normal file
View File

@ -0,0 +1,24 @@
{
"builders": [
{
"type": "docker",
"image": "rbkmoney/service_erlang",
"pull": "true",
"commit": "true"
}
],
"provisioners": [
{
"type": "file",
"source": "./_build/prod/rel/capi",
"destination": "/opt/"
}
],
"post-processors": [
{
"type": "docker-tag",
"repository": "rbkmoney/capi_erlang"
}
]
}

21
rebar.lock Normal file
View File

@ -0,0 +1,21 @@
[{<<"certifi">>,{pkg,<<"certifi">>,<<"0.4.0">>},1},
{<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},0},
{<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},1},
{<<"genlib">>,
{git,"https://github.com/rbkmoney/genlib.git",
{ref,"66db7fe296465a875b6894eb5ac944c90f82f913"}},
0},
{<<"hackney">>,{pkg,<<"hackney">>,<<"1.5.7">>},0},
{<<"idna">>,{pkg,<<"idna">>,<<"1.2.0">>},1},
{<<"jesse">>,
{git,"https://github.com/for-GET/jesse.git",
{ref,"f4270eb0a9bc64291c6e2205645b45b5b7b686f8"}},
0},
{<<"jsx">>,
{git,"https://github.com/talentdeficit/jsx.git",
{ref,"3074d4865b3385a050badf7828ad31490d860df5"}},
0},
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},1},
{<<"ranch">>,{pkg,<<"ranch">>,<<"1.2.1">>},1},
{<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.0">>},1}].

1
schemes/swag Submodule

@ -0,0 +1 @@
Subproject commit 60b0337c99be8546179f9473adcc1596395dc831

16
wercker.yml Normal file
View File

@ -0,0 +1,16 @@
box: erlang:18
dev:
steps:
- internal/shell:
code: make compile
build:
steps:
- script:
name: run test suite
code: make test
after-steps:
- slack-notifier:
url: ${SLACK_WEBHOOK_URL}
username: "wercker"