diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23b66ac..1f4f17c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,8 @@ name: Test on: push: branches: [ master, main ] + # Run on every pull request, whatever its base branch (e.g. stacked PRs). pull_request: - branches: [ master, main ] jobs: linux: @@ -120,16 +120,21 @@ jobs: rebar3 xref rebar3 as test eunit - # Optional integration tests with real CouchDB + # End-to-end tests against a real CouchDB. + # `3` is the latest 3.x; `latest` tracks the newest release and becomes 4.x + # once a 4.x image is published. integration: - name: Integration Tests (CouchDB) + name: Integration Tests (CouchDB ${{ matrix.couchdb }}) runs-on: ubuntu-22.04 - # This job is optional - don't block CI if it fails - continue-on-error: true + + strategy: + fail-fast: false + matrix: + couchdb: ['3', 'latest'] services: couchdb: - image: couchdb:3.3 + image: couchdb:${{ matrix.couchdb }} env: COUCHDB_USER: admin COUCHDB_PASSWORD: admin diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b471f9..d5a16f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,16 +16,29 @@ All notable changes to this project will be documented in this file. ### Compatibility -- Supports OTP 27, 28 and 29. -- CI now also runs on OTP 29. +- Requires OTP 27+. Dropped the OTP-version crypto shim and now always use + `crypto:mac/4`. +- CI runs on OTP 27, 28 and 29. + +### Testing + +- End-to-end tests run the full client against a real CouchDB. `make e2e` + starts CouchDB in Docker, runs the suite, and tears it down. CI runs all + suite groups and they now gate the build. +- The e2e matrix runs the latest 3.x (`3`) and the `latest` CouchDB image, so + CI adopts CouchDB 4.x automatically once it is released. ### Dependencies - hackney: 4.2.2 (from 2.0.1) - meck (test): 1.2.0 (from 0.9.2) +- Removed the optional `oauth` dependency. ### Removed +- OAuth support. The `{oauth, ...}` connection option and + `couchbeam_util:oauth_header/3` are gone; CouchDB removed server-side OAuth + in 2.x. Basic auth, proxy auth, and cookie auth are unchanged. - `hackney:skip_body/1` usage (removed in hackney 4.x); bodies are read directly. ## [2.0.0] - 2026-01-21 diff --git a/Makefile b/Makefile index d17130c..16913e7 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ # couchbeam developer tasks. -COUCHDB_URL ?= http://127.0.0.1:5984 -COUCHDB_USER ?= admin -COUCHDB_PASS ?= admin -COMPOSE ?= docker compose +COUCHDB_URL ?= http://127.0.0.1:5984 +COUCHDB_USER ?= admin +COUCHDB_PASS ?= admin +COUCHDB_VERSION ?= 3 +COMPOSE ?= docker compose -export COUCHDB_URL COUCHDB_USER COUCHDB_PASS +export COUCHDB_URL COUCHDB_USER COUCHDB_PASS COUCHDB_VERSION .PHONY: all compile eunit xref dialyzer test e2e e2e-up e2e-run e2e-down clean diff --git a/docker-compose.yml b/docker-compose.yml index 4de77a8..c0cd7cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # See `make e2e` (or support/run-e2e.sh) for the test runner. services: couchdb: - image: couchdb:3.3 + image: couchdb:${COUCHDB_VERSION:-3} environment: COUCHDB_USER: admin COUCHDB_PASSWORD: admin diff --git a/rebar.config b/rebar.config index a694c5c..c757ebf 100644 --- a/rebar.config +++ b/rebar.config @@ -1,25 +1,18 @@ %%-*- mode: erlang -*- -{erl_opts, [debug_info, - {platform_define, "^(2[3-9])", 'USE_CRYPTO_MAC'}]}. - -%% oauth is an optional dependency - ignore xref warnings for it -{xref_ignores, [{oauth, header_params_encode, 1}, {oauth, sign, 6}]}. +{erl_opts, [debug_info]}. %% Only check for undefined function calls (not unused exports, which are expected for a library) {xref_checks, [undefined_function_calls, undefined_functions, deprecated_function_calls]}. %% Dialyzer configuration -%% - Include optional apps in PLT to avoid unknown function warnings {dialyzer, [ - {plt_extra_apps, [mimerl, xmerl, oauth]} + {plt_extra_apps, [mimerl, xmerl]} ]}. {deps, [ - {hackney, "4.2.2"}, - %% oauth is optional - only needed if using OAuth authentication - {oauth, "2.1.0"} + {hackney, "4.2.2"} ]}. @@ -58,6 +51,6 @@ {profiles, [{test, [ {cover_enabled, true}, {eunit_opts, [verbose]}, - {deps, [{oauth, "2.1.0"}, {meck, "1.2.0"}]} + {deps, [{meck, "1.2.0"}]} ]} ]}. diff --git a/src/couchbeam.erl b/src/couchbeam.erl index 567df89..16593e7 100644 --- a/src/couchbeam.erl +++ b/src/couchbeam.erl @@ -112,19 +112,11 @@ server_connection(Host, Port) when is_integer(Port) -> %% {proxy_password, string()} | %% {basic_auth, {username(), password()}} | %% {cookie, string()} | -%% {oauth, oauthOptions()} | %% {proxyauth, [proxyauthOpt]} %% %% username() = string() %% password() = string() %% SSLOpt = term() -%% oauthOptions() = [oauth()] -%% oauth() = -%% {consumer_key, string()} | -%% {token, string()} | -%% {token_secret, string()} | -%% {consumer_secret, string()} | -%% {signature_method, string()} %% %% proxyauthOpt = {X-Auth-CouchDB-UserName, username :: string()} | %% {X-Auth-CouchDB-Roles, roles :: string} | list_of_user_roles_separated_by_a_comma diff --git a/src/couchbeam_httpc.erl b/src/couchbeam_httpc.erl index a391e25..e64f9d7 100644 --- a/src/couchbeam_httpc.erl +++ b/src/couchbeam_httpc.erl @@ -13,8 +13,7 @@ db_request/5, db_request/6, json_body/1, db_resp/2, - make_headers/4, - maybe_oauth_header/4]). + make_headers/4]). %% urls utils -export([server_url/1, db_url/1, doc_url/2]). %% atts utils @@ -137,25 +136,14 @@ db_request(Method, Url, Headers, Body, Options, Expect) -> json_body(Body) when is_binary(Body) -> couchbeam_ejson:decode(Body). -make_headers(Method, Url, Headers, Options) -> +make_headers(_Method, _Url, Headers, Options) -> Headers1 = case couchbeam_util:get_value(<<"Accept">>, Headers) of undefined -> [{<<"Accept">>, <<"application/json, */*;q=0.9">>} | Headers]; _ -> Headers end, - {Headers2, Options1} = maybe_oauth_header(Method, Url, Headers1, Options), - maybe_proxyauth_header(Headers2, Options1). - - -maybe_oauth_header(Method, Url, Headers, Options) -> - case couchbeam_util:get_value(oauth, Options) of - undefined -> - {Headers, Options}; - OauthProps -> - Hdr = couchbeam_util:oauth_header(Url, Method, OauthProps), - {[Hdr|Headers], proplists:delete(oauth, Options)} - end. + maybe_proxyauth_header(Headers1, Options). maybe_proxyauth_header(Headers, Options) -> case couchbeam_util:get_value(proxyauth, Options) of diff --git a/src/couchbeam_util.erl b/src/couchbeam_util.erl index 7fd4642..f96bc05 100644 --- a/src/couchbeam_util.erl +++ b/src/couchbeam_util.erl @@ -14,7 +14,6 @@ -export([to_list/1, to_binary/1, to_integer/1, to_atom/1]). -export([binary_env/2]). -export([encode_query/1, encode_query_value/2]). --export([oauth_header/3]). -export([propmerge/3, propmerge1/2]). -export([get_value/2, get_value/3]). -export([deprecated/3, shutdown_sync/1]). @@ -80,43 +79,6 @@ encode_query_value(K, V) when is_binary(K) -> encode_query_value(binary_to_list(K), V); encode_query_value(_K, V) -> V. -% build oauth header -oauth_header(Url, Action, OauthProps) when is_binary(Url) -> - oauth_header(binary_to_list(Url),Action, OauthProps); -oauth_header(Url, Action, OauthProps) -> - #hackney_url{qs=QS} = hackney_url:parse_url(Url), - QSL = [{binary_to_list(K), binary_to_list(V)} || {K,V} <- - hackney_url:parse_qs(QS)], - - % get oauth paramerers - ConsumerKey = to_list(get_value(consumer_key, OauthProps)), - Token = to_list(get_value(token, OauthProps)), - TokenSecret = to_list(get_value(token_secret, OauthProps)), - ConsumerSecret = to_list(get_value(consumer_secret, OauthProps)), - SignatureMethodStr = to_list(get_value(signature_method, - OauthProps, "HMAC-SHA1")), - - SignatureMethodAtom = case SignatureMethodStr of - "PLAINTEXT" -> - plaintext; - "HMAC-SHA1" -> - hmac_sha1; - "RSA-SHA1" -> - rsa_sha1 - end, - Consumer = {ConsumerKey, ConsumerSecret, SignatureMethodAtom}, - Method = case Action of - delete -> "DELETE"; - get -> "GET"; - post -> "POST"; - put -> "PUT"; - head -> "HEAD" - end, - Params = oauth:sign(Method, Url, QSL, Consumer, Token, TokenSecret) -- QSL, - - Realm = "OAuth " ++ oauth:header_params_encode(Params), - {<<"Authorization">>, list_to_binary(Realm)}. - %% @doc merge 2 proplists. All the Key - Value pairs from both proplists %% are included in the new proplists. If a key occurs in both dictionaries @@ -285,10 +247,6 @@ hgv(N,L) -> proxy_token(Secret,UserName) -> hackney_bstr:to_hex(hmac(sha, Secret, UserName)). --ifdef(USE_CRYPTO_MAC). +%% couchbeam requires OTP 27+, where crypto:mac/4 is always available. hmac(Alg, Key, Data) -> crypto:mac(hmac, Alg, Key, Data). --else. -hmac(Alg, Key, Data) -> - crypto:hmac(Alg, Key, Data). --endif. diff --git a/test/couchbeam_integration_SUITE.erl b/test/couchbeam_integration_SUITE.erl index f110e4c..2a11ab3 100644 --- a/test/couchbeam_integration_SUITE.erl +++ b/test/couchbeam_integration_SUITE.erl @@ -46,7 +46,8 @@ -export([doc_with_attachment/1, multiple_attachments/1, large_attachment/1, - attachment_streaming/1]). + attachment_streaming/1, + doc_multipart_stream/1]). %% Test cases - Views -export([all_docs/1, @@ -162,7 +163,8 @@ groups() -> doc_with_attachment, multiple_attachments, large_attachment, - attachment_streaming + attachment_streaming, + doc_multipart_stream ]}, {view_ops, [sequence], [ all_docs, @@ -807,6 +809,58 @@ collect_stream(Ref, Acc) -> ct:fail("Stream error: ~p", [Reason]) end. +%% Test reading a document and its attachments as a multipart stream. +doc_multipart_stream(Config) -> + Db = ?config(db, Config), + + %% Create a document with two attachments + Doc = #{<<"_id">> => <<"mp_stream">>, <<"type">> => <<"test">>}, + {ok, DocSaved} = couchbeam:save_doc(Db, Doc), + Rev0 = maps:get(<<"_rev">>, DocSaved), + %% Use a non-compressible content type so CouchDB returns the bytes + %% verbatim in the multipart response (text/* attachments are gzipped). + {ok, Att1} = couchbeam:put_attachment(Db, <<"mp_stream">>, <<"a.bin">>, + <<"alpha">>, + [{rev, Rev0}, + {content_type, <<"application/octet-stream">>}]), + Rev1 = maps:get(<<"rev">>, Att1), + {ok, _} = couchbeam:put_attachment(Db, <<"mp_stream">>, <<"b.bin">>, + <<"bravo">>, + [{rev, Rev1}, + {content_type, <<"application/octet-stream">>}]), + + %% Open it as a multipart stream (attachment bodies included) + {ok, {multipart, State}} = couchbeam:open_doc(Db, <<"mp_stream">>, + [{"attachments", true}]), + + {DocOut, AttsOut} = collect_multipart(State, undefined, <<>>, #{}), + + <<"mp_stream">> = maps:get(<<"_id">>, DocOut), + true = maps:is_key(<<"_attachments">>, DocOut), + <<"alpha">> = maps:get(<<"a.bin">>, AttsOut), + <<"bravo">> = maps:get(<<"b.bin">>, AttsOut), + + ct:pal("Multipart doc stream test passed"), + ok. + +%% Drive couchbeam:stream_doc/1 to completion, collecting the document and +%% each attachment's bytes. +collect_multipart(State, Doc, CurBuf, Atts) -> + case couchbeam:stream_doc(State) of + {doc, NewDoc, NState} -> + collect_multipart(NState, NewDoc, CurBuf, Atts); + {att, _Name, NState} -> + collect_multipart(NState, Doc, <<>>, Atts); + {att_body, _Name, Chunk, NState} -> + collect_multipart(NState, Doc, <>, Atts); + {att_eof, Name, NState} -> + collect_multipart(NState, Doc, <<>>, Atts#{Name => CurBuf}); + eof -> + {Doc, Atts}; + {error, Reason} -> + ct:fail("Multipart stream error: ~p", [Reason]) + end. + %%==================================================================== %% View tests %%====================================================================