Dowser.Opensearch.Codec behaviour (Dowser.Opensearch v0.1.0)

View Source

Casts OpenSearch documents and search results to and from native Elixir terms, based on each document's own index mapping — a Dowser.Client :decoder and :encoder:

config :dowser_client,
  contexts: [
    default: [
      endpoint: "http://localhost:9200",
      decoder: Dowser.Opensearch.Codec,
      encoder: Dowser.Opensearch.Codec
    ]
  ]

Once configured, every API function in Dowser.Opensearch.Document and Dowser.Opensearch.Search casts automatically — no per-call option needed. Mappings are fetched (and cached) through Dowser.Opensearch.MappingCacher.

The module works at two levels, and its two pairs of functions say which:

  • decode/2 and encode/2 take a whole body, and are what dowser_client calls.
  • load/2 and dump/2 take one value and the mapping entry that describes it, and are what the body pass dispatches into, field by field. They are also this module's behaviour.

decode/2

Finds and casts every document in a response body, at any nesting depth: a bare document (Dowser.Opensearch.Document.get/3), hits.hits[] (Dowser.Opensearch.Search.search/2), responses[].hits.hits[] (Dowser.Opensearch.Search.msearch/2), and so on — each hit's own _index selects its mapping, so mixed-index results are cast correctly.

A hit's inner_hits are cast too. They carry no _index of their own, so their mapping comes from the hit they belong to: _nested.field names the path into it, and an inner hit with no _nested (a has_child or has_parent join) is cast against the index mapping itself. The rest of a hit's envelope — fields, highlight, sort — has no mapping entry to be cast against, and only has its keys run through opts[:key_fn].

If no mapping can be found for a document's index (no Dowser.Opensearch.MappingCacher running, or the fetch fails), its values pass through unchanged; keys are still cast per opts[:key_fn].

Subtrees the mapping declares opaque

A flat_object field (OpenSearch's name for Elasticsearch's flattened), and an object mapped "enabled": false, hold whatever the document put in them — the mapping enumerates none of it. Both are returned exactly as they arrived: values uncast, and keys left as strings rather than run through opts[:key_fn].

That last part matters under keys: :atoms. Atoms are never garbage collected and the table is capped (:erlang.system_info(:atom_limit), a little over a million by default); atomizing keys that come from document content rather than from the mapping turns any writer into a way to exhaust it and bring the node down. A mapped field's name is one of a finite set, so casting it is safe; a flat_object field's keys are not.

A mixed result is the price: %{title: "hi", roster: %{"Managed Care Biller" => "Sam"}}. The alternative is a cast that is unsafe by construction.

Note that this is narrower than the whole risk. keys: :atoms also casts keys the mapping simply doesn't mention — an unmapped field under "dynamic": false, or one added since the mapping was cached. Where a body is wholly untrusted, keys: :atoms! (String.to_existing_atom/1) is the option that cannot grow the table at all.

encode/2

Casts one document source against the mapping of opts[:index] — never a query. dowser_client only hands an encoder a source, because a query value has no mapping entry to anchor it: the same value can appear in a range clause, a script parameter or an aggregation boundary, each wanting a different shape. Build queries in the shape OpenSearch expects.

encode_bulk/3 is the exception to one-source-at-a-time: a bulk payload is cast against the index named on the action line above it, which no per-line pass can see.

load/2 and dump/2

The mapping entry's "type" selects the field codec that handles it; only the types JSON can't natively represent are cast, and any other entry — like a nil value — falls back to identity.

mapping typefield codecElixir term
date, date_nanosDowser.Opensearch.Codec.DateDateTime
date_rangeDowser.Opensearch.Codec.DateRangeDate.Range
integer_rangeDowser.Opensearch.Codec.RangeRange
ipDowser.Opensearch.Codec.IP:inet tuple
binaryDowser.Opensearch.Codec.Binaryraw binary
geo_pointDowser.Opensearch.Codec.GeoPoint{lat, lon}
iex> Dowser.Opensearch.Codec.load("127.0.0.1", %{"type" => "ip"})
{127, 0, 0, 1}

iex> Dowser.Opensearch.Codec.dump({127, 0, 0, 1}, %{"type" => "ip"})
"127.0.0.1"

The behaviour

load/2 and dump/2 are the callbacks each field codec in the table implements: two functions over one value and the mapping entry describing it (field), returning the cast term directly. A value a codec doesn't recognize should pass through unchanged rather than raise, so a bad cast degrades to identity instead of failing the whole document.

Adding mapping types

A codec is a plain module, so covering one more type is a clause per direction and a delegation back here for everything else:

defmodule MyApp.Codec do
  @behaviour Dowser.Opensearch.Codec

  @impl true
  def load(value, %{"type" => "my_type"} = field) do
    # ...
  end

  def load(value, field), do: Dowser.Opensearch.Codec.load(value, field)

  @impl true
  def dump(value, %{"type" => "my_type"} = field) do
    # ...
  end

  def dump(value, field), do: Dowser.Opensearch.Codec.dump(value, field)
