diff --git a/README.md b/README.md index 3b42a07ef..0cc142862 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,74 @@ class AuthorResource < JSONAPI::Resource end ``` +#### Custom Links + +You can define custom links for Resource classes with the #custom_link method which takes a symbolized key and a lambda that accepts a source (Resource instance) and a link builder. For example: + +````ruby +class CityCouncilMeeting < JSONAPI::Resource + attribute :title, :location, :approved + + has_one :organizer + + custom_link :minutes, ->(source, link_builder) { link_builder.self_link(source) + "/minutes" } +end +```` + +This will create a custom link with the key `minutes` that is generated using the lambda. Here's an example JSON output + +```` +{ + "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": {...} + }, + //... + ] +} +```` + +If you set an if condition in the lambda that is not met the custom link will simply be set to null. Here's an example using the same class as above with a slight modification to the lambda. + +````ruby +class CityCouncilMeeting < JSONAPI::Resource + attribute :title, :location, :approved + + delegate :approved?, to: :model + + has_one :organizer + + custom_link :minutes, ->(source, link_builder) do + if source.approved? + link_builder.self_link(source) + "/minutes" + end + end +end +```` + +```` +{ + "data": [ + { + "id": "2", + "type": "cityCouncilMeetings", + "links": { + "self": "http://city.gov/api/city-council-meetings/2", + "minutes": null + }, + "attribute": {...} + }, + //... + ] +} +```` + #### Relationships Related resources need to be specified in the resource. These may be declared with the `relationship` or the `has_one` diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 122f3b66c..8cc223141 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -49,8 +49,37 @@ def self_link(source) end end + def build_custom_links(source) + link_instructions = source.class.custom_links + custom_links = {} + + link_instructions.each do |key, custom_link_lambda| + custom_links[key] = custom_link_lambda.call(source, self) + end + + custom_links + end + private + def self_link_extension(key, source, link_info={}) + extension = link_info[:ext] + path = link_info[:path] || key + + url = "#{ self_link(source) }/#{ path }" + url += ".#{extension}" if extension + + url + end + + def build_custom_link(key, source, link_info) + extension = link_info[:ext] + url = link_info[:with].call(source) + url += ".#{ extension }" if extension + + url + end + def build_engine_name scopes = module_scopes_from_class(primary_resource_klass) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 5bde68d6d..b6888fc9a 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -265,6 +265,7 @@ def inherited(base) base._attributes = (_attributes || {}).dup base._relationships = (_relationships || {}).dup base._allowed_filters = (_allowed_filters || Set.new).dup + base._custom_links = (_custom_links || {}).dup type = base.name.demodulize.sub(/Resource$/, '').underscore base._type = type.pluralize.to_sym @@ -283,7 +284,15 @@ def resource_for(type) resource end - attr_accessor :_attributes, :_relationships, :_allowed_filters, :_type, :_paginator + attr_accessor :_attributes, :_relationships, :_allowed_filters, :_type, :_paginator, :_custom_links + + def custom_links + @_custom_links ||= {} + end + + def custom_link(name, func) + @_custom_links[name.to_sym] = func + end def create(context) new(create_model, context) diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 73ee8b7f9..96a25afe5 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -206,6 +206,11 @@ def relationship_links(source) links = {} links[:self] = url_generator.self_link(source) + if source.class.custom_links + customized_links = url_generator.build_custom_links(source) + links.merge!(customized_links) + end + links end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index c27adf0de..a3f7eb138 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1051,6 +1051,83 @@ class WebPageResource < JSONAPI::Resource attribute :link 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 + + custom_link :raw, ->(source, link_builder) { link_builder.self_link(source) + "/raw" } +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 + + custom_link :raw, ->(source, link_builder) do + link_builder.self_link(source) + "/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 + + custom_link :conditional_custom_link, ->(source, link_builder) do + if source.title == "JR Solves your serialization woes!" + link_builder.self_link(source) + "/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 + + custom_link :link_to_external_api, ->(source, link_builder) do + "http://external-api.com/posts/#{ source.created_at.year }/#{ source.created_at.month }/#{ source.created_at.day }-#{ source.subject.gsub(' ', '-') }" + end +end + + module Api module V1 class WriterResource < JSONAPI::Resource diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 7088fc3bd..85dc2ac3a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -51,6 +51,35 @@ class MyNamespacedResource < JSONAPI::Resource end end +class CustomLinkTestResource < JSONAPI::Resource + model_name 'Post' + + custom_link :raw, ->(source, link_builder) { link_builder.self_link(source) + "/raw" } +end + +class CustomLinkTestWithExtensionResource < JSONAPI::Resource + model_name 'Post' + + custom_link :raw, ->(source, link_builder) { link_builder.self_link(source) + "/raw.xml" } +end + +class CustomLinkTestWithCustomPathResource < JSONAPI::Resource + model_name 'Post' + + custom_link :raw, ->(source, link_builder) do + link_builder.self_link(source) + "/super/duper/path.xml" + end +end + +class CustomLinkLambda< JSONAPI::Resource + model_name 'Post' + + custom_link :raw, ->(source, link_builder) do + "http://external-api/posts/" + + "#{ instance.created_at.year }/#{ instance.created_at.month}/#{ instance.created_at.day }" + end +end + class ResourceTest < ActiveSupport::TestCase def setup @post = Post.first diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 75e5928bf..f1702890d 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -1871,4 +1871,228 @@ 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", + conditional_custom_link: nil + }, + 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