Current section
Files
Jump to
Current section
Files
lib/ash_ai/open_api.ex
defmodule AshAi.OpenApi do
@moduledoc false
@spec resource_write_attribute_type(
term(),
resource :: Ash.Resource.t(),
action_type :: atom
) :: map()
@doc false
def resource_write_attribute_type(attribute, resource, action_type)
def resource_write_attribute_type(
%Ash.Resource.Aggregate{type: nil} = agg,
resource,
action_type
) do
{type, constraints} = field_type(agg, resource)
resource_write_attribute_type(
Map.merge(agg, %{type: type, constraints: constraints}),
resource,
action_type
)
end
def resource_write_attribute_type(
%{type: {:array, type}} = attr,
resource,
action_type
) do
%{
type: :array,
items:
resource_write_attribute_type(
%{
attr
| type: type,
constraints: attr.constraints[:items] || []
},
resource,
action_type
)
}
|> with_attribute_description(attr)
end
def resource_write_attribute_type(
%{type: Ash.Type.Map, constraints: constraints} = attr,
resource,
action_type
) do
if constraints[:fields] && constraints[:fields] != [] do
%{
type: :object,
additionalProperties: false,
properties:
Map.new(constraints[:fields], fn {key, config} ->
{key,
resource_write_attribute_type(
%{
attr
| type: config[:type],
constraints: config[:constraints] || []
}
|> Map.put(:description, config[:description] || nil),
resource,
action_type
)}
end),
required:
constraints[:fields]
|> Enum.filter(fn {_, config} -> !config[:allow_nil?] end)
|> Enum.map(&elem(&1, 0))
}
|> add_null_for_non_required()
|> make_all_required()
else
%{type: :object}
end
|> with_attribute_description(attr)
end
def resource_write_attribute_type(
%{type: Ash.Type.Union, constraints: constraints} = attr,
resource,
action_type
) do
subtypes =
Enum.map(constraints[:types], fn {_name, config} ->
fake_attr =
%{
attr
| type: Ash.Type.get_type(config[:type]),
constraints: config[:constraints]
}
|> Map.put(:description, config[:description] || nil)
resource_write_attribute_type(fake_attr, resource, action_type)
end)
%{
"anyOf" => subtypes
}
|> unwrap_any_of()
|> with_attribute_description(attr)
end
def resource_write_attribute_type(
%{type: Ash.Type.Struct, constraints: constraints} = attr,
resource,
action_type
) do
if instance_of = constraints[:instance_of] do
if embedded?(instance_of) && !constraints[:fields] do
embedded_type_input(attr, action_type)
else
resource_write_attribute_type(
%{attr | type: Ash.Type.Map},
resource,
action_type
)
end
else
%{}
end
|> with_attribute_description(attr)
end
def resource_write_attribute_type(%{type: type} = attr, resource, action_type) do
cond do
embedded?(type) ->
embedded_type_input(attr, action_type)
Ash.Type.NewType.new_type?(type) ->
new_constraints = Ash.Type.NewType.constraints(type, attr.constraints)
new_type = Ash.Type.NewType.subtype_of(type)
resource_write_attribute_type(
Map.merge(attr, %{type: Ash.Type.get_type(new_type), constraints: new_constraints}),
resource,
action_type
)
true ->
resource_attribute_type(attr, resource)
end
|> with_attribute_description(attr)
end
defp add_null_for_non_required(%{required: required} = schema)
when is_list(required) do
Map.update!(schema, :properties, fn
properties when is_map(properties) ->
Enum.reduce(properties, %{}, fn {key, value}, acc ->
if Enum.member?(required, key) do
Map.put(acc, key, value)
else
description = value |> Map.get(:description)
value = value |> Map.delete(:description)
new_value =
%{
"anyOf" => [
%{
"type" => "null"
},
value
]
}
|> then(fn new_value ->
case description do
nil -> new_value
description -> Map.put(new_value, "description", description)
end
end)
|> unwrap_any_of()
Map.put(
acc,
key,
new_value
)
end
end)
properties ->
properties
end)
end
@spec with_attribute_description(
map(),
Ash.Resource.Attribute.t() | Ash.Resource.Actions.Argument.t() | any
) :: map()
defp with_attribute_description(schema, %{"description" => nil}) do
schema
end
defp with_attribute_description(schema, %{description: nil}) do
schema
end
defp with_attribute_description(schema, %{description: description}) do
Map.merge(schema, %{"description" => description})
end
defp with_attribute_description(schema, %{"description" => description}) do
Map.merge(schema, %{"description" => description})
end
defp with_attribute_description(schema, _) do
schema
end
defp field_type(%Ash.Resource.Attribute{type: type, constraints: constraints}, _resource),
do: {type, constraints}
defp field_type(%Ash.Resource.Calculation{type: type, constraints: constraints}, _resource),
do: {type, constraints}
defp field_type(%Ash.Resource.Aggregate{type: type, constraints: constraints}, _resource)
when not is_nil(type),
do: {type, constraints}
defp field_type(
%Ash.Resource.Aggregate{
kind: kind,
field: field,
relationship_path: relationship_path
},
resource
) do
field_type =
with field when not is_nil(field) <- field,
related when not is_nil(related) <-
Ash.Resource.Info.related(resource, relationship_path),
attr when not is_nil(attr) <- Ash.Resource.Info.field(related, field) do
attr.type
end
{:ok, aggregate_type, constraints} =
Ash.Query.Aggregate.kind_to_type(kind, field_type, [])
{aggregate_type, constraints}
end
defp unwrap_any_of(%{"anyOf" => options} = schema) do
{options_remaining, options_to_add} =
Enum.reduce(options, {[], []}, fn schema, {options, to_add} ->
case schema do
%{"anyOf" => _} = schema ->
case unwrap_any_of(schema) do
%{"anyOf" => nested_options} ->
{options, nested_options ++ to_add}
schema ->
{options, [schema | to_add]}
end
_ ->
{[schema | to_add], options}
end
end)
case Enum.uniq(options_remaining ++ options_to_add) do
[] ->
%{"type" => "any"}
[one] ->
one
many ->
%{"anyOf" => many}
end
|> then(fn result ->
case schema["description"] || schema[:description] do
nil -> result
description -> Map.put(result, "description", description)
end
end)
end
defp embedded_type_input(%{type: resource} = attribute, action_type) do
attribute = %{
attribute
| constraints: Ash.Type.NewType.constraints(resource, attribute.constraints)
}
resource =
case attribute.constraints[:instance_of] do
nil -> Ash.Type.NewType.subtype_of(resource)
type -> type
end
create_action =
case attribute.constraints[:create_action] do
nil ->
Ash.Resource.Info.primary_action(resource, :create)
name ->
Ash.Resource.Info.action(resource, name)
end
update_action =
case attribute.constraints[:update_action] do
nil ->
Ash.Resource.Info.primary_action(resource, :update)
name ->
Ash.Resource.Info.action(resource, name)
end
create_write_attributes =
if create_action do
write_attributes(resource, create_action.arguments, create_action, nil)
else
%{}
end
update_write_attributes =
if update_action do
write_attributes(resource, update_action.arguments, update_action, nil)
else
%{}
end
create_required_attributes =
if create_action do
required_write_attributes(resource, create_action.arguments, create_action)
else
[]
end
update_required_attributes =
if update_action do
required_write_attributes(resource, update_action.arguments, update_action)
else
[]
end
required =
if action_type == :create do
create_required_attributes
else
create_required_attributes
|> MapSet.new()
|> MapSet.intersection(MapSet.new(update_required_attributes))
|> Enum.to_list()
end
%{
type: :object,
additionalProperties: false,
properties:
Map.merge(create_write_attributes, update_write_attributes, fn _k, l, r ->
%{
"anyOf" => [
l,
r
]
}
|> unwrap_any_of()
end),
required: required
}
|> add_null_for_non_required()
|> make_all_required()
end
# this is kind of a hack, specifically because open ai requires that all fields
# be required. But the value can be `null`
defp make_all_required(map) do
Map.put(map, :required, Map.keys(map.properties))
end
@doc false
def required_write_attributes(resource, arguments, action, _route \\ nil) do
arguments =
arguments
|> Enum.filter(& &1.public?)
attributes =
case action.type do
type when type in [:action, :read] ->
[]
:update ->
action.require_attributes
_ ->
resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(&(&1.name in action.accept && &1.writable?))
|> Enum.reject(
&(&1.name in arguments || &1.allow_nil? || not is_nil(&1.default) || &1.generated? ||
&1.name in Map.get(action, :allow_nil_input, []))
)
|> Enum.map(& &1.name)
end
arguments =
arguments
|> Enum.reject(& &1.allow_nil?)
|> Enum.map(& &1.name)
Enum.uniq(attributes ++ arguments ++ Map.get(action, :require_attributes, []))
end
@spec write_attributes(
resource :: module,
[Ash.Resource.Actions.Argument.t()],
action :: term(),
route :: term()
) :: %{atom => map()}
defp write_attributes(resource, arguments, action, _route) do
attributes =
if action.type in [:action, :read] do
%{}
else
resource
|> Ash.Resource.Info.attributes()
|> Enum.filter(&(&1.name in action.accept && &1.writable?))
|> Map.new(fn attribute ->
{attribute.name, resource_write_attribute_type(attribute, resource, action.type)}
end)
end
arguments
|> Enum.filter(& &1.public?)
|> Enum.reduce(attributes, fn argument, attributes ->
Map.put(
attributes,
argument.name,
resource_write_attribute_type(argument, resource, :create)
)
end)
end
@spec resource_attribute_type(
term(),
resource :: Ash.Resource.t()
) :: map()
defp resource_attribute_type(type, resource)
defp resource_attribute_type(%Ash.Resource.Aggregate{type: nil} = agg, resource) do
{type, constraints} = field_type(agg, resource)
resource_attribute_type(
Map.merge(agg, %{type: type, constraints: constraints}),
resource
)
end
defp resource_attribute_type(%{type: Ash.Type.String}, _resource) do
%{type: :string}
end
defp resource_attribute_type(%{type: Ash.Type.CiString}, _resource) do
%{type: :string}
end
defp resource_attribute_type(%{type: Ash.Type.Boolean}, _resource) do
%{type: :boolean}
end
defp resource_attribute_type(%{type: Ash.Type.Decimal}, _resource) do
%{type: :string}
end
defp resource_attribute_type(%{type: Ash.Type.Integer, constraints: constraints}, _resource) do
%{type: :integer}
|> add_number_constraints(constraints)
end
defp resource_attribute_type(
%{type: Ash.Type.Map, constraints: constraints} = attr,
resource
) do
if constraints[:fields] && constraints[:fields] != [] do
%{
type: :object,
properties:
Map.new(constraints[:fields], fn {key, config} ->
{key,
resource_attribute_type(
%{
attr
| type: Ash.Type.get_type(config[:type]),
constraints: config[:constraints] || []
}
|> Map.put(:description, config[:description] || nil),
resource
)}
end),
additionalProperties: false,
required:
constraints[:fields]
|> Enum.filter(fn {_, config} -> !config[:allow_nil?] end)
|> Enum.map(&elem(&1, 0))
}
|> add_null_for_non_required()
|> make_all_required()
else
%{type: :object}
end
end
defp resource_attribute_type(%{type: Ash.Type.Float, constraints: constraints}, _resource) do
%{type: :number, format: :float}
|> add_number_constraints(constraints)
end
defp resource_attribute_type(%{type: Ash.Type.Date}, _resource) do
%{type: :string, format: :date}
end
defp resource_attribute_type(%{type: Ash.Type.UtcDatetime}, _resource) do
%{type: :string, format: :"date-time"}
end
defp resource_attribute_type(%{type: Ash.Type.NaiveDatetime}, _resource) do
%{type: :string, format: :"date-time"}
end
defp resource_attribute_type(%{type: Ash.Type.Time}, _resource) do
%{type: :string, format: :time}
end
defp resource_attribute_type(%{type: Ash.Type.UUID}, _resource) do
%{type: :string, format: :uuid}
end
defp resource_attribute_type(%{type: Ash.Type.UUIDv7}, _resource) do
%{type: :string, format: :uuid}
end
defp resource_attribute_type(
%{type: Ash.Type.Atom, constraints: constraints},
_resource
) do
if one_of = constraints[:one_of] do
%{
type: :string,
enum: Enum.map(one_of, &to_string/1)
}
else
%{
type: :string
}
end
end
defp resource_attribute_type(%{type: Ash.Type.DurationName}, _resource) do
%{
type: :string,
enum: Enum.map(Ash.Type.DurationName.values(), &to_string/1)
}
end
defp resource_attribute_type(%{type: Ash.Type.File}, _resource),
do: %{"type" => :string, "format" => :byte, "description" => "Base64 encoded file content"}
defp resource_attribute_type(
%{type: Ash.Type.Union, constraints: constraints} = attr,
resource
) do
subtypes =
Enum.map(constraints[:types], fn {_name, config} ->
fake_attr =
%{
attr
| type: Ash.Type.get_type(config[:type]),
constraints: config[:constraints]
}
|> Map.put(:description, config[:description] || nil)
resource_attribute_type(fake_attr, resource)
end)
%{
"anyOf" => subtypes
}
|> unwrap_any_of()
|> with_attribute_description(attr)
end
defp resource_attribute_type(%{type: {:array, type}} = attr, resource) do
%{
type: :array,
items:
resource_attribute_type(
%{
attr
| type: type,
constraints: attr.constraints[:items] || []
},
resource
)
}
end
defp resource_attribute_type(
%{type: Ash.Type.Struct, constraints: constraints} = attr,
resource
) do
if instance_of = constraints[:instance_of] do
if embedded?(instance_of) && !constraints[:fields] do
%{
type: :object,
additionalProperties: false,
properties: resource_attributes(instance_of, false),
required: required_attributes(instance_of)
}
|> add_null_for_non_required()
|> make_all_required()
else
resource_attribute_type(%{attr | type: Ash.Type.Map}, resource)
end
else
%{}
end
end
defp resource_attribute_type(%{type: type} = attr, resource) do
constraints = attr.constraints
cond do
embedded?(type) ->
%{
type: :object,
additionalProperties: false,
properties: resource_attributes(type, false),
required: required_attributes(type)
}
|> add_null_for_non_required()
|> make_all_required()
Ash.Type.NewType.new_type?(type) ->
new_constraints = Ash.Type.NewType.constraints(type, constraints)
new_type = Ash.Type.NewType.subtype_of(type)
resource_attribute_type(
Map.merge(attr, %{type: Ash.Type.get_type(new_type), constraints: new_constraints}),
resource
)
Spark.implements_behaviour?(type, Ash.Type.Enum) ->
%{
type: :string,
enum: Enum.map(type.values(), &to_string/1)
}
true ->
%{}
end
end
@spec resource_attributes(
resource :: module,
hide_pkeys? :: boolean()
) :: %{
atom => map()
}
defp resource_attributes(resource, hide_pkeys?) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.concat(Ash.Resource.Info.public_calculations(resource))
|> Enum.concat(
Ash.Resource.Info.public_aggregates(resource)
|> set_aggregate_constraints(resource)
)
|> Enum.map(fn
%Ash.Resource.Aggregate{} = agg ->
field =
if agg.field do
related = Ash.Resource.Info.related(resource, agg.relationship_path)
Ash.Resource.Info.field(related, agg.field)
end
field_type =
if field do
field.type
end
field_constraints =
if field do
field.constraints
end
{:ok, type, constraints} =
Ash.Query.Aggregate.kind_to_type(agg.kind, field_type, field_constraints)
type = Ash.Type.get_type(type)
allow_nil? =
is_nil(Ash.Query.Aggregate.default_value(agg.kind))
%{
name: agg.name,
description: agg.description,
type: type,
constraints: constraints,
allow_nil?: allow_nil?
}
other ->
other
end)
|> then(fn keys ->
if hide_pkeys? do
Enum.reject(keys, &only_primary_key?(resource, &1.name))
else
keys
end
end)
|> Map.new(fn attr ->
{attr.name,
resource_attribute_type(attr, resource)
|> with_attribute_description(attr)
|> with_attribute_nullability(attr)
|> with_comment_on_included()}
end)
end
defp add_number_constraints(schema, constraints) do
constraints
|> Keyword.take([:min, :max, :greater_than, :less_than])
|> Enum.reduce(schema, fn
{:min, min}, schema ->
Map.put(schema, :minimum, min)
{:max, max}, schema ->
Map.put(schema, :maximum, max)
{:greater_than, exclusive_min}, schema ->
Map.put(schema, :exclusiveMinimum, exclusive_min)
{:less_than, exclusive_max}, schema ->
Map.put(schema, :exclusiveMaximum, exclusive_max)
end)
end
defp required_attributes(resource) do
resource
|> Ash.Resource.Info.public_attributes()
|> Enum.reject(&(&1.allow_nil? || only_primary_key?(resource, &1.name)))
|> Enum.map(& &1.name)
end
@spec with_comment_on_included(map()) :: map()
defp with_comment_on_included(schema) do
key = if Map.has_key?(schema, :description), do: :description, else: "description"
new_description =
case Map.get(schema, key) do
nil ->
"Field included by default."
description ->
if String.ends_with?(description, ["!", "."]) do
description <> " Field included by default."
else
description <> ". Field included by default."
end
end
Map.put(schema, key, new_description)
end
defp with_attribute_nullability(%{type: nil} = schema, _), do: schema
defp with_attribute_nullability(%{} = schema, attr) do
cond do
schema.type == "any" || schema.type == :any ->
schema
attr.allow_nil? ->
Map.put(schema, :nullable, true)
true ->
schema
end
end
def raw_filter_type(%Ash.Resource.Calculation{} = calculation, resource) do
{type, _constraints} = field_type(calculation, resource)
input =
if Enum.empty?(calculation.arguments) do
[]
else
inputs =
Enum.map(calculation.arguments, fn argument ->
{argument.name, resource_write_attribute_type(argument, resource, :create)}
end)
required =
Enum.flat_map(calculation.arguments, fn argument ->
if argument.allow_nil? do
[]
else
[argument.name]
end
end)
schema =
%{
type: :object,
properties: Map.new(inputs),
required: required,
additionalProperties: false
}
[
{:input, schema}
]
end
array_type? = match?({:array, _}, type)
fields =
Ash.Filter.builtin_operators()
|> Enum.concat(Ash.Filter.builtin_functions())
|> Enum.concat(Ash.DataLayer.functions(resource))
|> Enum.filter(& &1.predicate?())
|> restrict_for_lists(type)
|> Enum.flat_map(fn operator ->
filter_fields(operator, type, array_type?, calculation, resource)
end)
input_required = Enum.any?(calculation.arguments, &(!&1.allow_nil?))
fields_with_input =
Enum.concat(fields, input)
required =
if input_required do
[:input]
else
[]
end
if fields == [] do
nil
else
%{
type: :object,
required: required,
properties: Map.new(fields_with_input),
additionalProperties: false
}
|> with_attribute_description(calculation)
end
end
def raw_filter_type(attribute_or_aggregate, resource) do
{type, _constraints} = field_type(attribute_or_aggregate, resource)
array_type? = match?({:array, _}, type)
fields =
Ash.Filter.builtin_operators()
|> Enum.concat(Ash.Filter.builtin_functions())
|> Enum.concat(Ash.DataLayer.functions(resource))
|> Enum.filter(& &1.predicate?())
|> restrict_for_lists(type)
|> Enum.flat_map(fn operator ->
filter_fields(operator, type, array_type?, attribute_or_aggregate, resource)
end)
if fields == [] do
nil
else
%{
type: :object,
properties: Map.new(fields),
additionalProperties: false
# required: required Missing?
}
|> with_attribute_description(attribute_or_aggregate)
end
end
defp restrict_for_lists(operators, {:array, _}) do
list_predicates = [Ash.Query.Operator.IsNil, Ash.Query.Operator.Has]
Enum.filter(operators, &(&1 in list_predicates))
end
defp restrict_for_lists(operators, _), do: operators
defp filter_fields(
operator,
type,
array_type?,
attribute_or_aggregate,
resource
) do
expressable_types = get_expressable_types(operator, type, array_type?)
if Enum.any?(expressable_types, &(&1 == :same)) do
[
{operator.name(), resource_attribute_type(attribute_or_aggregate, resource)}
]
else
type =
case Enum.at(expressable_types, 0) do
[{:array, :any}, :same] ->
{:unwrap, type}
[_, {:array, :same}] ->
{:array, type}
[_, :same] ->
type
[_, :any] ->
Ash.Type.String
[_, type] when is_atom(type) ->
Ash.Type.get_type(type)
_ ->
nil
end
if type do
{type, attribute_or_aggregate} =
case type do
{:unwrap, type} ->
{:array, type} = type
{type, %{attribute_or_aggregate | type: type, constraints: []}}
type ->
{type, %{attribute_or_aggregate | type: type, constraints: []}}
end
if embedded?(type) do
[]
else
attribute_or_aggregate = constraints_to_item_constraints(type, attribute_or_aggregate)
[
{operator.name(), resource_attribute_type(attribute_or_aggregate, resource)}
]
end
else
[]
end
end
end
defp get_expressable_types(operator_or_function, field_type, array_type?) do
if :attributes
|> operator_or_function.__info__()
|> Keyword.get_values(:behaviour)
|> List.flatten()
|> Enum.any?(&(&1 == Ash.Query.Operator)) do
do_get_expressable_types(operator_or_function.types(), field_type, array_type?)
else
do_get_expressable_types(operator_or_function.args(), field_type, array_type?)
end
end
defp do_get_expressable_types(operator_types, field_type, array_type?) do
field_type_short_name =
case Ash.Type.short_names()
|> Enum.find(fn {_, type} -> type == field_type end) do
nil -> nil
{short_name, _} -> short_name
end
operator_types
|> Enum.filter(fn
[:any, {:array, type}] when is_atom(type) ->
true
[{:array, inner_type}, :same] when is_atom(inner_type) and array_type? ->
true
:same ->
true
:any ->
true
[:any, type] when is_atom(type) ->
true
[^field_type_short_name, type] when is_atom(type) and not is_nil(field_type_short_name) ->
true
_ ->
false
end)
end
defp constraints_to_item_constraints(
{:array, _},
%Ash.Resource.Attribute{
constraints: constraints,
allow_nil?: allow_nil?
} = attribute
) do
%{
attribute
| constraints: [
items: constraints,
nil_items?: allow_nil? || embedded?(attribute.type)
]
}
end
defp constraints_to_item_constraints(_, attribute_or_aggregate), do: attribute_or_aggregate
defp embedded?({:array, resource_or_type}) do
embedded?(resource_or_type)
end
defp embedded?(resource_or_type) do
if Ash.Resource.Info.resource?(resource_or_type) do
true
else
Ash.Type.embedded_type?(resource_or_type)
end
end
defp only_primary_key?(resource, field) do
resource
|> Ash.Resource.Info.primary_key()
|> case do
[^field] -> true
_ -> false
end
end
defp set_aggregate_constraints(aggregates, resource) do
Enum.map(aggregates, fn %{field: field, relationship_path: relationship_path} = aggregate ->
field_type_and_constraints =
with field when not is_nil(field) <- field,
related when not is_nil(related) <-
Ash.Resource.Info.related(resource, relationship_path),
attr when not is_nil(attr) <- Ash.Resource.Info.field(related, field) do
{attr.type, attr.constraints}
end
{field_type, field_constraints} = field_type_and_constraints || {nil, []}
{:ok, aggregate_type, aggregate_constraints} =
Ash.Query.Aggregate.kind_to_type(aggregate.kind, field_type, field_constraints)
Map.merge(aggregate, %{type: aggregate_type, constraints: aggregate_constraints})
end)
end
end