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
308 changes: 308 additions & 0 deletions lib/container/mongo_container.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
# SPDX-License-Identifier: MIT
defmodule Testcontainers.MongoContainer do
@behaviour Testcontainers.DatabaseBehaviour
@moduledoc """
Provides functionality for creating and managing Mongo container configurations.
"""

alias Testcontainers.CommandWaitStrategy
alias Testcontainers.Container
alias Testcontainers.ContainerBuilder
alias Testcontainers.MongoContainer

import Testcontainers.Container, only: [is_valid_image: 1]

@default_image "mongo"
@default_tag "latest"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
@default_user "test"
@default_password "test"
@default_database "test"
@default_port 27017
@default_wait_timeout 180_000

@enforce_keys [:image, :user, :password, :database, :port, :wait_timeout, :persistent_volume]
defstruct [
:image,
:user,
:password,
:database,
:port,
:wait_timeout,
:persistent_volume,
check_image: @default_image,
reuse: false
]

@doc """
Creates a new `MongoContainer` struct with default configurations.
"""
def new,
do: %__MODULE__{
image: @default_image_with_tag,
user: @default_user,
password: @default_password,
database: @default_database,
port: @default_port,
wait_timeout: @default_wait_timeout,
persistent_volume: nil
}

@doc """
Overrides the default image used for the Mongo container.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_image(config, "mongo:5")
iex> new_config.image
"mongo:5"
"""
def with_image(%__MODULE__{} = config, image) when is_binary(image) do
%{config | image: image}
end

@doc """
Alias for `with_user/2`, matching Mongo naming in other implementations.
"""
def with_username(%__MODULE__{} = config, username) when is_binary(username) do
with_user(config, username)
end

@doc """
Overrides the default user used for the Mongo container.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_user(config, "another-user")
iex> new_config.user
"another-user"
"""
def with_user(%__MODULE__{} = config, user) when is_binary(user) do
%{config | user: user}
end

@doc """
Overrides the default password used for the Mongo container.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_password(config, "another-password")
iex> new_config.password
"another-password"
"""
def with_password(%__MODULE__{} = config, password) when is_binary(password) do
%{config | password: password}
end

@doc """
Overrides the default database used for the Mongo container.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_database(config, "another-database")
iex> new_config.database
"another-database"
"""
def with_database(%__MODULE__{} = config, database) when is_binary(database) do
%{config | database: database}
end

@doc """
Overrides the default port used for the Mongo container.

Note: this will not change what port the docker container is listening to internally.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_port(config, 27018)
iex> new_config.port
27018
"""
def with_port(%__MODULE__{} = config, port) when is_integer(port) or is_tuple(port) do
%{config | port: port}
end

@doc """
mounts persistent volume in Mongo data path used for the Mongo container.

## Examples

iex> config = MongoContainer.new()
iex> config = MongoContainer.with_persistent_volume(config, "data_volume")
iex> config.persistent_volume
"data_volume"
"""
def with_persistent_volume(%__MODULE__{} = config, persistent_volume)
Comment thread
PedroI920224 marked this conversation as resolved.
when is_binary(persistent_volume) do
%{config | persistent_volume: persistent_volume}
end

@doc """
Mounts the default wait timeout used for the Mongo container.

Note: this timeout will be used for each individual wait strategy.

## Examples

iex> config = MongoContainer.new()
iex> new_config = MongoContainer.with_wait_timeout(config, 8000)
iex> new_config.wait_timeout
8000
"""
def with_wait_timeout(%__MODULE__{} = config, wait_timeout) when is_integer(wait_timeout) do
%{config | wait_timeout: wait_timeout}
end

@doc """
Set the regular expression to check the image validity.
"""
def with_check_image(%__MODULE__{} = config, check_image) when is_valid_image(check_image) do
%__MODULE__{config | check_image: check_image}
end

@doc """
Set the reuse flag to reuse the container if it is already running.
"""
def with_reuse(%__MODULE__{} = config, reuse) when is_boolean(reuse) do
%__MODULE__{config | reuse: reuse}
end

@doc """
Retrieves the default exposed port for the Mongo container.
"""
def default_port, do: @default_port

@doc """
Retrieves the default Docker image for the Mongo container.
"""
def default_image, do: @default_image

@doc """
Retrieves the default Docker image including tag for the Mongo container.
"""
def default_image_with_tag, do: @default_image <> ":" <> @default_tag

@doc """
Returns the port on the _host machine_ where the Mongo container is listening.
"""
def port(%Container{} = container), do: Testcontainers.get_port(container, @default_port)

