├── .rspec ├── Gemfile ├── lib ├── halogen │ ├── version.rb │ ├── properties │ │ └── definition.rb │ ├── errors.rb │ ├── railtie.rb │ ├── configuration.rb │ ├── definitions.rb │ ├── hash_util.rb │ ├── properties.rb │ ├── links.rb │ ├── embeds │ │ └── definition.rb │ ├── collection.rb │ ├── resource.rb │ ├── definition.rb │ ├── links │ │ └── definition.rb │ └── embeds.rb └── halogen.rb ├── Rakefile ├── spec ├── spec_helper.rb ├── halogen │ ├── configuration_spec.rb │ ├── definitions_spec.rb │ ├── properties_spec.rb │ ├── collection_spec.rb │ ├── embeds │ │ └── definition_spec.rb │ ├── resource_spec.rb │ ├── links │ │ └── definition_spec.rb │ ├── definition_spec.rb │ ├── links_spec.rb │ └── embeds_spec.rb └── halogen_spec.rb ├── .gitignore ├── examples ├── extensions.md └── simple.rb ├── LICENSE.txt ├── halogen.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/halogen/version.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | VERSION = '0.0.9' # :nodoc: 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /lib/halogen/properties/definition.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Properties 3 | class Definition < Halogen::Definition # :nodoc 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/spec' 4 | end 5 | 6 | require 'bundler/setup' 7 | Bundler.setup 8 | 9 | require 'halogen' 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | *.gem 16 | -------------------------------------------------------------------------------- /lib/halogen/errors.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | class InvalidCollection < StandardError; end # :nodoc 3 | class InvalidDefinition < StandardError; end # :nodoc 4 | class InvalidResource < StandardError; end # :nodoc 5 | end 6 | -------------------------------------------------------------------------------- /spec/halogen/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Configuration do 2 | describe '#extensions' do 3 | it 'is empty array by default' do 4 | expect(Halogen::Configuration.new.extensions).to eq([]) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/halogen/railtie.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Provide Rails-specific extensions if loaded in a Rails application 3 | # 4 | class Railtie < ::Rails::Railtie 5 | initializer 'halogen' do |_app| 6 | Halogen.config.extensions << ::Rails.application.routes.url_helpers 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/halogen/configuration.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Simple configuration class 3 | # 4 | class Configuration 5 | # Array of extension modules to be included in all representers 6 | # 7 | # @return [Array] 8 | # 9 | def extensions 10 | @extensions ||= [] 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/halogen/definitions_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Definitions do 2 | let :definitions do 3 | Halogen::Definitions.new 4 | end 5 | 6 | let :definition do 7 | Halogen::Definition.new(:name, {}, nil) 8 | end 9 | 10 | describe '#add' do 11 | it 'validates and adds definition' do 12 | expect(definitions.keys).to eq([]) 13 | 14 | definitions.add(definition) 15 | 16 | expect(definitions.keys).to eq(['Halogen::Definition']) 17 | expect(definitions['Halogen::Definition']).to eq([definition]) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/halogen/definitions.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Each representer class has a Halogen::Definitions instance which stores 3 | # Halogen::Definition instances by type. 4 | # 5 | class Definitions < Hash 6 | # @param definition [Halogen::Definition] 7 | # 8 | # @return [Halogen::Definition] the added definition 9 | # 10 | def add(definition) 11 | type = definition.class.name 12 | 13 | definition.validate 14 | 15 | self[type] ||= [] 16 | self[type] << definition 17 | 18 | definition 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/halogen/hash_util.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module HashUtil # :nodoc: 3 | extend self 4 | 5 | # Transform hash keys into strings if necessary 6 | # 7 | # @param hash [Hash] 8 | # 9 | # @return [Hash] 10 | # 11 | def stringify_keys!(hash) 12 | hash.transform_keys!(&:to_s) 13 | end 14 | 15 | # Transform hash keys into symbols if necessary 16 | # 17 | # @param hash [Hash] 18 | # 19 | # @return [Hash] 20 | # 21 | def symbolize_keys!(hash) 22 | hash.transform_keys!(&:to_sym) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /examples/extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | You can extend Halogen by configuring it to include your own Ruby modules. 4 | 5 | For instance, if you wanted to cache the rendered versions of your 6 | representers, you might use something like this to override the default 7 | `#render` behavior: 8 | 9 | ```ruby 10 | module MyCachingExtension 11 | def render 12 | Rails.cache.fetch(cache_key) { super } 13 | end 14 | 15 | def cache_key 16 | ... 17 | end 18 | end 19 | ``` 20 | 21 | ```ruby 22 | Halogen.configure do |config| 23 | config.extensions << MyCachingExtension 24 | end 25 | ``` 26 | -------------------------------------------------------------------------------- /spec/halogen/properties_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Properties do 2 | let :klass do 3 | Class.new { include Halogen } 4 | end 5 | 6 | describe Halogen::Properties::ClassMethods do 7 | describe '#property' do 8 | it 'defines property' do 9 | expect { 10 | klass.property(:foo) 11 | }.to change(klass.definitions, :size).by(1) 12 | end 13 | end 14 | end 15 | 16 | describe Halogen::Properties::InstanceMethods do 17 | let :repr do 18 | klass.new 19 | end 20 | 21 | describe '#render' do 22 | it 'merges super with rendered properties' do 23 | allow(repr).to receive(:properties).and_return(foo: 'bar') 24 | 25 | expect(repr.render).to eq(foo: 'bar') 26 | end 27 | end 28 | 29 | describe '#properties' do 30 | it 'builds properties from definitions' do 31 | klass.property(:foo, value: 'bar') 32 | 33 | expect(repr.properties).to eq(foo: 'bar') 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/halogen/properties.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Properties # :nodoc: 3 | def self.included(base) # :nodoc: 4 | base.extend ClassMethods 5 | 6 | base.send :include, InstanceMethods 7 | end 8 | 9 | module ClassMethods # :nodoc: 10 | # @param name [Symbol, String] 11 | # @param options [nil, Hash] 12 | # 13 | # @return [Halogen::Properties::Definition] 14 | # 15 | def property(name, options = {}, &procedure) 16 | definitions.add(Definition.new(name, options, procedure)) 17 | end 18 | end 19 | 20 | module InstanceMethods # :nodoc: 21 | # @return [Hash] the rendered hash with properties, if any 22 | # 23 | def render 24 | super.merge(properties) 25 | end 26 | 27 | # @return [Hash] properties from definitions 28 | # 29 | def properties 30 | render_definitions(Definition.name) do |definition, result| 31 | result[definition.name] = definition.value(self) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | 38 | require 'halogen/properties/definition' 39 | -------------------------------------------------------------------------------- /lib/halogen/links.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Links # :nodoc: 3 | def self.included(base) # :nodoc: 4 | base.extend ClassMethods 5 | 6 | base.send :include, InstanceMethods 7 | end 8 | 9 | module ClassMethods # :nodoc: 10 | # @return [Halogen::Links::Definition] 11 | # 12 | def link(name, *args, &procedure) 13 | definitions.add(Definition.new(name, *args, procedure)) 14 | end 15 | end 16 | 17 | module InstanceMethods # :nodoc: 18 | # @return [Hash] the rendered hash with links, if any 19 | # 20 | def render 21 | if options.fetch(:include_links, true) 22 | decorate_render :links, super 23 | else 24 | super 25 | end 26 | end 27 | 28 | # @return [Hash] links from definitions 29 | # 30 | def links 31 | render_definitions(Definition.name) do |definition, result| 32 | value = definition.value(self) 33 | 34 | result[definition.name] = value if value 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | require 'halogen/links/definition' 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Heather Rivers 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /halogen.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'halogen/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'halogen' 7 | spec.version = Halogen::VERSION 8 | spec.authors = ['Heather Rivers'] 9 | spec.email = ['heather@modeanalytics.com'] 10 | spec.summary = 'HAL+JSON generator' 11 | spec.description = 'Provides a framework-agnostic interface for ' \ 12 | 'generating HAL+JSON representations of resources' 13 | spec.homepage = 'https://github.com/mode/halogen' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(/^(test|spec|features)\//) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.required_ruby_version = '> 2.6', '< 4' 22 | 23 | spec.add_dependency 'json', '> 2.3.0' 24 | 25 | spec.add_development_dependency 'bundler', '>= 2.0' 26 | spec.add_development_dependency 'rake', '~> 13.0' 27 | spec.add_development_dependency 'rspec', '~> 3.2' 28 | spec.add_development_dependency 'simplecov', '~> 0.9' 29 | spec.add_development_dependency 'yard', '~> 0.9.11' 30 | end 31 | -------------------------------------------------------------------------------- /lib/halogen/embeds/definition.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Embeds 3 | class Definition < Halogen::Definition # :nodoc: 4 | # @return [true] if nothing is raised 5 | # 6 | # @raise [Halogen::InvalidDefinition] if the definition is invalid 7 | # 8 | def validate 9 | super 10 | 11 | return true if procedure 12 | 13 | fail InvalidDefinition, "Embed #{name} must be defined with a proc" 14 | end 15 | 16 | # Check whether this definition should be embedded for the given instance 17 | # 18 | # @param instance [Object] 19 | # 20 | # @return [true, false] 21 | # 22 | def enabled?(instance) 23 | return false unless super 24 | 25 | if instance.respond_to?(:embed?) 26 | instance.embed?(name.to_s) 27 | else 28 | embed_via_options?(instance) 29 | end 30 | end 31 | 32 | private 33 | 34 | # @param instance [Object] 35 | # 36 | # @return [true, false] 37 | # 38 | def embed_via_options?(instance) 39 | opts = instance.embed_options 40 | 41 | # Definition name must appear in instance embed option keys 42 | return false unless opts.include?(name.to_s) 43 | 44 | # Check value of embed option for definition name 45 | !%w(0 false).include?(opts.fetch(name.to_s).to_s) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/halogen/collection.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Behavior for representers with a primary collection resource. 3 | # 4 | # The main reason to declare a collection is that the resource with that name 5 | # will always be embedded during rendering. 6 | # 7 | module Collection 8 | def self.included(base) # :nodoc: 9 | if base.included_modules.include?(Resource) 10 | fail InvalidCollection, "#{base.name} has already defined a resource" 11 | end 12 | 13 | base.extend ClassMethods 14 | 15 | base.send :include, InstanceMethods 16 | 17 | base.class.send :attr_accessor, :collection_name 18 | end 19 | 20 | module ClassMethods # :nodoc: 21 | # @param name [Symbol, String] name of the collection 22 | # 23 | # @return [Module] self 24 | # 25 | def define_collection(name) 26 | self.collection_name = name.to_s 27 | end 28 | 29 | def collection? 30 | true 31 | end 32 | end 33 | 34 | module InstanceMethods # :nodoc: 35 | # Ensure that the primary collection is always embedded 36 | # 37 | # @param key [String] the embed key to check 38 | # 39 | # @return [true, false] whether the given key should be embedded 40 | # 41 | def embed?(key) 42 | key == self.class.collection_name 43 | end 44 | 45 | def collection? 46 | true 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/halogen/resource.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Behavior for representers with a single primary resource 3 | # 4 | module Resource 5 | def self.included(base) # :nodoc: 6 | if base.included_modules.include?(Collection) 7 | fail InvalidResource, "#{base.name} has already defined a collection" 8 | end 9 | 10 | base.extend ClassMethods 11 | 12 | base.send :include, InstanceMethods 13 | 14 | base.send :attr_reader, :resource 15 | 16 | base.class.send :attr_accessor, :resource_name 17 | end 18 | 19 | module ClassMethods # :nodoc: 20 | # @param name [Symbol, String] name of the resource 21 | # 22 | # @return [Module] self 23 | # 24 | def define_resource(name) 25 | self.resource_name = name.to_s 26 | 27 | alias_method name, :resource 28 | end 29 | 30 | # Override standard property definition for resource-based representers 31 | # 32 | # @param name [Symbol, String] name of the property 33 | # @param options [nil, Hash] property options for definition 34 | # 35 | def property(name, options = {}, &procedure) 36 | super.tap do |definition| 37 | unless definition.procedure || definition.options.key?(:value) 38 | definition.procedure = proc { resource.send(name) } 39 | end 40 | end 41 | end 42 | end 43 | 44 | module InstanceMethods # :nodoc: 45 | # Override standard initializer to assign primary resource 46 | # 47 | # @param resource [Object] the primary resource 48 | # 49 | def initialize(resource, *args) 50 | @resource = resource 51 | 52 | super *args 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/halogen/collection_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Collection do 2 | let :klass do 3 | Class.new do 4 | include Halogen 5 | include Halogen::Collection 6 | end 7 | end 8 | 9 | describe '.included' do 10 | it 'raises error if base is already a resource' do 11 | resource_class = Class.new do 12 | include Halogen 13 | include Halogen::Resource 14 | end 15 | 16 | expect { 17 | resource_class.send :include, Halogen::Collection 18 | }.to raise_error do |exception| 19 | expect(exception).to be_an_instance_of(Halogen::InvalidCollection) 20 | expect(exception.message).to match(/has already defined a resource/i) 21 | end 22 | end 23 | end 24 | 25 | describe Halogen::Collection::ClassMethods do 26 | describe '#define_collection' do 27 | it 'handles string argument' do 28 | klass.define_collection 'goats' 29 | 30 | expect(klass.collection_name).to eq('goats') 31 | end 32 | 33 | it 'handles symbol argument' do 34 | klass.define_collection :goats 35 | 36 | expect(klass.collection_name).to eq('goats') 37 | end 38 | end 39 | end 40 | 41 | describe Halogen::Collection::InstanceMethods do 42 | describe '#embed?' do 43 | it 'returns true if key matches collection name' do 44 | klass.collection_name = 'foo' 45 | 46 | expect(klass.new.embed?('foo')).to eq(true) 47 | end 48 | 49 | it 'returns false if key does not match' do 50 | klass.collection_name = 'bar' 51 | 52 | expect(klass.new.embed?('foo')).to eq(false) 53 | end 54 | end 55 | 56 | describe '#collection?' do 57 | it 'is true' do 58 | repr = klass.new 59 | 60 | expect(repr.collection?).to eq(true) 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/halogen/embeds/definition_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Embeds::Definition do 2 | describe '#validate' do 3 | it 'returns true with procedure' do 4 | result = Halogen::Embeds::Definition.new(:name, {}, proc {}).validate 5 | 6 | expect(result).to eq(true) 7 | end 8 | 9 | it 'raises exception without procedure' do 10 | expect { 11 | Halogen::Embeds::Definition.new(:name, {}, nil).validate 12 | }.to raise_error do |exception| 13 | expect(exception).to be_an_instance_of(Halogen::InvalidDefinition) 14 | expect(exception.message).to( 15 | eq('Embed name must be defined with a proc')) 16 | end 17 | end 18 | end 19 | 20 | describe '#enabled?' do 21 | let :definition do 22 | Halogen::Embeds::Definition.new(:name, {}, proc {}) 23 | end 24 | 25 | it 'is true if instance rules return true' do 26 | repr = double(:repr, embed?: true) 27 | 28 | expect(definition.enabled?(repr)).to eq(true) 29 | end 30 | 31 | it 'is false if instance rules return false' do 32 | repr = double(:repr, embed?: false) 33 | 34 | expect(definition.enabled?(repr)).to eq(false) 35 | end 36 | end 37 | 38 | describe '#embed_via_options?' do 39 | let :klass do 40 | Class.new { include Halogen } 41 | end 42 | 43 | it 'is true for expected values' do 44 | [1, 2, true, '1', '2', 'true', 'yes'].each do |value| 45 | repr = klass.new(embed: { foo: value }) 46 | 47 | definition = Halogen::Embeds::Definition.new(:foo, {}, proc {}) 48 | 49 | expect(definition.send(:embed_via_options?, repr)).to eq(true) 50 | end 51 | end 52 | 53 | it 'is false for expected values' do 54 | [0, false, '0', 'false'].each do |value| 55 | repr = klass.new(embed: { foo: value }) 56 | 57 | definition = Halogen::Embeds::Definition.new(:foo, {}, proc {}) 58 | 59 | expect(definition.send(:embed_via_options?, repr)).to eq(false) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/halogen/definition.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | # Stores instructions for how to render a value for a given representer 3 | # instance 4 | # 5 | class Definition 6 | attr_reader :name, :options 7 | 8 | attr_accessor :procedure 9 | 10 | # Construct a new Definition instance 11 | # 12 | # @param name [Symbol, String] definition name 13 | # @param options [Hash] hash of options 14 | # 15 | # @return [Halogen::Definition] the instance 16 | # 17 | def initialize(name, options, procedure) 18 | @name = name.to_sym 19 | @options = Halogen::HashUtil.symbolize_keys!(options) 20 | @procedure = procedure 21 | end 22 | 23 | # @param instance [Object] the representer instance with which to evaluate 24 | # the stored procedure 25 | # 26 | def value(instance) 27 | options.fetch(:value) do 28 | procedure ? instance.instance_eval(&procedure) : instance.send(name) 29 | end 30 | end 31 | 32 | # @return [true, false] whether this definition should be included based on 33 | # its conditional guard, if any 34 | # 35 | def enabled?(instance) 36 | if options.key?(:if) 37 | !!eval_guard(instance, options.fetch(:if)) 38 | elsif options.key?(:unless) 39 | !eval_guard(instance, options.fetch(:unless)) 40 | else 41 | true 42 | end 43 | end 44 | 45 | # @return [true] if nothing is raised 46 | # 47 | # @raise [Halogen::InvalidDefinition] if the definition is invalid 48 | # 49 | def validate 50 | return true unless options.key?(:value) && procedure 51 | 52 | fail InvalidDefinition, 53 | "Cannot specify both value and procedure for #{name}" 54 | end 55 | 56 | private 57 | 58 | # Evaluate guard procedure or method 59 | # 60 | def eval_guard(instance, guard) 61 | case guard 62 | when Proc 63 | instance.instance_eval(&guard) 64 | when Symbol, String 65 | instance.send(guard) 66 | else 67 | guard 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/halogen/links/definition.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Links 3 | class Definition < Halogen::Definition # :nodoc 4 | # Links have special keywords that other definitions don't, so override 5 | # the standard initializer to build options from keywords 6 | # 7 | def initialize(name, *args, procedure) 8 | super name, self.class.build_options(args), procedure 9 | end 10 | 11 | # @return [true] if nothing is raised 12 | # 13 | # @raise [Halogen::InvalidDefinition] if the definition is invalid 14 | # 15 | def validate 16 | super 17 | 18 | return true if procedure || options.key?(:value) 19 | 20 | fail InvalidDefinition, 21 | 'Link requires either procedure or explicit value' 22 | end 23 | 24 | # @return [nil, Hash] 25 | # 26 | def value(_instance) 27 | hrefs = super 28 | 29 | attrs = options.fetch(:attrs, {}) 30 | 31 | case hrefs 32 | when Array 33 | hrefs.map { |href| attrs.merge(href: href) } 34 | when nil 35 | # no-op 36 | else 37 | attrs.merge(href: hrefs) 38 | end 39 | end 40 | 41 | class << self 42 | # Build hash of options from flexible definition arguments 43 | # 44 | # @param args [Array] the raw definition arguments 45 | # 46 | # @return [Hash] standardized hash of options 47 | # 48 | def build_options(args) 49 | {}.tap do |options| 50 | options.merge!(args.pop) if args.last.is_a?(Hash) 51 | 52 | options[:attrs] ||= {} 53 | options[:attrs].merge!(build_attrs(args)) 54 | end 55 | end 56 | 57 | # @param keywords [Array] array of special keywords 58 | # 59 | # @raise [Halogen::InvalidDefinition] if a keyword is unrecognized 60 | # 61 | def build_attrs(keywords) 62 | keywords.each_with_object({}) do |keyword, attrs| 63 | case keyword 64 | when :templated, 'templated' 65 | attrs[:templated] = true 66 | else 67 | fail InvalidDefinition, "Unrecognized link keyword: #{keyword}" 68 | end 69 | end 70 | end 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/halogen/resource_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Resource do 2 | let :klass do 3 | Class.new do 4 | include Halogen 5 | include Halogen::Resource 6 | end 7 | end 8 | 9 | describe '.included' do 10 | it 'raises error if base is already a collection' do 11 | resource_class = Class.new do 12 | include Halogen 13 | include Halogen::Collection 14 | end 15 | 16 | expect { 17 | resource_class.send :include, Halogen::Resource 18 | }.to raise_error do |exception| 19 | expect(exception).to be_an_instance_of(Halogen::InvalidResource) 20 | expect(exception.message).to match(/has already defined a collection/i) 21 | end 22 | end 23 | end 24 | 25 | describe Halogen::Resource::ClassMethods do 26 | describe '#define_resource' do 27 | it 'defines resource' do 28 | klass.define_resource :foo 29 | 30 | expect(klass.resource_name).to eq('foo') 31 | expect(klass.new(nil).respond_to?(:foo)).to eq(true) 32 | end 33 | end 34 | 35 | describe '#property' do 36 | it 'returns result of super if procedure is present' do 37 | original = proc { 'bar' } 38 | 39 | definition = klass.property(:foo, &original) 40 | 41 | expect(definition.procedure).to eq(original) 42 | end 43 | 44 | it 'returns result of super if value is present' do 45 | definition = klass.property(:foo, value: 'bar') 46 | 47 | expect(definition.procedure).to be_nil 48 | end 49 | 50 | it 'assigns procedure without original procedure or value' do 51 | definition = klass.property(:foo) 52 | 53 | expect(definition.procedure).to be_an_instance_of(Proc) 54 | end 55 | end 56 | end 57 | 58 | describe Halogen::Resource::InstanceMethods do 59 | describe '#initialize' do 60 | it 'raises error if resource is not provided' do 61 | expect { klass.new }.to raise_error(ArgumentError) 62 | end 63 | 64 | it 'assigns resource' do 65 | resource = double(:resource) 66 | 67 | repr = klass.new(resource) 68 | 69 | expect(repr.instance_variable_get(:@resource)).to eq(resource) 70 | end 71 | end 72 | end 73 | 74 | describe '#collection?' do 75 | it 'is false' do 76 | repr = klass.new(nil) 77 | 78 | expect(repr.collection?).to eq(false) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/halogen/embeds.rb: -------------------------------------------------------------------------------- 1 | module Halogen 2 | module Embeds # :nodoc: 3 | def self.included(base) # :nodoc: 4 | base.extend ClassMethods 5 | 6 | base.send :include, InstanceMethods 7 | end 8 | 9 | module ClassMethods # :nodoc: 10 | # @param name [Symbol, String] 11 | # @param options [nil, Hash] 12 | # 13 | # @return [Halogen::Embeds::Definition] 14 | # 15 | def embed(name, options = {}, &procedure) 16 | definitions.add(Definition.new(name, options, procedure)) 17 | end 18 | end 19 | 20 | module InstanceMethods # :nodoc: 21 | # @return [Hash] the rendered hash with embedded resources, if any 22 | # 23 | def render 24 | decorate_render :embedded, super 25 | end 26 | 27 | # @return [Hash] hash of rendered resources to embed 28 | # 29 | def embedded 30 | render_definitions(Definition.name) do |definition, result| 31 | value = instance_eval(&definition.procedure) 32 | 33 | child = embedded_child(definition.name.to_s, value) 34 | 35 | result[definition.name] = child if child 36 | end 37 | end 38 | 39 | # @return [nil, Hash, Array] either a single rendered child 40 | # representer or an array of them 41 | # 42 | def embedded_child(key, value) 43 | return unless value 44 | 45 | opts = child_embed_opts(key) 46 | 47 | if value.is_a?(Array) 48 | value.map { |item| render_child(item, opts) }.compact 49 | else 50 | render_child(value, opts) 51 | end 52 | end 53 | 54 | # @param key [String] 55 | # 56 | # @return [Hash] 57 | # 58 | def child_embed_opts(key) 59 | opts = embed_options.fetch(key, {}) 60 | 61 | # Turn { :report => 1 } into { :report => {} } for child 62 | opts = {} unless opts.is_a?(Hash) 63 | 64 | opts 65 | end 66 | 67 | # @param repr [Object] the child representer 68 | # @param opts [Hash] the embed options to assign to the child 69 | # 70 | # @return [nil, Hash] the rendered child 71 | # 72 | def render_child(repr, opts) 73 | return unless repr.class.included_modules.include?(Halogen) 74 | 75 | repr.options[:embed] ||= {} 76 | repr.options[:embed].merge!(opts) 77 | 78 | repr.options[:parent] = self 79 | 80 | repr.render 81 | end 82 | 83 | # @return [Hash] hash of options with top level string keys 84 | # 85 | def embed_options 86 | @_embed_options ||= options.fetch(:embed, {}).tap do |result| 87 | Halogen::HashUtil.stringify_keys!(result) 88 | end 89 | end 90 | end 91 | end 92 | end 93 | 94 | require 'halogen/embeds/definition' 95 | -------------------------------------------------------------------------------- /spec/halogen/links/definition_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Links::Definition do 2 | describe '#validate' do 3 | it 'returns true with procedure' do 4 | result = Halogen::Links::Definition.new(:name, proc {}).validate 5 | 6 | expect(result).to eq(true) 7 | end 8 | 9 | it 'raises exception without procedure or explicit value' do 10 | expect { 11 | Halogen::Links::Definition.new(:name, nil).validate 12 | }.to raise_error do |exception| 13 | expect(exception).to be_an_instance_of(Halogen::InvalidDefinition) 14 | expect(exception.message).to( 15 | eq('Link requires either procedure or explicit value')) 16 | end 17 | end 18 | end 19 | 20 | describe '#value' do 21 | it 'handles multiple hrefs' do 22 | definition = Halogen::Links::Definition.new( 23 | :name, proc { %w(first second) }) 24 | 25 | expect(definition.value(nil)).to eq([ 26 | { href: 'first' }, 27 | { href: 'second' } 28 | ]) 29 | end 30 | 31 | it 'handles multiple hrefs with additional attributes' do 32 | definition = Halogen::Links::Definition.new( 33 | :name, { attrs: { foo: 'bar' } }, proc { %w(first second) }) 34 | 35 | expect(definition.value(nil)).to eq([ 36 | { href: 'first', foo: 'bar' }, 37 | { href: 'second', foo: 'bar' } 38 | ]) 39 | end 40 | 41 | it 'handles single href' do 42 | definition = Halogen::Links::Definition.new(:name, proc { 'first' }) 43 | 44 | expect(definition.value(nil)).to eq(href: 'first') 45 | end 46 | 47 | it 'is nil for nil href' do 48 | definition = Halogen::Links::Definition.new(:name, proc {}) 49 | 50 | expect(definition.value(nil)).to be_nil 51 | end 52 | end 53 | 54 | describe '.build_options' do 55 | it 'has expected value without options hash' do 56 | options = Halogen::Links::Definition.build_options([]) 57 | 58 | expect(options).to eq(attrs: {}) 59 | end 60 | 61 | it 'has expected value with options hash' do 62 | options = Halogen::Links::Definition.build_options([foo: 'bar']) 63 | 64 | expect(options).to eq(attrs: {}, foo: 'bar') 65 | end 66 | 67 | it 'merges attrs from options' do 68 | options = Halogen::Links::Definition.build_options([ 69 | :templated, 70 | attrs: { properties: {} }, 71 | foo: 'bar']) 72 | 73 | expect(options).to( 74 | eq(attrs: { properties: {}, templated: true }, foo: 'bar')) 75 | end 76 | end 77 | 78 | describe '.build_attrs' do 79 | it 'returns empty hash if no keywords are provided' do 80 | expect(Halogen::Links::Definition.build_attrs([])).to eq({}) 81 | end 82 | 83 | it 'builds expected hash with recognized keywords' do 84 | attrs = Halogen::Links::Definition.build_attrs([:templated]) 85 | 86 | expect(attrs).to eq(templated: true) 87 | end 88 | 89 | it 'raises exception if unrecognized keyword is included' do 90 | expect { 91 | Halogen::Links::Definition.build_attrs([:templated, :wat]) 92 | }.to raise_error do |exception| 93 | expect(exception.class).to eq(Halogen::InvalidDefinition) 94 | expect(exception.message).to eq('Unrecognized link keyword: wat') 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/halogen/definition_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Definition do 2 | describe '#initialize' do 3 | it 'symbolizes option keys' do 4 | definition = Halogen::Definition.new( 5 | :name, { 'value' => 'some value', 'foo' => 'bar' }, nil) 6 | 7 | expect(definition.options.keys).to eq([:value, :foo]) 8 | end 9 | end 10 | 11 | describe '#value' do 12 | it 'returns value from options if present' do 13 | definition = Halogen::Definition.new(:name, { value: 'some value' }, nil) 14 | 15 | expect(definition.value(nil)).to eq('some value') 16 | end 17 | 18 | it 'evaluates procedure if value from options is missing' do 19 | definition = Halogen::Definition.new(:name, {}, proc { size }) 20 | 21 | expect(definition.value('foo')).to eq(3) 22 | end 23 | end 24 | 25 | describe '#enabled?' do 26 | it 'is true if definition is not guarded' do 27 | definition = Halogen::Definition.new(:name, {}, nil) 28 | 29 | expect(definition.enabled?(nil)).to eq(true) 30 | end 31 | 32 | describe 'when guard is a proc' do 33 | let :definition do 34 | Halogen::Definition.new(:name, { if: proc { empty? } }, nil) 35 | end 36 | 37 | it 'is true if condition passes' do 38 | expect(definition.enabled?('')).to eq(true) 39 | end 40 | 41 | it 'is false if condition fails' do 42 | expect(definition.enabled?('foo')).to eq(false) 43 | end 44 | end 45 | 46 | describe 'when guard is a method name' do 47 | let :definition do 48 | Halogen::Definition.new(:name, { if: :empty? }, nil) 49 | end 50 | 51 | it 'is true if condition passes' do 52 | expect(definition.enabled?('')).to eq(true) 53 | end 54 | 55 | it 'is false if condition fails' do 56 | expect(definition.enabled?('foo')).to eq(false) 57 | end 58 | end 59 | 60 | describe 'when guard is truthy' do 61 | it 'is true if condition passes' do 62 | definition = Halogen::Definition.new(:name, { if: true }, nil) 63 | 64 | expect(definition.enabled?(nil)).to eq(true) 65 | end 66 | 67 | it 'is false if condition fails' do 68 | definition = Halogen::Definition.new(:name, { if: false }, nil) 69 | 70 | expect(definition.enabled?(nil)).to eq(false) 71 | end 72 | end 73 | 74 | describe 'when guard is negated' do 75 | let :definition do 76 | Halogen::Definition.new(:name, { unless: proc { empty? } }, nil) 77 | end 78 | 79 | it 'is false if condition passes' do 80 | expect(definition.enabled?('')).to eq(false) 81 | end 82 | end 83 | end 84 | 85 | describe '#validate' do 86 | it 'returns true for valid definition' do 87 | definition = Halogen::Definition.new(:name, { value: 'value' }, nil) 88 | 89 | expect(definition.validate).to eq(true) 90 | end 91 | 92 | it 'raises error for invalid definition' do 93 | definition = Halogen::Definition.new( 94 | :name, { value: 'value' }, proc { 'value' }) 95 | 96 | expect { 97 | definition.validate 98 | }.to raise_error(Halogen::InvalidDefinition) do |exception| 99 | expect(exception.message).to( 100 | eq('Cannot specify both value and procedure for name')) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /examples/simple.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path('../lib', File.dirname(__FILE__))) 2 | 3 | require 'halogen' 4 | require 'pp' 5 | 6 | # Example of a straightforward Halogen representer with no resource, 7 | # collection, or conditional definitions 8 | # 9 | class GoatRepresenter 10 | include Halogen 11 | 12 | # Simple instance methods that will be used for properties below 13 | # 14 | def id; 1; end 15 | def first_name; 'Gideon'; end 16 | def last_name; 'Goat'; end 17 | 18 | # == 1. Properties 19 | # 20 | # If you define a property without an explicit value or proc, Halogen will 21 | # look for a public instance method with the corresponding name. 22 | # 23 | # This will call GoatRepresenter#id. 24 | # 25 | property :id # => { id: 1 } 26 | 27 | # You can also define a property with an explicit value, e.g.: 28 | # 29 | property :age, value: 9.2 # => { age: 9.2 } 30 | 31 | # Or you can use a proc to determine the property value at render time. 32 | # 33 | # The example below could also be written: property(:full_name) { ... } 34 | # 35 | property :full_name do # => { full_name: 'Gideon Goat' } 36 | "#{first_name} #{last_name}" 37 | end 38 | 39 | # == 2. Links 40 | # 41 | # As with properties, links can be defined with a proc: 42 | # 43 | link :self do 44 | "/goats/#{id}" # => { self: { href: '/goats/1' } } 45 | end 46 | 47 | # ...Or with an explicit value: 48 | # 49 | link :root, value: '/goats' # => { root: { href: '/goats' } } 50 | 51 | # Links can also be defined as "templated", following HAL+JSON conventions: 52 | # 53 | link :find, :templated do # => ... { href: '/goats/{?id}', templated: true } 54 | '/goats/{?id}' 55 | end 56 | 57 | # If Halogen is loaded in a Rails application, url helpers will be available 58 | # automatically: 59 | # 60 | # link(:new) { new_goat_path } 61 | 62 | # == 3. Embeds 63 | # 64 | # Embedded resources are not rendered by default. They will be included if 65 | # both of the following conditions are met: 66 | # 67 | # 1. The proc returns either a Halogen instance or an array of Halogen instances 68 | # 2. The embed is requested via the parent representer's options, e.g.: 69 | # 70 | # GoatRepresenter.new(embed: { kids: true, parents: false }) 71 | # 72 | embed :kids do # => { kids: } 73 | GoatKidsRepresenter.new 74 | end 75 | 76 | embed :parents do # => will not be included according to example options above 77 | [ 78 | self.class.new, 79 | self.class.new 80 | ] 81 | end 82 | 83 | # Embedded resources can be nested to any depth, e.g.: 84 | # 85 | # GoatRepresenter.new(embed: { 86 | # kids: { 87 | # foods: { 88 | # ingredients: true 89 | # }, 90 | # enclosure: true 91 | # } 92 | # }) 93 | end 94 | 95 | # Another simple representer to demonstrate embedded resources above 96 | # 97 | class GoatKidsRepresenter 98 | include Halogen 99 | 100 | property :count, value: 5 101 | end 102 | 103 | puts 'GoatRepresenter.new(embed: { kids: true }).render:' 104 | puts 105 | pp GoatRepresenter.new(embed: { kids: true }).render 106 | # 107 | # Result: 108 | # 109 | # { 110 | # id: 1, 111 | # age: 9.2, 112 | # full_name: "Gideon Goat", 113 | # _embedded: { 114 | # kids: { count: 5 } 115 | # }, 116 | # _links: { 117 | # self: { href: '/goats/1' }, 118 | # root: { href: '/goats"'}, 119 | # find: { href: '/goats/{?id}', templated: true } 120 | # } 121 | # } 122 | # 123 | 124 | puts 125 | puts 'GoatRepresenter.new.render:' 126 | puts 127 | pp GoatRepresenter.new.render 128 | # 129 | # Result: 130 | # 131 | # { 132 | # id: 1, 133 | # age: 9.2, 134 | # full_name: "Gideon Goat", 135 | # _links: { 136 | # self: { href: '/goats/1' }, 137 | # root: { href: '/goats"'}, 138 | # find: { href: '/goats/{?id}', templated: true } 139 | # } 140 | # } 141 | -------------------------------------------------------------------------------- /spec/halogen/links_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Links do 2 | let :klass do 3 | Class.new { include Halogen } 4 | end 5 | 6 | describe Halogen::Links::ClassMethods do 7 | describe '#link' do 8 | describe 'with procedure' do 9 | it 'builds simple definition' do 10 | link = klass.link(:self) { 'path' } 11 | 12 | expect(link.name).to eq(:self) 13 | expect(link.options).to eq(attrs: {}) 14 | expect(link.procedure.call).to eq('path') 15 | end 16 | 17 | it 'builds complex definition' do 18 | link = klass.link( 19 | :self, :templated, foo: 'foo', attrs: { bar: 'bar' }) { 'path' } 20 | 21 | expect(link.name).to eq(:self) 22 | expect(link.options).to eq( 23 | foo: 'foo', attrs: { templated: true, bar: 'bar' }) 24 | expect(link.procedure.call).to eq('path') 25 | end 26 | 27 | it 'handles multiple values' do 28 | klass.link(:self) { %w(foo bar) } 29 | 30 | rendered = klass.new.render[:_links][:self] 31 | 32 | expect(rendered).to eq([{ href: 'foo' }, { href: 'bar' }]) 33 | end 34 | end 35 | 36 | describe 'without procedure' do 37 | describe 'with explicit value' do 38 | it 'builds simple definition' do 39 | link = klass.link(:self, value: 'path') 40 | 41 | expect(link.name).to eq(:self) 42 | expect(link.options).to eq(attrs: {}, value: 'path') 43 | expect(link.procedure).to be_nil 44 | end 45 | 46 | it 'builds complex definition' do 47 | link = klass.link( 48 | :self, 49 | :templated, 50 | foo: 'foo', attrs: { bar: 'bar' }, value: 'path') 51 | 52 | expect(link.name).to eq(:self) 53 | expect(link.options).to eq( 54 | foo: 'foo', 55 | attrs: { templated: true, bar: 'bar' }, 56 | value: 'path') 57 | expect(link.procedure).to be_nil 58 | end 59 | end 60 | end 61 | 62 | it 'converts string rel to symbol' do 63 | link = klass.link('ea:find', value: 'path') 64 | 65 | expect(link.name).to eq(:'ea:find') 66 | end 67 | end 68 | end 69 | 70 | describe Halogen::Links::InstanceMethods do 71 | describe 'options[:include_links]' do 72 | let :klass do 73 | Class.new do 74 | include Halogen 75 | end 76 | end 77 | 78 | it 'includes links when true' do 79 | link = klass.link(:self, :templated, foo: 'foo', attrs: { bar: 'bar' }) { 'path' } 80 | repr = klass.new('include_links' => true) 81 | 82 | expect(repr.options).to eq({ include_links: true }) 83 | render = repr.render 84 | 85 | expect(render[:_links]).to eq(:self => {:bar=>"bar", :href=>"path", :templated=>true}) 86 | end 87 | 88 | it 'defaults to true' do 89 | link = klass.link(:self, :templated, foo: 'foo', attrs: { bar: 'bar' }) { 'path' } 90 | repr = klass.new 91 | render = repr.render 92 | 93 | expect(render[:_links]).to eq(:self => {:bar=>'bar', :href=>'path', :templated=>true}) 94 | end 95 | 96 | it 'excludes links when false' do 97 | link = klass.link(:self, :templated, foo: 'foo', attrs: { bar: 'bar' }) { 'path' } 98 | repr = klass.new('include_links' => false) 99 | expect(repr.options).to eq({ include_links: false }) 100 | 101 | render = repr.render 102 | 103 | expect(render[:_links]).to eq(nil) 104 | end 105 | end 106 | 107 | describe '#links' do 108 | let :klass do 109 | Class.new do 110 | include Halogen 111 | 112 | link(:self) { nil } 113 | end 114 | end 115 | 116 | it 'does not include link if value is nil' do 117 | repr = klass.new 118 | 119 | expect(repr.links).to eq({}) 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/halogen_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen do 2 | let :klass do 3 | Class.new { include Halogen } 4 | end 5 | 6 | describe Halogen::ClassMethods do 7 | describe '#resource' do 8 | it 'includes resource module' do 9 | klass = Class.new { include Halogen } 10 | 11 | expect(klass).to receive(:define_resource) 12 | 13 | klass.resource :foo 14 | 15 | expect(klass.included_modules.include?(Halogen::Resource)).to eq(true) 16 | end 17 | end 18 | 19 | describe '#collection' do 20 | it 'includes collection module' do 21 | klass = Class.new { include Halogen } 22 | 23 | expect(klass).to receive(:define_collection) 24 | 25 | klass.collection :foo 26 | 27 | expect(klass.included_modules.include?(Halogen::Collection)).to eq(true) 28 | end 29 | end 30 | 31 | describe '#collection?' do 32 | it 'is false by default' do 33 | klass = Class.new { include Halogen } 34 | 35 | expect(klass.collection?).to eq(false) 36 | end 37 | 38 | it 'is true for collection' do 39 | klass = Class.new { include Halogen } 40 | 41 | klass.collection :foo 42 | 43 | expect(klass.collection?).to eq(true) 44 | end 45 | end 46 | end 47 | 48 | describe Halogen::InstanceMethods do 49 | describe '#initialize' do 50 | it 'symbolizes option keys' do 51 | repr = klass.new( 52 | 'embed' => { 'foo' => 'bar' }, 53 | 'ignore' => 'this', 54 | :convert => 'that') 55 | 56 | expect(repr.options).to eq( 57 | embed: { 'foo' => 'bar' }, 58 | ignore: 'this', 59 | convert: 'that' 60 | ) 61 | end 62 | end 63 | 64 | describe '#render' do 65 | let :rendered do 66 | klass.new.render 67 | end 68 | 69 | it 'renders simple link' do 70 | klass.link(:label) { 'href' } 71 | 72 | expect(rendered[:_links][:label]).to eq(href: 'href') 73 | end 74 | 75 | it 'does not include link if conditional checks fail' do 76 | klass.send(:define_method, :return_false) { false } 77 | klass.send(:define_method, :return_nil) { nil } 78 | 79 | klass.link(:label) { 'href' } 80 | 81 | klass.link(:label_2, if: false) { 'href' } 82 | klass.link(:label_3, if: proc { false }) { 'href' } 83 | klass.link(:label_4, if: proc { nil }) { 'href' } 84 | klass.link(:label_5, if: :return_false) { 'href' } 85 | 86 | expect(rendered[:_links].keys).to eq([:label]) 87 | end 88 | 89 | it 'includes link if conditional checks pass' do 90 | klass.send(:define_method, :return_true) { true } 91 | klass.send(:define_method, :return_one) { 1 } 92 | 93 | klass.link(:label) { 'href' } 94 | 95 | klass.link(:label_2, if: true) { 'href' } 96 | klass.link(:label_3, if: proc { true }) { 'href' } 97 | klass.link(:label_4, if: proc { 1 }) { 'href' } 98 | klass.link(:label_5, if: :return_true) { 'href' } 99 | 100 | expected = [:label, :label_2, :label_3, :label_4, :label_5] 101 | expect(rendered[:_links].keys).to eq(expected) 102 | end 103 | end 104 | 105 | describe '#depth' do 106 | it 'is zero for top level representer' do 107 | expect(klass.new.depth).to eq(0) 108 | end 109 | 110 | it 'has expected value for embedded children' do 111 | parent = klass.new 112 | 113 | child = klass.new 114 | allow(child).to receive(:parent).and_return(parent) 115 | 116 | grandchild = klass.new 117 | allow(grandchild).to receive(:parent).and_return(child) 118 | 119 | expect(parent.depth).to eq(0) 120 | expect(child.depth).to eq(1) 121 | expect(grandchild.depth).to eq(2) 122 | end 123 | end 124 | 125 | describe '#to_json' do 126 | it 'converts rendered representer to json' do 127 | expect(klass.new.to_json).to eq('{}') 128 | end 129 | end 130 | end 131 | 132 | describe '.config' do 133 | it 'yields configuration instance' do 134 | Halogen.configure do |config| 135 | expect(config).to eq(Halogen.config) 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/halogen.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__))) 3 | 4 | require 'json' 5 | 6 | # HAL+JSON generator 7 | # 8 | # Provides a framework-agnostic interface for generating HAL+JSON 9 | # representations of resources 10 | # 11 | module Halogen 12 | # Provide Halogen methods for the including module 13 | # 14 | # @return [Module] 15 | # 16 | def self.included(base) 17 | base.extend ClassMethods 18 | 19 | base.send :include, InstanceMethods 20 | base.send :include, Properties 21 | base.send :include, Links 22 | base.send :include, Embeds 23 | 24 | config.extensions.each { |extension| base.send :include, extension } 25 | 26 | base.send :attr_reader, :options 27 | end 28 | 29 | module ClassMethods # :nodoc: 30 | # @return [Halogen::Definitions] the definitions container instance 31 | # 32 | def definitions 33 | @definitions ||= Definitions.new 34 | end 35 | 36 | # @param [Symbol, String] name of the resource 37 | # 38 | # @return [Module] self 39 | # 40 | def resource(name) 41 | include Resource 42 | 43 | define_resource(name) 44 | end 45 | 46 | # @param [Symbol, String] name of the collection 47 | # 48 | # @return [Module] self 49 | # 50 | def collection(name) 51 | include Collection 52 | 53 | define_collection(name) 54 | end 55 | 56 | def collection? 57 | false 58 | end 59 | end 60 | 61 | module InstanceMethods # :nodoc: 62 | # @param options [nil, Hash] hash of options 63 | # 64 | # @return [Object] the representer instance 65 | # 66 | def initialize(options = {}) 67 | @options = Halogen::HashUtil.symbolize_keys!(options) 68 | end 69 | 70 | # @return [String] rendered JSON 71 | # 72 | def to_json 73 | render.to_json 74 | end 75 | 76 | # @return [Hash] rendered representation 77 | # 78 | def render 79 | {} 80 | end 81 | 82 | # @return [nil, Object] the parent representer, if this instance is an 83 | # embedded child 84 | # 85 | def parent 86 | @parent ||= options.fetch(:parent, nil) 87 | end 88 | 89 | # @return [Integer] the depth at which this representer is embedded 90 | # 91 | def depth 92 | @depth ||= parent ? parent.depth + 1 : 0 93 | end 94 | 95 | def collection? 96 | false 97 | end 98 | 99 | protected 100 | 101 | # Allow included modules to decorate rendered hash 102 | # 103 | # @param key [Symbol] the key (e.g. `embedded`, `links`) 104 | # @param result [Hash] the partially rendered hash to decorate 105 | # 106 | # @return [Hash] the decorated hash 107 | # 108 | def decorate_render(key, result) 109 | result.tap do 110 | value = send(key) 111 | 112 | result[:"_#{key}"] = value if value.any? 113 | end 114 | end 115 | 116 | # Iterate through enabled definitions of the given type, allowing instance 117 | # to build up resulting hash 118 | # 119 | # @param type [Symbol, String] the definition type 120 | # 121 | # @return [Hash] the result 122 | # 123 | def render_definitions(type) 124 | definitions = self.class.definitions.fetch(type, []) 125 | 126 | definitions.each_with_object({}) do |definition, result| 127 | next unless definition.enabled?(self) 128 | 129 | yield definition, result 130 | end 131 | end 132 | end 133 | 134 | class << self 135 | # @yield [Halogen::Configuration] configuration instance for modification 136 | # 137 | def configure 138 | yield config 139 | end 140 | 141 | # Configuration instance 142 | # 143 | # @return [Halogen::Configuration] 144 | # 145 | def config 146 | @config ||= Configuration.new 147 | end 148 | end 149 | end 150 | 151 | require 'halogen/collection' 152 | require 'halogen/configuration' 153 | require 'halogen/definition' 154 | require 'halogen/definitions' 155 | require 'halogen/embeds' 156 | require 'halogen/errors' 157 | require 'halogen/links' 158 | require 'halogen/properties' 159 | require 'halogen/resource' 160 | require 'halogen/hash_util' 161 | require 'halogen/version' 162 | 163 | require 'halogen/railtie' if defined?(::Rails) 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Halogen 2 | 3 | [![Code Climate](https://codeclimate.com/repos/552430af695680100500b659/badges/cdae8d5a10147d135be9/gpa.svg)](https://codeclimate.com/repos/552430af695680100500b659/feed) 4 | [![Build Status](https://travis-ci.org/mode/halogen.svg?branch=master)](https://travis-ci.org/mode/halogen) 5 | [![Gem Version](https://badge.fury.io/rb/halogen.svg)](http://badge.fury.io/rb/halogen) 6 | 7 | This library provides a framework-agnostic interface for generating 8 | [HAL+JSON](http://stateless.co/hal_specification.html) 9 | representations of resources in Ruby. 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'halogen' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install halogen 26 | 27 | ## Usage 28 | 29 | ### Basic usage 30 | 31 | Create a simple representer class and include Halogen: 32 | 33 | ```ruby 34 | class GoatRepresenter 35 | include Halogen 36 | 37 | property :name do 38 | 'Gideon' 39 | end 40 | 41 | link :self do 42 | '/goats/gideon' 43 | end 44 | end 45 | ``` 46 | 47 | Instantiate: 48 | 49 | ```ruby 50 | repr = GoatRepresenter.new 51 | ``` 52 | 53 | Then call `repr.render`: 54 | 55 | ```ruby 56 | { 57 | name: 'Gideon', 58 | _links: { 59 | self: { href: '/goats/gideon' } 60 | } 61 | } 62 | ``` 63 | 64 | Or `repr.to_json`: 65 | 66 | ```ruby 67 | '{"name": "Gideon", "_links": {"self": {"href": "/goats/gideon"}}}' 68 | ``` 69 | 70 | ### Representer types 71 | 72 | #### 1. Simple 73 | 74 | Not associated with any particular resource or collection. For example, an API 75 | entry point: 76 | 77 | ```ruby 78 | class ApiRootRepresenter 79 | include Halogen 80 | 81 | link(:self) { '/api' } 82 | end 83 | ``` 84 | 85 | #### 2. Resource 86 | 87 | Represents a single item: 88 | 89 | ```ruby 90 | class GoatRepresenter 91 | include Halogen 92 | 93 | resource :goat 94 | end 95 | ``` 96 | 97 | When a resource is declared, `#initialize` expects the resource as the first argument: 98 | 99 | ```ruby 100 | repr = GoatRepresenter.new(Goat.new, ...) 101 | ``` 102 | 103 | This makes property definitions cleaner: 104 | 105 | ```ruby 106 | property :name # now calls Goat#name by default 107 | ``` 108 | 109 | #### 3. Collection 110 | 111 | Represents a collection of items. When a collection is declared, the embedded 112 | resource with the same name will always be embedded, whether it is requested 113 | via standard embed options or not. 114 | 115 | ```ruby 116 | class GoatKidsRepresenter 117 | include Halogen 118 | 119 | collection :kids 120 | 121 | embed(:kids) { ... } # always embedded 122 | end 123 | ``` 124 | 125 | ### Defining properties, links and embeds 126 | 127 | Properties can be defined in several ways: 128 | 129 | ```ruby 130 | property(:age) { "#{goat.age} years old" } 131 | ``` 132 | 133 | ```ruby 134 | property :age # => Goat#age, if resource is declared 135 | ``` 136 | 137 | ```ruby 138 | property :age do 139 | goat.age.round 140 | end 141 | ``` 142 | 143 | ```ruby 144 | property(:age) { calculate_age } 145 | 146 | def calculate_age 147 | ... 148 | end 149 | ``` 150 | 151 | #### Conditionals 152 | 153 | The inclusion of properties can be determined by conditionals using `if` and 154 | `unless` options. For example, with a method name: 155 | 156 | ```ruby 157 | property :age, if: :include_age? 158 | 159 | def include_age? 160 | goat.age < 10 161 | end 162 | ``` 163 | 164 | With a proc: 165 | ```ruby 166 | property :age, unless: proc { goat.age.nil? }, value: ... 167 | ``` 168 | 169 | For links and embeds: 170 | 171 | ```ruby 172 | link :kids, :templated, unless: :exclude_kids_link?, value: ... 173 | ``` 174 | 175 | ```ruby 176 | embed :kids, if: proc { goat.kids.size > 0 } do 177 | ... 178 | end 179 | ``` 180 | 181 | #### Links 182 | 183 | Simple link: 184 | 185 | ```ruby 186 | link(:root) { '/' } 187 | # => { _links: { root: { href: '/' } } ... } 188 | ``` 189 | 190 | Templated link: 191 | 192 | ```ruby 193 | link(:find, :templated) { '/goats/{?id}' } 194 | # => { _links: { find: { href: '/goats/{?id}', templated: true } } ... } 195 | ``` 196 | 197 | Optional links: 198 | 199 | ```ruby 200 | representer = MyRepresenterWithManyLinks.new(include_links: false) 201 | representation = representer.render 202 | representation[:_links] #nil 203 | ``` 204 | 205 | #### Embedded resources 206 | 207 | Embedded resources are not rendered by default. They will be included if both 208 | of the following conditions are met: 209 | 210 | 1. The proc returns either a Halogen instance or an array of Halogen instances 211 | 2. The embed is requested via the parent representer's options, e.g.: 212 | 213 | ```ruby 214 | GoatRepresenter.new(embed: { kids: true, parents: false }) 215 | ``` 216 | 217 | Embedded resources can be nested to any depth, e.g.: 218 | 219 | ```ruby 220 | GoatRepresenter.new(embed: { 221 | kids: { 222 | foods: { 223 | ingredients: true 224 | }, 225 | pasture: true 226 | } 227 | }) 228 | ``` 229 | 230 | ### Using with Rails 231 | 232 | If Halogen is loaded in a Rails application, Rails url helpers will be 233 | available in representers: 234 | 235 | ```ruby 236 | link(:new) { new_goat_url } 237 | ``` 238 | 239 | ### More examples 240 | 241 | * [Full representer class](examples/simple.rb) 242 | * [Extensions](examples/extensions.md) 243 | 244 | ### What's with the goat? 245 | 246 | It is [majestic](https://twitter.com/ModeAnalytics/status/497876416013537280). 247 | 248 | ## Contributing 249 | 250 | 1. Fork it ( https://github.com/mode/halogen/fork ) 251 | 2. Create your feature branch (`git checkout -b my-new-feature`) 252 | 3. Commit your changes (`git commit -am 'Add some feature'`) 253 | 4. Push to the branch (`git push origin my-new-feature`) 254 | 5. Create a new Pull Request 255 | -------------------------------------------------------------------------------- /spec/halogen/embeds_spec.rb: -------------------------------------------------------------------------------- 1 | describe Halogen::Embeds do 2 | let :klass do 3 | Class.new do 4 | include Halogen 5 | include Halogen::Embeds 6 | end 7 | end 8 | 9 | describe Halogen::Embeds::ClassMethods do 10 | it 'adds simple embed definition' do 11 | expect(klass.definitions).to receive(:add) 12 | 13 | klass.embed(:foo, {}) { 'bar' } 14 | end 15 | end 16 | 17 | describe Halogen::Embeds::InstanceMethods do 18 | describe '#embedded' do 19 | describe 'when no embeds are defined' do 20 | it 'returns empty hash when no embeds are requested' do 21 | representer = klass.new 22 | 23 | expect(representer.embedded).to eq({}) 24 | end 25 | 26 | it 'returns empty hash when embeds are requested' do 27 | representer = klass.new(embed: { foo: true }) 28 | 29 | expect(representer.embedded).to eq({}) 30 | end 31 | end 32 | 33 | describe 'when embeds are defined' do 34 | before :each do 35 | klass.embed(:just_nil, {}) { nil } 36 | klass.embed(:non_repr, {}) { 'some object' } 37 | klass.embed(:child_repr, {}) { child } 38 | 39 | klass.send(:define_method, :child) do 40 | # just build another representer instance to be rendered 41 | Class.new { include Halogen }.new 42 | end 43 | end 44 | 45 | it 'returns empty hash when no embeds are requested' do 46 | representer = klass.new(embed: {}) 47 | 48 | expect(representer.embedded).to eq({}) 49 | end 50 | 51 | it 'builds embedded resources as expected' do 52 | embed_opts = { just_nil: true, non_repr: true, child_repr: true } 53 | 54 | representer = klass.new(embed: embed_opts) 55 | 56 | expect(representer.embedded).to eq(child_repr: {}) 57 | end 58 | end 59 | end 60 | 61 | describe '#embedded_child' do 62 | let :representer do 63 | klass.new 64 | end 65 | 66 | let :child_class do 67 | Class.new do 68 | include Halogen 69 | 70 | property(:foo) { 'bar' } 71 | end 72 | end 73 | 74 | let :child do 75 | child_class.new 76 | end 77 | 78 | it 'returns nil if value is falsey' do 79 | [nil, false, 0].each do |value| 80 | expect(representer.embedded_child(:foo, value)).to be_nil 81 | end 82 | end 83 | 84 | describe 'when value is an array' do 85 | it 'renders children' do 86 | array = [child, nil, 0, child, 1] 87 | 88 | result = representer.embedded_child(:embed_key, array) 89 | 90 | expect(result).to eq([{ foo: 'bar' }, { foo: 'bar' }]) 91 | end 92 | end 93 | 94 | describe 'when value is a representer' do 95 | it 'renders child' do 96 | result = representer.embedded_child(:embed_key, child) 97 | 98 | expect(result).to eq(foo: 'bar') 99 | end 100 | end 101 | end 102 | 103 | describe '#child_embed_opts' do 104 | it 'returns empty options for unknown key' do 105 | representer = klass.new 106 | 107 | opts = representer.send(:child_embed_opts, :unknown_key) 108 | 109 | expect(opts).to eq({}) 110 | end 111 | 112 | it 'returns empty options for known key with no child options' do 113 | representer = klass.new(embed: { requested_key: 1 }) 114 | 115 | opts = representer.send(:child_embed_opts, 'requested_key') 116 | 117 | expect(opts).to eq({}) 118 | end 119 | 120 | it 'returns child options for known key with child options' do 121 | representer = klass.new(embed: { requested_key: { child_key: 0 } }) 122 | 123 | opts = representer.send(:child_embed_opts, 'requested_key') 124 | 125 | expect(opts).to eq(child_key: 0) 126 | end 127 | 128 | it 'returns deeply nested child options' do 129 | representer = klass.new( 130 | embed: { 131 | requested_key: { 132 | child_key: { grandchild_key: { great_grandchild_key: 1 } } 133 | } 134 | } 135 | ) 136 | 137 | opts = representer.send(:child_embed_opts, 'requested_key') 138 | 139 | expect(opts).to eq( 140 | child_key: { grandchild_key: { great_grandchild_key: 1 } }) 141 | end 142 | end 143 | 144 | describe '#render_child' do 145 | let :representer do 146 | Class.new do 147 | include Halogen 148 | 149 | property(:verify_parent) { parent.object_id } 150 | property(:verify_opts) { options[:embed] } 151 | end.new 152 | end 153 | 154 | it 'returns nil if child is not a representer' do 155 | [nil, 1, ''].each do |child| 156 | expect(klass.new.render_child(child, {})).to be_nil 157 | end 158 | end 159 | 160 | it 'renders child representer with correct parent and options' do 161 | result = representer.render_child(representer, foo: 'bar') 162 | 163 | expect(result).to eq( 164 | verify_parent: representer.object_id, 165 | verify_opts: { foo: 'bar' }) 166 | end 167 | 168 | it 'merges child options if already present' do 169 | representer.options[:embed] = { bar: 'bar' } 170 | 171 | result = representer.render_child(representer, foo: 'foo') 172 | 173 | expect(result[:verify_opts]).to eq(foo: 'foo', bar: 'bar') 174 | end 175 | end 176 | 177 | describe '#embed_options' do 178 | it 'stringifies top level keys' do 179 | representer = klass.new(embed: { some: { options: 1 } }) 180 | 181 | expect(representer.embed_options).to eq('some' => { options: 1 }) 182 | end 183 | end 184 | end 185 | end 186 | --------------------------------------------------------------------------------