Dowser.OpenSearch
An Elixir client for the OpenSearch API, built on top
of Dowser.Client.
The library follows the OpenSearch OpenAPI specification: each of its tags maps to one module, and each endpoint to one function named after the operation.
Installation
Add dowser_opensearch to your dependencies in mix.exs:
def deps do
[
{:dowser_opensearch, "~> 0.4.0"}
]
end
Upgrading from 0.2? 0.3 replaces contexts with repos, moves the casting to
dowser_client and casts only nine date formats — see the
upgrade guide, and CHANGELOG.md
for everything that changed.
Define a repo — one module per cluster, configured under your own application
and started in your supervision tree, like an Ecto.Repo:
defmodule MyApp.Search do
use Dowser.OpenSearch.Repo, otp_app: :my_app
end
# config/runtime.exs
config :my_app, MyApp.Search, endpoint: System.get_env("OPENSEARCH_URL", "http://localhost:9200")
# the repo used when a call names none
config :dowser_opensearch, default_repo: MyApp.Search
# application.ex
children = [MyApp.Search]
Every API function takes the cluster as its :repo option — a repo module, or
[endpoint: ...] for a script — and falls back to :default_repo without one.
See Dowser.OpenSearch.Repo and Dowser.Repo for the configuration (auth,
headers, TLS, :httpc profiles, an init/2 callback for secrets), checking at
startup that the endpoint really is OpenSearch (verify: true), and the HTTP
verbs a repo exposes for endpoints this package doesn't cover.
Usage
Every API function is reached through its own module — there are no shortcuts on the top-level Dowser.OpenSearch namespace:
{:ok, info} = Dowser.OpenSearch.Info.info()
info["version"]["distribution"]
#=> "opensearch"
Each function comes in two variants: the plain one returns {:ok, body} or
{:error, exception}, and the bang one returns the body directly or raises:
Dowser.OpenSearch.Search.search!(%{query: %{match_all: %{}}}, index: "posts")
Required OpenSearch attributes are positional arguments; everything optional —
including the transport options forwarded to Dowser.Client — lives in the
trailing keyword list. A request body is always the first argument, so calls
pipe:
%{query: %{term: %{"status" => "published"}}}
|> Dowser.OpenSearch.Search.search(index: "posts", params: [size: 50])
HEAD checks come as a pair where the ? variant plays the bang role:
Dowser.OpenSearch.Index.index_exists("posts") #=> {:ok, true}
Dowser.OpenSearch.Index.index_exists?("posts") #=> true
Modules
| Module | Tag |
|---|---|
Dowser.OpenSearch.Alias |
Aliases |
Dowser.OpenSearch.Cat |
CAT |
Dowser.OpenSearch.Cluster |
Cluster |
Dowser.OpenSearch.DanglingIndices |
Dangling Indices |
Dowser.OpenSearch.DataStream |
Data Streams |
Dowser.OpenSearch.Document |
Document |
Dowser.OpenSearch.Index |
Index |
Dowser.OpenSearch.IndexSettings |
Index Settings |
Dowser.OpenSearch.IndexStateManagement |
Index State Management |
Dowser.OpenSearch.IndexTemplate |
Index Templates |
Dowser.OpenSearch.Info |
Info |
Dowser.OpenSearch.List |
List |
Dowser.OpenSearch.Mappings |
Mappings |
Dowser.OpenSearch.Reindex |
Reindex |
Dowser.OpenSearch.Search |
Search |
Beside the API modules, Dowser.OpenSearch.Repo is a cluster,
Dowser.OpenSearch.Codec casts documents, Dowser.OpenSearch.Streamer walks
large result sets, and Dowser.OpenSearch.Migration.Adapter runs
migrations.
Type casting
Wire Dowser.OpenSearch.Codec in as the decoder and encoder, and documents are
cast against their own index mapping — dates become DateTime, IPs become
:inet tuples, and so on:
config :my_app, MyApp.Search,
endpoint: "http://localhost:9200",
decoder: Dowser.OpenSearch.Codec,
encoder: Dowser.OpenSearch.Codec
# with "published_at" mapped {"type": "date", "format": "strict_date_time"}
Dowser.OpenSearch.Document.get!("posts", "1")["_source"]["published_at"]
#=> ~U[2026-08-11 00:00:00.000Z]
Mappings are fetched once and cached by Dowser.OpenSearch.MappingCacher. This
package doesn't start it: add it to your supervision tree, next to the repo —
children = [MyApp.Search, Dowser.OpenSearch.MappingCacher]
— or give the mappings yourself, and skip the cluster entirely:
config :dowser_opensearch, mapping: MyApp.Mappings (a Dowser.Mapping module),
or struct: on a call (a document module's own mapping). Casting against the
cluster's mappings with no cacher started fails with a Dowser.MappingError
naming it, rather than leaving every value uncast.
A flat_object field, and an object mapped "enabled": false, are returned
exactly as stored.
What is cast
The casting is dowser_client's (Dowser.Type), the same on Elasticsearch and
OpenSearch. Only the types JSON can't represent are cast; every other value
passes through as decoded JSON:
| mapping type | stored | Elixir |
|---|---|---|
date, date_nanos |
depends on the format — see below | Date, Time, DateTime, NaiveDateTime |
date_range (strict_date) |
{"gte": "2019-03-01", "lte": "2019-03-31"} |
Date.Range |
integer_range, long_range |
{"gte": 1, "lte": 10} |
Range |
ip |
"127.0.0.1" |
{127, 0, 0, 1} |
binary |
Base64 | raw binary |
geo_point |
{"lat": 1.2, "lon": 3.4} |
{1.2, 3.4} |
Dates
A date field's format decides what it is cast to. Nine formats are, each
read by the Elixir function that parses it:
| format | stored | Elixir |
|---|---|---|
epoch_millis |
1553391286000 |
DateTime.from_unix(value, :millisecond) |
epoch_second |
1553391286 |
DateTime.from_unix(value, :second) |
strict_date |
"2019-03-23" |
Date.from_iso8601/1 |
strict_date_hour_minute_second |
"2019-03-23T21:34:46" |
NaiveDateTime.from_iso8601/1 |
strict_date_hour_minute_second_millis |
"2019-03-23T21:34:46.123" |
NaiveDateTime.from_iso8601/1 |
strict_date_time_no_millis |
"2019-03-23T21:34:46-04:00" |
DateTime.from_iso8601/1 |
strict_date_time |
"2019-03-23T21:34:46.123-04:00" |
DateTime.from_iso8601/1 |
strict_hour_minute_second |
"21:34:46" |
Time.from_iso8601/1 |
strict_hour_minute_second_millis |
"21:34:46.123" |
Time.from_iso8601/1 |
- Any other format is not cast — nor is a field with no format at all,
which OpenSearch reads as
strict_date_optional_time||epoch_millis. Its values come back as stored, and are written as given. Map a date field with one of the nine to have it cast. A value its format can't read (a leap second, a malformed string) comes back as stored too. - Several formats (
"strict_date||epoch_millis"): a value is read with the first that recognizes it, and written with the first. - Precision: a value is read at whatever precision it carries, and written
at exactly the format's — truncated to the second or the millisecond, and
padded to three digits for a
_millisformat (~U[2019-03-24 01:34:46Z]is written"2019-03-24T01:34:46.000Z"tostrict_date_time). - Always UTC: an offset in a stored value is applied and dropped, giving
one UTC
DateTime; a value with no offset, or-00:00, is read as UTC, as OpenSearch reads it. ADateTimein any zone is written as its UTC instant (Z). No time-zone database is needed, andtzdatachanges nothing. The instant is exact, but the original offset isn't kept: store the zone in a field of its own where the local time matters.
Ranges
A range is cast only when the Elixir struct holds it exactly: a date_range
mapped strict_date with gte/lte dates, an integer_range or
long_range with integer gte/lte bounds, gte no greater than lte.
Anything else — an open-ended range, gt/lt bounds, another date format —
comes back as stored. Only a range stepping by 1 is written back as a range.
The casting options
Five options decide how documents are cast. Each can be passed on any API
call, given to the codec in a repo's configuration
(decoder: {Dowser.OpenSearch.Codec, types: MyApp.Types}), or set for the
application (config :dowser_opensearch, types: MyApp.Types) — most specific
first:
| Option | Decides | Default |
|---|---|---|
:mapping |
where the mapping comes from: a map, a Dowser.Mapping module, or a document module |
config :dowser_opensearch, mapping:, else the cluster's via Dowser.OpenSearch.MappingCacher, which you start |
:struct |
load each document into a document module's struct (Dowser.Schema), and dump it back — per call only |
maps |
:types |
the Dowser.Type module leaf values go through |
Dowser.Type |
:unknown_fields |
:keep, or :raise a Dowser.UnknownFieldError for a field the mapping doesn't declare |
:keep |
:mapping_failure |
:error, :warn or :ignore when the mapping can't be fetched |
:error |
Dowser.OpenSearch.Document.get("posts", "1", struct: MyApp.Post) # {:ok, %{"_source" => %MyApp.Post{}}}
Dowser.OpenSearch.Document.get("posts", "1", mapping: MyApp.Post) # cast with its mapping, still a map
:mapping and :struct turn casting on for that call even on a repo with no
:decoder/:encoder configured.
Testing
A repo is where a test takes over: every request goes through it, and nothing else needs replacing.
Stub the HTTP layer, keep the pipeline
Dowser.Client.HTTP.Stub answers every request the test process makes, while
everything else — the repo, URL building, JSON, casting — runs as in
production. Point the calls at an ad-hoc repo (or your own, configured in
config/test.exs); stubs are per process, so tests stay async: true:
test "loads a post" do
Dowser.Client.HTTP.Stub.stub(fn :get, "http://localhost:9200/posts/_doc/1", _headers, _body, _opts ->
Dowser.Client.HTTP.Stub.json(200, %{
"_index" => "posts",
"_id" => "1",
"_source" => %{"title" => "hi", "published_at" => "2026-08-11T00:00:00.000Z"}
})
end)
assert {:ok, %{"_source" => %MyApp.Post{title: "hi"}}} =
Dowser.OpenSearch.Document.get("posts", "1",
repo: [endpoint: "http://localhost:9200"],
struct: MyApp.Post
)
end
Casting against the cluster's mappings needs mappings in tests too: give them
with config :dowser_opensearch, mappings: %{"posts" => %{...}} (no cacher
needed), mapping: / struct:, or start Dowser.OpenSearch.MappingCacher with
start_supervised!/1 and put/3 the mappings into it.
Mock the repo
For a unit test that only needs a canned answer, mock the repo itself: a repo
is the Dowser.Repo behaviour, so Mox can stand in
for one.
# test/test_helper.exs
Mox.defmock(MyApp.SearchMock, for: Dowser.Repo)
# in your test
Mox.expect(MyApp.SearchMock, :request, fn :post, "/posts/_search", _query, _opts ->
{:ok, %Dowser.Client.Response{status: 200, headers: %{}, body: %{"hits" => %{"hits" => []}}}}
end)
assert {:ok, %{"hits" => %{"hits" => []}}} =
Dowser.OpenSearch.Search.search(%{query: %{match_all: %{}}}, index: "posts", repo: MyApp.SearchMock)
The mock stands in for the whole transport — encoding, decoding and casting
included — so its body is exactly what the caller gets. Set
config :dowser_opensearch, default_repo: MyApp.SearchMock in config/test.exs
to have every call that names no repo go to it.
Mock the repo with Mimic
Mimic stubs the functions of an existing module,
per test process. A test-only repo module becomes the default repo in the test
environment, so the code under test — which names no repo — talks to it, and
each test says what request/4 answers. request/4 is the one function every
API call goes through.
# mix.exs
defp deps do
[
# ...
{:mimic, "~> 1.10", only: :test}
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
# test/support/search_mock.ex — a repo like any other, compiled for tests only
defmodule MyApp.SearchMock do
use Dowser.OpenSearch.Repo, otp_app: :my_app
end
# config/test.exs — the default repo is the mock in tests
config :my_app, MyApp.SearchMock, endpoint: "http://search.test"
config :dowser_opensearch, default_repo: MyApp.SearchMock
# test/test_helper.exs
Mimic.copy(MyApp.SearchMock)
ExUnit.start()
defmodule MyApp.PostsTest do
use ExUnit.Case, async: true
use Mimic
test "searches the posts" do
expect(MyApp.SearchMock, :request, fn :post, "/posts/_search", _query, _opts ->
{:ok, %Dowser.Client.Response{status: 200, headers: %{}, body: %{"hits" => %{"hits" => []}}}}
end)
# MyApp.Posts.search/1 calls Dowser.OpenSearch.Search.search(query, index: "posts"),
# with no :repo — so it goes to the default repo, the mock.
assert {:ok, %{"hits" => %{"hits" => []}}} = MyApp.Posts.search("hello")
end
end
A few things to know:
- The stub stands in for the whole transport, so the body it returns is what
the caller gets — nothing is decoded or cast. Let the request through with
call_original/3where the casting matters, or stub the HTTP layer instead. - Mimic stubs are per process, which keeps
async: truetests apart. A request made from another process — aTask, a GenServer — needsMimic.allow(MyApp.SearchMock, self(), pid), orset_mimic_globalin a test that isn't async. - Mimic replaces calls into the module. The repo's own verbs
(
MyApp.SearchMock.get/2, …) reachrequest/4from inside it, so a test calling them directly stubs the verb it calls. - Calls that name a repo (
repo: MyApp.Search) bypass the default: pointrepo:at the same mock in tests, or have the code read its repo from configuration.
Against a real cluster
Point a repo at a local cluster in config/test.exs, and start the mapping
cacher if the tests cast against the cluster's mappings. Tests that share an
index can't run concurrently: run them async: false, or give each test
indices of its own ("posts_#{System.unique_integer([:positive])}"), created
in setup and deleted in on_exit.
Streaming
Dowser.OpenSearch.Streamer walks more documents than fit in one response,
over a point in time:
%{query: %{match_all: %{}}, sort: [%{"_id" => "asc"}]}
|> Dowser.OpenSearch.Streamer.stream(index: "posts")
|> Stream.map(& &1["_source"])
|> Enum.each(&process/1)
The sort is required and its last key must be unique per document —
OpenSearch has no _shard_doc to supply a tiebreaker, and search_after on a
non-unique key silently skips rows. See the module documentation.
Migrations
An application changes its indices with migrations, as it changes its
database tables with Ecto's: mix dowser.gen.migration, mix dowser.migrate,
mix dowser.rollback, from dowser_client. Every use Dowser.OpenSearch.Repo runs them against OpenSearch through
Dowser.OpenSearch.Migration.Adapter, so there is nothing to configure but
the repos to migrate:
# config/config.exs
config :my_app, dowser_repos: [MyApp.Search]
# .formatter.exs — the migration DSL without parentheses
[import_deps: [:dowser_opensearch], ...]
# priv/dowser/migrations/20261104120000_create_events.exs
defmodule MyApp.Search.Migrations.CreateEvents do
use Dowser.Migration
def change do
create_index "events", %{
settings: %{number_of_shards: 1},
mappings: %{dynamic: :strict, properties: %{title: %{type: :text}}}
}
end
end
mix dowser.migrate --dry-run --reindex # the requests it would send
mix dowser.migrate --reindex # copies the indices it must
mix dowser.rollback
"events" is an alias of events_20261104120000 — events-20261104120000
with create_index "events", body, separator: "-". The migrations run are
recorded in the dowser_schema_migrations index, which also holds the lock
keeping two runs apart. See Dowser.Migration and Dowser.Migrator.
Repositories
Dowser.OpenSearch.Repository binds the index-related functions to a fixed or
computed index:
defmodule Posts do
use Dowser.OpenSearch.Repository, index: "posts", only: [search: [:search], document: [:get]]
end
Posts.search!(%{query: %{match_all: %{}}}) # searches /posts/_search
Posts.get_doc!("1") # GETs /posts/_doc/1
Development
mix deps.get
mix test
mix lint # format --check-formatted, credo --strict, dialyzer
This package is developed alongside dowser_client. To build against a
working copy of it rather than the published release — needed whenever this
package relies on a dowser_client change not on Hex yet — set
DOWSER_CLIENT_PATH:
DOWSER_CLIENT_PATH=../dowser_client mix deps.get
DOWSER_CLIENT_PATH=../dowser_client mix test
Documentation
The full API reference is on HexDocs.
License
MIT — see LICENSE.txt.