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.3.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 |
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.
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.