├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bin ├── start_dynamodb.sh └── stop_dynamodb.sh ├── priv └── aws_credentials.term.template ├── rebar.config ├── rebar.lock ├── src ├── current.app.src ├── current.erl ├── current_app.erl ├── current_callback.erl ├── current_http_client.erl └── current_sup.erl └── test ├── aws4_testsuite ├── post-vanilla.authz ├── post-vanilla.creq ├── post-vanilla.req ├── post-vanilla.sreq └── post-vanilla.sts └── current_test.erl /.gitignore: -------------------------------------------------------------------------------- 1 | priv/aws_credentials.term 2 | *~ 3 | test/aws4_testsuite 4 | dynamodb_local 5 | _build/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: erlang 2 | dist: bionic 3 | sudo: false 4 | notifications: 5 | email: 6 | - backend@gameanalytics.com 7 | otp_release: 8 | - 22.3 9 | - 23.3.1 10 | addons: 11 | apt: 12 | packages: 13 | - default-jre 14 | install: make deps 15 | script: make xref test dialyzer 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Game Analytics ApS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean deep-clean deps compile test 2 | 3 | ERL=erl 4 | 5 | R3 = rebar3 6 | 7 | compile: 8 | $(R3) compile 9 | 10 | deep-clean: clean 11 | -rm -rf rebar.lock 12 | -rm -rf _build 13 | -rm -rf log 14 | -rm -rf _install 15 | -rm -rf _rel 16 | 17 | clean: 18 | $(R3) clean 19 | 20 | deps: 21 | $(R3) do upgrade, tree 22 | 23 | eunit: priv/aws_credentials.term 24 | $(R3) eunit 25 | 26 | test: eunit 27 | 28 | dialyzer: 29 | $(R3) dialyzer 30 | 31 | xref: 32 | $(R3) xref 33 | 34 | priv/aws_credentials.term: 35 | cp priv/aws_credentials.term.template priv/aws_credentials.term 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DynamoDB client for Erlang 2 | 3 | [![Build Status](https://travis-ci.org/GameAnalytics/current.svg?branch=master)](https://travis-ci.org/GameAnalytics/current) 4 | 5 | Current is an Erlang client for Amazons DynamoDB service. It exposes 6 | the raw JSON API described in the [DynamoDB documentation], taking 7 | input and giving output in terms compatible with [jiffy][]. Current 8 | can also retry requests when appropriate, for example when you're 9 | throttled due using all your provisioned throughput or using all 10 | available socket connections to DynamoDB. 11 | 12 | ## Dependencies 13 | * Erlang (>= R15) 14 | * [rebar][] 15 | * [Java JRE][] (for testing only) 16 | * [screen][] (for testing only) 17 | 18 | 19 | ## Usage 20 | 21 | Fetch and compile all application dependencies: 22 | ```bash 23 | $ rebar get compile 24 | ``` 25 | 26 | Example usage: 27 | ```erlang 28 | 1> application:ensure_all_started(current). 29 | {ok,[current]} 30 | 3> application:set_env(current, region, <<"us-east-1">>). 31 | ok 32 | 4> application:set_env(current, access_key, <<"foo">>). 33 | ok 34 | 5> application:set_env(current, secret_access_key, <<"bar">>). 35 | ok 36 | 37 | 6> GetRequest = {[{<<"TableName">>, <<"current_test_table">>}, {<<"Key">>, {[{<<"hash_key">>, {[{<<"N">>, <<"1">>}]}}, {<<"range_key">>, {[{<<"N">>, <<"1">>}]}}]}}]}. 38 | {[{<<"TableName">>,<<"current_test_table">>}, 39 | {<<"Key">>, 40 | {[{<<"hash_key">>,{[{<<"N">>,<<"1">>}]}}, 41 | {<<"range_key">>,{[{<<"N">>,<<"1">>}]}}]}}]} 42 | 43 | 7> current:get_item(GetRequest). 44 | {ok,{[{<<"ConsumedCapacity">>, 45 | {[{<<"CapacityUnits">>,0.5}, 46 | {<<"TableName">>,<<"current_test_table">>}]}}, 47 | {<<"Item">>, 48 | {[{<<"hash_key">>,{[{<<"N">>,<<"1">>}]}}, 49 | {<<"range_key">>,{[{<<"N">>,<<"1">>}]}}]}}]}} 50 | ``` 51 | 52 | With the new maps datastructure life will be great. 53 | 54 | ## Instrumentation 55 | 56 | Current will call functions in the module specified in the 57 | `callback_mod` environment variable. At the moment, you need to 58 | implement two functions: `request_complete/3` and `request_error/3`, 59 | see `src/current_callback.erl`. 60 | 61 | ```erlang 62 | request_complete(Operation, StartTimestamp, ConsumedCapacity) -> 63 | statman_histogram:record_value({ddb, Operation}, StartTimestamp), 64 | ok. 65 | ``` 66 | 67 | All calls to DynamoDB will have the `ReturnConsumedCapacity` value set 68 | to ```TOTAL``` by default. When DynamoDB returns the 69 | `ConsumedCapacity`, current will forward it to your callback 70 | module. Keep in mind that for batch requests it is a list containing 71 | capacity for one or more tables. 72 | 73 | ## Testing 74 | 75 | If you provide AWS credentials in `priv/aws_credentials.term` (see 76 | `priv/aws_credentials_term.template`), you can run the test 77 | suite. Tables will be created under your account. 78 | 79 | To run all tests use `rebar eunit` 80 | 81 | 82 | [jiffy]: https://github.com/davisp/jiffy 83 | [lhttpc]: https://github.com/ferd/lhttpc 84 | [DynamoDB documentation]: http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/Welcome.html 85 | [rebar]: https://github.com/rebar/rebar 86 | [Java JRE]: http://java.com/en/ 87 | [screen]: https://www.gnu.org/software/screen/ 88 | -------------------------------------------------------------------------------- /bin/start_dynamodb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail ; IFS=$'\t\n' 4 | 5 | ddb_local="dynamodb_local/dynamodb_local_latest.zip" 6 | 7 | echo '==> setup local dynamo (pre_hook)' 8 | 9 | 10 | if ! [ -f "$ddb_local" ]; then 11 | mkdir -p dynamodb_local 12 | wget -q http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest.zip -O dynamodb_local/dynamodb_local_latest.zip 13 | unzip -n -q dynamodb_local/dynamodb_local_latest.zip -d dynamodb_local 14 | fi 15 | 16 | if [ -f dynamodb_local/dynamodb.pid ]; then 17 | kill $(cat dynamodb_local/dynamodb.pid) || true 18 | fi 19 | nohup java -Djava.library.path=./dynamodb_local/DynamoDBLocal_lib -jar dynamodb_local/DynamoDBLocal.jar -inMemory > dynamodb_local/dynamodb.out 2> dynamodb_local/dynamodb.err < /dev/null & 20 | echo $! > dynamodb_local/dynamodb.pid 21 | 22 | while ! nc -z localhost 8000; do 23 | echo "DynamoDbLocal not started yet, trying again..." 24 | sleep 1 25 | done 26 | 27 | echo '==> local dynamo (started)' 28 | exit 0 29 | -------------------------------------------------------------------------------- /bin/stop_dynamodb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -f dynamodb_local/dynamodb.pid ]; then 3 | kill $(cat dynamodb_local/dynamodb.pid) || true 4 | echo '==> local dynamo (stopped)' 5 | fi 6 | exit 0 7 | -------------------------------------------------------------------------------- /priv/aws_credentials.term.template: -------------------------------------------------------------------------------- 1 | {access_key, <<"foo">>}. 2 | {secret_access_key, <<"bar">>}. 3 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% -*- erlang -*- vim:se ft=erlang: 2 | 3 | {erl_opts, [debug_info, 4 | warnings_as_errors 5 | ]}. 6 | 7 | {eunit_opts, 8 | [verbose, 9 | no_tty, 10 | {report, {eunit_progress, [colored]}} 11 | ] 12 | }. 13 | 14 | {deps, 15 | [ 16 | hackney, 17 | {jiffy, "~> 1.0"}, 18 | {base16, "1.0.0"}, 19 | {edatetime, {git, "https://github.com/GameAnalytics/edatetime.git", {tag, "1.0.3"}}} 20 | ]}. 21 | 22 | 23 | {profiles, 24 | [ 25 | {test, 26 | [ 27 | {deps, 28 | [ 29 | {meck, "0.8.13"} 30 | ]} 31 | ]} 32 | ]}. 33 | 34 | {pre_hooks, 35 | [ 36 | {eunit, "./bin/start_dynamodb.sh"} 37 | ]}. 38 | 39 | {post_hooks, 40 | [ 41 | {eunit, "./bin/stop_dynamodb.sh"} 42 | ]}. 43 | 44 | {xref_checks, 45 | [ 46 | undefined_function_calls, 47 | undefined_functions, 48 | locals_not_used, 49 | deprecated_function_calls, 50 | deprecated_functions] 51 | }. 52 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | {"1.2.0", 2 | [{<<"base16">>,{pkg,<<"base16">>,<<"1.0.0">>},0}, 3 | {<<"certifi">>,{pkg,<<"certifi">>,<<"2.6.1">>},1}, 4 | {<<"edatetime">>, 5 | {git,"https://github.com/GameAnalytics/edatetime.git", 6 | {ref,"a5778d5686eff531e57b296b6f913aad0bde5bbd"}}, 7 | 0}, 8 | {<<"hackney">>,{pkg,<<"hackney">>,<<"1.17.4">>},0}, 9 | {<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1}, 10 | {<<"jiffy">>,{pkg,<<"jiffy">>,<<"1.0.8">>},0}, 11 | {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, 12 | {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},1}, 13 | {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.1">>},1}, 14 | {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.6">>},1}, 15 | {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}]}. 16 | [ 17 | {pkg_hash,[ 18 | {<<"base16">>, <<"283644E2B21BD5915ACB7178BED7851FB07C6E5749B8FAD68A53C501092176D9">>}, 19 | {<<"certifi">>, <<"DBAB8E5E155A0763EEA978C913CA280A6B544BFA115633FA20249C3D396D9493">>}, 20 | {<<"hackney">>, <<"99DA4674592504D3FB0CFEF0DB84C3BA02B4508BAE2DFF8C0108BAA0D6E0977C">>}, 21 | {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, 22 | {<<"jiffy">>, <<"60E36F00BE35E5AC6E6CF2D4CAF3BDF3103D4460AFF385F543A8D7DF2D6D9613">>}, 23 | {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, 24 | {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>}, 25 | {<<"parse_trans">>, <<"16328AB840CC09919BD10DAB29E431DA3AF9E9E7E7E6F0089DD5A2D2820011D8">>}, 26 | {<<"ssl_verify_fun">>, <<"CF344F5692C82D2CD7554F5EC8FD961548D4FD09E7D22F5B62482E5AEAEBD4B0">>}, 27 | {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, 28 | {pkg_hash_ext,[ 29 | {<<"base16">>, <<"02AFD0827E61A7B07093873E063575CA3A2B07520567C7F8CEC7C5D42F052D76">>}, 30 | {<<"certifi">>, <<"524C97B4991B3849DD5C17A631223896272C6B0AF446778BA4675A1DFF53BB7E">>}, 31 | {<<"hackney">>, <<"DE16FF4996556C8548D512F4DBE22DD58A587BF3332E7FD362430A7EF3986B16">>}, 32 | {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, 33 | {<<"jiffy">>, <<"F9AE986BA5A0854EB48CF6A76192D9367086DA86C20197DA430630BE7C087A4E">>}, 34 | {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, 35 | {<<"mimerl">>, <<"F278585650AA581986264638EBF698F8BB19DF297F66AD91B18910DFC6E19323">>}, 36 | {<<"parse_trans">>, <<"07CD9577885F56362D414E8C4C4E6BDF10D43A8767ABB92D24CBE8B24C54888B">>}, 37 | {<<"ssl_verify_fun">>, <<"BDB0D2471F453C88FF3908E7686F86F9BE327D065CC1EC16FA4540197EA04680">>}, 38 | {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} 39 | ]. 40 | -------------------------------------------------------------------------------- /src/current.app.src: -------------------------------------------------------------------------------- 1 | {application, current, 2 | [ 3 | {description, "DynamoDB client"}, 4 | {vsn, "0.1.1"}, 5 | {registered, []}, 6 | {applications, [ 7 | kernel, 8 | stdlib, 9 | inets, 10 | jiffy, 11 | edatetime, 12 | base16, 13 | hackney 14 | ]}, 15 | {mod, { current_app, []}}, 16 | {env, []} 17 | ]}. 18 | -------------------------------------------------------------------------------- /src/current.erl: -------------------------------------------------------------------------------- 1 | %% @doc: DynamoDB client 2 | -module(current). 3 | 4 | %% DynamoDB API 5 | -export([batch_get_item/1, 6 | batch_get_item/2, 7 | batch_write_item/1, 8 | batch_write_item/2, 9 | batch_write_item_once/2, 10 | create_table/1, 11 | create_table/2, 12 | delete_item/1, 13 | delete_item/2, 14 | delete_table/1, 15 | delete_table/2, 16 | describe_table/1, 17 | describe_table/2, 18 | get_item/1, 19 | get_item/2, 20 | list_tables/1, 21 | list_tables/2, 22 | put_item/1, 23 | put_item/2, 24 | q/1, 25 | q/2, 26 | scan/1, 27 | scan/2, 28 | scan_once/2, 29 | update_item/1, 30 | update_item/2, 31 | update_table/1, 32 | update_table/2 33 | ]). 34 | 35 | -export([wait_for_delete/2, wait_for_active/2]). 36 | 37 | -ifdef(TEST). 38 | -export([take_get_batch/2, 39 | take_write_batch/2, 40 | derived_key/1, 41 | canonical/2, 42 | string_to_sign/2, 43 | authorization/3, 44 | apply_backpressure/6]). 45 | -endif. 46 | 47 | 48 | %% 49 | %% TYPES 50 | %% 51 | 52 | -type target() :: batch_get_item 53 | | batch_write_item 54 | | create_table 55 | | delete_item 56 | | delete_table 57 | | describe_table 58 | | get_item 59 | | list_tables 60 | | put_item 61 | | 'query' 62 | | scan 63 | | update_item 64 | | update_table. 65 | 66 | -type request() :: {[tuple()]}. 67 | 68 | -export_type([target/0, request/0]). 69 | 70 | 71 | %% 72 | %% LOW-LEVEL API 73 | %% 74 | 75 | batch_get_item(Request) -> do_batch_get_item(Request, []). 76 | batch_get_item(Request, Opts) -> do_batch_get_item(Request, Opts). 77 | batch_write_item(Request) -> do_batch_write_item(Request, []). 78 | batch_write_item(Request, Opts) -> do_batch_write_item(Request, Opts). 79 | batch_write_item_once(Request, Opts) -> retry(batch_write_item, Request, Opts). 80 | create_table(Request) -> retry(create_table, Request, []). 81 | create_table(Request, Opts) -> retry(create_table, Request, Opts). 82 | delete_item(Request) -> retry(delete_item, Request, []). 83 | delete_item(Request, Opts) -> retry(delete_item, Request, Opts). 84 | delete_table(Request) -> retry(delete_table, Request, []). 85 | delete_table(Request, Opts) -> retry(delete_table, Request, Opts). 86 | describe_table(Request) -> retry(describe_table, Request, []). 87 | describe_table(Request, Opts) -> retry(describe_table, Request, Opts). 88 | get_item(Request) -> retry(get_item, Request, []). 89 | get_item(Request, Opts) -> retry(get_item, Request, Opts). 90 | list_tables(Request) -> retry(list_tables, Request, []). 91 | list_tables(Request, Opts) -> retry(list_tables, Request, Opts). 92 | put_item(Request) -> retry(put_item, Request, []). 93 | put_item(Request, Opts) -> retry(put_item, Request, Opts). 94 | q(Request) -> do_query(Request, []). 95 | q(Request, Opts) -> do_query(Request, Opts). 96 | scan_once(Request, Opts) -> retry(scan, Request, Opts). 97 | scan(Request) -> do_scan(Request, []). 98 | scan(Request, Opts) -> do_scan(Request, Opts). 99 | update_item(Request) -> retry(update_item, Request, []). 100 | update_item(Request, Opts) -> retry(update_item, Request, Opts). 101 | update_table(Request) -> retry(update_table, Request, []). 102 | update_table(Request, Opts) -> retry(update_table, Request, Opts). 103 | 104 | %% 105 | %% HIGH-LEVEL HELPERS 106 | %% 107 | 108 | wait_for_active(Table, Timeout) -> 109 | case describe_table({[{<<"TableName">>, Table}]}, [{timeout, Timeout}]) of 110 | {ok, {[{<<"Table">>, {Description}}]}} -> 111 | case proplists:get_value(<<"TableStatus">>, Description) of 112 | <<"ACTIVE">> -> 113 | ok; 114 | <<"DELETING">> -> 115 | {error, deleting}; 116 | _Other -> 117 | wait_for_active(Table, Timeout) 118 | end; 119 | {error, {<<"ResourceNotFoundException">>, _}} -> 120 | {error, not_found} 121 | end. 122 | 123 | 124 | wait_for_delete(Table, Timeout) -> 125 | case describe_table({[{<<"TableName">>, Table}]}, [{timeout, Timeout}]) of 126 | {ok, {[{<<"Table">>, {Description}}]}} -> 127 | case proplists:get_value(<<"TableStatus">>, Description) of 128 | <<"DELETING">> -> 129 | wait_for_delete(Table, Timeout); 130 | Other -> 131 | {error, {unexpected_state, Other}} 132 | end; 133 | {error, {<<"ResourceNotFoundException">>, _}} -> 134 | ok 135 | end. 136 | 137 | 138 | %% ============================================================================ 139 | %% IMPLEMENTATION 140 | %% ============================================================================ 141 | 142 | %% 143 | %% BATCH GET AND WRITE 144 | %% 145 | 146 | 147 | do_batch_get_item(Request, Opts) -> 148 | case do_batch_get_item(Request, [], Opts) of 149 | {error, Reason} -> 150 | {error, Reason}; 151 | Result -> 152 | {ok, lists:reverse(Result)} 153 | end. 154 | 155 | 156 | do_batch_get_item({Request}, Acc, Opts) -> 157 | {value, {<<"RequestItems">>, RequestItems}, CleanRequest} = 158 | lists:keytake(<<"RequestItems">>, 1, Request), 159 | 160 | case take_get_batch(RequestItems, 100) of 161 | {[], []} -> 162 | Acc; 163 | {Batch, Rest} -> 164 | BatchRequest = {[{<<"RequestItems">>, {Batch}} | CleanRequest]}, 165 | 166 | case retry(batch_get_item, BatchRequest, Opts) of 167 | {ok, {Result}} -> 168 | {Responses} = proplists:get_value(<<"Responses">>, Result), 169 | NewAcc = orddict:merge(fun (_, Left, Right) -> Left ++ Right end, 170 | orddict:from_list(Responses), 171 | orddict:from_list(Acc)), 172 | 173 | {Unprocessed} = proplists:get_value(<<"UnprocessedKeys">>, Result), 174 | Remaining = orddict:merge( 175 | fun (_, {Left}, {Right}) -> 176 | LeftKeys = proplists:get_value( 177 | <<"Keys">>, Left), 178 | RightKeys = proplists:get_value( 179 | <<"Keys">>, Right), 180 | {lists:keystore( 181 | <<"Keys">>, 1, Right, 182 | {<<"Keys">>, LeftKeys ++ RightKeys})} 183 | end, 184 | orddict:from_list(Unprocessed), 185 | orddict:from_list(Rest)), 186 | do_batch_get_item({[{<<"RequestItems">>, {Remaining}}]}, 187 | NewAcc, Opts); 188 | {error, _} = Error -> 189 | Error 190 | end 191 | end. 192 | 193 | 194 | do_batch_write_item({Request}, Opts) -> 195 | {value, {<<"RequestItems">>, RequestItems}, CleanRequest} = 196 | lists:keytake(<<"RequestItems">>, 1, Request), 197 | 198 | case take_write_batch(RequestItems, 25) of 199 | {[], []} -> 200 | ok; 201 | {Batch, Rest} -> 202 | BatchRequest = {[{<<"RequestItems">>, {Batch}} | CleanRequest]}, 203 | 204 | case retry(batch_write_item, BatchRequest, Opts) of 205 | {ok, {Result}} -> 206 | {Unprocessed} = proplists:get_value(<<"UnprocessedItems">>, Result), 207 | case Unprocessed =:= [] andalso Rest =:= [] of 208 | true -> 209 | ok; 210 | false -> 211 | Remaining = orddict:merge(fun (_, Left, Right) -> 212 | Left ++ Right 213 | end, 214 | orddict:from_list(Unprocessed), 215 | orddict:from_list(Rest)), 216 | 217 | do_batch_write_item({[{<<"RequestItems">>, {Remaining}}]}, Opts) 218 | end; 219 | {error, _} = Error -> 220 | Error 221 | end 222 | end. 223 | 224 | 225 | take_get_batch({RequestItems}, MaxItems) -> 226 | do_take_get_batch(RequestItems, 0, MaxItems, []). 227 | 228 | do_take_get_batch(Remaining, MaxItems, MaxItems, Acc) -> 229 | {lists:reverse(Acc), Remaining}; 230 | 231 | do_take_get_batch([], _, _, Acc) -> 232 | {lists:reverse(Acc), []}; 233 | 234 | do_take_get_batch([{Table, {Spec}} | RemainingTables], N, MaxItems, Acc) -> 235 | case lists:keyfind(<<"Keys">>, 1, Spec) of 236 | {<<"Keys">>, []} -> 237 | do_take_get_batch(RemainingTables, N, MaxItems, Acc); 238 | {<<"Keys">>, Keys} -> 239 | {Batch, Rest} = split_batch(MaxItems - N, Keys, []), 240 | BatchSpec = lists:keystore(<<"Keys">>, 1, Spec, {<<"Keys">>, Batch}), 241 | RestSpec = lists:keystore(<<"Keys">>, 1, Spec, {<<"Keys">>, Rest}), 242 | do_take_get_batch([{Table, {RestSpec}} | RemainingTables], 243 | N + length(Batch), 244 | MaxItems, 245 | [{Table, {BatchSpec}} | Acc]) 246 | end. 247 | 248 | 249 | 250 | take_write_batch({RequestItems}, MaxItems) -> 251 | %% TODO: Validate item size 252 | %% TODO: Chunk on 1MB request size 253 | do_take_write_batch(RequestItems, 0, MaxItems, []). 254 | 255 | do_take_write_batch([{_, []} | RemainingTables], N, MaxItems, Acc) -> 256 | do_take_write_batch(RemainingTables, N, MaxItems, Acc); 257 | 258 | do_take_write_batch(Remaining, MaxItems, MaxItems, Acc) -> 259 | {lists:reverse(Acc), Remaining}; 260 | 261 | do_take_write_batch([], _, _, Acc) -> 262 | {lists:reverse(Acc), []}; 263 | 264 | do_take_write_batch([{Table, Requests} | RemainingTables], N, MaxItems, Acc) -> 265 | {Batch, Rest} = split_batch(MaxItems - N, Requests, []), 266 | 267 | do_take_write_batch([{Table, Rest} | RemainingTables], 268 | N + length(Batch), 269 | MaxItems, 270 | [{Table, Batch} | Acc]). 271 | 272 | 273 | split_batch(0, T, Acc) -> {lists:reverse(Acc), T}; 274 | split_batch(_, [], Acc) -> {[], Acc}; 275 | split_batch(_, [H], Acc) -> {lists:reverse([H | Acc]), []}; 276 | split_batch(N, [H | T], Acc) -> split_batch(N-1, T, [H | Acc]). 277 | 278 | 279 | 280 | 281 | 282 | %% 283 | %% QUERY 284 | %% 285 | 286 | do_query(Request, Opts) -> 287 | do_query(Request, undefined, Opts). 288 | 289 | do_query({UserRequest}, Acc, Opts) -> 290 | IsCount = proplists:get_value(<<"Select">>, UserRequest) =:= <<"COUNT">>, 291 | Accumulate = get_accumulate_fun(IsCount), 292 | 293 | case retry('query', {UserRequest}, Opts) of 294 | {ok, {Response}} -> 295 | Result = case IsCount of 296 | true -> proplists:get_value(<<"Count">>, Response); 297 | false -> proplists:get_value(<<"Items">>, Response) 298 | end, 299 | case proplists:get_value(<<"LastEvaluatedKey">>, Response) of 300 | undefined -> 301 | {ok, Accumulate(Result, Acc)}; 302 | LastEvaluatedKey -> 303 | NextRequest = update_query(UserRequest, 304 | <<"ExclusiveStartKey">>, 305 | LastEvaluatedKey), 306 | case proplists:is_defined(<<"Limit">>, NextRequest) of 307 | true -> 308 | {ok, Accumulate(Result, Acc), LastEvaluatedKey}; 309 | false -> 310 | do_query({NextRequest}, Accumulate(Result, Acc), Opts) 311 | end 312 | end; 313 | {error, Reason} -> 314 | {error, Reason} 315 | end. 316 | 317 | get_accumulate_fun(_IsCount = true) -> 318 | fun (Count, undefined) -> Count; 319 | (Count, A) -> Count + A 320 | end; 321 | get_accumulate_fun(_IsCount = false) -> 322 | fun (Items, undefined) -> Items; 323 | (Items, A) -> Items ++ A 324 | end. 325 | 326 | 327 | 328 | %% 329 | %% SCAN 330 | %% 331 | 332 | 333 | do_scan(Request, Opts) -> 334 | do_scan(Request, undefined, Opts). 335 | 336 | do_scan({UserRequest}, Acc, Opts) -> 337 | IsCount = proplists:get_value(<<"Select">>, UserRequest) =:= <<"COUNT">>, 338 | Accumulate = get_accumulate_fun(IsCount), 339 | 340 | case retry(scan, {UserRequest}, Opts) of 341 | {ok, {Response}} -> 342 | Result = case IsCount of 343 | true -> proplists:get_value(<<"Count">>, Response); 344 | false -> proplists:get_value(<<"Items">>, Response) 345 | end, 346 | case proplists:get_value(<<"LastEvaluatedKey">>, Response) of 347 | undefined -> 348 | {ok, Accumulate(Result, Acc)}; 349 | LastEvaluatedKey -> 350 | NextRequest = update_query(UserRequest, 351 | <<"ExclusiveStartKey">>, 352 | LastEvaluatedKey), 353 | case proplists:is_defined(<<"Limit">>, NextRequest) of 354 | true -> 355 | {ok, Accumulate(Result, Acc), LastEvaluatedKey}; 356 | false -> 357 | do_scan({NextRequest}, Accumulate(Result, Acc), Opts) 358 | end 359 | end; 360 | {error, Reason} -> 361 | {error, Reason} 362 | end. 363 | 364 | 365 | %% 366 | %% INTERNALS 367 | %% 368 | 369 | update_query(Request, Key, Value) -> 370 | lists:keystore(Key, 1, Request, {Key, Value}). 371 | 372 | -spec retry(target(),request(),[any()]) -> {'error', _} | {'ok',_}. 373 | retry(Op, Request, Opts) -> 374 | Body = encode_body(Op, Request), 375 | case proplists:is_defined(no_retry, Opts) of 376 | true -> do(Op, Body, opts_timeout(Opts)); 377 | false -> retry(Op, Body, 0, os:timestamp(), Opts) 378 | end. 379 | 380 | retry(Op, Body, Retries, Start, Opts) -> 381 | RequestStart = os:timestamp(), 382 | case do(Op, Body, Opts) of 383 | {ok, Response} -> 384 | case proplists:get_value(<<"ConsumedCapacity">>, 385 | element(1, Response)) of 386 | undefined -> 387 | ok; 388 | Capacity -> 389 | catch (config_callback_mod()):request_complete( 390 | Op, RequestStart, Capacity) 391 | end, 392 | {ok, Response}; 393 | {error, Reason} = Error -> 394 | catch (config_callback_mod()):request_error(Op, RequestStart, Reason), 395 | 396 | case should_retry(Reason) of 397 | true -> apply_backpressure(Op, Body, Retries, Start, Opts, Reason); 398 | false -> Error 399 | end 400 | end. 401 | 402 | do(Operation, Body, Opts) -> 403 | Now = edatetime:now2ts(), 404 | 405 | URL = <<"http://", (config_endpoint())/binary, "/">>, 406 | Headers = [{<<"Host">>, config_endpoint()}, 407 | {<<"Content-Type">>, <<"application/x-amz-json-1.0">>}, 408 | {<<"X-Amz-Date">>, edatetime:iso8601(Now)}, 409 | {<<"X-Amz-Target">>, target(Operation)} 410 | ], 411 | Signed = [{<<"Authorization">>, authorization(Headers, Body, Now)} 412 | | Headers], 413 | 414 | case current_http_client:post(URL, Signed, Body, Opts) of 415 | {ok, 200, ResponseBody} -> 416 | {ok, jiffy:decode(ResponseBody, [copy_strings])}; 417 | 418 | {ok, Code, ResponseBody} 419 | when 400 =< Code andalso Code =< 599 -> 420 | try 421 | {Response} = jiffy:decode(ResponseBody, [copy_strings]), 422 | Type = case proplists:get_value(<<"__type">>, Response) of 423 | <<"com.amazonaws.dynamodb.v20120810#", T/binary>> -> 424 | T; 425 | <<"com.amazon.coral.validate#", T/binary>> -> 426 | T; 427 | <<"com.amazon.coral.service#", T/binary>> -> 428 | T; 429 | <<"com.amazonaws.dynamodb.v", T/binary>> -> 430 | T 431 | end, 432 | Message = case proplists:get_value(<<"message">>, Response) of 433 | undefined -> 434 | %% com.amazon.coral.service#SerializationException 435 | proplists:get_value(<<"Message">>, Response); 436 | M -> 437 | M 438 | end, 439 | {error, {Type, Message}} 440 | catch 441 | error:{_, Err} when Err =:= invalid_json; 442 | Err =:= invalid_literal; 443 | Err =:= invalid_string; 444 | Err =:= invalid_number -> 445 | %% json decoding failed, return raw error response 446 | {error, {Code, ResponseBody}} 447 | end; 448 | 449 | {error, Reason} -> 450 | {error, Reason} 451 | end. 452 | 453 | apply_backpressure(Op, Body, Retries, Start, Opts, Reason) -> 454 | case Retries =:= opts_retries(Opts) of 455 | true -> 456 | {error, {max_retries, Reason}}; 457 | false -> 458 | BackoffTime = min(opts_max_backoff(Opts), 459 | trunc(math:pow(2, Retries) * 50)), 460 | timer:sleep(BackoffTime), 461 | retry(Op, Body, Retries + 1, Start, Opts) 462 | end. 463 | 464 | 465 | %% 466 | %% AWS4 request signing 467 | %% http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html 468 | %% 469 | 470 | 471 | authorization(Headers, Body, Now) -> 472 | CanonicalRequest = canonical(Headers, Body), 473 | 474 | HashedCanonicalRequest = base16:encode(crypto:hash(sha256, CanonicalRequest)), 475 | 476 | StringToSign = string_to_sign(HashedCanonicalRequest, Now), 477 | 478 | iolist_to_binary( 479 | ["AWS4-HMAC-SHA256 ", 480 | "Credential=", credential(Now), ", ", 481 | "SignedHeaders=", string:join([to_lower(K) 482 | || {K, _} <- lists:sort(Headers)], 483 | ";"), ", ", 484 | "Signature=", signature(StringToSign, Now)]). 485 | 486 | 487 | canonical(Headers, Body) -> 488 | string:join( 489 | ["POST", 490 | "/", 491 | "", 492 | [[to_lower(K), ":", V, "\n"] || {K, V} <- lists:sort(Headers)], 493 | string:join([to_lower(K) || {K, _} <- lists:sort(Headers)], 494 | ";"), 495 | hexdigest(Body)], 496 | "\n"). 497 | 498 | string_to_sign(HashedCanonicalRequest, Now) -> 499 | ["AWS4-HMAC-SHA256", "\n", 500 | binary_to_list(edatetime:iso8601_basic(Now)), "\n", 501 | [format_ymd(Now), "/", config_region(), "/", config_aws_host(), 502 | "/aws4_request"], "\n", HashedCanonicalRequest]. 503 | 504 | 505 | derived_key(Now) -> 506 | Secret = ["AWS4", config_secret_key()], 507 | Date = crypto:mac(hmac, sha256, Secret, format_ymd(Now)), 508 | Region = crypto:mac(hmac, sha256, Date, config_region()), 509 | Service = crypto:mac(hmac, sha256, Region, config_aws_host()), 510 | crypto:mac(hmac, sha256, Service, "aws4_request"). 511 | 512 | 513 | signature(StringToSign, Now) -> 514 | Key = derived_key(Now), 515 | base16:encode(crypto:mac(hmac, sha256, Key, StringToSign)). 516 | 517 | credential(Now) -> 518 | [config_access_key(), "/", format_ymd(Now), "/", config_region(), "/", 519 | config_aws_host(), "/aws4_request"]. 520 | 521 | target(batch_get_item) -> <<"DynamoDB_20120810.BatchGetItem">>; 522 | target(batch_write_item) -> <<"DynamoDB_20120810.BatchWriteItem">>; 523 | target(create_table) -> <<"DynamoDB_20120810.CreateTable">>; 524 | target(delete_table) -> <<"DynamoDB_20120810.DeleteTable">>; 525 | target(delete_item) -> <<"DynamoDB_20120810.DeleteItem">>; 526 | target(describe_table) -> <<"DynamoDB_20120810.DescribeTable">>; 527 | target(get_item) -> <<"DynamoDB_20120810.GetItem">>; 528 | target(list_tables) -> <<"DynamoDB_20120810.ListTables">>; 529 | target(put_item) -> <<"DynamoDB_20120810.PutItem">>; 530 | target('query') -> <<"DynamoDB_20120810.Query">>; 531 | target(scan) -> <<"DynamoDB_20120810.Scan">>; 532 | target(update_item) -> <<"DynamoDB_20120810.UpdateItem">>; 533 | target(update_table) -> <<"DynamoDB_20120810.UpdateTable">>. 534 | 535 | should_retry({<<"ProvisionedThroughputExceededException">>, _}) -> true; 536 | should_retry({<<"ResourceNotFoundException">>, _}) -> false; 537 | should_retry({<<"ResourceInUseException">>, _}) -> true; 538 | should_retry({<<"ValidationException">>, _}) -> false; 539 | should_retry({<<"InvalidSignatureException">>, _}) -> false; 540 | should_retry({<<"SerializationException">>, _}) -> false; 541 | should_retry({<<"InternalServerError">>, _}) -> true; 542 | should_retry({<<"ConditionalCheckFailedException">>, _}) -> false; 543 | should_retry({<<"AccessDeniedException">>, _}) -> false; 544 | should_retry({<<"ServiceUnavailableException">>, _}) -> true; 545 | should_retry({Code, _}) when Code >= 500 -> true; 546 | should_retry({Code, _}) when Code < 500 -> false; 547 | should_retry(timeout) -> true; 548 | should_retry(closed) -> true; 549 | should_retry({closed, _}) -> true; 550 | should_retry(claim_timeout) -> true; 551 | should_retry(connect_timeout) -> true; 552 | should_retry(busy) -> true; 553 | should_retry(econnrefused) -> false; 554 | should_retry(max_concurrency) -> true; 555 | should_retry(socket_closed_remotely) -> true; 556 | should_retry(_Other) -> false. 557 | 558 | 559 | 560 | %% 561 | %% INTERNAL HELPERS 562 | %% 563 | 564 | encode_body(Operation, {UserRequest}) -> 565 | Request = case Operation of 566 | Op when Op =:= delete_table; 567 | Op =:= describe_table; 568 | Op =:= list_tables; 569 | Op =:= create_table -> 570 | {UserRequest}; 571 | _Other -> 572 | {lists:keystore( 573 | <<"ReturnConsumedCapacity">>, 1, UserRequest, 574 | {<<"ReturnConsumedCapacity">>, <<"TOTAL">>})} 575 | end, 576 | jiffy:encode(Request). 577 | 578 | %% Configuration 579 | config_region() -> 580 | {ok, Region} = application:get_env(current, region), 581 | Region. 582 | 583 | config_endpoint() -> 584 | case application:get_env(current, endpoint) of 585 | {ok, Endpoint} -> 586 | Endpoint; 587 | undefined -> 588 | <<"dynamodb.", (config_region())/binary, ".amazonaws.com">> 589 | end. 590 | 591 | config_aws_host() -> 592 | application:get_env(current, aws_host, <<"dynamodb">>). 593 | 594 | config_access_key() -> 595 | {ok, Access} = application:get_env(current, access_key), 596 | Access. 597 | 598 | config_secret_key() -> 599 | {ok, Secret} = application:get_env(current, secret_access_key), 600 | Secret. 601 | 602 | config_callback_mod() -> 603 | application:get_env(current, callback_mod, current_callback). 604 | 605 | %% Query Options 606 | opts_timeout(Opts) -> proplists:get_value(timeout, Opts, 5000). 607 | opts_retries(Opts) -> proplists:get_value(retries, Opts, 3). 608 | opts_max_backoff(Opts) -> proplists:get_value(max_backoff, Opts, 60000). 609 | 610 | %% Formatting helpers 611 | hexdigest(Body) -> 612 | binary_to_list(base16:encode(crypto:hash(sha256, Body))). 613 | 614 | format_ymd(Now) -> 615 | {Y, M, D} = edatetime:ts2date(Now), 616 | io_lib:format("~4.10.0B~2.10.0B~2.10.0B", [Y, M, D]). 617 | 618 | to_lower(Binary) when is_binary(Binary) -> 619 | to_lower(binary_to_list(Binary)); 620 | to_lower(List) -> 621 | string:to_lower(List). 622 | -------------------------------------------------------------------------------- /src/current_app.erl: -------------------------------------------------------------------------------- 1 | -module(current_app). 2 | 3 | -behaviour(application). 4 | 5 | %% Application callbacks 6 | -export([start/2, stop/1]). 7 | 8 | %% =================================================================== 9 | %% Application callbacks 10 | %% =================================================================== 11 | 12 | start(_StartType, _StartArgs) -> 13 | current_sup:start_link(). 14 | 15 | stop(_State) -> 16 | ok. 17 | -------------------------------------------------------------------------------- /src/current_callback.erl: -------------------------------------------------------------------------------- 1 | -module(current_callback). 2 | -export([request_complete/3, request_error/3]). 3 | 4 | -callback request_complete(current:target(), erlang:timestamp(), term()) -> ok. 5 | -callback request_error(current:target(), erlang:timestamp(), term()) -> ok. 6 | 7 | request_complete(_Op, _Start, _Capacity) -> 8 | ok. 9 | 10 | request_error(_Operation, _Start, _Reason) -> 11 | ok. 12 | -------------------------------------------------------------------------------- /src/current_http_client.erl: -------------------------------------------------------------------------------- 1 | %% @doc HTTP client wrapper 2 | -module(current_http_client). 3 | 4 | %% API 5 | -export([post/4]). 6 | 7 | %% 8 | %% TYPES 9 | %% 10 | -type header() :: {binary() | string(), any()}. 11 | -type headers() :: [header()]. 12 | -type body() :: iolist() | binary(). 13 | -type options() :: list({atom(), any()}). 14 | -type response_ok() :: {ok, integer(), body()}. 15 | -type response_error() :: {error, any()}. 16 | 17 | 18 | %% 19 | %% API 20 | %% 21 | -spec post(binary(), headers(), body(), options()) -> 22 | response_ok() | response_error(). 23 | post(URL, Headers, Body, Opts) -> 24 | CallTimeout = proplists:get_value(call_timeout, Opts, 10000), 25 | Options = [{pool, default}, {recv_timeout, CallTimeout}], 26 | case hackney:request(post, URL, Headers, Body, Options) of 27 | {ok, Code, _Headers, Ref} -> 28 | case hackney:body(Ref) of 29 | {ok, RetBody} -> 30 | {ok, Code, RetBody}; 31 | {error, Error} -> 32 | {error, Error} 33 | end; 34 | {error, Error} -> 35 | {error, Error} 36 | end. 37 | -------------------------------------------------------------------------------- /src/current_sup.erl: -------------------------------------------------------------------------------- 1 | -module(current_sup). 2 | -behaviour(supervisor). 3 | 4 | %% API 5 | -export([start_link/0]). 6 | 7 | %% Supervisor callbacks 8 | -export([init/1]). 9 | 10 | %% Helper macro for declaring children of supervisor 11 | -define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}). 12 | 13 | %% =================================================================== 14 | %% API functions 15 | %% =================================================================== 16 | 17 | start_link() -> 18 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 19 | 20 | %% =================================================================== 21 | %% Supervisor callbacks 22 | %% =================================================================== 23 | 24 | init([]) -> 25 | {ok, {{one_for_one, 5, 10}, []}}. 26 | 27 | -------------------------------------------------------------------------------- /test/aws4_testsuite/post-vanilla.authz: -------------------------------------------------------------------------------- 1 | AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=22902d79e148b64e7571c3565769328423fe276eae4b26f83afceda9e767f726 -------------------------------------------------------------------------------- /test/aws4_testsuite/post-vanilla.creq: -------------------------------------------------------------------------------- 1 | POST 2 | / 3 | 4 | date:Mon, 09 Sep 2011 23:36:00 GMT 5 | host:host.foo.com 6 | 7 | date;host 8 | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -------------------------------------------------------------------------------- /test/aws4_testsuite/post-vanilla.req: -------------------------------------------------------------------------------- 1 | POST / http/1.1 2 | Date:Mon, 09 Sep 2011 23:36:00 GMT 3 | Host:host.foo.com 4 | 5 | -------------------------------------------------------------------------------- /test/aws4_testsuite/post-vanilla.sreq: -------------------------------------------------------------------------------- 1 | POST / http/1.1 2 | Date:Mon, 09 Sep 2011 23:36:00 GMT 3 | Host:host.foo.com 4 | Authorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=date;host, Signature=22902d79e148b64e7571c3565769328423fe276eae4b26f83afceda9e767f726 5 | 6 | -------------------------------------------------------------------------------- /test/aws4_testsuite/post-vanilla.sts: -------------------------------------------------------------------------------- 1 | AWS4-HMAC-SHA256 2 | 20110909T233600Z 3 | 20110909/us-east-1/host/aws4_request 4 | 05da62cee468d24ae84faff3c39f1b85540de60243c1bcaace39c0a2acc7b2c4 -------------------------------------------------------------------------------- /test/current_test.erl: -------------------------------------------------------------------------------- 1 | -module(current_test). 2 | -include_lib("eunit/include/eunit.hrl"). 3 | 4 | -export([request_error/3]). 5 | 6 | -define(ENDPOINT, <<"localhost:8000">>). 7 | -define(REGION, <<"us-east-1">>). 8 | 9 | -define(TABLE, <<"current_test">>). 10 | -define(TABLE_OTHER, <<"current_test_other">>). 11 | -define(i2b(I), list_to_binary(integer_to_list(I))). 12 | 13 | -define(NUMBER(I), {[{<<"N">>, ?i2b(I)}]}). 14 | 15 | current_test_() -> 16 | {setup, fun setup/0, fun teardown/1, 17 | [ 18 | {timeout, 120, ?_test(table_manipulation())}, 19 | {timeout, 30, ?_test(batch_get_write_item())}, 20 | {timeout, 30, ?_test(batch_get_unprocessed_items())}, 21 | {timeout, 30, ?_test(scan())}, 22 | {timeout, 30, ?_test(q())}, 23 | {timeout, 30, ?_test(get_put_update_delete())}, 24 | {timeout, 30, ?_test(retry_with_timeout())}, 25 | {timeout, 30, ?_test(timeout())}, 26 | {timeout, 30, ?_test(throttled())}, 27 | {timeout, 30, ?_test(non_json_error())}, 28 | {timeout, 30, ?_test(http_client())}, 29 | {timeout, 30, ?_test(exp_error_tuple_backpressure())} 30 | ]}. 31 | 32 | 33 | %% 34 | %% DYNAMODB 35 | %% 36 | 37 | 38 | table_manipulation() -> 39 | current:delete_table({[{<<"TableName">>, ?TABLE}]}), 40 | ?assertEqual(ok, current:wait_for_delete(?TABLE, 5000)), 41 | 42 | ?assertMatch({error, {<<"ResourceNotFoundException">>, _}}, 43 | current:describe_table({[{<<"TableName">>, ?TABLE}]})), 44 | 45 | ok = create_table(?TABLE), 46 | 47 | ?assertEqual(ok, current:wait_for_active(?TABLE, 5000)), 48 | ?assertMatch({ok, _}, current:describe_table({[{<<"TableName">>, ?TABLE}]})), 49 | %% TODO: list tables and check membership 50 | ok. 51 | 52 | 53 | batch_get_write_item() -> 54 | ok = create_table(?TABLE), 55 | ok = clear_table(?TABLE), 56 | ok = create_table(?TABLE_OTHER), 57 | ok = clear_table(?TABLE_OTHER), 58 | 59 | Keys = [{[{<<"range_key">>, ?NUMBER(rand:uniform(1000))}, 60 | {<<"hash_key">>, ?NUMBER(rand:uniform(100000))}]} 61 | || _ <- lists:seq(1, 50)], 62 | 63 | WriteRequestItems = [{[{<<"PutRequest">>, {[{<<"Item">>, Key}]}}]} 64 | || Key <- Keys], 65 | WriteRequest = {[{<<"RequestItems">>, 66 | {[{?TABLE, WriteRequestItems}, 67 | {?TABLE_OTHER, WriteRequestItems}]} 68 | }]}, 69 | 70 | ?assertEqual(ok, current:batch_write_item(WriteRequest, [])), 71 | 72 | GetRequest = {[{<<"RequestItems">>, 73 | {[{?TABLE, {[{<<"Keys">>, Keys}]}}, 74 | {?TABLE_OTHER, {[{<<"Keys">>, Keys}]}} 75 | ]} 76 | }]}, 77 | 78 | {ok, [{?TABLE_OTHER, Table1}, {?TABLE, Table2}]} = 79 | current:batch_get_item(GetRequest), 80 | 81 | ?assertEqual(key_sort(Keys), key_sort(Table1)), 82 | ?assertEqual(key_sort(Keys), key_sort(Table2)). 83 | 84 | batch_get_unprocessed_items() -> 85 | ok = create_table(?TABLE), 86 | ok = create_table(?TABLE_OTHER), 87 | 88 | Keys = [{[{<<"range_key">>, ?NUMBER(rand:uniform(1000))}, 89 | {<<"hash_key">>, ?NUMBER(rand:uniform(100000))}]} 90 | || _ <- lists:seq(1, 150)], 91 | 92 | WriteRequestItems = [{[{<<"PutRequest">>, {[{<<"Item">>, Key}]}}]} 93 | || Key <- Keys], 94 | WriteRequest = {[{<<"RequestItems">>, 95 | {[{?TABLE, WriteRequestItems}, 96 | {?TABLE_OTHER, WriteRequestItems}]} 97 | }]}, 98 | 99 | ?assertEqual(ok, current:batch_write_item(WriteRequest, [])), 100 | 101 | 102 | {Keys1, Keys2} = lists:split(110, Keys), 103 | UnprocessedKeys = {[{?TABLE, {[{<<"Keys">>, Keys2}]}}, 104 | {?TABLE_OTHER, {[{<<"Keys">>, Keys2}]}} 105 | ]}, 106 | meck:new(current_http_client, [passthrough]), 107 | meck:expect(current_http_client, post, 4, 108 | meck:seq([fun (URL, Headers, Body, Opts) -> 109 | {ok, 200, ResponseBody} = 110 | meck:passthrough([URL, Headers, Body, Opts]), 111 | {Result} = jiffy:decode(ResponseBody), 112 | ?assertEqual( 113 | {[]}, proplists:get_value(<<"UnprocessedKeys">>, Result)), 114 | MockResult = lists:keystore( 115 | <<"UnprocessedKeys">>, 1, 116 | Result, {<<"UnprocessedKeys">>, 117 | UnprocessedKeys}), 118 | {ok, 200, 119 | jiffy:encode({MockResult})} 120 | end, 121 | meck:passthrough()])), 122 | 123 | GetRequest = {[{<<"RequestItems">>, 124 | {[{?TABLE, {[{<<"Keys">>, Keys1}]}}, 125 | {?TABLE_OTHER, {[{<<"Keys">>, Keys1}]}} 126 | ]} 127 | }]}, 128 | 129 | {ok, [{?TABLE_OTHER, Table1}, {?TABLE, Table2}]} = 130 | current:batch_get_item(GetRequest, []), 131 | 132 | ?assertEqual(key_sort(Keys), key_sort(Table1)), 133 | ?assertEqual(key_sort(Keys), key_sort(Table2)), 134 | 135 | meck:unload(current_http_client). 136 | 137 | 138 | scan() -> 139 | ok = create_table(?TABLE), 140 | ok = clear_table(?TABLE), 141 | 142 | RequestItems = [begin 143 | {[{<<"PutRequest">>, 144 | {[{<<"Item">>, 145 | {[{<<"hash_key">>, {[{<<"N">>, <<"1">>}]}}, 146 | {<<"range_key">>, {[{<<"N">>, ?i2b(I)}]}}, 147 | {<<"attribute">>, {[{<<"S">>, <<"foo">>}]}} 148 | ]}}]} 149 | }]} 150 | end || I <- lists:seq(1, 100)], 151 | Request = {[{<<"RequestItems">>, 152 | {[{?TABLE, RequestItems}]} 153 | }]}, 154 | 155 | ok = current:batch_write_item(Request, []), 156 | 157 | Q = {[{<<"TableName">>, ?TABLE}]}, 158 | 159 | ?assertMatch({ok, L} when is_list(L), current:scan(Q, [])), 160 | 161 | %% Errors 162 | ErrorQ = {[{<<"TableName">>, <<"non-existing-table">>}]}, 163 | ?assertMatch({error, {<<"ResourceNotFoundException">>, _}}, 164 | current:scan(ErrorQ, [])), 165 | 166 | %% Limit and pagging 167 | Q1 = {[{<<"TableName">>, ?TABLE}, 168 | {<<"Limit">>, 80}]}, 169 | {ok, LimitedItems1, LastEvaluatedKey1} = current:scan(Q1), 170 | ?assertEqual(80, length(LimitedItems1)), 171 | 172 | %% Pagging last page 173 | Q2 = {[{<<"TableName">>, ?TABLE}, 174 | {<<"ExclusiveStartKey">>, LastEvaluatedKey1}, 175 | {<<"Limit">>, 30}]}, 176 | {ok, LimitedItems2} = current:scan(Q2), 177 | 178 | %% check for overlaps 179 | ?assertEqual(0, sets:size(sets:intersection(sets:from_list(LimitedItems1), 180 | sets:from_list(LimitedItems2)))), 181 | 182 | ?assertEqual(20, length(LimitedItems2)). 183 | 184 | 185 | take_write_batch_test() -> 186 | ?assertEqual({[{<<"table1">>, [1, 2, 3]}, 187 | {<<"table2">>, [1, 2, 3]}], 188 | []}, 189 | current:take_write_batch( 190 | {[{<<"table1">>, [1, 2, 3]}, 191 | {<<"table2">>, [1, 2, 3]}]}, 25)), 192 | 193 | 194 | {Batch1, Rest1} = current:take_write_batch( 195 | {[{<<"table1">>, lists:seq(1, 30)}, 196 | {<<"table2">>, lists:seq(1, 30)}]}, 25), 197 | ?assertEqual([{<<"table1">>, lists:seq(1, 25)}], Batch1), 198 | ?assertEqual([{<<"table1">>, lists:seq(26, 30)}, 199 | {<<"table2">>, lists:seq(1, 30)}], Rest1), 200 | 201 | 202 | {Batch2, Rest2} = current:take_write_batch({Rest1}, 25), 203 | ?assertEqual([{<<"table1">>, lists:seq(26, 30)}, 204 | {<<"table2">>, lists:seq(1, 20)}], Batch2), 205 | ?assertEqual([{<<"table2">>, lists:seq(21, 30)}], Rest2), 206 | 207 | {Batch3, Rest3} = current:take_write_batch({Rest2}, 25), 208 | ?assertEqual([{<<"table2">>, lists:seq(21, 30)}], Batch3), 209 | ?assertEqual([], Rest3). 210 | 211 | take_get_batch_test() -> 212 | Spec = {[{<<"Keys">>, [1,2,3]}, 213 | {<<"AttributesToGet">>, [<<"foo">>, <<"bar">>]}, 214 | {<<"ConsistentRead">>, false}]}, 215 | 216 | {Batch1, Rest1} = current:take_get_batch({[{<<"table1">>, Spec}, 217 | {<<"table2">>, Spec}]}, 2), 218 | 219 | 220 | ?assertEqual([{<<"table1">>, {[{<<"Keys">>, [1, 2]}, 221 | {<<"AttributesToGet">>, [<<"foo">>, <<"bar">>]}, 222 | {<<"ConsistentRead">>, false}]}}], 223 | Batch1), 224 | 225 | {Batch2, _Rest2} = current:take_get_batch({Rest1}, 2), 226 | ?assertEqual([{<<"table1">>, {[{<<"Keys">>, [3]}, 227 | {<<"AttributesToGet">>, [<<"foo">>, <<"bar">>]}, 228 | {<<"ConsistentRead">>, false}]}}, 229 | {<<"table2">>, {[{<<"Keys">>, [1]}, 230 | {<<"AttributesToGet">>, [<<"foo">>, <<"bar">>]}, 231 | {<<"ConsistentRead">>, false}]}}], 232 | Batch2). 233 | 234 | 235 | 236 | 237 | q() -> 238 | ok = create_table(?TABLE), 239 | ok = clear_table(?TABLE), 240 | 241 | Items = [{[{<<"range_key">>, {[{<<"N">>, ?i2b(I)}]}}, 242 | {<<"hash_key">>, {[{<<"N">>, <<"1">>}]}}]} 243 | || I <- lists:seq(1, 100)], 244 | 245 | RequestItems = [begin 246 | {[{<<"PutRequest">>, {[{<<"Item">>, Item}]}}]} 247 | end || Item <- Items], 248 | Request = {[{<<"RequestItems">>, {[{?TABLE, RequestItems}]}}]}, 249 | 250 | ok = current:batch_write_item(Request, []), 251 | 252 | Q = {[{<<"TableName">>, ?TABLE}, 253 | {<<"KeyConditions">>, 254 | {[{<<"hash_key">>, 255 | {[{<<"AttributeValueList">>, [{[{<<"N">>, <<"1">>}]}]}, 256 | {<<"ComparisonOperator">>, <<"EQ">>}]}}]}}]}, 257 | 258 | {ok, ResultItems} = current:q(Q, []), 259 | 260 | ?assertEqual(key_sort(Items), key_sort(ResultItems)), 261 | 262 | %% Count 263 | CountQ = {[{<<"TableName">>, ?TABLE}, 264 | {<<"KeyConditions">>, 265 | {[{<<"hash_key">>, 266 | {[{<<"AttributeValueList">>, [{[{<<"N">>, <<"1">>}]}]}, 267 | {<<"ComparisonOperator">>, <<"EQ">>}]}}]}}, 268 | {<<"Select">>, <<"COUNT">>}]}, 269 | {ok, ResultCount} = current:q(CountQ, []), 270 | ?assertEqual(100, ResultCount), 271 | 272 | %% Errors 273 | ErrorQ = {[{<<"TableName">>, <<"non-existing-table">>}, 274 | {<<"KeyConditions">>, 275 | {[{<<"hash_key">>, 276 | {[{<<"AttributeValueList">>, [{[{<<"N">>, <<"1">>}]}]}, 277 | {<<"ComparisonOperator">>, <<"EQ">>}]}}]}}]}, 278 | ?assertMatch({error, {<<"ResourceNotFoundException">>, _}}, 279 | current:q(ErrorQ, [])), 280 | 281 | %% Limit and pagging 282 | Q1 = {[{<<"TableName">>, ?TABLE}, 283 | {<<"KeyConditions">>, 284 | {[{<<"hash_key">>, 285 | {[{<<"AttributeValueList">>, [{[{<<"N">>, <<"1">>}]}]}, 286 | {<<"ComparisonOperator">>, <<"EQ">>}]}}]}}, 287 | {<<"Limit">>, 80}]}, 288 | {ok, LimitedItems1, LastEvaluatedKey1} = current:q(Q1), 289 | ?assertEqual(80, length(LimitedItems1)), 290 | 291 | %% Pagging last page 292 | Q2 = {[{<<"TableName">>, ?TABLE}, 293 | {<<"KeyConditions">>, 294 | {[{<<"hash_key">>, 295 | {[{<<"AttributeValueList">>, [{[{<<"N">>, <<"1">>}]}]}, 296 | {<<"ComparisonOperator">>, <<"EQ">>}]}}]}}, 297 | {<<"ExclusiveStartKey">>, LastEvaluatedKey1}, 298 | {<<"Limit">>, 30}]}, 299 | {ok, LimitedItems2} = current:q(Q2), 300 | 301 | %% check for overlaps 302 | ?assertEqual(0, sets:size( 303 | sets:intersection(sets:from_list(LimitedItems1), 304 | sets:from_list(LimitedItems2)))), 305 | 306 | ?assertEqual(20, length(LimitedItems2)). 307 | 308 | 309 | get_put_update_delete() -> 310 | ok = create_table(?TABLE), 311 | ok = clear_table(?TABLE), 312 | 313 | Key = {[{<<"hash_key">>, {[{<<"N">>, <<"1">>}]}}, 314 | {<<"range_key">>, {[{<<"N">>, <<"1">>}]}}]}, 315 | 316 | Item = {[{<<"attribute">>, {[{<<"SS">>, [<<"foo">>]}]}}, 317 | {<<"range_key">>, {[{<<"N">>, <<"1">>}]}}, 318 | {<<"hash_key">>, {[{<<"N">>, <<"1">>}]}}]}, 319 | 320 | {ok, {NoItem}} = current:get_item({[{<<"TableName">>, ?TABLE}, 321 | {<<"Key">>, Key}]}), 322 | ?assertNot(proplists:is_defined(<<"Item">>, NoItem)), 323 | 324 | 325 | ?assertMatch({ok, _}, current:put_item({[{<<"TableName">>, ?TABLE}, 326 | {<<"Item">>, Item}]})), 327 | 328 | {ok, {WithItem}} = current:get_item({[{<<"TableName">>, ?TABLE}, 329 | {<<"Key">>, Key}]}), 330 | {ActualItem} = proplists:get_value(<<"Item">>, WithItem), 331 | ?assertEqual(lists:sort(element(1, Item)), lists:sort(ActualItem)), 332 | 333 | {ok, _} = current:update_item( 334 | {[{<<"TableName">>, ?TABLE}, 335 | {<<"AttributeUpdates">>, 336 | {[{<<"attribute">>, {[{<<"Action">>, <<"ADD">>}, 337 | {<<"Value">>, {[{<<"SS">>, [<<"bar">>]}]}} 338 | ]}}]}}, 339 | {<<"Key">>, Key}]}), 340 | 341 | {ok, {WithUpdate}} = current:get_item({[{<<"TableName">>, ?TABLE}, 342 | {<<"Key">>, Key}]}), 343 | {UpdatedItem} = proplists:get_value(<<"Item">>, WithUpdate), 344 | Attribute = proplists:get_value(<<"attribute">>, UpdatedItem), 345 | ?assertMatch({[{<<"SS">>, _Values}]}, Attribute), 346 | {[{<<"SS">>, Values}]} = Attribute, 347 | ?assertEqual([<<"bar">>, <<"foo">>], lists:sort(Values)), 348 | 349 | 350 | ?assertMatch({ok, _}, current:delete_item({[{<<"TableName">>, ?TABLE}, 351 | {<<"Key">>, Key}]})), 352 | 353 | {ok, {NoItemAgain}} = current:get_item({[{<<"TableName">>, ?TABLE}, 354 | {<<"Key">>, Key}]}), 355 | ?assertNot(proplists:is_defined(<<"Item">>, NoItemAgain)). 356 | 357 | 358 | retry_with_timeout() -> 359 | meck:new(current_http_client, [passthrough]), 360 | meck:expect(current_http_client, post, fun (_, _, _, _) -> 361 | {error, claim_timeout} 362 | end), 363 | ?assertEqual({error, {max_retries, claim_timeout}}, 364 | current:describe_table({[{<<"TableName">>, ?TABLE}]}, 365 | [{retries, 3}])), 366 | 367 | meck:unload(current_http_client). 368 | 369 | timeout() -> 370 | ?assertMatch({error, {max_retries, _}}, 371 | current:describe_table({[{<<"TableName">>, ?TABLE}]}, 372 | [{call_timeout, 0}])). 373 | 374 | 375 | throttled() -> 376 | ok = create_table(?TABLE), 377 | ok = clear_table(?TABLE), 378 | 379 | E = <<"com.amazonaws.dynamodb.v20120810#" 380 | "ProvisionedThroughputExceededException">>, 381 | 382 | ThrottledResponse = {ok, 400, 383 | jiffy:encode( 384 | {[{'__type', E}, 385 | {message, <<"foobar">>}]})}, 386 | 387 | meck:new(current_http_client, [passthrough]), 388 | meck:expect(current_http_client, post, 4, 389 | meck_ret_spec:seq( 390 | [ThrottledResponse, 391 | ThrottledResponse, 392 | meck_ret_spec:passthrough()])), 393 | 394 | 395 | Key = {[{<<"hash_key">>, ?NUMBER(1)}, 396 | {<<"range_key">>, ?NUMBER(1)}]}, 397 | 398 | WriteRequestItems = [{[{<<"PutRequest">>, {[{<<"Item">>, Key}]}}]}], 399 | 400 | WriteRequest = {[{<<"RequestItems">>, 401 | {[{?TABLE, WriteRequestItems}]}} 402 | ]}, 403 | 404 | ?assertEqual(ok, current:batch_write_item(WriteRequest, [{retries, 3}])), 405 | 406 | meck:unload(current_http_client). 407 | 408 | non_json_error() -> 409 | meck:new(current_http_client, [passthrough]), 410 | CurrentResponse = {ok, 413, <<"not a json response!">>}, 411 | meck:expect(current_http_client, post, 4, CurrentResponse), 412 | 413 | Key = {[{<<"hash_key">>, ?NUMBER(1)}, 414 | {<<"range_key">>, ?NUMBER(1)}]}, 415 | Response = current:get_item({[{<<"TableName">>, ?TABLE}, 416 | {<<"Key">>, Key}]}), 417 | 418 | ?assertEqual({error, {413, <<"not a json response!">>}}, 419 | Response), 420 | 421 | meck:unload(current_http_client). 422 | 423 | 424 | 425 | %% 426 | %% SIGNING 427 | %% 428 | 429 | key_derivation_test() -> 430 | application:set_env(current, secret_access_key, 431 | <<"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY">>), 432 | application:set_env(current, region, <<"us-east-1">>), 433 | application:set_env(current, aws_host, <<"iam">>), 434 | Now = edatetime:datetime2ts({{2012, 2, 15}, {0, 0, 0}}), 435 | 436 | ?assertEqual(<<"f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d">>, 437 | base16:encode(current:derived_key(Now))). 438 | 439 | post_vanilla_test() -> 440 | application:set_env(current, region, <<"us-east-1">>), 441 | application:set_env(current, aws_host, <<"host">>), 442 | application:set_env(current, access_key, <<"AKIDEXAMPLE">>), 443 | application:set_env(current, secret_access_key, 444 | <<"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY">>), 445 | 446 | Now = edatetime:datetime2ts({{2011, 9, 9}, {23, 36, 0}}), 447 | 448 | %% from post-vanilla.req 449 | Headers = [{<<"date">>, <<"Mon, 09 Sep 2011 23:36:00 GMT">>}, 450 | {<<"host">>, <<"host.foo.com">>}], 451 | 452 | CanonicalRequest = current:canonical(Headers, ""), 453 | ?assertEqual(creq("post-vanilla"), iolist_to_binary(CanonicalRequest)), 454 | 455 | HashedCanonicalRequest = base16:encode(crypto:hash(sha256, CanonicalRequest)), 456 | 457 | ?assertEqual(sts("post-vanilla"), 458 | iolist_to_binary( 459 | current:string_to_sign(HashedCanonicalRequest, Now))), 460 | 461 | ?assertEqual(authz("post-vanilla"), 462 | iolist_to_binary( 463 | current:authorization(Headers, "", Now))). 464 | 465 | http_client() -> 466 | current:delete_table({[{<<"TableName">>, ?TABLE}]}), 467 | ?assertEqual(ok, current:wait_for_delete(?TABLE, 5000)), 468 | ok. 469 | 470 | exp_error_tuple_backpressure() -> 471 | Reason = timeout, 472 | Retries = 3, 473 | Opts = [{retries, Retries}], 474 | 475 | ?assertEqual({error, {max_retries, Reason}}, 476 | current:apply_backpressure(some_op, 477 | some_body, 478 | Retries, 479 | start, 480 | Opts, 481 | Reason)), 482 | ok. 483 | 484 | %% 485 | %% HELPERS 486 | %% 487 | 488 | creq(Name) -> 489 | {ok, B} = file:read_file( 490 | filename:join(["test", "aws4_testsuite", Name ++ ".creq"])), 491 | binary:replace(B, <<"\r\n">>, <<"\n">>, [global]). 492 | 493 | sts(Name) -> 494 | {ok, B} = file:read_file( 495 | filename:join(["test", "aws4_testsuite", Name ++ ".sts"])), 496 | binary:replace(B, <<"\r\n">>, <<"\n">>, [global]). 497 | 498 | 499 | authz(Name) -> 500 | {ok, B} = file:read_file( 501 | filename:join(["test", "aws4_testsuite", Name ++ ".authz"])), 502 | binary:replace(B, <<"\r\n">>, <<"\n">>, [global]). 503 | 504 | key_sort(L) -> 505 | lists:sort(normalize_key_order(L, [])). 506 | 507 | normalize_key_order([], Acc) -> 508 | lists:reverse(Acc); 509 | normalize_key_order([{H} | T], Acc) -> 510 | K1 = proplists:get_value(<<"hash_key">>, H), 511 | K2 = proplists:get_value(<<"range_key">>, H), 512 | normalize_key_order(T, [{[{<<"hash_key">>, K1}, {<<"range_key">>, K2}]} | Acc]). 513 | 514 | setup() -> 515 | %% Make travis-ci use different env/config we do not need valid 516 | %% credentials for CI since we are using local DynamDB 517 | Environment = case os:getenv("TRAVIS") of 518 | "true" -> "aws_credentials.term.template"; 519 | false -> "aws_credentials.term" 520 | end, 521 | 522 | File = filename:join([code:priv_dir(current), Environment]), 523 | {ok, Cred} = file:consult(File), 524 | AccessKey = proplists:get_value(access_key, Cred), 525 | SecretAccessKey = proplists:get_value(secret_access_key, Cred), 526 | 527 | application:set_env(current, callback_mod, ?MODULE), 528 | application:set_env(current, endpoint, ?ENDPOINT), 529 | application:set_env(current, region, ?REGION), 530 | application:set_env(current, access_key, AccessKey), 531 | application:set_env(current, secret_access_key, SecretAccessKey), 532 | 533 | {ok, _} = application:ensure_all_started(current), 534 | 535 | ok. 536 | 537 | teardown(_) -> 538 | meck:unload(), 539 | application:stop(current). 540 | 541 | 542 | create_table(Name) -> 543 | AttrDefs = [{[{<<"AttributeName">>, <<"hash_key">>}, 544 | {<<"AttributeType">>, <<"N">>}]}, 545 | {[{<<"AttributeName">>, <<"range_key">>}, 546 | {<<"AttributeType">>, <<"N">>}]}], 547 | KeySchema = [{[{<<"AttributeName">>, <<"hash_key">>}, 548 | {<<"KeyType">>, <<"HASH">>}]}, 549 | {[{<<"AttributeName">>, <<"range_key">>}, 550 | {<<"KeyType">>, <<"RANGE">>}]}], 551 | 552 | R = {[{<<"AttributeDefinitions">>, AttrDefs}, 553 | {<<"KeySchema">>, KeySchema}, 554 | {<<"ProvisionedThroughput">>, 555 | {[{<<"ReadCapacityUnits">>, 10}, 556 | {<<"WriteCapacityUnits">>, 5}]}}, 557 | {<<"TableName">>, Name}]}, 558 | 559 | case current:describe_table({[{<<"TableName">>, Name}]}) of 560 | {error, {<<"ResourceNotFoundException">>, _}} -> 561 | ?assertMatch({ok, _}, 562 | current:create_table(R, [{timeout, 5000}, {retries, 3}])), 563 | ok = current:wait_for_active(?TABLE, 5000); 564 | {error, {_Type, Reason}} -> 565 | error_logger:info_msg("~p~n", [Reason]); 566 | {ok, _} -> 567 | ok 568 | end. 569 | 570 | clear_table(Name) -> 571 | case current:scan({[{<<"TableName">>, Name}, 572 | {<<"AttributesToGet">>, [<<"hash_key">>, <<"range_key">>]}]}, 573 | []) of 574 | {ok, []} -> 575 | ok; 576 | {ok, Items} -> 577 | RequestItems = [{[{<<"DeleteRequest">>, 578 | {[{<<"Key">>, Item}]}}]} || Item <- Items], 579 | Request = {[{<<"RequestItems">>, {[{Name, RequestItems}]}}]}, 580 | ok = current:batch_write_item(Request, []), 581 | clear_table(Name) 582 | end. 583 | 584 | request_error(Operation, _Start, Reason) -> 585 | io:format("ERROR in ~p: ~p~n", [Operation, Reason]). 586 | --------------------------------------------------------------------------------