Skip to content

Commit 055c8ac

Browse files
authored
feat(container): Add Mongo DB container (#251)
* feat(container): Add Mongo DB container * Remove original and modify by in MongoTestContainers * test(mongo): use mongodb_driver for reachability roundtrip tests * chore(mongo): Upgrade MongoDriverVersion to 1.6.2
1 parent c5056ea commit 055c8ac

File tree

4 files changed

+458
-0
lines changed

4 files changed

+458
-0
lines changed

lib/container/mongo_container.ex

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# SPDX-License-Identifier: MIT
2+
defmodule Testcontainers.MongoContainer do
3+
@behaviour Testcontainers.DatabaseBehaviour
4+
@moduledoc """
5+
Provides functionality for creating and managing Mongo container configurations.
6+
"""
7+
8+
alias Testcontainers.CommandWaitStrategy
9+
alias Testcontainers.Container
10+
alias Testcontainers.ContainerBuilder
11+
alias Testcontainers.MongoContainer
12+
13+
import Testcontainers.Container, only: [is_valid_image: 1]
14+
15+
@default_image "mongo"
16+
@default_tag "latest"
17+
@default_image_with_tag "#{@default_image}:#{@default_tag}"
18+
@default_user "test"
19+
@default_password "test"
20+
@default_database "test"
21+
@default_port 27017
22+
@default_wait_timeout 180_000
23+
24+
@enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
25+
defstruct [
26+
:image,
27+
:user,
28+
:password,
29+
:database,
30+
:port,
31+
:wait_timeout,
32+
:persistent_volume,
33+
check_image: @default_image,
34+
reuse: false
35+
]
36+
37+
@doc """
38+
Creates a new `MongoContainer` struct with default configurations.
39+
"""
40+
def new,
41+
do: %__MODULE__{
42+
image: @default_image_with_tag,
43+
user: @default_user,
44+
password: @default_password,
45+
database: @default_database,
46+
port: @default_port,
47+
wait_timeout: @default_wait_timeout,
48+
persistent_volume: nil
49+
}
50+
51+
@doc """
52+
Overrides the default image used for the Mongo container.
53+
54+
## Examples
55+
56+
iex> config = MongoContainer.new()
57+
iex> new_config = MongoContainer.with_image(config, "mongo:5")
58+
iex> new_config.image
59+
"mongo:5"
60+
"""
61+
def with_image(%__MODULE__{} = config, image) when is_binary(image) do
62+
%{config | image: image}
63+
end
64+
65+
@doc """
66+
Alias for `with_user/2`, matching Mongo naming in other implementations.
67+
"""
68+
def with_username(%__MODULE__{} = config, username) when is_binary(username) do
69+
with_user(config, username)
70+
end
71+
72+
@doc """
73+
Overrides the default user used for the Mongo container.
74+
75+
## Examples
76+
77+
iex> config = MongoContainer.new()
78+
iex> new_config = MongoContainer.with_user(config, "another-user")
79+
iex> new_config.user
80+
"another-user"
81+
"""
82+
def with_user(%__MODULE__{} = config, user) when is_binary(user) do
83+
%{config | user: user}
84+
end
85+
86+
@doc """
87+
Overrides the default password used for the Mongo container.
88+
89+
## Examples
90+
91+
iex> config = MongoContainer.new()
92+
iex> new_config = MongoContainer.with_password(config, "another-password")
93+
iex> new_config.password
94+
"another-password"
95+
"""
96+
def with_password(%__MODULE__{} = config, password) when is_binary(password) do
97+
%{config | password: password}
98+
end
99+
100+
@doc """
101+
Overrides the default database used for the Mongo container.
102+
103+
## Examples
104+
105+
iex> config = MongoContainer.new()
106+
iex> new_config = MongoContainer.with_database(config, "another-database")
107+
iex> new_config.database
108+
"another-database"
109+
"""
110+
def with_database(%__MODULE__{} = config, database) when is_binary(database) do
111+
%{config | database: database}
112+
end
113+
114+
@doc """
115+
Overrides the default port used for the Mongo container.
116+
117+
Note: this will not change what port the docker container is listening to internally.
118+
119+
## Examples
120+
121+
iex> config = MongoContainer.new()
122+
iex> new_config = MongoContainer.with_port(config, 27018)
123+
iex> new_config.port
124+
27018
125+
"""
126+
def with_port(%__MODULE__{} = config, port) when is_integer(port) or is_tuple(port) do
127+
%{config | port: port}
128+
end
129+
130+
@doc """
131+
mounts persistent volume in Mongo data path used for the Mongo container.
132+
133+
## Examples
134+
135+
iex> config = MongoContainer.new()
136+
iex> config = MongoContainer.with_persistent_volume(config, "data_volume")
137+
iex> config.persistent_volume
138+
"data_volume"
139+
"""
140+
def with_persistent_volume(%__MODULE__{} = config, persistent_volume)
141+
when is_binary(persistent_volume) do
142+
%{config | persistent_volume: persistent_volume}
143+
end
144+
145+
@doc """
146+
Mounts the default wait timeout used for the Mongo container.
147+
148+
Note: this timeout will be used for each individual wait strategy.
149+
150+
## Examples
151+
152+
iex> config = MongoContainer.new()
153+
iex> new_config = MongoContainer.with_wait_timeout(config, 8000)
154+
iex> new_config.wait_timeout
155+
8000
156+
"""
157+
def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do
158+
%{config | wait_timeout: wait_timeout}
159+
end
160+
161+
@doc """
162+
Set the regular expression to check the image validity.
163+
"""
164+
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
165+
%__MODULE__{config | check_image: check_image}
166+
end
167+
168+
@doc """
169+
Set the reuse flag to reuse the container if it is already running.
170+
"""
171+
def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do
172+
%__MODULE__{config | reuse: reuse}
173+
end
174+
175+
@doc """
176+
Retrieves the default exposed port for the Mongo container.
177+
"""
178+
def default_port, do: @default_port
179+
180+
@doc """
181+
Retrieves the default Docker image for the Mongo container.
182+
"""
183+
def default_image, do: @default_image
184+
185+
@doc """
186+
Retrieves the default Docker image including tag for the Mongo container.
187+
"""
188+
def default_image_with_tag, do: @default_image <> ":" <> @default_tag
189+
190+
@doc """
191+
Returns the port on the _host machine_ where the Mongo container is listening.
192+
"""
193+
def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port)
194+
195+
@doc """
196+
Returns the connection parameters to connect to the database from the _host machine_.
197+
"""
198+
def connection_parameters(%Container{} = container) do
199+
[
200+
hostname: Testcontainers.get_host(container),
201+
port: port(container),
202+
username: container.environment[:MONGO_INITDB_ROOT_USERNAME],
203+
password: container.environment[:MONGO_INITDB_ROOT_PASSWORD],
204+
database: container.environment[:MONGO_INITDB_DATABASE]
205+
]
206+
end
207+
208+
@doc """
209+
Generates the MongoDB connection URL.
210+
211+
## Options
212+
* `:protocol` - URL scheme, defaults to `"mongodb"`.
213+
* `:username` - Overrides username from container env.
214+
* `:password` - Overrides password from container env.
215+
* `:database` - Overrides database from container env.
216+
* `:options` - Query options as map/keyword list.
217+
"""
218+
def mongo_url(%Container{} = container, opts \\ []) when is_list(opts) do
219+
protocol = Keyword.get(opts, :protocol, "mongodb")
220+
username = Keyword.get(opts, :username, container.environment[:MONGO_INITDB_ROOT_USERNAME])
221+
password = Keyword.get(opts, :password, container.environment[:MONGO_INITDB_ROOT_PASSWORD])
222+
database = Keyword.get(opts, :database, container.environment[:MONGO_INITDB_DATABASE])
223+
query_string = opts |> Keyword.get(:options, []) |> encode_query_string()
224+
225+
"#{protocol}://#{username}:#{password}@#{Testcontainers.get_host(container)}:#{port(container)}/#{database}#{query_string}"
226+
end
227+
228+
@doc """
229+
Alias for `mongo_url/2`.
230+
"""
231+
def database_url(%Container{} = container, opts \\ []), do: mongo_url(container, opts)
232+
233+
defimpl ContainerBuilder do
234+
import Container
235+
236+
@doc """
237+
Implementation of the `ContainerBuilder` protocol specific to `MongoContainer`.
238+
239+
This function builds a new container configuration, ensuring the Mongo image is compatible, setting environment variables, and applying a waiting strategy for the container to be ready.
240+
241+
The build process raises an `ArgumentError` if the specified container image is not compatible with the expected Mongo image.
242+
243+
## Examples
244+
245+
# Assuming `ContainerBuilder.build/2` is called from somewhere in the application with a `MongoContainer` configuration:
246+
iex> config = MongoContainer.new()
247+
iex> built_container = ContainerBuilder.build(config, [])
248+
# `built_container` is now a ready-to-use `%Container{}` configured specifically for Mongo.
249+
250+
## Errors
251+
252+
- Raises `ArgumentError` if the provided image is not compatible with the default Mongo image.
253+
"""
254+
@spec build(%MongoContainer{}) :: %Container{}
255+
@impl true
256+
def build(%MongoContainer{} = config) do
257+
new(config.image)
258+
|> then(MongoContainer.container_port_fun(config.port))
259+
|> with_environment(:MONGO_INITDB_ROOT_USERNAME, config.user)
260+
|> with_environment(:MONGO_INITDB_ROOT_PASSWORD, config.password)
261+
|> with_environment(:MONGO_INITDB_DATABASE, config.database)
262+
|> then(MongoContainer.container_volume_fun(config.persistent_volume))
263+
|> with_waiting_strategy(
264+
CommandWaitStrategy.new(
265+
[
266+
"sh",
267+
"-c",
268+
"mongosh --eval \"db.adminCommand('ping')\" || mongo --eval \"db.adminCommand('ping')\""
269+
],
270+
config.wait_timeout
271+
)
272+
)
273+
|> with_check_image(config.check_image)
274+
|> with_reuse(config.reuse)
275+
|> valid_image!()
276+
end
277+
278+
@impl true
279+
def after_start(_config, _container, _conn), do: :ok
280+
end
281+
282+
@doc false
283+
def container_port_fun(nil), do: &Function.identity/1
284+
285+
def container_port_fun({exposed_port, host_port}) do
286+
fn container -> Container.with_fixed_port(container, exposed_port, host_port) end
287+
end
288+
289+
def container_port_fun(port) do
290+
fn container -> Container.with_exposed_port(container, port) end
291+
end
292+
293+
@doc false
294+
def container_volume_fun(nil), do: &Function.identity/1
295+
296+
def container_volume_fun(volume) when is_binary(volume) do
297+
fn container -> Container.with_bind_volume(container, volume, "/data/db") end
298+
end
299+
300+
defp encode_query_string(options) when options in [nil, [], %{}], do: ""
301+
302+
defp encode_query_string(options) when is_map(options) or is_list(options) do
303+
case URI.encode_query(options) do
304+
"" -> ""
305+
query -> "?" <> query
306+
end
307+
end
308+
end

