diff --git a/documentation/dsls/DSL-AshJsonApi.Resource.md b/documentation/dsls/DSL-AshJsonApi.Resource.md index 72386ae..1350779 100644 --- a/documentation/dsls/DSL-AshJsonApi.Resource.md +++ b/documentation/dsls/DSL-AshJsonApi.Resource.md @@ -67,8 +67,8 @@ end | [`paginated_includes`](#json_api-paginated_includes){: #json_api-paginated_includes } | `list(atom \| list(atom))` | `[]` | A list of relationship paths that can be paginated when included via the `included_page` query parameter. Each entry can be either an atom (for top-level relationships) or a list of atoms (for nested paths). | | [`include_nil_values?`](#json_api-include_nil_values?){: #json_api-include_nil_values? } | `any` | | Whether or not to include properties for values that are nil in the JSON output | | [`default_fields`](#json_api-default_fields){: #json_api-default_fields } | `list(atom)` | | The fields to include in the object if the `fields` query parameter does not specify. Defaults to all public | -| [`hide_fields`](#json_api-hide_fields){: #json_api-hide_fields } | `list(atom)` | `[]` | A list of fields to hide from the generated OpenAPI specification. Applies to attributes, relationships, calculations, and aggregates. | -| [`show_fields`](#json_api-show_fields){: #json_api-show_fields } | `list(atom)` | | A list of fields to show in the generated OpenAPI specification. If not specified, all public fields are shown except those in `hide_fields`. | +| [`hide_fields`](#json_api-hide_fields){: #json_api-hide_fields } | `list(atom)` | `[]` | A list of fields to hide from generated API specifications and JSON:API responses. Applies to attributes, relationships, calculations, and aggregates. | +| [`show_fields`](#json_api-show_fields){: #json_api-show_fields } | `list(atom)` | | A list of fields to show in generated API specifications and JSON:API responses. If not specified, all public fields are shown except those in `hide_fields`. | | [`derive_sort?`](#json_api-derive_sort?){: #json_api-derive_sort? } | `boolean` | `true` | Whether or not to derive a sort parameter based on the sortable fields of the resource | | [`derive_filter?`](#json_api-derive_filter?){: #json_api-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource | | [`relationship_meta_in`](#json_api-relationship_meta_in){: #json_api-relationship_meta_in } | `keyword` | `[]` | Configures how incoming JSON:API `meta` keys on relationship resource identifiers map to join resource attributes for many_to_many relationship writes. Use together with `relationship_meta_out` for reads. Each relationship you want to support must declare both mappings explicitly. | diff --git a/lib/ash_json_api/domain/persisters/define_router.ex b/lib/ash_json_api/domain/persisters/define_router.ex index b2c64db..1c32e2d 100644 --- a/lib/ash_json_api/domain/persisters/define_router.ex +++ b/lib/ash_json_api/domain/persisters/define_router.ex @@ -9,7 +9,10 @@ defmodule AshJsonApi.Domain.Persisters.DefineRouter do alias Spark.Dsl.Transformer def transform(dsl) do - routes = AshJsonApi.Domain.Info.routes(dsl) + routes = + dsl + |> AshJsonApi.Domain.Info.routes() + |> Enum.filter(&route_visible?/1) route_matchers = routes @@ -45,6 +48,12 @@ defmodule AshJsonApi.Domain.Persisters.DefineRouter do )} end + defp route_visible?(%{relationship: nil}), do: true + + defp route_visible?(%{resource: resource, relationship: relationship}) do + AshJsonApi.Resource.Info.show_field?(resource, relationship) + end + # sobelow_skip ["DOS.StringToAtom"] def route_match(route) do split_route = String.split(route.route, "/", trim: true) diff --git a/lib/ash_json_api/includes/parser.ex b/lib/ash_json_api/includes/parser.ex index e1815c5..52572db 100644 --- a/lib/ash_json_api/includes/parser.ex +++ b/lib/ash_json_api/includes/parser.ex @@ -30,9 +30,48 @@ defmodule AshJsonApi.Includes.Parser do defp allowed_preloads(resource) do resource |> AshJsonApi.Resource.Info.includes() + |> filter_hidden_includes(resource) |> to_nested_map() end + defp filter_hidden_includes(includes, resource) when is_list(includes) do + includes + |> Enum.flat_map(fn include -> + case filter_hidden_include(include, resource) do + nil -> [] + include -> [include] + end + end) + end + + defp filter_hidden_include({include, further}, resource) do + case public_relationship(resource, include) do + %{destination: destination} -> + {include, filter_hidden_includes(List.wrap(further), destination)} + + nil -> + nil + end + end + + defp filter_hidden_include(include, resource) do + if public_relationship(resource, include) do + include + end + end + + defp public_relationship(resource, relationship_name) do + case Ash.Resource.Info.public_relationship(resource, relationship_name) do + %{name: name} = relationship -> + if AshJsonApi.Resource.Info.show_field?(resource, name) do + relationship + end + + nil -> + nil + end + end + defp to_nested_map(list) when is_list(list) do list |> Enum.map(fn 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/lib/ash_json_api/request.ex b/lib/ash_json_api/request.ex index 9b63cc8..46580bb 100644 --- a/lib/ash_json_api/request.ex +++ b/lib/ash_json_api/request.ex @@ -687,22 +687,28 @@ defmodule AshJsonApi.Request do defp parse_field_inputs(request), do: request - if function_exported?(Ash.Resource.Info, :public_related, 2) do - defp public_related(resource, relationship) do - Ash.Resource.Info.public_related(resource, relationship) - end - else - defp public_related(resource, relationship) when not is_list(relationship) do - public_related(resource, [relationship]) + defp public_related(resource, relationship) when not is_list(relationship) do + public_related(resource, [relationship]) + end + + defp public_related(resource, []), do: resource + + defp public_related(resource, [path | rest]) do + case public_relationship(resource, path) do + %{destination: destination} -> public_related(destination, rest) + nil -> nil end + end - defp public_related(resource, []), do: resource + defp public_relationship(resource, relationship_name) do + case Ash.Resource.Info.public_relationship(resource, relationship_name) do + %{name: name} = relationship -> + if AshJsonApi.Resource.Info.show_field?(resource, name) do + relationship + end - defp public_related(resource, [path | rest]) do - case Ash.Resource.Info.public_relationship(resource, path) do - %{destination: destination} -> public_related(destination, rest) - nil -> nil - end + nil -> + nil end end @@ -730,48 +736,56 @@ defmodule AshJsonApi.Request do ) calculation -> - Enum.reduce(arguments, request, fn {arg_name, arg_value}, request -> - calc_arg_names = AshJsonApi.Resource.Info.calculation_argument_names(resource) - - calculation_arg = - Enum.find(calculation.arguments, fn argument -> - AshJsonApi.Resource.Info.apply_argument_name_mapping( - calc_arg_names, - calculation.name, - argument.name - ) == arg_name - end) + if AshJsonApi.Resource.Info.show_field?(resource, calculation.name) do + Enum.reduce(arguments, request, fn {arg_name, arg_value}, request -> + calc_arg_names = AshJsonApi.Resource.Info.calculation_argument_names(resource) + + calculation_arg = + Enum.find(calculation.arguments, fn argument -> + AshJsonApi.Resource.Info.apply_argument_name_mapping( + calc_arg_names, + calculation.name, + argument.name + ) == arg_name + end) - case calculation_arg do - nil -> - add_error( - request, - InvalidField.exception(type: type, parameter?: true, field: arg_name), - request.route.type - ) + case calculation_arg do + nil -> + add_error( + request, + InvalidField.exception(type: type, parameter?: true, field: arg_name), + request.route.type + ) - _ -> - cur_resource_field_inputs = Map.get(request.field_inputs, resource, %{}) + _ -> + cur_resource_field_inputs = Map.get(request.field_inputs, resource, %{}) - cur_calculation_field_inputs = - Map.get(cur_resource_field_inputs, calculation.name, %{}) + cur_calculation_field_inputs = + Map.get(cur_resource_field_inputs, calculation.name, %{}) - updated_calculation_field_inputs = - Map.put(cur_calculation_field_inputs, calculation_arg.name, arg_value) + updated_calculation_field_inputs = + Map.put(cur_calculation_field_inputs, calculation_arg.name, arg_value) - updated_resource_field_inputs = - Map.put( - cur_resource_field_inputs, - calculation.name, - updated_calculation_field_inputs - ) + updated_resource_field_inputs = + Map.put( + cur_resource_field_inputs, + calculation.name, + updated_calculation_field_inputs + ) - updated_field_inputs = - Map.put(request.field_inputs, resource, updated_resource_field_inputs) + updated_field_inputs = + Map.put(request.field_inputs, resource, updated_resource_field_inputs) - %{request | field_inputs: updated_field_inputs} - end - end) + %{request | field_inputs: updated_field_inputs} + end + end) + else + add_error( + request, + InvalidField.exception(type: type, parameter?: true, field: calculation_name), + request.route.type + ) + end end end) end @@ -788,12 +802,13 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_attributes() |> Enum.find(fn a -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == key + AshJsonApi.Resource.Info.show_field?(resource, a.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == key end) -> fields = Map.update(request.fields, resource, [attr.name], &[attr.name | &1]) %{request | fields: fields} - rel = Ash.Resource.Info.public_relationship(resource, key) -> + rel = public_relationship(resource, key) -> fields = Map.update(request.fields, resource, [rel.name], &[rel.name | &1]) %{request | fields: fields} @@ -801,7 +816,8 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_aggregates() |> Enum.find(fn a -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == key + AshJsonApi.Resource.Info.show_field?(resource, a.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == key end) -> fields = Map.update(request.fields, resource, [agg.name], &[agg.name | &1]) %{request | fields: fields} @@ -810,7 +826,8 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_calculations() |> Enum.find(fn c -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, c.name) == key + AshJsonApi.Resource.Info.show_field?(resource, c.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, c.name) == key end) -> fields = Map.update(request.fields, resource, [calc.name], &[calc.name | &1]) %{request | fields: fields} @@ -873,8 +890,9 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_attributes() |> Enum.find(fn a -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == - field_name + AshJsonApi.Resource.Info.show_field?(resource, a.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == + field_name end) -> %{request | sort: [{attr.name, order} | request.sort]} @@ -882,8 +900,9 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_aggregates() |> Enum.find(fn a -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == - field_name + AshJsonApi.Resource.Info.show_field?(resource, a.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, a.name) == + field_name end) -> %{request | sort: [{agg.name, order} | request.sort]} @@ -891,8 +910,9 @@ defmodule AshJsonApi.Request do resource |> Ash.Resource.Info.public_calculations() |> Enum.find(fn c -> - AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, c.name) == - field_name + AshJsonApi.Resource.Info.show_field?(resource, c.name) && + AshJsonApi.Resource.Info.apply_field_name_mapping(attr_names, c.name) == + field_name end) -> %{request | sort: [{calc.name, order} | request.sort]} @@ -981,7 +1001,9 @@ defmodule AshJsonApi.Request do ) do includes = Enum.reduce( - AshJsonApi.Resource.Info.always_include_linkage(resource), + resource + |> AshJsonApi.Resource.Info.always_include_linkage() + |> filter_shown_fields(resource), includes, fn key, includes -> if Keyword.has_key?(includes, key) do @@ -1018,6 +1040,7 @@ defmodule AshJsonApi.Request do |> Ash.Resource.Info.public_attributes() |> Enum.map(& &1.name) ) + |> filter_shown_fields(related) |> Enum.map(fn field -> case Map.get(related_field_inputs, field) do nil -> field @@ -1418,6 +1441,10 @@ defmodule AshJsonApi.Request do {:error, InvalidRelationshipInput.exception(relationship: name, input: value)} end + defp filter_shown_fields(fields, resource) do + Enum.filter(fields, &AshJsonApi.Resource.Info.show_field?(resource, &1)) + end + defp url(conn) do Conn.request_url(conn) end diff --git a/lib/ash_json_api/resource/info.ex b/lib/ash_json_api/resource/info.ex index 2b98d01..42fc58e 100644 --- a/lib/ash_json_api/resource/info.ex +++ b/lib/ash_json_api/resource/info.ex @@ -70,17 +70,21 @@ defmodule AshJsonApi.Resource.Info do Extension.get_opt(resource, [:json_api], :default_fields, nil, true) end - @doc "Fields to hide from the generated API specification" + @doc "Fields to hide from generated API specifications and JSON:API responses" def hide_fields(resource) do Extension.get_opt(resource, [:json_api], :hide_fields, [], true) end - @doc "Fields to show in the generated API specification" + @doc "Fields to show in generated API specifications and JSON:API responses" def show_fields(resource) do Extension.get_opt(resource, [:json_api], :show_fields, nil, true) end - @doc "Whether or not a given field should be shown in the generated API specification" + @doc "Whether or not a given field should be exposed in JSON:API" + def show_field?(resource, %{name: name}) do + show_field?(resource, name) + end + def show_field?(resource, field) do hide_fields = hide_fields(resource) show_fields = show_fields(resource) || [field] @@ -189,7 +193,10 @@ defmodule AshJsonApi.Resource.Info do Ash.Resource.Info.public_aggregates(resource) Enum.find_value(all_fields, fn field -> - if apply_field_name_mapping(names, field.name) == json_key, do: field.name + if show_field?(resource, field.name) && + apply_field_name_mapping(names, field.name) == json_key do + field.name + end end) end diff --git a/lib/ash_json_api/resource/persisters/define_router.ex b/lib/ash_json_api/resource/persisters/define_router.ex index 8bafe00..18940a3 100644 --- a/lib/ash_json_api/resource/persisters/define_router.ex +++ b/lib/ash_json_api/resource/persisters/define_router.ex @@ -9,7 +9,10 @@ defmodule AshJsonApi.Resource.Persisters.DefineRouter do alias Spark.Dsl.Transformer def transform(dsl) do - routes = AshJsonApi.Resource.Info.routes(dsl) + routes = + dsl + |> AshJsonApi.Resource.Info.routes() + |> Enum.filter(&route_visible?(dsl, &1)) route_matchers = routes @@ -45,6 +48,12 @@ defmodule AshJsonApi.Resource.Persisters.DefineRouter do )} end + defp route_visible?(_resource, %{relationship: nil}), do: true + + defp route_visible?(resource, %{relationship: relationship}) do + AshJsonApi.Resource.Info.show_field?(resource, relationship) + end + # sobelow_skip ["DOS.StringToAtom"] def route_match(route) do split_route = String.split(route.route, "/", trim: true) diff --git a/lib/ash_json_api/resource/resource.ex b/lib/ash_json_api/resource/resource.ex index 880b3b3..b255c79 100644 --- a/lib/ash_json_api/resource/resource.ex +++ b/lib/ash_json_api/resource/resource.ex @@ -561,12 +561,12 @@ defmodule AshJsonApi.Resource do type: {:list, :atom}, default: [], doc: - "A list of fields to hide from the generated OpenAPI specification. Applies to attributes, relationships, calculations, and aggregates." + "A list of fields to hide from generated API specifications and JSON:API responses. Applies to attributes, relationships, calculations, and aggregates." ], show_fields: [ type: {:list, :atom}, doc: - "A list of fields to show in the generated OpenAPI specification. If not specified, all public fields are shown except those in `hide_fields`." + "A list of fields to show in generated API specifications and JSON:API responses. If not specified, all public fields are shown except those in `hide_fields`." ], derive_sort?: [ type: :boolean, @@ -797,11 +797,18 @@ defmodule AshJsonApi.Resource do def route(resource, domains, criteria \\ %{}) do resource |> routes(domains) + |> Enum.filter(&route_visible?(resource, &1)) |> Enum.find(fn route -> Map.take(route, Map.keys(criteria)) == criteria end) end + defp route_visible?(_resource, %{relationship: nil}), do: true + + defp route_visible?(resource, %{relationship: relationship}) do + AshJsonApi.Resource.Info.show_field?(resource, relationship) + end + @doc false def set_related_route(%{route: nil, relationship: relationship} = route) do {:ok, %{route | route: ":id/#{relationship}"}} diff --git a/lib/ash_json_api/router.ex b/lib/ash_json_api/router.ex index 9845ef4..c1ded4b 100644 --- a/lib/ash_json_api/router.ex +++ b/lib/ash_json_api/router.ex @@ -109,11 +109,16 @@ defmodule AshJsonApi.Router do router.domains() |> Enum.flat_map(&Ash.Domain.Info.resources/1) |> Enum.flat_map(fn resource -> - resource |> AshJsonApi.Resource.Info.routes() |> Enum.map(&Map.put(&1, :resource, resource)) + resource + |> AshJsonApi.Resource.Info.routes() + |> Enum.map(&Map.put(&1, :resource, resource)) + |> Enum.filter(&route_visible?/1) end) |> then(fn routes -> Enum.concat( - Enum.flat_map(router.domains(), &AshJsonApi.Domain.Info.routes/1), + router.domains() + |> Enum.flat_map(&AshJsonApi.Domain.Info.routes/1) + |> Enum.filter(&route_visible?/1), routes ) end) @@ -162,4 +167,10 @@ defmodule AshJsonApi.Router do defp match_path?(_, _) do false end + + defp route_visible?(%{relationship: nil}), do: true + + defp route_visible?(%{resource: resource, relationship: relationship}) do + AshJsonApi.Resource.Info.show_field?(resource, relationship) + end end diff --git a/lib/ash_json_api/serializer.ex b/lib/ash_json_api/serializer.ex index 9b971db..69fffb3 100644 --- a/lib/ash_json_api/serializer.ex +++ b/lib/ash_json_api/serializer.ex @@ -570,6 +570,7 @@ defmodule AshJsonApi.Serializer do defp serialize_relationships(request, %resource{} = record) do resource |> Ash.Resource.Info.public_relationships() + |> filter_shown_fields(resource) |> Enum.into(%{}, fn relationship -> value = %{ @@ -1092,6 +1093,7 @@ defmodule AshJsonApi.Serializer do default_attributes(resource) end |> Enum.concat(load_fields) + |> filter_shown_fields(resource) Enum.reduce(fields, %{}, fn field_name, acc -> field = Ash.Resource.Info.field(resource, field_name) @@ -1326,4 +1328,8 @@ defmodule AshJsonApi.Serializer do defp get_field_value(record, field) do Map.get(record, field.name) end + + defp filter_shown_fields(fields, resource) do + Enum.filter(fields, &AshJsonApi.Resource.Info.show_field?(resource, &1)) + end end diff --git a/test/acceptance/field_visibility_test.exs b/test/acceptance/field_visibility_test.exs new file mode 100644 index 0000000..e9fea1a --- /dev/null +++ b/test/acceptance/field_visibility_test.exs @@ -0,0 +1,351 @@ +# SPDX-FileCopyrightText: 2019 ash_json_api contributors +# +# SPDX-License-Identifier: MIT + +defmodule Test.Acceptance.FieldVisibilityTest do + use ExUnit.Case, async: true + + defmodule Author do + use Ash.Resource, + domain: Test.Acceptance.FieldVisibilityTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("visibility-author") + + routes do + base("/visibility_authors") + get(:read) + index(:read) + end + end + + actions do + default_accept(:*) + defaults([:read, :create]) + end + + attributes do + uuid_primary_key(:id) + attribute(:name, :string, allow_nil?: false, public?: true) + end + end + + defmodule Post do + use Ash.Resource, + domain: Test.Acceptance.FieldVisibilityTest.Domain, + data_layer: Ash.DataLayer.Ets, + primary_read_warning?: false, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("visibility-post") + includes([:visible_author, :hidden_author]) + paginated_includes([:visible_author, :hidden_author]) + default_fields([:title, :secret, :secret_calc]) + hide_fields([:secret, :secret_calc, :hidden_author]) + + routes do + base("/visibility_posts") + get(:read) + index(:read) + related(:visible_author, :read) + related(:hidden_author, :read) + end + end + + actions do + default_accept(:*) + defaults([:create, :update, :destroy]) + + read :read do + primary? true + prepare(build(load: [:secret_calc, :visible_author, :hidden_author])) + end + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, allow_nil?: false, public?: true) + attribute(:secret, :string, allow_nil?: false, public?: true) + end + + calculations do + calculate(:secret_calc, :string, concat([:title, :secret], ":"), public?: true) + end + + relationships do + belongs_to :visible_author, Test.Acceptance.FieldVisibilityTest.Author do + allow_nil?(false) + public?(true) + attribute_writable?(true) + end + + belongs_to :hidden_author, Test.Acceptance.FieldVisibilityTest.Author do + allow_nil?(false) + public?(true) + attribute_writable?(true) + end + end + end + + defmodule ShowOnlyPost do + use Ash.Resource, + domain: Test.Acceptance.FieldVisibilityTest.Domain, + data_layer: Ash.DataLayer.Ets, + primary_read_warning?: false, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("visibility-show-post") + includes([:visible_author, :extra_author]) + default_fields([:title, :summary, :secret_calc]) + show_fields([:title, :visible_author]) + + routes do + base("/visibility_show_posts") + get(:read) + related(:visible_author, :read) + related(:extra_author, :read) + end + end + + actions do + default_accept(:*) + defaults([:create, :update, :destroy]) + + read :read do + primary? true + prepare(build(load: [:secret_calc, :visible_author, :extra_author])) + end + end + + attributes do + uuid_primary_key(:id) + attribute(:title, :string, allow_nil?: false, public?: true) + attribute(:summary, :string, allow_nil?: false, public?: true) + end + + calculations do + calculate(:secret_calc, :string, concat([:title, :summary], ":"), public?: true) + end + + relationships do + belongs_to :visible_author, Test.Acceptance.FieldVisibilityTest.Author do + allow_nil?(false) + public?(true) + attribute_writable?(true) + end + + belongs_to :extra_author, Test.Acceptance.FieldVisibilityTest.Author do + allow_nil?(false) + public?(true) + attribute_writable?(true) + end + end + end + + defmodule Domain do + use Ash.Domain, + otp_app: :ash_json_api, + extensions: [ + AshJsonApi.Domain + ] + + json_api do + log_errors?(false) + end + + resources do + resource(Author) + resource(Post) + resource(ShowOnlyPost) + end + end + + defmodule Router do + use AshJsonApi.Router, domain: Domain + end + + import AshJsonApi.Test + + setup do + Application.put_env(:ash_json_api, Domain, json_api: [test_router: Router]) + + visible_author = + Author + |> Ash.Changeset.for_create(:create, %{name: "visible"}) + |> Ash.create!() + + hidden_author = + Author + |> Ash.Changeset.for_create(:create, %{name: "hidden"}) + |> Ash.create!() + + post = + Post + |> Ash.Changeset.for_create(:create, %{ + title: "Public", + secret: "private", + visible_author_id: visible_author.id, + hidden_author_id: hidden_author.id + }) + |> Ash.create!() + + show_only_post = + ShowOnlyPost + |> Ash.Changeset.for_create(:create, %{ + title: "Shown", + summary: "not shown", + visible_author_id: visible_author.id, + extra_author_id: hidden_author.id + }) + |> Ash.create!() + + %{post: post, show_only_post: show_only_post, visible_author: visible_author} + end + + test "hidden fields are omitted from get responses", %{post: post} do + response = + Domain + |> get("/visibility_posts/#{post.id}", status: 200) + + attributes = response.resp_body["data"]["attributes"] + relationships = response.resp_body["data"]["relationships"] + + assert attributes["title"] == "Public" + refute Map.has_key?(attributes, "secret") + refute Map.has_key?(attributes, "secret_calc") + + assert Map.has_key?(relationships, "visible_author") + refute Map.has_key?(relationships, "hidden_author") + end + + test "hidden fields are omitted from index responses", %{post: post} do + response = + Domain + |> get("/visibility_posts", status: 200) + + data = Enum.find(response.resp_body["data"], &(&1["id"] == post.id)) + attributes = data["attributes"] + relationships = data["relationships"] + + assert attributes["title"] == "Public" + refute Map.has_key?(attributes, "secret") + refute Map.has_key?(attributes, "secret_calc") + + assert Map.has_key?(relationships, "visible_author") + refute Map.has_key?(relationships, "hidden_author") + end + + test "hidden fields cannot be requested with sparse fieldsets", %{post: post} do + Domain + |> get("/visibility_posts/#{post.id}?fields[visibility-post]=secret", status: 400) + |> assert_has_error(%{ + "code" => "invalid_field" + }) + end + + test "hidden fields cannot be requested with derived sort" do + Domain + |> get("/visibility_posts?sort=secret", status: 400) + |> assert_has_error(%{ + "code" => "invalid_sort" + }) + end + + test "hidden relationships cannot be included", %{post: post} do + Domain + |> get("/visibility_posts/#{post.id}?include=hidden_author", status: 400) + |> assert_has_error(%{ + "code" => "invalid_includes" + }) + end + + test "visible relationships can still be included", %{ + post: post, + visible_author: visible_author + } do + response = + Domain + |> get("/visibility_posts/#{post.id}?include=visible_author", status: 200) + + assert [%{"id" => visible_author_id, "type" => "visibility-author"}] = + response.resp_body["included"] + + assert visible_author_id == visible_author.id + end + + test "hidden relationship routes are not exposed", %{post: post} do + Domain + |> get("/visibility_posts/#{post.id}/hidden_author", status: 404) + end + + test "visible relationship routes are still exposed", %{ + post: post, + visible_author: visible_author + } do + response = + Domain + |> get("/visibility_posts/#{post.id}/visible_author", status: 200) + + assert response.resp_body["data"]["id"] == visible_author.id + assert response.resp_body["data"]["type"] == "visibility-author" + end + + test "show_fields only exposes allowlisted fields", %{show_only_post: post} do + response = + Domain + |> get("/visibility_show_posts/#{post.id}", status: 200) + + attributes = response.resp_body["data"]["attributes"] + relationships = response.resp_body["data"]["relationships"] + + assert attributes["title"] == "Shown" + refute Map.has_key?(attributes, "summary") + refute Map.has_key?(attributes, "secret_calc") + + assert Map.has_key?(relationships, "visible_author") + refute Map.has_key?(relationships, "extra_author") + end + + test "show_fields rejects non-allowlisted sparse fieldsets", %{show_only_post: post} do + Domain + |> get("/visibility_show_posts/#{post.id}?fields[visibility-show-post]=summary", status: 400) + |> assert_has_error(%{ + "code" => "invalid_field" + }) + end + + test "show_fields rejects non-allowlisted includes", %{show_only_post: post} do + Domain + |> get("/visibility_show_posts/#{post.id}?include=extra_author", status: 400) + |> assert_has_error(%{ + "code" => "invalid_includes" + }) + end + + test "show_fields hides non-allowlisted relationship routes", %{show_only_post: post} do + Domain + |> get("/visibility_show_posts/#{post.id}/extra_author", status: 404) + end +end 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