diff --git a/.check.exs b/.check.exs index 0621cea..df516ce 100644 --- a/.check.exs +++ b/.check.exs @@ -20,6 +20,7 @@ {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]}, {:credo, "mix credo --strict"}, {:sobelow, "mix sobelow --config"}, + {:test_codegen, "mix test.codegen"}, {:compile_generated, "mix cmd --cd test/ts npm run compileGenerated"}, {:compile_should_pass, "mix cmd --cd test/ts npm run compileShouldPass"}, {:compile_should_fail, "mix cmd --cd test/ts npm run compileShouldFail"}, diff --git a/.formatter.exs b/.formatter.exs index dc1f699..5b192ee 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -5,14 +5,17 @@ # Used by "mix format" spark_locals_without_parens = [ argument_names: 1, + error_handler: 1, field_names: 1, fields: 1, metadata_field_names: 1, + read_action: 1, resource: 1, resource: 2, rpc_action: 2, rpc_action: 3, show_metadata: 1, + show_raised_errors?: 1, ts_fields_const_name: 1, ts_result_type_name: 1, type_name: 1, diff --git a/documentation/dsls/DSL-AshTypescript.Rpc.md b/documentation/dsls/DSL-AshTypescript.Rpc.md index ad0d87c..bbfe08a 100644 --- a/documentation/dsls/DSL-AshTypescript.Rpc.md +++ b/documentation/dsls/DSL-AshTypescript.Rpc.md @@ -17,6 +17,15 @@ Define available RPC-actions for resources in this domain. +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`error_handler`](#typescript_rpc-error_handler){: #typescript_rpc-error_handler } | `mfa \| module` | `{AshTypescript.Rpc.DefaultErrorHandler, :handle_error, []}` | An MFA or module that implements error handling for RPC operations. The error handler will be called with (error, context) and should return a modified error map. If a module is provided, it must export a handle_error/2 function. Example: ```elixir error_handler {MyApp.CustomErrorHandler, :handle_error, []} # or error_handler MyApp.CustomErrorHandler ``` | +| [`show_raised_errors?`](#typescript_rpc-show_raised_errors?){: #typescript_rpc-show_raised_errors? } | `boolean` | `false` | Whether to show detailed information for raised exceptions. Set to true in development to see full error details. Keep false in production for security. | + + + ### typescript_rpc.resource ```elixir resource resource @@ -71,6 +80,7 @@ Example: `metadata_field_names [field_1: :field1, is_valid?: :isValid]` | Name | Type | Default | Docs | |------|------|---------|------| +| [`read_action`](#typescript_rpc-resource-rpc_action-read_action){: #typescript_rpc-resource-rpc_action-read_action } | `atom` | | The read action to use for update and destroy operations when finding records | | [`show_metadata`](#typescript_rpc-resource-rpc_action-show_metadata){: #typescript_rpc-resource-rpc_action-show_metadata } | `nil \| boolean \| list(atom)` | | Which metadata fields to expose (nil=all, false/[]=none, list=specific fields) | | [`metadata_field_names`](#typescript_rpc-resource-rpc_action-metadata_field_names){: #typescript_rpc-resource-rpc_action-metadata_field_names } | `list({atom, atom})` | `[]` | Map metadata field names to valid TypeScript identifiers | diff --git a/documentation/topics/error-handling.md b/documentation/topics/error-handling.md new file mode 100644 index 0000000..40ad24e --- /dev/null +++ b/documentation/topics/error-handling.md @@ -0,0 +1,305 @@ + + +# Error Handling + +AshTypescript provides a comprehensive error handling system that transforms Ash framework errors into TypeScript-friendly JSON responses. Errors are returned with structured information that can be easily consumed by TypeScript clients. + +## Error Response Format + +All errors from RPC actions are returned in a standardized format: + +```typescript +export type AshRpcError = { + type: string; // Error type (e.g., "not_found", "invalid_attribute") + message: string; // Error message template (may contain %{var} placeholders) + shortMessage?: string; // Brief error description + fields?: string[]; // Affected field names + path?: Array; // Path to error location in data structure + vars?: Record; // Variables for message interpolation + details?: Record; // Additional error context + errorId?: string; // Unique error identifier for tracking +} +``` + +## Client-Side Variable Interpolation + +Unlike server-side rendering, AshTypescript returns error messages as templates with separate variables. This allows clients to handle localization and formatting according to their needs: + +```typescript +// Server returns: +{ + type: "required", + message: "Field %{field} is required", + vars: { field: "email" }, + fields: ["email"] +} + +// Client can interpolate: +function interpolateMessage(error: AshRpcError): string { + let message = error.message; + if (error.vars) { + Object.entries(error.vars).forEach(([key, value]) => { + message = message.replace(`%{${key}}`, String(value)); + }); + } + return message; +} +``` + +## Error Types + +AshTypescript implements protocol-based error handling for common Ash error types: + +- `not_found` - Resource or record not found +- `required` - Required field missing +- `invalid_attribute` - Invalid attribute value +- `invalid_argument` - Invalid action argument +- `forbidden` - Authorization failure +- `forbidden_field` - Field-level authorization failure +- `invalid_changes` - Invalid changeset +- `invalid_query` - Invalid query parameters +- `invalid_page` - Invalid pagination parameters +- `invalid_keyset` - Invalid keyset for pagination +- `invalid_primary_key` - Invalid primary key value +- `unknown_field` - Unknown or inaccessible field +- `unknown_error` - Unexpected error + +## Configuring Error Handlers + +### Domain-Level Error Handler + +Configure a custom error handler for all resources in a domain: + +```elixir +defmodule MyApp.Domain do + use Ash.Domain, + extensions: [AshTypescript.Rpc] + + rpc do + error_handler {MyApp.RpcErrorHandler, :handle_error, []} + end +end +``` + +### Resource-Level Error Handler + +Configure error handling for specific resources: + +```elixir +defmodule MyApp.Resource do + use Ash.Resource, + domain: MyApp.Domain, + extensions: [AshTypescript.Resource] + + rpc do + error_handler {MyApp.ResourceErrorHandler, :handle_error, []} + end +end +``` + +When both domain and resource error handlers are defined, they are applied in sequence: +1. Resource error handler (if defined) +2. Domain error handler (if defined) +3. Default error handler + +## Writing Custom Error Handlers + +Error handlers receive the error and context, allowing for custom transformations: + +```elixir +defmodule MyApp.RpcErrorHandler do + def handle_error(error, context) do + # Context includes: + # - domain: The domain module + # - resource: The resource module (if applicable) + # - action: The action being performed + # - actor: The current actor/user + + case error.type do + "forbidden" -> + # Customize forbidden errors + %{error | message: "Access denied to this resource"} + + "not_found" -> + # Add custom details for not found errors + %{error | details: Map.put(error.details || %{}, :support_url, "https://example.com/help")} + + _ -> + # Pass through other errors unchanged + error + end + end +end +``` + +### Action-Specific Error Handling + +You can customize errors based on the specific action that triggered them: + +```elixir +defmodule MyApp.ResourceErrorHandler do + def handle_error(error, %{action: action} = context) do + case action.name do + :create -> + # Special handling for create actions + customize_create_error(error) + + :update -> + # Special handling for update actions + customize_update_error(error) + + _ -> + # Default handling + error + end + end + + defp customize_create_error(%{type: "required"} = error) do + %{error | message: "This field is required when creating a new record"} + end + + defp customize_create_error(error), do: error + + defp customize_update_error(error), do: error +end +``` + +## Custom Error Types + +To add support for custom Ash errors, implement the `AshTypescript.Rpc.Error` protocol: + +```elixir +defmodule MyApp.CustomError do + use Splode.Error, fields: [:field, :reason], class: :invalid + + def message(error) do + "Custom validation failed for #{error.field}: #{error.reason}" + end +end + +defimpl AshTypescript.Rpc.Error, for: MyApp.CustomError do + def to_error(error) do + %{ + message: "Field %{field} failed validation: %{reason}", + short_message: "Validation failed", + type: "custom_validation_error", + vars: %{ + field: error.field, + reason: error.reason + }, + fields: [error.field], + path: [] + } + end +end +``` + +## Field Path Tracking + +Errors include a `path` field (returned as camelCase `fieldPath` in JSON) that tracks the location of errors in nested data structures: + +```javascript +// Error in nested relationship field +{ + type: "unknown_field", + message: "Unknown field 'user.invalid_field'", + fieldPath: "user.invalid_field", + path: ["user"] +} + +// Error in array element +{ + type: "invalid_attribute", + message: "Invalid value at position %{index}", + vars: { index: 2 }, + path: ["items", 2, "quantity"] +} +``` + +## Handling Multiple Errors + +When multiple errors occur, they are returned as an array in the `errors` field: + +```typescript +interface RpcErrorResponse { + success: false; + errors: AshRpcError[]; +} + +// Client handling +async function handleRpcCall(response: any) { + if (!response.success) { + response.errors.forEach((error: AshRpcError) => { + console.error(`${error.type}: ${interpolateMessage(error)}`); + + // Handle specific error types + if (error.type === "forbidden") { + redirectToLogin(); + } else if (error.type === "validation_error") { + highlightFields(error.fields); + } + }); + } +} +``` + +## TypeScript Integration + +The generated TypeScript client includes full type definitions for error handling: + +```typescript +// Using generated RPC functions +import { createTodo } from './generated'; + +try { + const result = await createTodo({ + title: "New Todo", + userId: "123" + }); + + if (result.success) { + console.log("Created:", result.data); + } else { + // TypeScript knows result.errors is AshRpcError[] + result.errors.forEach(error => { + if (error.type === "required") { + console.error(`Missing required field: ${error.fields?.[0]}`); + } + }); + } +} catch (e) { + // Network or other errors + console.error("Request failed:", e); +} +``` + +## Best Practices + +1. **Let the client handle interpolation**: Return message templates and variables separately for better localization support. + +2. **Use specific error types**: Choose the most specific error type that matches the condition. + +3. **Include field information**: Always populate the `fields` array for field-specific errors. + +4. **Provide actionable messages**: Error messages should guide users on how to fix the issue. + +5. **Track error paths**: Use the `path` field to indicate where in nested structures errors occurred. + +6. **Add debugging context**: Use the `details` field to include additional debugging information (but be careful not to expose sensitive data). + +7. **Handle errors gracefully in TypeScript**: Always check the `success` field before accessing `data` in responses. + +## Differences from GraphQL Error Handling + +Unlike AshGraphql which can interpolate variables server-side, AshTypescript intentionally returns templates and variables separately. This design choice provides: + +- Better support for client-side localization +- Flexibility in message formatting +- Ability to use different messages for the same error type based on client context +- Reduced server-side processing + +The error structure is also flattened compared to GraphQL's nested error format, making it easier to work with in TypeScript applications. \ No newline at end of file diff --git a/lib/ash_typescript/codegen/resource_schemas.ex b/lib/ash_typescript/codegen/resource_schemas.ex index 02ad5f7..99ee95d 100644 --- a/lib/ash_typescript/codegen/resource_schemas.ex +++ b/lib/ash_typescript/codegen/resource_schemas.ex @@ -51,62 +51,228 @@ defmodule AshTypescript.Codegen.ResourceSchemas do def generate_unified_resource_schema(resource, allowed_resources) do resource_name = Helpers.build_resource_type_name(resource) - primitive_fields = get_primitive_fields(resource) + fields = + resource + |> Ash.Resource.Info.fields([:attributes, :aggregates, :calculations]) + |> Enum.filter(& &1.public?) + |> Enum.map(fn + %Ash.Resource.Aggregate{} = aggregate -> + attribute = + if aggregate.field do + related = Ash.Resource.Info.related(resource, aggregate.relationship_path) + Ash.Resource.Info.attribute(related, aggregate.field) + end + + attribute_type = + if attribute do + attribute.type + end + + attribute_constraints = + if attribute do + attribute.constraints + end - primitive_fields_union = generate_primitive_fields_union(primitive_fields, resource) + case Ash.Query.Aggregate.kind_to_type( + aggregate.kind, + attribute_type, + attribute_constraints + ) do + {:ok, type, constraints} -> + Map.merge(aggregate, %{type: type, constraints: constraints}) + + _other -> + aggregate + end + + field -> + field + end) + + {complex_fields, primitive_fields} = + Enum.split_with(fields, fn field -> + is_complex_attr?(field) + end) + + complex_fields = + Enum.concat( + complex_fields, + Enum.filter( + Ash.Resource.Info.public_relationships(resource), + &(&1.destination in allowed_resources) + ) + ) + + primitive_fields_union = + generate_primitive_fields_union(Enum.map(primitive_fields, & &1.name), resource) metadata_schema_fields = [ " __type: \"Resource\";", " __primitiveFields: #{primitive_fields_union};" ] - primitive_field_defs = generate_primitive_field_definitions(resource) + all_field_lines = + primitive_fields + |> Enum.map(fn field -> + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, field.name) - relationship_field_defs = generate_relationship_field_definitions(resource, allowed_resources) - embedded_field_defs = generate_embedded_field_definitions(resource, allowed_resources) - complex_calc_field_defs = generate_complex_calculation_field_definitions(resource) - union_field_defs = generate_union_field_definitions(resource) - keyword_tuple_field_defs = generate_keyword_tuple_field_definitions(resource) + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - all_field_lines = - metadata_schema_fields ++ - primitive_field_defs ++ - relationship_field_defs ++ - embedded_field_defs ++ - complex_calc_field_defs ++ - union_field_defs ++ - keyword_tuple_field_defs + type_str = TypeMapper.get_ts_type(field) + + if allow_nil?(field) do + " #{formatted_name}: #{type_str} | null;" + else + " #{formatted_name}: #{type_str};" + end + end) + |> Enum.concat( + Enum.map(complex_fields, fn field -> + case field do + %rel{} + when rel in [ + Ash.Resource.Relationships.HasMany, + Ash.Resource.Relationships.ManyToMany, + Ash.Resource.Relationships.HasOne, + Ash.Resource.Relationships.BelongsTo + ] -> + relationship_field_definition(resource, field) + + %Ash.Resource.Calculation{} = calc -> + complex_calculation_definition(resource, calc) + + field -> + if type_str = type_name(field) do + mapped_name = + AshTypescript.Resource.Info.get_mapped_field_name(resource, field.name) + + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) + + if allow_nil?(field) do + " #{formatted_name}: #{type_str} | null;" + else + " #{formatted_name}: #{type_str};" + end + else + # Check the actual type (unwrap NewType for classification) + actual_field = + if Ash.Type.NewType.new_type?(field.type) do + %{ + field + | type: Ash.Type.NewType.subtype_of(field.type), + constraints: + Ash.Type.NewType.constraints(field.type, field.constraints || []) + } + else + field + end + + cond do + is_embedded_attribute?(actual_field) -> + if embedded_resource_allowed?(actual_field, allowed_resources) do + embedded_field_definition(resource, actual_field) + else + nil + end + + is_union_attribute?(actual_field) -> + union_field_definition(resource, actual_field) + + is_map_attribute?(actual_field) or is_keyword_attribute?(actual_field) or + is_tuple_attribute?(actual_field) -> + typed_map_field_definition(resource, field) + + true -> + mapped_name = + AshTypescript.Resource.Info.get_mapped_field_name(resource, field.name) + + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) + + # Pass the ORIGINAL field, not the unwrapped one, so TypeMapper + # can access typescript_field_names callback if present + type_str = TypeMapper.get_ts_type(field) + + if allow_nil?(field) do + " #{formatted_name}: #{type_str} | null;" + else + " #{formatted_name}: #{type_str};" + end + end + end + end + end) + ) + |> Enum.filter(& &1) + |> then(&Enum.concat(metadata_schema_fields, &1)) + |> Enum.join("\n") """ export type #{resource_name}ResourceSchema = { - #{Enum.join(all_field_lines, "\n")} + #{all_field_lines} }; """ end - defp get_primitive_fields(resource) do - attributes = Ash.Resource.Info.public_attributes(resource) - calculations = Ash.Resource.Info.public_calculations(resource) - aggregates = Ash.Resource.Info.public_aggregates(resource) - - primitive_attrs = - attributes - |> Enum.reject(fn attr -> - is_union_attribute?(attr) or - is_embedded_attribute?(attr) or - is_typed_struct_attribute?(attr) or - is_keyword_attribute?(attr) or - is_tuple_attribute?(attr) - end) - |> Enum.map(& &1.name) + defp allow_nil?(%{include_nil?: include_nil?}) do + include_nil? + end - simple_calcs = - calculations - |> Enum.filter(&Helpers.is_simple_calculation/1) - |> Enum.map(& &1.name) + defp allow_nil?(%{allow_nil?: allow_nil?}) do + allow_nil? + end + + defp type_name(%{type: {:array, type}} = attr) do + case type_name(%{attr | type: type}) do + nil -> nil + type_name -> "#{type_name}[]" + end + end + + defp type_name(%{type: type}) do + if function_exported?(type, :typescript_type_name, 0) do + type.typescript_type_name() + end + end + + defp is_complex_attr?(attr, first_check? \\ true) + + defp is_complex_attr?(%Ash.Resource.Calculation{} = calc, true) do + if Helpers.is_simple_calculation(calc) do + is_complex_attr?(calc, false) + else + # Calculations with arguments are always complex + true + end + end - aggregate_names = Enum.map(aggregates, & &1.name) - primitive_attrs ++ simple_calcs ++ aggregate_names + defp is_complex_attr?(attr, _) do + if Ash.Type.NewType.new_type?(attr.type) do + is_complex_attr?(%{ + attr + | type: Ash.Type.NewType.subtype_of(attr.type), + constraints: Ash.Type.NewType.constraints(attr.type, attr.constraints || []) + }) + else + is_union_attribute?(attr) or + is_embedded_attribute?(attr) or + is_typed_struct_attribute?(attr) or + is_keyword_attribute?(attr) or + is_map_attribute?(attr) or + is_struct_attribute?(attr) or + is_tuple_attribute?(attr) + end end defp get_union_primitive_fields(union_types) do @@ -166,280 +332,242 @@ defmodule AshTypescript.Codegen.ResourceSchemas do end end - defp generate_primitive_field_definitions(resource) do - attributes = Ash.Resource.Info.public_attributes(resource) - calculations = Ash.Resource.Info.public_calculations(resource) - aggregates = Ash.Resource.Info.public_aggregates(resource) - - primitive_attrs = - attributes - |> Enum.reject(fn attr -> - is_union_attribute?(attr) or - is_embedded_attribute?(attr) or - is_typed_struct_attribute?(attr) or - is_keyword_attribute?(attr) or - is_tuple_attribute?(attr) - end) - - simple_calcs = - calculations - |> Enum.filter(&Helpers.is_simple_calculation/1) - - attr_defs = - Enum.map(primitive_attrs, fn attr -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) - - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + defp relationship_field_definition(resource, rel) do + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, rel.name) - type_str = TypeMapper.get_ts_type(attr) - - if attr.allow_nil? do - " #{formatted_name}: #{type_str} | null;" - else - " #{formatted_name}: #{type_str};" - end - end) - - calc_defs = - Enum.map(simple_calcs, fn calc -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, calc.name) - - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - type_str = TypeMapper.get_ts_type(calc) + related_resource_name = Helpers.build_resource_type_name(rel.destination) - if calc.allow_nil? do - " #{formatted_name}: #{type_str} | null;" + resource_type = + if rel.type in [:has_many, :many_to_many] do + "#{related_resource_name}ResourceSchema" + else + if Map.get(rel, :allow_nil?, true) do + "#{related_resource_name}ResourceSchema | null" else - " #{formatted_name}: #{type_str};" + "#{related_resource_name}ResourceSchema" end - end) - - agg_defs = - Enum.map(aggregates, fn agg -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, agg.name) - - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) - - type_str = - case agg.kind do - :sum -> - resource - |> Helpers.lookup_aggregate_type(agg.relationship_path, agg.field) - |> TypeMapper.get_ts_type() + end - :first -> - resource - |> Helpers.lookup_aggregate_type(agg.relationship_path, agg.field) - |> TypeMapper.get_ts_type() + metadata = + case rel.type do + :has_many -> + "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" - _ -> - TypeMapper.get_ts_type(agg.kind) - end + :many_to_many -> + "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" - if agg.include_nil? do - " #{formatted_name}?: #{type_str} | null;" - else - " #{formatted_name}: #{type_str};" - end - end) + _ -> + "{ __type: \"Relationship\"; __resource: #{resource_type}; }" + end - attr_defs ++ calc_defs ++ agg_defs + " #{formatted_name}: #{metadata};" end - defp generate_relationship_field_definitions(resource, allowed_resources) do - relationships = Ash.Resource.Info.public_relationships(resource) + defp embedded_field_definition(resource, attr) do + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) - relationships - |> Enum.filter(fn rel -> - Enum.member?(allowed_resources, rel.destination) - end) - |> Enum.map(fn rel -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, rel.name) + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + embedded_resource = get_embedded_resource_from_attr(attr) + embedded_resource_name = Helpers.build_resource_type_name(embedded_resource) - related_resource_name = Helpers.build_resource_type_name(rel.destination) + resource_type = + case attr.type do + {:array, _} -> + "#{embedded_resource_name}ResourceSchema" - resource_type = - if rel.type in [:has_many, :many_to_many] do - "#{related_resource_name}ResourceSchema" - else - if Map.get(rel, :allow_nil?, true) do - "#{related_resource_name}ResourceSchema | null" + _ -> + if attr.allow_nil? do + "#{embedded_resource_name}ResourceSchema | null" else - "#{related_resource_name}ResourceSchema" + "#{embedded_resource_name}ResourceSchema" end - end - - metadata = - case rel.type do - :has_many -> - "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" + end - :many_to_many -> - "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" + metadata = + case attr.type do + {:array, _} -> + "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" - _ -> - "{ __type: \"Relationship\"; __resource: #{resource_type}; }" - end + _ -> + "{ __type: \"Relationship\"; __resource: #{resource_type}; }" + end - " #{formatted_name}: #{metadata};" - end) + " #{formatted_name}: #{metadata};" end - defp generate_embedded_field_definitions(resource, allowed_resources) do - attributes = Ash.Resource.Info.public_attributes(resource) + defp complex_calculation_definition(resource, calc) do + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, calc.name) - attributes - |> Enum.filter(fn attr -> - is_embedded_attribute?(attr) and - embedded_resource_allowed?(attr, allowed_resources) - end) - |> Enum.map(fn attr -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + return_type = get_calculation_return_type_for_metadata(calc, calc.allow_nil?) - embedded_resource = get_embedded_resource_from_attr(attr) - embedded_resource_name = Helpers.build_resource_type_name(embedded_resource) + metadata = + if Enum.empty?(calc.arguments) do + "{ __type: \"ComplexCalculation\"; __returnType: #{return_type}; }" + else + args_type = generate_calculation_args_type(calc.arguments) - resource_type = - case attr.type do - {:array, _} -> - "#{embedded_resource_name}ResourceSchema" + "{ __type: \"ComplexCalculation\"; __returnType: #{return_type}; __args: #{args_type}; }" + end - _ -> - if attr.allow_nil? do - "#{embedded_resource_name}ResourceSchema | null" - else - "#{embedded_resource_name}ResourceSchema" - end - end + " #{formatted_name}: #{metadata};" + end - metadata = - case attr.type do - {:array, _} -> - "{ __type: \"Relationship\"; __array: true; __resource: #{resource_type}; }" + defp union_field_definition(resource, attr) do + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) - _ -> - "{ __type: \"Relationship\"; __resource: #{resource_type}; }" - end + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - " #{formatted_name}: #{metadata};" - end) - end + union_metadata = generate_union_metadata(attr) - defp generate_complex_calculation_field_definitions(resource) do - calculations = Ash.Resource.Info.public_calculations(resource) + # Check if this is an array union and add __array: true + final_type = + case attr.type do + {:array, Ash.Type.Union} -> + # Extract the content of the union metadata and add __array: true + # Remove outer braces + union_content = String.slice(union_metadata, 1..-2//1) + "{ __array: true; #{union_content} }" - calculations - |> Enum.reject(&Helpers.is_simple_calculation/1) - |> Enum.map(fn calc -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, calc.name) + _ -> + union_metadata + end - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + if attr.allow_nil? do + " #{formatted_name}: #{final_type} | null;" + else + " #{formatted_name}: #{final_type};" + end + end - return_type = get_calculation_return_type_for_metadata(calc, calc.allow_nil?) + defp typed_map_field_definition(resource, attr) do + mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) - metadata = - if Enum.empty?(calc.arguments) do - "{ __type: \"ComplexCalculation\"; __returnType: #{return_type}; }" - else - args_type = generate_calculation_args_type(calc.arguments) + formatted_name = + AshTypescript.FieldFormatter.format_field( + mapped_name, + AshTypescript.Rpc.output_field_formatter() + ) - "{ __type: \"ComplexCalculation\"; __returnType: #{return_type}; __args: #{args_type}; }" - end + # Get the constraints for the TypedMap + {_inner_type, constraints} = + case attr.type do + {:array, inner} -> {inner, attr.constraints[:items] || []} + inner -> {inner, attr.constraints || []} + end - " #{formatted_name}: #{metadata};" - end) - end + field_specs = Keyword.get(constraints, :fields, []) - defp generate_union_field_definitions(resource) do - attributes = Ash.Resource.Info.public_attributes(resource) + # Check if this NewType has typescript_field_names callback + # Extract the actual type (handle {:array, Type} case) + actual_type = + case attr.type do + {:array, inner} -> inner + type -> type + end - attributes - |> Enum.filter(&is_union_attribute?/1) - |> Enum.map(fn attr -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) + field_name_mappings = + if is_atom(actual_type) and function_exported?(actual_type, :typescript_field_names, 0) do + actual_type.typescript_field_names() + else + nil + end - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + # Build the primitive fields list + primitive_fields = + field_specs + |> Enum.map(fn {field_name, _config} -> field_name end) + |> Enum.map_join(" | ", fn field_name -> + # Apply field name mapping if present + mapped_field_name = + if field_name_mappings && Keyword.has_key?(field_name_mappings, field_name) do + Keyword.get(field_name_mappings, field_name) + else + field_name + end - union_metadata = generate_union_metadata(attr) + formatted = + AshTypescript.FieldFormatter.format_field( + mapped_field_name, + AshTypescript.Rpc.output_field_formatter() + ) - # Check if this is an array union and add __array: true - final_type = - case attr.type do - {:array, Ash.Type.Union} -> - # Extract the content of the union metadata and add __array: true - # Remove outer braces - union_content = String.slice(union_metadata, 1..-2//1) - "{ __array: true; #{union_content} }" + "\"#{formatted}\"" + end) - _ -> - union_metadata - end + # Build field definitions + field_defs = + field_specs + |> Enum.map_join(", ", fn {field_name, config} -> + field_type = Keyword.get(config, :type) + allow_nil = Keyword.get(config, :allow_nil?, true) + + # Apply field name mapping if present + mapped_field_name = + if field_name_mappings && Keyword.has_key?(field_name_mappings, field_name) do + Keyword.get(field_name_mappings, field_name) + else + field_name + end - if attr.allow_nil? do - " #{formatted_name}: #{final_type} | null;" - else - " #{formatted_name}: #{final_type};" - end - end) - end + formatted_field = + AshTypescript.FieldFormatter.format_field( + mapped_field_name, + AshTypescript.Rpc.output_field_formatter() + ) - defp generate_keyword_tuple_field_definitions(resource) do - attributes = Ash.Resource.Info.public_attributes(resource) + ts_type = + TypeMapper.get_ts_type(%{ + type: field_type, + constraints: Keyword.get(config, :constraints, []) + }) - attributes - |> Enum.filter(fn attr -> - is_keyword_attribute?(attr) or is_tuple_attribute?(attr) - end) - |> Enum.map(fn attr -> - mapped_name = AshTypescript.Resource.Info.get_mapped_field_name(resource, attr.name) + if allow_nil do + "#{formatted_field}: #{ts_type} | null" + else + "#{formatted_field}: #{ts_type}" + end + end) - formatted_name = - AshTypescript.FieldFormatter.format_field( - mapped_name, - AshTypescript.Rpc.output_field_formatter() - ) + # Build the TypedMap metadata + typed_map_metadata = + "{#{field_defs}, __type: \"TypedMap\", __primitiveFields: #{primitive_fields}}" - ts_type = TypeMapper.get_ts_type(attr, nil) + # Check if this is an array and add __array: true + final_type = + case attr.type do + {:array, _} -> + "{ __array: true; #{field_defs}, __type: \"TypedMap\", __primitiveFields: #{primitive_fields} }" - if attr.allow_nil? do - " #{formatted_name}: #{ts_type} | null;" - else - " #{formatted_name}: #{ts_type};" + _ -> + typed_map_metadata end - end) + + if attr.allow_nil? do + " #{formatted_name}: #{final_type} | null;" + else + " #{formatted_name}: #{final_type};" + end end defp is_union_attribute?(%{type: Ash.Type.Union}), do: true @@ -462,12 +590,42 @@ defmodule AshTypescript.Codegen.ResourceSchemas do defp is_typed_struct_attribute?(_), do: false - defp is_keyword_attribute?(%{type: Ash.Type.Keyword}), do: true - defp is_keyword_attribute?(%{type: {:array, Ash.Type.Keyword}}), do: true + defp is_keyword_attribute?(%{type: Ash.Type.Keyword, constraints: constraints}), + do: Keyword.has_key?(constraints, :fields) + + defp is_keyword_attribute?(%{type: {:array, Ash.Type.Keyword}, constraints: constraints}), + do: Keyword.has_key?(constraints[:items] || [], :fields) + defp is_keyword_attribute?(_), do: false - defp is_tuple_attribute?(%{type: Ash.Type.Tuple}), do: true - defp is_tuple_attribute?(%{type: {:array, Ash.Type.Tuple}}), do: true + defp is_struct_attribute?(%{type: Ash.Type.Struct, constraints: constraints}), + do: + Keyword.has_key?(constraints, :fields) || + (constraints[:instance_of] && + Ash.Resource.Info.resource?(constraints[:instance_of])) + + defp is_struct_attribute?(%{type: {:array, Ash.Type.Struct}, constraints: constraints}), + do: + Keyword.has_key?(constraints[:items] || [], :fields) || + (constraints[:items][:instance_of] && + Ash.Resource.Info.resource?(constraints[:items][:instance_of])) + + defp is_struct_attribute?(_), do: false + + defp is_map_attribute?(%{type: Ash.Type.Map, constraints: constraints}), + do: Keyword.has_key?(constraints, :fields) + + defp is_map_attribute?(%{type: {:array, Ash.Type.Map}, constraints: constraints}), + do: Keyword.has_key?(constraints[:items] || [], :fields) + + defp is_map_attribute?(_), do: false + + defp is_tuple_attribute?(%{type: Ash.Type.Tuple, constraints: constraints}), + do: Keyword.has_key?(constraints, :fields) + + defp is_tuple_attribute?(%{type: {:array, Ash.Type.Tuple}, constraints: constraints}), + do: Keyword.has_key?(constraints[:items] || [], :fields) + defp is_tuple_attribute?(_), do: false defp embedded_resource_allowed?(attr, allowed_resources) do diff --git a/lib/ash_typescript/rpc.ex b/lib/ash_typescript/rpc.ex index 2266143..2b7cdde 100644 --- a/lib/ash_typescript/rpc.ex +++ b/lib/ash_typescript/rpc.ex @@ -11,7 +11,14 @@ defmodule AshTypescript.Rpc do Defines the mapping between a named RPC endpoint and an Ash action. """ - defstruct [:name, :action, :show_metadata, :metadata_field_names, __spark_metadata__: nil] + defstruct [ + :name, + :action, + :read_action, + :show_metadata, + :metadata_field_names, + __spark_metadata__: nil + ] end defmodule Resource do @@ -94,6 +101,11 @@ defmodule AshTypescript.Rpc do type: :atom, doc: "The resource action to expose" ], + read_action: [ + type: :atom, + doc: "The read action to use for update and destroy operations when finding records", + required: false + ], show_metadata: [ type: {:or, [nil, :boolean, {:list, :atom}]}, doc: "Which metadata fields to expose (nil=all, false/[]=none, list=specific fields)", @@ -128,6 +140,35 @@ defmodule AshTypescript.Rpc do @rpc %Spark.Dsl.Section{ name: :typescript_rpc, describe: "Define available RPC-actions for resources in this domain.", + schema: [ + error_handler: [ + type: {:or, [:mfa, :module]}, + doc: """ + An MFA or module that implements error handling for RPC operations. + + The error handler will be called with (error, context) and should return a modified error map. + If a module is provided, it must export a handle_error/2 function. + + Example: + ```elixir + error_handler {MyApp.CustomErrorHandler, :handle_error, []} + # or + error_handler MyApp.CustomErrorHandler + ``` + """, + default: {AshTypescript.Rpc.DefaultErrorHandler, :handle_error, []} + ], + show_raised_errors?: [ + type: :boolean, + default: false, + doc: """ + Whether to show detailed information for raised exceptions. + + Set to true in development to see full error details. + Keep false in production for security. + """ + ] + ], entities: [ @resource ] @@ -142,7 +183,7 @@ defmodule AshTypescript.Rpc do AshTypescript.Rpc.VerifyRpcWarnings ] - alias AshTypescript.Rpc.{ErrorBuilder, Pipeline} + alias AshTypescript.Rpc.{ErrorBuilder, Errors, Pipeline} def codegen(args) do Mix.Task.reenable("ash_typescript.codegen") @@ -370,23 +411,30 @@ defmodule AshTypescript.Rpc do case action.type do :read -> # For read actions, validate by building a query - validate_read_action(resource, action, input, opts) + validate_read_action(request, input, opts) action_type when action_type in [:update, :destroy] -> case Ash.get(resource, request.primary_key, opts) do {:ok, record} -> - perform_form_validation(record, action.name, input, opts) + perform_form_validation(record, action.name, input, opts, request) {:error, error} -> - %{success: false, errors: [ErrorBuilder.build_error_response(error)]} + errors = + Errors.to_errors(error, request.domain, resource, action.name, request.context) + + %{success: false, errors: errors} end _ -> - perform_form_validation(resource, action.name, input, opts) + perform_form_validation(resource, action.name, input, opts, request) end end - defp validate_read_action(resource, action, input, opts) do + defp validate_read_action( + %{resource: resource, action: action, domain: domain, context: context}, + input, + opts + ) do # For read actions, validate by building a query query = resource @@ -398,15 +446,7 @@ defmodule AshTypescript.Rpc do %{success: true} %Ash.Query{errors: errors} when errors != [] -> - formatted_errors = - errors - |> Enum.map(fn error -> - %{ - type: "validation_error", - message: Exception.message(error) - } - end) - + formatted_errors = Errors.to_errors(errors, domain, resource, action.name, context) %{success: false, errors: formatted_errors} _ -> @@ -414,42 +454,43 @@ defmodule AshTypescript.Rpc do end rescue e -> - %{ - success: false, - errors: [ - %{ - type: "validation_error", - message: Exception.message(e) - } - ] - } + formatted_errors = Errors.to_errors(e, domain, resource, action.name, context) + %{success: false, errors: formatted_errors} end - defp perform_form_validation(record_or_resource, action_name, input, opts) do - form_errors = + defp perform_form_validation(record_or_resource, action_name, input, opts, %{ + domain: domain, + resource: resource, + context: context + }) do + form = record_or_resource |> AshPhoenix.Form.for_action(action_name, opts) |> AshPhoenix.Form.validate(input) - |> AshPhoenix.Form.errors() + + form_errors = AshPhoenix.Form.errors(form) if Enum.empty?(form_errors) do %{success: true} else - formatted_errors = + # Convert form errors to exceptions/error classes for proper handling + errors = form_errors |> Enum.map(fn {field, messages} -> - formatted_messages = - messages - |> List.wrap() - |> Enum.map(&to_string/1) - - %{ - type: "validation_error", - field: to_string(field), - errors: formatted_messages - } + messages + |> List.wrap() + |> Enum.map(fn message -> + # Create a validation error structure + %Ash.Error.Changes.InvalidAttribute{ + field: field, + message: to_string(message), + path: [field] + } + end) end) + |> List.flatten() + formatted_errors = Errors.to_errors(errors, domain, resource, action_name, context) %{success: false, errors: formatted_errors} end end diff --git a/lib/ash_typescript/rpc/codegen/helpers/action_introspection.ex b/lib/ash_typescript/rpc/codegen/helpers/action_introspection.ex index 945316d..1aeb809 100644 --- a/lib/ash_typescript/rpc/codegen/helpers/action_introspection.ex +++ b/lib/ash_typescript/rpc/codegen/helpers/action_introspection.ex @@ -100,26 +100,37 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection do end @doc """ - Returns true if the action accepts input. - - Checks for: - - Arguments on any action type - - Accept list on create/update/destroy actions + Returns :required | :optional | :none """ - def action_has_input?(resource, action) do - case action.type do - :read -> - action.arguments != [] - - :create -> - accepts = Ash.Resource.Info.action(resource, action.name).accept || [] - accepts != [] || action.arguments != [] - - action_type when action_type in [:update, :destroy] -> - action.accept != [] || action.arguments != [] - - :action -> - action.arguments != [] + def action_input_type(resource, action) do + inputs = + resource + |> Ash.Resource.Info.action_inputs(action.name) + |> Enum.filter(&is_atom/1) + |> Enum.map(fn input -> + Enum.find(action.arguments, fn argument -> + argument.name == input + end) || Ash.Resource.Info.attribute(resource, input) + end) + |> Enum.uniq_by(& &1.name) + + cond do + Enum.empty?(inputs) -> + :none + + Enum.any?(inputs, fn + %Ash.Resource.Actions.Argument{} = input -> + not input.allow_nil? and is_nil(input.default) + + %Ash.Resource.Attribute{} = input -> + input.name not in Map.get(action, :allow_nil_input, []) and + (input.name in Map.get(action, :require_attributes, []) || + (not input.allow_nil? and is_nil(input.default))) + end) -> + :required + + true -> + :optional end end diff --git a/lib/ash_typescript/rpc/codegen/helpers/config_builder.ex b/lib/ash_typescript/rpc/codegen/helpers/config_builder.ex index 0a01a55..0df3bb1 100644 --- a/lib/ash_typescript/rpc/codegen/helpers/config_builder.ex +++ b/lib/ash_typescript/rpc/codegen/helpers/config_builder.ex @@ -25,17 +25,17 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ConfigBuilder do - `:requires_primary_key` - Whether the action requires a primary key (update/destroy) - `:supports_pagination` - Whether the action supports pagination (list reads) - `:supports_filtering` - Whether the action supports filtering (list reads) - - `:has_input` - Whether the action has input (arguments or accepts) + - `:action_input_type` - Whether the input is :none, :required, or :optional ## Examples - iex> get_action_context(MyResource, read_action) + iex> get_action_context(MyRes ource, read_action) %{ requires_tenant: true, requires_primary_key: false, supports_pagination: true, supports_filtering: true, - has_input: false + action_input_type: :required } """ def get_action_context(resource, action) do @@ -46,7 +46,7 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ConfigBuilder do action.type == :read and not action.get? and ActionIntrospection.action_supports_pagination?(action), supports_filtering: action.type == :read and not action.get?, - has_input: ActionIntrospection.action_has_input?(resource, action) + action_input_type: ActionIntrospection.action_input_type(resource, action) } end @@ -210,10 +210,15 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ConfigBuilder do end config_fields = - if context.has_input do - config_fields ++ [" #{format_output_field(:input)}: #{rpc_action_name_pascal}Input;"] - else - config_fields + case context.action_input_type do + :required -> + config_fields ++ [" #{format_output_field(:input)}: #{rpc_action_name_pascal}Input;"] + + :optional -> + config_fields ++ [" #{format_output_field(:input)}?: #{rpc_action_name_pascal}Input;"] + + :none -> + config_fields end # Add hookCtx field if hooks are enabled diff --git a/lib/ash_typescript/rpc/codegen/helpers/payload_builder.ex b/lib/ash_typescript/rpc/codegen/helpers/payload_builder.ex index ebd3813..4ed32ae 100644 --- a/lib/ash_typescript/rpc/codegen/helpers/payload_builder.ex +++ b/lib/ash_typescript/rpc/codegen/helpers/payload_builder.ex @@ -67,11 +67,13 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.PayloadBuilder do end payload_fields = - if context.has_input do - payload_fields ++ - ["#{format_output_field(:input)}: config.#{format_output_field(:input)}"] - else - payload_fields + case context.action_input_type do + :none -> + payload_fields + + _ -> + payload_fields ++ + ["#{format_output_field(:input)}: config.#{format_output_field(:input)}"] end payload_fields = diff --git a/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex b/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex index a1d1e9f..761f025 100644 --- a/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex +++ b/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex @@ -31,7 +31,9 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do A string containing the TypeScript input type definition, or an empty string if no input is required. """ def generate_input_type(resource, action, rpc_action_name) do - if ActionIntrospection.action_has_input?(resource, action) do + action_input_type = ActionIntrospection.action_input_type(resource, action) + + if action_input_type != :none do input_type_name = "#{snake_to_pascal_case(rpc_action_name)}Input" input_field_defs = diff --git a/lib/ash_typescript/rpc/codegen/typescript_static.ex b/lib/ash_typescript/rpc/codegen/typescript_static.ex index c89420f..e5c59bb 100644 --- a/lib/ash_typescript/rpc/codegen/typescript_static.ex +++ b/lib/ash_typescript/rpc/codegen/typescript_static.ex @@ -203,11 +203,35 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do : FieldSelection[FieldIndex] extends Record ? { [UnionKey in keyof FieldSelection[FieldIndex]]: UnionKey extends keyof UnionSchema - ? UnionSchema[UnionKey] extends { __type: "TypedMap"; __primitiveFields: any } - ? UnionSchema[UnionKey] - : UnionSchema[UnionKey] extends TypedSchema - ? InferResult + ? UnionSchema[UnionKey] extends { __array: true; __type: "TypedMap"; __primitiveFields: infer TypedMapFields } + ? FieldSelection[FieldIndex][UnionKey] extends any[] + ? Array< + UnionToIntersection< + { + [FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields + ? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof UnionSchema[UnionKey] + ? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: UnionSchema[UnionKey][P] } + : never + : never; + }[number] + > + > | null : never + : UnionSchema[UnionKey] extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields } + ? FieldSelection[FieldIndex][UnionKey] extends any[] + ? UnionToIntersection< + { + [FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields + ? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof UnionSchema[UnionKey] + ? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: UnionSchema[UnionKey][P] } + : never + : never; + }[number] + > | null + : never + : UnionSchema[UnionKey] extends TypedSchema + ? InferResult + : never : never; } : never; @@ -250,25 +274,27 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do : NonNullable extends TypedSchema ? { #{formatted_fields_field()}: UnifiedFieldSelection>[] } : never - : T[K] extends { __type: "Union"; __primitiveFields: infer PrimitiveFields } - ? T[K] extends { __array: true } - ? (PrimitiveFields | { - [UnionKey in keyof Omit]?: T[K][UnionKey] extends { __type: "TypedMap"; __primitiveFields: any } - ? T[K][UnionKey]["__primitiveFields"][] - : T[K][UnionKey] extends TypedSchema - ? UnifiedFieldSelection[] - : never; - })[] - : (PrimitiveFields | { - [UnionKey in keyof Omit]?: T[K][UnionKey] extends { __type: "TypedMap"; __primitiveFields: any } - ? T[K][UnionKey]["__primitiveFields"][] - : T[K][UnionKey] extends TypedSchema - ? UnifiedFieldSelection[] - : never; - })[] - : NonNullable extends TypedSchema - ? UnifiedFieldSelection>[] - : never; + : T[K] extends { __type: "TypedMap"; __primitiveFields: infer PrimitiveFields } + ? PrimitiveFields[] + : T[K] extends { __type: "Union"; __primitiveFields: infer PrimitiveFields } + ? T[K] extends { __array: true } + ? (PrimitiveFields | { + [UnionKey in keyof Omit]?: T[K][UnionKey] extends { __type: "TypedMap"; __primitiveFields: any } + ? T[K][UnionKey]["__primitiveFields"][] + : T[K][UnionKey] extends TypedSchema + ? UnifiedFieldSelection[] + : never; + })[] + : (PrimitiveFields | { + [UnionKey in keyof Omit]?: T[K][UnionKey] extends TypedSchema + ? T[K][UnionKey]["__primitiveFields"][] + : T[K][UnionKey] extends TypedSchema + ? UnifiedFieldSelection[] + : never; + })[] + : NonNullable extends TypedSchema + ? UnifiedFieldSelection>[] + : never; }; // Main type: Use explicit base case detection to prevent infinite recursion @@ -307,27 +333,75 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do ? InferResult, Field[K]["fields"]> | null : InferResult, Field[K]["fields"]> : ReturnType - : T[K] extends { __type: "Union"; __primitiveFields: any } - ? T[K] extends { __array: true } - ? { - [CurrentK in K]: T[CurrentK] extends { __type: "Union"; __primitiveFields: any } - ? Field[CurrentK] extends any[] - ? Array> | null - : never - : never - } - : { - [CurrentK in K]: T[CurrentK] extends { __type: "Union"; __primitiveFields: any } - ? Field[CurrentK] extends any[] - ? InferUnionFieldValue | null - : never - : never - } - : NonNullable extends TypedSchema + : NonNullable extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields } + ? NonNullable extends { __array: true } + ? Field[K] extends any[] ? null extends T[K] - ? InferResult, Field[K]> | null - : InferResult, Field[K]> + ? Array< + UnionToIntersection< + { + [FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends TypedMapFields + ? Field[K][FieldIndex] extends keyof NonNullable + ? { [P in Field[K][FieldIndex]]: NonNullable[P] } + : never + : never; + }[number] + > + > | null + : Array< + UnionToIntersection< + { + [FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends TypedMapFields + ? Field[K][FieldIndex] extends keyof NonNullable + ? { [P in Field[K][FieldIndex]]: NonNullable[P] } + : never + : never; + }[number] + > + > : never + : Field[K] extends any[] + ? null extends T[K] + ? UnionToIntersection< + { + [FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends TypedMapFields + ? Field[K][FieldIndex] extends keyof NonNullable + ? { [P in Field[K][FieldIndex]]: NonNullable[P] } + : never + : never; + }[number] + > | null + : UnionToIntersection< + { + [FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends TypedMapFields + ? Field[K][FieldIndex] extends keyof T[K] + ? { [P in Field[K][FieldIndex]]: T[K][P] } + : never + : never; + }[number] + > + : never + : T[K] extends { __type: "Union"; __primitiveFields: any } + ? T[K] extends { __array: true } + ? { + [CurrentK in K]: T[CurrentK] extends { __type: "Union"; __primitiveFields: any } + ? Field[CurrentK] extends any[] + ? Array> | null + : never + : never + } + : { + [CurrentK in K]: T[CurrentK] extends { __type: "Union"; __primitiveFields: any } + ? Field[CurrentK] extends any[] + ? InferUnionFieldValue | null + : never + : never + } + : NonNullable extends TypedSchema + ? null extends T[K] + ? InferResult, Field[K]> | null + : InferResult, Field[K]> + : never : never; } : never; @@ -411,9 +485,12 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do export type AshRpcError = { type: string; message: string; - field?: string; - fieldPath?: string; + shortMessage?: string; + fields?: string[]; + path?: Array; + vars?: Record; details?: Record; + errorId?: string; } @@ -557,7 +634,7 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do }; // Metadata - metadataFields?: ReadonlyArray>; + metadataFields?: ReadonlyArray; // Channel-specific channel: any; // Phoenix Channel @@ -636,7 +713,7 @@ defmodule AshTypescript.Rpc.Codegen.TypescriptStatic do }; // Metadata - metadataFields?: Record; // Metadata field selection + metadataFields?: ReadonlyArray; // HTTP customization headers?: Record; // Custom headers diff --git a/lib/ash_typescript/rpc/default_error_handler.ex b/lib/ash_typescript/rpc/default_error_handler.ex new file mode 100644 index 0000000..dc33e20 --- /dev/null +++ b/lib/ash_typescript/rpc/default_error_handler.ex @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.DefaultErrorHandler do + @moduledoc """ + Default error handler for RPC operations. + + This handler returns errors as-is without any transformation. + Variable interpolation is left to the client for better flexibility. + + This module is called as the last step in the error processing pipeline. + """ + + @doc """ + Default error handler that returns errors as-is. + + Previously this handler would interpolate variables into messages, + but now we let the client handle that for better flexibility. + + The error is returned with the message template and vars separate, + allowing the client to handle interpolation as needed. + + ## Examples + + iex> error = %{ + ...> message: "Field %{field} is required", + ...> short_message: "Required field", + ...> vars: %{field: "email"}, + ...> code: "required", + ...> fields: ["email"] + ...> } + iex> handle_error(error, %{}) + %{ + message: "Field %{field} is required", + short_message: "Required field", + vars: %{field: "email"}, + code: "required", + fields: ["email"] + } + """ + @spec handle_error(map(), map()) :: map() + def handle_error(error, _context) when is_map(error) do + # Return error as-is, let client handle variable interpolation + error + end + + def handle_error(nil, _context), do: nil +end diff --git a/lib/ash_typescript/rpc/error.ex b/lib/ash_typescript/rpc/error.ex new file mode 100644 index 0000000..31ac318 --- /dev/null +++ b/lib/ash_typescript/rpc/error.ex @@ -0,0 +1,292 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defprotocol AshTypescript.Rpc.Error do + @moduledoc """ + Protocol for extracting minimal information from exceptions for RPC responses. + + Similar to AshGraphql.Error, this protocol transforms various error types into + a standardized format with only the essential information needed by TypeScript clients. + + ## Error Format + + Each implementation should return a map with these fields: + - `:message` - The full error message (may contain template variables like %{key}) + - `:short_message` - A concise version of the message + - `:type` - A machine-readable error type (e.g., "invalid_changes", "not_found") + - `:vars` - A map of variables to interpolate into messages + - `:fields` - A list of affected field names (for field-level errors) + - `:path` - The path to the error location in the data structure + + ## Example Implementation + + defimpl AshTypescript.Rpc.Error, for: MyApp.CustomError do + def to_error(error) do + %{ + message: error.message, + short_message: "Custom error occurred", + type: "custom_error", + vars: %{detail: error.detail}, + fields: [], + path: error.path || [] + } + end + end + """ + + @doc """ + Transforms an exception into a minimal error representation for RPC responses. + """ + @spec to_error(Exception.t()) :: map() + def to_error(exception) +end + +# Implementation for Ash.Error.Changes.InvalidChanges +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Changes.InvalidChanges do + def to_error(error) do + %{ + message: Map.get(error, :message) || Exception.message(error), + short_message: "Invalid changes", + vars: Map.new(error.vars || []), + type: "invalid_changes", + fields: List.wrap(error.fields), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.InvalidQuery +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.InvalidQuery do + def to_error(error) do + %{ + message: Map.get(error, :message) || Exception.message(error), + short_message: "Invalid query", + vars: Map.new(error.vars || []), + type: "invalid_query", + fields: List.wrap(Map.get(error, :fields) || Map.get(error, :field)), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.NotFound +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.NotFound do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Not found", + vars: Map.new(error.vars || []), + type: "not_found", + fields: [], + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Changes.Required +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Changes.Required do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Required field", + vars: Map.new(error.vars || []) |> Map.put(:field, error.field), + type: "required", + fields: List.wrap(error.field), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.Required +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.Required do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Required field", + vars: Map.new(error.vars || []) |> Map.put(:field, error.field), + type: "required", + fields: List.wrap(error.field), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Forbidden.Policy +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Forbidden.Policy do + def to_error(error) do + # Check if policy breakdown should be shown (would need configuration) + base = %{ + message: Exception.message(error), + short_message: "Forbidden", + vars: Map.new(error.vars || []), + type: "forbidden", + fields: [], + path: error.path || [] + } + + # Optionally include policy details if configured to do so + if Map.get(error, :policy_breakdown?) do + Map.put(base, :policy_breakdown, Map.get(error, :policies)) + else + base + end + end +end + +# Implementation for Ash.Error.Forbidden.ForbiddenField +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Forbidden.ForbiddenField do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Forbidden field", + vars: Map.new(error.vars || []) |> Map.put(:field, error.field), + type: "forbidden_field", + fields: List.wrap(error.field), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Changes.InvalidAttribute +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Changes.InvalidAttribute do + def to_error(error) do + %{ + message: Map.get(error, :message) || Exception.message(error), + short_message: "Invalid attribute", + vars: Map.new(error.vars || []) |> Map.put(:field, error.field), + type: "invalid_attribute", + fields: List.wrap(error.field), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Changes.InvalidArgument +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Changes.InvalidArgument do + def to_error(error) do + %{ + message: Map.get(error, :message) || Exception.message(error), + short_message: "Invalid argument", + vars: Map.new(error.vars || []) |> Map.put(:field, Map.get(error, :field)), + type: "invalid_argument", + fields: List.wrap(Map.get(error, :field)), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.InvalidArgument +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.InvalidArgument do + def to_error(error) do + %{ + message: Map.get(error, :message) || Exception.message(error), + short_message: "Invalid argument", + vars: Map.new(error.vars || []) |> Map.put(:field, Map.get(error, :field)), + type: "invalid_argument", + fields: List.wrap(Map.get(error, :field)), + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Page.InvalidKeyset +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Page.InvalidKeyset do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Invalid keyset", + vars: Map.new(error.vars || []), + type: "invalid_keyset", + fields: [], + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.InvalidPage +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.InvalidPage do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Invalid pagination", + vars: Map.new(error.vars || []), + type: "invalid_page", + fields: [], + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Invalid.InvalidPrimaryKey +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Invalid.InvalidPrimaryKey do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Invalid primary key", + vars: Map.new(error.vars || []), + type: "invalid_primary_key", + fields: [], + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Query.ReadActionRequiresActor +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Query.ReadActionRequiresActor do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Authentication required", + vars: Map.new(error.vars || []), + type: "forbidden", + fields: [], + path: error.path || [] + } + end +end + +# Implementation for Ash.Error.Unknown.UnknownError +defimpl AshTypescript.Rpc.Error, for: Ash.Error.Unknown.UnknownError do + def to_error(error) do + %{ + message: Exception.message(error), + short_message: "Unknown error", + vars: Map.new(error.vars || []), + type: "unknown_error", + fields: [], + path: error.path || [] + } + end +end + +# Check if AshAuthentication is available and implement its errors +if Code.ensure_loaded?(AshAuthentication.Errors.AuthenticationFailed) do + defimpl AshTypescript.Rpc.Error, for: AshAuthentication.Errors.AuthenticationFailed do + def to_error(error) do + %{ + message: Map.get(error, :message) || "Authentication failed", + short_message: "Authentication failed", + vars: Map.new(error.vars || []), + type: "authentication_failed", + fields: List.wrap(Map.get(error, :field)), + path: error.path || [] + } + end + end +end + +if Code.ensure_loaded?(AshAuthentication.Errors.InvalidToken) do + defimpl AshTypescript.Rpc.Error, for: AshAuthentication.Errors.InvalidToken do + def to_error(error) do + %{ + message: Map.get(error, :message) || "Invalid token", + short_message: "Invalid token", + vars: Map.new(error.vars || []), + type: "invalid_token", + fields: [], + path: error.path || [] + } + end + end +end diff --git a/lib/ash_typescript/rpc/error_builder.ex b/lib/ash_typescript/rpc/error_builder.ex index d42b02a..f5b9ff0 100644 --- a/lib/ash_typescript/rpc/error_builder.ex +++ b/lib/ash_typescript/rpc/error_builder.ex @@ -10,11 +10,15 @@ defmodule AshTypescript.Rpc.ErrorBuilder do detailed context for debugging and client consumption. """ + alias AshTypescript.Rpc.Errors + @doc """ Builds a detailed error response from various error types. Converts internal error tuples into structured error responses with clear messages and debugging context. + + For Ash framework errors, uses the new Error protocol for standardized extraction. """ @spec build_error_response(term()) :: map() def build_error_response(error) do @@ -24,6 +28,8 @@ defmodule AshTypescript.Rpc.ErrorBuilder do %{ type: "action_not_found", message: "RPC action '#{action_name}' not found", + path: [], + fields: [], details: %{ action_name: action_name, suggestion: "Check that the action is properly configured in your domain's rpc block" @@ -161,7 +167,7 @@ defmodule AshTypescript.Rpc.ErrorBuilder do type: "invalid_field_selection", message: "Cannot select fields from primitive type #{return_type_string}", details: %{ - field_type: "primitive_type", + field_code: "primitive_type", return_type: return_type_string, suggestion: "Remove the field selection for this primitive type" } @@ -227,7 +233,7 @@ defmodule AshTypescript.Rpc.ErrorBuilder do message: "Fields parameter must be an array", details: %{ received: inspect(fields), - expected_type: "array", + expected_code: "array", suggestion: "Wrap field names in an array, e.g., [\"field1\", \"field2\"]" } } @@ -299,40 +305,36 @@ defmodule AshTypescript.Rpc.ErrorBuilder do # === ASH FRAMEWORK ERRORS === - # NotFound errors (specific handling) - %Ash.Error.Query.NotFound{} = not_found_error -> - %{ - type: "not_found", - message: Exception.message(not_found_error), - details: %{ - resource: not_found_error.resource, - primary_key: not_found_error.primary_key - } - } + # Any exception or Ash error - convert to Ash error class and process + error when is_exception(error) or is_map(error) -> + # Always convert to Ash error class first + ash_error = Ash.Error.to_error_class(error) - # Check for NotFound errors nested inside other Ash errors - %{class: :invalid, errors: errors} = ash_error when is_list(errors) -> - case Enum.find(errors, &is_struct(&1, Ash.Error.Query.NotFound)) do - %Ash.Error.Query.NotFound{} = not_found_error -> - %{ - type: "not_found", - message: Exception.message(not_found_error), - details: %{ - resource: not_found_error.resource, - primary_key: not_found_error.primary_key - } - } - - _ -> - build_ash_error_response(ash_error) - end + # Process through the new error system + transformed = Errors.to_errors(ash_error) - # Generic Ash errors - %{class: _class} = ash_error -> - build_ash_error_response(ash_error) + case transformed do + [single_error] -> single_error + multiple -> %{type: "multiple_errors", errors: multiple} + end # === FALLBACK ERROR HANDLERS === + # Invalid field type errors (from validator throws) + {:invalid_field_type, field_name, path} -> + field_path = Enum.join(path ++ [field_name], ".") + + %{ + type: "unknown_field", + message: "Unknown field '#{field_path}'", + field_path: field_path, + details: %{ + field: field_name, + path: path, + suggestion: "Check that the field exists and is accessible" + } + } + {field_error_type, _} when is_atom(field_error_type) -> %{ type: "field_validation_error", @@ -353,48 +355,6 @@ defmodule AshTypescript.Rpc.ErrorBuilder do end end - # Build error responses for Ash framework errors - defp build_ash_error_response(ash_error) when is_exception(ash_error) do - %{ - type: "ash_error", - message: Exception.message(ash_error), - details: %{ - class: ash_error.class, - errors: serialize_nested_errors(ash_error.errors || []), - path: ash_error.path || [] - } - } - end - - defp build_ash_error_response(ash_error) do - %{ - type: "ash_error", - message: inspect(ash_error), - details: %{ - error: inspect(ash_error) - } - } - end - - defp serialize_nested_errors(errors) when is_list(errors) do - Enum.map(errors, &serialize_single_error/1) - end - - defp serialize_single_error(error) when is_exception(error) do - %{ - message: Exception.message(error), - field: Map.get(error, :field), - fields: Map.get(error, :fields, []), - path: Map.get(error, :path, []) - } - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> Map.new() - end - - defp serialize_single_error(error) do - %{message: inspect(error)} - end - # Format field type for error messages defp format_field_type(:primitive_type), do: "primitive type" defp format_field_type({:ash_type, type, _}), do: "#{inspect(type)}" diff --git a/lib/ash_typescript/rpc/error_handler.ex b/lib/ash_typescript/rpc/error_handler.ex new file mode 100644 index 0000000..c271651 --- /dev/null +++ b/lib/ash_typescript/rpc/error_handler.ex @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.ErrorHandler do + @moduledoc """ + Behaviour for custom RPC error handlers. + + Error handlers allow you to customize how errors are transformed + and presented to TypeScript clients. They are called after the + Error protocol transformation but before the final response. + + ## Context + + The context map passed to handle_error/2 may contain: + - `:domain` - The domain module + - `:resource` - The resource module + - `:action` - The action being performed + - `:actor` - The actor performing the action + - Additional application-specific context + + ## Example Implementation + + defmodule MyApp.CustomErrorHandler do + @behaviour AshTypescript.Rpc.ErrorHandler + + def handle_error(error, context) do + # Add custom error tracking + Logger.error("RPC Error: \#{inspect(error)}") + + # Customize error format + error + |> Map.put(:timestamp, DateTime.utc_now()) + |> Map.update(:message, "Error", &translate_message/1) + end + + defp translate_message(message) do + # Custom translation logic + message + end + end + """ + + @doc """ + Handles an error by transforming it before sending to the client. + + Receives an error map that has already been processed by the Error protocol, + and a context map with additional information. + + Should return a modified error map or nil to filter out the error. + """ + @callback handle_error(error :: map(), context :: map()) :: map() | nil +end diff --git a/lib/ash_typescript/rpc/errors.ex b/lib/ash_typescript/rpc/errors.ex new file mode 100644 index 0000000..e129a3c --- /dev/null +++ b/lib/ash_typescript/rpc/errors.ex @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.Errors do + @moduledoc """ + Central error processing module for RPC operations. + + Handles error transformation, unwrapping, and formatting for TypeScript clients. + Uses the AshTypescript.Rpc.Error protocol to extract minimal information from exceptions. + """ + + require Logger + + alias AshTypescript.Rpc.DefaultErrorHandler + alias AshTypescript.Rpc.Error, as: ErrorProtocol + + @doc """ + Transforms errors into standardized RPC error responses. + + Processes errors through the following pipeline: + 1. Convert to Ash error class using Ash.Error.to_error_class + 2. Unwrap nested error structures + 3. Transform via Error protocol + 4. Apply resource-level error handler (if configured) + 5. Apply domain-level error handler (if configured) + 6. Interpolate variables into messages + """ + @spec to_errors(term(), atom() | nil, atom() | nil, atom() | nil, map()) :: list(map()) + def to_errors(errors, domain \\ nil, resource \\ nil, action \\ nil, context \\ %{}) + + def to_errors(errors, domain, resource, action, context) do + # First ensure we have an Ash error class + ash_error = Ash.Error.to_error_class(errors) + + # Then process the errors + ash_error + |> unwrap_errors() + |> Enum.map(&process_single_error(&1, domain, resource, action, context)) + |> List.flatten() + |> Enum.reject(&is_nil/1) + end + + @doc """ + Unwraps nested error structures from Ash error classes. + """ + @spec unwrap_errors(term()) :: list(term()) + def unwrap_errors(%{errors: errors}) when is_list(errors) do + # Recursively unwrap nested errors + Enum.flat_map(errors, &unwrap_errors/1) + end + + def unwrap_errors(%{errors: error}) when not is_list(error) do + unwrap_errors([error]) + end + + def unwrap_errors(errors) when is_list(errors) do + Enum.flat_map(errors, &unwrap_errors/1) + end + + def unwrap_errors(error) do + # Single error - return as list + [error] + end + + defp process_single_error(error, domain, resource, _action, context) do + # Check if we should show raised errors + show_raised_errors? = get_show_raised_errors?(domain) + + transformed_error = + if show_raised_errors? and is_exception(error) do + # When show_raised_errors? is true, always expose the actual exception message + %{ + message: Exception.message(error), + short_message: error.__struct__ |> Module.split() |> List.last(), + code: Macro.underscore(error.__struct__ |> Module.split() |> List.last()), + vars: %{}, + fields: [], + path: Map.get(error, :path, []) + } + else + # Use protocol implementation or fallback + if ErrorProtocol.impl_for(error) do + try do + ErrorProtocol.to_error(error) + rescue + e -> + Logger.warning(""" + Failed to transform error via protocol: #{inspect(e)} + Original error: #{inspect(error)} + """) + + fallback_error_response(error, false) + end + else + # No protocol implementation - use fallback + handle_unimplemented_error(error, false) + end + end + + # Apply resource-level error handler if configured + transformed_error = + if resource && function_exported?(resource, :handle_rpc_error, 2) do + apply_error_handler( + {resource, :handle_rpc_error, []}, + transformed_error, + context + ) + else + transformed_error + end + + # Apply domain-level error handler if configured + transformed_error = + if domain do + handler = get_domain_error_handler(domain) + apply_error_handler(handler, transformed_error, context) + else + transformed_error + end + + # Apply default error handler for variable interpolation + DefaultErrorHandler.handle_error(transformed_error, context) + end + + defp apply_error_handler({module, function, args}, error, context) do + case apply(module, function, [error, context | args]) do + nil -> nil + handled -> handled + end + rescue + e -> + Logger.warning(""" + Error handler failed: #{inspect(e)} + Handler: #{inspect({module, function, args})} + Original error: #{inspect(error)} + """) + + error + end + + defp get_domain_error_handler(domain) do + # Check if domain has RPC configuration with error handler + with true <- function_exported?(domain, :spark_dsl_config, 0), + {:ok, handler} <- + Spark.Dsl.Extension.fetch_opt(domain, [:typescript_rpc], :error_handler) do + case handler do + {module, function, args} -> {module, function, args} + module when is_atom(module) -> {module, :handle_error, []} + _ -> {DefaultErrorHandler, :handle_error, []} + end + else + _ -> + {DefaultErrorHandler, :handle_error, []} + end + end + + defp get_show_raised_errors?(nil), do: false + + defp get_show_raised_errors?(domain) do + AshTypescript.Rpc.Info.typescript_rpc_show_raised_errors?(domain) + end + + defp handle_unimplemented_error(error, _show_raised_errors?) when is_exception(error) do + uuid = Ash.UUID.generate() + + # Log the full error details for debugging (only visible server-side) + Logger.warning(""" + Unhandled error in RPC (no protocol implementation). + Error ID: #{uuid} + Error type: #{inspect(error.__struct__)} + Message: #{Exception.message(error)} + + To handle this error type, implement the AshTypescript.Rpc.Error protocol: + + defimpl AshTypescript.Rpc.Error, for: #{inspect(error.__struct__)} do + def to_error(error) do + %{ + message: error.message, + short_message: "Error description", + code: "error_code", + vars: %{}, + fields: [], + path: error.path || [] + } + end + end + """) + + %{ + message: "Something went wrong. Unique error id: #{uuid}", + short_message: "Internal error", + code: "internal_error", + vars: %{}, + fields: [], + path: Map.get(error, :path, []), + error_id: uuid + } + end + + defp handle_unimplemented_error(error, _show_raised_errors?) do + uuid = Ash.UUID.generate() + + Logger.warning(""" + Unhandled non-exception error in RPC. + Error ID: #{uuid} + Error: #{inspect(error)} + """) + + %{ + message: "Something went wrong. Unique error id: #{uuid}", + short_message: "Internal error", + code: "internal_error", + vars: %{}, + fields: [], + path: [], + error_id: uuid + } + end + + defp fallback_error_response(error, _show_raised_errors?) when is_exception(error) do + %{ + message: "something went wrong", + short_message: "Error", + code: "error", + vars: %{}, + fields: [], + path: Map.get(error, :path, []) + } + end + + defp fallback_error_response(_error, _show_raised_errors?) do + %{ + message: "something went wrong", + short_message: "Error", + code: "error", + vars: %{}, + fields: [], + path: [] + } + end +end diff --git a/lib/ash_typescript/rpc/field_processing/field_classifier.ex b/lib/ash_typescript/rpc/field_processing/field_classifier.ex index badb4a4..ff9a256 100644 --- a/lib/ash_typescript/rpc/field_processing/field_classifier.ex +++ b/lib/ash_typescript/rpc/field_processing/field_classifier.ex @@ -186,13 +186,31 @@ defmodule AshTypescript.Rpc.FieldProcessing.FieldClassifier do :calculation_complex {:ash_type, type, constraints} when is_atom(type) -> - # Check if this is a TypedStruct module by creating a fake attribute - fake_attribute = %{type: type, constraints: constraints} - - if Introspection.is_typed_struct_from_attribute?(fake_attribute) do - :calculation_complex + # Check if this is a NewType that wraps a union + if Ash.Type.NewType.new_type?(type) do + subtype = Ash.Type.NewType.subtype_of(type) + + if subtype == Ash.Type.Union do + :calculation_complex + else + # Check if the wrapped type is TypedStruct + fake_attribute = %{type: subtype, constraints: constraints} + + if Introspection.is_typed_struct_from_attribute?(fake_attribute) do + :calculation_complex + else + :calculation + end + end else - :calculation + # Check if this is a TypedStruct module by creating a fake attribute + fake_attribute = %{type: type, constraints: constraints} + + if Introspection.is_typed_struct_from_attribute?(fake_attribute) do + :calculation_complex + else + :calculation + end end _ -> diff --git a/lib/ash_typescript/rpc/field_processing/field_processor.ex b/lib/ash_typescript/rpc/field_processing/field_processor.ex index 2382bdd..7d95864 100644 --- a/lib/ash_typescript/rpc/field_processing/field_processor.ex +++ b/lib/ash_typescript/rpc/field_processing/field_processor.ex @@ -107,39 +107,93 @@ defmodule AshTypescript.Rpc.FieldProcessing.FieldProcessor do process_generic_fields(requested_fields, path) {:ash_type, type, constraints} when is_atom(type) -> - fake_attribute = %{type: type, constraints: constraints} + # Check if this is a NewType that wraps a union + if Ash.Type.NewType.new_type?(type) do + subtype = Ash.Type.NewType.subtype_of(type) - if Introspection.is_typed_struct_from_attribute?(fake_attribute) do - if requested_fields == [] do - throw({:requires_field_selection, :typed_struct, nil}) - end - - field_specs = Keyword.get(constraints, :fields, []) - instance_of = Keyword.get(constraints, :instance_of) - - field_name_mappings = - if instance_of && function_exported?(instance_of, :typescript_field_names, 0) do - instance_of.typescript_field_names() - else - [] - end + if subtype == Ash.Type.Union do + # This is a NewType wrapping a union - process as union + union_types = Keyword.get(constraints, :types, []) - {_field_names, template_items} = - TypedStructProcessor.process_typed_struct_fields( + UnionProcessor.process_union_fields( + union_types, requested_fields, - field_specs, path, - field_name_mappings, &process_fields_for_type/3 ) + else + # NewType wrapping something else - check if it's a TypedStruct + fake_attribute = %{type: subtype, constraints: constraints} + + if Introspection.is_typed_struct_from_attribute?(fake_attribute) do + if requested_fields == [] do + throw({:requires_field_selection, :typed_struct, nil}) + end + + field_specs = Keyword.get(constraints, :fields, []) + instance_of = Keyword.get(constraints, :instance_of) + + field_name_mappings = + if instance_of && function_exported?(instance_of, :typescript_field_names, 0) do + instance_of.typescript_field_names() + else + [] + end + + {_field_names, template_items} = + TypedStructProcessor.process_typed_struct_fields( + requested_fields, + field_specs, + path, + field_name_mappings, + &process_fields_for_type/3 + ) + + {[], [], template_items} + else + if requested_fields != [] do + throw({:invalid_field_selection, :primitive_type, return_type}) + end - {[], [], template_items} - else - if requested_fields != [] do - throw({:invalid_field_selection, :primitive_type, return_type}) + {[], [], []} + end end + else + # Not a NewType - check if it's a TypedStruct + fake_attribute = %{type: type, constraints: constraints} + + if Introspection.is_typed_struct_from_attribute?(fake_attribute) do + if requested_fields == [] do + throw({:requires_field_selection, :typed_struct, nil}) + end + + field_specs = Keyword.get(constraints, :fields, []) + instance_of = Keyword.get(constraints, :instance_of) + + field_name_mappings = + if instance_of && function_exported?(instance_of, :typescript_field_names, 0) do + instance_of.typescript_field_names() + else + [] + end - {[], [], []} + {_field_names, template_items} = + TypedStructProcessor.process_typed_struct_fields( + requested_fields, + field_specs, + path, + field_name_mappings, + &process_fields_for_type/3 + ) + + {[], [], template_items} + else + if requested_fields != [] do + throw({:invalid_field_selection, :primitive_type, return_type}) + end + + {[], [], []} + end end _ -> diff --git a/lib/ash_typescript/rpc/field_processing/type_processors/calculation_processor.ex b/lib/ash_typescript/rpc/field_processing/type_processors/calculation_processor.ex index 6b5f24d..9b2be63 100644 --- a/lib/ash_typescript/rpc/field_processing/type_processors/calculation_processor.ex +++ b/lib/ash_typescript/rpc/field_processing/type_processors/calculation_processor.ex @@ -149,8 +149,23 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.CalculationProcessor template, process_fields_fn ) do + # Extract args and fields from the nested structure (if present) + # For calculations without arguments, this will be %{args: %{}, fields: [...]} + # For backward compatibility, also support plain arrays + fields = + case nested_fields do + %{} = map when is_map(map) -> + Map.get(map, :fields, []) + + list when is_list(list) -> + list + + _ -> + [] + end + # Validate that nested fields are not empty - if nested_fields == [] do + if fields == [] do field_path = Utilities.build_field_path(path, calc_name) throw({:requires_field_selection, :calculation_complex, field_path}) @@ -168,7 +183,7 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.CalculationProcessor new_path = path ++ [calc_name] {nested_select, nested_load, nested_template} = - process_fields_fn.(calc_return_type, nested_fields, new_path) + process_fields_fn.(calc_return_type, fields, new_path) load_spec = Utilities.build_load_spec(calc_name, nested_select, nested_load) diff --git a/lib/ash_typescript/rpc/field_processing/type_processors/union_processor.ex b/lib/ash_typescript/rpc/field_processing/type_processors/union_processor.ex index ca3c5ba..7ab1e75 100644 --- a/lib/ash_typescript/rpc/field_processing/type_processors/union_processor.ex +++ b/lib/ash_typescript/rpc/field_processing/type_processors/union_processor.ex @@ -50,19 +50,7 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.UnionProcessor do # Example: [:note, %{text: [:id, :text, :formatting]}] # Also supports shorthand: %{member_name: member_fields} for single member selection - # Normalize shorthand map format to list format - normalized_fields = - case nested_fields do - %{} = field_map when map_size(field_map) > 0 -> - # Convert map to list format: %{member: fields} -> [%{member: fields}] - [field_map] - - fields when is_list(fields) -> - fields - - _ -> - nested_fields - end + normalized_fields = normalize_fields(nested_fields) Validator.validate_non_empty_fields(normalized_fields, field_name, path, "Union") Validator.check_for_duplicate_fields(normalized_fields, path ++ [field_name]) @@ -71,59 +59,13 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.UnionProcessor do union_types = get_union_types(attribute) {load_items, template_items} = - Enum.reduce(normalized_fields, {[], []}, fn field_item, {load_acc, template_acc} -> - case field_item do - member when is_atom(member) -> - # Simple union member selection (like :note, :priority_value, :url) - process_simple_member(member, union_types, path, field_name, load_acc, template_acc) - - %{} = member_map -> - # Union member(s) with field selection - process each member in the map - Enum.reduce(member_map, {load_acc, template_acc}, fn {member, member_fields}, - {l_acc, t_acc} -> - if Keyword.has_key?(union_types, member) do - member_config = Keyword.get(union_types, member) - - # Convert union member config to return type descriptor - member_return_type = union_member_to_return_type(member_config) - new_path = path ++ [field_name, member] - - # Use the provided field processing function - {_nested_select, nested_load, nested_template} = - process_fields_fn.(member_return_type, member_fields, new_path) - - # For union types, only embedded resources with loadable fields (calculations, - # aggregates) require explicit load statements. The union field selection itself - # ensures the entire union value is returned by Ash. - combined_load_fields = - case member_return_type do - {:resource, _resource} -> - # Embedded resource - only load loadable fields (calculations/aggregates) - nested_load - - _ -> - # All other types - no load statements needed - [] - end - - if combined_load_fields != [] do - {l_acc ++ [{member, combined_load_fields}], - t_acc ++ [{member, nested_template}]} - else - {l_acc, t_acc ++ [{member, nested_template}]} - end - else - field_path = Utilities.build_field_path(path ++ [field_name], member) - throw({:unknown_field, member, "union_attribute", field_path}) - end - end) - - _ -> - # Invalid field item type - field_path = Utilities.build_field_path(path, field_name) - throw({:invalid_union_field_format, field_path}) - end - end) + process_union_members( + normalized_fields, + union_types, + path ++ [field_name], + process_fields_fn, + :attribute + ) new_select = select ++ [field_name] @@ -137,18 +79,147 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.UnionProcessor do {new_select, new_load, template ++ [{field_name, template_items}]} end - # Helper function to extract union types from attribute constraints - # Handles both direct union types and array union types - defp get_union_types(attribute) do - Introspection.get_union_types(attribute) + @doc """ + Processes union fields given the union types directly. + + This is used for calculations or other contexts where we have union types + but not an attribute to extract them from. + + ## Parameters + + - `union_types` - Keyword list of union member configurations + - `requested_fields` - Field selection for union members + - `path` - Current field path for error messages + - `process_fields_fn` - Function to recursively process nested fields + + ## Returns + + `{select, load, template}` tuple for the processed union fields + """ + def process_union_fields(union_types, requested_fields, path, process_fields_fn) do + normalized_fields = normalize_fields(requested_fields) + + Validator.validate_non_empty_fields(normalized_fields, "union", path, "Union") + Validator.check_for_duplicate_fields(normalized_fields, path) + + {load_items, template_items} = + process_union_members( + normalized_fields, + union_types, + path, + process_fields_fn, + :union_type + ) + + {[], load_items, template_items} + end + + # Normalizes field selection format from shorthand map to list format + defp normalize_fields(fields) do + case fields do + %{} = field_map when map_size(field_map) > 0 -> + # Convert map to list format: %{member: fields} -> [%{member: fields}] + [field_map] + + fields when is_list(fields) -> + fields + + _ -> + fields + end + end + + # Processes union members with a unified approach for both attributes and union types + defp process_union_members(normalized_fields, union_types, path, process_fields_fn, context) do + Enum.reduce(normalized_fields, {[], []}, fn field_item, {load_acc, template_acc} -> + case field_item do + member when is_atom(member) -> + # Simple union member selection (like :note, :priority_value, :url) + process_simple_member(member, union_types, path, context, load_acc, template_acc) + + %{} = member_map -> + # Union member(s) with field selection - process each member in the map + process_member_map( + member_map, + union_types, + path, + process_fields_fn, + context, + load_acc, + template_acc + ) + + _ -> + # Invalid field item type + field_path = build_error_path(path, context) + + error_type = + if context == :attribute, do: :invalid_union_field_format, else: :invalid_field_format + + throw({error_type, field_item, field_path}) + end + end) + end + + # Process a map of union members with field selection + defp process_member_map( + member_map, + union_types, + path, + process_fields_fn, + context, + load_acc, + template_acc + ) do + Enum.reduce(member_map, {load_acc, template_acc}, fn {member, member_fields}, + {l_acc, t_acc} -> + if Keyword.has_key?(union_types, member) do + member_config = Keyword.get(union_types, member) + member_return_type = union_member_to_return_type(member_config) + new_path = path ++ [member] + + # Use the provided field processing function + {_nested_select, nested_load, nested_template} = + process_fields_fn.(member_return_type, member_fields, new_path) + + # For union types, only embedded resources with loadable fields (calculations, + # aggregates) require explicit load statements. The union field selection itself + # ensures the entire union value is returned by Ash. + combined_load_fields = + case member_return_type do + {:resource, _resource} -> + # Embedded resource - only load loadable fields (calculations/aggregates) + nested_load + + _ -> + # All other types - no load statements needed + [] + end + + # Different accumulation strategies for attributes vs union types + case context do + :attribute -> + if combined_load_fields != [] do + {l_acc ++ [{member, combined_load_fields}], t_acc ++ [{member, nested_template}]} + else + {l_acc, t_acc ++ [{member, nested_template}]} + end + + :union_type -> + {l_acc ++ combined_load_fields, t_acc ++ [{member, nested_template}]} + end + else + field_path = build_error_path(path, context, member) + error_context = if context == :attribute, do: "union_attribute", else: "union_type" + throw({:unknown_field, member, error_context, field_path}) + end + end) end # Process a simple member selection (atom without nested fields) - defp process_simple_member(member, union_types, path, field_name, load_acc, template_acc) do + defp process_simple_member(member, union_types, path, context, load_acc, template_acc) do if Keyword.has_key?(union_types, member) do member_config = Keyword.get(union_types, member) - - # Check if this simple member actually requires field selection member_return_type = union_member_to_return_type(member_config) case member_return_type do @@ -158,7 +229,7 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.UnionProcessor do field_specs = Keyword.get(constraints, :fields, []) if field_specs != [] do - field_path = Utilities.build_field_path(path ++ [field_name], member) + field_path = build_error_path(path, context, member) throw({:requires_field_selection, :complex_type, field_path}) else # Map with no field constraints - simple type @@ -171,15 +242,28 @@ defmodule AshTypescript.Rpc.FieldProcessing.TypeProcessors.UnionProcessor do {:resource, _resource} -> # Embedded resource requires field selection - field_path = Utilities.build_field_path(path ++ [field_name], member) + field_path = build_error_path(path, context, member) throw({:requires_field_selection, :complex_type, field_path}) end else - field_path = Utilities.build_field_path(path ++ [field_name], member) - throw({:unknown_field, member, "union_attribute", field_path}) + field_path = build_error_path(path, context, member) + error_context = if context == :attribute, do: "union_attribute", else: "union_type" + throw({:unknown_field, member, error_context, field_path}) end end + # Build error path based on context + defp build_error_path(path, :attribute), do: Utilities.build_field_path(path, nil) + defp build_error_path(path, :union_type), do: Utilities.build_field_path(path, "union") + defp build_error_path(path, :attribute, member), do: Utilities.build_field_path(path, member) + defp build_error_path(path, :union_type, member), do: Utilities.build_field_path(path, member) + + # Helper function to extract union types from attribute constraints + # Handles both direct union types and array union types + defp get_union_types(attribute) do + Introspection.get_union_types(attribute) + end + @doc """ Convert union member configuration to a return type descriptor that can be processed by the existing field processing logic. diff --git a/lib/ash_typescript/rpc/pipeline.ex b/lib/ash_typescript/rpc/pipeline.ex index eb2dbc7..3d21038 100644 --- a/lib/ash_typescript/rpc/pipeline.ex +++ b/lib/ash_typescript/rpc/pipeline.ex @@ -52,7 +52,8 @@ defmodule AshTypescript.Rpc.Pipeline do conn_or_socket.assigns[:ash_context] || %{}} end - with {:ok, {resource, action, rpc_action}} <- discover_action(otp_app, normalized_params), + with {:ok, {domain, resource, action, rpc_action}} <- + discover_action(otp_app, normalized_params), :ok <- validate_required_parameters_for_action_type( normalized_params, @@ -137,6 +138,7 @@ defmodule AshTypescript.Rpc.Pipeline do request = Request.new(%{ + domain: domain, resource: resource, action: action, rpc_action: rpc_action, @@ -282,9 +284,9 @@ defmodule AshTypescript.Rpc.Pipeline do nil -> {:error, {:typed_query_not_found, typed_query_name}} - {resource, typed_query} -> + {domain, resource, typed_query} -> action = Ash.Resource.Info.action(resource, typed_query.action) - {:ok, {resource, action, typed_query}} + {:ok, {domain, resource, action, typed_query}} end end @@ -296,9 +298,9 @@ defmodule AshTypescript.Rpc.Pipeline do nil -> {:error, {:action_not_found, action_name}} - {resource, rpc_action} -> + {domain, resource, rpc_action} -> action = Ash.Resource.Info.action(resource, rpc_action.action) - {:ok, {resource, action, rpc_action}} + {:ok, {domain, resource, action, rpc_action}} end end @@ -313,12 +315,15 @@ defmodule AshTypescript.Rpc.Pipeline do otp_app |> Ash.Info.domains() - |> Enum.flat_map(&AshTypescript.Rpc.Info.typescript_rpc/1) - |> Enum.find_value(fn %{resource: resource, typed_queries: typed_queries} -> - Enum.find_value(typed_queries, fn typed_query -> - if to_string(typed_query.name) == query_string do - {resource, typed_query} - end + |> Enum.find_value(fn domain -> + domain + |> AshTypescript.Rpc.Info.typescript_rpc() + |> Enum.find_value(fn %{resource: resource, typed_queries: typed_queries} -> + Enum.find_value(typed_queries, fn typed_query -> + if to_string(typed_query.name) == query_string do + {domain, resource, typed_query} + end + end) end) end) end @@ -329,12 +334,15 @@ defmodule AshTypescript.Rpc.Pipeline do otp_app |> Ash.Info.domains() - |> Enum.flat_map(&AshTypescript.Rpc.Info.typescript_rpc/1) - |> Enum.find_value(fn %{resource: resource, rpc_actions: rpc_actions} -> - Enum.find_value(rpc_actions, fn rpc_action -> - if to_string(rpc_action.name) == action_string do - {resource, rpc_action} - end + |> Enum.find_value(fn domain -> + domain + |> AshTypescript.Rpc.Info.typescript_rpc() + |> Enum.find_value(fn %{resource: resource, rpc_actions: rpc_actions} -> + Enum.find_value(rpc_actions, fn rpc_action -> + if to_string(rpc_action.name) == action_string do + {domain, resource, rpc_action} + end + end) end) end) end @@ -535,35 +543,120 @@ defmodule AshTypescript.Rpc.Pipeline do end defp execute_update_action(%Request{} = request, opts) do - with {:ok, record} <- Ash.get(request.resource, request.primary_key, opts) do - record - |> Ash.Changeset.for_update(request.action.name, request.input, opts) - |> Ash.Changeset.select(request.select) - |> Ash.Changeset.load(request.load) - |> Ash.update() + # Build a query to find the record by primary key + filter = primary_key_filter(request.resource, request.primary_key) + + # Use the configured read_action if available + read_action = request.rpc_action.read_action + + query = + request.resource + |> Ash.Query.do_filter(filter) + |> Ash.Query.set_tenant(opts[:tenant]) + |> Ash.Query.set_context(opts[:context] || %{}) + |> Ash.Query.limit(1) + + # Build bulk_update options with read_action if configured + bulk_opts = [ + return_errors?: true, + notify?: true, + strategy: [:atomic, :stream, :atomic_batches], + allow_stream_with: :full_read, + authorize_changeset_with: authorize_bulk_with(request.resource), + return_records?: true, + tenant: opts[:tenant], + context: opts[:context] || %{}, + actor: opts[:actor], + domain: request.domain, + select: request.select, + load: request.load + ] + + # Add read_action if configured + bulk_opts = + if read_action do + Keyword.put(bulk_opts, :read_action, read_action) + else + bulk_opts + end + + # Use bulk_update with the query + result = + query + |> Ash.bulk_update(request.action.name, request.input, bulk_opts) + + # Handle the bulk result + case result do + %Ash.BulkResult{status: :success, records: [record]} -> + {:ok, record} + + %Ash.BulkResult{status: :success, records: []} -> + {:error, Ash.Error.Query.NotFound.exception(resource: request.resource)} + + %Ash.BulkResult{errors: errors} when errors != [] -> + {:error, errors} + + other -> + {:error, other} end end defp execute_destroy_action(%Request{} = request, opts) do - with {:ok, record} <- Ash.get(request.resource, request.primary_key, opts) do - changeset = - Ash.Changeset.for_destroy( - record, - request.action.name, - request.input, - opts ++ [error?: true] - ) + # Build a query to find the record by primary key + filter = primary_key_filter(request.resource, request.primary_key) - case Ash.destroy(changeset, return_destroyed?: true) do - :ok -> - {:ok, %{}} + # Use the configured read_action if available + read_action = request.rpc_action.read_action - {:ok, destroyed_record} -> - {:ok, destroyed_record} + query = + request.resource + |> Ash.Query.do_filter(filter) + |> Ash.Query.set_tenant(opts[:tenant]) + |> Ash.Query.set_context(opts[:context] || %{}) + |> Ash.Query.limit(1) + |> apply_select_and_load(request) + + # Build bulk_destroy options with read_action if configured + bulk_opts = [ + return_errors?: true, + notify?: true, + strategy: [:atomic, :stream, :atomic_batches], + allow_stream_with: :full_read, + authorize_changeset_with: authorize_bulk_with(request.resource), + return_records?: true, + tenant: opts[:tenant], + context: opts[:context] || %{}, + actor: opts[:actor], + domain: request.domain + ] - error -> - error + # Add read_action if configured + bulk_opts = + if read_action do + Keyword.put(bulk_opts, :read_action, read_action) + else + bulk_opts end + + # Use bulk_destroy with the query + result = + query + |> Ash.bulk_destroy(request.action.name, request.input, bulk_opts) + + # Handle the bulk result + case result do + %Ash.BulkResult{status: :success, records: [record]} -> + {:ok, record} + + %Ash.BulkResult{status: :success, records: []} -> + # If no records returned but operation succeeded, return empty map + {:ok, %{}} + + %Ash.BulkResult{errors: errors} when errors != [] -> + {:error, errors} + + other -> + {:error, other} end end @@ -789,6 +882,45 @@ defmodule AshTypescript.Rpc.Pipeline do end end + defp primary_key_filter(resource, primary_key_value) do + primary_key_fields = Ash.Resource.Info.primary_key(resource) + + if is_map(primary_key_value) do + Enum.map(primary_key_fields, fn field -> + {field, Map.get(primary_key_value, field)} + end) + else + [{List.first(primary_key_fields), primary_key_value}] + end + end + + # Helper to determine authorization strategy for bulk operations + defp authorize_bulk_with(resource) do + if Ash.DataLayer.data_layer_can?(resource, :expr_error) do + :error + else + :filter + end + end + + # Helper to apply select and load to a query + # Only applies them if they contain actual values (not empty lists) + # Empty lists can cause issues with embedded resource loading + defp apply_select_and_load(query, request) do + query = + if request.select && request.select != [] do + Ash.Query.select(query, request.select) + else + query + end + + if request.load && request.load != [] do + Ash.Query.load(query, request.load) + else + query + end + end + defp add_metadata(filtered_result, original_result, %Request{} = request) do if Enum.empty?(request.show_metadata) do filtered_result diff --git a/lib/ash_typescript/rpc/request.ex b/lib/ash_typescript/rpc/request.ex index 4c5c1ad..e5db8a7 100644 --- a/lib/ash_typescript/rpc/request.ex +++ b/lib/ash_typescript/rpc/request.ex @@ -11,6 +11,7 @@ defmodule AshTypescript.Rpc.Request do """ @type t :: %__MODULE__{ + domain: module(), resource: module(), action: map(), rpc_action: map(), @@ -29,6 +30,7 @@ defmodule AshTypescript.Rpc.Request do } defstruct [ + :domain, :resource, :action, :rpc_action, diff --git a/lib/ash_typescript/rpc/result_processor.ex b/lib/ash_typescript/rpc/result_processor.ex index ff402ad..ab0154a 100644 --- a/lib/ash_typescript/rpc/result_processor.ex +++ b/lib/ash_typescript/rpc/result_processor.ex @@ -59,60 +59,109 @@ defmodule AshTypescript.Rpc.ResultProcessor do end defp extract_list_fields(results, extraction_template, resource) do - if extraction_template == [] and Enum.any?(results, &(not is_map(&1))) do - Enum.map(results, &normalize_value_for_json/1) - else - Enum.map(results, &extract_single_result(&1, extraction_template, resource)) + cond do + # For empty templates with primitive struct types (Date, DateTime, etc.), just normalize + extraction_template == [] and Enum.any?(results, &is_primitive_struct?/1) -> + Enum.map(results, &normalize_value_for_json/1) + + # For empty templates with structs that are resources, extract all public fields + (extraction_template == [] and Enum.any?(results, &is_struct(&1)) and + resource) && Ash.Resource.Info.resource?(resource) -> + # Extract all public fields from resource structs + Enum.map(results, fn item -> + case item do + %_struct{} -> + public_attrs = Ash.Resource.Info.public_attributes(resource) + public_calcs = Ash.Resource.Info.public_calculations(resource) + public_aggs = Ash.Resource.Info.public_aggregates(resource) + + all_public_fields = + Enum.map(public_attrs, & &1.name) ++ + Enum.map(public_calcs, & &1.name) ++ + Enum.map(public_aggs, & &1.name) + + extract_single_result(item, all_public_fields, resource) + + other -> + normalize_value_for_json(other) + end + end) + + # For empty templates with non-map values, just normalize + extraction_template == [] and Enum.any?(results, &(not is_map(&1))) -> + Enum.map(results, &normalize_value_for_json/1) + + # Otherwise use the extraction template + true -> + Enum.map(results, &extract_single_result(&1, extraction_template, resource)) + end + end + + # Check if a value is a primitive struct type that should be normalized rather than extracted + defp is_primitive_struct?(value) do + case value do + %DateTime{} -> true + %Date{} -> true + %Time{} -> true + %NaiveDateTime{} -> true + %Decimal{} -> true + %Ash.CiString{} -> true + _ -> false end end defp extract_single_result(data, extraction_template, resource) when is_list(extraction_template) do - is_tuple = is_tuple(data) - - typed_struct_module = - if is_map(data) and not is_tuple(data) and Map.has_key?(data, :__struct__) do - module = data.__struct__ - if Introspection.is_typed_struct?(module), do: module, else: nil - else - nil - end + # For empty templates with primitive struct types (Date, DateTime, etc.), just normalize + if extraction_template == [] and is_primitive_struct?(data) do + normalize_value_for_json(data) + else + is_tuple = is_tuple(data) - normalized_data = - cond do - is_tuple -> - convert_tuple_to_map(data, extraction_template) + typed_struct_module = + if is_map(data) and not is_tuple(data) and Map.has_key?(data, :__struct__) do + module = data.__struct__ + if Introspection.is_typed_struct?(module), do: module, else: nil + else + nil + end - Keyword.keyword?(data) -> - Map.new(data) + normalized_data = + cond do + is_tuple -> + convert_tuple_to_map(data, extraction_template) - true -> - normalize_data(data) - end + Keyword.keyword?(data) -> + Map.new(data) - effective_resource = resource || typed_struct_module + true -> + normalize_data(data) + end - if is_tuple do - normalized_data - else - Enum.reduce(extraction_template, %{}, fn field_spec, acc -> - case field_spec do - field_atom when is_atom(field_atom) or is_tuple(data) -> - extract_simple_field(normalized_data, field_atom, acc, effective_resource) - - {field_atom, nested_template} when is_atom(field_atom) and is_list(nested_template) -> - extract_nested_field( - normalized_data, - field_atom, - nested_template, - acc, - effective_resource - ) + effective_resource = resource || typed_struct_module - _ -> - acc - end - end) + if is_tuple do + normalized_data + else + Enum.reduce(extraction_template, %{}, fn field_spec, acc -> + case field_spec do + field_atom when is_atom(field_atom) or is_tuple(data) -> + extract_simple_field(normalized_data, field_atom, acc, effective_resource) + + {field_atom, nested_template} when is_atom(field_atom) and is_list(nested_template) -> + extract_nested_field( + normalized_data, + field_atom, + nested_template, + acc, + effective_resource + ) + + _ -> + acc + end + end) + end end end @@ -138,9 +187,64 @@ defmodule AshTypescript.Rpc.ResultProcessor do %Ash.NotLoaded{} -> acc - # Extract the value and normalize it + # Extract the value value -> - Map.put(acc, output_field_name, normalize_value_for_json(value)) + # Check if this field is a struct type that should be treated as nested + field_resource = get_field_struct_resource(resource, field_atom) + + normalized_value = + if field_resource do + # If it's a struct field with a resource, treat it like a nested field + # and only include public attributes + case value do + nil -> + nil + + %_struct{} = struct_value -> + # Extract all public fields when no specific selection is made + public_attrs = Ash.Resource.Info.public_attributes(field_resource) + public_calcs = Ash.Resource.Info.public_calculations(field_resource) + public_aggs = Ash.Resource.Info.public_aggregates(field_resource) + + all_public_fields = + Enum.map(public_attrs, & &1.name) ++ + Enum.map(public_calcs, & &1.name) ++ + Enum.map(public_aggs, & &1.name) + + extract_single_result(struct_value, all_public_fields, field_resource) + + list when is_list(list) -> + Enum.map(list, fn item -> + case item do + nil -> + nil + + %_struct{} -> + public_attrs = Ash.Resource.Info.public_attributes(field_resource) + public_calcs = Ash.Resource.Info.public_calculations(field_resource) + public_aggs = Ash.Resource.Info.public_aggregates(field_resource) + + all_public_fields = + Enum.map(public_attrs, & &1.name) ++ + Enum.map(public_calcs, & &1.name) ++ + Enum.map(public_aggs, & &1.name) + + extract_single_result(item, all_public_fields, field_resource) + + other -> + normalize_value_for_json(other) + end + end) + + other -> + normalize_value_for_json(other) + end + else + # For non-struct fields, normalize as usual + normalize_value_for_json(value) + end + + Map.put(acc, output_field_name, normalized_value) end end @@ -201,11 +305,16 @@ defmodule AshTypescript.Rpc.ResultProcessor do # Recursively normalize values for JSON serialization def normalize_value_for_json(value) do + normalize_value_for_json(value, nil) + end + + # Version with extraction template for field selection + defp normalize_value_for_json(value, extraction_template) do case value do # Handle Ash union types %Ash.Union{type: type_name, value: union_value} -> type_key = to_string(type_name) - normalized_value = normalize_value_for_json(union_value) + normalized_value = normalize_value_for_json(union_value, extraction_template) %{type_key => normalized_value} # Handle native Elixir structs that need special JSON formatting @@ -232,30 +341,26 @@ defmodule AshTypescript.Rpc.ResultProcessor do # Convert structs to maps recursively %_struct{} = struct_data -> - struct_data - |> Map.from_struct() - |> Enum.reduce(%{}, fn {key, val}, acc -> - Map.put(acc, key, normalize_value_for_json(val)) - end) + normalize_struct(struct_data, extraction_template) list when is_list(list) -> if Keyword.keyword?(list) do result = Enum.reduce(list, %{}, fn {key, val}, acc -> string_key = to_string(key) - normalized_val = normalize_value_for_json(val) + normalized_val = normalize_value_for_json(val, extraction_template) Map.put(acc, string_key, normalized_val) end) result else - Enum.map(list, &normalize_value_for_json/1) + Enum.map(list, &normalize_value_for_json(&1, extraction_template)) end # Handle maps recursively (but not structs, handled above) map when is_map(map) and not is_struct(map) -> Enum.reduce(map, %{}, fn {key, val}, acc -> - Map.put(acc, key, normalize_value_for_json(val)) + Map.put(acc, key, normalize_value_for_json(val, extraction_template)) end) # Pass through primitives @@ -264,6 +369,56 @@ defmodule AshTypescript.Rpc.ResultProcessor do end end + # Normalize struct data, filtering to public attributes if it's a resource + defp normalize_struct(struct_data, extraction_template) do + module = struct_data.__struct__ + + # If it's an Ash resource, filter to public attributes + if Ash.Resource.Info.resource?(module) do + normalize_resource_struct(struct_data, module, extraction_template) + else + # For other structs, convert all fields + struct_data + |> Map.from_struct() + |> Enum.reduce(%{}, fn {key, val}, acc -> + Map.put(acc, key, normalize_value_for_json(val, nil)) + end) + end + end + + # Normalize a resource struct with field filtering and selection + defp normalize_resource_struct(struct_data, resource, extraction_template) do + # If we have an extraction template, use it for field selection + if extraction_template do + extract_single_result(struct_data, extraction_template, resource) + else + # Otherwise, include all public attributes + public_attrs = Ash.Resource.Info.public_attributes(resource) + + # Also include public calculations and aggregates + public_calcs = Ash.Resource.Info.public_calculations(resource) + public_aggs = Ash.Resource.Info.public_aggregates(resource) + + # Combine all public field names + public_field_names = + (Enum.map(public_attrs, & &1.name) ++ + Enum.map(public_calcs, & &1.name) ++ + Enum.map(public_aggs, & &1.name)) + |> MapSet.new() + + # Convert to map and filter to public fields + struct_data + |> Map.from_struct() + |> Enum.reduce(%{}, fn {key, val}, acc -> + if MapSet.member?(public_field_names, key) do + Map.put(acc, key, normalize_value_for_json(val, nil)) + else + acc + end + end) + end + end + # Extract nested data recursively with proper permission handling defp extract_nested_data(data, template, resource) do case data do @@ -367,24 +522,95 @@ defmodule AshTypescript.Rpc.ResultProcessor do relationship = Ash.Resource.Info.relationship(resource, field_name) -> relationship.destination - # Check if it's an embedded resource attribute + # Check if it's an attribute + attribute = Ash.Resource.Info.attribute(resource, field_name) -> + get_resource_from_attribute_type(attribute.type, attribute.constraints) + + # Check if it's a calculation + calculation = Ash.Resource.Info.public_calculation(resource, field_name) -> + get_resource_from_type(calculation.type, calculation.constraints) + + # Check if it's an aggregate + aggregate = Ash.Resource.Info.public_aggregate(resource, field_name) -> + # Aggregates typically don't have nested resources, but check just in case + aggregate_type = Ash.Resource.Info.aggregate_type(resource, aggregate) + get_resource_from_type(aggregate_type, []) + + true -> + nil + end + end + + # Helper to extract resource from attribute type + defp get_resource_from_attribute_type(type, constraints) do + case type do + {:array, inner_type} -> + get_resource_from_type(inner_type, constraints[:items] || []) + + other -> + get_resource_from_type(other, constraints) + end + end + + # Helper to extract resource from a type and constraints + defp get_resource_from_type(type, constraints) do + case type do + # Check for Ash.Type.Struct with instance_of + Ash.Type.Struct -> + instance_of = Keyword.get(constraints, :instance_of) + + if instance_of && Ash.Resource.Info.resource?(instance_of) do + instance_of + else + nil + end + + # Check for embedded resource types + resource_module when is_atom(resource_module) -> + if Ash.Resource.Info.resource?(resource_module) && + Ash.Resource.Info.embedded?(resource_module) do + resource_module + else + nil + end + + _ -> + nil + end + end + + # Get the resource type for a field if it's a struct type (not nested/relationship) + defp get_field_struct_resource(nil, _field_name), do: nil + + defp get_field_struct_resource(resource, field_name) do + cond do + # Check attributes for struct types attribute = Ash.Resource.Info.attribute(resource, field_name) -> case attribute.type do - {:array, embedded_resource} when is_atom(embedded_resource) -> - if Ash.Resource.Info.resource?(embedded_resource) && - Ash.Resource.Info.embedded?(embedded_resource) do - embedded_resource - else - nil - end + Ash.Type.Struct -> + instance_of = Keyword.get(attribute.constraints || [], :instance_of) + if instance_of && Ash.Resource.Info.resource?(instance_of), do: instance_of, else: nil - embedded_resource when is_atom(embedded_resource) -> - if Ash.Resource.Info.resource?(embedded_resource) && - Ash.Resource.Info.embedded?(embedded_resource) do - embedded_resource - else - nil - end + {:array, Ash.Type.Struct} -> + items_constraints = Keyword.get(attribute.constraints || [], :items, []) + instance_of = Keyword.get(items_constraints, :instance_of) + if instance_of && Ash.Resource.Info.resource?(instance_of), do: instance_of, else: nil + + _ -> + nil + end + + # Check calculations for struct types + calculation = Ash.Resource.Info.public_calculation(resource, field_name) -> + case calculation.type do + Ash.Type.Struct -> + instance_of = Keyword.get(calculation.constraints || [], :instance_of) + if instance_of && Ash.Resource.Info.resource?(instance_of), do: instance_of, else: nil + + {:array, Ash.Type.Struct} -> + items_constraints = Keyword.get(calculation.constraints || [], :items, []) + instance_of = Keyword.get(items_constraints, :instance_of) + if instance_of && Ash.Resource.Info.resource?(instance_of), do: instance_of, else: nil _ -> nil diff --git a/lib/ash_typescript/rpc/validation_error_schemas.ex b/lib/ash_typescript/rpc/validation_error_schemas.ex index a9df7bc..aa0ef9c 100644 --- a/lib/ash_typescript/rpc/validation_error_schemas.ex +++ b/lib/ash_typescript/rpc/validation_error_schemas.ex @@ -13,13 +13,14 @@ defmodule AshTypescript.Rpc.ValidationErrorSchemas do import AshTypescript.Helpers import AshTypescript.Codegen, only: [build_resource_type_name: 1] + alias AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection alias AshTypescript.TypeSystem.Introspection @doc """ Generates validation error type for an RPC action. """ def generate_validation_error_type(resource, action, rpc_action_name) do - if action_has_input?(resource, action) do + if ActionIntrospection.action_input_type(resource, action) != :none do error_type_name = "#{snake_to_pascal_case(rpc_action_name)}ValidationErrors" error_field_defs = generate_rpc_action_error_fields(resource, action) @@ -327,21 +328,4 @@ defmodule AshTypescript.Rpc.ValidationErrorSchemas do function_exported?(type, :typescript_type_name, 0) and Spark.implements_behaviour?(type, Ash.Type) end - - defp action_has_input?(resource, action) do - case action.type do - :read -> - action.arguments != [] - - :create -> - accepts = Ash.Resource.Info.action(resource, action.name).accept || [] - accepts != [] || action.arguments != [] - - action_type when action_type in [:update, :destroy] -> - action.accept != [] || action.arguments != [] - - :action -> - action.arguments != [] - end - end end diff --git a/lib/ash_typescript/rpc/zod_schema_generator.ex b/lib/ash_typescript/rpc/zod_schema_generator.ex index 1c670bf..109d2f0 100644 --- a/lib/ash_typescript/rpc/zod_schema_generator.ex +++ b/lib/ash_typescript/rpc/zod_schema_generator.ex @@ -11,6 +11,7 @@ defmodule AshTypescript.Rpc.ZodSchemaGenerator do """ alias AshTypescript.Codegen.Helpers, as: CodegenHelpers + alias AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection alias AshTypescript.TypeSystem.Introspection import AshTypescript.Helpers @@ -282,7 +283,7 @@ defmodule AshTypescript.Rpc.ZodSchemaGenerator do Generates a Zod schema definition for action input validation. """ def generate_zod_schema(resource, action, rpc_action_name) do - if action_has_input?(resource, action) do + if ActionIntrospection.action_input_type(resource, action) != :none do suffix = AshTypescript.Rpc.zod_schema_suffix() schema_name = format_output_field("#{rpc_action_name}_#{suffix}") @@ -467,23 +468,6 @@ defmodule AshTypescript.Rpc.ZodSchemaGenerator do Spark.implements_behaviour?(type, Ash.Type) end - defp action_has_input?(resource, action) do - case action.type do - :read -> - action.arguments != [] - - :create -> - accepts = Ash.Resource.Info.action(resource, action.name).accept || [] - accepts != [] || action.arguments != [] - - action_type when action_type in [:update, :destroy] -> - action.accept != [] || action.arguments != [] - - :action -> - action.arguments != [] - end - end - defp build_integer_zod_with_constraints(constraints) do base = "z.number().int()" diff --git a/mix.exs b/mix.exs index 6feecb8..84bb78a 100644 --- a/mix.exs +++ b/mix.exs @@ -112,6 +112,7 @@ defmodule AshTypescript.MixProject do # Topics "documentation/topics/lifecycle-hooks.md", + "documentation/topics/error-handling.md", "documentation/topics/phoenix-channels.md", "documentation/topics/embedded-resources.md", "documentation/topics/union-types.md", diff --git a/test/ash_typescript/rpc/comprehensive_integration_test.exs b/test/ash_typescript/rpc/comprehensive_integration_test.exs index 3aa18ac..5a22dc8 100644 --- a/test/ash_typescript/rpc/comprehensive_integration_test.exs +++ b/test/ash_typescript/rpc/comprehensive_integration_test.exs @@ -1274,10 +1274,8 @@ defmodule AshTypescript.Rpc.ComprehensiveIntegrationTest do assert result["success"] == false first_error = List.first(result["errors"]) - assert first_error["type"] == "unknown_error" - assert first_error["message"] == "An unexpected error occurred" - assert String.contains?(first_error["details"]["error"], "invalid_user_field") - assert String.contains?(first_error["details"]["error"], "user") + assert first_error["type"] == "unknown_field" + assert is_binary(first_error["message"]) end test "missing required input parameters return validation errors" do @@ -1296,7 +1294,8 @@ defmodule AshTypescript.Rpc.ComprehensiveIntegrationTest do assert result["success"] == false # Should get validation error about missing required field first_error = List.first(result["errors"]) - assert first_error["type"] == "ash_error" + # Should get validation error about missing required field + assert first_error["type"] == "required" end test "invalid primary key for get operations returns not found error" do diff --git a/test/ash_typescript/rpc/error_handling_test.exs b/test/ash_typescript/rpc/error_handling_test.exs index fbb0843..f788d72 100644 --- a/test/ash_typescript/rpc/error_handling_test.exs +++ b/test/ash_typescript/rpc/error_handling_test.exs @@ -91,17 +91,12 @@ defmodule AshTypescript.Rpc.ErrorHandlingTest do response = ErrorBuilder.build_error_response(ash_error) - assert response.type == "ash_error" - # Uses Exception.message/1 - assert String.contains?(response.message, "Invalid") - assert response.details.class == :invalid - assert response.details.path == [:data, :attributes] - assert is_list(response.details.errors) - assert length(response.details.errors) == 1 - - nested_error = List.first(response.details.errors) - assert nested_error.field == :title - assert String.contains?(nested_error.message, "is required") + # Now uses the protocol which extracts the error code + assert response.type == "invalid_attribute" + assert is_binary(response.message) + assert response.fields == [:title] + # Path comes from the inner error, not the wrapper + assert response.path == [] end test "generic ash error fallback" do @@ -109,8 +104,9 @@ defmodule AshTypescript.Rpc.ErrorHandlingTest do response = ErrorBuilder.build_error_response(ash_error) + # Now converts to Ash error class (UnknownError) and uses its protocol implementation assert response.type == "unknown_error" - assert response.details.error != nil + assert is_binary(response.message) end end diff --git a/test/ash_typescript/rpc/error_protocol_test.exs b/test/ash_typescript/rpc/error_protocol_test.exs new file mode 100644 index 0000000..1d85ffa --- /dev/null +++ b/test/ash_typescript/rpc/error_protocol_test.exs @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.ErrorProtocolTest do + use ExUnit.Case + + alias AshTypescript.Rpc.{DefaultErrorHandler, Error, Errors} + + @moduletag :ash_typescript + + describe "Error protocol implementation" do + test "InvalidChanges error is properly transformed" do + error = %Ash.Error.Changes.InvalidChanges{ + fields: [:field1, :field2], + vars: [key: "value"], + path: [:data, :attributes] + } + + result = Error.to_error(error) + + # Message comes from Exception.message/1 + assert is_binary(result.message) + assert result.short_message == "Invalid changes" + assert result.type == "invalid_changes" + assert result.vars == %{key: "value"} + assert result.fields == [:field1, :field2] + assert result.path == [:data, :attributes] + end + + test "NotFound error is properly transformed" do + error = %Ash.Error.Query.NotFound{ + vars: [], + resource: "MyResource" + } + + result = Error.to_error(error) + + # Message comes from Exception.message/1 + assert is_binary(result.message) + assert result.short_message == "Not found" + assert result.type == "not_found" + assert result.fields == [] + end + + test "Required field error includes field information" do + error = %Ash.Error.Changes.Required{ + field: :email, + vars: [] + } + + result = Error.to_error(error) + + # Message comes from Exception.message/1 + assert is_binary(result.message) + assert result.short_message == "Required field" + assert result.type == "required" + assert result.vars == %{field: :email} + assert result.fields == [:email] + end + + test "Forbidden policy error is transformed correctly" do + error = %Ash.Error.Forbidden.Policy{ + vars: [], + policy_breakdown?: false + } + + result = Error.to_error(error) + + # Message comes from Exception.message/1 + assert is_binary(result.message) + assert result.short_message == "Forbidden" + assert result.type == "forbidden" + assert result.fields == [] + end + + test "InvalidAttribute error includes field details" do + error = %Ash.Error.Changes.InvalidAttribute{ + field: :age, + vars: [] + } + + result = Error.to_error(error) + + # Message comes from Exception.message/1 + assert is_binary(result.message) + assert result.short_message == "Invalid attribute" + assert result.type == "invalid_attribute" + assert result.fields == [:age] + end + + test "errors without path default to empty list" do + error = %Ash.Error.Query.Required{ + field: :name, + vars: [] + } + + result = Error.to_error(error) + + assert result.path == [] + end + + test "errors with path preserve it" do + error = %Ash.Error.Changes.Required{ + field: :email, + vars: [], + path: [:user, :profile] + } + + result = Error.to_error(error) + + assert result.path == [:user, :profile] + assert result.fields == [:email] + end + end + + describe "Error unwrapping" do + test "unwraps nested Ash.Error.Invalid errors" do + inner_error = %Ash.Error.Changes.Required{field: :title} + + wrapped_error = %Ash.Error.Invalid{ + errors: [inner_error] + } + + result = Errors.unwrap_errors(wrapped_error) + + assert result == [inner_error] + end + + test "unwraps deeply nested errors" do + innermost = %Ash.Error.Changes.Required{field: :title} + + middle = %Ash.Error.Invalid{ + errors: [innermost] + } + + outer = %Ash.Error.Forbidden{ + errors: [middle] + } + + result = Errors.unwrap_errors(outer) + + assert result == [innermost] + end + + test "handles mixed error lists" do + error1 = %Ash.Error.Changes.Required{field: :title} + error2 = %Ash.Error.Changes.InvalidAttribute{field: :age} + + wrapped = %Ash.Error.Invalid{ + errors: [error1, error2] + } + + result = Errors.unwrap_errors([wrapped]) + + assert length(result) == 2 + assert error1 in result + assert error2 in result + end + end + + describe "Error processing pipeline" do + test "processes single error through full pipeline" do + error = %Ash.Error.Changes.Required{ + field: :email + } + + [result] = Errors.to_errors(error) + + # Should have a message from Exception.message/1 + assert is_binary(result.message) + assert result.type == "required" + assert result.fields == [:email] + end + + test "processes multiple errors" do + errors = [ + %Ash.Error.Changes.Required{field: :email}, + %Ash.Error.Changes.InvalidAttribute{field: :age} + ] + + results = Errors.to_errors(errors) + + assert length(results) == 2 + codes = Enum.map(results, & &1.type) + assert "required" in codes + assert "invalid_attribute" in codes + end + + test "converts non-Ash errors to Ash error classes" do + # Simulate a generic exception + error = %RuntimeError{message: "Something went wrong"} + + # This should convert to an Ash error class first + results = Errors.to_errors(error) + + assert is_list(results) + assert length(results) > 0 + end + end + + describe "Default error handler" do + test "returns error as-is without interpolating variables" do + error = %{ + message: "Field %{field} must be at least %{min} characters", + short_message: "Too short", + vars: %{field: "password", min: 8}, + type: "validation_error" + } + + result = DefaultErrorHandler.handle_error(error, %{}) + + # Variables should NOT be interpolated - client handles that + assert result.message == "Field %{field} must be at least %{min} characters" + assert result.short_message == "Too short" + assert result.vars == %{field: "password", min: 8} + end + + test "handles errors without variables" do + error = %{ + message: "Field %{field} is invalid", + short_message: "Invalid", + vars: %{}, + code: "error" + } + + result = DefaultErrorHandler.handle_error(error, %{}) + + assert result.message == "Field %{field} is invalid" + end + + test "preserves error structure when no vars" do + error = %{ + message: "Simple error message", + short_message: "Error", + code: "error" + } + + result = DefaultErrorHandler.handle_error(error, %{}) + + assert result == error + end + end + + describe "Integration with ErrorBuilder" do + test "ErrorBuilder uses protocol for Ash errors" do + ash_error = %Ash.Error.Query.NotFound{} + + result = AshTypescript.Rpc.ErrorBuilder.build_error_response(ash_error) + + # Should have been processed through the protocol + assert result.type == "not_found" + assert is_binary(result.message) + end + + test "ErrorBuilder handles wrapped Ash errors" do + inner_error = %Ash.Error.Changes.Required{ + field: :title + } + + wrapped = %Ash.Error.Invalid{ + class: :invalid, + errors: [inner_error] + } + + result = AshTypescript.Rpc.ErrorBuilder.build_error_response(wrapped) + + # Should unwrap and process the inner error + assert result.type == "required" + assert is_binary(result.message) + end + + test "ErrorBuilder handles multiple errors" do + errors = %Ash.Error.Invalid{ + class: :invalid, + errors: [ + %Ash.Error.Changes.Required{field: :title}, + %Ash.Error.Changes.InvalidAttribute{field: :age} + ] + } + + result = AshTypescript.Rpc.ErrorBuilder.build_error_response(errors) + + assert result.type == "multiple_errors" + assert is_list(result.errors) + assert length(result.errors) == 2 + end + end +end diff --git a/test/ash_typescript/rpc/error_scenarios_test.exs b/test/ash_typescript/rpc/error_scenarios_test.exs index 67db79a..6dd7a42 100644 --- a/test/ash_typescript/rpc/error_scenarios_test.exs +++ b/test/ash_typescript/rpc/error_scenarios_test.exs @@ -2,258 +2,58 @@ # # SPDX-License-Identifier: MIT -defmodule AshTypescript.Rpc.ErrorScenariosTest do - @moduledoc """ - Tests for comprehensive error handling through the refactored AshTypescript.Rpc module. - - This module focuses on testing: - - Invalid action names and non-existent RPC actions - - Invalid field names (base fields, relationship fields, calculation fields) - - Malformed input structures and data type validation - - Missing required parameters and validation - - Invalid pagination parameters and edge cases - - Field structure validation errors and malformed syntax - - Comprehensive error message validation and user-friendly responses - - Error scenarios with embedded resources and union types - - All error scenarios are tested end-to-end through AshTypescript.Rpc.run_action/3. - Tests verify both error detection and quality of error messaging. - """ - +defmodule AshTypescript.Rpc.NewErrorTest do + @moduledoc false use ExUnit.Case, async: false alias AshTypescript.Rpc alias AshTypescript.Test.TestHelpers - @moduletag :ash_typescript - - describe "invalid action names" do - test "non-existent RPC action returns meaningful error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "non_existent_action", - "fields" => ["id"] - }) - - assert result["success"] == false - errors = result["errors"] - assert is_list(errors) - assert length(errors) > 0 - - # Should have clear error about non-existent action - action_error = List.first(errors) - assert action_error["type"] == "action_not_found" - assert String.contains?(action_error["message"], "non_existent_action") - assert String.contains?(action_error["message"], "not found") - end - - test "RPC action not configured for resource returns error" do - conn = TestHelpers.build_rpc_conn() - - # Try to use an action that exists on the resource but isn't exposed via RPC - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "some_internal_action", - "fields" => ["id"] - }) - - assert result["success"] == false - errors = result["errors"] - assert is_list(errors) - - # Should have error about action not being available via RPC - rpc_error = List.first(errors) - assert rpc_error["type"] == "action_not_found" - assert String.contains?(rpc_error["message"], "some_internal_action") - assert String.contains?(rpc_error["message"], "not found") - end - - test "empty action name returns validation error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "", - "fields" => ["id"] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about empty action - empty_action_error = List.first(errors) - assert empty_action_error["type"] == "missing_required_parameter" - assert String.contains?(empty_action_error["message"], "action") - assert String.contains?(empty_action_error["message"], "missing or empty") - end - - test "missing action parameter returns validation error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "fields" => ["id"] - # Missing "action" parameter - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about missing action parameter - missing_action_error = List.first(errors) - assert missing_action_error["type"] == "missing_required_parameter" - assert String.contains?(missing_action_error["message"], "action") - assert String.contains?(missing_action_error["message"], "missing or empty") - end - end - - describe "invalid field names" do - test "non-existent base field returns error" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Test Todo", - "user_id" => user["id"] - }, - "fields" => [ - "id", - "title", - # This field doesn't exist on Todo resource - "non_existent_field" - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about non-existent field - field_error = List.first(errors) - assert field_error["type"] == "unknown_error" - assert field_error["message"] == "An unexpected error occurred" - assert String.contains?(field_error["details"]["error"], "non_existent_field") - end - - test "non-existent relationship field returns error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => [ - "id", - "title", - # This relationship doesn't exist - %{"non_existent_relation" => ["id"]} - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about non-existent relationship - relation_error = List.first(errors) - assert relation_error["type"] == "unknown_field" - - assert String.contains?(relation_error["message"], "nonExistentRelation") or - String.contains?(relation_error["fieldPath"], "nonExistentRelation") - end - - test "non-existent calculation field returns error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => [ - "id", - "title", - # This calculation doesn't exist - "non_existent_calculation" - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about non-existent calculation - calc_error = List.first(errors) - assert calc_error["type"] == "unknown_error" - assert calc_error["message"] == "An unexpected error occurred" - assert String.contains?(calc_error["details"]["error"], "non_existent_calculation") - end - - test "private field access returns error" do + describe "actual error behavior" do + test "invalid field returns unknown_field error" do conn = TestHelpers.build_rpc_conn() result = Rpc.run_action(:ash_typescript, conn, %{ "action" => "list_todos", - "fields" => [ - "id", - "title", - # This is a private field (public? false) - # Note: camelCase due to field formatting - "updatedAt" - ] + "fields" => ["id", "invalid_field"] }) assert result["success"] == false - errors = result["errors"] - - # Should have error about private field access - private_error = List.first(errors) - assert private_error["type"] == "unknown_field" + [error | _] = result["errors"] - assert String.contains?(private_error["message"], "updatedAt") or - String.contains?(private_error["fieldPath"], "updatedAt") + # The actual behavior is that invalid fields throw {:invalid_field_type, ...} + # which we now convert to unknown_field + assert error["type"] == "unknown_field" + assert error["message"] =~ "Unknown field" end - end - describe "malformed input structures" do - test "wrong data type for required field returns validation error" do + test "wrong data type returns invalid_attribute error" do conn = TestHelpers.build_rpc_conn() - user = TestHelpers.create_test_user(conn, fields: ["id"]) result = Rpc.run_action(:ash_typescript, conn, %{ "action" => "create_todo", "input" => %{ - # Should be string, not integer - "title" => 12_345, + # Should be string + "title" => 123, "user_id" => user["id"] }, - "fields" => ["id", "title"] + "fields" => ["id"] }) assert result["success"] == false - errors = result["errors"] + [error | _] = result["errors"] - # Should have error about wrong data type for title - type_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "title") or - String.contains?(field, "title") or - String.contains?(message, "type") or - String.contains?(message, "string") - end) - - assert type_error, "Should have error about wrong data type for title" + assert error["type"] == "invalid_attribute" + assert error["message"] == "is invalid" + # Fields come as atoms from Ash + assert :title in error["fields"] end - test "missing required field returns validation error" do + test "missing required field returns required error" do conn = TestHelpers.build_rpc_conn() - user = TestHelpers.create_test_user(conn, fields: ["id"]) result = @@ -261,304 +61,94 @@ defmodule AshTypescript.Rpc.ErrorScenariosTest do "action" => "create_todo", "input" => %{ "user_id" => user["id"] - # Missing required "title" field + # Missing required title }, - "fields" => ["id", "title"] + "fields" => ["id"] }) assert result["success"] == false - errors = result["errors"] - - # Should have error about missing required title - required_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "title") or - String.contains?(field, "title") or - String.contains?(message, "required") or - String.contains?(message, "nil") - end) + [error | _] = result["errors"] - assert required_error, "Should have error about missing required title" + assert error["type"] == "required" + assert :title in error["fields"] end - test "invalid UUID format returns validation error" do + test "non-existent action returns action_not_found" do conn = TestHelpers.build_rpc_conn() result = Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Test Todo", - # Invalid UUID format - "user_id" => "not-a-valid-uuid" - }, - "fields" => ["id", "title"] + "action" => "non_existent_action", + "fields" => ["id"] }) assert result["success"] == false - errors = result["errors"] - - # Should have error about invalid UUID or user not found - uuid_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - type = error["type"] || "" - - String.contains?(message, "user_id") or - String.contains?(field, "user_id") or - String.contains?(message, "User") or - String.contains?(message, "not found") or - type == "not_found" - end) - - assert uuid_error, "Should have error about invalid UUID or user not found" + [error | _] = result["errors"] + + assert error["type"] == "action_not_found" + assert error["message"] =~ "not found" + # Details use camelCase + assert error["details"]["actionName"] == "non_existent_action" end - test "invalid enum value returns validation error" do + test "invalid enum value returns invalid_attribute" do conn = TestHelpers.build_rpc_conn() - user = TestHelpers.create_test_user(conn, fields: ["id"]) result = Rpc.run_action(:ash_typescript, conn, %{ "action" => "create_todo", "input" => %{ - "title" => "Test Todo", + "title" => "Test", "user_id" => user["id"], - # Not in valid enum values [:low, :medium, :high, :urgent] - "priority" => "super_urgent" + # Not in enum + "priority" => "invalid_priority" }, - "fields" => ["id", "title", "priority"] + "fields" => ["id"] }) assert result["success"] == false - errors = result["errors"] - - # Should have error about invalid enum value - enum_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "priority") or - String.contains?(field, "priority") or - String.contains?(message, "super_urgent") or - String.contains?(message, "one_of") - end) + [error | _] = result["errors"] - assert enum_error, "Should have error about invalid enum value" + assert error["type"] == "invalid_attribute" + assert :priority in error["fields"] end - end - describe "missing required parameters" do - test "missing fields parameter returns error" do + test "missing fields parameter returns proper error" do conn = TestHelpers.build_rpc_conn() result = Rpc.run_action(:ash_typescript, conn, %{ "action" => "list_todos" - # Missing "fields" parameter - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about missing fields parameter - fields_error = List.first(errors) - assert fields_error["type"] == "missing_required_parameter" - assert String.contains?(fields_error["message"], "fields") - assert String.contains?(fields_error["message"], "missing or empty") - end - - test "missing input for create action returns error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "fields" => ["id", "title"] - # Missing "input" parameter for create action + # Missing fields }) assert result["success"] == false - errors = result["errors"] - - # Should have error about missing input parameter - input_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - - String.contains?(message, "input") or - String.contains?(message, "required") or - String.contains?(message, "missing") - end) + [error | _] = result["errors"] - assert input_error, "Should have error about missing input parameter" + assert error["type"] == "missing_required_parameter" + assert error["message"] =~ "fields" end - end - describe "invalid pagination parameters" do - test "negative limit returns validation error" do + test "invalid pagination returns proper error" do conn = TestHelpers.build_rpc_conn() result = Rpc.run_action(:ash_typescript, conn, %{ "action" => "list_todos", - "fields" => ["id", "title"], - # Negative limit should be invalid + "fields" => ["id"], + # Negative limit "page" => %{"limit" => -5} }) assert result["success"] == false - errors = result["errors"] - - # Should have error about negative limit - limit_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "limit") or - String.contains?(field, "limit") or - String.contains?(message, "negative") or - String.contains?(message, "greater") - end) - - assert limit_error, "Should have error about negative limit" - end - - test "negative offset returns validation error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => ["id", "title"], - # Negative offset should be invalid - "page" => %{"offset" => -10} - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about negative offset - offset_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "offset") or - String.contains?(field, "offset") or - String.contains?(message, "negative") or - String.contains?(message, "greater") - end) - - assert offset_error, "Should have error about negative offset" - end - - test "invalid limit data type returns validation error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => ["id", "title"], - # String instead of integer - "page" => %{"limit" => "not_a_number"} - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about invalid limit type - limit_type_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "limit") or - String.contains?(field, "limit") or - String.contains?(message, "type") or - String.contains?(message, "integer") - end) - - assert limit_type_error, "Should have error about invalid limit data type" - end - end - - describe "field structure validation errors" do - test "malformed relationship field selection returns error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => [ - "id", - "title", - # Should be array, not string - %{"comments" => "invalid_structure"} - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about malformed relationship structure - structure_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "comments") or - String.contains?(field, "comments") or - String.contains?(message, "structure") or - String.contains?(message, "format") - end) - - assert structure_error, "Should have error about malformed relationship structure" - end - - test "invalid calculation arguments structure returns error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - "fields" => [ - "id", - "title", - %{ - "days_until_due" => %{ - "invalid_args" => %{"some" => "value"} - } - } - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about invalid calculation arguments - calc_args_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" + [error | _] = result["errors"] - String.contains?(message, "days_until_due") or - String.contains?(field, "days_until_due") or - String.contains?(message, "args") or - String.contains?(message, "arguments") - end) - - assert calc_args_error, "Should have error about invalid calculation arguments" + # Ash validation errors come through + assert error["type"] in ["invalid_page", "invalid_attribute"] end - test "deeply nested invalid field structure returns error" do + test "nested field errors maintain path context" do conn = TestHelpers.build_rpc_conn() result = @@ -566,394 +156,35 @@ defmodule AshTypescript.Rpc.ErrorScenariosTest do "action" => "list_todos", "fields" => [ "id", - { - "comments", - [ - "id", - { - "user", - [ - "id", - "name", - # Invalid field deep in nesting - "non_existent_nested_field" - ] - } - ] - } - ] - }) - - assert result["success"] == false - errors = result["errors"] - - nested_error = - Enum.find(errors, fn error -> - error["details"]["error"] == - "{:invalid_field_type, \"non_existent_nested_field\", [\"comments\", \"user\"]}" - end) - - assert nested_error, "Should have error about deeply nested invalid field" - end - end - - describe "embedded resource and union type errors" do - test "invalid embedded resource field returns error" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Embedded Error Test", - "user_id" => user["id"], - "metadata" => %{ - "category" => "Test" - } - }, - "fields" => [ - "id", - { - "metadata", - [ - "category", - # This field doesn't exist on TodoMetadata - "non_existent_embedded_field" - ] - } - ] - }) - - assert result["success"] == false - errors = result["errors"] - - embedded_error = - Enum.find(errors, fn error -> - error["details"]["error"] == - "{:invalid_field_type, \"non_existent_embedded_field\", [\"metadata\"]}" - end) - - assert embedded_error, "Should have error about non-existent embedded field" - end - - test "invalid union type member returns error" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Union Error Test", - "user_id" => user["id"] - }, - "fields" => [ - "id", - { - "content", - [ - "text", - # This union member doesn't exist - "invalid_union_member" - ] - } - ] - }) - - assert result["success"] == false - errors = result["errors"] - - union_error = - Enum.find(errors, fn error -> - error["details"]["error"] == - "{:invalid_field_type, \"invalid_union_member\", [\"content\"]}" - end) - - assert union_error, "Should have error about invalid union member" - end - - test "invalid union embedded resource field returns error" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Union Embedded Error Test", - "user_id" => user["id"] - }, - "fields" => [ - "id", - %{ - "content" => %{ - "text" => [ - "text", - "formatting", - # This field doesn't exist on TextContent - "invalid_text_content_field" - ] - } - } - ] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about invalid union embedded resource field - union_embedded_error = List.first(errors) - assert union_embedded_error["type"] == "unknown_error" - assert union_embedded_error["message"] == "An unexpected error occurred" - - assert String.contains?( - union_embedded_error["details"]["error"], - "invalid_text_content_field" - ) - - assert String.contains?(union_embedded_error["details"]["error"], ":content") - end - end - - describe "comprehensive error message validation" do - test "error messages are user-friendly and informative" do - conn = TestHelpers.build_rpc_conn() - - # Create a request with multiple errors to test message quality - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - # Wrong type - "title" => 12_345, - # Invalid UUID - "user_id" => "invalid-uuid", - # Invalid enum - "priority" => "invalid_priority" - }, - "fields" => [ - "id", - "title", - # Invalid field - "non_existent_field", - { - # Invalid relationship - "non_existent_relation", - ["id"] - } + %{"user" => ["id", "invalid_nested_field"]} ] }) assert result["success"] == false - errors = result["errors"] - assert is_list(errors) - assert length(errors) > 0 - - # Verify each error has required structure - Enum.each(errors, fn error -> - assert is_map(error) - assert Map.has_key?(error, "message") - assert is_binary(error["message"]) - assert String.length(error["message"]) > 0 - - # Message should be descriptive - assert String.length(error["message"]) > 10, - "Error message should be descriptive: #{inspect(error)}" - - # Should have field context if applicable - if Map.has_key?(error, "field") do - assert is_binary(error["field"]) - end - end) - - # Verify we have at least one error (pipeline fails fast on first error) - assert length(errors) >= 1, "Should have at least one validation error" - end + [error | _] = result["errors"] - test "error response structure is consistent" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "non_existent_action", - "fields" => ["id"] - }) - - assert result["success"] == false - - error_response = result["errors"] - # Verify consistent error response structure - assert is_list(error_response) + assert error["type"] == "unknown_field" + # Check if fieldPath (camelCase) contains the invalid field name + field_path = error["fieldPath"] || "" + assert String.contains?(field_path, "invalid_nested_field") end - test "validation errors include field context when applicable" do + test "validate action returns errors with atom keys" do conn = TestHelpers.build_rpc_conn() - user = TestHelpers.create_test_user(conn, fields: ["id"]) - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - # Empty string should fail validation - "title" => "", - "user_id" => user["id"] - }, - "fields" => ["id", "title"] - }) - - assert result["success"] == false - errors = result["errors"] - - # Find the validation error for title - title_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - String.contains?(message, "title") or String.contains?(field, "title") - end) - - assert title_error, "Should have validation error for title" - - # Should include field context - assert Map.has_key?(title_error, "field") or - String.contains?(title_error["message"], "title"), - "Error should include field context" - end - - test "complex nested errors provide clear location context" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - # Create invalid embedded resource data - invalid_metadata = %{ - "category" => "Test", - # Should match ~r/^[A-Z]{2}-\d{4}$/ - "external_reference" => "invalid-format", - # Should be 0-100 - "priority_score" => 150 - } - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Nested Error Test", - "user_id" => user["id"], - "metadata" => invalid_metadata - }, - "fields" => [ - "id", - %{"metadata" => ["category", "external_reference", "priority_score"]} - ] + Rpc.validate_action(:ash_typescript, conn, %{ + "action" => "update_todo", + "primaryKey" => "550e8400-e29b-41d4-a716-446655440000", + "input" => %{"title" => "Updated"} }) - assert result["success"] == false - errors = result["errors"] - - # Should have errors for embedded resource validation - embedded_errors = - Enum.filter(errors, fn error -> - message = error["message"] || "" - field = error["field"] || "" - - String.contains?(message, "metadata") or - String.contains?(field, "metadata") or - String.contains?(message, "external_reference") or - String.contains?(message, "priority_score") - end) - - assert length(embedded_errors) > 0, "Should have embedded resource validation errors" - - # Errors should provide clear context about the nested location - Enum.each(embedded_errors, fn error -> - assert String.length(error["message"]) > 5, "Error message should be meaningful" - end) - end - end - - describe "error handling edge cases" do - test "null input parameter returns appropriate error" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - # Null input - "input" => nil, - "fields" => ["id", "title"] - }) - - assert result["success"] == false - errors = result["errors"] - - # Should handle null input gracefully - null_error = List.first(errors) - assert null_error["type"] == "invalid_input_format" - assert String.contains?(null_error["message"], "Input parameter must be a map") - end - - test "malformed JSON-like structures return parsing errors" do - conn = TestHelpers.build_rpc_conn() - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "list_todos", - # Should be array, not string - "fields" => "not_an_array" - }) - - assert result["success"] == false - errors = result["errors"] - - # Should have error about malformed fields structure - format_error = - Enum.find(errors, fn error -> - message = error["message"] || "" - - String.contains?(message, "fields") or - String.contains?(message, "array") or - String.contains?(message, "format") - end) - - assert format_error, "Should have error about malformed fields structure" - end - - test "very large payloads are handled appropriately" do - conn = TestHelpers.build_rpc_conn() - - user = TestHelpers.create_test_user(conn, fields: ["id"]) - - # Create a very large field list - large_field_list = ["id", "title"] ++ (1..1000 |> Enum.map(&"field_#{&1}")) - - result = - Rpc.run_action(:ash_typescript, conn, %{ - "action" => "create_todo", - "input" => %{ - "title" => "Large Payload Test", - "user_id" => user["id"] - }, - "fields" => large_field_list - }) - - assert result["success"] == false - errors = result["errors"] - - # Should handle large payloads gracefully (either by processing or rejecting with clear error) - assert is_list(errors) - assert length(errors) > 0 - - # Verify error messages are still clear and helpful - Enum.each(errors, fn error -> - assert is_binary(error["message"]) - assert String.length(error["message"]) > 0 - end) + if not result["success"] do + [error | _] = result["errors"] + # Validate returns maps with atom keys + assert Map.has_key?(error, :type) + assert error.type == "not_found" + end end end end diff --git a/test/ash_typescript/rpc/rpc_integration_test.exs b/test/ash_typescript/rpc/rpc_integration_test.exs index 104a8bf..9d47205 100644 --- a/test/ash_typescript/rpc/rpc_integration_test.exs +++ b/test/ash_typescript/rpc/rpc_integration_test.exs @@ -245,9 +245,9 @@ defmodule AshTypescript.Rpc.IntegrationTest do assert error_response["success"] == false error = List.first(error_response["errors"]) - assert error["type"] == "unknown_error" - assert error["message"] == "An unexpected error occurred" - assert String.contains?(error["details"]["error"], "completely_unknown_field") + assert error["type"] == "unknown_field" + assert String.contains?(error["message"], "Unknown field") + assert String.contains?(error["fieldPath"] || "", "completely_unknown_field") end test "nested field errors provide context" do @@ -264,10 +264,9 @@ defmodule AshTypescript.Rpc.IntegrationTest do assert error_response["success"] == false error = List.first(error_response["errors"]) - assert error["type"] == "unknown_error" - assert error["message"] == "An unexpected error occurred" - assert String.contains?(error["details"]["error"], "nonexistent_user_field") - assert String.contains?(error["details"]["error"], "user") + assert error["type"] == "unknown_field" + assert String.contains?(error["message"], "Unknown field") + assert String.contains?(error["fieldPath"] || "", "nonexistent_user_field") end end @@ -378,8 +377,8 @@ defmodule AshTypescript.Rpc.IntegrationTest do assert error_response["success"] == false error = List.first(error_response["errors"]) - assert error["type"] == "unknown_error" - assert String.contains?(error["details"]["error"], "titel") + assert error["type"] == "unknown_field" + assert String.contains?(error["fieldPath"] || error["message"] || "", "titel") end test "wrong action name should fail" do diff --git a/test/ash_typescript/rpc/rpc_run_action_generic_actions_test.exs b/test/ash_typescript/rpc/rpc_run_action_generic_actions_test.exs index 78c9503..6121590 100644 --- a/test/ash_typescript/rpc/rpc_run_action_generic_actions_test.exs +++ b/test/ash_typescript/rpc/rpc_run_action_generic_actions_test.exs @@ -813,4 +813,51 @@ defmodule AshTypescript.Rpc.RpcRunActionGenericActionsTest do end) end end + + describe "date array return type action (get_important_dates)" do + setup do + conn = TestHelpers.build_rpc_conn() + %{conn: conn} + end + + test "returns dates as ISO strings", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_important_dates", + "fields" => [] + }) + + assert result["success"] == true + data = result["data"] + + # Should return array of dates as ISO strings + assert is_list(data) + assert length(data) == 3 + + # Each item should be a date string in ISO format + assert data == ["2025-01-15", "2025-02-20", "2025-03-25"] + end + end + + describe "single date return type action (get_publication_date)" do + setup do + conn = TestHelpers.build_rpc_conn() + %{conn: conn} + end + + test "returns date as ISO string", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_publication_date", + "fields" => [] + }) + + assert result["success"] == true + data = result["data"] + + # Should return a single date as ISO string + assert is_binary(data) + assert data == "2025-01-15" + end + end end diff --git a/test/ash_typescript/rpc/rpc_run_action_ltree_test.exs b/test/ash_typescript/rpc/rpc_run_action_ltree_test.exs index f93e723..aaa3104 100644 --- a/test/ash_typescript/rpc/rpc_run_action_ltree_test.exs +++ b/test/ash_typescript/rpc/rpc_run_action_ltree_test.exs @@ -399,7 +399,8 @@ defmodule AshTypescript.Rpc.RpcRunActionLtreeTest do assert result["success"] == false assert is_list(result["errors"]) [error | _] = result["errors"] - assert error["type"] == "ash_error" + # This is a validation error for invalid attribute format + assert error["type"] == "invalid_attribute" assert String.contains?( error["message"], diff --git a/test/ash_typescript/rpc/rpc_run_action_union_calculation_test.exs b/test/ash_typescript/rpc/rpc_run_action_union_calculation_test.exs new file mode 100644 index 0000000..6c361c0 --- /dev/null +++ b/test/ash_typescript/rpc/rpc_run_action_union_calculation_test.exs @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.RpcRunActionUnionCalculationTest do + @moduledoc """ + Tests for union type calculations that return resources. + + Tests the scenario where: + - Content resource has an `item` calculation that returns a ContentItem union type + - ContentItem union has an `article` member that is a struct (Article resource) + - Field selection works through the union calculation to fetch Article fields + """ + use ExUnit.Case, async: false + alias AshTypescript.Rpc + alias AshTypescript.Test.TestHelpers + + describe "union calculation with resource members - get_content" do + setup do + conn = TestHelpers.build_rpc_conn() + + # Create a user to be the author + user = TestHelpers.create_test_user(conn, name: "Article Author", fields: ["id"]) + + # Create content with an article + content = + TestHelpers.create_test_content(conn, + title: "Understanding Nutrition", + user_id: user["id"], + thumbnail_url: "https://example.com/nutrition-thumb.jpg", + thumbnail_alt: "Nutrition thumbnail", + published_at: "2024-01-15T10:00:00Z", + category: :nutrition, + article_hero_image_url: "https://example.com/nutrition-hero.jpg", + article_hero_image_alt: "Nutrition hero image", + article_summary: "A comprehensive guide to nutrition", + article_body: "Detailed article body about nutrition and healthy eating.", + fields: ["id", "title"] + ) + + %{ + conn: conn, + user: user, + content: content + } + end + + test "fetches content fields only when item calculation is not requested", %{ + conn: conn, + content: content + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_content", + "input" => %{"id" => content["id"]}, + "fields" => [ + "id", + "title", + "thumbnailUrl", + "thumbnailAlt", + "category" + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify content fields are present + assert data["id"] == content["id"] + assert data["title"] == "Understanding Nutrition" + assert data["thumbnailUrl"] == "https://example.com/nutrition-thumb.jpg" + assert data["thumbnailAlt"] == "Nutrition thumbnail" + assert data["category"] == "nutrition" + + # Verify item calculation is not included + refute Map.has_key?(data, "item") + end + + test "fetches article fields through item calculation", %{ + conn: conn, + content: content + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_content", + "input" => %{"id" => content["id"]}, + "fields" => [ + "id", + "title", + %{ + "item" => %{ + "args" => %{}, + "fields" => [ + %{ + "article" => ["heroImageUrl", "heroImageAlt", "summary", "body"] + } + ] + } + } + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify content fields + assert data["id"] == content["id"] + assert data["title"] == "Understanding Nutrition" + + # Verify item calculation returns the union + assert Map.has_key?(data, "item") + assert is_map(data["item"]) + + # Verify article member is present in the union + assert Map.has_key?(data["item"], "article") + article = data["item"]["article"] + + # Verify article fields + assert article["heroImageUrl"] == "https://example.com/nutrition-hero.jpg" + assert article["heroImageAlt"] == "Nutrition hero image" + assert article["summary"] == "A comprehensive guide to nutrition" + assert article["body"] == "Detailed article body about nutrition and healthy eating." + end + + test "fetches subset of article fields through item calculation", %{ + conn: conn, + content: content + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_content", + "input" => %{"id" => content["id"]}, + "fields" => [ + "id", + %{ + "item" => %{ + "args" => %{}, + "fields" => [ + %{ + "article" => ["summary"] + } + ] + } + } + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify item and article + article = data["item"]["article"] + assert article["summary"] == "A comprehensive guide to nutrition" + + # Verify only requested fields are present + refute Map.has_key?(article, "heroImageUrl") + refute Map.has_key?(article, "heroImageAlt") + refute Map.has_key?(article, "body") + end + + test "fetches both content and article fields together", %{ + conn: conn, + content: content + } do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "get_content", + "input" => %{"id" => content["id"]}, + "fields" => [ + "id", + "title", + "thumbnailUrl", + "category", + "publishedAt", + %{ + "item" => %{ + "args" => %{}, + "fields" => [ + %{ + "article" => ["heroImageUrl", "summary"] + } + ] + } + } + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify all content fields + assert data["id"] == content["id"] + assert data["title"] == "Understanding Nutrition" + assert data["thumbnailUrl"] == "https://example.com/nutrition-thumb.jpg" + assert data["category"] == "nutrition" + assert data["publishedAt"] == "2024-01-15T10:00:00Z" + + # Verify article fields through item calculation + article = data["item"]["article"] + assert article["heroImageUrl"] == "https://example.com/nutrition-hero.jpg" + assert article["summary"] == "A comprehensive guide to nutrition" + + # Verify only requested article fields are present + refute Map.has_key?(article, "heroImageAlt") + refute Map.has_key?(article, "body") + end + end + + describe "union calculation with resource members - list_content" do + setup do + conn = TestHelpers.build_rpc_conn() + + # Create users + user1 = TestHelpers.create_test_user(conn, name: "Author 1", fields: ["id"]) + user2 = TestHelpers.create_test_user(conn, name: "Author 2", fields: ["id"]) + + # Create multiple content items + content1 = + TestHelpers.create_test_content(conn, + title: "Nutrition Basics", + user_id: user1["id"], + category: :nutrition, + article_summary: "Introduction to nutrition", + article_body: "Basic nutrition concepts", + fields: ["id", "title"] + ) + + content2 = + TestHelpers.create_test_content(conn, + title: "Fitness Guide", + user_id: user2["id"], + category: :fitness, + article_summary: "Guide to fitness", + article_body: "Comprehensive fitness guide", + fields: ["id", "title"] + ) + + content3 = + TestHelpers.create_test_content(conn, + title: "Mindset Matters", + user_id: user1["id"], + category: :mindset, + article_summary: "Mental health and mindset", + article_body: "Building a healthy mindset", + fields: ["id", "title"] + ) + + %{ + conn: conn, + content1: content1, + content2: content2, + content3: content3 + } + end + + test "lists content with article fields through item calculation", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "list_content", + "fields" => [ + "id", + "title", + "category", + %{ + "item" => %{ + "args" => %{}, + "fields" => [ + %{ + "article" => ["summary"] + } + ] + } + } + ] + }) + + assert result["success"] == true + data = result["data"] + + # Should return all 3 content items + assert is_list(data) + assert length(data) == 3 + + # Verify each item has the expected structure + Enum.each(data, fn content -> + assert Map.has_key?(content, "id") + assert Map.has_key?(content, "title") + assert Map.has_key?(content, "category") + assert Map.has_key?(content, "item") + + # Verify item has article + assert Map.has_key?(content["item"], "article") + article = content["item"]["article"] + assert Map.has_key?(article, "summary") + + # Verify only summary is present + refute Map.has_key?(article, "heroImageUrl") + refute Map.has_key?(article, "body") + end) + + # Verify specific content titles and summaries + titles = Enum.map(data, & &1["title"]) |> Enum.sort() + assert titles == ["Fitness Guide", "Mindset Matters", "Nutrition Basics"] + + summaries = + Enum.map(data, &get_in(&1, ["item", "article", "summary"])) |> Enum.sort() + + assert summaries == [ + "Guide to fitness", + "Introduction to nutrition", + "Mental health and mindset" + ] + end + + test "lists content with multiple article fields", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "list_content", + "fields" => [ + "id", + "title", + %{ + "item" => %{ + "args" => %{}, + "fields" => [ + %{ + "article" => ["heroImageUrl", "summary", "body"] + } + ] + } + } + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify first content item has all requested article fields + first_content = Enum.find(data, &(&1["title"] == "Nutrition Basics")) + article = first_content["item"]["article"] + + assert article["heroImageUrl"] == "https://example.com/hero.jpg" + assert article["summary"] == "Introduction to nutrition" + assert article["body"] == "Basic nutrition concepts" + end + + test "lists content without item calculation when not requested", %{conn: conn} do + result = + Rpc.run_action(:ash_typescript, conn, %{ + "action" => "list_content", + "fields" => [ + "id", + "title", + "category" + ] + }) + + assert result["success"] == true + data = result["data"] + + # Verify no item field is present + Enum.each(data, fn content -> + refute Map.has_key?(content, "item") + end) + end + end +end diff --git a/test/ash_typescript/rpc/rpc_validate_action_test.exs b/test/ash_typescript/rpc/rpc_validate_action_test.exs index cef602c..3ec03b5 100644 --- a/test/ash_typescript/rpc/rpc_validate_action_test.exs +++ b/test/ash_typescript/rpc/rpc_validate_action_test.exs @@ -56,34 +56,6 @@ defmodule AshTypescript.Rpc.RpcValidateActionTest do assert result["success"] == true end - - test "works across all action types without field specification", %{conn: conn} do - actions_to_test = [ - "create_todo", - "list_todos", - "get_statistics_todo", - "bulk_complete_todo" - ] - - for action <- actions_to_test do - result = - Rpc.validate_action(:ash_typescript, conn, %{ - "action" => action, - "input" => %{} - }) - - # All actions should parse successfully in validation mode - assert is_map(result) - assert Map.has_key?(result, "success") - - # If validation fails, it should be for input validation, not field requirements - if not result["success"] do - error_message = inspect(result["errors"]) - refute error_message =~ "fields" - refute error_message =~ "empty_fields_array" - end - end - end end describe "form validation scenarios" do @@ -140,9 +112,9 @@ defmodule AshTypescript.Rpc.RpcValidateActionTest do assert Map.has_key?(result, "success") if not result["success"] do - error_message = inspect(result["errors"]) - refute error_message =~ "fields" - refute error_message =~ "empty_fields_array" + assert length(result["errors"]) > 0, "Should have at least one error" + [error | _] = result["errors"] + assert error.type == "not_found" end end diff --git a/test/ash_typescript/rpc/struct_field_filtering_test.exs b/test/ash_typescript/rpc/struct_field_filtering_test.exs new file mode 100644 index 0000000..e11f64e --- /dev/null +++ b/test/ash_typescript/rpc/struct_field_filtering_test.exs @@ -0,0 +1,223 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Rpc.StructFieldFilteringTest do + use ExUnit.Case, async: true + + alias AshTypescript.Rpc.ResultProcessor + + defmodule User do + use Ash.Resource, + domain: AshTypescript.Test.Domain, + data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + attribute :name, :string, public?: true + attribute :email, :string, public?: true + attribute :secret, :string, public?: false + attribute :internal_notes, :string, public?: false + end + + calculations do + calculate :self_struct, :struct, fn records, _ -> + Enum.map(records, & &1) + end do + constraints instance_of: __MODULE__ + public? true + end + end + + actions do + defaults [:read] + + action :search, {:array, Ash.Type.Struct} do + constraints items: [instance_of: __MODULE__] + + argument :query, :string, allow_nil?: false + + run fn _input, _context -> + # Return test records + records = [ + struct(__MODULE__, %{ + id: Ash.UUID.generate(), + name: "John Doe", + email: "john@example.com", + secret: "secret1", + internal_notes: "internal1" + }) + ] + + {:ok, records} + end + end + end + end + + describe "struct field filtering" do + test "filters struct fields to only public attributes when no selection is specified" do + # Create a struct with both public and private fields + user_struct = + struct(User, %{ + id: Ash.UUID.generate(), + name: "Test User", + email: "test@example.com", + secret: "secret_value", + internal_notes: "internal_notes" + }) + + # Process without field selection + result = ResultProcessor.normalize_value_for_json(user_struct) + + # Should only include public fields + assert Map.has_key?(result, :id) + assert Map.has_key?(result, :name) + assert Map.has_key?(result, :email) + refute Map.has_key?(result, :secret) + refute Map.has_key?(result, :internal_notes) + end + + test "respects field selection when processing struct fields" do + user_struct = + struct(User, %{ + id: Ash.UUID.generate(), + name: "Test User", + email: "test@example.com", + secret: "secret_value", + internal_notes: "internal_notes" + }) + + # Process with specific field selection + extraction_template = [:name] + result = ResultProcessor.process(user_struct, extraction_template, User) + + # Should only include selected field + assert Map.has_key?(result, :name) + refute Map.has_key?(result, :id) + refute Map.has_key?(result, :email) + refute Map.has_key?(result, :secret) + end + + test "handles nested struct fields by filtering to public attributes" do + # Create a record with a struct field + record_with_struct = %{ + id: Ash.UUID.generate(), + name: "Main User", + email: "main@example.com", + self_struct: + struct(User, %{ + id: Ash.UUID.generate(), + name: "Nested User", + email: "nested@example.com", + secret: "nested_secret", + internal_notes: "nested_notes" + }) + } + + # Process with struct field included + extraction_template = [:id, :name, :email, :self_struct] + result = ResultProcessor.process(record_with_struct, extraction_template, User) + + # Main record fields + assert Map.has_key?(result, :id) + assert Map.has_key?(result, :name) + assert Map.has_key?(result, :email) + + # Nested struct should only have public fields + assert is_map(result[:self_struct]) + assert Map.has_key?(result[:self_struct], :id) + assert Map.has_key?(result[:self_struct], :name) + assert Map.has_key?(result[:self_struct], :email) + refute Map.has_key?(result[:self_struct], :secret) + refute Map.has_key?(result[:self_struct], :internal_notes) + end + + test "supports field selection on nested struct fields" do + record_with_struct = %{ + id: Ash.UUID.generate(), + name: "Main User", + self_struct: + struct(User, %{ + id: Ash.UUID.generate(), + name: "Nested User", + email: "nested@example.com", + secret: "nested_secret", + internal_notes: "nested_notes" + }) + } + + # Process with specific field selection on the struct field + extraction_template = [:id, :name, {:self_struct, [:name]}] + result = ResultProcessor.process(record_with_struct, extraction_template, User) + + # Main record fields + assert Map.has_key?(result, :id) + assert Map.has_key?(result, :name) + + # Nested struct should only have the selected field + assert is_map(result[:self_struct]) + assert Map.has_key?(result[:self_struct], :name) + refute Map.has_key?(result[:self_struct], :id) + refute Map.has_key?(result[:self_struct], :email) + refute Map.has_key?(result[:self_struct], :secret) + end + + test "handles arrays of structs by filtering each to public attributes" do + # Simulate an action that returns array of structs + action_result = [ + struct(User, %{ + id: Ash.UUID.generate(), + name: "User 1", + email: "user1@example.com", + secret: "secret1", + internal_notes: "notes1" + }), + struct(User, %{ + id: Ash.UUID.generate(), + name: "User 2", + email: "user2@example.com", + secret: "secret2", + internal_notes: "notes2" + }) + ] + + # Process without field selection + result = ResultProcessor.process(action_result, [], User) + + assert length(result) == 2 + + Enum.each(result, fn user -> + assert Map.has_key?(user, :id) + assert Map.has_key?(user, :name) + assert Map.has_key?(user, :email) + refute Map.has_key?(user, :secret) + refute Map.has_key?(user, :internal_notes) + end) + end + + test "handles arrays of structs with field selection" do + action_result = [ + struct(User, %{ + id: Ash.UUID.generate(), + name: "User 1", + email: "user1@example.com", + secret: "secret1", + internal_notes: "notes1" + }) + ] + + # Process with field selection + extraction_template = [:name] + result = ResultProcessor.process(action_result, extraction_template, User) + + assert length(result) == 1 + [user] = result + + assert Map.has_key?(user, :name) + refute Map.has_key?(user, :id) + refute Map.has_key?(user, :email) + refute Map.has_key?(user, :secret) + end + end +end diff --git a/test/ash_typescript/rpc/working_comprehensive_test.exs b/test/ash_typescript/rpc/working_comprehensive_test.exs index efd1b00..ab094ee 100644 --- a/test/ash_typescript/rpc/working_comprehensive_test.exs +++ b/test/ash_typescript/rpc/working_comprehensive_test.exs @@ -619,10 +619,8 @@ defmodule AshTypescript.Rpc.WorkingComprehensiveTest do assert result["success"] == false first_error = List.first(result["errors"]) - assert first_error["type"] == "unknown_error" - assert first_error["message"] == "An unexpected error occurred" - assert String.contains?(first_error["details"]["error"], "invalid_user_field") - assert String.contains?(first_error["details"]["error"], "user") + assert first_error["type"] == "unknown_field" + assert is_binary(first_error["message"]) end test "missing required input parameters return validation errors" do @@ -642,11 +640,7 @@ defmodule AshTypescript.Rpc.WorkingComprehensiveTest do # Should get validation error about missing required field first_error = List.first(result["errors"]) - assert first_error["type"] in [ - "validation_error", - "input_validation_error", - "ash_error" - ] + assert first_error["type"] == "required" end test "invalid pagination parameters return proper error" do diff --git a/test/support/domain.ex b/test/support/domain.ex index f20c0ce..3f4fec5 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -105,12 +105,10 @@ defmodule AshTypescript.Test.Domain do rpc_action :get_task_stats, :get_task_stats rpc_action :list_task_stats, :list_task_stats - # Read action with metadata field name mapping rpc_action :read_tasks_with_mapped_metadata, :read_with_invalid_metadata_names, show_metadata: [:meta_1, :is_valid?, :field_2], metadata_field_names: [meta_1: :meta1, is_valid?: :is_valid, field_2: :field2] - # Read actions with different show_metadata configurations rpc_action :read_tasks_with_metadata_all, :read_with_metadata, show_metadata: nil rpc_action :read_tasks_with_metadata_false, :read_with_metadata, show_metadata: false rpc_action :read_tasks_with_metadata_empty, :read_with_metadata, show_metadata: [] @@ -119,21 +117,18 @@ defmodule AshTypescript.Test.Domain do rpc_action :read_tasks_with_metadata_two, :read_with_metadata, show_metadata: [:some_string, :some_number] - # Create actions with different show_metadata configurations rpc_action :create_task_metadata_all, :create, show_metadata: nil rpc_action :create_task_metadata_false, :create, show_metadata: false rpc_action :create_task_metadata_empty, :create, show_metadata: [] rpc_action :create_task_metadata_one, :create, show_metadata: [:some_string] rpc_action :create_task_metadata_two, :create, show_metadata: [:some_string, :some_number] - # Update actions with different show_metadata configurations rpc_action :update_task_metadata_all, :update, show_metadata: nil rpc_action :update_task_metadata_false, :update, show_metadata: false rpc_action :update_task_metadata_empty, :update, show_metadata: [] rpc_action :update_task_metadata_one, :update, show_metadata: [:some_string] rpc_action :update_task_metadata_two, :update, show_metadata: [:some_string, :some_number] - # Destroy actions with different show_metadata configurations rpc_action :destroy_task_metadata_all, :destroy, show_metadata: nil rpc_action :destroy_task_metadata_false, :destroy, show_metadata: false rpc_action :destroy_task_metadata_empty, :destroy, show_metadata: [] @@ -144,6 +139,19 @@ defmodule AshTypescript.Test.Domain do resource AshTypescript.Test.PostComment resource AshTypescript.Test.MapFieldResource resource AshTypescript.Test.EmptyResource + + resource AshTypescript.Test.Content do + rpc_action :list_content, :read + rpc_action :get_content, :get_by_id + rpc_action :create_content, :create + rpc_action :update_content, :update + rpc_action :destroy_content, :destroy + end + + resource AshTypescript.Test.Article do + rpc_action :get_important_dates, :get_important_dates + rpc_action :get_publication_date, :get_publication_date + end end resources do @@ -159,5 +167,7 @@ defmodule AshTypescript.Test.Domain do resource AshTypescript.Test.NoRelationshipsResource resource AshTypescript.Test.EmptyResource resource AshTypescript.Test.MapFieldResource + resource AshTypescript.Test.Content + resource AshTypescript.Test.Article end end diff --git a/test/support/resources/article.ex b/test/support/resources/article.ex new file mode 100644 index 0000000..ae456f0 --- /dev/null +++ b/test/support/resources/article.ex @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Test.Article do + @moduledoc """ + Test resource representing article content with details. + """ + use Ash.Resource, + domain: AshTypescript.Test.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [AshTypescript.Resource] + + typescript do + type_name "Article" + end + + ets do + private? true + end + + attributes do + uuid_primary_key :id + + attribute :hero_image_url, :string do + allow_nil? false + public? true + end + + attribute :hero_image_alt, :string do + allow_nil? false + public? true + end + + attribute :summary, :string do + allow_nil? false + public? true + end + + attribute :body, :string do + allow_nil? false + public? true + end + + create_timestamp :created_at do + public? true + end + + update_timestamp :updated_at do + public? true + end + end + + relationships do + belongs_to :content, AshTypescript.Test.Content do + allow_nil? false + attribute_writable? true + public? true + end + end + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + + accept [ + :content_id, + :hero_image_url, + :hero_image_alt, + :summary, + :body + ] + end + + update :update do + primary? true + + accept [ + :hero_image_url, + :hero_image_alt, + :summary, + :body + ] + end + + action :get_important_dates, {:array, :date} do + run fn _input, _context -> + {:ok, [~D[2025-01-15], ~D[2025-02-20], ~D[2025-03-25]]} + end + end + + action :get_publication_date, :date do + run fn _input, _context -> + {:ok, ~D[2025-01-15]} + end + end + end +end diff --git a/test/support/resources/calculations/item_calculation.ex b/test/support/resources/calculations/item_calculation.ex new file mode 100644 index 0000000..82cd10f --- /dev/null +++ b/test/support/resources/calculations/item_calculation.ex @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Test.ItemCalculation do + @moduledoc """ + Item calculation that returns the content's article as a union type. + """ + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context) do + [:article] + end + + @impl true + def strict_loads?, do: false + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn record -> + if record.article do + %Ash.Union{type: :article, value: record.article} + else + nil + end + end) + end +end diff --git a/test/support/resources/content.ex b/test/support/resources/content.ex new file mode 100644 index 0000000..8ad379f --- /dev/null +++ b/test/support/resources/content.ex @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Test.Content do + @moduledoc """ + Test resource representing content items (articles, videos, etc) in a CMS. + """ + use Ash.Resource, + domain: AshTypescript.Test.Domain, + data_layer: Ash.DataLayer.Ets, + primary_read_warning?: false, + extensions: [AshTypescript.Resource] + + typescript do + type_name "Content" + end + + ets do + private? true + end + + attributes do + uuid_primary_key :id + + attribute :type, :atom do + constraints one_of: [:article] + allow_nil? false + default :article + public? true + end + + attribute :title, :string do + allow_nil? false + public? true + end + + attribute :thumbnail_url, :string do + allow_nil? false + public? true + end + + attribute :thumbnail_alt, :string do + allow_nil? false + public? true + end + + attribute :published_at, :utc_datetime do + allow_nil? true + public? true + end + + attribute :category, :atom do + constraints one_of: [:fitness, :nutrition, :mindset, :progress] + allow_nil? false + default :nutrition + public? true + end + + create_timestamp :created_at do + public? true + end + + update_timestamp :updated_at do + public? true + end + end + + calculations do + calculate :item, AshTypescript.Test.ContentItem, AshTypescript.Test.ItemCalculation do + public? true + end + end + + relationships do + has_one :article, AshTypescript.Test.Article do + public? true + end + + belongs_to :author, AshTypescript.Test.User do + public? true + end + end + + actions do + defaults [:destroy] + + read :read do + primary? true + + pagination required?: false, + offset?: true, + keyset?: true + + argument :include_unpublished, :boolean do + allow_nil? true + default false + end + end + + read :get_by_id do + get_by :id + + argument :include_unpublished, :boolean do + allow_nil? true + default false + end + end + + create :create do + primary? true + + accept [ + :type, + :title, + :thumbnail_url, + :thumbnail_alt, + :published_at, + :category, + :author_id + ] + + argument :item, :map do + allow_nil? false + end + + argument :user_id, :uuid do + allow_nil? false + end + + change manage_relationship(:item, :article, type: :create) + change manage_relationship(:user_id, :author, type: :append) + end + + update :update do + primary? true + require_atomic? false + + accept [ + :type, + :title, + :thumbnail_url, + :thumbnail_alt, + :published_at, + :category + ] + + argument :item, :map do + allow_nil? true + end + + change manage_relationship(:item, :article, type: :direct_control) + end + end +end diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 849e5a1..82b1f1d 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -17,7 +17,7 @@ defmodule AshTypescript.Test.TestHelpers do import Plug.Conn import ExUnit.Assertions alias AshTypescript.Rpc - alias AshTypescript.Test.{Domain, Todo, User} + alias AshTypescript.Test.{Content, Domain, Todo, User} @doc """ Creates a properly configured Plug.Conn for RPC testing. @@ -207,6 +207,81 @@ defmodule AshTypescript.Test.TestHelpers do {user, todo} end + @doc """ + Creates a test content item with an article. + + Options: + - `:title` - Content title (default: "Test Content") + - `:user_id` - User ID (author) (required) + - `:thumbnail_url` - Thumbnail URL (default: "https://example.com/thumb.jpg") + - `:thumbnail_alt` - Thumbnail alt text (default: "Thumbnail") + - `:published_at` - Published timestamp (default: nil) + - `:category` - Content category (default: :nutrition) + - `:article_hero_image_url` - Article hero image URL (default: "https://example.com/hero.jpg") + - `:article_hero_image_alt` - Article hero image alt text (default: "Hero Image") + - `:article_summary` - Article summary (default: "Test summary") + - `:article_body` - Article body (default: "Test body content") + - `:fields` - Fields to return (default: ["id", "title"]) + - `:via_rpc` - Create via RPC action instead of direct Ash (default: false) + + Returns the created content data. + """ + def create_test_content(conn_or_opts \\ [], opts \\ []) + + def create_test_content(conn, opts) when is_struct(conn) do + opts = + Keyword.merge( + [ + title: "Test Content", + thumbnail_url: "https://example.com/thumb.jpg", + thumbnail_alt: "Thumbnail", + published_at: nil, + category: :nutrition, + article_hero_image_url: "https://example.com/hero.jpg", + article_hero_image_alt: "Hero Image", + article_summary: "Test summary", + article_body: "Test body content", + fields: ["id", "title"], + via_rpc: true + ], + opts + ) + + unless opts[:user_id] do + raise ArgumentError, "user_id is required for creating test content" + end + + if opts[:via_rpc] do + create_content_via_rpc(conn, opts) + else + create_content_direct(opts) + end + end + + def create_test_content(opts, _) when is_list(opts) do + opts = + Keyword.merge( + [ + title: "Test Content", + thumbnail_url: "https://example.com/thumb.jpg", + thumbnail_alt: "Thumbnail", + published_at: nil, + category: :nutrition, + article_hero_image_url: "https://example.com/hero.jpg", + article_hero_image_alt: "Hero Image", + article_summary: "Test summary", + article_body: "Test body content" + ], + opts + ) + + unless opts[:user_id] do + raise ArgumentError, "user_id is required for creating test content" + end + + create_content_direct(opts) + end + @doc """ Validates that a result contains only the requested fields. @@ -344,4 +419,65 @@ defmodule AshTypescript.Test.TestHelpers do }) |> Ash.create!(domain: Domain) end + + defp create_content_via_rpc(conn, opts) do + # Build article input - use snake_case as it's passed directly to manage_relationship + article_input = %{ + "hero_image_url" => opts[:article_hero_image_url], + "hero_image_alt" => opts[:article_hero_image_alt], + "summary" => opts[:article_summary], + "body" => opts[:article_body] + } + + # Build content input - use camelCase for top-level fields + input = %{ + "type" => "article", + "title" => opts[:title], + "thumbnailUrl" => opts[:thumbnail_url], + "thumbnailAlt" => opts[:thumbnail_alt], + "category" => to_string(opts[:category]), + "userId" => opts[:user_id], + "item" => article_input + } + + input = + if opts[:published_at], + do: Map.put(input, "publishedAt", opts[:published_at]), + else: input + + content_params = %{ + "action" => "create_content", + "fields" => opts[:fields], + "input" => input + } + + result = Rpc.run_action(:ash_typescript, conn, content_params) + assert_rpc_success(result) + end + + defp create_content_direct(opts) do + # First create the content (without article relationship initially) + content = + Content + |> Ash.Changeset.for_create(:create, %{ + type: :article, + title: opts[:title], + thumbnail_url: opts[:thumbnail_url], + thumbnail_alt: opts[:thumbnail_alt], + published_at: opts[:published_at], + category: opts[:category], + user_id: opts[:user_id], + item: %{ + hero_image_url: opts[:article_hero_image_url], + hero_image_alt: opts[:article_hero_image_alt], + summary: opts[:article_summary], + body: opts[:article_body] + } + }) + |> Ash.create!(domain: Domain) + + # Reload content to get the relationship + content + |> Ash.load!([:article], domain: Domain) + end end diff --git a/test/support/types/content_item.ex b/test/support/types/content_item.ex new file mode 100644 index 0000000..4598a8a --- /dev/null +++ b/test/support/types/content_item.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshTypescript.Test.ContentItem do + @moduledoc """ + Union type for polymorphic content items. + """ + + use Ash.Type.NewType, + subtype_of: :union, + constraints: [ + types: [ + article: [ + type: :struct, + constraints: [instance_of: AshTypescript.Test.Article] + ] + ] + ] +end diff --git a/test/ts/shouldPass/embeddedResources.ts b/test/ts/shouldPass/embeddedResources.ts index 5441289..0666607 100644 --- a/test/ts/shouldPass/embeddedResources.ts +++ b/test/ts/shouldPass/embeddedResources.ts @@ -153,7 +153,7 @@ export const complexEmbeddedScenario = await getTodo({ "id", "title", { - metadata: ["category", "settings"], + metadata: ["category", { settings: ["notifications", "autoArchive", "reminderFrequency"] }], metadataHistory: ["category", "priorityScore"], }, { @@ -188,8 +188,14 @@ if (complexEmbeddedScenario.success && complexEmbeddedScenario.data) { // Top level embedded resources if (complexEmbeddedScenario.data.metadata) { const topCategory: string = complexEmbeddedScenario.data.metadata.category; - const topSettings: Record | null | undefined = - complexEmbeddedScenario.data.metadata.settings; + if (complexEmbeddedScenario.data.metadata.settings) { + const topNotifications: boolean | null = + complexEmbeddedScenario.data.metadata.settings.notifications; + const topAutoArchive: boolean | null = + complexEmbeddedScenario.data.metadata.settings.autoArchive; + const topReminderFreq: number | null = + complexEmbeddedScenario.data.metadata.settings.reminderFrequency; + } } // Array embedded resources (metadataHistory) diff --git a/test/ts/shouldPass/typedMaps.ts b/test/ts/shouldPass/typedMaps.ts index 4df6f66..ecd83a0 100644 --- a/test/ts/shouldPass/typedMaps.ts +++ b/test/ts/shouldPass/typedMaps.ts @@ -19,7 +19,10 @@ export const todoWithFullSettings = await getTodo({ "id", "title", { - metadata: ["category", "settings"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, ], }); @@ -38,13 +41,7 @@ if (todoWithFullSettings.success && todoWithFullSettings.data) { const reminderFrequency: number | null = todoWithFullSettings.data.metadata.settings.reminderFrequency; - // Validate TypedMap metadata - const mapType: "TypedMap" = - todoWithFullSettings.data.metadata.settings.__type; - const primitiveFields: - | "notifications" - | "autoArchive" - | "reminderFrequency" = "notifications"; // Example usage + // TypedMap fields are available without metadata in the result } } @@ -55,7 +52,10 @@ export const todoWithPartialSettings = await getTodo({ "id", "title", { - metadata: ["category", "settings"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, ], }); @@ -83,7 +83,11 @@ export const todoWithMultipleTypedMaps = await getTodo({ "id", "title", { - metadata: ["category", "settings", "customFields"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + "customFields", + ], }, ], }); @@ -136,7 +140,12 @@ export const createTodoWithTypedMap = await createTodo({ "id", "title", { - metadata: ["category", "priorityScore", "settings", "customFields"], + metadata: [ + "category", + "priorityScore", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + "customFields", + ], }, ], headers: buildCSRFHeaders(), @@ -192,7 +201,11 @@ export const updateTodoWithTypedMap = await updateTodo({ "id", "title", { - metadata: ["category", "settings", "customFields"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + "customFields", + ], }, ], }); @@ -231,7 +244,10 @@ export const todoWithTypedMapCalculation = await getTodo({ "id", "title", { - metadata: ["category", "settings"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, { self: { @@ -239,7 +255,13 @@ export const todoWithTypedMapCalculation = await getTodo({ fields: [ "id", { - metadata: ["category", "settings", "customFields"], + metadata: [ + "category", + { + settings: ["notifications", "autoArchive", "reminderFrequency"], + }, + "customFields", + ], }, ], }, @@ -291,7 +313,11 @@ export const todoWithNullableTypedMaps = await getTodo({ "id", "title", { - metadata: ["category", "settings", "customFields"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + "customFields", + ], }, ], }); @@ -348,7 +374,10 @@ export const createTodoWithMinimalTypedMap = await createTodo({ "id", "title", { - metadata: ["category", "settings"], + metadata: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, ], }); @@ -384,7 +413,9 @@ export const typedMapFormattingTest = await getTodo({ "id", "title", { - metadata: ["settings"], + metadata: [ + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, ], }); @@ -418,8 +449,15 @@ export const complexTypedMapScenario = await getTodo({ "id", "title", { - metadata: ["category", "settings", "customFields"], - metadataHistory: ["category", "settings"], + metadata: [ + "category", + "customFields", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], + metadataHistory: [ + "category", + { settings: ["notifications", "autoArchive", "reminderFrequency"] }, + ], }, { self: { @@ -427,8 +465,17 @@ export const complexTypedMapScenario = await getTodo({ fields: [ "id", { - metadata: ["settings"], - metadataHistory: ["settings", "customFields"], + metadata: [ + { + settings: ["notifications", "autoArchive", "reminderFrequency"], + }, + ], + metadataHistory: [ + { + settings: ["notifications", "autoArchive", "reminderFrequency"], + }, + "customFields", + ], }, ], }, diff --git a/test/ts/shouldPass/unionTypes.ts b/test/ts/shouldPass/unionTypes.ts index 3b1eef7..6317084 100644 --- a/test/ts/shouldPass/unionTypes.ts +++ b/test/ts/shouldPass/unionTypes.ts @@ -212,7 +212,11 @@ export const createTodoWithChecklistContent = await createTodo({ { content: [ { - checklist: ["title", "items", "completedCount"], + checklist: [ + "title", + "completedCount", + { items: ["text", "completed", "createdAt"] }, + ], }, ], }, diff --git a/test/ts/structFieldSelection.ts b/test/ts/structFieldSelection.ts new file mode 100644 index 0000000..cb2de9e --- /dev/null +++ b/test/ts/structFieldSelection.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2025 ash_typescript contributors +// +// SPDX-License-Identifier: MIT + +// Test file to verify struct field selection works properly +import { getTodo, getById } from './generated'; + +// Test 1: Selecting fields from a struct calculation without arguments +async function testSimpleStructFieldSelection() { + // Should be able to select specific fields from the 'self' struct calculation + const result = await getTodo({ + fields: [ + 'id', + 'title', + { + self: ['id', 'title', 'completed'] // Selecting specific fields from the struct + } + ] + }); + + if (result.success) { + result.data.forEach(todo => { + // TypeScript should know that self has only the selected fields + console.log(todo.self?.id); + console.log(todo.self?.title); + console.log(todo.self?.completed); + + // @ts-expect-error - description was not selected, should be an error + console.log(todo.self?.description); + }); + } +} + +// Test 2: Selecting fields from a struct calculation with arguments +async function testStructFieldSelectionWithArgs() { + const result = await getTodo({ + fields: [ + 'id', + 'title', + { + self: { + args: { prefix: 'TEST' }, + fields: ['id', 'title'] // Selecting fields with arguments + } + } + ] + }); + + if (result.success) { + result.data.forEach(todo => { + // Should have access to selected fields + console.log(todo.self?.id); + console.log(todo.self?.title); + + // @ts-expect-error - completed was not selected + console.log(todo.self?.completed); + }); + } +} + +// Test 3: Nested field selection within struct +async function testNestedStructFieldSelection() { + const result = await getById({ + fields: [ + 'id', + 'name', + { + self: [ + 'id', + 'name', + { + todos: ['id', 'title'] // Nested relationship within struct + } + ] + } + ] + }); + + if (result.success) { + result.data.forEach(user => { + // Should have access to selected fields + console.log(user.self?.id); + console.log(user.self?.name); + + // Nested todos should only have selected fields + user.self?.todos?.forEach(todo => { + console.log(todo.id); + console.log(todo.title); + + // @ts-expect-error - description was not selected + console.log(todo.description); + }); + }); + } +} + +// Test 4: Mixed field selection with regular relationships and struct calculations +async function testMixedFieldSelection() { + const result = await getTodo({ + fields: [ + 'id', + 'title', + { + user: ['id', 'name'], // Regular relationship + self: ['id', 'completed'] // Struct calculation + } + ] + }); + + if (result.success) { + result.data.forEach(todo => { + // Regular relationship fields + console.log(todo.user?.id); + console.log(todo.user?.name); + + // Struct calculation fields + console.log(todo.self?.id); + console.log(todo.self?.completed); + + // @ts-expect-error - title not selected on self + console.log(todo.self?.title); + }); + } +} + +export { + testSimpleStructFieldSelection, + testStructFieldSelectionWithArgs, + testNestedStructFieldSelection, + testMixedFieldSelection +}; \ No newline at end of file diff --git a/test/ts/testStructFieldSelection.ts b/test/ts/testStructFieldSelection.ts new file mode 100644 index 0000000..e6d7dc7 --- /dev/null +++ b/test/ts/testStructFieldSelection.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 ash_typescript contributors +// +// SPDX-License-Identifier: MIT + +// Test file to verify struct field selection works for calculations with Ash.Type.Struct +import { getTodo } from './generated'; + +// This test verifies that struct-typed calculations with instance_of constraint +// support field selection similar to relationships + +async function test() { + // The 'self' calculation returns a struct with instance_of: TodoResourceSchema + // We should be able to select specific fields from it + const result = await getTodo({ + input: {}, // Empty input for read action + fields: [ + 'id', + 'title', + { + self: { + args: { prefix: 'TEST' }, + fields: ['id', 'title', 'completed'] + } + } + ] + }); + + if (result.success && result.data) { + // TypeScript should know that self has the selected fields + const todo = result.data; + + // These should work - selected fields + console.log(todo.id); + console.log(todo.title); + + if (todo.self) { + console.log(todo.self.id); + console.log(todo.self.title); + console.log(todo.self.completed); + + // @ts-expect-error - description was not selected for self + console.log(todo.self.description); + } + } +} + +// Test 2: Using field selection without args (when args are optional) +async function testWithoutArgs() { + const result = await getTodo({ + input: {}, // Empty input for read action + fields: [ + 'id', + { + // When using field selection, we need to specify the structure + self: { + args: {}, // Empty args + fields: ['id', 'title'] + } + } + ] + }); + + if (result.success && result.data) { + const todo = result.data; + + if (todo.self) { + console.log(todo.self.id); + console.log(todo.self.title); + + // @ts-expect-error - completed was not selected + console.log(todo.self.completed); + } + } +} + +export { test, testWithoutArgs }; diff --git a/test/ts/union_calculation_syntax.ts b/test/ts/union_calculation_syntax.ts new file mode 100644 index 0000000..2dbc854 --- /dev/null +++ b/test/ts/union_calculation_syntax.ts @@ -0,0 +1,20 @@ +// Test file to demonstrate fetching a ash resource in a calculation that returns a union type +import { getContent } from "./generated"; + +const result = await getContent({ + input: { id: "123e4567-e89b-12d3-a456-426614174000" }, + fields: [ + "id", + "category", + "title", + "type", + "thumbnailUrl", + "thumbnailAlt", + "publishedAt", + { + item: { + fields: [{ article: ["heroImageUrl", "heroImageAlt"] }], + }, + }, + ], +});