Current section
Files
Jump to
Current section
Files
lib/boxic/dmn/xml/serialization_validator.ex
defmodule Boxic.DMN.XML.SerializationValidator do
@moduledoc false
alias Boxic.DMN.Compatibility
alias Boxic.DMN.Model
alias Boxic.DMN.Model.{
ConditionalExpression,
ContextExpression,
FilterExpression,
FunctionDefinition,
Invocation,
IteratorExpression,
ListExpression,
Relation
}
alias Boxic.DMN.SerializationError
alias Boxic.DMN.Validator
@allowed_options [:format, :xml_declaration]
@spec validate(Model.t(), keyword()) :: :ok | {:error, [SerializationError.t()]}
def validate(%Model{} = model, opts) when is_list(opts) do
if Keyword.keyword?(opts) do
try do
errors =
option_errors(opts) ++
profile_errors(model) ++
fidelity_errors(model) ++
model_validation_errors(model) ++
import_errors(model) ++
identifier_errors(model) ++
identity_errors(model) ++
unsupported_expression_errors(model)
case errors do
[] -> :ok
errors -> {:error, errors}
end
rescue
_exception -> invalid_model_error()
catch
_kind, _reason -> invalid_model_error()
end
else
invalid_options_error()
end
end
def validate(%Model{}, _opts), do: invalid_options_error()
def validate(model, _opts) do
{:error,
[
error(
:invalid_model,
[],
"Expected a Boxic.DMN.Model.",
%{type: model_type(model)}
)
]}
end
defp invalid_model_error do
{:error,
[
error(
:invalid_model,
[],
"The normalized model could not be inspected safely.",
nil
)
]}
end
defp invalid_options_error do
{:error,
[
error(
:invalid_option,
[:options],
"XML encoding options must be a keyword list.",
nil
)
]}
end
defp option_errors(opts) do
unknown =
opts
|> Keyword.keys()
|> Enum.reject(&(&1 in @allowed_options))
|> Enum.uniq()
|> Enum.map(fn option ->
error(:invalid_option, [:options, option], "Unknown XML encoding option.", %{
option: option
})
end)
duplicates =
opts
|> Keyword.keys()
|> Enum.frequencies()
|> Enum.filter(fn {_option, count} -> count > 1 end)
|> Enum.map(fn {option, _count} ->
error(
:invalid_option,
[:options, option],
"XML encoding option was provided more than once.",
%{option: option}
)
end)
values =
[]
|> validate_option_value(opts, :format, [:pretty, :compact])
|> validate_option_value(opts, :xml_declaration, [true, false])
unknown ++ duplicates ++ values
end
defp validate_option_value(errors, opts, option, allowed) do
case Keyword.fetch(opts, option) do
:error ->
errors
{:ok, value} ->
if value in allowed do
errors
else
errors ++
[
error(
:invalid_option,
[:options, option],
"Invalid XML encoding option value.",
%{option: option, value: value, allowed: allowed}
)
]
end
end
end
defp profile_errors(%Model{source_profile: profile}) do
pinned = Compatibility.pinned_profile().source_profile
if profile in [nil, pinned] do
[]
else
[
error(
:dmn_version_mismatch,
[:source_profile],
"The model source DMN profile does not match the pinned output profile.",
%{source: profile, pinned: pinned}
)
]
end
end
defp fidelity_errors(%Model{serialization_fidelity: :complete}), do: []
defp fidelity_errors(%Model{serialization_fidelity: {:lossy, losses}}) do
Enum.with_index(losses, fn loss, index ->
error(
:lossy_serialization,
[:serialization_fidelity, index],
"The loader did not retain XML content required for a safe round-trip.",
%{loss: loss}
)
end)
end
defp fidelity_errors(%Model{serialization_fidelity: other}) do
[
error(
:invalid_model,
[:serialization_fidelity],
"The model has invalid serialization fidelity metadata.",
%{value: other}
)
]
end
defp model_validation_errors(model) do
case Validator.validate(model) do
:ok ->
[]
{:error, errors} ->
Enum.with_index(errors, fn validation_error, index ->
error(
:invalid_model,
[:validation, index],
"The normalized model is not valid.",
%{validation_error: validation_error}
)
end)
end
end
defp import_errors(model) do
Enum.flat_map(model.imports, fn {namespace, import} ->
[]
|> required_import_value(namespace, import, :name)
|> required_import_value(namespace, import, :namespace)
|> required_import_value(namespace, import, :import_type)
|> then(fn errors ->
if import.namespace == namespace do
errors
else
errors ++
[
error(
:invalid_identifier,
[:imports, namespace, :namespace],
"The import map key and embedded namespace do not agree.",
%{key: namespace, namespace: import.namespace}
)
]
end
end)
end)
end
defp required_import_value(errors, namespace, import, field) do
if is_binary(Map.get(import, field)) do
errors
else
errors ++
[
error(
:missing_required_value,
[:imports, namespace, field],
"The import is missing a value required by DMN XML.",
%{field: field}
)
]
end
end
defp identifier_errors(model) do
identifiers = collect_identifiers(model, [])
invalid =
Enum.flat_map(identifiers, fn {id, path} ->
if valid_ncname?(id) do
[]
else
[
error(
:invalid_identifier,
path,
"The DMN identifier is not a valid XML NCName.",
%{id: id}
)
]
end
end)
duplicates =
identifiers
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.filter(fn {_id, paths} -> length(paths) > 1 end)
|> Enum.map(fn {id, paths} ->
error(
:duplicate_identifier,
List.first(paths),
"The DMN identifier occurs more than once in the definitions document.",
%{id: id, paths: paths}
)
end)
invalid ++ duplicates
end
defp collect_identifiers(%_{} = struct, path) do
own =
case Map.get(struct, :id) do
id when is_binary(id) -> [{id, path ++ [:id]}]
_other -> []
end
children =
struct
|> Map.from_struct()
|> Enum.flat_map(fn {key, value} -> collect_identifiers(value, path ++ [key]) end)
own ++ children
end
defp collect_identifiers(map, path) when is_map(map) do
Enum.flat_map(map, fn {key, value} -> collect_identifiers(value, path ++ [key]) end)
end
defp collect_identifiers(list, path) when is_list(list) do
list
|> Enum.with_index()
|> Enum.flat_map(fn {value, index} -> collect_identifiers(value, path ++ [index]) end)
end
defp collect_identifiers(_value, _path), do: []
defp valid_ncname?(id) do
:xmerl_lib.is_ncname(String.to_charlist(id))
rescue
_error -> false
end
defp model_type(%module{}), do: module
defp model_type(value) when is_map(value), do: :map
defp model_type(value) when is_list(value), do: :list
defp model_type(value) when is_binary(value), do: :binary
defp model_type(value) when is_atom(value), do: :atom
defp model_type(_value), do: :other
defp identity_errors(model) do
[
{:decisions, model.decisions},
{:input_data, model.input_data},
{:bkms, model.bkms},
{:decision_services, model.decision_services}
]
|> Enum.flat_map(fn {collection_name, collection} ->
Enum.flat_map(collection, fn {key, value} ->
if value.id == key do
[]
else
[
error(
:invalid_identifier,
[collection_name, key, :id],
"The map key and embedded DMN identifier do not agree.",
%{key: key, id: value.id}
)
]
end
end)
end)
end
defp unsupported_expression_errors(model) do
Enum.flat_map(model.decisions, fn {id, decision} ->
expression_errors(decision.expression, [:decisions, id, :expression])
end) ++
Enum.flat_map(model.bkms, fn {id, bkm} ->
expression_errors(bkm.expression, [:bkms, id, :expression])
end)
end
defp expression_errors({:unsupported, name}, path) do
[
error(
:unsupported_expression,
path,
"The expression was not normalized faithfully and cannot be serialized.",
%{expression: name}
)
]
end
defp expression_errors(%ContextExpression{entries: entries}, path) do
entries
|> Enum.with_index()
|> Enum.flat_map(fn {entry, index} ->
expression_errors(entry.expression, path ++ [:entries, index, :expression])
end)
end
defp expression_errors(%Invocation{} = invocation, path) do
expression_errors(invocation.function, path ++ [:function]) ++
(invocation.bindings
|> Enum.with_index()
|> Enum.flat_map(fn {binding, index} ->
expression_errors(binding.expression, path ++ [:bindings, index, :expression])
end))
end
defp expression_errors(%FunctionDefinition{body: body}, path),
do: expression_errors(body, path ++ [:body])
defp expression_errors(%Relation{rows: rows}, path) do
rows
|> Enum.with_index()
|> Enum.flat_map(fn {row, row_index} ->
row
|> Enum.with_index()
|> Enum.flat_map(fn {expression, column_index} ->
expression_errors(expression, path ++ [:rows, row_index, column_index])
end)
end)
end
defp expression_errors(%ListExpression{items: items}, path) do
items
|> Enum.with_index()
|> Enum.flat_map(fn {item, index} -> expression_errors(item, path ++ [:items, index]) end)
end
defp expression_errors(%ConditionalExpression{} = expression, path) do
expression_errors(expression.condition, path ++ [:condition]) ++
expression_errors(expression.then_branch, path ++ [:then_branch]) ++
expression_errors(expression.else_branch, path ++ [:else_branch])
end
defp expression_errors(%FilterExpression{} = expression, path) do
expression_errors(expression.source, path ++ [:source]) ++
expression_errors(expression.match, path ++ [:match])
end
defp expression_errors(%IteratorExpression{} = expression, path) do
expression_errors(expression.source, path ++ [:source]) ++
expression_errors(expression.body, path ++ [:body])
end
defp expression_errors(_expression, _path), do: []
defp error(code, path, message, details),
do: %SerializationError{
code: code,
path: bounded(path),
message: message,
details: bounded(details)
}
defp bounded(value), do: bounded(value, 0)
defp bounded(value, _depth) when is_binary(value) and byte_size(value) <= 256, do: value
defp bounded(value, _depth) when is_binary(value),
do: String.slice(value, 0, 256) <> "…"
defp bounded(value, depth) when is_list(value) and depth < 4,
do: value |> Enum.take(20) |> Enum.map(&bounded(&1, depth + 1))
defp bounded(value, depth) when is_map(value) and depth < 4 do
value
|> Enum.take(20)
|> Map.new(fn {key, item} -> {bounded(key, depth + 1), bounded(item, depth + 1)} end)
end
defp bounded(value, depth) when is_tuple(value) and depth < 4 do
value
|> Tuple.to_list()
|> Enum.map(&bounded(&1, depth + 1))
|> List.to_tuple()
end
defp bounded(value, _depth) when is_atom(value) or is_number(value) or is_nil(value), do: value
defp bounded(_value, _depth), do: :omitted
end