Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/ash_typescript.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ defmodule AshTypescript do
Application.get_env(:ash_typescript, :type_mapping_overrides, [])
end

@doc """
Gets extra TypedStructs to generate types for, even if not referenced by RPC resources.

## Configuration

config :ash_typescript,
extra_structs: [Platform.Auth.SessionInfo]

## Returns
A list of TypedStruct module atoms.
"""
def extra_structs do
Application.get_env(:ash_typescript, :extra_structs, [])
end

@doc """
Gets whether to generate generic filter schemas (e.g. UserFilterSchema) for Zod/Valibot.
Defaults to false.
"""
def generate_filter_schemas? do
Application.get_env(:ash_typescript, :generate_filter_schemas, false)
end

@doc """
Gets whether to generate clean types for resources. Defaults to true.
"""
def generate_clean_types? do
Application.get_env(:ash_typescript, :generate_clean_types, true)
end

@doc """
Gets the TypeScript type to use for untyped maps from application configuration.

Expand Down
80 changes: 16 additions & 64 deletions lib/ash_typescript/codegen/filter_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,8 @@ defmodule AshTypescript.Codegen.FilterTypes do

defp generate_attribute_filter(attribute, resource) do
base_type = TypeMapper.get_ts_type(attribute)
allow_nil? = Map.get(attribute, :allow_nil?, true)

# Generate specific filter operations based on the attribute type
operations = get_applicable_operations(attribute.type, base_type, allow_nil?)
filter_type = map_to_generic_filter(attribute.type, base_type)

formatted_name =
AshTypescript.FieldFormatter.format_field_for_client(
Expand All @@ -141,9 +139,7 @@ defmodule AshTypescript.Codegen.FilterTypes do
)

"""
#{formatted_name}?: {
#{Enum.join(operations, "\n")}
};
#{formatted_name}?: #{filter_type};
"""
end

Expand Down Expand Up @@ -176,16 +172,12 @@ defmodule AshTypescript.Codegen.FilterTypes do
base_type = TypeMapper.get_ts_type(field)
array_type = "Array<#{base_type}>"

operations =
[:eq, :not_eq, :in, :is_nil]
|> Enum.map(&format_operation(&1, array_type))
filter_type = "GenericFilter<#{array_type}>"

formatted_name = format_aggregate_name(aggregate.name, resource)

"""
#{formatted_name}?: {
#{Enum.join(operations, "\n")}
};
#{formatted_name}?: #{filter_type};
"""
end
end
Expand All @@ -195,13 +187,11 @@ defmodule AshTypescript.Codegen.FilterTypes do

defp generate_fixed_type_aggregate_filter(aggregate, type, resource) do
base_type = TypeMapper.get_ts_type(%{type: type}, nil)
operations = get_applicable_operations(type, base_type, _allow_nil? = true)
filter_type = map_to_generic_filter(type, base_type)
formatted_name = format_aggregate_name(aggregate.name, resource)

"""
#{formatted_name}?: {
#{Enum.join(operations, "\n")}
};
#{formatted_name}?: #{filter_type};
"""
end

Expand Down Expand Up @@ -229,15 +219,17 @@ defmodule AshTypescript.Codegen.FilterTypes do
AshTypescript.FieldFormatter.format_field_for_client(name, resource, formatter())
end

defp get_applicable_operations(type, base_type, allow_nil?) do
ops =
type
|> classify_filter_type()
|> get_operations_for_type()
defp map_to_generic_filter(type, base_type) do
category = classify_filter_type(type)

ops = if allow_nil?, do: ops ++ [:is_nil], else: ops

Enum.map(ops, &format_operation(&1, base_type))
case category do
:string -> "StringFilter"
:numeric -> "NumberFilter<#{base_type}>"
:datetime -> "DateFilter<#{base_type}>"
:boolean -> "BooleanFilter"
:atom -> "AtomFilter"
:default -> "GenericFilter<#{base_type}>"
end
end

defp classify_filter_type(type) do
Expand Down Expand Up @@ -272,46 +264,6 @@ defmodule AshTypescript.Codegen.FilterTypes do
end
end

defp get_operations_for_type(:string), do: [:eq, :not_eq, :in]

defp get_operations_for_type(:numeric),
do: [
:eq,
:not_eq,
:greater_than,
:greater_than_or_equal,
:less_than,
:less_than_or_equal,
:in
]

defp get_operations_for_type(:datetime),
do: [
:eq,
:not_eq,
:greater_than,
:greater_than_or_equal,
:less_than,
:less_than_or_equal,
:in
]

defp get_operations_for_type(:boolean), do: [:eq, :not_eq]
defp get_operations_for_type(:atom), do: [:eq, :not_eq, :in]
defp get_operations_for_type(:default), do: [:eq, :not_eq, :in]

defp format_operation(:is_nil, _base_type) do
" #{format_field("is_nil")}?: boolean;"
end

defp format_operation(:in, base_type) do
" #{format_field("in")}?: Array<#{base_type}>;"
end

defp format_operation(op, base_type) do
" #{format_field(Atom.to_string(op))}?: #{base_type};"
end

defp generate_relationship_filters(resource, allowed_resources) do
resource
|> Ash.Resource.Info.public_relationships()
Expand Down
11 changes: 8 additions & 3 deletions lib/ash_typescript/codegen/orchestrator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,16 @@ defmodule AshTypescript.Codegen.Orchestrator do
embedded_resources = TypeDiscovery.find_embedded_resources(otp_app)
struct_argument_resources = TypeDiscovery.find_struct_argument_resources(otp_app)
controller_resources = collect_typed_controller_resources()
extra_structs = AshTypescript.extra_structs()

all_resources =
(rpc_resources ++ embedded_resources ++ struct_argument_resources ++ controller_resources)
(rpc_resources ++
embedded_resources ++ struct_argument_resources ++ controller_resources ++ extra_structs)
|> Enum.uniq()
|> Enum.sort_by(&inspect/1)

schema_resources =
(embedded_resources ++ struct_argument_resources ++ controller_resources)
(embedded_resources ++ struct_argument_resources ++ controller_resources ++ extra_structs)
|> Enum.uniq()
|> Enum.sort_by(&inspect/1)

Expand Down Expand Up @@ -329,12 +331,15 @@ defmodule AshTypescript.Codegen.Orchestrator do
resource_schemas_str =
SchemaCore.generate_schemas_for_resources(formatter, schema_resources)

all_schema_strings = [resource_schemas_str | additional_schemas]
generic_schemas_str = SchemaCore.generate_generic_schemas(formatter)

all_schema_strings = [resource_schemas_str, generic_schemas_str | additional_schemas]
validate_unique_schema_names!(formatter, all_schema_strings)

content =
SharedSchemaGenerator.generate(formatter,
resource_schemas: resource_schemas_str,
generic_schemas: generic_schemas_str,
types_output_file: types_output_file,
schema_output_file: output_file,
additional_schemas: additional_schemas
Expand Down
126 changes: 100 additions & 26 deletions lib/ash_typescript/codegen/resource_schemas.ex
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,16 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
# Used by first aggregates and load restrictions (allowed_loads with flat allows)
attributes_only_schema = generate_attributes_only_schema(resource, allowed_resources)

clean_mapped_type = """
export type #{resource_name} = Clean<#{resource_name}ResourceSchema>;
"""

base_schemas = """
// #{resource_name} Schema
#{unified_schema}
"""

[base_schemas, attributes_only_schema, input_schema]
[base_schemas, attributes_only_schema, clean_mapped_type, input_schema]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n\n")
end
Expand Down Expand Up @@ -252,19 +256,63 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
)
)

primitive_fields_union =
generate_primitive_fields_union(Enum.map(primitive_fields, & &1.name), resource)
primitive_fields_structs = primitive_fields
primitive_fields = Enum.map(primitive_fields, & &1.name)

const_name = "#{Helpers.camel_case_prefix(resource_name)}ResourcePrimitiveFields"

primitive_array_elements =
Enum.map_join(primitive_fields, ", ", fn field ->
"\"#{format_client_field_name(resource, field)}\""
end)

{primitive_fields_union, const_declaration} =
if Enum.empty?(primitive_fields) do
{"never", ""}
else
{"(typeof #{const_name})[number]",
"export const #{const_name} = [#{primitive_array_elements}] as const;"}
end

enum_fields =
primitive_fields_structs
|> Enum.filter(fn field ->
{base, _} = Introspection.unwrap_new_type(field.type, field.constraints || [])
is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum)
end)

enum_consts =
enum_fields
|> Enum.map_join("\n", fn field ->
{base, _} = Introspection.unwrap_new_type(field.type, field.constraints || [])

const_name =
"#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum"

values = Enum.map_join(base.values(), ", ", &"\"#{to_string(&1)}\"")
"export const #{const_name} = [#{values}] as const;"
end)

metadata_schema_fields = [
" __type: \"Resource\";",
" __primitiveFields: #{primitive_fields_union};"
]

all_field_lines =
primitive_fields
primitive_fields_structs
|> Enum.map(fn field ->
formatted_name = format_client_field_name(resource, field.name)
type_str = TypeMapper.get_ts_type(field)
{base, _} = Introspection.unwrap_new_type(field.type, field.constraints || [])

type_str =
if is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum) do
const_name =
"#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum"

"(typeof #{const_name})[number]"
else
TypeMapper.get_ts_type(field)
end

if allow_nil?(field) do
" #{formatted_name}: #{type_str} | null;"
Expand All @@ -281,8 +329,15 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
|> then(&Enum.concat(metadata_schema_fields, &1))
|> Enum.join("\n")

const_out =
[const_declaration, enum_consts]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")

const_out = if const_out != "", do: "#{const_out}\n", else: ""

"""
export type #{resource_name}ResourceSchema = {
#{const_out}export type #{resource_name}ResourceSchema = {
#{all_field_lines}
};
"""
Expand All @@ -309,19 +364,46 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
is_complex_attr?(attr)
end)

primitive_fields_union =
generate_primitive_fields_union(Enum.map(primitive_attrs, & &1.name), resource)
primitive_attrs_structs = primitive_attrs
primitive_attrs = Enum.map(primitive_attrs, & &1.name)

const_name = "#{Helpers.camel_case_prefix(resource_name)}AttributesOnlyPrimitiveFields"

primitive_array_elements =
Enum.map_join(primitive_attrs, ", ", fn field ->
"\"#{format_client_field_name(resource, field)}\""
end)

{primitive_fields_union, const_declaration} =
if Enum.empty?(primitive_attrs) do
{"never", ""}
else
{"(typeof #{const_name})[number]",
"export const #{const_name} = [#{primitive_array_elements}] as const;"}
end

enum_consts = ""

metadata_schema_fields = [
" __type: \"Resource\";",
" __primitiveFields: #{primitive_fields_union};"
]

all_field_lines =
primitive_attrs
primitive_attrs_structs
|> Enum.map(fn field ->
formatted_name = format_client_field_name(resource, field.name)
type_str = TypeMapper.get_ts_type(field)
{base, _} = Introspection.unwrap_new_type(field.type, field.constraints || [])

type_str =
if is_atom(base) and Spark.implements_behaviour?(base, Ash.Type.Enum) do
const_name =
"#{Helpers.camel_case_prefix(resource_name)}#{Helpers.camel_case_prefix(to_string(field.name))}Enum"

"(typeof #{const_name})[number]"
else
TypeMapper.get_ts_type(field)
end

if allow_nil?(field) do
" #{formatted_name}: #{type_str} | null;"
Expand All @@ -338,8 +420,15 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
|> then(&Enum.concat(metadata_schema_fields, &1))
|> Enum.join("\n")

const_out =
[const_declaration, enum_consts]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")

const_out = if const_out != "", do: "#{const_out}\n", else: ""

"""
export type #{resource_name}AttributesOnlySchema = {
#{const_out}export type #{resource_name}AttributesOnlySchema = {
#{all_field_lines}
};
"""
Expand Down Expand Up @@ -635,21 +724,6 @@ defmodule AshTypescript.Codegen.ResourceSchemas do
end
end

defp generate_primitive_fields_union(fields, resource) do
if Enum.empty?(fields) do
"never"
else
fields
|> Enum.map_join(
" | ",
fn field_name ->
formatted = format_client_field_name(resource, field_name)
"\"#{formatted}\""
end
)
end
end

defp relationship_field_definition(resource, rel) do
formatted_name = format_client_field_name(resource, rel.name)
related_resource_name = Helpers.build_resource_type_name(rel.destination)
Expand Down
Loading
Loading