Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 4 additions & 11 deletions rebar.config
Original file line number Diff line number Diff line change
@@ -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"}
]}.


Expand Down Expand Up @@ -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"}]}
]}
]}.
8 changes: 0 additions & 8 deletions src/couchbeam.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 3 additions & 15 deletions src/couchbeam_httpc.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 1 addition & 43 deletions src/couchbeam_util.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
58 changes: 56 additions & 2 deletions test/couchbeam_integration_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,7 +163,8 @@ groups() ->
doc_with_attachment,
multiple_attachments,
large_attachment,
attachment_streaming
attachment_streaming,
doc_multipart_stream
]},
{view_ops, [sequence], [
all_docs,
Expand Down Expand Up @@ -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, <<CurBuf/binary, Chunk/binary>>, 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
%%====================================================================
Expand Down
Loading