@doc """
Returns the connection parameters to connect to the database from the _host machine_.
"""
def connection_parameters(%Container{} = container) do
[
hostname: Testcontainers.get_host(container),
port: port(container),
username: container.environment[:MONGO_INITDB_ROOT_USERNAME],
password: container.environment[:MONGO_INITDB_ROOT_PASSWORD],
database: container.environment[:MONGO_INITDB_DATABASE]
]
end

@doc """
Generates the MongoDB connection URL.

## Options
* `:protocol` - URL scheme, defaults to `"mongodb"`.
* `:username` - Overrides username from container env.
* `:password` - Overrides password from container env.
* `:database` - Overrides database from container env.
* `:options` - Query options as map/keyword list.
Comment thread
PedroI920224 marked this conversation as resolved.
"""
def mongo_url(%Container{} = container, opts \\ []) when is_list(opts) do
protocol = Keyword.get(opts, :protocol, "mongodb")
username = Keyword.get(opts, :username, container.environment[:MONGO_INITDB_ROOT_USERNAME])
password = Keyword.get(opts, :password, container.environment[:MONGO_INITDB_ROOT_PASSWORD])
database = Keyword.get(opts, :database, container.environment[:MONGO_INITDB_DATABASE])
query_string = opts |> Keyword.get(:options, []) |> encode_query_string()

"#{protocol}://#{username}:#{password}@#{Testcontainers.get_host(container)}:#{port(container)}/#{database}#{query_string}"
end

@doc """
Alias for `mongo_url/2`.
"""
def database_url(%Container{} = container, opts \\ []), do: mongo_url(container, opts)

defimpl ContainerBuilder do
import Container

@doc """
Implementation of the `ContainerBuilder` protocol specific to `MongoContainer`.

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.

The build process raises an `ArgumentError` if the specified container image is not compatible with the expected Mongo image.

## Examples

# Assuming `ContainerBuilder.build/2` is called from somewhere in the application with a `MongoContainer` configuration:
iex> config = MongoContainer.new()
iex> built_container = ContainerBuilder.build(config, [])
# `built_container` is now a ready-to-use `%Container{}` configured specifically for Mongo.

## Errors

- Raises `ArgumentError` if the provided image is not compatible with the default Mongo image.
"""
@spec build(%MongoContainer{}) :: %Container{}
@impl true
def build(%MongoContainer{} = config) do
new(config.image)
|> then(MongoContainer.container_port_fun(config.port))
|> with_environment(:MONGO_INITDB_ROOT_USERNAME, config.user)
|> with_environment(:MONGO_INITDB_ROOT_PASSWORD, config.password)
|> with_environment(:MONGO_INITDB_DATABASE, config.database)
|> then(MongoContainer.container_volume_fun(config.persistent_volume))
|> with_waiting_strategy(
CommandWaitStrategy.new(
[
"sh",
"-c",
"mongosh --eval \"db.adminCommand('ping')\" || mongo --eval \"db.adminCommand('ping')\""
],
config.wait_timeout
)
)
|> with_check_image(config.check_image)
|> with_reuse(config.reuse)
|> valid_image!()
end

@impl true
def after_start(_config, _container, _conn), do: :ok
end

@doc false
def container_port_fun(nil), do: &Function.identity/1

def container_port_fun({exposed_port, host_port}) do
fn container -> Container.with_fixed_port(container, exposed_port, host_port) end
end

def container_port_fun(port) do
fn container -> Container.with_exposed_port(container, port) end
end

@doc false
def container_volume_fun(nil), do: &Function.identity/1

def container_volume_fun(volume) when is_binary(volume) do
fn container -> Container.with_bind_volume(container, volume, "/data/db") end
end

Comment thread
PedroI920224 marked this conversation as resolved.
defp encode_query_string(options) when options in [nil, [], %{}], do: ""

defp encode_query_string(options) when is_map(options) or is_list(options) do
case URI.encode_query(options) do
"" -> ""
query -> "?" <> query
end
end
end
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ defmodule TestcontainersElixir.MixProject do
{:myxql, "~> 0.4", only: [:dev, :test]},
# postgres
{:postgrex, "~> 0.14", only: [:dev, :test]},
# mongo
{:mongodb_driver, "~> 1.6.2", only: [:dev, :test]},
# redis
{:redix, "~> 1.0", only: [:dev, :test]},
# ceph and minio
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"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"},
"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"},
"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"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
Expand Down
Loading
Loading