From d4a9784f8aeb7bfab2d1ab24129d002b71501576 Mon Sep 17 00:00:00 2001 From: Oliver Severin Mulelid-Tynes Date: Fri, 19 Jun 2026 10:05:35 +0200 Subject: [PATCH] Fix JSON schema field visibility --- lib/ash_json_api/json_schema/json_schema.ex | 27 +++- test/acceptance/json_schema_test.exs | 161 ++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/lib/ash_json_api/json_schema/json_schema.ex b/lib/ash_json_api/json_schema/json_schema.ex index f942037..6016abb 100644 --- a/lib/ash_json_api/json_schema/json_schema.ex +++ b/lib/ash_json_api/json_schema/json_schema.ex @@ -25,6 +25,7 @@ defmodule AshJsonApi.JsonSchema do {new_refs, route_schemas} = resource |> AshJsonApi.Resource.Info.routes(domains) + |> Enum.filter(&route_visible?(resource, &1)) |> Enum.reduce({[], []}, fn route, {refs, route_schemas} -> {new_refs, new_route_schema} = route_schema(route, domain, resource, opts) @@ -278,6 +279,7 @@ defmodule AshJsonApi.JsonSchema do defp required_attributes(resource) do resource |> Ash.Resource.Info.public_attributes() + |> filter_shown_fields(resource) |> Enum.reject(&(&1.allow_nil? || AshJsonApi.Resource.only_primary_key?(resource, &1.name))) |> Enum.map(&AshJsonApi.Resource.Info.field_to_json_key(resource, &1.name)) end @@ -290,6 +292,7 @@ defmodule AshJsonApi.JsonSchema do Ash.Resource.Info.public_aggregates(resource) |> set_aggregate_constraints(resource) ) + |> filter_shown_fields(resource) |> Enum.reject(&AshJsonApi.Resource.only_primary_key?(resource, &1.name)) |> Enum.reduce(%{}, fn attr, acc -> Map.put( @@ -332,6 +335,7 @@ defmodule AshJsonApi.JsonSchema do defp resource_relationships(resource) do resource |> Ash.Resource.Info.public_relationships() + |> filter_shown_fields(resource) |> Enum.filter(fn relationship -> AshJsonApi.Resource.Info.type(relationship.destination) end) @@ -719,6 +723,24 @@ defmodule AshJsonApi.JsonSchema do action && action.type == :read end + defp show_field?(resource, %{name: name}) do + AshJsonApi.Resource.Info.show_field?(resource, name) + end + + defp show_field?(resource, field) do + AshJsonApi.Resource.Info.show_field?(resource, field) + end + + defp filter_shown_fields(fields, resource) do + Enum.filter(fields, &show_field?(resource, &1)) + end + + defp route_visible?(_resource, %{relationship: nil}), do: true + + defp route_visible?(resource, %{relationship: relationship}) do + show_field?(resource, relationship) + end + defp add_route_properties(keys, resource, properties) do Enum.reduce(properties, keys, fn property, keys -> spec = @@ -760,7 +782,7 @@ defmodule AshJsonApi.JsonSchema do end defp sort_format(resource) do - sorts = sortable_fields(resource) + sorts = resource |> sortable_fields() |> filter_shown_fields(resource) "(#{Enum.map_join(sorts, "|", &AshJsonApi.Resource.Info.field_to_json_key(resource, &1.name))}),*" end @@ -985,6 +1007,7 @@ defmodule AshJsonApi.JsonSchema do resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(&1.name in action.accept && &1.writable?)) + |> filter_shown_fields(resource) |> Enum.reduce(%{}, fn attribute, acc -> Map.put( acc, @@ -1023,6 +1046,7 @@ defmodule AshJsonApi.JsonSchema do defp required_relationship_attributes(resource, relationship_arguments, action) do action.arguments |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name)) + |> filter_shown_fields(resource) |> Enum.reject(& &1.allow_nil?) |> Enum.map(&AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, &1.name)) end @@ -1030,6 +1054,7 @@ defmodule AshJsonApi.JsonSchema do defp write_relationships(resource, relationship_arguments, action) do action.arguments |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name)) + |> filter_shown_fields(resource) |> Enum.reduce(%{}, fn argument, acc -> data = resource_relationship_field_data(resource, argument) diff --git a/test/acceptance/json_schema_test.exs b/test/acceptance/json_schema_test.exs index 4dd1d3c..8733f2b 100644 --- a/test/acceptance/json_schema_test.exs +++ b/test/acceptance/json_schema_test.exs @@ -151,6 +151,120 @@ defmodule Test.Acceptance.JsonSchemaTest do end end + defmodule HiddenSpecAuthor do + use Ash.Resource, + domain: Test.Acceptance.JsonSchemaTest.HiddenSpecDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("hidden-json-author") + + routes do + base("/hidden_json_authors") + index(:read) + end + end + + actions do + default_accept(:*) + defaults([:read, :create]) + end + + attributes do + uuid_primary_key(:id, writable?: true, public?: true) + attribute(:name, :string, allow_nil?: false, public?: true) + end + end + + defmodule HiddenSpecPost do + use Ash.Resource, + domain: Test.Acceptance.JsonSchemaTest.HiddenSpecDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("hidden-json-post") + default_fields([:name, :secret, :secret_calc]) + hide_fields([:secret, :secret_calc, :hidden_author]) + + routes do + base("/hidden_json_posts") + get(:read) + index(:read) + post(:create, relationship_arguments: [{:id, :visible_author}, {:id, :hidden_author}]) + related(:visible_author, :read) + related(:hidden_author, :read) + end + end + + actions do + default_accept(:*) + defaults([:read]) + + create :create do + primary? true + accept([:id, :name, :secret]) + argument(:visible_author, :uuid, allow_nil?: false) + argument(:hidden_author, :uuid, allow_nil?: false) + + change(manage_relationship(:visible_author, type: :append_and_remove)) + change(manage_relationship(:hidden_author, type: :append_and_remove)) + end + end + + attributes do + uuid_primary_key(:id, writable?: true, public?: true) + attribute(:name, :string, allow_nil?: false, public?: true) + attribute(:secret, :string, allow_nil?: false, public?: true) + end + + calculations do + calculate(:secret_calc, :string, concat([:name, :name], "-"), public?: true) + end + + relationships do + belongs_to(:visible_author, Test.Acceptance.JsonSchemaTest.HiddenSpecAuthor, + allow_nil?: false, + public?: true + ) + + belongs_to(:hidden_author, Test.Acceptance.JsonSchemaTest.HiddenSpecAuthor, + allow_nil?: false, + public?: true + ) + end + end + + defmodule HiddenSpecDomain do + use Ash.Domain, + otp_app: :ash_json_api, + extensions: [ + AshJsonApi.Domain + ] + + json_api do + log_errors?(false) + end + + resources do + resource(HiddenSpecAuthor) + resource(HiddenSpecPost) + end + end + defmodule Blogs do use Ash.Domain, otp_app: :ash_json_api, @@ -189,6 +303,53 @@ defmodule Test.Acceptance.JsonSchemaTest do ) end + test "hide_fields hides fields from the generated JSON schema" do + json_api = AshJsonApi.JsonSchema.generate([HiddenSpecDomain]) + + post_schema = json_api["definitions"]["hidden-json-post"] + attributes = post_schema["properties"]["attributes"]["properties"] + relationships = post_schema["properties"]["relationships"]["properties"] + + assert Map.has_key?(attributes, "name") + refute Map.has_key?(attributes, "secret") + refute Map.has_key?(attributes, "secret_calc") + refute "secret" in post_schema["properties"]["attributes"]["required"] + + assert Map.has_key?(relationships, "visible_author") + refute Map.has_key?(relationships, "hidden_author") + + hrefs = Enum.map(json_api["links"], & &1["href"]) + assert Enum.any?(hrefs, &String.contains?(&1, "/hidden_json_posts/{id}/visible_author")) + refute Enum.any?(hrefs, &String.contains?(&1, "/hidden_json_posts/{id}/hidden_author")) + + index_link = + Enum.find(json_api["links"], fn link -> + link["rel"] == "index" && String.starts_with?(link["href"], "/hidden_json_posts") + end) + + assert index_link["hrefSchema"]["properties"]["sort"]["format"] =~ "name" + refute index_link["hrefSchema"]["properties"]["sort"]["format"] =~ "secret" + + create_link = + Enum.find(json_api["links"], fn link -> + link["rel"] == "post" && String.starts_with?(link["href"], "/hidden_json_posts") + end) + + create_attributes = create_link["schema"]["properties"]["data"]["properties"]["attributes"] + + create_relationships = + create_link["schema"]["properties"]["data"]["properties"]["relationships"] + + assert Map.has_key?(create_attributes["properties"], "name") + refute Map.has_key?(create_attributes["properties"], "secret") + refute "secret" in create_attributes["required"] + + assert Map.has_key?(create_relationships["properties"], "visible_author") + refute Map.has_key?(create_relationships["properties"], "hidden_author") + assert "visible_author" in create_relationships["required"] + refute "hidden_author" in create_relationships["required"] + end + test "handles self-referential embedded resources without infinite loop" do # This should complete without timing out # If it loops infinitely, the test will timeout