mix.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ defmodule TestcontainersElixir.MixProject do
5959
{:myxql, "~> 0.4", only: [:dev, :test]},
6060
# postgres
6161
{:postgrex, "~> 0.14", only: [:dev, :test]},
62+
# mongo
63+
{:mongodb_driver, "~> 1.6.2", only: [:dev, :test]},
6264
# redis
6365
{:redix, "~> 1.0", only: [:dev, :test]},
6466
# ceph and minio

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
2828
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
2929
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
30+
"mongodb_driver": {:hex, :mongodb_driver, "1.6.2", "3ae95f3a3bf194578400e9c334f3656fcbd70e98d7d112619bb5214c9a979702", [:mix], [{:db_connection, "~> 2.6", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, ">= 2.1.1 and < 3.0.0-0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ezstd, "~> 1.1", [hex: :ezstd, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cf1ad8bf74609ab1f4f3f3ed105e82e2c00860e93baa31d885d96e78a0c7b7d4"},
3031
"myxql": {:hex, :myxql, "0.8.1", "5d7b96f288a98927a40309b274917d16a288a8c0b273205135913ff9799563b3", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "85a4795712bbab1a0f0803d5f0c7332bb383e5f07d3443a42e17a9aa996bbddb"},
3132
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
3233
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},

0 commit comments

Comments
 (0)