|
| 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 |
0 commit comments