From ca907287f224929da842b59604edde29fa0ef189 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Tue, 6 Feb 2024 21:12:53 +0100 Subject: [PATCH 01/13] Explore validation with JSON Schema --- lib/jsonapi_plug/document.ex | 63 ++++++++--- lib/jsonapi_plug/document/jsonapi_object.ex | 22 +++- .../document/relationship_object.ex | 19 ++-- .../document/resource_identifier_object.ex | 20 +++- lib/jsonapi_plug/document/resource_object.ex | 58 +++++++--- lib/jsonapi_plug/exceptions.ex | 32 ++++-- lib/jsonapi_plug/normalizer.ex | 105 ++++++++++++++---- lib/jsonapi_plug/plug.ex | 46 ++++---- lib/jsonapi_plug/resource.ex | 58 +++++++--- mix.exs | 1 + mix.lock | 2 + test/jsonapi_plug/plug_test.exs | 6 +- 12 files changed, 316 insertions(+), 116 deletions(-) diff --git a/lib/jsonapi_plug/document.ex b/lib/jsonapi_plug/document.ex index a7c91f8c..d24d6789 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 bb242c2d..03711899 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" @@ -22,8 +24,14 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do defp deserialize_meta(_jsonapi_object, %{"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(jsonapi_object, _data), do: jsonapi_object @@ -37,7 +45,13 @@ defmodule JSONAPIPlug.Document.JSONAPIObject do defp deserialize_version(_jsonapi_object, %{"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(jsonapi_object, _data), do: jsonapi_object diff --git a/lib/jsonapi_plug/document/relationship_object.ex b/lib/jsonapi_plug/document/relationship_object.ex index 16122792..bd30d54c 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()} @@ -62,8 +59,14 @@ defmodule JSONAPIPlug.Document.RelationshipObject do defp deserialize_meta(_relationship_object, %{"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(relationship_object, _data), do: relationship_object diff --git a/lib/jsonapi_plug/document/resource_identifier_object.ex b/lib/jsonapi_plug/document/resource_identifier_object.ex index 5aeb31de..ee5a68f1 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(), @@ -28,8 +30,13 @@ defmodule JSONAPIPlug.Document.ResourceIdentifierObject do defp deserialize_type(_resource_identifier_object, 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 defp deserialize_id(resource_identifier_object, %{"id" => id}) @@ -46,7 +53,12 @@ defmodule JSONAPIPlug.Document.ResourceIdentifierObject do defp deserialize_meta(_resource_identifier_object, %{"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(resource_identifier_object, _payload), diff --git a/lib/jsonapi_plug/document/resource_object.ex b/lib/jsonapi_plug/document/resource_object.ex index d44626c7..615bf97d 100644 --- a/lib/jsonapi_plug/document/resource_object.ex +++ b/lib/jsonapi_plug/document/resource_object.ex @@ -5,12 +5,9 @@ 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() @@ -63,19 +60,34 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_type(_resource_object, %{"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(_resource_object, %{"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(_resource_object, %{"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(resource_object, %{"attributes" => attributes}) @@ -98,13 +110,23 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_relationships(_resource_object, %{"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(_resource_object, %{"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( @@ -130,7 +152,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(relationships, _data), do: relationships @@ -141,7 +168,12 @@ defmodule JSONAPIPlug.Document.ResourceObject do defp deserialize_meta(_resource_object, %{"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(resource_object, _data), do: resource_object diff --git a/lib/jsonapi_plug/exceptions.ex b/lib/jsonapi_plug/exceptions.ex index 9e5761e5..a5da6081 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 e842923f..661411b2 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -31,17 +31,17 @@ defmodule JSONAPIPlug.Normalizer do any point in your normalizer code. """ - alias JSONAPIPlug.{ - API, - Document, - Document.RelationshipObject, - Document.ResourceIdentifierObject, - Document.ResourceObject, - Exceptions.InvalidDocument, - Pagination, - Resource + alias JSONAPIPlug.{API, Document, Pagination, Resource} + + alias JSONAPIPlug.Document.{ + ErrorObject, + RelationshipObject, + ResourceIdentifierObject, + ResourceObject } + alias JSONAPIPlug.Exceptions.{InvalidAttributes, InvalidDocument} + alias Plug.Conn @type t :: module() @@ -64,7 +64,7 @@ defmodule JSONAPIPlug.Normalizer do params() | no_return() @callback normalize_attribute(params(), Resource.field_name()) :: value() | 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: %{} @@ -103,7 +103,12 @@ defmodule JSONAPIPlug.Normalizer do if API.get_config(jsonapi_plug.api, [:client_generated_ids], false) do 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" + } + ] end params @@ -124,6 +129,28 @@ defmodule JSONAPIPlug.Normalizer do Enum.reduce(resource.attributes(), params, fn attribute, params -> denormalize_attribute(params, resource_object, resource, conn, attribute, case, normalizer) end) + |> validate_params(resource, case) + end + + defp validate_params(params, resource, case) do + case resource.validate(params) do + :ok -> + params + + {:error, errors} -> + raise InvalidAttributes, + message: "Resource '#{resource.type()}' is invalid.", + errors: + Enum.map(errors, fn {msg, "#/" <> pointer} -> + name = resource.recase_field(pointer, case) + + %ErrorObject{ + title: "Attribute '#{name}' is invalid.", + detail: msg, + source: %{pointer: "/data/attributes/" <> name} + } + end) + end end defp denormalize_attribute(params, resource_object, resource, conn, attribute, case, normalizer) do @@ -184,13 +211,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 + raise InvalidDocument, + 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: nil} = relationship_object} -> normalizer.denormalize_relationship(params, relationship_object, key, nil) @@ -327,13 +364,23 @@ defmodule JSONAPIPlug.Normalizer do case {related_many, related_data} do {false, 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()}' relationship '#{name}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{resource.type()}' relationship '#{name}'", + detail: "Relationship '#{name}' is one-to-one but a list was received." + } + ] {true, _related_data} when not is_list(related_data) -> raise InvalidDocument, - message: "Single resource given to render for many relationship", - 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." + } + ] {_related_many, related_data} -> { @@ -464,13 +511,23 @@ 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 '#{related_resource.type()}' relationship '#{name}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", + detail: "Relationship '#{name}' 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 '#{related_resource.type()}' relationship '#{name}'", + errors: [ + %ErrorObject{ + title: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", + detail: "Relationship '#{name}' 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 e70c233c..d2fac142 100644 --- a/lib/jsonapi_plug/plug.ex +++ b/lib/jsonapi_plug/plug.ex @@ -102,9 +102,7 @@ defmodule JSONAPIPlug.Plug do require Logger alias JSONAPIPlug.{Document, Exceptions} - alias JSONAPIPlug.Plug.{ContentTypeNegotiation, Params, QueryParam, ResponseContentType} - alias Plug.Conn plug :config @@ -134,33 +132,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 @@ -168,19 +175,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 end diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index 9877e593..ff4c16c0 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -90,6 +90,16 @@ defmodule JSONAPIPlug.Resource do type: {:or, [:boolean, {:fun, 2}, :mfa]}, default: true ], + required: [ + doc: "Attribute is required.", + type: :boolean, + default: true + ], + type: [ + doc: "Attribute type.", + type: {:in, [:string, :number]}, + default: :string + ], deserialize: [ doc: "Can be either a boolean, a function reference or MFA returning the attribute value to be deserialized.", @@ -306,27 +316,55 @@ defmodule JSONAPIPlug.Resource do Stream.concat(attributes, relationships) |> Enum.find(&(JSONAPIPlug.Resource.field_name(&1) in [:id, :type])) do name = JSONAPIPlug.Resource.field_name(field) - resource = Module.split(__CALLER__.module) |> List.last() - raise "Illegal field name '#{name}' for resource #{resource}. See https://jsonapi.org/format/#document-resource-object-fields for more information." + raise "Illegal field name '#{name}', see https://jsonapi.org/format/#document-resource-object-fields for more information." end recase_field = for field <- Stream.concat(attributes, relationships) - |> Stream.map(&JSONAPIPlug.Resource.field_name(&1)) + |> Stream.map(&field_name(&1)) |> MapSet.new(), casing <- [:camelize, :dasherize, :underscore] do result = JSONAPIPlug.recase(field, casing) quote do - def recase_field(unquote(field), unquote(casing)) do - unquote(result) - end + def recase_field(unquote(field), unquote(casing)), do: unquote(result) + def recase_field(unquote(to_string(field)), unquote(casing)), do: unquote(result) end end + schema = + %{ + "type" => "object", + "required" => + Enum.reduce(attributes, [], fn attribute, required -> + (field_option(attribute, :required) && + [field_name(attribute) |> to_string() | required]) || required + end), + "properties" => + Enum.reduce(attributes, %{}, fn attribute, properties -> + Map.merge( + properties, + %{ + (field_name(attribute) |> to_string()) => %{ + "type" => (field_option(attribute, :type) || :string) |> to_string() + } + } + ) + end) + } + |> ExJsonSchema.Schema.resolve() + |> Macro.escape() + quote do + # require Exonerate + + def validate(params), + do: ExJsonSchema.Validator.validate(unquote(schema), params) + + # Exonerate.function_from_string(:def, :validate, unquote(Jason.encode!(schema))) + @behaviour JSONAPIPlug.Resource @impl JSONAPIPlug.Resource @@ -434,13 +472,7 @@ defmodule JSONAPIPlug.Resource do @spec for_related_type(t(), ResourceObject.type()) :: t() | nil def for_related_type(resource, type) do Enum.find_value(resource.relationships(), fn {_relationship, options} -> - relationship_resource = Keyword.fetch!(options, :resource) - - if relationship_resource.type() == type do - relationship_resource - else - nil - end + options[:resource].type() == type && options[:resource] end) end diff --git a/mix.exs b/mix.exs index 1184d737..cfbb154a 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, "~> 0.4 or ~> 0.5 or ~> 1.0"}, {:plug, "~> 1.0"} diff --git a/mix.lock b/mix.lock index 93457e39..19a50428 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,12 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [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", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, + "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.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, diff --git a/test/jsonapi_plug/plug_test.exs b/test/jsonapi_plug/plug_test.exs index 5b3155a9..54777f35 100644 --- a/test/jsonapi_plug/plug_test.exs +++ b/test/jsonapi_plug/plug_test.exs @@ -186,7 +186,8 @@ defmodule JSONAPIPlug.PlugTest do "id" => "1", "type" => "user", "attributes" => %{ - "firstName" => "Jerome" + "firstName" => "Jerome", + "lastName" => "finch" }, "relationships" => %{ "company" => %{ @@ -229,7 +230,8 @@ defmodule JSONAPIPlug.PlugTest do "id" => "1", "type" => "user", "attributes" => %{ - "firstName" => "Jerome" + "firstName" => "Jerome", + "lastName" => "Finch" } }, "included" => [ From ccef5c965510fd270b6b471b10b3bc171eee28f6 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Tue, 6 Feb 2024 21:16:22 +0100 Subject: [PATCH 02/13] Update lib/jsonapi_plug/resource.ex --- lib/jsonapi_plug/resource.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index ff4c16c0..9cf59064 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -472,7 +472,7 @@ defmodule JSONAPIPlug.Resource do @spec for_related_type(t(), ResourceObject.type()) :: t() | nil def for_related_type(resource, type) do Enum.find_value(resource.relationships(), fn {_relationship, options} -> - options[:resource].type() == type && options[:resource] + if options[:resource].type() == type, do: options[:resource] end) end From 456ef7e33ea8af042f1b611abcdf35fa023ce00a Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Tue, 6 Feb 2024 21:23:33 +0100 Subject: [PATCH 03/13] Cleanup --- lib/jsonapi_plug/resource.ex | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index 9cf59064..9f930a59 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -358,13 +358,6 @@ defmodule JSONAPIPlug.Resource do |> Macro.escape() quote do - # require Exonerate - - def validate(params), - do: ExJsonSchema.Validator.validate(unquote(schema), params) - - # Exonerate.function_from_string(:def, :validate, unquote(Jason.encode!(schema))) - @behaviour JSONAPIPlug.Resource @impl JSONAPIPlug.Resource @@ -431,6 +424,10 @@ defmodule JSONAPIPlug.Resource do raise "invalid action #{action}, use one of create.json, index.json, show.json, update.json" end end + + @doc false + def validate(params), + do: ExJsonSchema.Validator.validate(unquote(schema), params) end end From ce00f4c4106d3fc9ff699e6eebf558b6906c8bf6 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Sat, 25 May 2024 14:23:23 +0200 Subject: [PATCH 04/13] Format --- lib/jsonapi_plug/normalizer.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 661411b2..41089b36 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -220,14 +220,14 @@ defmodule JSONAPIPlug.Normalizer do ] {false, %RelationshipObject{data: data}} when is_list(data) -> - raise InvalidDocument, - 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." - } - ] + raise InvalidDocument, + 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: nil} = relationship_object} -> normalizer.denormalize_relationship(params, relationship_object, key, nil) From 5b49569883057f95bec7733e24f6853001d84d3b Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Tue, 6 Feb 2024 21:12:53 +0100 Subject: [PATCH 05/13] Explore validation with JSON Schema --- lib/jsonapi_plug/resource.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index 9f930a59..0a3ed187 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -358,6 +358,13 @@ defmodule JSONAPIPlug.Resource do |> Macro.escape() quote do + # require Exonerate + + def validate(params), + do: ExJsonSchema.Validator.validate(unquote(schema), params) + + # Exonerate.function_from_string(:def, :validate, unquote(Jason.encode!(schema))) + @behaviour JSONAPIPlug.Resource @impl JSONAPIPlug.Resource From 4f9dd26603f987b61d347b2c12a92f323b97ad6a Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 6 Jun 2024 21:03:26 +0200 Subject: [PATCH 06/13] Remove duplicate dunction --- lib/jsonapi_plug/resource.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index 0a3ed187..9cf59064 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -431,10 +431,6 @@ defmodule JSONAPIPlug.Resource do raise "invalid action #{action}, use one of create.json, index.json, show.json, update.json" end end - - @doc false - def validate(params), - do: ExJsonSchema.Validator.validate(unquote(schema), params) end end From c85bd22472bc23763fdfa09135027e0096fde276 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Wed, 2 Oct 2024 00:41:14 +0200 Subject: [PATCH 07/13] Adapt to protocols --- lib/jsonapi_plug.ex | 18 +++-- lib/jsonapi_plug/document/resource_object.ex | 4 +- lib/jsonapi_plug/normalizer.ex | 68 ++++++++++------- lib/jsonapi_plug/resource.ex | 79 +++++++++----------- mix.lock | 1 + test/jsonapi_plug/plug_test.exs | 2 +- test/support/resources.ex | 2 +- 7 files changed, 95 insertions(+), 79 deletions(-) diff --git a/lib/jsonapi_plug.ex b/lib/jsonapi_plug.ex index 894fb47d..2af8099b 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/resource_object.ex b/lib/jsonapi_plug/document/resource_object.ex index 931c9cd8..dffb15fc 100644 --- a/lib/jsonapi_plug/document/resource_object.ex +++ b/lib/jsonapi_plug/document/resource_object.ex @@ -11,12 +11,13 @@ defmodule JSONAPIPlug.Document.ResourceObject do @type id :: String.t() @type type :: String.t() + @type attributes :: %{String.t() => Document.value()} @type t :: %__MODULE__{ id: id(), lid: id(), type: type(), - attributes: %{String.t() => Document.value()} | nil, + attributes: attributes() | nil, links: Document.links() | nil, meta: Document.meta() | nil, relationships: %{String.t() => [RelationshipObject.t()]} | nil @@ -65,6 +66,7 @@ defmodule JSONAPIPlug.Document.ResourceObject do title: "Resource object type (#{type}) is invalid", detail: "https://jsonapi.org/format/#document-resource-objects" } + ] end defp deserialize_attributes(_resource_object, %{"attributes" => %{"id" => _id}}) do diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 8a53b920..bcbdcaa0 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -31,8 +31,12 @@ defmodule JSONAPIPlug.Normalizer do any point in your normalizer code. """ + alias JSONAPIPlug.Resource.ErrorFormatter + alias JSONAPIPlug.{ Document, + Document, + Document.ErrorObject, Document.RelationshipObject, Document.ResourceIdentifierObject, Document.ResourceObject, @@ -146,29 +150,31 @@ defmodule JSONAPIPlug.Normalizer do end end - defp denormalize_attributes(params, %ResourceObject{} = resource_object, resource, conn) do - Enum.reduce(Resource.attributes(resource), params, &denormalize_attribute(&2, resource_object, resource, &1, conn)) - |> validate_params(resource, case) + defp denormalize_attributes( + params, + %ResourceObject{} = resource_object, + resource, + conn + ) do + Enum.reduce( + Resource.attributes(resource), + params, + &denormalize_attribute(&2, resource_object, resource, &1, conn) + ) + |> validate_params(resource, conn) end - defp validate_params(params, resource, case) do - case resource.validate(params) do + defp validate_params(params, resource, %Conn{ + private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} + }) do + case Resource.validate(resource, params) do :ok -> params {:error, errors} -> raise InvalidAttributes, - message: "Resource '#{resource.type()}' is invalid.", - errors: - Enum.map(errors, fn {msg, "#/" <> pointer} -> - name = resource.recase_field(pointer, case) - - %ErrorObject{ - title: "Attribute '#{name}' is invalid.", - detail: msg, - source: %{pointer: "/data/attributes/" <> name} - } - end) + message: "Resource '#{Resource.type(resource)}' is invalid.", + errors: ErrorFormatter.format(errors, resource, jsonapi_plug.config[:case]) end end @@ -404,21 +410,21 @@ defmodule JSONAPIPlug.Normalizer do case {related_many, related_resource} do {false, related_resources} when is_list(related_resources) -> raise InvalidDocument, - message: "Invalid value for '#{resource.type()}' relationship '#{name}'", + message: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", errors: [ %ErrorObject{ - title: "Invalid value for '#{resource.type()}' relationship '#{name}'", - detail: "Relationship '#{name}' is one-to-one but a list was received." + 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: "Invalid value for '#{resource.type()}' relationship '#{name}'", + message: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", errors: [ %ErrorObject{ - title: "Invalid value for '#{resource.type()}' relationship '#{name}'", - detail: "Relationship '#{name}' is one-to-many but a single value was received." + title: "Invalid value for '#{Resource.type(resource)}' relationship '#{key}'", + detail: "Relationship '#{key}' is one-to-many but a single value was received." } ] @@ -513,6 +519,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) -> @@ -523,21 +530,26 @@ defmodule JSONAPIPlug.Normalizer do {true, _related_many, related_data} when is_list(related_data) -> raise InvalidDocument, - message: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", + message: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", errors: [ %ErrorObject{ - title: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", - detail: "Relationship '#{name}' is one-to-one but a list was received." + 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: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", + message: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", errors: [ %ErrorObject{ - title: "Invalid value for '#{related_resource.type()}' relationship '#{name}'", - detail: "Relationship '#{name}' is one-to-many but a single value was received." + title: + "Invalid value for '#{Resource.type(related_resource)}' relationship '#{relationship}'", + detail: + "Relationship '#{relationship}' is one-to-many but a single value was received." } ] diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index a6ef7098..9dfb1a27 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -148,11 +148,12 @@ defprotocol JSONAPIPlug.Resource do """ @spec type(t()) :: ResourceObject.type() def type(resource) - + @doc """ Validates the resource """ - def validate(resource) + @spec validate(t(), ResourceObject.attributes()) :: :ok | {:error, term()} + def validate(resource, attributes) end defimpl JSONAPIPlug.Resource, for: Any do @@ -160,7 +161,14 @@ defimpl JSONAPIPlug.Resource, for: Any do options = options |> Macro.prewalk(&Macro.expand(&1, __CALLER__)) - |> NimbleOptions.validate!(JSONAPIPlug.resource_options_schema()) + |> 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) relationships = generate_relationships(options) @@ -168,7 +176,7 @@ 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 @@ -189,25 +197,20 @@ defimpl JSONAPIPlug.Resource, for: Any do unquote(recase_field) def relationships(_resource), do: unquote(relationships) def type(_resource), do: unquote(options[:type]) - def validate(params), - do: ExJsonSchema.Validator.validate(unquote(schema), params) + + def validate(_resource, params) do + ExJsonSchema.Validator.validate(unquote(schema), params, error_formatter: false) + end 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 @@ -226,11 +229,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 @@ -241,40 +239,36 @@ 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 do - %{ + + def generate_schema(options) do + %{ "type" => "object", "required" => - Enum.reduce(attributes, [], fn attribute, required -> - (field_option(attribute, :required) && - [field_name(attribute) |> to_string() | required]) || required + Enum.reduce(options[:attributes], [], fn {attribute, options}, required -> + (options[:required] && [to_string(options[:name] || attribute) | required]) || required end), "properties" => - Enum.reduce(attributes, %{}, fn attribute, properties -> - Map.merge( - properties, - %{ - (field_name(attribute) |> to_string()) => %{ - "type" => (field_option(attribute, :type) || :string) |> to_string() - } - } - ) + Enum.into(options[:attributes], %{}, fn {attribute, options} -> + { + to_string(options[:name] || attribute), + %{"type" => to_string(options[:type] || :string)} + } end) } |> ExJsonSchema.Schema.resolve() @@ -289,4 +283,5 @@ defimpl JSONAPIPlug.Resource, for: Any do def recase_field(_resource, field, _case), do: field def relationships(_resource), do: [] def type(_resource), do: "" + def validate(_resource, _params), do: :ok end diff --git a/mix.lock b/mix.lock index 3f1be227..11f9c6c1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.8", "9722ba1681e973025908d542ec3d95db5f9c549251ba5b028e251ad8c24ab8c5", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb9e87cc64f152f3ed1c6e325e7b894dea8f5ef2e41123bd864e3cd5ceb44968"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.4", "fb3ce8741edeaea59c9ae84d5cec75da00fa89fe401c72d6e047d11a61f65f70", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cd6111e8017ccd563e65621a4d9a4a1c5cd333df30cebc7face8029cacb4eff6"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, diff --git a/test/jsonapi_plug/plug_test.exs b/test/jsonapi_plug/plug_test.exs index c5cdad9a..eda1a38d 100644 --- a/test/jsonapi_plug/plug_test.exs +++ b/test/jsonapi_plug/plug_test.exs @@ -137,7 +137,7 @@ defmodule JSONAPIPlug.PlugTest do "type" => "user", "attributes" => %{ "firstName" => "Jerome", - "lastName" => "finch" + "lastName" => "Finch" }, "relationships" => %{ "company" => %{ diff --git a/test/support/resources.ex b/test/support/resources.ex index aebd5cba..70eeec83 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], From 6ac5c066b997e98339e66c0984eb18fbcce3c91c Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 6 Jun 2024 21:17:15 +0200 Subject: [PATCH 08/13] Move function definition --- lib/jsonapi_plug/resource/error_formatter.ex | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 lib/jsonapi_plug/resource/error_formatter.ex diff --git a/lib/jsonapi_plug/resource/error_formatter.ex b/lib/jsonapi_plug/resource/error_formatter.ex new file mode 100644 index 00000000..f3b0176b --- /dev/null +++ b/lib/jsonapi_plug/resource/error_formatter.ex @@ -0,0 +1,25 @@ +defmodule JSONAPIPlug.Resource.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 From bc6d6bb5f22ba1123eca07cabc7b7ef4448ee24b Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Wed, 2 Oct 2024 21:06:08 +0200 Subject: [PATCH 09/13] Don't validate params on patch --- lib/jsonapi_plug/normalizer.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index bcbdcaa0..675c61e7 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -161,12 +161,12 @@ defmodule JSONAPIPlug.Normalizer do params, &denormalize_attribute(&2, resource_object, resource, &1, conn) ) - |> validate_params(resource, conn) + |> validate_attributes(resource, conn) end - defp validate_params(params, resource, %Conn{ - private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} - }) do + defp validate_attributes(params, _resource, %Conn{method: "PATCH"}), do: params + + defp validate_attributes(params, resource, %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}}) do case Resource.validate(resource, params) do :ok -> params From 52180034f9399244d088034771beb82794a7a065 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Wed, 2 Oct 2024 21:07:58 +0200 Subject: [PATCH 10/13] Format --- lib/jsonapi_plug/normalizer.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 675c61e7..9e3d499a 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -166,7 +166,9 @@ defmodule JSONAPIPlug.Normalizer do defp validate_attributes(params, _resource, %Conn{method: "PATCH"}), do: params - defp validate_attributes(params, resource, %Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}}) do + defp validate_attributes(params, resource, %Conn{ + private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} + }) do case Resource.validate(resource, params) do :ok -> params From 93a4d21e6c54c695041e623a2867bd994e48db8b Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 3 Oct 2024 00:57:25 +0200 Subject: [PATCH 11/13] Split validation to protocol --- lib/jsonapi_plug/normalizer.ex | 48 +++++++------------- lib/jsonapi_plug/resource.ex | 21 ++++----- lib/jsonapi_plug/resource/error_formatter.ex | 25 ---------- 3 files changed, 27 insertions(+), 67 deletions(-) delete mode 100644 lib/jsonapi_plug/resource/error_formatter.ex diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 9e3d499a..44fc9f1d 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -31,24 +31,16 @@ defmodule JSONAPIPlug.Normalizer do any point in your normalizer code. """ - alias JSONAPIPlug.Resource.ErrorFormatter - - alias JSONAPIPlug.{ - Document, - Document, - Document.ErrorObject, - 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 @@ -156,27 +148,21 @@ defmodule JSONAPIPlug.Normalizer do resource, conn ) do - Enum.reduce( - Resource.attributes(resource), - params, - &denormalize_attribute(&2, resource_object, resource, &1, conn) - ) - |> validate_attributes(resource, conn) - end - - defp validate_attributes(params, _resource, %Conn{method: "PATCH"}), do: params + params = + Enum.reduce( + Resource.attributes(resource), + params, + &denormalize_attribute(&2, resource_object, resource, &1, conn) + ) - defp validate_attributes(params, resource, %Conn{ - private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} - }) do - case Resource.validate(resource, params) do + case Validator.validate(resource, params, conn) do :ok -> params {:error, errors} -> raise InvalidAttributes, message: "Resource '#{Resource.type(resource)}' is invalid.", - errors: ErrorFormatter.format(errors, resource, jsonapi_plug.config[:case]) + errors: errors end end diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index 9dfb1a27..5e14cae9 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 @@ -148,12 +156,6 @@ defprotocol JSONAPIPlug.Resource do """ @spec type(t()) :: ResourceObject.type() def type(resource) - - @doc """ - Validates the resource - """ - @spec validate(t(), ResourceObject.attributes()) :: :ok | {:error, term()} - def validate(resource, attributes) end defimpl JSONAPIPlug.Resource, for: Any do @@ -196,11 +198,8 @@ 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]) - - def validate(_resource, params) do - ExJsonSchema.Validator.validate(unquote(schema), params, error_formatter: false) - end end end end @@ -282,6 +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: "" - def validate(_resource, _params), do: :ok end diff --git a/lib/jsonapi_plug/resource/error_formatter.ex b/lib/jsonapi_plug/resource/error_formatter.ex deleted file mode 100644 index f3b0176b..00000000 --- a/lib/jsonapi_plug/resource/error_formatter.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule JSONAPIPlug.Resource.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 From 65531ae811dcffe360ba103aabaf5dcf4fd213a4 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 3 Oct 2024 00:57:36 +0200 Subject: [PATCH 12/13] Split validation to protocol --- lib/jsonapi_plug/resource/validator.ex | 52 +++++++++++++++++++ .../resource/validator/error_formatter.ex | 25 +++++++++ 2 files changed, 77 insertions(+) create mode 100644 lib/jsonapi_plug/resource/validator.ex create mode 100644 lib/jsonapi_plug/resource/validator/error_formatter.ex diff --git a/lib/jsonapi_plug/resource/validator.ex b/lib/jsonapi_plug/resource/validator.ex new file mode 100644 index 00000000..74ac6ffd --- /dev/null +++ b/lib/jsonapi_plug/resource/validator.ex @@ -0,0 +1,52 @@ +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 + schema = Resource.schema(resource) + + case Validator.validate(schema, 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 00000000..710aacf2 --- /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 From c59c07b656235069be06decf75feb6c269fddd12 Mon Sep 17 00:00:00 2001 From: Luca Corti Date: Thu, 3 Oct 2024 00:58:17 +0200 Subject: [PATCH 13/13] Cleanup --- lib/jsonapi_plug/resource/validator.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/jsonapi_plug/resource/validator.ex b/lib/jsonapi_plug/resource/validator.ex index 74ac6ffd..7c712969 100644 --- a/lib/jsonapi_plug/resource/validator.ex +++ b/lib/jsonapi_plug/resource/validator.ex @@ -39,9 +39,7 @@ defimpl JSONAPIPlug.Resource.Validator, for: Any do def validate(resource, attributes, %Conn{ private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug} }) do - schema = Resource.schema(resource) - - case Validator.validate(schema, attributes, error_formatter: false) do + case Validator.validate(Resource.schema(resource), attributes, error_formatter: false) do :ok -> :ok