diff --git a/README.md b/README.md index da2db61dc..d6d284f53 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ backed by ActiveRecord models or by custom objects. * [Pagination] (#pagination) * [Included relationships (side-loading resources)] (#included-relationships-side-loading-resources) * [Resource meta] (#resource-meta) + * [Custom Links] (#resource-meta) * [Callbacks] (#callbacks) * [Controllers] (#controllers) * [Namespaces] (#namespaces) @@ -929,6 +930,72 @@ method is called with an `options` has. The `options` hash will contain the foll * `:serializer` -> the serializer instance * `:serialization_options` -> the contents of the `serialization_options` method on the controller. +#### Custom Links + +Custom links can be included for each resource by overriding the `custom_links` method. If a non empty hash is returned from `custom_links`, it will be merged with the default links hash containing the resource's `self` link. The `custom_links` method is called with the same `options` hash used by for [resource meta information](#resource-meta). The `options` hash contains the following: + + * `:serializer` -> the serializer instance + * `:serialization_options` -> the contents of the `serialization_options` method on the controller. + +For example: + +```ruby +class CityCouncilMeeting < JSONAPI::Resource + attribute :title, :location, :approved + + def custom_links(options) + { minutes: options[:serialzer].link_builder.self_link(self) + "/minutes" } + end +end +``` + +This will create a custom link with the key `minutes`, which will be merged with the default `self` link, like so: + +```json +{ + "data": [ + { + "id": "1", + "type": "cityCouncilMeetings", + "links": { + "self": "http://city.gov/api/city-council-meetings/1", + "minutes": "http://city.gov/api/city-council-meetings/1/minutes" + }, + "attributes": {...} + }, + //... + ] +} +``` + +Of course, the `custom_links` method can include logic to include links only when relevant: + +````ruby +class CityCouncilMeeting < JSONAPI::Resource + attribute :title, :location, :approved + + delegate :approved?, to: :model + + def custom_links(options) + extra_links = {} + if approved? + extra_links[:minutes] = options[:serialzer].link_builder.self_link(self) + "/minutes" + end + extra_links + end +end +``` + +It's also possibly to suppress the default `self` link by returning a hash with `{self: nil}`: + +````ruby +class Selfless < JSONAPI::Resource + def custom_links(options) + {self: nil} + end +end +``` + #### Callbacks `ActiveSupport::Callbacks` is used to provide callback functionality, so the behavior is very similar to what you may be diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 03f303c22..f64560fb8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -152,6 +152,15 @@ def meta(_options) {} end + # Override this to return custom links + # must return a hash, which will be merged with the default { self: 'self-url' } links hash + # links keys will be not be formatted with the key formatter for the serializer by default. + # They can however use the serializer's format_key and format_value methods if desired + # the _options hash will contain the serializer and the serialization_options + def custom_links(_options) + {} + end + private def save diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 8e4edfcb9..89d5407b0 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -114,19 +114,18 @@ def object_hash(source, include_directives) obj_hash['type'] = format_key(source.class._type.to_s) - links = relationship_links(source) + links = links_hash(source) obj_hash['links'] = links unless links.empty? - attributes = attribute_hash(source) + attributes = attributes_hash(source) obj_hash['attributes'] = attributes unless attributes.empty? - relationships = relationship_data(source, include_directives) + relationships = relationships_hash(source, include_directives) obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? - meta = source.meta(custom_generation_options) - if meta.is_a?(Hash) && !meta.empty? - obj_hash['meta'] = meta - end + meta = meta_hash(source) + obj_hash['meta'] = meta unless meta.empty? + obj_hash end @@ -139,7 +138,7 @@ def requested_fields(klass) end end - def attribute_hash(source) + def attributes_hash(source) requested = requested_fields(source.class) fields = source.fetchable_fields & source.class._attributes.keys.to_a fields = requested & fields unless requested.nil? @@ -159,7 +158,23 @@ def custom_generation_options } end - def relationship_data(source, include_directives) + def meta_hash(source) + meta = source.meta(custom_generation_options) + (meta.is_a?(Hash) && meta) || {} + end + + def links_hash(source) + { + self: link_builder.self_link(source) + }.merge(custom_links_hash(source)).compact + end + + def custom_links_hash(source) + custom_links = source.custom_links(custom_generation_options) + (custom_links.is_a?(Hash) && custom_links) || {} + end + + def relationships_hash(source, include_directives) relationships = source.class._relationships requested = requested_fields(source.class) fields = relationships.keys @@ -197,7 +212,7 @@ def relationship_data(source, include_directives) if include_linkage && !relationships_only add_included_object(id, object_hash(resource, ia)) elsif include_linked_children || relationships_only - relationship_data(resource, ia) + relationships_hash(resource, ia) end end elsif relationship.is_a?(JSONAPI::Relationship::ToMany) @@ -208,7 +223,7 @@ def relationship_data(source, include_directives) if include_linkage && !relationships_only add_included_object(id, object_hash(resource, ia)) elsif include_linked_children || relationships_only - relationship_data(resource, ia) + relationships_hash(resource, ia) end end end @@ -217,13 +232,6 @@ def relationship_data(source, include_directives) end end - def relationship_links(source) - links = {} - links[:self] = link_builder.self_link(source) - - links - end - def already_serialized?(type, id) type = format_key(type) @included_objects.key?(type) && @included_objects[type].key?(id) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 244ee95ff..d9cc2ceb4 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1125,6 +1125,87 @@ class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff end +class SimpleCustomLinkResource < JSONAPI::Resource + model_name 'Post' + attributes :title, :body, :subject + + def subject + @model.title + end + + has_one :writer, foreign_key: 'author_id', class_name: 'Writer' + has_one :section + has_many :comments, acts_as_set: false + + filters :writer + + def custom_links(options) + { raw: options[:serializer].link_builder.self_link(self) + "/raw" } + end +end + +class CustomLinkWithRelativePathOptionResource < JSONAPI::Resource + model_name 'Post' + attributes :title, :body, :subject + + def subject + @model.title + end + + has_one :writer, foreign_key: 'author_id', class_name: 'Writer' + has_one :section + has_many :comments, acts_as_set: false + + filters :writer + + def custom_links(options) + { raw: options[:serializer].link_builder.self_link(self) + "/super/duper/path.xml" } + end +end + +class CustomLinkWithIfCondition < JSONAPI::Resource + model_name 'Post' + attributes :title, :body, :subject + + def subject + @model.title + end + + has_one :writer, foreign_key: 'author_id', class_name: 'Writer' + has_one :section + has_many :comments, acts_as_set: false + + filters :writer + + def custom_links(options) + if title == "JR Solves your serialization woes!" + {conditional_custom_link: options[:serializer].link_builder.self_link(self) + "/conditional/link.json"} + end + end +end + +class CustomLinkWithLambda < JSONAPI::Resource + model_name 'Post' + attributes :title, :body, :subject, :created_at + + def subject + @model.title + end + + has_one :writer, foreign_key: 'author_id', class_name: 'Writer' + has_one :section + has_many :comments, acts_as_set: false + + filters :writer + + def custom_links(options) + { + link_to_external_api: "http://external-api.com/posts/#{ created_at.year }/#{ created_at.month }/#{ created_at.day }-#{ subject.gsub(' ', '-') }" + } + end +end + + module Api module V1 class WriterResource < JSONAPI::Resource diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index ce1721a98..117831662 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -1936,4 +1936,227 @@ class ::Questionable2Resource < JSONAPI::Resource out.strip ) end + + def test_simple_custom_links + serialized_custom_link_resource = JSONAPI::ResourceSerializer.new(SimpleCustomLinkResource, base_url: 'http://example.com').serialize_to_hash(SimpleCustomLinkResource.new(Post.first, {})) + + custom_link_spec = { + data: { + type: 'simpleCustomLinks', + id: '1', + attributes: { + title: "New post", + body: "A body!!!", + subject: "New post" + }, + links: { + self: "http://example.com/simpleCustomLinks/1", + raw: "http://example.com/simpleCustomLinks/1/raw" + }, + relationships: { + writer: { + links: { + self: "http://example.com/simpleCustomLinks/1/relationships/writer", + related: "http://example.com/simpleCustomLinks/1/writer" + } + }, + section: { + links: { + self: "http://example.com/simpleCustomLinks/1/relationships/section", + related: "http://example.com/simpleCustomLinks/1/section" + } + }, + comments: { + links: { + self: "http://example.com/simpleCustomLinks/1/relationships/comments", + related: "http://example.com/simpleCustomLinks/1/comments" + } + } + } + } + } + + assert_hash_equals(custom_link_spec, serialized_custom_link_resource) + end + + def test_custom_links_with_custom_relative_paths + serialized_custom_link_resource = JSONAPI::ResourceSerializer + .new(CustomLinkWithRelativePathOptionResource, base_url: 'http://example.com') + .serialize_to_hash(CustomLinkWithRelativePathOptionResource.new(Post.first, {})) + + custom_link_spec = { + data: { + type: 'customLinkWithRelativePathOptions', + id: '1', + attributes: { + title: "New post", + body: "A body!!!", + subject: "New post" + }, + links: { + self: "http://example.com/customLinkWithRelativePathOptions/1", + raw: "http://example.com/customLinkWithRelativePathOptions/1/super/duper/path.xml" + }, + relationships: { + writer: { + links: { + self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/writer", + related: "http://example.com/customLinkWithRelativePathOptions/1/writer" + } + }, + section: { + links: { + self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/section", + related: "http://example.com/customLinkWithRelativePathOptions/1/section" + } + }, + comments: { + links: { + self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/comments", + related: "http://example.com/customLinkWithRelativePathOptions/1/comments" + } + } + } + } + } + + assert_hash_equals(custom_link_spec, serialized_custom_link_resource) + end + + def test_custom_links_with_if_condition_equals_false + serialized_custom_link_resource = JSONAPI::ResourceSerializer + .new(CustomLinkWithIfCondition, base_url: 'http://example.com') + .serialize_to_hash(CustomLinkWithIfCondition.new(Post.first, {})) + + custom_link_spec = { + data: { + type: 'customLinkWithIfConditions', + id: '1', + attributes: { + title: "New post", + body: "A body!!!", + subject: "New post" + }, + links: { + self: "http://example.com/customLinkWithIfConditions/1", + }, + relationships: { + writer: { + links: { + self: "http://example.com/customLinkWithIfConditions/1/relationships/writer", + related: "http://example.com/customLinkWithIfConditions/1/writer" + } + }, + section: { + links: { + self: "http://example.com/customLinkWithIfConditions/1/relationships/section", + related: "http://example.com/customLinkWithIfConditions/1/section" + } + }, + comments: { + links: { + self: "http://example.com/customLinkWithIfConditions/1/relationships/comments", + related: "http://example.com/customLinkWithIfConditions/1/comments" + } + } + } + } + } + + assert_hash_equals(custom_link_spec, serialized_custom_link_resource) + end + + def test_custom_links_with_if_condition_equals_true + serialized_custom_link_resource = JSONAPI::ResourceSerializer + .new(CustomLinkWithIfCondition, base_url: 'http://example.com') + .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) + + custom_link_spec = { + data: { + type: 'customLinkWithIfConditions', + id: '2', + attributes: { + title: "JR Solves your serialization woes!", + body: "Use JR", + subject: "JR Solves your serialization woes!" + }, + links: { + self: "http://example.com/customLinkWithIfConditions/2", + conditional_custom_link: "http://example.com/customLinkWithIfConditions/2/conditional/link.json" + }, + relationships: { + writer: { + links: { + self: "http://example.com/customLinkWithIfConditions/2/relationships/writer", + related: "http://example.com/customLinkWithIfConditions/2/writer" + } + }, + section: { + links: { + self: "http://example.com/customLinkWithIfConditions/2/relationships/section", + related: "http://example.com/customLinkWithIfConditions/2/section" + } + }, + comments: { + links: { + self: "http://example.com/customLinkWithIfConditions/2/relationships/comments", + related: "http://example.com/customLinkWithIfConditions/2/comments" + } + } + } + } + } + + assert_hash_equals(custom_link_spec, serialized_custom_link_resource) + end + + + def test_custom_links_with_lambda + # custom link is based on created_at timestamp of Post + post_created_at = Post.first.created_at + serialized_custom_link_resource = JSONAPI::ResourceSerializer + .new(CustomLinkWithLambda, base_url: 'http://example.com') + .serialize_to_hash(CustomLinkWithLambda.new(Post.first, {})) + + custom_link_spec = { + data: { + type: 'customLinkWithLambdas', + id: '1', + attributes: { + title: "New post", + body: "A body!!!", + subject: "New post", + createdAt: post_created_at + }, + links: { + self: "http://example.com/customLinkWithLambdas/1", + link_to_external_api: "http://external-api.com/posts/#{post_created_at.year}/#{post_created_at.month}/#{post_created_at.day}-New-post" + }, + relationships: { + writer: { + links: { + self: "http://example.com/customLinkWithLambdas/1/relationships/writer", + related: "http://example.com/customLinkWithLambdas/1/writer" + } + }, + section: { + links: { + self: "http://example.com/customLinkWithLambdas/1/relationships/section", + related: "http://example.com/customLinkWithLambdas/1/section" + } + }, + comments: { + links: { + self: "http://example.com/customLinkWithLambdas/1/relationships/comments", + related: "http://example.com/customLinkWithLambdas/1/comments" + } + } + } + } + } + + assert_hash_equals(custom_link_spec, serialized_custom_link_resource) + end + + end