diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3b992e3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,44 @@ +name: ci +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + strategy: + fail-fast: false + matrix: + otp: + - '26.2' + rebar3: + - '3.23' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.1.4 + - name: setup erlang + uses: erlef/setup-beam@v1.17.5 + with: + otp-version: ${{ matrix.otp }} + rebar3-version: ${{ matrix.rebar3 }} + - name: cache hex deps + uses: actions/cache@v4.0.2 + with: + path: ~/.cache/rebar3/hex/hexpm/packages + key: ${{ runner.os }}-hex-${{ hashFiles(format('{0}{1}', github.workspace, '/rebar.lock')) }} + restore-keys: | + ${{ runner.os }}-hex- + - name: build + run: make compile + - name: eunit + run: make eunit + - name: start azurite + run: test/start_azurite.sh + - name: ct + run: make ct + env: + AZURITE_ENDPOINT: "http://127.0.0.1:10000/" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1e369c2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,7 +0,0 @@ -language: erlang -script: ./rebar compile && ./rebar skip_deps=true eunit suites=erlazure_xml_tests,erlazure_utils_tests,erlazure_queue_tests,erlazure_blob_tests -notifications: - email: dmitriy.kataskin@gmail.com -otp_release: - - 18.2.1 - - 17.5 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3bdf428 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +REBAR ?= rebar3 + +compile: + $(REBAR) compile + +.PHONY: eunit +eunit: + $(REBAR) eunit -v -m erlazure,erlazure_xml_tests,erlazure_utils_tests,erlazure_queue_tests,erlazure_blob_tests + +.PHONY: ct +ct: + $(REBAR) ct -v --readable=true diff --git a/README.md b/README.md index 0ab9a35..b77ac74 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Erlazure requires OTP version R16+. * Delete message * Clear messages * Update message - + * Blob storage service * List containers * Create container @@ -39,7 +39,7 @@ Erlazure requires OTP version R16+. * Put block list * Get block list * Lease container - + * Table storage service * List tables * New table @@ -53,6 +53,16 @@ Start an instance of erlazure by calling ```erlazure:start/2``` where **Account* ``` Account and Key are strings. +### Using an emulated service like [Azurite](https://github.com/Azure/Azurite/blob/2bb552e703772b9a57ca713ef271c3c7c624a535/README.md) + +```erlang +%% default dev credentials from Azurite +Account = "devstoreaccount1". +Key = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==". +%% Mind the trailing slash at the end of the endpoint. +{ok, Pid} = erlazure:start(#{account => Account, key => Key, endpoint => "http://127.0.0.1:10000/"}) +``` + ## Calling Azure services Almost each azure services request has three corresponding functions in ```erlazure``` module, the first has minimal set of parameters, the second has additionaly list of ```Options``` and the third has additionaly ```Timeout``` parameter. diff --git a/src/erlazure.erl b/src/erlazure.erl index f19160f..aa67bce 100644 --- a/src/erlazure.erl +++ b/src/erlazure.erl @@ -40,7 +40,7 @@ -behaviour(gen_server). %% API --export([start/2]). +-export([start/1, start/2]). %% Queue API -export([list_queues/1, list_queues/2, list_queues/3]). @@ -81,15 +81,29 @@ %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --record(state, { account = "", key = "", options = [], param_specs = [] }). +-type init_opts() :: #{ + account := string(), + key := string() | function(), + endpoint => string() +}. +-type state_opts() :: #{ + endpoint := undefined | string() +}. + +-record(state, { account = "", key = "", options = #{}, param_specs = [] }). %%==================================================================== %% API %%==================================================================== +-spec start(init_opts()) -> gen_server:start_ret(). +start(InitOpts0) -> + InitOpts = ensure_wrapped_key(InitOpts0), + gen_server:start_link(?MODULE, InitOpts, []). + -spec start(string(), string()) -> {ok, pid()}. start(Account, Key) -> - gen_server:start_link(?MODULE, {Account, Key}, []). + start(#{account => Account, key => Key}). %%==================================================================== %% Queue @@ -323,16 +337,21 @@ delete_table(Pid, TableName) when is_list(TableName) -> %% gen_server callbacks %%==================================================================== -init({Account, Key}) -> +init(InitOpts) -> + #{ account := Account + , key := Key + } = InitOpts, + StateOpts = parse_init_opts(InitOpts), {ok, #state { account = Account, key = Key, + options = StateOpts, param_specs = get_req_param_specs() }}. % List queues handle_call({list_queues, Options}, _From, State) -> ServiceContext = new_service_context(?queue_service, State), ReqOptions = [{params, [{comp, list}] ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), ParseResult = erlazure_queue:parse_queue_list(Body), @@ -345,7 +364,7 @@ handle_call({set_queue_acl, Queue, SignedId=#signed_id{}, Options}, _From, State {path, string:to_lower(Queue)}, {body, erlazure_queue:get_request_body(set_queue_acl, SignedId)}, {params, [{comp, acl}] ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_no_content, created); @@ -355,7 +374,7 @@ handle_call({get_queue_acl, Queue, Options}, _From, State) -> ServiceContext = new_service_context(?queue_service, State), ReqOptions = [{path, string:to_lower(Queue)}, {params, [{comp, acl}] ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), ParseResult = erlazure_queue:parse_queue_acl_response(Body), @@ -367,7 +386,7 @@ handle_call({create_queue, Queue, Options}, _From, State) -> ReqOptions = [{method, put}, {path, string:to_lower(Queue)}, {params, Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, _Body} = execute_request(ServiceContext, ReqContext), case Code of @@ -383,7 +402,7 @@ handle_call({delete_queue, Queue, Options}, _From, State) -> ReqOptions = [{method, delete}, {path, string:to_lower(Queue)}, {params, Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_no_content, deleted); @@ -395,7 +414,7 @@ handle_call({put_message, Queue, Message, Options}, _From, State) -> {path, lists:concat([string:to_lower(Queue), "/messages"])}, {body, erlazure_queue:get_request_body(put_message, Message)}, {params, Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, created); @@ -405,7 +424,7 @@ handle_call({get_messages, Queue, Options}, _From, State) -> ServiceContext = new_service_context(?queue_service, State), ReqOptions = [{path, string:to_lower(Queue) ++ "/messages"}, {params, Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {reply, erlazure_queue:parse_queue_messages_list(Body), State}; @@ -415,7 +434,7 @@ handle_call({peek_messages, Queue, Options}, _From, State) -> ServiceContext = new_service_context(?queue_service, State), ReqOptions = [{path, string:to_lower(Queue) ++ "/messages"}, {params, [{peek_only, true}] ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {reply, erlazure_queue:parse_queue_messages_list(Body), State}; @@ -426,7 +445,7 @@ handle_call({delete_message, Queue, MessageId, PopReceipt, Options}, _From, Stat ReqOptions = [{method, delete}, {path, lists:concat([string:to_lower(Queue), "/messages/", MessageId])}, {params, [{pop_receipt, PopReceipt}] ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_no_content, deleted); @@ -437,7 +456,7 @@ handle_call({clear_messages, Queue, Options}, _From, State) -> ReqOptions = [{method, delete}, {path, string:to_lower(Queue) ++ "/messages"}, {params, Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_no_content, deleted); @@ -451,7 +470,7 @@ handle_call({update_message, Queue, UpdatedMessage=#queue_message{}, VisibilityT {path, lists:concat([string:to_lower(Queue), "/messages/", UpdatedMessage#queue_message.id])}, {body, erlazure_queue:get_request_body(update_message, UpdatedMessage#queue_message.text)}, {params, Params ++ Options}], - ReqContext = new_req_context(?queue_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?queue_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_no_content, updated); @@ -460,7 +479,7 @@ handle_call({update_message, Queue, UpdatedMessage=#queue_message{}, VisibilityT handle_call({list_containers, Options}, _From, State) -> ServiceContext = new_service_context(?blob_service, State), ReqOptions = [{params, [{comp, list}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {ok, Containers} = erlazure_blob:parse_container_list(Body), @@ -472,7 +491,7 @@ handle_call({create_container, Name, Options}, _From, State) -> ReqOptions = [{method, put}, {path, Name}, {params, [{res_type, container}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), case Code of ?http_created -> {reply, {ok, created}, State}; @@ -485,7 +504,7 @@ handle_call({delete_container, Name, Options}, _From, State) -> ReqOptions = [{method, delete}, {path, Name}, {params, [{res_type, container}] ++ Options}], - RequestContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + RequestContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, RequestContext), return_response(Code, Body, State, ?http_accepted, deleted); @@ -499,7 +518,7 @@ handle_call({lease_container, Name, Mode, Options}, _From, State) -> ReqOptions = [{method, put}, {path, Name}, {params, Params ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_accepted, deleted); @@ -511,7 +530,7 @@ handle_call({list_blobs, Name, Options}, _From, State) -> {res_type, container}], ReqOptions = [{path, Name}, {params, Params ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {ok, Blobs} = erlazure_blob:parse_blob_list(Body), @@ -524,7 +543,7 @@ handle_call({put_blob, Container, Name, Type = block_blob, Data, Options}, _From {path, lists:concat([Container, "/", Name])}, {body, Data}, {params, [{blob_type, Type}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), ReqContext1 = case proplists:get_value(content_type, Options) of undefined -> ReqContext#req_context{ content_type = "application/octet-stream" }; ContentType -> ReqContext#req_context{ content_type = ContentType } @@ -541,7 +560,7 @@ handle_call({put_blob, Container, Name, Type = page_blob, ContentLength, Options ReqOptions = [{method, put}, {path, lists:concat([Container, "/", Name])}, {params, Params ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, created); @@ -551,7 +570,7 @@ handle_call({get_blob, Container, Blob, Options}, _From, State) -> ServiceContext = new_service_context(?blob_service, State), ReqOptions = [{path, lists:concat([Container, "/", Blob])}, {params, Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), case Code of @@ -559,7 +578,7 @@ handle_call({get_blob, Container, Blob, Options}, _From, State) -> {reply, {ok, Body}, State}; ?http_partial_content-> {reply, {ok, Body}, State}; - _ -> {reply, {error, Body}, State} + _ -> {reply, {error, Body}, State} end; % Snapshot blob @@ -568,7 +587,7 @@ handle_call({snapshot_blob, Container, Blob, Options}, _From, State) -> ReqOptions = [{method, put}, {path, lists:concat([Container, "/", Blob])}, {params, [{comp, snapshot}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, created); @@ -579,7 +598,7 @@ handle_call({copy_blob, Container, Blob, Source, Options}, _From, State) -> ReqOptions = [{method, put}, {path, lists:concat([Container, "/", Blob])}, {params, [{blob_copy_source, Source}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_accepted, created); @@ -590,7 +609,7 @@ handle_call({delete_blob, Container, Blob, Options}, _From, State) -> ReqOptions = [{method, delete}, {path, lists:concat([Container, "/", Blob])}, {params, Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_accepted, deleted); @@ -604,7 +623,7 @@ handle_call({put_block, Container, Blob, BlockId, Content, Options}, _From, Stat {path, lists:concat([Container, "/", Blob])}, {body, Content}, {params, Params ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, created); @@ -616,7 +635,7 @@ handle_call({put_block_list, Container, Blob, BlockRefs, Options}, _From, State) {path, lists:concat([Container, "/", Blob])}, {body, erlazure_blob:get_request_body(BlockRefs)}, {params, [{comp, "blocklist"}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, created); @@ -626,7 +645,7 @@ handle_call({get_block_list, Container, Blob, Options}, _From, State) -> ServiceContext = new_service_context(?blob_service, State), ReqOptions = [{path, lists:concat([Container, "/", Blob])}, {params, [{comp, "blocklist"}] ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {ok, BlockList} = erlazure_blob:parse_block_list(Body), @@ -644,7 +663,7 @@ handle_call({acquire_blob_lease, Container, Blob, ProposedId, Duration, Options} ReqOptions = [{method, put}, {path, lists:concat([Container, "/", Blob])}, {params, Params ++ Options}], - ReqContext = new_req_context(?blob_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?blob_service, ReqOptions, State), {Code, Body} = execute_request(ServiceContext, ReqContext), return_response(Code, Body, State, ?http_created, acquired); @@ -654,7 +673,7 @@ handle_call({list_tables, Options}, _From, State) -> ServiceContext = new_service_context(?table_service, State), ReqOptions = [{path, "Tables"}, {params, Options}], - ReqContext = new_req_context(?table_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?table_service, ReqOptions, State), {?http_ok, Body} = execute_request(ServiceContext, ReqContext), {reply, {ok, erlazure_table:parse_table_list(Body)}, State}; @@ -665,13 +684,13 @@ handle_call({new_table, TableName}, _From, State) -> ReqOptions = [{path, "Tables"}, {method, post}, {body, jsx:encode([{<<"TableName">>, TableName}])}], - ReqContext = new_req_context(?table_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?table_service, ReqOptions, State), ReqContext1 = ReqContext#req_context{ content_type = ?json_content_type }, {Code, Body} = execute_request(ServiceContext, ReqContext1), return_response(Code, Body, State, ?http_created, created); % Get host -handle_call({get_host, Service, Domain}, _From, State) -> +handle_call({get_host, Service, Domain}, _From, State) -> Account = State#state.account, Host = lists:concat([Account, ".", erlang:atom_to_list(Service), Domain]), {reply, Host, State}; @@ -681,7 +700,7 @@ handle_call({delete_table, TableName}, _From, State) -> ServiceContext = new_service_context(?table_service, State), ReqOptions = [{path, io:format("Tables('~s')", [TableName])}, {method, delete}], - ReqContext = new_req_context(?table_service, State#state.account, State#state.param_specs, ReqOptions), + ReqContext = new_req_context(?table_service, ReqOptions, State), {?http_no_content, _} = execute_request(ServiceContext, ReqContext), {reply, {ok, deleted}, State}. @@ -745,21 +764,28 @@ execute_request(ServiceContext = #service_context{}, ReqContext = #req_context{} {Code, Body}; {ok, {{_, _, _}, _, Body}} -> - try get_error_code(Body) of - ErrorCodeAtom -> {error, ErrorCodeAtom} - catch - _ -> {error, Body} - end - end. + get_error_code(Body) + end. get_error_code(Body) -> + try do_get_error_code(Body) of + ErrorCodeContext -> {error, ErrorCodeContext} + catch + _:_ -> {error, #{raw => Body}} + end. + +do_get_error_code(Body) -> {ParseResult, _} = xmerl_scan:string(binary_to_list(Body)), ErrorContent = ParseResult#xmlElement.content, - ErrorContentHead = hd(ErrorContent), - CodeContent = ErrorContentHead#xmlElement.content, - CodeContentHead = hd(CodeContent), - ErrorCodeText = CodeContentHead#xmlText.value, - list_to_atom(ErrorCodeText). + Code = + lists:flatten([Txt + || #xmlElement{name = 'Code', content = Cs} <- ErrorContent, + #xmlText{value = Txt} <- Cs]), + Message = + lists:flatten([Txt + || #xmlElement{name = 'Message', content = Cs} <- ErrorContent, + #xmlText{value = Txt} <- Cs]), + #{code => Code, message => Message}. get_shared_key(Service, Account, Key, HttpMethod, Path, Parameters, Headers) -> SignatureString = get_signature_string(Service, HttpMethod, Headers, Account, Path, Parameters), @@ -778,6 +804,8 @@ get_signature_string(Service, HttpMethod, Headers, Account, Path, Parameters) -> get_headers_string(Service, Headers) -> FoldFun = fun(HeaderName, Acc) -> case lists:keyfind(HeaderName, 1, Headers) of + %% Special case: zero length should be an empty line. + {"Content-Length", "0"} -> lists:concat([Acc, "\n"]); {HeaderName, Value} -> lists:concat([Acc, Value, "\n"]); false -> lists:concat([Acc, "\n"]) end @@ -801,9 +829,11 @@ get_headers_string(Service, Headers) -> -spec sign_string(base64:ascii_string(), string()) -> binary(). sign_string(Key, StringToSign) -> - hmac(base64:decode(Key), StringToSign). + hmac(base64:decode(unwrap(Key)), StringToSign). -build_uri_base(Service, Account) -> +build_uri_base(_Service, #state{options = #{endpoint := Endpoint}}) when Endpoint =/= undefined -> + Endpoint; +build_uri_base(Service, #state{account = Account}) -> lists:concat(["https://", get_host(Service, Account), "/"]). get_host(Service, Account) -> @@ -901,7 +931,7 @@ new_service_context(?table_service, State=#state{}) -> account = State#state.account, key = State#state.key }. -new_req_context(Service, Account, ParamSpecs, Options) -> +new_req_context(Service, Options, State) -> Method = proplists:get_value(method, Options, get), Path = proplists:get_value(path, Options, ""), Body = proplists:get_value(body, Options, ""), @@ -915,10 +945,11 @@ new_req_context(Service, Account, ParamSpecs, Options) -> true -> [] end, + ParamSpecs = State#state.param_specs, ReqParams = get_req_uri_params(Params, ParamSpecs), ReqHeaders = lists:append([Headers, AddHeaders, get_req_headers(Params, ParamSpecs)]), - #req_context{ address = build_uri_base(Service, Account), + #req_context{ address = build_uri_base(Service, State), path = Path, method = Method, body = Body, @@ -970,3 +1001,59 @@ return_response(Code, Body, State, ExpectedResponseCode, SuccessAtom) -> _ -> {reply, {error, Body}, State} end. + +-spec parse_init_opts(init_opts()) -> state_opts(). +parse_init_opts(InitOpts) -> + Endpoint = maps:get(endpoint, InitOpts, undefined), + #{ endpoint => Endpoint + }. + +unwrap(Fun) when is_function(Fun) -> + %% handle potentially nested functions + unwrap(Fun()); +unwrap(V) -> + V. + +wrap(V) -> + fun() -> + V + end. + +-spec ensure_wrapped_key(init_opts()) -> init_opts(). +ensure_wrapped_key(#{key := Key} = InitOpts) -> + case is_function(Key) of + true -> + InitOpts; + false -> + InitOpts#{key := wrap(Key)} + end. + +%%==================================================================== +%% Tests +%%==================================================================== +-ifdef(TEST). + +-include_lib("eunit/include/eunit.hrl"). + +sample_error_raw() -> + <<"\n\n AuthorizationFailure\n Server failed to authenticate the request. Make sure the value of the Authorization header is formed correctly including the signature.\nRequestId:9d2010f6-fe5f-4cc0-ba45-54162b64e1c9\nTime:2024-05-08T14:53:22.751Z\n">>. + +get_error_code_test_() -> + [ { "sample error response", + ?_assertMatch( + {error, #{ code := "AuthorizationFailure" + , message := "Server failed to authenticate" ++ _ + }}, + get_error_code(sample_error_raw()) + ) + } + , { "unparseable error" + , ?_assertMatch( + {error, #{raw := <<"something else">>}}, + get_error_code(<<"something else">>) + ) + } + ]. + +%% END ifdef(TEST) +-endif. diff --git a/test/erlazure_SUITE.erl b/test/erlazure_SUITE.erl new file mode 100644 index 0000000..d3a1dcc --- /dev/null +++ b/test/erlazure_SUITE.erl @@ -0,0 +1,127 @@ +%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Redistribution and use in source and binary forms, with or without +%% modification, are permitted provided that the following conditions are met: +%% +%% * Redistributions of source code must retain the above copyright notice, +%% this list of conditions and the following disclaimer. +%% * Redistributions in binary form must reproduce the above copyright +%% notice, this list of conditions and the following disclaimer in the +%% documentation and/or other materials provided with the distribution. +%% * Neither the name of erlazure nor the names of its contributors may be used to +%% endorse or promote products derived from this software without specific +%% prior written permission. +%% +%% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +%% AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +%% IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +%% ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +%% LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +%% CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +%% SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +%% INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +%% CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +%% ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +%% POSSIBILITY OF SUCH DAMAGE. +-module(erlazure_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include("erlazure.hrl"). + +%%------------------------------------------------------------------------------ +%% Type definitions +%%------------------------------------------------------------------------------ + +%% Default Azurite credentials +%% See: https://github.com/Azure/Azurite/blob/main/README.md#default-storage-account +-define(ACCOUNT, "devstoreaccount1"). +-define(KEY, "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="). + +%%------------------------------------------------------------------------------ +%% CT boilerplate +%%------------------------------------------------------------------------------ + +all() -> + test_utils:all(?MODULE). + +init_per_suite(Config) -> + Endpoint = os:getenv("AZURITE_ENDPOINT", "http://127.0.0.1:10000/"), + #{host := Host, port := Port} = uri_string:parse(Endpoint), + case test_utils:is_tcp_server_available(Host, Port) of + false -> + throw(endpoint_unavailable); + true -> + ok + end, + [{endpoint, Endpoint} | Config]. + +end_per_suite(_Config) -> + ok. + +init_per_testcase(_TestCase, Config) -> + Config. + +end_per_testcase(_TestCase, Config) -> + delete_all_containers(Config), + ok. + +%%------------------------------------------------------------------------------ +%% Helper fns +%%------------------------------------------------------------------------------ + +start(Config) -> + Endpoint = ?config(endpoint, Config), + {ok, Pid} = erlazure:start(#{account => ?ACCOUNT, key => ?KEY, endpoint => Endpoint}), + Pid. + +delete_all_containers(Config) -> + Pid = start(Config), + {Containers, _} = erlazure:list_containers(Pid), + lists:foreach( + fun(#blob_container{name = Name}) -> + {ok, deleted} = erlazure:delete_container(Pid, Name) + end, + Containers). + +container_name(Name) -> + IOList = re:replace(atom_to_list(Name), <<"[^a-z0-9-]">>, <<"-">>, [global]), + binary_to_list(iolist_to_binary(IOList)). + +%%------------------------------------------------------------------------------ +%% Test cases : blob storage +%%------------------------------------------------------------------------------ + +%% Basic smoke test for basic blob storage operations. +t_blob_storage_smoke_test(Config) -> + Endpoint = ?config(endpoint, Config), + {ok, Pid} = erlazure:start(#{account => ?ACCOUNT, key => ?KEY, endpoint => Endpoint}), + %% Create a container + Container = container_name(?FUNCTION_NAME), + ?assertMatch({[], _}, erlazure:list_containers(Pid)), + ?assertMatch({ok, created}, erlazure:create_container(Pid, Container)), + %% Upload some blobs + ?assertMatch({ok, created}, erlazure:put_block_blob(Pid, Container, "blob1", <<"1">>)), + ?assertMatch({ok, created}, erlazure:put_block_blob(Pid, Container, "blob2", <<"2">>)), + ?assertMatch({[#cloud_blob{name = "blob1"}, #cloud_blob{name = "blob2"}], _}, + erlazure:list_blobs(Pid, Container)), + %% Read back data + ?assertMatch({ok, <<"1">>}, erlazure:get_blob(Pid, Container, "blob1")), + ?assertMatch({ok, <<"2">>}, erlazure:get_blob(Pid, Container, "blob2")), + %% Delete blob + ?assertMatch({ok, deleted}, erlazure:delete_blob(Pid, Container, "blob1")), + ?assertMatch({[#cloud_blob{name = "blob2"}], _}, + erlazure:list_blobs(Pid, Container)), + %% Delete container + ?assertMatch({ok, deleted}, erlazure:delete_container(Pid, Container)), + ok. + +%% Basic smoke test to check that we can pass already wrapped keys to `erlazure:start`. +t_blob_storage_wrapped_key(Config) -> + Endpoint = ?config(endpoint, Config), + {ok, Pid} = erlazure:start(#{account => ?ACCOUNT, key => ?KEY, endpoint => Endpoint}), + ?assertMatch({[], _}, erlazure:list_containers(Pid)), + ok. diff --git a/test/start_azurite.sh b/test/start_azurite.sh new file mode 100755 index 0000000..0ec575e --- /dev/null +++ b/test/start_azurite.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -exuo pipefail + +docker run -d --rm -p 10000:10000 --name azurite mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0 -d debug.log diff --git a/test/test_utils.erl b/test/test_utils.erl index 3c7e02d..05f37e1 100644 --- a/test/test_utils.erl +++ b/test/test_utils.erl @@ -28,8 +28,38 @@ -module(test_utils). -author("Dmitry Kataskin"). -%% API --export([append_ticks/1, get_ticks/0, read_file/1]). +-compile(export_all). +-compile(nowarn_export_all). + +-define(DEFAULT_TCP_SERVER_CHECK_AVAIL_TIMEOUT, 1000). + +all(Module) -> + lists:usort([ + F + || {F, 1} <- Module:module_info(exports), + string:substr(atom_to_list(F), 1, 2) == "t_" + ]). + +-spec is_tcp_server_available( + Host :: inet:socket_address() | inet:hostname(), + Port :: inet:port_number() +) -> boolean. +is_tcp_server_available(Host, Port) -> + is_tcp_server_available(Host, Port, ?DEFAULT_TCP_SERVER_CHECK_AVAIL_TIMEOUT). + +-spec is_tcp_server_available( + Host :: inet:socket_address() | inet:hostname(), + Port :: inet:port_number(), + Timeout :: integer() +) -> boolean. +is_tcp_server_available(Host, Port, Timeout) -> + case gen_tcp:connect(Host, Port, [], Timeout) of + {ok, Socket} -> + gen_tcp:close(Socket), + true; + {error, _} -> + false + end. append_ticks(Name) -> Name ++ integer_to_list(get_ticks()). @@ -43,4 +73,4 @@ read_file(FileName) -> erlang:binary_to_list(Binary). file_path(File) -> - filename:join([code:priv_dir(erlazure), responses, File]). \ No newline at end of file + filename:join([code:priv_dir(erlazure), responses, File]).