diff --git a/lib/jsonapi_plug.ex b/lib/jsonapi_plug.ex index 894fb47..2af8099 100644 --- a/lib/jsonapi_plug.ex +++ b/lib/jsonapi_plug.ex @@ -200,6 +200,16 @@ defmodule JSONAPIPlug do doc: "Controls wether the attribute is deserialized in requests.", type: :boolean, default: true + ], + required: [ + doc: "Attribute is required.", + type: :boolean, + default: false + ], + type: [ + doc: "Attribute type", + type: {:in, [:string, :number]}, + default: :string ] ] @@ -225,12 +235,8 @@ defmodule JSONAPIPlug do doc: "Resource attributes. This will be used to (de)serialize requests/responses:\n\n" <> NimbleOptions.docs(@attribute_schema, nest_level: 1), - type: - {:or, - [ - {:list, :atom}, - {:keyword_list, [*: [type: [keyword_list: [keys: @attribute_schema]]]]} - ]}, + type: :keyword_list, + keys: [*: [type: :keyword_list, keys: @attribute_schema]], default: [] ], id_attribute: [ diff --git a/lib/jsonapi_plug/document.ex b/lib/jsonapi_plug/document.ex index 2139ff8..1df3afe 100644 --- a/lib/jsonapi_plug/document.ex +++ b/lib/jsonapi_plug/document.ex @@ -8,14 +8,9 @@ defmodule JSONAPIPlug.Document do https://jsonapi.org/format/#document-structure """ - alias JSONAPIPlug.{ - Document.ErrorObject, - Document.JSONAPIObject, - Document.LinkObject, - Document.ResourceObject, - Exceptions.InvalidDocument, - Resource - } + alias JSONAPIPlug.Document.{ErrorObject, JSONAPIObject, LinkObject, ResourceObject} + alias JSONAPIPlug.Exceptions.InvalidDocument + alias JSONAPIPlug.Resource @type value :: String.t() | integer() | float() | [value()] | %{String.t() => value()} | nil @@ -99,7 +94,12 @@ defmodule JSONAPIPlug.Document do defp deserialize_data(%{"data" => _data, "errors" => _errors}) do raise InvalidDocument, message: "Document cannot contain both 'data' and 'errors' members", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document cannot contain both 'data' and 'errors' members", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp deserialize_data(%{"data" => resources}) when is_list(resources), @@ -124,14 +124,24 @@ defmodule JSONAPIPlug.Document do when is_list(included) do raise InvalidDocument, message: "Document 'included' cannot be present if 'data' isn't also present", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document 'included' cannot be present if 'data' isn't also present", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp deserialize_included(%{"included" => included}) when not is_nil(included) do raise InvalidDocument, message: "Document 'included' must be a list", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document 'included' must be a list", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp deserialize_included(_data), do: nil @@ -152,7 +162,12 @@ defmodule JSONAPIPlug.Document do defp deserialize_meta(%{"meta" => meta}) when not is_nil(meta) do raise InvalidDocument, message: "Document 'meta' must be an object", - reference: "https://jsonapi.org/format/#document-meta" + errors: [ + %ErrorObject{ + title: "Document 'meta' must be an object", + detail: "https://jsonapi.org/format/#document-meta" + } + ] end defp deserialize_meta(_data), do: nil @@ -183,11 +198,15 @@ defmodule JSONAPIPlug.Document do defp serialize_data(nil), do: nil - defp serialize_errors(errors) - when not is_nil(errors) and not is_list(errors) do + defp serialize_errors(errors) when not is_nil(errors) and not is_list(errors) do raise InvalidDocument, message: "Document 'errors' must be a list", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document 'errors' must be a list", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp serialize_errors(errors) when is_list(errors), @@ -199,7 +218,12 @@ defmodule JSONAPIPlug.Document do when not is_nil(included) and not is_list(included) do raise InvalidDocument, message: "Document 'included' must be a list resource objects", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document 'included' must be a list resource objects", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp serialize_included(included) when is_list(included), @@ -210,7 +234,12 @@ defmodule JSONAPIPlug.Document do defp serialize_meta(meta) when not is_nil(meta) and not is_map(meta) do raise InvalidDocument, message: "Document 'meta' must be a map", - reference: "https://jsonapi.org/format/#document-top-level" + errors: [ + %ErrorObject{ + title: "Document 'meta' must be a map", + detail: "https://jsonapi.org/format/#document-top-level" + } + ] end defp serialize_meta(meta), do: meta diff --git a/lib/jsonapi_plug/document/jsonapi_object.ex b/lib/jsonapi_plug/document/jsonapi_object.ex index fd78974..464522c 100644 --- a/lib/jsonapi_plug/document/jsonapi_object.ex +++ b/lib/jsonapi_plug/document/jsonapi_object.ex @@ -3,7 +3,9 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do JSON:API Document JSON:API Object """ - alias JSONAPIPlug.{Document, Exceptions.InvalidDocument} + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.ErrorObject + alias JSONAPIPlug.Exceptions.InvalidDocument @type version :: :"1.0" | :"1.1" @@ -19,8 +21,14 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do defp deserialize_meta(%{"meta" => _meta}) do raise InvalidDocument, - message: "JSON:API object 'meta' must be an object", - reference: "https://jsonapi.org/format/#document-jsonapi-object" + message: "JSON:API Object 'meta' must be an object", + errors: [ + %ErrorObject{ + title: "JSON:API object 'meta' must be an object", + detail: "https://jsonapi.org/format/#document-jsonapi-object", + source: %{pointer: "/jsonapi/meta"} + } + ] end defp deserialize_meta(_data), do: nil @@ -30,7 +38,13 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do defp deserialize_version(%{"version" => version}) do raise InvalidDocument, message: "JSON:API Object has invalid version (#{version})", - reference: "https://jsonapi.org/format/#document-jsonapi-object" + errors: [ + %ErrorObject{ + title: "JSON:API Object has invalid version (#{version})", + detail: "https://jsonapi.org/format/#document-jsonapi-object", + source: %{pointer: "/jsonapi/version"} + } + ] end defp deserialize_version(_data), do: nil diff --git a/lib/jsonapi_plug/document/relationship_object.ex b/lib/jsonapi_plug/document/relationship_object.ex index a477a61..89849a0 100644 --- a/lib/jsonapi_plug/document/relationship_object.ex +++ b/lib/jsonapi_plug/document/relationship_object.ex @@ -5,12 +5,9 @@ defmodule JSONAPIPlug.Document.RelationshipObject do https://jsonapi.org/format/#document-resource-object-relationships """ - alias JSONAPIPlug.{ - Document, - Document.LinkObject, - Document.ResourceIdentifierObject, - Exceptions.InvalidDocument - } + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.{ErrorObject, LinkObject, ResourceIdentifierObject} + alias JSONAPIPlug.Exceptions.InvalidDocument @type links :: %{atom() => LinkObject.t()} @@ -54,8 +51,14 @@ defmodule JSONAPIPlug.Document.RelationshipObject do defp deserialize_meta(%{"meta" => _meta}) do raise InvalidDocument, - message: "Relationship object 'meta' must be an object", - reference: "https://jsonapi.org/format/#document-resource-object-relationships" + message: "Relationship Object 'meta' must be an object", + errors: [ + %ErrorObject{ + title: "Relationship Object 'meta' must be an object", + detail: "https://jsonapi.org/format/#document-resource-object-relationships", + source: %{pointer: "/meta"} + } + ] end defp deserialize_meta(_data), do: nil diff --git a/lib/jsonapi_plug/document/resource_identifier_object.ex b/lib/jsonapi_plug/document/resource_identifier_object.ex index ea86a00..094485f 100644 --- a/lib/jsonapi_plug/document/resource_identifier_object.ex +++ b/lib/jsonapi_plug/document/resource_identifier_object.ex @@ -5,7 +5,9 @@ defmodule JSONAPIPlug.Document.ResourceIdentifierObject do https://jsonapi.org/format/#document-resource-object-linkage """ - alias JSONAPIPlug.{Document, Document.ResourceObject, Exceptions.InvalidDocument} + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.{ErrorObject, ResourceObject} + alias JSONAPIPlug.Exceptions.InvalidDocument @type t :: %__MODULE__{ id: ResourceObject.id() | nil, @@ -36,7 +38,12 @@ defmodule JSONAPIPlug.Document.ResourceIdentifierObject do defp deserialize_meta(%{"meta" => _meta}) do raise InvalidDocument, message: "Resource Identifier object 'meta' must be an object", - reference: "https://jsonapi.org/format/#document-resource-identifier-objects" + errors: [ + %ErrorObject{ + title: "Resource Identifier object 'meta' must be an object", + detail: "https://jsonapi.org/format/#document-resource-identifier-objects" + } + ] end defp deserialize_meta(_payload), do: nil @@ -45,7 +52,12 @@ defmodule JSONAPIPlug.Document.ResourceIdentifierObject do defp deserialize_type(type) do raise InvalidDocument, - message: "Resource Identifier object type (#{type}) is invalid", - reference: "https://jsonapi.org/format/#document-resource-objects" + message: "Resource Identifier object type '#{type}' is invalid", + errors: [ + %ErrorObject{ + title: "Resource Identifier object type '#{type}' is invalid", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end end diff --git a/lib/jsonapi_plug/document/resource_object.ex b/lib/jsonapi_plug/document/resource_object.ex index 2036335..67b768e 100644 --- a/lib/jsonapi_plug/document/resource_object.ex +++ b/lib/jsonapi_plug/document/resource_object.ex @@ -5,21 +5,19 @@ defmodule JSONAPIPlug.Document.ResourceObject do https://jsonapi.org/format/#resource_object-resource-objects """ - alias JSONAPIPlug.{ - Document, - Document.LinkObject, - Document.RelationshipObject, - Exceptions.InvalidDocument - } + alias JSONAPIPlug.Document + alias JSONAPIPlug.Document.{ErrorObject, LinkObject, RelationshipObject} + alias JSONAPIPlug.Exceptions.InvalidDocument @type id :: String.t() @type type :: String.t() + @type attributes :: %{String.t() => Document.value()} @type t :: %__MODULE__{ id: id() | nil, lid: id() | nil, type: type(), - attributes: %{String.t() => Document.value()}, + attributes: attributes() | nil, links: Document.links() | nil, meta: Document.meta() | nil, relationships: %{String.t() => [RelationshipObject.t()]} @@ -56,19 +54,34 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_type(%{"type" => type}) do raise InvalidDocument, message: "Resource object type '#{type}' is invalid", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object type (#{type}) is invalid", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_attributes(%{"attributes" => %{"id" => _id}}) do raise InvalidDocument, message: "Resource object cannot have an attribute named 'id'", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object cannot have an attribute named 'id'", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_attributes(%{"attributes" => %{"type" => _type}}) do raise InvalidDocument, message: "Resource object cannot have an attribute named 'type'", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object cannot have an attribute named 'type'", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_attributes(%{"attributes" => attributes}) @@ -88,13 +101,23 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_relationships(%{"relationships" => %{"id" => _id}}) do raise InvalidDocument, message: "Resource object cannot have a relationship named 'id'", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object cannot have a relationship named 'id'", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_relationships(%{"relationships" => %{"type" => _type}}) do raise InvalidDocument, message: "Resource object cannot have a relationship named 'type'", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object cannot have a relationship named 'type'", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_relationships(%{"relationships" => relationships}) @@ -113,7 +136,12 @@ defmodule JSONAPIPlug.Document.ResourceObject do }) do raise InvalidDocument, message: "Resource object 'relationships' attribute must be an object", - reference: "https://jsonapi.org/format/#document-resource-object-relationships" + errors: [ + %ErrorObject{ + title: "Resource object 'relationships' attribute must be an object", + detail: "https://jsonapi.org/format/#document-resource-object-relationships" + } + ] end defp deserialize_relationships(_data), do: %{} @@ -123,7 +151,12 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_meta(%{"meta" => _meta}) do raise InvalidDocument, message: "Resource object 'meta' must be an object", - reference: "https://jsonapi.org/format/#document-resource-objects" + errors: [ + %ErrorObject{ + title: "Resource object 'meta' must be an object", + detail: "https://jsonapi.org/format/#document-resource-objects" + } + ] end defp deserialize_meta(_data), do: nil diff --git a/lib/jsonapi_plug/exceptions.ex b/lib/jsonapi_plug/exceptions.ex index 9e5761e..a5da608 100644 --- a/lib/jsonapi_plug/exceptions.ex +++ b/lib/jsonapi_plug/exceptions.ex @@ -3,22 +3,40 @@ defmodule JSONAPIPlug.Exceptions do defmodule InvalidDocument do @moduledoc """ - Defines a generic exception for when an invalid document is received. + Exception for when an invalid document is received. """ - defexception message: nil, reference: nil + alias JSONAPIPlug.Document.ErrorObject + defexception message: nil, errors: nil + + @default_error %ErrorObject{ + status: "500", + title: "An error occurred while processing the request.", + detail: "Contact the system administrator for assitance." + } @spec exception(keyword()) :: Exception.t() def exception(options) do - message = Keyword.fetch!(options, :message) - reference = Keyword.fetch!(options, :reference) + message = List.first(options[:errors], @default_error).title + + %__MODULE__{message: message, errors: options[:errors] || [@default_error]} + end + end - %__MODULE__{message: message, reference: reference} + defmodule InvalidAttributes do + @moduledoc """ + Exception for when invalid resource attributes are received. + """ + defexception message: nil, errors: nil + + @spec exception(keyword()) :: Exception.t() + def exception(options) do + %__MODULE__{message: options[:message], errors: options[:errors]} end end defmodule InvalidHeader do @moduledoc """ - Defines a generic exception for when an invalid header is received. + Exception for when an invalid header is received. """ defexception header: nil, message: nil, reference: nil, status: nil @@ -35,7 +53,7 @@ defmodule JSONAPIPlug.Exceptions do defmodule InvalidQuery do @moduledoc """ - Defines a generic exception for when an invalid query parameter is received. + Exception for when an invalid query parameter is received. """ defexception message: "invalid query", type: nil, diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 2324227..92be668 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -31,17 +31,15 @@ defmodule JSONAPIPlug.Normalizer do any point in your normalizer code. """ - alias JSONAPIPlug.{ - Document, - Document.RelationshipObject, - Document.ResourceIdentifierObject, - Document.ResourceObject, - Exceptions.InvalidDocument, - Pagination, - Resource, - Resource.Attribute, - Resource.Links, - Resource.Meta + alias JSONAPIPlug.{Document, Pagination, Resource} + alias JSONAPIPlug.Exceptions.{InvalidAttributes, InvalidDocument} + alias JSONAPIPlug.Resource.{Attribute, Links, Meta, Validator} + + alias JSONAPIPlug.Document.{ + ErrorObject, + RelationshipObject, + ResourceIdentifierObject, + ResourceObject } alias Plug.Conn @@ -65,7 +63,7 @@ defmodule JSONAPIPlug.Normalizer do ) :: params() | no_return() - @doc "Transforms a JSON:API Document user data" + @doc "Transforms a JSON:API Document into user data" @spec denormalize(Document.t(), Resource.t(), Conn.t()) :: Conn.params() | no_return() def denormalize(%Document{data: nil}, _resource, _conn), do: %{} @@ -115,7 +113,12 @@ defmodule JSONAPIPlug.Normalizer do {true, nil} -> raise InvalidDocument, message: "Resource ID not received in request and API requires Client-Generated IDs", - reference: "https://jsonapi.org/format/1.0/#crud-creating-client-ids" + errors: [ + %ErrorObject{ + title: "Resource ID not received in request and API requires Client-Generated IDs", + detail: "https://jsonapi.org/format/1.0/#crud-creating-client-ids" + } + ] {true, id} -> jsonapi_plug.config[:normalizer].denormalize_attribute( @@ -129,21 +132,38 @@ defmodule JSONAPIPlug.Normalizer do {false, _id} -> raise InvalidDocument, - message: "Resource ID received in request and API forbids Client-Generated IDs", - reference: "https://jsonapi.org/format/1.0/#crud-creating-client-ids" + message: "Resource ID not received in request and API forbids Client-Generated IDs", + errors: [ + %ErrorObject{ + title: "Resource ID not received in request and API requires Client-Generated IDs", + detail: "https://jsonapi.org/format/1.0/#crud-creating-client-ids" + } + ] end end - defp denormalize_attributes(params, %ResourceObject{} = resource_object, resource, conn) do - Enum.reduce(Resource.attributes(resource), params, fn attribute, params -> - denormalize_attribute( + defp denormalize_attributes( + params, + %ResourceObject{} = resource_object, + resource, + conn + ) do + params = + Enum.reduce( + Resource.attributes(resource), params, - resource_object, - resource, - attribute, - conn + &denormalize_attribute(&2, resource_object, resource, &1, conn) ) - end) + + case Validator.validate(resource, params, conn) do + :ok -> + params + + {:error, errors} -> + raise InvalidAttributes, + message: "Resource '#{Resource.type(resource)}' is invalid.", + errors: errors + end end defp denormalize_attribute( @@ -224,13 +244,23 @@ defmodule JSONAPIPlug.Normalizer do {true, _related_data} -> raise InvalidDocument, - message: "Single resource for many relationship during normalization", - reference: nil + message: "Invalid value for '#{resource.type()}' relationship '#{name}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{resource.type()}' relationship '#{name}'", + detail: "Relationship '#{name}' is one-to-many but a single value was received." + } + ] {false, %RelationshipObject{data: data}} when is_list(data) -> raise InvalidDocument, - message: "List of resources for one-to-one relationship during normalization", - reference: nil + message: "Invalid value for '#{resource.type()}' relationship '#{name}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{resource.type()}' relationship '#{name}'", + detail: "Relationship '#{name}' is one-to-one but a list was received." + } + ] {false, %RelationshipObject{data: resource_identifier_object} = relationship_object} -> value = @@ -370,13 +400,23 @@ defmodule JSONAPIPlug.Normalizer do case {related_many, related_resource} do {false, related_resources} when is_list(related_resources) -> raise InvalidDocument, - message: "List of resources given to render for one-to-one relationship", - reference: nil + message: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", + detail: "Relationship '#{key}' is one-to-one but a list was received." + } + ] {true, _related_resource} when not is_list(related_resource) -> raise InvalidDocument, - message: "Single resource given to render for many relationship", - reference: nil + message: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", + detail: "Relationship '#{key}' is one-to-many but a single value was received." + } + ] {_related_many, related_resource} -> { @@ -469,6 +509,7 @@ defmodule JSONAPIPlug.Normalizer do related_data = Map.get(resource, relationship) related_loaded? = relationship_loaded?(related_data) related_many = Resource.field_option(resource, relationship, :many) + related_resource = struct(Resource.field_option(resource, relationship, :resource)) case {related_loaded?, related_many, related_data} do {true, true, related_data} when is_list(related_data) -> @@ -479,13 +520,28 @@ defmodule JSONAPIPlug.Normalizer do {true, _related_many, related_data} when is_list(related_data) -> raise InvalidDocument, - message: "List of resources given to render for one-to-one relationship", - reference: nil + message: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", + errors: [ + %ErrorObject{ + title: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", + detail: "Relationship '#{relationship}' is one-to-one but a list was received." + } + ] {true, true, _related_data} -> raise InvalidDocument, - message: "Single resource given to render for many relationship", - reference: nil + message: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", + errors: [ + %ErrorObject{ + title: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", + detail: + "Relationship '#{relationship}' is one-to-many but a single value was received." + } + ] {true, _related_many, _related_data} -> MapSet.put( diff --git a/lib/jsonapi_plug/plug.ex b/lib/jsonapi_plug/plug.ex index 75be5ef..2040670 100644 --- a/lib/jsonapi_plug/plug.ex +++ b/lib/jsonapi_plug/plug.ex @@ -142,33 +142,42 @@ defmodule JSONAPIPlug.Plug do end @impl Plug.ErrorHandler + def handle_errors( + conn, + %{kind: :error, reason: %Exceptions.InvalidAttributes{} = exception, stack: _stack} + ) do + send_errors(conn, :not_acceptable, exception.errors) + end + def handle_errors( conn, %{kind: :error, reason: %Exceptions.InvalidDocument{} = exception, stack: _stack} ) do - send_error(conn, :bad_request, %Document.ErrorObject{ - detail: "#{exception.message}. See #{exception.reference} for more information." - }) + send_errors(conn, :bad_request, exception.errors) end def handle_errors( conn, %{kind: :error, reason: %Exceptions.InvalidHeader{} = exception, stack: _stack} ) do - send_error(conn, exception.status, %Document.ErrorObject{ - detail: "#{exception.message}. See #{exception.reference} for more information.", - source: %{pointer: "/header/#{exception.header}"} - }) + send_errors(conn, exception.status, [ + %Document.ErrorObject{ + detail: "#{exception.message}. See #{exception.reference} for more information.", + source: %{pointer: "/header/#{exception.header}"} + } + ]) end def handle_errors( conn, %{kind: :error, reason: %Exceptions.InvalidQuery{} = exception, stack: _stack} ) do - send_error(conn, :bad_request, %Document.ErrorObject{ - detail: exception.message, - source: %{pointer: "/query/#{exception.param}"} - }) + send_errors(conn, :bad_request, [ + %Document.ErrorObject{ + detail: exception.message, + source: %{pointer: "/query/#{exception.param}"} + } + ]) end def handle_errors(conn, error) do @@ -176,19 +185,10 @@ defmodule JSONAPIPlug.Plug do send_resp(conn, 500, "Something went wrong") end - defp send_error(conn, code, %Document.ErrorObject{} = error) do - status_code = Conn.Status.code(code) - + defp send_errors(conn, code, errors) do conn |> put_resp_content_type(JSONAPIPlug.mime_type()) - |> send_resp( - code, - Jason.encode!(%Document{ - errors: [ - %{error | status: to_string(status_code), title: Conn.Status.reason_phrase(status_code)} - ] - }) - ) + |> send_resp(code, Jason.encode!(%Document{errors: errors})) |> halt() end diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index b41eb65..5e14cae 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -141,6 +141,14 @@ defprotocol JSONAPIPlug.Resource do @spec relationships(t()) :: [field_name()] def relationships(resource) + @doc """ + Resource schema + + Returns an ExJsonSchema used to validate resource attributes. + """ + @spec schema(t()) :: term() + def schema(resource) + @doc """ Resource Type @@ -155,6 +163,13 @@ defimpl JSONAPIPlug.Resource, for: Any do options = options |> Macro.prewalk(&Macro.expand(&1, __CALLER__)) + |> Keyword.update(:attributes, [], fn attributes -> + Enum.map(attributes, fn + {field_name, nil} -> {field_name, []} + {field_name, field_options} -> {field_name, field_options} + field_name -> {field_name, []} + end) + end) |> NimbleOptions.validate!(JSONAPIPlug.resource_options_schema()) attributes = generate_attributes(options) @@ -163,7 +178,8 @@ defimpl JSONAPIPlug.Resource, for: Any do check_fields(attributes, relationships, options) field_option = generate_field_option(options) - recase_field = generate_recase_field(options) + recase_field = generate_recase_field(attributes, relationships) + schema = generate_schema(options) quote do defimpl JSONAPIPlug.Resource, for: unquote(module) do @@ -182,23 +198,18 @@ defimpl JSONAPIPlug.Resource, for: Any do def path(_resource), do: unquote(options[:path]) unquote(recase_field) def relationships(_resource), do: unquote(relationships) + def schema(_resource), do: unquote(schema) def type(_resource), do: unquote(options[:type]) end end end defp generate_attributes(options) do - Enum.map(options[:attributes] || [], fn - {field_name, _field_options} -> field_name - field_name -> field_name - end) + Enum.map(options[:attributes] || [], fn {attribute, _options} -> attribute end) end defp generate_relationships(options) do - Enum.map(options[:relationships] || [], fn - {field_name, _field_options} -> field_name - field_name -> field_name - end) + Enum.map(options[:relationships] || [], fn {relationship, _options} -> relationship end) end defp check_fields(attributes, relationships, options) do @@ -217,11 +228,6 @@ defimpl JSONAPIPlug.Resource, for: Any do defp generate_field_option(options) do Stream.concat(options[:attributes], options[:relationships]) - |> Stream.map(fn - {field_name, nil} -> {field_name, []} - {field_name, field_options} -> {field_name, field_options} - field_name -> {field_name, []} - end) |> Enum.flat_map(fn {field_name, field_options} -> Enum.map(field_options, fn {field_option, value} -> quote do @@ -232,22 +238,42 @@ defimpl JSONAPIPlug.Resource, for: Any do end) end - defp generate_recase_field(options) do - Stream.concat(options[:attributes], options[:relationships]) - |> Stream.map(fn - {field_name, _field_options} -> field_name - field_name -> field_name - end) + defp generate_recase_field(attributes, relationships) do + Stream.concat(attributes, relationships) |> Enum.flat_map(fn field_name -> Enum.map([:camelize, :dasherize, :underscore], fn field_case -> + recased = JSONAPIPlug.recase(field_name, field_case) + quote do def recase_field(_resource, unquote(field_name), unquote(field_case)), - do: unquote(JSONAPIPlug.recase(field_name, field_case)) + do: unquote(recased) + + def recase_field(_resource, unquote(to_string(field_name)), unquote(field_case)), + do: unquote(recased) end end) end) end + def generate_schema(options) do + %{ + "type" => "object", + "required" => + Enum.reduce(options[:attributes], [], fn {attribute, options}, required -> + (options[:required] && [to_string(options[:name] || attribute) | required]) || required + end), + "properties" => + Enum.into(options[:attributes], %{}, fn {attribute, options} -> + { + to_string(options[:name] || attribute), + %{"type" => to_string(options[:type] || :string)} + } + end) + } + |> ExJsonSchema.Schema.resolve() + |> Macro.escape() + end + def id_attribute(_resource), do: :id def attributes(_resource), do: [] def field_name(_resource, field_name), do: field_name @@ -255,5 +281,6 @@ defimpl JSONAPIPlug.Resource, for: Any do def path(_resource), do: nil def recase_field(_resource, field, _case), do: field def relationships(_resource), do: [] + def schema(_resource), do: nil def type(_resource), do: "" end diff --git a/lib/jsonapi_plug/resource/validator.ex b/lib/jsonapi_plug/resource/validator.ex new file mode 100644 index 0000000..7c71296 --- /dev/null +++ b/lib/jsonapi_plug/resource/validator.ex @@ -0,0 +1,50 @@ +defprotocol JSONAPIPlug.Resource.Validator do + @moduledoc """ + Resource Validator + + Implement this protocol to validate a JSON:API resource. + Validation will be performed on resource attributes before normalizig them. + + This example implementation always accepts params regardless of what is received. + + ```elixir + defimpl JSONAPIPlug.Resource.Meta, for: MyApp.Post do + def validate(%@for{} = post, _params, _conn), do: :ok + end + ``` + """ + + alias JSONAPIPlug.Document.{ErrorObject, ResourceObject} + alias JSONAPIPlug.Resource + alias Plug.Conn + + @fallback_to_any true + + @doc """ + Validates the resource + """ + @spec validate(Resource.t(), ResourceObject.attributes(), Conn.t()) :: + :ok | {:error, [ErrorObject.t()]} + def validate(resource, attributes, conn) +end + +defimpl JSONAPIPlug.Resource.Validator, for: Any do + alias ExJsonSchema.Validator + alias JSONAPIPlug.Resource + alias JSONAPIPlug.Resource.Validator.ErrorFormatter + alias Plug.Conn + + def validate(_resource, _attributes, %Conn{method: "PATCH"}), do: :ok + + def validate(resource, attributes, %Conn{ + private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} + }) do + case Validator.validate(Resource.schema(resource), attributes, error_formatter: false) do + :ok -> + :ok + + {:error, errors} -> + {:error, ErrorFormatter.format(errors, resource, jsonapi_plug.config[:case])} + end + end +end diff --git a/lib/jsonapi_plug/resource/validator/error_formatter.ex b/lib/jsonapi_plug/resource/validator/error_formatter.ex new file mode 100644 index 0000000..710aacf --- /dev/null +++ b/lib/jsonapi_plug/resource/validator/error_formatter.ex @@ -0,0 +1,25 @@ +defmodule JSONAPIPlug.Resource.Validator.ErrorFormatter do + @moduledoc false + + alias ExJsonSchema.Validator.Error + alias JSONAPIPlug.Document.ErrorObject + alias JSONAPIPlug.Resource + + def format(errors, resource, case) do + Enum.flat_map(errors, fn %Error{error: error, path: "#" <> path} -> + format_error(error, resource, case, "/data/attributes/" <> path) + end) + end + + defp format_error(%Error.Required{} = error, resource, case, base_path) do + Enum.map(error.missing, fn attribute -> + %ErrorObject{ + title: "Campo obbligatorio", + detail: "Questo campo รจ obbligatorio.", + status: 422, + code: :unprocessable_entity, + source: %{pointer: base_path <> Resource.recase_field(resource, attribute, case)} + } + end) + end +end diff --git a/mix.exs b/mix.exs index 7993a5d..63d25ca 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,7 @@ defmodule JSONAPIPlug.Mixfile do {:credo, "~> 1.0", only: :dev, runtime: false}, {:dialyxir, "~> 1.0", only: :dev, runtime: false}, {:ex_doc, "~> 0.20", only: :dev, runtime: false}, + {:ex_json_schema, "~> 0.10"}, {:jason, "~> 1.0"}, {:nimble_options, "~> 1.0"}, {:plug, "~> 1.0"} diff --git a/mix.lock b/mix.lock index 64dc0ce..2294748 100644 --- a/mix.lock +++ b/mix.lock @@ -5,6 +5,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, diff --git a/test/jsonapi_plug/plug_test.exs b/test/jsonapi_plug/plug_test.exs index 4f68e1c..8759715 100644 --- a/test/jsonapi_plug/plug_test.exs +++ b/test/jsonapi_plug/plug_test.exs @@ -226,7 +226,8 @@ defmodule JSONAPIPlug.PlugTest do "data" => %{ "type" => "user", "attributes" => %{ - "firstName" => "Jerome" + "firstName" => "Jerome", + "lastName" => "Finch" }, "relationships" => %{ "company" => %{ @@ -263,7 +264,8 @@ defmodule JSONAPIPlug.PlugTest do "lid" => "1", "type" => "user", "attributes" => %{ - "firstName" => "Jerome" + "firstName" => "Jerome", + "lastName" => "Finch" }, "relationships" => %{ "company" => %{ diff --git a/test/support/resources.ex b/test/support/resources.ex index aebd5cb..70eeec8 100644 --- a/test/support/resources.ex +++ b/test/support/resources.ex @@ -40,7 +40,7 @@ defmodule JSONAPIPlug.TestSupport.Resources do JSONAPIPlug.Resource, type: "user", attributes: [ - age: nil, + age: [type: :number], first_name: nil, last_name: nil, full_name: [deserialize: false],