end

Delegating last inherits the built-in casts — including the nil short-circuit and the fall-through to identity, so neither needs restating. Matching a type this module already handles replaces that cast (e.g. to handle a custom date format), since your clause comes first.

A module like that only needs load/2 and dump/2: the envelope walking stays here, and :codec points decode/2/encode/2 at it. It resolves most-specific-first, like every other option in this package:

  1. Per request — :codec alongside any other option:

    Dowser.Opensearch.Document.get("posts", "1", codec: MyApp.Codec)
  2. Per context — named alongside the pass it belongs to:

    decoder: {Dowser.Opensearch.Codec, codec: MyApp.Codec},
    encoder: {Dowser.Opensearch.Codec, codec: MyApp.Codec}
  3. Globally — the usual place for an application with one codec, and the only tier that needs no tuple:

    config :dowser_opensearch, codec: MyApp.Codec
  4. This module, when none of the above is set.

Options

dowser_client always supplies :context (the resolved Dowser.Client.Context), and a decoder also gets :key_fn (the function :keys resolved to). The rest are this module's own, given alongside it as {Dowser.Opensearch.Codec, opts}:

  • :codec — the field codec load/2/dump/2 are dispatched through, as above.
  • :index — the index whose mapping encode/2 casts a source against, and that decode/2 casts a :source body against. Set by the API function itself.
  • :source — true when a response body is a bare _source document with no _index of its own (Dowser.Opensearch.Document.get_source/3). Set by the API function itself.
  • :mapping — a mapping to cast against, instead of looking one up. Skips Dowser.Opensearch.MappingCacher entirely.
  • :mapping_failure — what to do when a mapping cannot be fetched; see below. Defaults to the application environment (config :dowser_opensearch, mapping_failure: ...), itself :error.

When the mapping can't be fetched

An index with no mapping, and no index to speak of, both cast to identity: there is nothing to dispatch on, and that is a correct answer.

A mapping that could not be fetched is a different thing. The fetch is an OpenSearch request like any other, so it fails when the cluster is overloaded — and casting to identity there means the same field comes back as a Date.Range on a good day and a %{"gte" => _, "lte" => _} on a bad one, a document source is written with a Date.Range the JSON encoder chokes on, and nothing says so. :mapping_failure decides what happens instead:

  • :error (default) — raise Dowser.Opensearch.MappingError, which dowser_client turns into the {:error, exception} every API function already returns. The request fails; the types don't drift.
  • :warn — log a warning and cast to identity.
  • :ignore — cast to identity, silently.

Where :telemetry is available — an optional dependency, so only if your application already pulls it in — every failed fetch also emits [:dowser_opensearch, :mapping, :failure] whatever the policy, with %{index: index, reason: reason, policy: policy} as metadata: the place to count them without a log line per document.

Summary

Callbacks

Casts value back into its OpenSearch representation.

Casts value from its OpenSearch representation into a richer term.

Functions

Casts every document found in body, returning the decoded term.

Casts value back into OpenSearch's representation, dispatching on field's "type".

Casts source against the mapping of opts[:index], returning the encoded term.

Casts value from OpenSearch's representation, dispatching on field's "type".

Callbacks

dump(value, field)

@callback dump(value :: term(), field :: term()) :: term()

Casts value back into its OpenSearch representation.

load(value, field)

@callback load(value :: term(), field :: term()) :: term()

Casts value from its OpenSearch representation into a richer term.

Functions

decode(body, opts)

@spec decode(term(), keyword()) :: term()

Casts every document found in body, returning the decoded term.

See the module documentation for the options; :key_fn is required, and dowser_client always supplies it.

dump(value, field)

Casts value back into OpenSearch's representation, dispatching on field's "type".

The mirror image of load/2, with the same fallbacks.

encode(source, opts)

@spec encode(term(), keyword()) :: term()

Casts source against the mapping of opts[:index], returning the encoded term.

Without an :index there is no mapping to cast against, and the source passes through unchanged. See the module documentation for the options.

encode_bulk(operations, fun, opts)

@spec encode_bulk([map()], (term(), keyword() -> term()), keyword()) :: [map()]

Casts the payloads of a Dowser.Opensearch.Document.bulk/2 operation list.

A bulk body is a flat list alternating action and payload maps, so a payload only knows which index it is going to from the action line above it — which is why it is cast here, over the whole list, rather than line by line through Dowser.Client's :encode.

fun is the resolved encoder (encode/2 or a custom one) and opts its options; opts[:index] is the bulk-level default index, which a per-action _index overrides. index/create actions have their whole payload cast, update actions only their doc/upsert source, and delete actions carry no payload to cast.

load(value, field)

Casts value from OpenSearch's representation, dispatching on field's "type".

A nil value, a mapping entry with no known "type", or no mapping entry at all, all return value untouched — so a missing mapping degrades a cast to identity rather than failing.