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/2andencode/2take a whole body, and are whatdowser_clientcalls.load/2anddump/2take 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 type | field codec | Elixir term |
|---|---|---|
date, date_nanos | Dowser.Opensearch.Codec.Date | DateTime |
date_range | Dowser.Opensearch.Codec.DateRange | Date.Range |
integer_range | Dowser.Opensearch.Codec.Range | Range |
ip | Dowser.Opensearch.Codec.IP | :inet tuple |
binary | Dowser.Opensearch.Codec.Binary | raw binary |
geo_point | Dowser.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)
endDelegating 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:
Per request —
:codecalongside any other option:Dowser.Opensearch.Document.get("posts", "1", codec: MyApp.Codec)Per context — named alongside the pass it belongs to:
decoder: {Dowser.Opensearch.Codec, codec: MyApp.Codec}, encoder: {Dowser.Opensearch.Codec, codec: MyApp.Codec}Globally — the usual place for an application with one codec, and the only tier that needs no tuple:
config :dowser_opensearch, codec: MyApp.CodecThis 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 codecload/2/dump/2are dispatched through, as above.:index— the index whose mappingencode/2casts a source against, and thatdecode/2casts a:sourcebody against. Set by the API function itself.:source—truewhen a response body is a bare_sourcedocument with no_indexof 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. SkipsDowser.Opensearch.MappingCacherentirely.: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) — raiseDowser.Opensearch.MappingError, whichdowser_clientturns 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 the payloads of a Dowser.Opensearch.Document.bulk/2 operation list.
Casts value from OpenSearch's representation, dispatching on field's
"type".
Callbacks
Functions
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.
Casts value back into OpenSearch's representation, dispatching on field's
"type".
The mirror image of load/2, with the same fallbacks.
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.
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.
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.