Dowser.OpenSearch

Hex.pm

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

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:

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.