%%% -*- erlang -*- %%% %%% This file is part of couchbeam released under the MIT license. %%% See the NOTICE for more information. -module(couchbeam). -author('Benoit Chesneau'). -include("couchbeam.hrl"). -include_lib("hackney/include/hackney.hrl"). -include_lib("hackney/include/hackney_lib.hrl"). -define(TIMEOUT, infinity). %% API urls -export([server_connection/0, server_connection/1, server_connection/2, server_connection/4, server_info/1, get_uuid/1, get_uuids/2, replicate/2, replicate/3, replicate/4, all_dbs/1, all_dbs/2, db_exists/2, create_db/2, create_db/3, create_db/4, open_db/2, open_db/3, open_or_create_db/2, open_or_create_db/3, open_or_create_db/4, delete_db/1, delete_db/2, db_info/1, design_info/2, view_cleanup/1, save_doc/2, save_doc/3, save_doc/4, doc_exists/2, open_doc/2, open_doc/3, stream_doc/1, end_doc_stream/1, delete_doc/2, delete_doc/3, save_docs/2, save_docs/3, delete_docs/2, delete_docs/3, copy_doc/2, copy_doc/3, lookup_doc_rev/2, lookup_doc_rev/3, fetch_attachment/3, fetch_attachment/4, stream_attachment/1, stream_attachment/3, delete_attachment/3, delete_attachment/4, put_attachment/4, put_attachment/5, send_attachment/2, ensure_full_commit/1, ensure_full_commit/2, compact/1, compact/2, get_missing_revs/2, find/3]). %% Connection handle is now a pid in hackney process-per-connection branch -opaque doc_stream() :: {pid(), any()}. -export_type([doc_stream/0]). -type mp_attachment() :: {Name :: binary(), Bin :: binary()} | {Name :: binary(), Bin :: binary(), Encoding :: binary()} | { Name :: binary(), Bin :: binary(), Type :: binary(), Encoding :: binary()} | { Name :: binary(), {file, Path :: string()}} | { Name :: binary(), {file, Path :: string()}, Encoding :: binary()} | { Name :: binary(), Fun :: fun(), Length :: integer()} | { Name :: binary(), Fun :: fun(), Length :: integer(), Encoding :: binary()} | { Name :: binary(), Fun :: fun(), Length :: integer(), Type :: binary(), Encoding :: binary()} | { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer()} | { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Encoding :: binary()} | { Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Type :: binary(), Encoding :: binary()}. -type mp_attachments() :: [mp_attachment()]. %% -------------------------------------------------------------------- %% API functins. %% -------------------------------------------------------------------- %% @doc Create a server for connectiong to a CouchDB node %% @equiv server_connection("127.0.0.1", 5984, "", [], false) server_connection() -> URL = couchbeam_util:binary_env("COUCHDB_URL", "http://127.0.0.1:5984"), ADMIN = couchbeam_util:binary_env("COUCHDB_ADMIN", "admin"), PASSWORD = couchbeam_util:binary_env("COUCHDB_PASSWORD", "change_me"), server_connection(URL, [{basic_auth, {ADMIN, PASSWORD}}]). server_connection(URL) when is_list(URL) orelse is_binary(URL) -> server_connection(URL, []). %% @doc Create a server for connectiong to a CouchDB node %% @equiv server_connection(Host, Port, "", []) server_connection(URL, Options0) when is_list(Options0) -> Options = case hackney_url:property(scheme, hackney_url:parse_url(URL)) of http -> [{insecure_basic_auth, true} | Options0]; _ -> Options0 end, #server{url=hackney_url:fix_path(URL), options=Options}; server_connection(Host, Port) when is_integer(Port) -> server_connection(Host, Port, <<>>, []). %% @doc Create a server for connectiong to a CouchDB node %% %% Connections are made to: %% ```http://Host:PortPrefix''' %% %% If ssl is set https is used. %% %% For a description of SSL Options, look in the ssl manpage. %% -spec server_connection(Host::string(), Port::non_neg_integer(), Prefix::binary(), OptionsList::list()) -> Server::server(). %% OptionsList() = [option()] %% option() = %% {is_ssl, boolean()} | %% {ssl_options, [SSLOpt]} | %% {pool_name, atom()} | %% {proxy_host, string()} | %% {proxy_port, integer()} | %% {proxy_user, string()} | %% {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 %% {X-Auth-CouchDB-Token: token :: string()} | authentication token. Optional, but strongly recommended to force token be required to prevent requests from untrusted sources. server_connection(Host, Port, Prefix, Options) when is_integer(Port), Port =:= 443 -> BaseUrl = iolist_to_binary(["https://", Host, ":", integer_to_list(Port)]), Url = hackney_url:make_url(BaseUrl, [Prefix], []), server_connection(Url, Options); server_connection(Host, Port, Prefix, Options) -> Scheme = case proplists:get_value(is_ssl, Options) of true -> "https"; _ -> "http" end, BaseUrl = iolist_to_binary([Scheme, "://", Host, ":", integer_to_list(Port)]), Url = hackney_url:make_url(BaseUrl, [Prefix], []), server_connection(Url, Options). %% @doc Get Information from the server -spec server_info(server()) -> {ok, map()} | {error, term()}. server_info(#server{url=Url, options=Opts}) -> case hackney:get(Url, [], <<>>, Opts) of {ok, 200, _, Ref} -> Version = couchbeam_httpc:json_body(Ref), {ok, Version}; {ok, Status, Headers, Ref} -> {ok, Body} = hackney:body(Ref), {error, {bad_response, {Status, Headers, Body}}}; Error -> Error end. %% @doc Get one uuid from the server -spec get_uuid(server()) -> [binary()]. get_uuid(Server) -> couchbeam_uuids:get_uuids(Server, 1). %% @doc Get a list of uuids from the server -spec get_uuids(server(), integer()) -> [binary()]. get_uuids(Server, Count) -> couchbeam_uuids:get_uuids(Server, Count). %% @doc Handle replication. Pass an object containting all informations %% It allows to pass for example an authentication info %% ``` %% RepObj = {[ %% {<<"source">>, <<"sourcedb">>}, %% {<<"target">>, <<"targetdb">>}, %% {<<"create_target">>, true} %% ]} %% replicate(Server, RepObj). %% ''' -spec replicate(server(), map()) -> {ok, map()} | {error, term()}. replicate(#server{}=Server, RepObj) -> case open_db(Server, <<"_replicator">>) of {ok, ReplicatorDb} -> case save_doc(ReplicatorDb, RepObj) of {ok, Doc} -> {ok, Doc}; Error -> Error end end. %% @doc Handle replication. -spec replicate(server(), binary() | string(), binary() | string()) -> {ok, map()} | {error, term()}. replicate(Server, Source, Target) -> replicate(Server, Source, Target, []). %% @doc handle Replication. Allows to pass options with source and %% target. Source and target can be either simple URI strings or %% complex document structures with authentication. Options is a Json object. %% ex: %% ``` %% %% Simple URI replication %% Options = [{<<"create_target">>, true}]}, %% couchbeam:replicate(S, "testdb", "testdb2", Options). %% %% %% Complex replication with authentication %% Source = "http://user:pass@remote.com:5984/db", %% Target = {[{<<"url">>, <<"http://localhost:5984/target_db">>}, %% {<<"auth">>, {[{<<"basic">>, {[{<<"username">>, <<"user">>}, %% {<<"password">>, <<"pass">>}]}}]}}]}, %% couchbeam:replicate(S, Source, Target, [{<<"continuous">>, true}]). %% ''' replicate(Server, Source, Target, Options) when is_list(Options) -> SourceProp = format_replication_endpoint(Source), TargetProp = format_replication_endpoint(Target), BaseMap = #{<<"source">> => SourceProp, <<"target">> => TargetProp}, RepMap = lists:foldl(fun({K, V}, Acc) -> maps:put(K, V, Acc) end, BaseMap, Options), replicate(Server, RepMap); replicate(Server, Source, Target, Options) when is_map(Options) -> SourceProp = format_replication_endpoint(Source), TargetProp = format_replication_endpoint(Target), RepMap = Options#{<<"source">> => SourceProp, <<"target">> => TargetProp}, replicate(Server, RepMap). %% @doc get list of databases on a CouchDB node -spec all_dbs(server()) -> {ok, [binary()]} | {error, term()}. all_dbs(#server{}=Server) -> all_dbs(Server, []). %% @doc get list of databases on a CouchDB node with optional filter -spec all_dbs(server(), list()) -> {ok, [binary()]} | {error, term()}. all_dbs(#server{url=ServerUrl, options=Opts}, Options) -> Args = couchbeam_view:parse_view_options(Options), Url = hackney_url:make_url(ServerUrl, <<"_all_dbs">>, Args#view_query_args.options), Resp = couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]), case Resp of {ok, _, _, Ref} -> AllDbs = couchbeam_httpc:json_body(Ref), {ok, AllDbs}; Error -> Error end. design_info(#db{server=Server, name=DbName, options=Opts}, DesignName) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [DbName, <<"_design">>, DesignName, <<"_info">>], []), Resp = couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]), case Resp of {ok, _, _, Ref} -> DesignInfo = couchbeam_httpc:json_body(Ref), {ok, DesignInfo}; Error -> Error end. view_cleanup(#db{server=Server, name=DbName, options=Opts}) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [DbName, <<"_view_cleanup">>], []), Headers = [{<<"Content-Type">>, <<"application/json">>}], Resp = couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [200, 202]), case Resp of {ok, _, _, Ref} -> catch hackney:skip_body(Ref), ok; Error -> Error end. %% @doc test if db with dbname exists on the CouchDB node -spec db_exists(server(), binary() | string()) -> boolean(). db_exists(#server{url=ServerUrl, options=Opts}, DbName) -> Url = hackney_url:make_url(ServerUrl, couchbeam_util:dbname(DbName), []), case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of {ok, 200, _}-> true; _Error -> io:format("db exists error ~p", [_Error]), false end. %% @doc Create a database and a client for connectiong to it. %% @equiv create_db(Server, DbName, [], []) create_db(Server, DbName) -> create_db(Server, DbName, [], []). %% @doc Create a database and a client for connectiong to it. %% @equiv create_db(Server, DbName, Options, []) create_db(Server, DbName, Options) -> create_db(Server, DbName, Options, []). %% @doc Create a database and a client for connectiong to it. %% %% Connections are made to: %% ```http://Host:PortPrefix/DbName''' %% %% If ssl is set https is used. See server_connections for options. %% Params is a list of optionnal query argument you want to pass to the %% db. Useful for bigcouch for example. %% -spec create_db(server(), binary() | string(), list(), list()) -> {ok, db()} | {error, term()}. create_db(#server{url=ServerUrl, options=Opts}=Server, DbName0, Options, Params) -> DbName = couchbeam_util:dbname(DbName0), Options1 = couchbeam_util:propmerge1(Options, Opts), Url = hackney_url:make_url(ServerUrl, DbName, Params), Resp = couchbeam_httpc:db_request(put, Url, [], <<>>, Options1, [201]), case Resp of {ok, _Status, _Headers, Ref} -> hackney:skip_body(Ref), {ok, #db{server=Server, name=DbName, options=Options1}}; {error, precondition_failed} -> {error, db_exists}; Error -> Error end. %% @doc Create a client for connection to a database %% @equiv open_db(Server, DbName, []) open_db(Server, DbName) -> open_db(Server, DbName, []). %% @doc Create a client for connection to a database -spec open_db(server(), binary() | string(), list()) -> {ok, db()}. open_db(#server{options=Opts}=Server, DbName, Options) -> Options1 = couchbeam_util:propmerge1(Options, Opts), {ok, #db{server=Server, name=couchbeam_util:dbname(DbName), options=Options1}}. %% @doc Create a client for connecting to a database and create the %% database if needed. %% @equiv open_or_create_db(Server, DbName, [], []) open_or_create_db(Server, DbName) -> open_or_create_db(Server, DbName, [], []). %% @doc Create a client for connecting to a database and create the %% database if needed. %% @equiv open_or_create_db(Server, DbName, Options, []) open_or_create_db(Server, DbName, Options) -> open_or_create_db(Server, DbName, Options, []). %% @doc Create a client for connecting to a database and create the %% database if needed. -spec open_or_create_db(server(), binary() | string(), list(), list()) -> {ok, db()} | {error, term()}. open_or_create_db(#server{url=ServerUrl, options=Opts}=Server, DbName0, Options, Params) -> DbName = couchbeam_util:dbname(DbName0), Url = hackney_url:make_url(ServerUrl, DbName, []), Opts1 = couchbeam_util:propmerge1(Options, Opts), Resp = couchbeam_httpc:request(get, Url, [], <<>>, Opts1), case couchbeam_httpc:db_resp(Resp, [200]) of {ok, _Status, _Headers, Ref} -> hackney:skip_body(Ref), open_db(Server, DbName, Options); {error, not_found} -> create_db(Server, DbName, Options, Params); Error -> Error end. %% @doc delete database %% @equiv delete_db(Server, DbName) delete_db(#db{server=Server, name=DbName}) -> delete_db(Server, DbName). %% @doc delete database -spec delete_db(server(), binary() | string()) -> {ok, map()} | {error, term()}. delete_db(#server{url=ServerUrl, options=Opts}, DbName) -> Url = hackney_url:make_url(ServerUrl, couchbeam_util:dbname(DbName), []), Resp = couchbeam_httpc:request(delete, Url, [], <<>>, Opts), case couchbeam_httpc:db_resp(Resp, [200]) of {ok, _, _, Ref} -> {ok, couchbeam_httpc:json_body(Ref)}; Error -> Error end. %% @doc get database info -spec db_info(db()) -> {ok, map()} | {error, term()}. db_info(#db{server=Server, name=DbName, options=Opts}) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_util:dbname(DbName), []), case couchbeam_httpc:db_request(get, Url, [], <<>>, Opts, [200]) of {ok, _Status, _Headers, Ref} -> Infos = couchbeam_httpc:json_body(Ref), {ok, Infos}; {error, not_found} -> {error, db_not_found}; Error -> Error end. %% @doc test if doc with uuid exists in the given db -spec doc_exists(db(), binary() | string()) -> boolean(). doc_exists(#db{server=Server, options=Opts}=Db, DocId) -> DocId1 = couchbeam_util:encode_docid(DocId), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1), []), case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of {ok, _, _} -> true; _Error -> false end. %% @doc open a document %% @equiv open_doc(Db, DocId, []) open_doc(Db, DocId) -> open_doc(Db, DocId, []). %% @doc open a document %% Params is a list of query argument. Have a look in CouchDb API -spec open_doc(db(), binary() | string(), list()) -> {ok, map()} | {error, term()}. open_doc(#db{server=Server, options=Opts}=Db, DocId, Params) -> DocId1 = couchbeam_util:encode_docid(DocId), %% is there any accepted content-type passed to the params? {Accept, Params1} = case proplists:get_value(accept, Params) of undefined -> {any, Params}; A -> {A, proplists:delete(accept, Params)} end, %% set the headers with the accepted content-type if needed Headers = case {Accept, proplists:get_value("attachments", Params)} of {any, true} -> %% only use the more efficient method when we get the %% attachments so we don't use much bandwidth. [{<<"Accept">>, <<"multipart/related">>}]; {Accept, _} when is_binary(Accept) -> %% accepted content-type has been forced [{<<"Accept">>, Accept}]; _ -> [] end, Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1), Params1), case couchbeam_httpc:db_request(get, Url, Headers, <<>>, Opts, [200, 201]) of {ok, _, RespHeaders, Ref} -> ParsedHeaders = hackney_headers:from_list(RespHeaders), case hackney_headers:get_value(<<"content-type">>, ParsedHeaders) of undefined -> {ok, couchbeam_httpc:json_body(Ref)}; ContentType -> case hackney_headers:parse_content_type(ContentType) of {<<"multipart">>, _, Params} -> %% we get a multipart request, start to parse it. {_, Boundary} = lists:keyfind(<<"boundary">>, 1, Params), InitialState = {Ref, fun() -> couchbeam_httpc:wait_mp_doc(Ref, Boundary, <<>>) end}, {ok, {multipart, InitialState}}; _ -> {ok, couchbeam_httpc:json_body(Ref)} end end; Error -> Error end. %% @doc stream the multipart response of the doc API. Use this function %% when you get `{ok, {multipart, State}}' from the function %% `couchbeam:open_doc/3'. -spec stream_doc(doc_stream()) -> {doc, doc()} | {att, Name :: binary(), doc_stream()} | {att_body, Name :: binary(), Chunk :: binary(), doc_stream()} | {att_eof, Name :: binary(), doc_stream()} | eof | {error, term()}. stream_doc({_Ref, Cont}) -> Cont(). %% @doc stop to receive the multipart response of the doc api and close %% the connection. -spec end_doc_stream(doc_stream()) -> ok. end_doc_stream({Ref, _Cont}) -> hackney:close(Ref). %% @doc save a document %% @equiv save_doc(Db, Doc, []) save_doc(Db, Doc) -> save_doc(Db, Doc, []). %% @doc save a *document %% A document is a Json object like this one: %% %% ```{[ %% {<<"_id">>, <<"myid">>}, %% {<<"title">>, <<"test">>} %% ]}''' %% %% Options are arguments passed to the request. This function return a %% new document with last revision and a docid. If _id isn't specified in %% document it will be created. Id is created by extracting an uuid from %% the couchdb node. %% -spec save_doc(db(), map(), list()) -> {ok, map()} | {error, term()}. save_doc(Db, Doc, Options) -> save_doc(Db, Doc, [], Options). %% @doc save a *document with all its attacjments %% A document is a Json object like this one: %% %% ```{[ %% {<<"_id">>, <<"myid">>}, %% {<<"title">>, <<"test">>} %% ]}''' %% %% Options are arguments passed to the request. This function return a %% new document with last revision and a docid. If _id isn't specified in %% document it will be created. Id is created by extracting an uuid from %% the couchdb node. %% %% If the attachments is not empty, the doc will be sent as multipart. %% Attachments are passed as a list of the following tuples: %% %% - `{Name :: binary(), Bin :: binary()}' %% - `{Name :: binary(), Bin :: binary(), Encoding :: binary()}' %% - `{ Name :: binary(), Bin :: binary(), Type :: binary(), Encoding :: binary()}' %% - `{ Name :: binary(), {file, Path :: string()}}' %% - `{ Name :: binary(), {file, Path :: string()}, Encoding :: binary()}' %% - `{ Name :: binary(), Fun :: fun(), Length :: integer()}' %% - `{ Name :: binary(), Fun :: fun(), Length :: integer(), Encoding :: binary()}' %% - `{Name :: binary(), Fun :: fun(), Length :: integer(), Type :: binary(), Encoding :: binary()}' %% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer()}' %% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Encoding :: binary()}' %% - `{ Name :: binary(), {Fun :: fun(), Acc :: any()}, Length :: integer(), Type :: binary(), Encoding :: binary()}.' %% %% where `Type` is the content-type of the attachments (detected in other %% case) and `Encoding` the encoding of the attachments: %% `<<"identity">>' if normal or `<<"gzip">>' if the attachments is %% gzipped. -spec save_doc(Db::db(), doc(), mp_attachments(), Options :: [{binary(), binary() | true}] | binary()) -> {ok, doc()} | {error, term()}. save_doc(#db{server=Server, options=Opts}=Db, Doc, Atts, Options) when is_map(Doc) -> DocId = case maps:get(<<"_id">>, Doc, undefined) of undefined -> [Id] = get_uuid(Server), Id; DocId1 -> couchbeam_util:encode_docid(DocId1) end, Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId), Options), case Atts of [] -> JsonDoc = couchbeam_ejson:encode(Doc), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(put, Url, Headers, JsonDoc, Opts, [200, 201, 202]) of {ok, _, _, Ref} -> JsonProp = couchbeam_httpc:json_body(Ref), NewRev = maps:get(<<"rev">>, JsonProp), NewDocId = maps:get(<<"id">>, JsonProp), Doc1 = couchbeam_doc:set_value(<<"_rev">>, NewRev, couchbeam_doc:set_value(<<"_id">>, NewDocId, Doc)), {ok, Doc1}; Error -> Error end; _ -> Boundary = couchbeam_uuids:random(), %% for now couchdb can't received chunked multipart stream %% so we have to calculate the content-length. It also means %% that we need to know the size of each attachments. (Which %% should be expected). {CLen, JsonDoc, Doc2} = couchbeam_httpc:len_doc_to_mp_stream(Atts, Boundary, Doc), CType = <<"multipart/related; boundary=\"", Boundary/binary, "\"" >>, Headers = [{<<"Content-Type">>, CType}, {<<"Content-Length">>, hackney_bstr:to_binary(CLen)}], case couchbeam_httpc:request(put, Url, Headers, stream, Opts) of {ok, Ref} -> couchbeam_httpc:send_mp_doc(Atts, Ref, Boundary, JsonDoc, Doc2); Error -> Error end end. %% @doc delete a document %% @equiv delete_doc(Db, Doc, []) delete_doc(Db, Doc) -> delete_doc(Db, Doc, []). %% @doc delete a document %% if you want to make sure the doc it emptied on delete, use the option %% {empty_on_delete, true} or pass a doc with just _id and _rev %% members. -spec delete_doc(db(), map(), list()) -> {ok, list()} | {error, term()}. delete_doc(Db, Doc, Options) -> delete_docs(Db, [Doc], Options). %% @doc delete a list of documents %% @equiv delete_docs(Db, Docs, []) delete_docs(Db, Docs) -> delete_docs(Db, Docs, []). %% @doc delete a list of documents %% if you want to make sure the doc it emptied on delete, use the option %% {empty_on_delete, true} or pass a doc with just _id and _rev %% members. -spec delete_docs(db(), [map()], list()) -> {ok, list()} | {error, term()}. delete_docs(Db, Docs, Options) -> Empty = couchbeam_util:get_value("empty_on_delete", Options, false), {FinalDocs, FinalOptions} = case Empty of true -> Docs1 = lists:map(fun(Doc)-> #{<<"_id">> => couchbeam_doc:get_id(Doc), <<"_rev">> => couchbeam_doc:get_rev(Doc), <<"_deleted">> => true} end, Docs), {Docs1, proplists:delete("all_or_nothing", Options)}; _ -> Docs1 = lists:map(fun(Doc)-> maps:put(<<"_deleted">>, true, Doc) end, Docs), {Docs1, Options} end, save_docs(Db, FinalDocs, FinalOptions). %% @doc save a list of documents %% @equiv save_docs(Db, Docs, []) save_docs(Db, Docs) -> save_docs(Db, Docs, []). %% @doc save a list of documents -spec save_docs(db(), [map()], list()) -> {ok, list()} | {error, term()}. save_docs(#db{server=Server, options=Opts}=Db, Docs, Options) -> Docs1 = [maybe_docid(Server, Doc) || Doc <- Docs], Options1 = couchbeam_util:parse_options(Options), DocOptions = [ {list_to_binary(K), V} || {K, V} <- Options1, (K =:= "all_or_nothing" orelse K =:= "new_edits") andalso is_boolean(V) ], Options2 = [ {K, V} || {K, V} <- Options1, K =/= "all_or_nothing" andalso K =/= "new_edits" ], DocOptionsMap = maps:from_list(DocOptions), Body = couchbeam_ejson:encode(maps:put(<<"docs">>, Docs1, DocOptionsMap)), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), <<"_bulk_docs">>], Options2), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(post, Url, Headers, Body, Opts, [201]) of {ok, _, _, Ref} -> {ok, couchbeam_httpc:json_body(Ref)}; Error -> Error end. %% @doc duplicate a document using the doc API copy_doc(#db{server=Server}=Db, Doc) -> [DocId] = get_uuid(Server), copy_doc(Db, Doc, DocId). %% @doc copy a doc to a destination. If the destination exist it will %% use the last revision, in other case a new doc is created with the %% the current doc revision. copy_doc(Db, Doc, Dest) when is_binary(Dest) -> Destination = case open_doc(Db, Dest) of {ok, DestDoc} -> Rev = couchbeam_doc:get_rev(DestDoc), {Dest, Rev}; _ -> {Dest, <<>>} end, do_copy(Db, Doc, Destination); copy_doc(Db, Doc, Props) when is_map(Props) -> DocId = maps:get(<<"_id">>, Props), Rev = maps:get(<<"_rev">>, Props, <<>>), do_copy(Db, Doc, {DocId, Rev}). do_copy(Db, Doc, Destination) when is_map(Doc) -> case maps:get(<<"_id">>, Doc, undefined) of undefined -> {error, invalid_source}; DocId -> DocRev = maps:get(<<"_rev">>, Doc, nil), do_copy(Db, {DocId, DocRev}, Destination) end; do_copy(Db, DocId, Destination) when is_binary(DocId) -> do_copy(Db, {DocId, nil}, Destination); do_copy(#db{server=Server, options=Opts}=Db, {DocId, DocRev}, {DestId, DestRev}) -> Destination = case DestRev of <<>> -> DestId; _ -> << DestId/binary, "?rev=", DestRev/binary >> end, Headers = [{<<"Destination">>, Destination}], {Headers1, Params} = case {DocRev, DestRev} of {nil, _} -> {Headers, []}; {_, <<>>} -> {[{<<"If-Match">>, DocRev} | Headers], []}; {_, _} -> {Headers, [{<<"rev">>, DocRev}]} end, Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId), Params), case couchbeam_httpc:db_request(copy, Url, Headers1, <<>>, Opts, [201]) of {ok, _, _, Ref} -> JsonProp = couchbeam_httpc:json_body(Ref), NewRev = maps:get(<<"rev">>, JsonProp), NewDocId = maps:get(<<"id">>, JsonProp), {ok, NewDocId, NewRev}; Error -> Error end. %% @doc get the last revision of the document lookup_doc_rev(Db, DocId) -> lookup_doc_rev(Db, DocId, []). lookup_doc_rev(#db{server=Server, options=Opts}=Db, DocId, Params) -> DocId1 = couchbeam_util:encode_docid(DocId), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), couchbeam_httpc:doc_url(Db, DocId1), Params), case couchbeam_httpc:db_request(head, Url, [], <<>>, Opts, [200]) of {ok, _, Headers} -> HeadersDict = hackney_headers:new(Headers), Rev = re:replace(hackney_headers:get_value(<<"etag">>, HeadersDict), <<"\"">>, <<>>, [global, {return, binary}]), {ok, Rev}; Error -> Error end. %% @doc fetch a document attachment %% @equiv fetch_attachment(Db, DocId, Name, []) fetch_attachment(Db, DocId, Name) -> fetch_attachment(Db, DocId, Name, []). %% @doc fetch a document attachment %% Options are %% %% -spec fetch_attachment(db(), list() | binary(), list() | binary(), list()) -> {ok, binary()}| {ok, atom()} |{error, term()}. fetch_attachment(#db{server=Server, options=Opts}=Db, DocId, Name, Options0) -> {Stream, Options} = case couchbeam_util:get_value(stream, Options0) of undefined -> {false, Options0}; true -> {true, proplists:delete(stream, Options0)}; _ -> {false, proplists:delete(stream, Options0)} end, Options1 = couchbeam_util:parse_options(Options), %% custom headers. Allows us to manage Range. {Options2, Headers} = case couchbeam_util:get_value("headers", Options1) of undefined -> {Options1, []}; Headers1 -> {proplists:delete("headers", Options1), Headers1} end, DocId1 = couchbeam_util:encode_docid(DocId), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), DocId1, Name], Options2), case hackney:get(Url, Headers, <<>>, Opts) of {ok, 200, _, Ref} when Stream /= true -> hackney:body(Ref); {ok, 200, _, Ref} -> {ok, Ref}; {ok, 404, _, Ref} -> hackney:skip_body(Ref), {error, not_found}; {ok, Status, Headers, Ref} -> {ok, Body} = hackney:body(Ref), {error, {bad_response, {Status, Headers, Body}}}; Error -> Error end. %% @doc fetch an attachment chunk. %% Use this function when you pass the `stream' option to the %% `couchbeam:fetch_attachment/4' function. %% This function return the following response: %%
%%
done
%%
You got all the attachment
%%
{ok, binary()}
%%
Part of the attachment
%%
{error, term()}
%%
n error occurred
%%
%% -spec stream_attachment(pid()) -> {ok, binary()} | done | {error, term()}. stream_attachment(Ref) -> hackney:stream_body(Ref). %% @doc Start streaming an attachment. Returns a reference that can be %% passed to stream_attachment/1 to receive chunks. %% @equiv fetch_attachment(Db, DocId, Name, [stream]) -spec stream_attachment(db(), binary(), binary()) -> {ok, pid()} | {error, term()}. stream_attachment(Db, DocId, Name) -> fetch_attachment(Db, DocId, Name, [stream]). %% @doc put an attachment %% @equiv put_attachment(Db, DocId, Name, Body, []) put_attachment(Db, DocId, Name, Body)-> put_attachment(Db, DocId, Name, Body, []). %% @doc put an attachment -spec put_attachment(db(), binary() | string(), binary() | string(), iodata() | fun(), list()) -> {ok, map()} | {error, term()}. put_attachment(#db{server=Server, options=Opts}=Db, DocId, Name, Body, Options) -> QueryArgs = case couchbeam_util:get_value(rev, Options) of undefined -> []; Rev -> [{<<"rev">>, couchbeam_util:to_binary(Rev)}] end, Headers = couchbeam_util:get_value(headers, Options, []), FinalHeaders = lists:foldl(fun(Option, Acc) -> case Option of {content_length, V} -> V1 = couchbeam_util:to_binary(V), [{<<"Content-Length">>, V1}|Acc]; {content_type, V} -> V1 = couchbeam_util:to_binary(V), [{<<"Content-Type">>, V1}|Acc]; _ -> Acc end end, Headers, Options), DocId1 = couchbeam_util:encode_docid(DocId), AttName = couchbeam_util:encode_att_name(Name), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), DocId1, AttName], QueryArgs), case couchbeam_httpc:db_request(put, Url, FinalHeaders, Body, Opts, [201, 202]) of {ok, _, _, Ref} -> JsonBody = couchbeam_httpc:json_body(Ref), #{<<"ok">> := true} = JsonBody, {ok, maps:remove(<<"ok">>, JsonBody)}; {ok, Ref} -> {ok, Ref}; Error -> Error end. %% @doc send an attachment chunk %% Msg could be Data, eof to stop sending. send_attachment(Ref, eof) -> case hackney:finish_send_body(Ref) of ok -> Resp = hackney:start_response(Ref), couchbeam_httpc:reply_att(Resp); Error -> Error end; send_attachment(Ref, Msg) -> Reply = hackney:send_body(Ref, Msg), couchbeam_httpc:reply_att(Reply). %% @doc delete a document attachment %% @equiv delete_attachment(Db, Doc, Name, []) delete_attachment(Db, Doc, Name) -> delete_attachment(Db, Doc, Name, []). %% @doc delete a document attachment -spec delete_attachment(db(), map() | binary() | string(), binary() | string(), list()) -> {ok, map()} | {error, term()}. delete_attachment(#db{server=Server, options=Opts}=Db, DocOrDocId, Name, Options) -> Options1 = couchbeam_util:parse_options(Options), {Rev, DocId} = case DocOrDocId of Props when is_map(Props) -> Rev1 = maps:get(<<"_rev">>, Props, undefined), DocId1 = maps:get(<<"_id">>, Props), {Rev1, DocId1}; DocId1 -> Rev1 = couchbeam_util:get_value("rev", Options1), {Rev1, DocId1} end, case Rev of undefined -> {error, rev_undefined}; _ -> Options2 = case couchbeam_util:get_value("rev", Options1) of undefined -> [{<<"rev">>, couchbeam_util:to_binary(Rev)}|Options1]; _ -> Options1 end, Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), DocId, Name], Options2), case couchbeam_httpc:db_request(delete, Url, [], <<>>, Opts, [200]) of {ok, _, _, Ref} -> Map = couchbeam_httpc:json_body(Ref), #{<<"ok">> := true} = Map, {ok, maps:remove(<<"ok">>, Map)}; Error -> Error end end. %% @doc commit all docs in memory %% @equiv ensure_full_commit(Db, []) ensure_full_commit(Db) -> ensure_full_commit(Db, []). %% @doc commit all docs in memory -spec ensure_full_commit(Db::db(), Options::list()) -> {ok, InstancestartTime :: binary()} | {error, term()}. ensure_full_commit(#db{server=Server, options=Opts}=Db, Options) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), <<"_ensure_full_commit">>], Options), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [201]) of {ok, _, _, Ref} -> Props = couchbeam_httpc:json_body(Ref), {ok, maps:get(<<"instance_start_time">>, Props)}; Error -> Error end. %% @doc Compaction compresses the database file by removing unused %% sections created during updates. %% See [http://wiki.apache.org/couchdb/Compaction] for more informations -spec compact(db()) -> ok | {error, term()}. compact(#db{server=Server, options=Opts}=Db) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), <<"_compact">>], []), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [202]) of {ok, _, _, Ref} -> hackney:skip_body(Ref), ok; Error -> Error end. %% @doc Like compact/1 but this compacts the view index from the %% current version of the design document. %% See [http://wiki.apache.org/couchdb/Compaction#View_compaction] for more informations -spec compact(db(), binary() | string()) -> ok | {error, term()}. compact(#db{server=Server, options=Opts}=Db, DesignName) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), <<"_compact">>, DesignName], []), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(post, Url, Headers, <<>>, Opts, [202]) of {ok, _, _, Ref} -> hackney:skip_body(Ref), ok; Error -> Error end. %% @doc get missing revisions -spec get_missing_revs(#db{}, [{binary(), [binary()]}]) -> {ok, [{DocId :: binary(), [MissingRev :: binary()], [ PossibleAncestor :: binary()]}]} | {error, term()}. get_missing_revs(#db{server=Server, options=Opts}=Db, IdRevs) -> %% Convert [{DocId, [Revs]}] to map IdRevsMap = maps:from_list(IdRevs), Json = couchbeam_ejson:encode(IdRevsMap), Url = hackney_url:make_url(couchbeam_httpc:server_url(Server), [couchbeam_httpc:db_url(Db), <<"_revs_diff">>], []), Headers = [{<<"Content-Type">>, <<"application/json">>}], case couchbeam_httpc:db_request(post, Url, Headers, Json, Opts, [200]) of {ok, _, _, Ref} -> Props = couchbeam_httpc:json_body(Ref), Res = maps:fold(fun(Id, Result, Acc) -> MissingRevs = maps:get(<<"missing">>, Result, []), PossibleAncestors = maps:get(<<"possible_ancestors">>, Result, []), [{Id, MissingRevs, PossibleAncestors} | Acc] end, [], Props), {ok, Res}; Error -> Error end. -spec find(Db::db(), Selector::doc(), Params::list()) -> {ok, Doc::doc()} | {error, any()}. find(#db{server=Server, options=Opts}=Db, Selector, Params) -> Url = hackney_url:make_url(couchbeam_httpc:server_url(Server) ,[couchbeam_httpc:db_url(Db), <<"_find">>] ,[] ), Headers = [{<<"content-type">>, <<"application/json">>} ,{<<"accept">>, <<"application/json">>} ], BodyJson = maps:put(<<"selector">>, Selector, maps:from_list(Params)), case couchbeam_httpc:db_request(post, Url, Headers, couchbeam_ejson:encode(BodyJson), Opts, [200, 201]) of {ok, _, _, Ref} -> {ok, couchbeam_httpc:json_body(Ref)}; Error -> Error end. %% -------------------------------------------------------------------- %% private functions. %% -------------------------------------------------------------------- %% add missing docid to a list of documents if needed maybe_docid(Server, Doc) when is_map(Doc) -> case maps:get(<<"_id">>, Doc, undefined) of undefined -> [DocId] = get_uuid(Server), maps:put(<<"_id">>, DocId, Doc); _DocId -> Doc end; maybe_docid(_Server, Doc) -> Doc. %% format replication endpoint for source or target %% supports Db record, simple URI strings and complex document structures format_replication_endpoint(Props) when is_map(Props) -> Props; format_replication_endpoint(<<"http://", _/binary>> = Endpoint)-> couchbeam_util:to_binary(Endpoint); format_replication_endpoint(<<"https://", _/binary>> = Endpoint)-> couchbeam_util:to_binary(Endpoint); format_replication_endpoint(#db{server=#server{url=BaseUrl, options=Options}, name=DbName}) -> case proplists:get_value(basic_auth, Options) of {User, Password} -> ServerBaseUrl = hackney_url:parse_url(BaseUrl), ServerUrl = hackney_url:unparse_url(ServerBaseUrl#hackney_url{user=User, password=Password}), hackney_url:make_url(ServerUrl, [DbName], []); _ -> hackney_url:make_url(BaseUrl, [DbName], []) end. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). basic_test() -> couchbeam_mocks:setup(), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock hackney:get for server_info ServerInfoResponse = #{<<"couchdb">> => <<"Welcome">>, <<"version">> => <<"3.3.0">>, <<"uuid">> => <<"test-uuid">>}, Ref = make_ref(), meck:expect(hackney, get, fun(_Url, _Headers, _Body, _Opts) -> couchbeam_mocks:set_body(Ref, ServerInfoResponse), {ok, 200, [], Ref} end), Server = couchbeam:server_connection(), {ok, Data} = couchbeam:server_info(Server), ?assertEqual(<<"Welcome">>, maps:get(<<"couchdb">>, Data)), ?assertEqual(<<"3.3.0">>, maps:get(<<"version">>, Data)), ok after couchbeam_mocks:teardown() end. db_test() -> couchbeam_mocks:setup(), %% Track created databases put(mock_dbs, []), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock db_request for database operations meck:expect(couchbeam_httpc, db_request, fun(Method, Url, _Headers, _Body, _Options, _Expect) -> db_mock_handle_request(Method, Url) end), %% Mock request for delete_db and open_or_create_db (use request directly) meck:expect(couchbeam_httpc, request, fun(delete, Url, _Headers, _Body, _Options) -> DbName = extract_mock_dbname(Url), Dbs = get(mock_dbs), case lists:member(DbName, Dbs) of true -> put(mock_dbs, lists:delete(DbName, Dbs)), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true}), {ok, 200, [], Ref}; false -> {ok, 404, [], make_ref()} end; (get, Url, _Headers, _Body, _Options) -> %% open_or_create_db uses GET to check if db exists DbName = extract_mock_dbname(Url), Dbs = get(mock_dbs), case lists:member(DbName, Dbs) of true -> Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"db_name">> => DbName}), {ok, 200, [], Ref}; false -> {ok, 404, [], make_ref()} end; (Method, Url, Headers, Body, Options) -> meck:passthrough([Method, Url, Headers, Body, Options]) end), Server = couchbeam:server_connection(), %% Test db creation ?assertMatch({ok, _}, couchbeam:create_db(Server, "couchbeam_testdb")), ?assertEqual({error, db_exists}, couchbeam:create_db(Server, "couchbeam_testdb")), ?assertMatch({ok, _}, couchbeam:create_db(Server, "couchbeam_testdb2")), %% Test all_dbs {ok, AllDbs} = couchbeam:all_dbs(Server), ?assert(is_list(AllDbs)), ?assertEqual(true, lists:member(<<"couchbeam_testdb">>, AllDbs)), ?assertEqual(true, lists:member(<<"couchbeam_testdb2">>, AllDbs)), %% Test db_exists ?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb")), ?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb2")), %% Test delete_db ?assertMatch({ok, _}, couchbeam:delete_db(Server, "couchbeam_testdb2")), {ok, AllDbs1} = couchbeam:all_dbs(Server), ?assertEqual(true, lists:member(<<"couchbeam_testdb">>, AllDbs1)), ?assertEqual(false, lists:member(<<"couchbeam_testdb2">>, AllDbs1)), %% Test db_exists after delete ?assertEqual(true, couchbeam:db_exists(Server, "couchbeam_testdb")), ?assertEqual(false, couchbeam:db_exists(Server, "couchbeam_testdb2")), %% Test open_or_create_db ?assertMatch({ok, _}, couchbeam:open_or_create_db(Server, "couchbeam_testdb")), ?assertMatch({ok, _}, couchbeam:open_or_create_db(Server, "couchbeam_testdb2")), ok after erase(mock_dbs), couchbeam_mocks:teardown() end. %% Helper for db_mock_test - handle db_request calls db_mock_handle_request(put, Url) -> DbName = extract_mock_dbname(Url), Dbs = get(mock_dbs), case lists:member(DbName, Dbs) of true -> {error, precondition_failed}; false -> put(mock_dbs, [DbName | Dbs]), {ok, 201, [], make_ref()} end; db_mock_handle_request(head, Url) -> DbName = extract_mock_dbname(Url), Dbs = get(mock_dbs), case lists:member(DbName, Dbs) of true -> {ok, 200, []}; false -> {error, not_found} end; db_mock_handle_request(get, Url) -> case binary:match(Url, <<"_all_dbs">>) of nomatch -> %% db_info request Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"db_name">> => <<"testdb">>}), {ok, 200, [], Ref}; _ -> %% all_dbs request Dbs = get(mock_dbs), Ref = make_ref(), couchbeam_mocks:set_body(Ref, Dbs), {ok, 200, [], Ref} end; db_mock_handle_request(_Method, _Url) -> {ok, 200, [], make_ref()}. %% Extract database name from URL extract_mock_dbname(Url) when is_binary(Url) -> %% URL like "http://127.0.0.1:5984/dbname" or with query params case hackney_url:parse_url(Url) of #hackney_url{path = Path} -> %% Path is like "/dbname" or "/dbname/" PathBin = iolist_to_binary(Path), case binary:split(PathBin, <<"/">>, [global, trim_all]) of [DbName | _] -> DbName; [] -> <<>> end; _ -> <<>> end. basic_doc_test() -> couchbeam_mocks:setup(), %% Track documents: #{DocId => {Doc, Rev}} put(mock_docs, #{}), put(mock_uuid_counter, 0), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock couchbeam_uuids:get_uuids for auto-generated IDs meck:new(couchbeam_uuids, [passthrough, no_link]), meck:expect(couchbeam_uuids, get_uuids, fun(_Server, Count) -> Counter = get(mock_uuid_counter), Uuids = [list_to_binary("auto-uuid-" ++ integer_to_list(Counter + I)) || I <- lists:seq(1, Count)], put(mock_uuid_counter, Counter + Count), Uuids end), %% Mock db_request for document operations meck:expect(couchbeam_httpc, db_request, fun(Method, Url, Headers, Body, _Options, _Expect) -> doc_mock_handle_request(Method, Url, Headers, Body) end), %% Mock request for db operations (create_db uses this via db_mock_test pattern) meck:expect(couchbeam_httpc, request, fun(get, Url, _H, _B, _O) -> %% For open_or_create_db check case binary:match(Url, <<"couchbeam_testdb">>) of nomatch -> {ok, 404, [], make_ref()}; _ -> {ok, 200, [], make_ref()} end; (Method, Url, Headers, Body, Options) -> meck:passthrough([Method, Url, Headers, Body, Options]) end), Server = couchbeam:server_connection(), Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]}, %% Test save_doc with auto-generated ID {ok, Doc} = couchbeam:save_doc(Db, #{<<"test">> => <<"blah">>}), ?assert(is_map(Doc)), %% Test save_doc with specific ID {ok, Props} = couchbeam:save_doc(Db, #{<<"_id">> => <<"test">>, <<"test">> => <<"blah">>}), ?assertEqual(<<"test">>, maps:get(<<"_id">>, Props)), %% Test conflict on duplicate save ?assertEqual({error, conflict}, couchbeam:save_doc(Db, #{<<"_id">> => <<"test">>, <<"test">> => <<"blah">>})), %% Test lookup_doc_rev {ok, Rev} = couchbeam:lookup_doc_rev(Db, "test"), ?assertMatch(<<_/binary>>, Rev), %% Test open_doc {ok, Doc1} = couchbeam:open_doc(Db, <<"test">>), ?assertEqual(Rev, maps:get(<<"_rev">>, Doc1)), ?assertEqual(<<"blah">>, maps:get(<<"test">>, Doc1)), %% Test save and open another doc _ = couchbeam:save_doc(Db, #{<<"_id">> => <<"test2">>, <<"test">> => <<"blah">>}), {ok, Doc2} = couchbeam:open_doc(Db, "test2"), ?assert(is_map(Doc2)), ?assertEqual(true, couchbeam_doc:is_saved(Doc2)), ?assertEqual(<<"test2">>, couchbeam_doc:get_id(Doc2)), %% Test doc_exists ?assertMatch(true, couchbeam:doc_exists(Db, "test2")), %% Test delete_doc ?assertMatch({ok, _}, couchbeam:delete_doc(Db, Doc2)), ?assertEqual({error, not_found}, couchbeam:open_doc(Db, "test2")), ?assertMatch(false, couchbeam:doc_exists(Db, "test2")), %% Test special characters in doc ID Doc3 = #{<<"_id">> => <<"special-chars-test">>}, {ok, _Doc4} = couchbeam:save_doc(Db, Doc3), {ok, Doc5} = couchbeam:open_doc(Db, <<"special-chars-test">>), ?assertEqual(<<"special-chars-test">>, couchbeam_doc:get_id(Doc5)), ok after erase(mock_docs), erase(mock_uuid_counter), catch meck:unload(couchbeam_uuids), couchbeam_mocks:teardown() end. %% Helper for basic_doc_mock_test - handle document requests doc_mock_handle_request(put, Url, _Headers, Body) -> %% save_doc - PUT /db/docid DocId = extract_mock_docid(Url), Docs = get(mock_docs), DocProps = couchbeam_ejson:decode(Body), %% Extract and store inline attachments if present case maps:get(<<"_attachments">>, DocProps, undefined) of undefined -> ok; AttMap when is_map(AttMap) -> Atts = case get(mock_attachments) of undefined -> #{}; A -> A end, NewAtts = maps:fold(fun(AttName, AttProps, Acc) -> case maps:get(<<"data">>, AttProps, undefined) of undefined -> Acc; Base64Data -> Data = base64:decode(Base64Data), maps:put({DocId, AttName}, Data, Acc) end end, Atts, AttMap), put(mock_attachments, NewAtts) end, case maps:is_key(DocId, Docs) of true -> %% Check if update (has _rev) or conflict case maps:get(<<"_rev">>, DocProps, undefined) of undefined -> {error, conflict}; _Rev -> %% Update with new rev NewRev = generate_mock_rev(), UpdatedDoc = maps:merge(maps:remove(<<"_rev">>, maps:remove(<<"_id">>, DocProps)), #{<<"_id">> => DocId, <<"_rev">> => NewRev}), put(mock_docs, maps:put(DocId, {UpdatedDoc, NewRev}, Docs)), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}), {ok, 201, [], Ref} end; false -> %% New document NewRev = generate_mock_rev(), NewDoc = maps:merge(maps:remove(<<"_rev">>, maps:remove(<<"_id">>, DocProps)), #{<<"_id">> => DocId, <<"_rev">> => NewRev}), put(mock_docs, maps:put(DocId, {NewDoc, NewRev}, Docs)), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}), {ok, 201, [], Ref} end; doc_mock_handle_request(get, Url, _Headers, _Body) -> %% open_doc - GET /db/docid DocId = extract_mock_docid(Url), Docs = get(mock_docs), case maps:get(DocId, Docs, undefined) of undefined -> {error, not_found}; {Doc, _Rev} -> Ref = make_ref(), couchbeam_mocks:set_body(Ref, Doc), {ok, 200, [], Ref} end; doc_mock_handle_request(head, Url, _Headers, _Body) -> %% lookup_doc_rev or doc_exists - HEAD /db/docid DocId = extract_mock_docid(Url), Docs = get(mock_docs), case maps:get(DocId, Docs, undefined) of undefined -> {error, not_found}; {_Doc, Rev} -> ETag = <<"\"", Rev/binary, "\"">>, {ok, 200, [{<<"ETag">>, ETag}]} end; doc_mock_handle_request(post, Url, _Headers, Body) -> %% _bulk_docs - POST /db/_bulk_docs case binary:match(Url, <<"_bulk_docs">>) of nomatch -> {ok, 200, [], make_ref()}; _ -> BodyProps = couchbeam_ejson:decode(Body), InputDocs = maps:get(<<"docs">>, BodyProps, []), Results = lists:map(fun(DocProps) -> DocId = maps:get(<<"_id">>, DocProps), IsDeleted = maps:get(<<"_deleted">>, DocProps, false), case IsDeleted of true -> put(mock_docs, maps:remove(DocId, get(mock_docs))), #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => generate_mock_rev()}; false -> NewRev = generate_mock_rev(), NewDoc = maps:merge(maps:without([<<"_deleted">>, <<"_rev">>, <<"_id">>], DocProps), #{<<"_id">> => DocId, <<"_rev">> => NewRev}), put(mock_docs, maps:put(DocId, {NewDoc, NewRev}, get(mock_docs))), #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev} end end, InputDocs), Ref = make_ref(), couchbeam_mocks:set_body(Ref, Results), {ok, 201, [], Ref} end; doc_mock_handle_request(copy, Url, Headers, _Body) -> %% copy_doc - COPY /db/docid with Destination header DocId = extract_mock_docid(Url), Docs = get(mock_docs), case maps:get(DocId, Docs, undefined) of undefined -> {error, not_found}; {SourceDoc, _SourceRev} -> %% Get destination from headers DestId = case proplists:get_value(<<"Destination">>, Headers) of undefined -> generate_mock_uuid(); DestHeader -> %% Strip ?rev= if present case binary:split(DestHeader, <<"?">>) of [Id | _] -> Id; _ -> DestHeader end end, %% Create new doc with destination ID NewRev = generate_mock_rev(), NewDoc = maps:merge(maps:without([<<"_rev">>, <<"_id">>], SourceDoc), #{<<"_id">> => DestId, <<"_rev">> => NewRev}), put(mock_docs, maps:put(DestId, {NewDoc, NewRev}, get(mock_docs))), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DestId, <<"rev">> => NewRev}), {ok, 201, [], Ref} end; doc_mock_handle_request(_Method, _Url, _Headers, _Body) -> {ok, 200, [], make_ref()}. %% Extract document ID from URL like "http://127.0.0.1:5984/db/docid" extract_mock_docid(Url) when is_binary(Url) -> case hackney_url:parse_url(Url) of #hackney_url{path = Path} -> PathBin = iolist_to_binary(Path), case binary:split(PathBin, <<"/">>, [global, trim_all]) of [_Db, DocId | _] -> DocId; _ -> <<>> end; _ -> <<>> end. %% Generate a mock revision generate_mock_rev() -> Counter = get(mock_uuid_counter), put(mock_uuid_counter, Counter + 1), iolist_to_binary(["1-mock-rev-", integer_to_list(Counter)]). %% Generate a mock UUID generate_mock_uuid() -> Counter = get(mock_uuid_counter), put(mock_uuid_counter, Counter + 1), iolist_to_binary(["mock-uuid-", integer_to_list(Counter)]). bulk_doc_test() -> couchbeam_mocks:setup(), put(mock_docs, #{}), put(mock_uuid_counter, 0), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock db_request for document operations (reuse doc_mock_handle_request) meck:expect(couchbeam_httpc, db_request, fun(Method, Url, Headers, Body, _Options, _Expect) -> doc_mock_handle_request(Method, Url, Headers, Body) end), Server = couchbeam:server_connection(), Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]}, %% Test bulk save Doc1 = #{<<"_id">> => <<"a">>}, Doc2 = #{<<"_id">> => <<"b">>}, {ok, [Props1, Props2]} = couchbeam:save_docs(Db, [Doc1, Doc2]), ?assertEqual(<<"a">>, maps:get(<<"id">>, Props1)), ?assertEqual(<<"b">>, maps:get(<<"id">>, Props2)), %% Verify docs exist ?assertMatch(true, couchbeam:doc_exists(Db, "a")), ?assertMatch(true, couchbeam:doc_exists(Db, "b")), %% Test bulk delete {ok, Doc3} = couchbeam:open_doc(Db, <<"a">>), {ok, Doc4} = couchbeam:open_doc(Db, <<"b">>), couchbeam:delete_docs(Db, [Doc3, Doc4]), ?assertEqual({error, not_found}, couchbeam:open_doc(Db, <<"a">>)), ?assertEqual({error, not_found}, couchbeam:open_doc(Db, <<"b">>)), ok after erase(mock_docs), erase(mock_uuid_counter), couchbeam_mocks:teardown() end. copy_doc_test() -> couchbeam_mocks:setup(), put(mock_docs, #{}), put(mock_uuid_counter, 0), %% Mock UUIDs for copy_doc without destination meck:new(couchbeam_uuids, [passthrough, no_link]), meck:expect(couchbeam_uuids, get_uuids, fun(_Server, _Count) -> [generate_mock_uuid()] end), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock db_request for document operations meck:expect(couchbeam_httpc, db_request, fun(Method, Url, Headers, Body, _Options, _Expect) -> doc_mock_handle_request(Method, Url, Headers, Body) end), Server = couchbeam:server_connection(), Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]}, %% Create a document to copy Doc = #{<<"_id">> => <<"original">>, <<"test">> => 1}, {ok, SavedDoc} = couchbeam:save_doc(Db, Doc), %% Test copy without destination (generates UUID) {ok, CopyId, _Rev} = couchbeam:copy_doc(Db, SavedDoc), {ok, Doc2} = couchbeam:open_doc(Db, CopyId), ?assertEqual(1, couchbeam_doc:get_value(<<"test">>, Doc2)), %% Test copy with specific destination {ok, CopyId1, _} = couchbeam:copy_doc(Db, SavedDoc, <<"test_copy">>), ?assertEqual(<<"test_copy">>, CopyId1), {ok, Doc4} = couchbeam:open_doc(Db, CopyId1), ?assertEqual(1, couchbeam_doc:get_value(<<"test">>, Doc4)), ok after erase(mock_docs), erase(mock_uuid_counter), catch meck:unload(couchbeam_uuids), couchbeam_mocks:teardown() end. attachments_test() -> couchbeam_mocks:setup(), put(mock_docs, #{}), put(mock_attachments, #{}), put(mock_uuid_counter, 0), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock db_request for document and attachment operations meck:expect(couchbeam_httpc, db_request, fun(Method, Url, Headers, Body, _Options, _Expect) -> att_mock_handle_request(Method, Url, Headers, Body) end), %% Mock hackney:get for fetch_attachment meck:expect(hackney, get, fun(Url, _Headers, _Body, _Opts) -> case extract_mock_attachment_info(Url) of {DocId, AttName} -> Atts = get(mock_attachments), case maps:get({DocId, AttName}, Atts, undefined) of undefined -> {ok, 404, [], make_ref()}; AttBody -> Ref = make_ref(), couchbeam_mocks:set_body(Ref, AttBody), {ok, 200, [], Ref} end; _ -> {ok, 404, [], make_ref()} end end), Server = couchbeam:server_connection(), Db = #db{server=Server, name = <<"couchbeam_testdb">>, options=[]}, %% Create a document Doc = #{<<"_id">> => <<"test">>}, {ok, Doc1} = couchbeam:save_doc(Db, Doc), RevDoc1 = couchbeam_doc:get_rev(Doc1), %% Put attachment {ok, Res} = couchbeam:put_attachment(Db, "test", "test.txt", <<"hello">>, [{rev, RevDoc1}]), RevDoc11 = maps:get(<<"rev">>, Res), ?assertNot(RevDoc1 =:= RevDoc11), %% Fetch attachment {ok, Attachment} = couchbeam:fetch_attachment(Db, "test", "test.txt"), ?assertEqual(<<"hello">>, Attachment), %% Open doc and delete attachment {ok, Doc2} = couchbeam:open_doc(Db, "test"), ?assertMatch({ok, _}, couchbeam:delete_attachment(Db, Doc2, "test.txt")), %% Verify attachment is deleted ?assertEqual({error, not_found}, couchbeam:fetch_attachment(Db, "test", "test.txt")), %% Test inline attachments Doc3 = #{<<"_id">> => <<"test2">>}, Doc4 = couchbeam_attachments:add_inline(Doc3, "test", "test.txt"), {ok, _} = couchbeam:save_doc(Db, Doc4), {ok, Attachment1} = couchbeam:fetch_attachment(Db, "test2", "test.txt"), ?assertEqual(<<"test">>, Attachment1), ok after erase(mock_docs), erase(mock_attachments), erase(mock_uuid_counter), couchbeam_mocks:teardown() end. %% Helper for attachments mock test att_mock_handle_request(put, Url, _Headers, Body) -> case is_attachment_url(Url) of {true, DocId, AttName} -> %% PUT attachment Atts = get(mock_attachments), BodyBin = if is_binary(Body) -> Body; true -> iolist_to_binary(Body) end, put(mock_attachments, maps:put({DocId, AttName}, BodyBin, Atts)), NewRev = generate_mock_rev(), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}), {ok, 201, [], Ref}; false -> %% Regular doc PUT doc_mock_handle_request(put, Url, [], Body) end; att_mock_handle_request(delete, Url, _Headers, _Body) -> case is_attachment_url(Url) of {true, DocId, AttName} -> Atts = get(mock_attachments), put(mock_attachments, maps:remove({DocId, AttName}, Atts)), NewRev = generate_mock_rev(), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"rev">> => NewRev}), {ok, 200, [], Ref}; false -> doc_mock_handle_request(delete, Url, [], <<>>) end; att_mock_handle_request(Method, Url, Headers, Body) -> doc_mock_handle_request(Method, Url, Headers, Body). %% Check if URL is for an attachment (has 3 path parts: db/doc/att) is_attachment_url(Url) when is_binary(Url) -> case hackney_url:parse_url(Url) of #hackney_url{path = Path} -> PathBin = iolist_to_binary(Path), case binary:split(PathBin, <<"/">>, [global, trim_all]) of [_Db, DocId, AttName | _] -> %% Check it's not a special endpoint case binary:match(AttName, <<"_">>) of {0, _} -> false; % _all_docs, _bulk_docs, etc. _ -> {true, DocId, AttName} end; _ -> false end; _ -> false end. %% Extract doc id and attachment name from URL extract_mock_attachment_info(Url) when is_binary(Url) -> case hackney_url:parse_url(Url) of #hackney_url{path = Path} -> PathBin = iolist_to_binary(Path), case binary:split(PathBin, <<"/">>, [global, trim_all]) of [_Db, DocId, AttName | _] -> {DocId, AttName}; _ -> undefined end; _ -> undefined end. %% Replicate test - tests replication document creation and related operations replicate_test() -> couchbeam_mocks:setup(), put(mock_docs, #{}), put(mock_uuid_counter, 0), %% Mock UUIDs for replication doc meck:new(couchbeam_uuids, [passthrough, no_link]), meck:expect(couchbeam_uuids, get_uuids, fun(_Server, _Count) -> [generate_mock_uuid()] end), try {ok, _} = application:ensure_all_started(couchbeam), %% Mock db_request for replication operations meck:expect(couchbeam_httpc, db_request, fun(Method, Url, _H, Body, _O, _E) -> replicate_mock_handle(Method, Url, Body) end), Server = couchbeam:server_connection(), Db = #db{server=Server, name = <<"source">>, options=[]}, Db2 = #db{server=Server, name = <<"target">>, options=[]}, %% Test replicate - creates a replication document {ok, RepDoc} = couchbeam:replicate(Server, Db, Db2, [{<<"continuous">>, true}]), ?assert(couchbeam_doc:is_saved(RepDoc)), %% Test get_missing_revs {ok, Missing} = couchbeam:get_missing_revs(Db2, [{<<"doc1">>, [<<"1-abc">>, <<"2-def">>]}]), ?assertEqual([{<<"doc1">>, [<<"1-abc">>, <<"2-def">>], []}], Missing), %% Test ensure_full_commit {ok, StartTime} = couchbeam:ensure_full_commit(Db), ?assert(is_binary(StartTime)), ok after erase(mock_docs), erase(mock_uuid_counter), catch meck:unload(couchbeam_uuids), couchbeam_mocks:teardown() end. %% Handle replication-related mock requests replicate_mock_handle(put, Url, Body) -> UrlBin = iolist_to_binary(Url), case binary:match(UrlBin, <<"_replicator">>) of nomatch -> doc_mock_handle_request(put, Url, [], Body); _ -> %% Save replication doc to _replicator DocId = generate_mock_uuid(), NewRev = generate_mock_rev(), Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"id">> => DocId, <<"rev">> => NewRev}), {ok, 201, [], Ref} end; replicate_mock_handle(post, Url, Body) -> UrlBin = iolist_to_binary(Url), case binary:match(UrlBin, <<"_revs_diff">>) of nomatch -> case binary:match(UrlBin, <<"_ensure_full_commit">>) of nomatch -> {ok, 200, [], make_ref()}; _ -> %% ensure_full_commit Ref = make_ref(), couchbeam_mocks:set_body(Ref, #{<<"ok">> => true, <<"instance_start_time">> => <<"0">>}), {ok, 201, [], Ref} end; _ -> %% _revs_diff - return all revisions as missing IdRevs = couchbeam_ejson:decode(Body), Result = maps:fold(fun(DocId, Revs, Acc) -> maps:put(DocId, #{<<"missing">> => Revs}, Acc) end, #{}, IdRevs), Ref = make_ref(), couchbeam_mocks:set_body(Ref, Result), {ok, 200, [], Ref} end; replicate_mock_handle(Method, Url, Body) -> doc_mock_handle_request(Method, Url, [], Body). -endif.