diff --git a/.formatter.exs b/.formatter.exs index ab365e14..9638aa73 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -29,6 +29,7 @@ spark_locals_without_parens = [ get: 2, get: 3, group_by: 1, + hide_fields: 1, include_nil_values?: 1, includes: 1, index: 1, @@ -75,6 +76,7 @@ spark_locals_without_parens = [ route: 5, router: 1, serve_schema?: 1, + show_fields: 1, show_raised_errors?: 1, tag: 1, type: 1, diff --git a/documentation/dsls/DSL-AshJsonApi.Resource.md b/documentation/dsls/DSL-AshJsonApi.Resource.md index c7bd564b..72386aed 100644 --- a/documentation/dsls/DSL-AshJsonApi.Resource.md +++ b/documentation/dsls/DSL-AshJsonApi.Resource.md @@ -67,6 +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`. | | [`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/json_schema/open_api.ex b/lib/ash_json_api/json_schema/open_api.ex index c60d346c..38b3007a 100644 --- a/lib/ash_json_api/json_schema/open_api.ex +++ b/lib/ash_json_api/json_schema/open_api.ex @@ -198,7 +198,12 @@ if Code.ensure_loaded?(OpenApiSpex) do defp all_resources_requiring_filter_schemas(domains) do domains |> Enum.flat_map(&Ash.Domain.Info.resources/1) - |> Enum.reject(&Enum.empty?(AshJsonApi.Resource.Info.routes(&1, domains))) + |> Enum.reject(fn resource -> + resource + |> AshJsonApi.Resource.Info.routes(domains) + |> Enum.filter(&route_visible?(resource, &1)) + |> Enum.empty?() + end) |> with_all_related_resources() |> Enum.filter(fn resource -> AshJsonApi.Resource.Info.type(resource) && @@ -209,7 +214,11 @@ if Code.ensure_loaded?(OpenApiSpex) do defp with_all_related_resources(resources, checked \\ []) do resources |> Enum.reject(&(&1 in checked)) - |> Enum.flat_map(&Ash.Resource.Info.public_relationships/1) + |> Enum.flat_map(fn resource -> + resource + |> Ash.Resource.Info.public_relationships() + |> filter_shown_fields(resource) + end) |> Enum.map(& &1.destination) |> Enum.reject(&(&1 in resources)) |> case do @@ -233,7 +242,11 @@ if Code.ensure_loaded?(OpenApiSpex) do domains |> Stream.flat_map(&Ash.Domain.Info.resources/1) |> Stream.reject(&Enum.empty?(AshJsonApi.Resource.Info.routes(&1, domains))) - |> Stream.flat_map(&Ash.Resource.Info.relationships/1) + |> Stream.flat_map(fn resource -> + resource + |> Ash.Resource.Info.relationships() + |> filter_shown_fields(resource) + end) |> Stream.filter(& &1.public?) |> Enum.any?(&(&1.destination == resource)) end @@ -252,6 +265,18 @@ if Code.ensure_loaded?(OpenApiSpex) 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 resource_filter_schemas(domains, resource, acc) do {field_types, acc} = filter_field_types(resource, acc) @@ -381,6 +406,8 @@ if Code.ensure_loaded?(OpenApiSpex) do fields || AshJsonApi.Resource.Info.default_fields(resource) || Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name) + fields = Enum.filter(fields, &show_field?(resource, &1)) + {properties, acc} = resource_attributes(resource, fields, :json, acc) schema = @@ -399,6 +426,7 @@ if Code.ensure_loaded?(OpenApiSpex) 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(fn attr -> AshJsonApi.Resource.Info.field_to_json_key(resource, attr.name) @@ -420,6 +448,7 @@ if Code.ensure_loaded?(OpenApiSpex) do Ash.Resource.Info.public_aggregates(resource) |> AshJsonApi.JsonSchema.set_aggregate_constraints(resource) ) + |> filter_shown_fields(resource) |> Enum.map(fn %Ash.Resource.Aggregate{} = agg -> field = @@ -1343,6 +1372,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp resource_relationships(resource) do resource |> Ash.Resource.Info.public_relationships() + |> filter_shown_fields(resource) |> Enum.filter(fn %{destination: destination} -> AshJsonApi.Resource.Info.type(destination) end) @@ -1517,7 +1547,10 @@ if Code.ensure_loaded?(OpenApiSpex) do domain |> resources() |> Enum.flat_map_reduce(acc, fn resource, acc -> - routes = AshJsonApi.Resource.Info.routes(resource, all_domains) + routes = + resource + |> AshJsonApi.Resource.Info.routes(all_domains) + |> Enum.filter(&route_visible?(resource, &1)) {route_operations, acc} = Enum.map_reduce(routes, acc, fn route, acc -> @@ -1535,6 +1568,12 @@ if Code.ensure_loaded?(OpenApiSpex) do {final_paths, final_acc} end + defp route_visible?(_resource, %{relationship: nil}), do: true + + defp route_visible?(resource, %{relationship: relationship}) do + show_field?(resource, relationship) + end + @spec route_operation( Route.t(), domain :: module, @@ -1745,6 +1784,13 @@ if Code.ensure_loaded?(OpenApiSpex) do end) ) |> Enum.filter(& &1) + |> Enum.reject(fn + %Ash.Resource.Attribute{name: name} -> + !show_field?(resource, name) && to_string(name) not in route_params + + _ -> + false + end) |> Enum.reduce({[], acc}, fn argument_or_attribute, {list, acc} -> {schema, acc} = resource_write_attribute_type(argument_or_attribute, resource, action.type, acc) @@ -1859,6 +1905,7 @@ if Code.ensure_loaded?(OpenApiSpex) do sorts = resource |> AshJsonApi.JsonSchema.sortable_fields() + |> filter_shown_fields(resource) |> Enum.flat_map(fn attr -> name = AshJsonApi.Resource.Info.field_to_json_key(resource, attr.name) [name, "-" <> name, "\\+\\+" <> name, "--" <> name] @@ -1992,6 +2039,7 @@ if Code.ensure_loaded?(OpenApiSpex) do resource |> AshJsonApi.Resource.Info.includes() |> all_paths() + |> Enum.filter(&visible_relationship_path?(resource, &1)) |> Enum.map(&Enum.join(&1, ".")) %Parameter{ @@ -2016,6 +2064,10 @@ if Code.ensure_loaded?(OpenApiSpex) do # Get all paginated relationship paths paginated_paths = paginated_includes + |> Enum.filter(fn + path when is_list(path) -> visible_relationship_path?(resource, path) + atom when is_atom(atom) -> visible_relationship_path?(resource, [atom]) + end) |> Enum.map(fn path when is_list(path) -> Enum.join(path, ".") atom when is_atom(atom) -> to_string(atom) @@ -2041,11 +2093,10 @@ if Code.ensure_loaded?(OpenApiSpex) do type = AshJsonApi.Resource.Info.type(resource) example = - Enum.join( - AshJsonApi.Resource.Info.default_fields(resource) || - Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name), - "," - ) + (AshJsonApi.Resource.Info.default_fields(resource) || + Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name)) + |> Enum.filter(&show_field?(resource, &1)) + |> Enum.join(",") %Parameter{ name: :fields, @@ -2070,6 +2121,26 @@ if Code.ensure_loaded?(OpenApiSpex) do } end + defp visible_relationship_path?(resource, path) do + path + |> List.wrap() + |> Enum.reduce_while(resource, fn relationship_name, current_resource -> + relationship = Ash.Resource.Info.public_relationship(current_resource, relationship_name) + + cond do + is_nil(relationship) -> + {:halt, false} + + !show_field?(current_resource, relationship_name) -> + {:halt, false} + + true -> + {:cont, relationship.destination} + end + end) + |> then(&(&1 != false)) + end + @spec read_argument_parameters( Route.t(), resource :: module, @@ -2394,12 +2465,13 @@ if Code.ensure_loaded?(OpenApiSpex) do [] :update -> - action.require_attributes + Enum.filter(action.require_attributes, &show_field?(resource, &1)) _ -> resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(&1.name in action.accept && &1.writable?)) + |> filter_shown_fields(resource) |> Enum.reject( &(&1.name in filtered_arguments || &1.allow_nil? || not is_nil(&1.default) || &1.generated? || @@ -2417,6 +2489,7 @@ if Code.ensure_loaded?(OpenApiSpex) do require_attributes = Map.get(action, :require_attributes, []) + |> Enum.filter(&show_field?(resource, &1)) |> Enum.map(&AshJsonApi.Resource.Info.field_to_json_key(resource, &1)) Enum.uniq(attribute_names ++ argument_names ++ require_attributes) @@ -2438,6 +2511,7 @@ if Code.ensure_loaded?(OpenApiSpex) do resource |> Ash.Resource.Info.attributes() |> Enum.filter(&(&1.name in action.accept && &1.writable?)) + |> filter_shown_fields(resource) |> Enum.reduce({%{}, acc}, fn attribute, {attrs, acc} -> {schema, acc} = resource_write_attribute_type(attribute, resource, action.type, acc, format) @@ -2492,6 +2566,7 @@ if Code.ensure_loaded?(OpenApiSpex) 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(fn arg -> AshJsonApi.Resource.Info.argument_to_json_key(resource, action.name, arg.name) @@ -2503,6 +2578,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp write_relationships(resource, relationship_arguments, action) do action.arguments |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name)) + |> filter_shown_fields(resource) |> Map.new(fn argument -> data = resource_write_relationship_field_data(resource, argument) @@ -2747,11 +2823,15 @@ if Code.ensure_loaded?(OpenApiSpex) do do: relationship_destination(resource, include) |> List.wrap() defp relationship_destination(resource, include) do - resource - |> Ash.Resource.Info.public_relationship(include) - |> case do - %{destination: destination} -> destination - _ -> nil + if show_field?(resource, include) do + resource + |> Ash.Resource.Info.public_relationship(include) + |> case do + %{destination: destination} -> destination + _ -> nil + end + else + nil end end @@ -2765,6 +2845,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp filter_attribute_types(resource, acc) do resource |> Ash.Resource.Info.public_attributes() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.reduce({[], acc}, fn attribute, {results, acc} -> {result, acc} = filter_type(attribute, resource, acc) @@ -2775,6 +2856,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp filter_aggregate_types(resource, acc) do resource |> Ash.Resource.Info.public_aggregates() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.reduce({[], acc}, fn aggregate, {results, acc} -> {result, acc} = filter_type(aggregate, resource, acc) @@ -2785,6 +2867,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp filter_calculation_types(resource, acc) do resource |> Ash.Resource.Info.public_calculations() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.reduce({[], acc}, fn calculation, {results, acc} -> {result, acc} = filter_type(calculation, resource, acc) @@ -2987,6 +3070,7 @@ if Code.ensure_loaded?(OpenApiSpex) do resource |> Ash.Resource.Info.public_relationships() + |> filter_shown_fields(resource) |> Enum.filter( &(&1.destination in all_resources && AshJsonApi.Resource.Info.derive_filter?(&1.destination) && @@ -3006,6 +3090,7 @@ if Code.ensure_loaded?(OpenApiSpex) do if Ash.DataLayer.data_layer_can?(resource, :expression_calculation) do resource |> Ash.Resource.Info.public_calculations() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.map(fn calculation -> {calculation.name, @@ -3021,6 +3106,7 @@ if Code.ensure_loaded?(OpenApiSpex) do defp attribute_filter_fields(resource) do resource |> Ash.Resource.Info.public_attributes() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.map(fn attribute -> {attribute.name, @@ -3034,6 +3120,7 @@ if Code.ensure_loaded?(OpenApiSpex) do if Ash.DataLayer.data_layer_can?(resource, :aggregate_filter) do resource |> Ash.Resource.Info.public_aggregates() + |> filter_shown_fields(resource) |> Enum.filter(&filterable?(&1, resource)) |> Enum.map(fn aggregate -> {aggregate.name, diff --git a/lib/ash_json_api/resource/info.ex b/lib/ash_json_api/resource/info.ex index 53342c7f..2b98d01d 100644 --- a/lib/ash_json_api/resource/info.ex +++ b/lib/ash_json_api/resource/info.ex @@ -70,6 +70,24 @@ 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" + def hide_fields(resource) do + Extension.get_opt(resource, [:json_api], :hide_fields, [], true) + end + + @doc "Fields to show in the generated API specification" + 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" + def show_field?(resource, field) do + hide_fields = hide_fields(resource) + show_fields = show_fields(resource) || [field] + + field not in hide_fields and field in show_fields + end + @doc """ Returns the `relationship_meta_in` config for the resource. """ diff --git a/lib/ash_json_api/resource/resource.ex b/lib/ash_json_api/resource/resource.ex index c31c2d2b..880b3b36 100644 --- a/lib/ash_json_api/resource/resource.ex +++ b/lib/ash_json_api/resource/resource.ex @@ -557,6 +557,17 @@ defmodule AshJsonApi.Resource do doc: "The fields to include in the object if the `fields` query parameter does not specify. Defaults to all public" ], + hide_fields: [ + type: {:list, :atom}, + default: [], + doc: + "A list of fields to hide from the generated OpenAPI specification. 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`." + ], derive_sort?: [ type: :boolean, doc: @@ -722,6 +733,7 @@ defmodule AshJsonApi.Resource do AshJsonApi.Resource.Verifiers.VerifyIncludes, AshJsonApi.Resource.Verifiers.VerifyActions, AshJsonApi.Resource.Verifiers.VerifyHasType, + AshJsonApi.Resource.Verifiers.VerifyFieldReferences, AshJsonApi.Resource.Verifiers.VerifyQueryParams ] diff --git a/lib/ash_json_api/resource/verifiers/verify_field_references.ex b/lib/ash_json_api/resource/verifiers/verify_field_references.ex new file mode 100644 index 00000000..a7286d75 --- /dev/null +++ b/lib/ash_json_api/resource/verifiers/verify_field_references.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2019 ash_json_api contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshJsonApi.Resource.Verifiers.VerifyFieldReferences do + @moduledoc "Validates field names referenced in JSON:API DSL options" + use Spark.Dsl.Verifier + + @impl true + def verify(dsl) do + resource = Spark.Dsl.Verifier.get_persisted(dsl, :module) + show_fields = AshJsonApi.Resource.Info.show_fields(dsl) + hide_fields = AshJsonApi.Resource.Info.hide_fields(dsl) + + public_fields = + dsl + |> Ash.Resource.Info.public_attributes() + |> Enum.concat(Ash.Resource.Info.public_relationships(dsl)) + |> Enum.concat(Ash.Resource.Info.public_calculations(dsl)) + |> Enum.concat(Ash.Resource.Info.public_aggregates(dsl)) + |> MapSet.new(& &1.name) + + validate_fields!(resource, :show_fields, show_fields, public_fields) + validate_fields!(resource, :hide_fields, hide_fields, public_fields) + validate_show_hide_overlap!(resource, show_fields, hide_fields) + + :ok + end + + defp validate_fields!(_resource, _option, nil, _public_fields), do: :ok + + defp validate_fields!(resource, option, fields, public_fields) do + Enum.each(fields, fn field -> + unless MapSet.member?(public_fields, field) do + raise Spark.Error.DslError, + module: resource, + path: [:json_api, option], + message: """ + Unknown public field `#{inspect(field)}` in `#{option}`. + + Available: #{inspect(public_fields |> MapSet.to_list() |> Enum.sort())} + """ + end + end) + end + + defp validate_show_hide_overlap!(_resource, nil, _hide_fields), do: :ok + + defp validate_show_hide_overlap!(resource, show_fields, hide_fields) do + overlap = + show_fields + |> MapSet.new() + |> MapSet.intersection(MapSet.new(hide_fields || [])) + |> MapSet.to_list() + |> Enum.sort() + + unless Enum.empty?(overlap) do + raise Spark.Error.DslError, + module: resource, + path: [:json_api], + message: """ + Fields cannot appear in both `show_fields` and `hide_fields`. + + Conflicting fields: #{inspect(overlap)} + """ + end + end +end diff --git a/test/acceptance/open_api_test.exs b/test/acceptance/open_api_test.exs index 88b0af11..210af210 100644 --- a/test/acceptance/open_api_test.exs +++ b/test/acceptance/open_api_test.exs @@ -266,6 +266,122 @@ defmodule Test.Acceptance.OpenApiTest do end end + defmodule HiddenSpecAuthor do + use Ash.Resource, + domain: Test.Acceptance.OpenApiTest.HiddenSpecDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("hidden-spec-author") + + routes do + base("/hidden_spec_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.OpenApiTest.HiddenSpecDomain, + data_layer: Ash.DataLayer.Ets, + extensions: [ + AshJsonApi.Resource + ] + + ets do + private?(true) + end + + json_api do + type("hidden-spec-post") + includes([:visible_author, :hidden_author]) + paginated_includes([:visible_author, :hidden_author]) + default_fields([:name, :secret, :secret_calc]) + hide_fields([:secret, :secret_calc, :hidden_author]) + + routes do + base("/hidden_spec_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.OpenApiTest.HiddenSpecAuthor, + allow_nil?: false, + public?: true + ) + + belongs_to(:hidden_author, Test.Acceptance.OpenApiTest.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, @@ -333,6 +449,68 @@ defmodule Test.Acceptance.OpenApiTest do assert author.description == "This is an author!" end + test "hide_fields hides fields from the generated OpenAPI specification" do + api_spec = + AshJsonApi.Controllers.OpenApi.spec(%{private: %{}}, + domains: [HiddenSpecDomain] + ) + + schema = api_spec.components.schemas["hidden-spec-post"] + attributes = schema.properties.attributes.properties + relationships = schema.properties.relationships.properties + + assert Map.has_key?(attributes, "name") + 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) + + filter_schema = api_spec.components.schemas["hidden-spec-post-filter"] + assert Map.has_key?(filter_schema.properties, :name) + assert Map.has_key?(filter_schema.properties, :visible_author) + refute Map.has_key?(filter_schema.properties, :secret) + refute Map.has_key?(filter_schema.properties, :secret_calc) + refute Map.has_key?(filter_schema.properties, :hidden_author) + refute Map.has_key?(api_spec.components.schemas, "hidden-spec-post-filter-secret") + refute Map.has_key?(api_spec.components.schemas, "hidden-spec-post-filter-secret_calc") + + operation = api_spec.paths["/hidden_spec_posts"].get + + sort = Enum.find(operation.parameters, &(&1.name == "sort")) + assert sort.schema.pattern =~ "name" + refute sort.schema.pattern =~ "secret" + + fields = Enum.find(operation.parameters, &(&1.name == "fields")) + assert fields.schema.example == %{"hidden-spec-post" => "name"} + + include = Enum.find(operation.parameters, &(&1.name == "include")) + assert include.schema.pattern =~ "visible_author" + refute include.schema.pattern =~ "hidden_author" + + included_page = Enum.find(operation.parameters, &(&1.name == "included_page")) + assert included_page.description =~ "visible_author" + refute included_page.description =~ "hidden_author" + + refute Map.has_key?(api_spec.paths, "/hidden_spec_posts/{id}/hidden_author") + assert Map.has_key?(api_spec.paths, "/hidden_spec_posts/{id}/visible_author") + + create_schema = + api_spec.paths["/hidden_spec_posts"].post.requestBody.content["application/vnd.api+json"].schema + + create_attributes = create_schema.properties.data.properties.attributes + create_relationships = create_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 "API routes are mapped to OpenAPI Operations", %{open_api_spec: %OpenApi{} = api_spec} do assert map_size(api_spec.paths) == 13