lib/ash_json_api/json_schema/open_api.ex

if Code.ensure_loaded?(OpenApiSpex) do
  defmodule AshJsonApi.OpenApi do
    @moduledoc """
    Provides functions for generating schemas and operations for OpenApi definitions.

    Add `open_api_spex` to your `mix.exs` deps for the required struct definitions.

    ## Example

        defmodule MyApp.OpenApi do
          alias OpenApiSpex.{OpenApi, Info, Server, Components}

          def spec do
            %OpenApi{
              info: %Info{
                title: "MyApp JSON API",
                version: "1.1"
              },
              servers: [
                Server.from_endpoint(MyAppWeb.Endpoint)
              ],
              paths: AshJsonApi.OpenApi.paths(MyApp.Api),
              tags: AshJsonApi.OpenApi.tags(MyApp.Api),
              components: %Components{
                responses: AshJsonApi.OpenApi.responses(),
                schemas: AshJsonApi.OpenApi.schemas(MyApp.Api)
              }
            }
          end
        end
    """
    alias Ash.Query.Aggregate
    alias AshJsonApi.Resource.Route
    alias Ash.Resource.{Actions, Relationships}

    alias OpenApiSpex.{
      MediaType,
      Operation,
      Parameter,
      PathItem,
      Paths,
      Reference,
      RequestBody,
      Response,
      Schema,
      Tag
    }

    @dialyzer {:nowarn_function, {:action_description, 2}}
    @dialyzer {:nowarn_function, {:relationship_resource_identifiers, 1}}
    @dialyzer {:nowarn_function, {:resource_object_schema, 1}}

    @doc """
    Common responses to include in the API Spec.
    """
    @spec responses() :: OpenApiSpex.Components.responses_map()
    def responses do
      %{
        "errors" => %Response{
          description: "General Error",
          content: %{
            "application/vnd.api+json" => %MediaType{
              schema: %Reference{"$ref": "#/components/schemas/errors"}
            }
          }
        }
      }
    end

    @doc """
    Resource schemas to include in the API spec.
    """
    @spec schemas(api :: module | [module]) :: %{String.t() => Schema.t()}
    def schemas(apis) when is_list(apis) do
      apis
      |> Enum.reduce(base_definitions(), fn api, definitions ->
        api
        |> resources
        |> Enum.map(&{AshJsonApi.Resource.Info.type(&1), resource_object_schema(&1)})
        |> Enum.into(definitions)
      end)
    end

    def schemas(api) do
      api
      |> resources
      |> Enum.map(&{AshJsonApi.Resource.Info.type(&1), resource_object_schema(&1)})
      |> Enum.into(base_definitions())
    end

    @spec base_definitions() :: %{String.t() => Schema.t()}
    defp base_definitions do
      %{
        "links" => %Schema{
          type: :object,
          additionalProperties: %Reference{"$ref": "#/components/schemas/link"}
        },
        "link" => %Schema{
          description:
            "A link MUST be represented as either: a string containing the link's URL or a link object.",
          type: :string
        },
        "errors" => %Schema{
          type: :array,
          items: %Reference{
            "$ref": "#/components/schemas/error"
          },
          uniqueItems: true
        },
        "error" => %Schema{
          type: :object,
          properties: %{
            id: %Schema{
              description: "A unique identifier for this particular occurrence of the problem.",
              type: :string
            },
            links: %Reference{
              "$ref": "#/components/schemas/links"
            },
            status: %Schema{
              description:
                "The HTTP status code applicable to this problem, expressed as a string value.",
              type: :string
            },
            code: %Schema{
              description: "An application-specific error code, expressed as a string value.",
              type: :string
            },
            title: %Schema{
              description:
                "A short, human-readable summary of the problem. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization.",
              type: :string
            },
            detail: %Schema{
              description:
                "A human-readable explanation specific to this occurrence of the problem.",
              type: :string
            },
            source: %Schema{
              type: :object,
              properties: %{
                pointer: %Schema{
                  description:
                    "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].",
                  type: :string
                },
                parameter: %Schema{
                  description: "A string indicating which query parameter caused the error.",
                  type: :string
                }
              }
            }
            # "meta" => %{
            #   "$ref" => "#/definitions/meta"
            # }
          },
          additionalProperties: false
        }
      }
    end

    defp resources(api) do
      api
      |> Ash.Api.Info.resources()
      |> Enum.filter(&AshJsonApi.Resource.Info.type(&1))
    end

    defp resource_object_schema(resource, fields \\ nil) do
      %Schema{
        description:
          "A \"Resource object\" representing a #{AshJsonApi.Resource.Info.type(resource)}",
        type: :object,
        required: [:type, :id],
        properties: %{
          type: %Schema{type: :string},
          id: %{type: :string},
          attributes: attributes(resource, fields),
          relationships: relationships(resource)
          # "meta" => %{
          #   "$ref" => "#/definitions/meta"
          # }
        },
        additionalProperties: false
      }
    end

    @spec attributes(resource :: Ash.Resource.t(), fields :: nil | list(atom)) :: Schema.t()
    defp attributes(resource, fields) do
      fields =
        fields || AshJsonApi.Resource.Info.default_fields(resource) ||
          Enum.map(Ash.Resource.Info.public_attributes(resource), & &1.name)

      %Schema{
        description: "An attributes object for a #{AshJsonApi.Resource.Info.type(resource)}",
        type: :object,
        properties: resource_attributes(resource, fields),
        additionalProperties: false
      }
    end

    @spec resource_attributes(resource :: module, fields :: nil | list(atom)) :: %{
            atom => Schema.t()
          }
    defp resource_attributes(resource, fields) do
      resource
      |> Ash.Resource.Info.public_attributes()
      |> Enum.concat(Ash.Resource.Info.public_calculations(resource))
      |> Enum.concat(Ash.Resource.Info.public_aggregates(resource))
      |> Enum.map(fn
        %Ash.Resource.Aggregate{} = agg ->
          field =
            if agg.field do
              related = Ash.Resource.Info.related(resource, agg.relationship_path)
              Ash.Resource.Info.field(related, agg.field)
            end

          field_type =
            if field do
              field.type
            end

          field_constraints =
            if field do
              field.constraints
            end

          {:ok, type, constraints} =
            Aggregate.kind_to_type(agg.kind, field_type, field_constraints)

          type = Ash.Type.get_type(type)

          allow_nil? =
            is_nil(Ash.Query.Aggregate.default_value(agg.kind))

          %{
            name: agg.name,
            description: agg.description,
            type: type,
            constraints: constraints,
            allow_nil?: allow_nil?
          }

        other ->
          other
      end)
      |> Enum.reject(&AshJsonApi.Resource.only_primary_key?(resource, &1.name))
      |> Map.new(fn attr ->
        {attr.name,
         resource_attribute_type(attr)
         |> with_attribute_description(attr)
         |> with_attribute_nullability(attr)
         |> with_comment_on_included(attr, fields)}
      end)
    end

    defp with_comment_on_included(schema, attr, fields) do
      new_description =
        if attr.name in fields do
          case schema.description do
            nil ->
              "Field included by default."

            description ->
              if String.ends_with?(description, ["!", "."]) do
                description <> " Field included by default."
              else
                description <> ". Field included by default."
              end
          end
        else
          schema.description
        end

      %Schema{schema | description: new_description}
    end

    defp with_attribute_nullability(schema, attr) do
      if attr.allow_nil? do
        %Schema{
          anyOf: [%{schema | description: nil}, %Schema{type: :null}],
          description: schema.description
        }
      else
        schema
      end
    end

    @spec resource_attribute_type(Ash.Resource.Attribute.t() | Ash.Resource.Actions.Argument.t()) ::
            Schema.t()
    defp resource_attribute_type(%{type: Ash.Type.String}) do
      %Schema{type: :string}
    end

    defp resource_attribute_type(%{type: Ash.Type.Boolean}) do
      %Schema{type: :boolean}
    end

    defp resource_attribute_type(%{type: Ash.Type.Integer}) do
      %Schema{type: :integer}
    end

    defp resource_attribute_type(%{type: Ash.Type.Float}) do
      %Schema{type: :number, format: :float}
    end

    defp resource_attribute_type(%{type: Ash.Type.UtcDatetime}) do
      %Schema{
        type: :string,
        format: "date-time"
      }
    end

    defp resource_attribute_type(%{type: Ash.Type.UUID}) do
      %Schema{
        type: :string,
        format: "uuid"
      }
    end

    defp resource_attribute_type(%{type: {:array, type}} = attr) do
      %Schema{
        type: :array,
        items:
          resource_attribute_type(%{
            attr
            | type: type,
              constraints: attr.constraints[:items] || []
          })
      }
    end

    defp resource_attribute_type(%{type: type} = attr) do
      if :erlang.function_exported(type, :json_schema, 1) do
        if Map.get(attr, :constraints) do
          type.json_schema(attr.constraints)
        else
          type.json_schema([])
        end
      else
        %Schema{
          type: :object,
          additionalProperties: true
        }
      end
    end

    @spec with_attribute_description(
            Schema.t(),
            Ash.Resource.Attribute.t() | Ash.Resource.Actions.Argument.t()
          ) :: Schema.t()
    defp with_attribute_description(schema, %{description: nil}) do
      schema
    end

    defp with_attribute_description(schema, %{description: description}) do
      Map.merge(schema, %{description: description})
    end

    @spec relationships(resource :: Ash.Resource.t()) :: Schema.t()
    defp relationships(resource) do
      %Schema{
        description: "A relationships object for a #{AshJsonApi.Resource.Info.type(resource)}",
        type: :object,
        properties: resource_relationships(resource),
        additionalProperties: false
      }
    end

    @spec resource_relationships(resource :: module) :: %{atom => Schema.t()}
    defp resource_relationships(resource) do
      resource
      |> Ash.Resource.Info.public_relationships()
      |> Enum.filter(fn %{destination: relationship} ->
        AshJsonApi.Resource.Info.type(relationship)
      end)
      |> Map.new(fn rel ->
        data = resource_relationship_field_data(resource, rel)
        links = resource_relationship_link_data(resource, rel)

        object =
          if links do
            %Schema{properties: %{data: data, links: links}}
          else
            %Schema{properties: %{data: data}}
          end

        {rel.name, object}
      end)
    end

    defp resource_relationship_link_data(_resource, _rel) do
      nil
    end

    @spec resource_relationship_field_data(
            resource :: module,
            Relationships.relationship()
          ) :: Schema.t()
    defp resource_relationship_field_data(_resource, %{
           type: {:array, _},
           name: name
         }) do
      %Schema{
        description: "Identifiers for #{name}",
        type: :object,
        nullable: true,
        required: [:type, :id],
        additionalProperties: false,
        properties: %{
          type: %Schema{type: :string},
          id: %Schema{type: :string},
          meta: %Schema{
            type: :object,
            additionalProperties: true
          }
        }
      }
    end

    defp resource_relationship_field_data(_resource, %{
           name: name
         }) do
      %Schema{
        description: "An array of inputs for #{name}",
        type: :array,
        items: %{
          description: "Resource identifiers for #{name}",
          type: :object,
          required: [:type, :id],
          properties: %{
            type: %Schema{type: :string},
            id: %Schema{type: :string},
            meta: %Schema{
              type: :object,
              additionalProperties: true
            }
          }
        },
        uniqueItems: true
      }
    end

    @doc """
    Tags based on resource names to include in the API spec
    """
    @spec tags(api :: module | [module]) :: [Tag.t()]
    def tags(apis) when is_list(apis) do
      Enum.flat_map(apis, &tags/1)
    end

    def tags(api) do
      tag = AshJsonApi.Api.Info.tag(api)
      group_by = AshJsonApi.Api.Info.group_by(api)

      if tag && group_by == :api do
        [
          %Tag{
            name: to_string(tag),
            description: "Operations on the #{tag} Api."
          }
        ]
      else
        api
        |> resources()
        |> Enum.map(fn resource ->
          name = AshJsonApi.Resource.Info.type(resource)

          %Tag{
            name: to_string(name),
            description: "Operations on the #{name} resource."
          }
        end)
      end
    end

    @doc """
    Paths (routes) from the API.
    """
    @spec paths(api :: module | [module]) :: Paths.t()
    def paths(apis) when is_list(apis) do
      apis
      |> Enum.map(&paths/1)
      |> Enum.reduce(&Map.merge/2)
    end

    def paths(api) do
      api
      |> resources()
      |> Enum.flat_map(fn resource ->
        resource
        |> AshJsonApi.Resource.Info.routes()
        |> Enum.map(&route_operation(&1, api, resource))
      end)
      |> Enum.group_by(fn {path, _route_op} -> path end, fn {_path, route_op} -> route_op end)
      |> Map.new(fn {path, route_ops} -> {path, struct!(PathItem, route_ops)} end)
    end

    @spec route_operation(Route.t(), api :: module, resource :: module) ::
            {Paths.path(), {verb :: atom, Operation.t()}}
    defp route_operation(route, api, resource) do
      tag = AshJsonApi.Api.Info.tag(api)
      group_by = AshJsonApi.Api.Info.group_by(api)

      {path, path_params} = AshJsonApi.JsonSchema.route_href(route, api)
      operation = operation(route, resource, path_params)

      operation =
        if tag && group_by === :api do
          Map.merge(operation, %{tags: [to_string(tag)]})
        else
          operation
        end

      {path, {route.method, operation}}
    end

    @spec operation(Route.t(), resource :: module, path_params :: [String.t()]) ::
            Operation.t()
    defp operation(route, resource, path_params) do
      unless path_params == [] or path_params == ["id"] do
        raise "Haven't figured out more complex route parameters yet."
      end

      action = Ash.Resource.Info.action(resource, route.action)

      %Operation{
        description: action_description(action, resource),
        tags: [to_string(AshJsonApi.Resource.Info.type(resource))],
        parameters: path_parameters(path_params, action) ++ query_parameters(route, resource),
        responses: %{
          :default => %Reference{
            "$ref": "#/components/responses/errors"
          },
          200 => response_body(route, resource)
        },
        requestBody: request_body(route, resource)
      }
    end

    defp action_description(action, resource) do
      action.description ||
        "#{action.name} operation on #{AshJsonApi.Resource.Info.type(resource)} resource"
    end

    @spec path_parameters(path_params :: [String.t()], action :: Actions.action()) ::
            [Parameter.t()]
    defp path_parameters(path_params, action) do
      Enum.map(path_params, fn param ->
        description =
          action.arguments
          |> Enum.find(&(to_string(&1.name) == param))
          |> case do
            %{description: description} when is_binary(description) -> description
            _ -> nil
          end

        %Parameter{
          name: param,
          description: description,
          in: :path,
          required: true,
          schema: %Schema{type: :string}
        }
      end)
    end

    @spec query_parameters(
            Route.t(),
            resource :: module
          ) :: [Parameter.t()]
    defp query_parameters(%{type: :index} = route, resource) do
      [
        filter_parameter(resource),
        sort_parameter(resource),
        page_parameter(),
        include_parameter(),
        fields_parameter(resource)
      ] ++
        read_argument_parameters(route, resource)
    end

    defp query_parameters(%{type: type}, _resource)
         when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] do
      []
    end

    defp query_parameters(%{type: type} = route, resource) when type in [:get, :related] do
      [include_parameter(), fields_parameter(resource)] ++
        read_argument_parameters(route, resource)
    end

    defp query_parameters(_route, resource) do
      [include_parameter(), fields_parameter(resource)]
    end

    @spec filter_parameter(resource :: module) :: Parameter.t()
    defp filter_parameter(resource) do
      %Parameter{
        name: :filter,
        in: :query,
        description:
          "Filters the query to results with attributes matching the given filter object",
        required: false,
        style: :deepObject,
        schema: filter_schema(resource)
      }
    end

    @spec sort_parameter(resource :: module) :: Parameter.t()
    defp sort_parameter(resource) do
      sorts =
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Enum.concat(
          Ash.Resource.Info.public_calculations(resource)
          |> Enum.filter(&Ash.Resource.Info.sortable?(resource, &1))
        )
        |> Enum.flat_map(fn attr -> [to_string(attr.name), "-#{attr.name}"] end)

      %Parameter{
        name: :sort,
        in: :query,
        description: "Sort order to apply to the results",
        required: false,
        style: :form,
        explode: false,
        schema: %Schema{
          type: :array,
          items: %Schema{
            type: :string,
            enum: sorts
          }
        }
      }
    end

    @spec page_parameter() :: Parameter.t()
    defp page_parameter do
      %Parameter{
        name: :page,
        in: :query,
        description: "Paginates the response with the limit and offset",
        required: false,
        style: :deepObject,
        schema: %Schema{
          type: :object,
          properties: %{
            limit: %Schema{type: :integer, minimum: 1},
            offset: %Schema{type: :integer, minimum: 0}
          }
        }
      }
    end

    @spec include_parameter() :: Parameter.t()
    defp include_parameter do
      %Parameter{
        name: :include,
        in: :query,
        required: false,
        description: "Relationship paths to include in the response",
        style: :form,
        explode: false,
        schema: %Schema{
          type: :array,
          items: %Schema{
            type: :string,
            pattern: ~r/^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$/
          }
        }
      }
    end

    @spec fields_parameter(resource :: module) :: Parameter.t()
    defp fields_parameter(resource) do
      type = AshJsonApi.Resource.Info.type(resource)

      %Parameter{
        name: :fields,
        in: :query,
        description: "Limits the response fields to only those listed for each type",
        required: false,
        style: :deepObject,
        schema: %Schema{
          type: :object,
          additionalProperties: true,
          properties: %{
            # There is a static set of types (one per resource)
            # so this is safe.
            #
            # sobelow_skip ["DOS.StringToAtom"]
            String.to_atom(type) => %Schema{
              description: "Comma separated field names for #{type}",
              type: :string,
              example:
                Ash.Resource.Info.public_attributes(resource)
                |> Enum.map_join(",", & &1.name)
            }
          }
        }
      }
    end

    @spec read_argument_parameters(Route.t(), resource :: module) :: [Parameter.t()]
    defp read_argument_parameters(route, resource) do
      action = Ash.Resource.Info.action(resource, route.action)

      action.arguments
      |> Enum.reject(& &1.private?)
      |> Enum.map(fn argument ->
        schema = resource_attribute_type(argument)

        %Parameter{
          name: argument.name,
          in: :query,
          description: argument.description || to_string(argument.name),
          required: !argument.allow_nil?,
          style:
            case schema.type do
              :object -> :deepObject
              _ -> :form
            end,
          schema: schema
        }
      end)
    end

    @spec filter_schema(resource :: module) :: Schema.t()
    defp filter_schema(resource) do
      props =
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Map.new(fn attr ->
          {attr.name,
           attribute_filter_schema(attr.type)
           |> with_attribute_description(attr)}
        end)

      props =
        resource
        |> Ash.Resource.Info.public_relationships()
        |> Enum.map(fn rel ->
          {rel.name, relationship_filter_schema(rel)}
        end)
        |> Enum.into(props)

      props =
        resource
        |> Ash.Resource.Info.public_aggregates()
        |> Enum.map(fn agg ->
          field =
            if agg.field do
              related = Ash.Resource.Info.related(resource, agg.relationship_path)
              Ash.Resource.Info.field(related, agg.field)
            end

          field_type =
            if field do
              field.type
            end

          field_constraints =
            if field do
              field.constraints
            end

          {:ok, type, _constraints} =
            Aggregate.kind_to_type(agg.kind, field_type, field_constraints)

          type = Ash.Type.get_type(type)

          {agg.name, attribute_filter_schema(type)}
        end)
        |> Enum.into(props)

      %Schema{
        type: :object,
        properties: props
      }
    end

    @spec relationship_filter_schema(relationship :: Relationships.relationship()) :: Schema.t()
    defp relationship_filter_schema(_rel) do
      %Schema{type: :object, additionalProperties: true}
    end

    @spec attribute_filter_schema(type :: module) :: Schema.t()
    defp attribute_filter_schema(_type) do
      %Schema{
        anyOf: [
          %Schema{
            type: :object,
            additionalProperties: true
          },
          %Schema{
            type: :string
          }
        ]
      }
    end

    @spec request_body(Route.t(), resource :: module) :: nil | RequestBody.t()
    defp request_body(%{method: method}, _resource)
         when method in [:get, :delete] do
      nil
    end

    defp request_body(route, resource) do
      body_schema = request_body_schema(route, resource)

      body_required =
        body_schema.properties.data.properties.attributes.required != [] ||
          body_schema.properties.data.properties.relationships.required != []

      %RequestBody{
        description:
          "Request body for #{route.action} operation on #{AshJsonApi.Resource.Info.type(resource)} resource",
        required: body_required,
        content: %{
          "application/vnd.api+json" => %MediaType{schema: body_schema}
        }
      }
    end

    @spec request_body_schema(Route.t(), resource :: module) :: Schema.t()
    defp request_body_schema(
           %{
             type: :post,
             action: action,
             relationship_arguments: relationship_arguments
           },
           resource
         ) do
      action = Ash.Resource.Info.action(resource, action)

      non_relationship_arguments =
        Enum.reject(
          action.arguments,
          &has_relationship_argument?(relationship_arguments, &1.name)
        )

      %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %Schema{
            type: :object,
            additionalProperties: false,
            properties: %{
              type: %Schema{
                enum: [AshJsonApi.Resource.Info.type(resource)]
              },
              attributes: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_attributes(resource, non_relationship_arguments, action.accept),
                required:
                  required_write_attributes(resource, non_relationship_arguments, action.accept)
              },
              relationships: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_relationships(resource, relationship_arguments, action),
                required:
                  required_relationship_attributes(resource, relationship_arguments, action)
              }
            }
          }
        }
      }
    end

    defp request_body_schema(
           %{
             type: :patch,
             action: action,
             relationship_arguments: relationship_arguments
           },
           resource
         ) do
      action = Ash.Resource.Info.action(resource, action)

      non_relationship_arguments =
        Enum.reject(
          action.arguments,
          &has_relationship_argument?(relationship_arguments, &1.name)
        )

      %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %Schema{
            type: :object,
            additionalProperties: false,
            required: [:id],
            properties: %{
              id: %Schema{
                type: :string
              },
              type: %Schema{
                enum: [AshJsonApi.Resource.Info.type(resource)]
              },
              attributes: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_attributes(resource, non_relationship_arguments, action.accept),
                required:
                  non_relationship_arguments
                  |> Enum.reject(& &1.allow_nil?)
                  |> Enum.map(&to_string(&1.name))
              },
              relationships: %Schema{
                type: :object,
                additionalProperties: false,
                properties: write_relationships(resource, relationship_arguments, action),
                required:
                  required_relationship_attributes(resource, relationship_arguments, action)
              }
            }
          }
        }
      }
    end

    defp request_body_schema(
           %{type: type, relationship: relationship},
           resource
         )
         when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] do
      resource
      |> Ash.Resource.Info.public_relationship(relationship)
      |> relationship_resource_identifiers()
    end

    @spec required_write_attributes(
            resource :: module,
            [Ash.Resource.Actions.Argument.t()],
            accept :: [atom()]
          ) :: [atom()]
    defp required_write_attributes(resource, arguments, accept) do
      attributes =
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Enum.filter(&((is_nil(accept) || &1.name in accept) && &1.writable?))
        |> Enum.reject(&(&1.allow_nil? || &1.default || &1.generated?))
        |> Enum.map(& &1.name)

      arguments =
        arguments
        |> Enum.reject(& &1.allow_nil?)
        |> Enum.map(& &1.name)

      attributes ++ arguments
    end

    @spec write_attributes(
            resource :: module,
            [Ash.Resource.Actions.Argument.t()],
            accept :: [atom()]
          ) :: %{atom => Schema.t()}
    defp write_attributes(resource, arguments, accept) do
      attributes =
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Enum.filter(&((is_nil(accept) || &1.name in accept) && &1.writable?))
        |> Map.new(fn attribute ->
          {attribute.name, resource_attribute_type(attribute)}
        end)

      Enum.reduce(arguments, attributes, fn argument, attributes ->
        Map.put(attributes, argument.name, resource_attribute_type(argument))
      end)
    end

    @spec required_relationship_attributes(
            resource :: module,
            [Actions.Argument.t()],
            Actions.action()
          ) :: [atom()]
    defp required_relationship_attributes(_resource, relationship_arguments, action) do
      action.arguments
      |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
      |> Enum.reject(& &1.allow_nil?)
      |> Enum.map(& &1.name)
    end

    @spec write_relationships(resource :: module, [Actions.Argument.t()], Actions.action()) ::
            %{atom() => Schema.t()}
    defp write_relationships(resource, relationship_arguments, action) do
      action.arguments
      |> Enum.filter(&has_relationship_argument?(relationship_arguments, &1.name))
      |> Map.new(fn argument ->
        data = resource_relationship_field_data(resource, argument)

        schema = %Schema{
          type: :object,
          properties: %{
            data: data,
            links: %Schema{type: :object, additionalProperties: true}
          }
        }

        {argument.name, schema}
      end)
    end

    @spec has_relationship_argument?(relationship_arguments :: list, name :: atom) :: boolean()
    defp has_relationship_argument?(relationship_arguments, name) do
      Enum.any?(relationship_arguments, fn
        {:id, ^name} -> true
        ^name -> true
        _ -> false
      end)
    end

    @spec response_body(Route.t(), resource :: module) :: Response.t()
    defp response_body(%{method: :delete}, _resource) do
      %Response{
        description: "Deleted successfully"
      }
    end

    defp response_body(route, resource) do
      %Response{
        description: "Success",
        content: %{
          "application/vnd.api+json" => %MediaType{
            schema: response_schema(route, resource)
          }
        }
      }
    end

    @spec response_schema(Route.t(), resource :: module) :: Schema.t()
    defp response_schema(route, resource) do
      case route.type do
        :index ->
          %Schema{
            type: :object,
            properties: %{
              data: %Schema{
                description:
                  "An array of resource objects representing a #{AshJsonApi.Resource.Info.type(resource)}",
                type: :array,
                items: item_reference(route, resource),
                uniqueItems: true
              },
              included: included_resource_schemas(resource)
            }
          }

        :delete ->
          nil

        type
        when type in [:post_to_relationship, :patch_relationship, :delete_from_relationship] ->
          resource
          |> Ash.Resource.Info.public_relationship(route.relationship)
          |> relationship_resource_identifiers()

        _ ->
          %Schema{
            properties: %{
              data: item_reference(route, resource),
              included: included_resource_schemas(resource)
            }
          }
      end
    end

    defp item_reference(%{default_fields: nil}, resource) do
      %Reference{
        "$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}"
      }
    end

    defp item_reference(%{default_fields: default_fields}, resource) do
      resource_object_schema(resource, default_fields)
    end

    @spec relationship_resource_identifiers(relationship :: Relationships.relationship()) ::
            Schema.t()
    defp relationship_resource_identifiers(relationship) do
      %Schema{
        type: :object,
        required: [:data],
        additionalProperties: false,
        properties: %{
          data: %{
            type: :array,
            items: %{
              type: :object,
              required: [:id, :type],
              additionalProperties: false,
              properties: %{
                id: %Schema{
                  type: :string
                },
                type: %Schema{
                  enum: [AshJsonApi.Resource.Info.type(relationship.destination)]
                },
                meta: %Schema{
                  type: :object,
                  additionalProperties: true
                }
              }
            }
          }
        }
      }
    end

    defp included_resource_schemas(resource) do
      includes = AshJsonApi.Resource.Info.includes(resource)
      include_resources = includes_to_resources(resource, includes)

      include_schemas =
        include_resources
        |> Enum.filter(&AshJsonApi.Resource.Info.type(&1))
        |> Enum.map(fn resource ->
          %Reference{"$ref": "#/components/schemas/#{AshJsonApi.Resource.Info.type(resource)}"}
        end)

      %Schema{
        type: :array,
        uniqueItems: true,
        items: %Schema{
          oneOf: include_schemas
        }
      }
    end

    defp includes_to_resources(nil, _), do: []

    defp includes_to_resources(resource, includes) when is_list(includes) do
      includes
      |> Enum.flat_map(fn
        {include, []} ->
          relationship_destination(resource, include) |> List.wrap()

        {include, includes} ->
          case relationship_destination(resource, include) do
            nil ->
              []

            resource ->
              [resource | includes_to_resources(resource, includes)]
          end

        include ->
          relationship_destination(resource, include) |> List.wrap()
      end)
      |> Enum.uniq()
    end

    defp includes_to_resources(resource, include),
      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
      end
    end
  end
end