diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd18c868..dbd227579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Breaking changes: Features: +- [#1004](https://github.com/rails-api/active_model_serializers/pull/1004) JSON API errors object implementation. + - Only implements `detail` and `source` as derived from `ActiveModel::Error` + - Provides checklist of remaining questions and remaining parts of the spec. - [#1515](https://github.com/rails-api/active_model_serializers/pull/1515) Adds support for symbols to the `ActiveModel::Serializer.type` method. (@groyoh) - [#1504](https://github.com/rails-api/active_model_serializers/pull/1504) Adds the changes missing from #1454 diff --git a/docs/README.md b/docs/README.md index 7f0a8ac02..b1db25da2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,9 @@ This is the documentation of ActiveModelSerializers, it's focused on the **0.10. - [Caching](general/caching.md) - [Logging](general/logging.md) - [Instrumentation](general/instrumentation.md) -- [JSON API Schema](jsonapi/schema.md) +- JSON API + - [Schema](jsonapi/schema.md) + - [Errors](jsonapi/errors.md) - [ARCHITECTURE](ARCHITECTURE.md) ## How to diff --git a/docs/jsonapi/errors.md b/docs/jsonapi/errors.md new file mode 100644 index 000000000..1d15dde01 --- /dev/null +++ b/docs/jsonapi/errors.md @@ -0,0 +1,56 @@ +[Back to Guides](../README.md) + +# [JSON API Errors](http://jsonapi.org/format/#errors) + +Rendering error documents requires specifying the error serializer(s): + +- Serializer: + - For a single resource: `serializer: ActiveModel::Serializer::ErrorSerializer`. + - For a collection: `serializer: ActiveModel::Serializer::ErrorsSerializer`, `each_serializer: ActiveModel::Serializer::ErrorSerializer`. + +The resource **MUST** have a non-empty associated `#errors` object. +The `errors` object must have a `#messages` method that returns a hash of error name to array of +descriptions. + +## Use in controllers + +```ruby +resource = Profile.new(name: 'Name 1', + description: 'Description 1', + comments: 'Comments 1') +resource.errors.add(:name, 'cannot be nil') +resource.errors.add(:name, 'must be longer') +resource.errors.add(:id, 'must be a uuid') + +render json: resource, status: 422, adapter: :json_api, serializer: ActiveModel::Serializer::ErrorSerializer +# #=> +# { :errors => +# [ +# { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' }, +# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' }, +# { :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' } +# ] +# }.to_json +``` + +## Direct error document generation + +```ruby +options = nil +resource = ModelWithErrors.new +resource.errors.add(:name, 'must be awesome') + +serializable_resource = ActiveModel::SerializableResource.new( + resource, { + serializer: ActiveModel::Serializer::ErrorSerializer, + adapter: :json_api + }) +serializable_resource.as_json(options) +# #=> +# { +# :errors => +# [ +# { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' } +# ] +# } +``` diff --git a/docs/jsonapi/schema.md b/docs/jsonapi/schema.md index 72b149484..ba718ec0c 100644 --- a/docs/jsonapi/schema.md +++ b/docs/jsonapi/schema.md @@ -58,33 +58,33 @@ Example supported requests |-----------------------|----------------------------------------------------------------------------------------------------|----------|---------------------------------------| | schema | oneOf (success, failure, info) | | | success | data, included, meta, links, jsonapi | | AM::SerializableResource -| success.meta | meta | | AM::S::Adapter::Base#meta -| success.included | UniqueArray(resource) | | AM::S::Adapter::JsonApi#serializable_hash_for_collection +| success.meta | meta | | AMS::Adapter::Base#meta +| success.included | UniqueArray(resource) | | AMS::Adapter::JsonApi#serializable_hash_for_collection | success.data | data | | -| success.links | allOf (links, pagination) | | AM::S::Adapter::JsonApi#links_for +| success.links | allOf (links, pagination) | | AMS::Adapter::JsonApi#links_for | success.jsonapi | jsonapi | | -| failure | errors, meta, jsonapi | errors | -| failure.errors | UniqueArray(error) | | #1004 -| meta | Object | | -| data | oneOf (resource, UniqueArray(resource)) | | AM::S::Adapter::JsonApi#serializable_hash_for_collection,#serializable_hash_for_single_resource +| failure | errors, meta, jsonapi | errors | AMS::Adapter::JsonApi#failure_document, #1004 +| failure.errors | UniqueArray(error) | | AM::S::ErrorSerializer, #1004 +| meta | Object | | +| data | oneOf (resource, UniqueArray(resource)) | | AMS::Adapter::JsonApi#serializable_hash_for_collection,#serializable_hash_for_single_resource | resource | String(type), String(id),
attributes, relationships,
links, meta | type, id | AM::S::Adapter::JsonApi#primary_data_for | links | Uri(self), Link(related) | | #1028, #1246, #1282 | link | oneOf (linkString, linkObject) | | | link.linkString | Uri | | | link.linkObject | Uri(href), meta | href | -| attributes | patternProperties(
`"^(?!relationships$|links$)\\w[-\\w_]*$"`),
any valid JSON | | AM::Serializer#attributes, AM::S::Adapter::JsonApi#resource_object_for -| relationships | patternProperties(
`"^\\w[-\\w_]*$"`);
links, relationships.data, meta | | AM::S::Adapter::JsonApi#relationships_for -| relationships.data | oneOf (relationshipToOne, relationshipToMany) | | AM::S::Adapter::JsonApi#resource_identifier_for +| attributes | patternProperties(
`"^(?!relationships$|links$)\\w[-\\w_]*$"`),
any valid JSON | | AM::Serializer#attributes, AMS::Adapter::JsonApi#resource_object_for +| relationships | patternProperties(
`"^\\w[-\\w_]*$"`);
links, relationships.data, meta | | AMS::Adapter::JsonApi#relationships_for +| relationships.data | oneOf (relationshipToOne, relationshipToMany) | | AMS::Adapter::JsonApi#resource_identifier_for | relationshipToOne | anyOf(empty, linkage) | | | relationshipToMany | UniqueArray(linkage) | | | empty | null | | -| linkage | String(type), String(id), meta | type, id | AM::S::Adapter::JsonApi#primary_data_for -| pagination | pageObject(first), pageObject(last),
pageObject(prev), pageObject(next) | | AM::S::Adapter::JsonApi::PaginationLinks#serializable_hash +| linkage | String(type), String(id), meta | type, id | AMS::Adapter::JsonApi#primary_data_for +| pagination | pageObject(first), pageObject(last),
pageObject(prev), pageObject(next) | | AMS::Adapter::JsonApi::PaginationLinks#serializable_hash | pagination.pageObject | oneOf(Uri, null) | | -| jsonapi | String(version), meta | | AM::S::Adapter::JsonApi::ApiObjects::JsonApi -| error | String(id), links, String(status),
String(code), String(title),
String(detail), error.source, meta | | -| error.source | String(pointer), String(parameter) | | -| pointer | [JSON Pointer RFC6901](https://tools.ietf.org/html/rfc6901) | | +| jsonapi | String(version), meta | | AMS::Adapter::JsonApi::ApiObjects::JsonApi#as_json +| error | String(id), links, String(status),
String(code), String(title),
String(detail), error.source, meta | | AM::S::ErrorSerializer, AMS::Adapter::JsonApi::Error.resource_errors +| error.source | String(pointer), String(parameter) | | AMS::Adapter::JsonApi::Error.error_source +| pointer | [JSON Pointer RFC6901](https://tools.ietf.org/html/rfc6901) | | AMS::JsonPointer The [http://jsonapi.org/schema](schema/schema.json) makes a nice roadmap. @@ -102,7 +102,7 @@ The [http://jsonapi.org/schema](schema/schema.json) makes a nice roadmap. ### Failure Document - [ ] failure - - [ ] errors: array of unique items of type ` "$ref": "#/definitions/error"` + - [x] errors: array of unique items of type ` "$ref": "#/definitions/error"` - [ ] meta: `"$ref": "#/definitions/meta"` - [ ] jsonapi: `"$ref": "#/definitions/jsonapi"` @@ -137,4 +137,15 @@ The [http://jsonapi.org/schema](schema/schema.json) makes a nice roadmap. - [ ] pagination - [ ] jsonapi - [ ] meta - - [ ] error: id, links, status, code, title: detail: source [{pointer, type}, {parameter: {description, type}], meta + - [ ] error + - [ ] id: a unique identifier for this particular occurrence of the problem. + - [ ] links: a links object containing the following members: + - [ ] about: a link that leads to further details about this particular occurrence of the problem. + - [ ] status: the HTTP status code applicable to this problem, expressed as a string value. + - [ ] code: an application-specific error code, expressed as a string value. + - [ ] title: a short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization. + - [x] detail: a human-readable explanation specific to this occurrence of the problem. + - [x] source: an object containing references to the source of the error, optionally including any of the following members: + - [x] pointer: a JSON Pointer [RFC6901](https://tools.ietf.org/html/rfc6901) to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. + - [x] parameter: a string indicating which query parameter caused the error. + - [ ] meta: a meta object containing non-standard meta-information about the error. diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 64bff0eca..b0ca3fa6c 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -1,6 +1,8 @@ require 'thread_safe' require 'active_model/serializer/collection_serializer' require 'active_model/serializer/array_serializer' +require 'active_model/serializer/error_serializer' +require 'active_model/serializer/errors_serializer' require 'active_model/serializer/include_tree' require 'active_model/serializer/associations' require 'active_model/serializer/attributes' @@ -116,6 +118,10 @@ def initialize(object, options = {}) end end + def success? + true + end + # Used by adapter as resource root. def json_key root || object.class.model_name.to_s.underscore diff --git a/lib/active_model/serializer/adapter/json_api/api_objects/relationship.rb b/lib/active_model/serializer/adapter/json_api/api_objects/relationship.rb index dfaabc39b..154b71fdb 100644 --- a/lib/active_model/serializer/adapter/json_api/api_objects/relationship.rb +++ b/lib/active_model/serializer/adapter/json_api/api_objects/relationship.rb @@ -4,6 +4,10 @@ module Adapter class JsonApi module ApiObjects class Relationship + # {http://jsonapi.org/format/#document-resource-object-related-resource-links Document Resource Object Related Resource Links} + # {http://jsonapi.org/format/#document-links Document Links} + # {http://jsonapi.org/format/#document-resource-object-linkage Document Resource Relationship Linkage} + # {http://jsonapi.org/format/#document-meta Docment Meta} def initialize(parent_serializer, serializer, options = {}, links = {}, meta = nil) @object = parent_serializer.object @scope = parent_serializer.scope diff --git a/lib/active_model/serializer/adapter/json_api/api_objects/resource_identifier.rb b/lib/active_model/serializer/adapter/json_api/api_objects/resource_identifier.rb index 058f06031..0336e0b50 100644 --- a/lib/active_model/serializer/adapter/json_api/api_objects/resource_identifier.rb +++ b/lib/active_model/serializer/adapter/json_api/api_objects/resource_identifier.rb @@ -4,6 +4,7 @@ module Adapter class JsonApi module ApiObjects class ResourceIdentifier + # {http://jsonapi.org/format/#document-resource-identifier-objects Resource Identifier Objects} def initialize(serializer) @id = id_for(serializer) @type = type_for(serializer) diff --git a/lib/active_model/serializer/collection_serializer.rb b/lib/active_model/serializer/collection_serializer.rb index c1edfeafc..022588381 100644 --- a/lib/active_model/serializer/collection_serializer.rb +++ b/lib/active_model/serializer/collection_serializer.rb @@ -22,6 +22,10 @@ def initialize(resources, options = {}) end end + def success? + true + end + def json_key root || derived_root end diff --git a/lib/active_model/serializer/error_serializer.rb b/lib/active_model/serializer/error_serializer.rb new file mode 100644 index 000000000..c12dfd370 --- /dev/null +++ b/lib/active_model/serializer/error_serializer.rb @@ -0,0 +1,10 @@ +class ActiveModel::Serializer::ErrorSerializer < ActiveModel::Serializer + # @return [Hash>] + def as_json + object.errors.messages + end + + def success? + false + end +end diff --git a/lib/active_model/serializer/errors_serializer.rb b/lib/active_model/serializer/errors_serializer.rb new file mode 100644 index 000000000..4b67bae83 --- /dev/null +++ b/lib/active_model/serializer/errors_serializer.rb @@ -0,0 +1,27 @@ +require 'active_model/serializer/error_serializer' +class ActiveModel::Serializer::ErrorsSerializer + include Enumerable + delegate :each, to: :@serializers + attr_reader :object, :root + + def initialize(resources, options = {}) + @root = options[:root] + @object = resources + @serializers = resources.map do |resource| + serializer_class = options.fetch(:serializer) { ActiveModel::Serializer::ErrorSerializer } + serializer_class.new(resource, options.except(:serializer)) + end + end + + def success? + false + end + + def json_key + nil + end + + protected + + attr_reader :serializers +end diff --git a/lib/active_model/serializer/lint.rb b/lib/active_model/serializer/lint.rb index b2bc48ff9..b791d40da 100644 --- a/lib/active_model/serializer/lint.rb +++ b/lib/active_model/serializer/lint.rb @@ -129,6 +129,20 @@ def test_model_name assert_instance_of resource_class.model_name, ActiveModel::Name end + def test_active_model_errors + assert_respond_to resource, :errors + end + + def test_active_model_errors_human_attribute_name + assert_respond_to resource.class, :human_attribute_name + assert_equal(-2, resource.class.method(:human_attribute_name).arity) + end + + def test_active_model_errors_lookup_ancestors + assert_respond_to resource.class, :lookup_ancestors + assert_equal 0, resource.class.method(:lookup_ancestors).arity + end + private def resource diff --git a/lib/active_model_serializers.rb b/lib/active_model_serializers.rb index e75e30a0d..bf2dac136 100644 --- a/lib/active_model_serializers.rb +++ b/lib/active_model_serializers.rb @@ -10,6 +10,7 @@ module ActiveModelSerializers autoload :Logging autoload :Test autoload :Adapter + autoload :JsonPointer class << self; attr_accessor :logger; end self.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT)) diff --git a/lib/active_model_serializers/adapter/json_api.rb b/lib/active_model_serializers/adapter/json_api.rb index 841185f00..0407ef095 100644 --- a/lib/active_model_serializers/adapter/json_api.rb +++ b/lib/active_model_serializers/adapter/json_api.rb @@ -8,11 +8,13 @@ class JsonApi < Base require 'active_model/serializer/adapter/json_api/meta' autoload :Deserialization require 'active_model/serializer/adapter/json_api/api_objects' + autoload :Error # TODO: if we like this abstraction and other API objects to it, # then extract to its own file and require it. module ApiObjects - module JsonApi + # {http://jsonapi.org/format/#document-jsonapi-object Jsonapi Object} + module Jsonapi ActiveModelSerializers.config.jsonapi_version = '1.0' ActiveModelSerializers.config.jsonapi_toplevel_meta = {} # Make JSON API top-level jsonapi member opt-in @@ -50,9 +52,19 @@ def initialize(serializer, options = {}) @fieldset = options[:fieldset] || ActiveModel::Serializer::Fieldset.new(options.delete(:fields)) end + # {http://jsonapi.org/format/#crud Requests are transactional, i.e. success or failure} + # {http://jsonapi.org/format/#document-top-level data and errors MUST NOT coexist in the same document.} def serializable_hash(options = nil) options ||= {} + if serializer.success? + success_document(options) + else + failure_document + end + end + # {http://jsonapi.org/format/#document-top-level Primary data} + def success_document(options) is_collection = serializer.respond_to?(:each) serializers = is_collection ? serializer : [serializer] primary_data, included = resource_objects_for(serializers) @@ -61,7 +73,7 @@ def serializable_hash(options = nil) hash[:data] = is_collection ? primary_data : primary_data[0] hash[:included] = included if included.any? - ApiObjects::JsonApi.add!(hash) + ApiObjects::Jsonapi.add!(hash) if instance_options[:links] hash[:links] ||= {} @@ -76,6 +88,29 @@ def serializable_hash(options = nil) hash end + # {http://jsonapi.org/format/#errors JSON API Errors} + # TODO: look into caching + # rubocop:disable Style/AsciiComments + # definition: + # ☑ toplevel_errors array (required) + # ☐ toplevel_meta + # ☐ toplevel_jsonapi + # rubocop:enable Style/AsciiComments + def failure_document + hash = {} + # PR Please :) + # ApiObjects::Jsonapi.add!(hash) + + if serializer.respond_to?(:each) + hash[:errors] = serializer.flat_map do |error_serializer| + Error.resource_errors(error_serializer) + end + else + hash[:errors] = Error.resource_errors(serializer) + end + hash + end + def fragment_cache(cached_hash, non_cached_hash) root = false if instance_options.include?(:include) ActiveModelSerializers::Adapter::JsonApi::FragmentCache.new.fragment_cache(root, cached_hash, non_cached_hash) @@ -87,6 +122,7 @@ def fragment_cache(cached_hash, non_cached_hash) private + # {http://jsonapi.org/format/#document-resource-objects Primary data} def resource_objects_for(serializers) @primary = [] @included = [] @@ -128,10 +164,12 @@ def process_relationship(serializer, include_tree) process_relationships(serializer, include_tree) end + # {http://jsonapi.org/format/#document-resource-object-attributes Document Resource Object Attributes} def attributes_for(serializer, fields) serializer.attributes(fields).except(:id) end + # {http://jsonapi.org/format/#document-resource-objects Document Resource Objects} def resource_object_for(serializer) resource_object = cache_check(serializer) do resource_object = ActiveModel::Serializer::Adapter::JsonApi::ApiObjects::ResourceIdentifier.new(serializer).as_json @@ -155,6 +193,7 @@ def resource_object_for(serializer) resource_object end + # {http://jsonapi.org/format/#document-resource-object-relationships Document Resource Object Relationship} def relationships_for(serializer, requested_associations) include_tree = ActiveModel::Serializer::IncludeTree.from_include_args(requested_associations) serializer.associations(include_tree).each_with_object({}) do |association, hash| @@ -168,16 +207,19 @@ def relationships_for(serializer, requested_associations) end end + # {http://jsonapi.org/format/#document-links Document Links} def links_for(serializer) serializer._links.each_with_object({}) do |(name, value), hash| hash[name] = Link.new(serializer, value).as_json end end + # {http://jsonapi.org/format/#fetching-pagination Pagination Links} def pagination_links_for(serializer, options) JsonApi::PaginationLinks.new(serializer.object, options[:serialization_context]).serializable_hash(options) end + # {http://jsonapi.org/format/#document-meta Docment Meta} def meta_for(serializer) ActiveModel::Serializer::Adapter::JsonApi::Meta.new(serializer).as_json end diff --git a/lib/active_model_serializers/adapter/json_api/error.rb b/lib/active_model_serializers/adapter/json_api/error.rb new file mode 100644 index 000000000..c8383d216 --- /dev/null +++ b/lib/active_model_serializers/adapter/json_api/error.rb @@ -0,0 +1,77 @@ +module ActiveModelSerializers + module Adapter + class JsonApi < Base + module Error + # rubocop:disable Style/AsciiComments + UnknownSourceTypeError = Class.new(ArgumentError) + + # Builds a JSON API Errors Object + # {http://jsonapi.org/format/#errors JSON API Errors} + # + # @param [ActiveModel::Serializer::ErrorSerializer] + # @return [Array] i.e. attribute_name, [attribute_errors] + def self.resource_errors(error_serializer) + error_serializer.as_json.flat_map do |attribute_name, attribute_errors| + attribute_error_objects(attribute_name, attribute_errors) + end + end + + # definition: + # JSON Object + # + # properties: + # ☐ id : String + # ☐ status : String + # ☐ code : String + # ☐ title : String + # ☑ detail : String + # ☐ links + # ☐ meta + # ☑ error_source + # + # description: + # id : A unique identifier for this particular occurrence of the problem. + # status : The HTTP status code applicable to this problem, expressed as a string value + # code : An application-specific error code, expressed as a string value. + # title : A short, human-readable summary of the problem. It **SHOULD NOT** change from + # occurrence to occurrence of the problem, except for purposes of localization. + # detail : A human-readable explanation specific to this occurrence of the problem. + def self.attribute_error_objects(attribute_name, attribute_errors) + attribute_errors.map do |attribute_error| + { + source: error_source(:pointer, attribute_name), + detail: attribute_error + } + end + end + + # description: + # oneOf + # ☑ pointer : String + # ☑ parameter : String + # + # description: + # pointer: A JSON Pointer RFC6901 to the associated entity in the request document e.g. "/data" + # for a primary data object, or "/data/attributes/title" for a specific attribute. + # https://tools.ietf.org/html/rfc6901 + # + # parameter: A string indicating which query parameter caused the error + def self.error_source(source_type, attribute_name) + case source_type + when :pointer + { + pointer: ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name) + } + when :parameter + { + parameter: attribute_name + } + else + fail UnknownSourceTypeError, "Unknown source type '#{source_type}' for attribute_name '#{attribute_name}'" + end + end + # rubocop:enable Style/AsciiComments + end + end + end +end diff --git a/lib/active_model_serializers/json_pointer.rb b/lib/active_model_serializers/json_pointer.rb new file mode 100644 index 000000000..a262f3b28 --- /dev/null +++ b/lib/active_model_serializers/json_pointer.rb @@ -0,0 +1,14 @@ +module ActiveModelSerializers + module JsonPointer + module_function + + POINTERS = { + attribute: '/data/attributes/%s'.freeze, + primary_data: '/data%s'.freeze + }.freeze + + def new(pointer_type, value = nil) + format(POINTERS[pointer_type], value) + end + end +end diff --git a/lib/active_model_serializers/model.rb b/lib/active_model_serializers/model.rb index 3043c389e..629713929 100644 --- a/lib/active_model_serializers/model.rb +++ b/lib/active_model_serializers/model.rb @@ -6,10 +6,11 @@ class Model include ActiveModel::Model include ActiveModel::Serializers::JSON - attr_reader :attributes + attr_reader :attributes, :errors def initialize(attributes = {}) @attributes = attributes + @errors = ActiveModel::Errors.new(self) super end @@ -35,5 +36,14 @@ def read_attribute_for_serialization(key) attributes[key] end end + + # The following methods are needed to be minimally implemented for ActiveModel::Errors + def self.human_attribute_name(attr, _options = {}) + attr + end + + def self.lookup_ancestors + [self] + end end end diff --git a/test/action_controller/json_api/errors_test.rb b/test/action_controller/json_api/errors_test.rb new file mode 100644 index 000000000..d58901103 --- /dev/null +++ b/test/action_controller/json_api/errors_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' + +module ActionController + module Serialization + class JsonApi + class ErrorsTest < ActionController::TestCase + def test_active_model_with_multiple_errors + get :render_resource_with_errors + + expected_errors_object = + { :errors => + [ + { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' }, + { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' }, + { :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' } + ] + }.to_json + assert_equal json_reponse_body.to_json, expected_errors_object + end + + def json_reponse_body + JSON.load(@response.body) + end + + class ErrorsTestController < ActionController::Base + def render_resource_with_errors + resource = Profile.new(name: 'Name 1', + description: 'Description 1', + comments: 'Comments 1') + resource.errors.add(:name, 'cannot be nil') + resource.errors.add(:name, 'must be longer') + resource.errors.add(:id, 'must be a uuid') + render json: resource, adapter: :json_api, serializer: ActiveModel::Serializer::ErrorSerializer + end + end + + tests ErrorsTestController + end + end + end +end diff --git a/test/active_model_serializers/json_pointer_test.rb b/test/active_model_serializers/json_pointer_test.rb new file mode 100644 index 000000000..64acc7eb7 --- /dev/null +++ b/test/active_model_serializers/json_pointer_test.rb @@ -0,0 +1,20 @@ +require 'test_helper' + +class ActiveModelSerializers::JsonPointerTest < ActiveSupport::TestCase + def test_attribute_pointer + attribute_name = 'title' + pointer = ActiveModelSerializers::JsonPointer.new(:attribute, attribute_name) + assert_equal '/data/attributes/title', pointer + end + + def test_primary_data_pointer + pointer = ActiveModelSerializers::JsonPointer.new(:primary_data) + assert_equal '/data', pointer + end + + def test_unkown_data_pointer + assert_raises(TypeError) do + ActiveModelSerializers::JsonPointer.new(:unknown) + end + end +end diff --git a/test/adapter/json_api/errors_test.rb b/test/adapter/json_api/errors_test.rb new file mode 100644 index 000000000..da7eff9be --- /dev/null +++ b/test/adapter/json_api/errors_test.rb @@ -0,0 +1,78 @@ +require 'test_helper' + +module ActiveModelSerializers + module Adapter + class JsonApi < Base + class ErrorsTest < Minitest::Test + include ActiveModel::Serializer::Lint::Tests + + def setup + @resource = ModelWithErrors.new + end + + def test_active_model_with_error + options = { + serializer: ActiveModel::Serializer::ErrorSerializer, + adapter: :json_api + } + + @resource.errors.add(:name, 'cannot be nil') + + serializable_resource = ActiveModel::SerializableResource.new(@resource, options) + assert_equal serializable_resource.serializer_instance.attributes, {} + assert_equal serializable_resource.serializer_instance.object, @resource + + expected_errors_object = + { :errors => + [ + { + source: { pointer: '/data/attributes/name' }, + detail: 'cannot be nil' + } + ] + } + assert_equal serializable_resource.as_json, expected_errors_object + end + + def test_active_model_with_multiple_errors + options = { + serializer: ActiveModel::Serializer::ErrorSerializer, + adapter: :json_api + } + + @resource.errors.add(:name, 'cannot be nil') + @resource.errors.add(:name, 'must be longer') + @resource.errors.add(:id, 'must be a uuid') + + serializable_resource = ActiveModel::SerializableResource.new(@resource, options) + assert_equal serializable_resource.serializer_instance.attributes, {} + assert_equal serializable_resource.serializer_instance.object, @resource + + expected_errors_object = + { :errors => + [ + { :source => { :pointer => '/data/attributes/name' }, :detail => 'cannot be nil' }, + { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be longer' }, + { :source => { :pointer => '/data/attributes/id' }, :detail => 'must be a uuid' } + ] + } + assert_equal serializable_resource.as_json, expected_errors_object + end + + # see http://jsonapi.org/examples/ + def test_parameter_source_type_error + parameter = 'auther' + error_source = ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:parameter, parameter) + assert_equal({ parameter: parameter }, error_source) + end + + def test_unknown_source_type_error + value = 'auther' + assert_raises(ActiveModelSerializers::Adapter::JsonApi::Error::UnknownSourceTypeError) do + ActiveModelSerializers::Adapter::JsonApi::Error.error_source(:hyper, value) + end + end + end + end + end +end diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb index 445530e40..c40b1ca61 100644 --- a/test/fixtures/poro.rb +++ b/test/fixtures/poro.rb @@ -27,6 +27,17 @@ def cache_key_with_digest end end +# see +# https://github.com/rails/rails/blob/4-2-stable/activemodel/lib/active_model/errors.rb +# The below allows you to do: +# +# model = ModelWithErrors.new +# model.validate! # => ["cannot be nil"] +# model.errors.full_messages # => ["name cannot be nil"] +class ModelWithErrors < ::ActiveModelSerializers::Model + attr_accessor :name +end + class Profile < Model end diff --git a/test/lint_test.rb b/test/lint_test.rb index 0ebdda647..9d0f2bc87 100644 --- a/test/lint_test.rb +++ b/test/lint_test.rb @@ -27,6 +27,15 @@ def id def updated_at end + def errors + end + + def self.human_attribute_name(attr, options = {}) + end + + def self.lookup_ancestors + end + def self.model_name @_model_name ||= ActiveModel::Name.new(self) end diff --git a/test/serializable_resource_test.rb b/test/serializable_resource_test.rb index 698795040..4c683f9b7 100644 --- a/test/serializable_resource_test.rb +++ b/test/serializable_resource_test.rb @@ -31,5 +31,46 @@ def test_use_adapter_with_adapter_option def test_use_adapter_with_adapter_option_as_false refute ActiveModel::SerializableResource.new(@resource, { adapter: false }).use_adapter? end + + class SerializableResourceErrorsTest < Minitest::Test + def test_serializable_resource_with_errors + options = nil + resource = ModelWithErrors.new + resource.errors.add(:name, 'must be awesome') + serializable_resource = ActiveModel::SerializableResource.new( + resource, { + serializer: ActiveModel::Serializer::ErrorSerializer, + adapter: :json_api + }) + expected_response_document = + { :errors => + [ + { :source => { :pointer => '/data/attributes/name' }, :detail => 'must be awesome' } + ] + } + assert_equal serializable_resource.as_json(options), expected_response_document + end + + def test_serializable_resource_with_collection_containing_errors + options = nil + resources = [] + resources << resource = ModelWithErrors.new + resource.errors.add(:title, 'must be amazing') + resources << ModelWithErrors.new + serializable_resource = ActiveModel::SerializableResource.new( + resources, { + serializer: ActiveModel::Serializer::ErrorsSerializer, + each_serializer: ActiveModel::Serializer::ErrorSerializer, + adapter: :json_api + }) + expected_response_document = + { :errors => + [ + { :source => { :pointer => '/data/attributes/title' }, :detail => 'must be amazing' } + ] + } + assert_equal serializable_resource.as_json(options), expected_response_document + end + end end end