├── lib
├── rabl-rails
│ ├── version.rb
│ ├── exceptions.rb
│ ├── visitors.rb
│ ├── nodes
│ │ ├── child.rb
│ │ ├── const.rb
│ │ ├── extend.rb
│ │ ├── polymorphic.rb
│ │ ├── fetch.rb
│ │ ├── condition.rb
│ │ ├── code.rb
│ │ ├── glue.rb
│ │ ├── attribute.rb
│ │ └── lookup.rb
│ ├── helpers.rb
│ ├── nodes.rb
│ ├── handler.rb
│ ├── visitors
│ │ ├── visitor.rb
│ │ └── to_hash.rb
│ ├── renderers
│ │ ├── plist.rb
│ │ ├── xml.rb
│ │ ├── json.rb
│ │ └── hash.rb
│ ├── template.rb
│ ├── railtie.rb
│ ├── configuration.rb
│ ├── library.rb
│ └── compiler.rb
├── tasks
│ └── rabl-rails.rake
└── rabl-rails.rb
├── .gitignore
├── .travis.yml
├── Gemfile
├── test
├── test_helpers.rb
├── test_configuration.rb
├── renderers
│ ├── test_xml_renderer.rb
│ ├── test_json_renderer.rb
│ ├── test_plist_renderer.rb
│ └── test_hash_renderer.rb
├── helper.rb
├── test_library.rb
├── test_hash_visitor.rb
└── test_compiler.rb
├── Rakefile
├── rabl-rails.gemspec
├── MIT-LICENSE
├── CHANGELOG.md
└── README.md
/lib/rabl-rails/version.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | VERSION = '0.6.2'
3 | end
4 |
--------------------------------------------------------------------------------
/lib/rabl-rails/exceptions.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | class PartialError < StandardError; end
3 | end
4 |
--------------------------------------------------------------------------------
/lib/rabl-rails/visitors.rb:
--------------------------------------------------------------------------------
1 | require 'rabl-rails/visitors/visitor'
2 | require 'rabl-rails/visitors/to_hash'
3 |
--------------------------------------------------------------------------------
/lib/tasks/rabl-rails.rake:
--------------------------------------------------------------------------------
1 | # desc "Explaining what the task does"
2 | # task :rabl-rails do
3 | # # Task goes here
4 | # end
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## General
2 | log
3 | doc
4 | rdoc
5 |
6 | ## Bundler
7 | .bundle
8 | pkg
9 | Gemfile.lock
10 |
11 | .ruby-version
12 | .byebug_history
13 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/child.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Child < Glue
4 | attr_reader :name
5 |
6 | def initialize(name, template)
7 | super(template)
8 | @name = name
9 | end
10 | end
11 | end
12 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/const.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Const
4 | attr_reader :name, :value
5 |
6 | def initialize(name, value)
7 | @name = name
8 | @value = value
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/extend.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Extend
4 | attr_reader :nodes, :locals
5 |
6 | def initialize(nodes, locals)
7 | @nodes = nodes
8 | @locals = locals
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/polymorphic.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Polymorphic
4 | attr_reader :template_lambda
5 |
6 | def initialize(template_lambda)
7 | @template_lambda = template_lambda
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/fetch.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Fetch < Child
4 | attr_reader :field
5 |
6 | def initialize(name, template, field)
7 | super(name, template)
8 | @field = field
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/condition.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Condition
4 | attr_reader :condition, :nodes
5 |
6 | def initialize(condition, nodes)
7 | @condition = condition
8 | @nodes = nodes
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | cache: bundler
3 | dist: trusty
4 | env:
5 | - "RAILS_VERSION=4.2.6"
6 | - "RAILS_VERSION=5.2.0"
7 | - "RAILS_VERSION=6.1.0"
8 | rvm:
9 | - 2.5.3
10 | - 2.6.0
11 | - 2.7.2
12 | - jruby
13 | before_install:
14 | - gem update bundler
15 | matrix:
16 | fast_finish: true
17 |
--------------------------------------------------------------------------------
/lib/rabl-rails/helpers.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Helpers
3 | def collection?(resource)
4 | klass = resource.class
5 |
6 | resource && resource.respond_to?(:each) &&
7 | klass.ancestors.none? { |a| RablRails.configuration.non_collection_classes.include? a.name }
8 | end
9 | end
10 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/code.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Code
4 | attr_reader :name, :block, :condition
5 |
6 | def initialize(name, block, condition = nil)
7 | @name = name
8 | @block = block
9 | @condition = condition
10 | end
11 |
12 | def merge?
13 | !name
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes.rb:
--------------------------------------------------------------------------------
1 | require 'rabl-rails/nodes/attribute'
2 | require 'rabl-rails/nodes/const'
3 | require 'rabl-rails/nodes/glue'
4 | require 'rabl-rails/nodes/child'
5 | require 'rabl-rails/nodes/code'
6 | require 'rabl-rails/nodes/condition'
7 | require 'rabl-rails/nodes/extend'
8 | require 'rabl-rails/nodes/polymorphic'
9 | require 'rabl-rails/nodes/lookup'
10 | require 'rabl-rails/nodes/fetch'
11 |
--------------------------------------------------------------------------------
/lib/rabl-rails/handler.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/core_ext/class/attribute'
2 |
3 | module RablRails
4 | module Handlers
5 | class Rabl
6 | def self.call(template, source = nil)
7 | %{
8 | RablRails::Library.instance.
9 | get_rendered_template(#{(source || template.source).inspect}, self, local_assigns)
10 | }
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/glue.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Glue
4 | attr_reader :nodes, :data
5 |
6 | def initialize(template)
7 | @nodes = template.nodes
8 | @data = template.data
9 | @is_var = @data.to_s.start_with?('@')
10 | end
11 |
12 | def instance_variable_data?
13 | @is_var
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/attribute.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Attribute
4 | attr_reader :hash
5 | attr_accessor :condition
6 |
7 | def initialize(hash = {})
8 | @hash = hash
9 | end
10 |
11 | def []=(key, value)
12 | @hash[key] = value
13 | end
14 |
15 | def each(&block)
16 | @hash.each(&block)
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/rabl-rails/visitors/visitor.rb:
--------------------------------------------------------------------------------
1 | module Visitors
2 | class Visitor
3 | def visit(node)
4 | dispatch(node)
5 | end
6 |
7 | def visit_Array a
8 | a.each { |n| dispatch(n) }
9 | end
10 |
11 | private
12 |
13 | DISPATCH = Hash.new do |hash, node_class|
14 | hash[node_class] = "visit_#{node_class.name.split('::').last}"
15 | end
16 |
17 | def dispatch(node)
18 | send DISPATCH[node.class], node
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/rabl-rails/renderers/plist.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Renderers
3 | module PLIST
4 | include Renderers::Hash
5 | extend self
6 |
7 | def format_output(hash, options = {})
8 | hash = { options[:root_name] => hash } if options[:root_name] && RablRails.configuration.include_plist_root
9 | RablRails.configuration.plist_engine.dump(hash)
10 | end
11 |
12 | def resolve_cache_key(key, data)
13 | "#{super}.plist"
14 | end
15 | end
16 | end
17 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/renderers/xml.rb:
--------------------------------------------------------------------------------
1 | require 'active_support/core_ext/hash/conversions'
2 |
3 | module RablRails
4 | module Renderers
5 | module XML
6 | include Renderers::Hash
7 | extend self
8 |
9 | def format_output(hash, options = {})
10 | xml_options = { root: options[:root_name] }.merge!(RablRails.configuration.xml_options)
11 | hash.to_xml(xml_options)
12 | end
13 |
14 | def resolve_cache_key(key, data)
15 | "#{super}.xml"
16 | end
17 | end
18 | end
19 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/template.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | class CompiledTemplate
3 | attr_accessor :nodes, :data, :root_name, :cache_key
4 |
5 | def initialize
6 | @nodes = []
7 | @data = nil
8 | @cache_key = false
9 | end
10 |
11 | def initialize_dup(other)
12 | super
13 | self.nodes = other.nodes.dup
14 | end
15 |
16 | def add_node(n)
17 | @nodes << n
18 | end
19 |
20 | def extends(template)
21 | @nodes.concat template.nodes
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/rabl-rails/nodes/lookup.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Nodes
3 | class Lookup
4 | attr_reader :name, :data, :field
5 |
6 | def initialize(name, data, field, cast = false)
7 | @name = name
8 | @data = data
9 | @field = field
10 | @cast = cast
11 | @is_var = @data.to_s.start_with?('@')
12 | end
13 |
14 | def instance_variable_data?
15 | @is_var
16 | end
17 |
18 | def cast_to_boolean?
19 | @cast
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'http://rubygems.org'
2 |
3 | gemspec
4 |
5 | rails_version = ENV['RAILS_VERSION'] || 'default'
6 |
7 | rails = case rails_version
8 | when 'master'
9 | { github: 'rails/rails' }
10 | when "default"
11 | '~> 5.2.1'
12 | else
13 | "~> #{rails_version}"
14 | end
15 |
16 | gem 'activesupport', rails
17 | gem 'railties', rails
18 |
19 | group :test do
20 | gem 'minitest', '~> 5.8'
21 | gem 'actionpack', rails
22 | gem 'actionview', rails
23 | end
24 |
25 | gem 'plist'
26 |
27 | platforms :mri do
28 | gem 'libxml-ruby'
29 | gem 'oj'
30 | end
31 |
32 | platforms :jruby do
33 | gem 'nokogiri'
34 | end
35 |
--------------------------------------------------------------------------------
/test/test_helpers.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 | require 'set'
3 |
4 | class TestHelpers < Minitest::Test
5 | include RablRails::Helpers
6 |
7 | def test_collection_with_default
8 | assert collection?(['foo'])
9 | refute collection?(User.new(1))
10 | end
11 |
12 | NotACollection = Class.new do
13 | def each; end
14 | end
15 |
16 | def test_collection_with_configuration
17 | assert collection?(NotACollection.new)
18 |
19 | with_configuration(:non_collection_classes, Set.new(['Struct', 'TestHelpers::NotACollection'])) do
20 | refute collection?(NotACollection.new), 'NotACollection triggers #collection?'
21 | end
22 | end
23 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/renderers/json.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Renderers
3 | module JSON
4 | include Renderers::Hash
5 | extend self
6 |
7 | def format_output(hash, options = {})
8 | hash = { options[:root_name] => hash } if options[:root_name] && RablRails.configuration.include_json_root
9 | json = RablRails.configuration.json_engine.dump(hash)
10 | params = options.fetch(:params, {})
11 |
12 | RablRails.configuration.enable_jsonp_callbacks && params.has_key?(:callback) ? "#{params[:callback]}(#{json})" : json
13 | end
14 |
15 | def resolve_cache_key(key, data)
16 | "#{super}.json"
17 | end
18 | end
19 | end
20 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/railtie.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | class Railtie < Rails::Railtie
3 | initializer "rabl.initialize" do |app|
4 | ActiveSupport.on_load(:action_view) do
5 | ActionView::Template.register_template_handler :rabl, RablRails::Handlers::Rabl
6 | end
7 |
8 | if Rails::VERSION::MAJOR >= 5
9 | module ::ActionController
10 | module ApiRendering
11 | include ActionView::Rendering
12 | end
13 | end
14 |
15 | ActiveSupport.on_load :action_controller do
16 | if self == ActionController::API
17 | include ActionController::Helpers
18 | include ActionController::ImplicitRender
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env rake
2 | # begin
3 | # require 'bundler/setup'
4 | # rescue LoadError
5 | # puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6 | # end
7 | # begin
8 | # require 'rdoc/task'
9 | # rescue LoadError
10 | # require 'rdoc/rdoc'
11 | # require 'rake/rdoctask'
12 | # RDoc::Task = Rake::RDocTask
13 | # end
14 | #
15 | # RDoc::Task.new(:rdoc) do |rdoc|
16 | # rdoc.rdoc_dir = 'rdoc'
17 | # rdoc.title = 'RablRails'
18 | # rdoc.options << '--line-numbers'
19 | # rdoc.rdoc_files.include('README.rdoc')
20 | # rdoc.rdoc_files.include('lib/**/*.rb')
21 | # end
22 |
23 | require 'bundler'
24 | Bundler::GemHelper.install_tasks
25 |
26 | require 'rake/testtask'
27 | Rake::TestTask.new(:test) do |t|
28 | t.libs << 'lib'
29 | t.libs << 'test'
30 | t.pattern = 'test/**/test_*.rb'
31 | # t.verbose = true
32 | end
33 |
34 |
35 | task :default => :test
36 |
--------------------------------------------------------------------------------
/rabl-rails.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("../lib", __FILE__)
2 | require "rabl-rails/version"
3 |
4 | Gem::Specification.new do |s|
5 | s.name = "rabl-rails"
6 | s.version = RablRails::VERSION
7 | s.platform = Gem::Platform::RUBY
8 | s.authors = ["Christopher Cocchi-Perrier"]
9 | s.email = ["cocchi.c@gmail.com"]
10 | s.homepage = "https://github.com/ccocchi/rabl-rails"
11 | s.summary = "Fast Rails 4+ templating system with JSON, XML and PList support"
12 | s.description = "Fast Rails 4+ templating system with JSON, XML and PList support"
13 | s.license = 'MIT'
14 |
15 | s.required_ruby_version = '>= 2.2.0'
16 |
17 | s.files = `git ls-files`.split("\n")
18 | s.test_files = `git ls-files -- test/*`.split("\n")
19 | s.require_paths = ["lib"]
20 |
21 | s.add_dependency 'activesupport', '>= 4.2'
22 | s.add_dependency 'railties', '>= 4.2'
23 | s.add_dependency 'concurrent-ruby', '~> 1.0', ">= 1.0.2"
24 |
25 | s.add_development_dependency 'actionpack', '>= 4.2'
26 | s.add_development_dependency 'actionview', '>= 4.2'
27 | end
28 |
--------------------------------------------------------------------------------
/test/test_configuration.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestConfiguration < Minitest::Test
4 | describe 'Configuration' do
5 | it 'has a zero score by default' do
6 | config = RablRails::Configuration.new
7 | assert_equal 0, config.result_flags
8 | end
9 |
10 | it 'sets a bit per option' do
11 | config = RablRails::Configuration.new
12 | config.replace_nil_values_with_empty_strings = true
13 | assert_equal 1, config.result_flags
14 |
15 | config = RablRails::Configuration.new
16 | config.replace_empty_string_values_with_nil = true
17 | assert_equal 2, config.result_flags
18 |
19 | config = RablRails::Configuration.new
20 | config.exclude_nil_values = true
21 | assert_equal 4, config.result_flags
22 | end
23 |
24 | it 'allows mutiple bits to be set at the same time' do
25 | config = RablRails::Configuration.new
26 | config.replace_nil_values_with_empty_strings = true
27 | config.replace_empty_string_values_with_nil = true
28 | assert_equal 3, config.result_flags
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/rabl-rails.rb:
--------------------------------------------------------------------------------
1 | require 'active_support'
2 |
3 | require 'rabl-rails/version'
4 | require 'rabl-rails/helpers'
5 | require 'rabl-rails/exceptions'
6 | require 'rabl-rails/template'
7 | require 'rabl-rails/nodes'
8 | require 'rabl-rails/compiler'
9 |
10 | require 'rabl-rails/visitors'
11 | require 'rabl-rails/renderers/hash'
12 | require 'rabl-rails/renderers/json'
13 | require 'rabl-rails/renderers/xml'
14 | require 'rabl-rails/renderers/plist'
15 | require 'rabl-rails/library'
16 |
17 | require 'rabl-rails/handler'
18 |
19 | if defined?(Rails)
20 | require 'rails/railtie'
21 | require 'rabl-rails/railtie'
22 | end
23 |
24 | require 'rabl-rails/configuration'
25 |
26 | begin
27 | require 'oj'
28 | Oj.default_options = { mode: :compat, time_format: :ruby }
29 | rescue LoadError
30 | require 'json'
31 | end
32 |
33 | module RablRails
34 | class << self
35 | def configure
36 | yield configuration
37 | end
38 |
39 | def configuration
40 | @_configuration ||= Configuration.new
41 | end
42 |
43 | def reset_configuration
44 | @_configuration = nil
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012 Christopher Cocchi-Perrier
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/test/renderers/test_xml_renderer.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestXMLRenderer < Minitest::Test
4 | INDENT_REGEXP = /\n(\s)*/
5 | HEADER_REGEXP = /<[^>]+>/
6 |
7 | describe 'XML renderer' do
8 | def render
9 | RablRails::Renderers::XML.render(@template, @context).to_s.gsub!(INDENT_REGEXP, '').sub!(HEADER_REGEXP, '')
10 | end
11 |
12 | before do
13 | @resource = User.new(1, 'Marty')
14 | @context = Context.new
15 | @context.assigns['user'] = @resource
16 | @template = RablRails::CompiledTemplate.new
17 | @template.data = :@user
18 | @template.add_node RablRails::Nodes::Attribute.new(name: :name)
19 | end
20 |
21 | it 'extends hash renderer' do
22 | RablRails::Renderers::XML.ancestors.include?(RablRails::Renderers::Hash)
23 | end
24 |
25 | it 'uses global XML options' do
26 | @template.nodes = [RablRails::Nodes::Attribute.new(first_name: :name)]
27 | with_configuration :xml_options, { dasherize: false, skip_types: false } do
28 | assert_equal %q(Marty), render
29 | end
30 | end
31 |
32 | it 'uses template root_name option' do
33 | @template.root_name = :user
34 | assert_equal %q(Marty), render
35 | end
36 | end
37 | end
--------------------------------------------------------------------------------
/test/renderers/test_json_renderer.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestJSONRenderer < Minitest::Test
4 | describe 'JSON renderer' do
5 | def render
6 | RablRails::Renderers::JSON.render(@template, @context)
7 | end
8 |
9 | before do
10 | @resource = User.new(1, 'Marty')
11 | @context = Context.new
12 | @context.assigns['user'] = @resource
13 | @template = RablRails::CompiledTemplate.new
14 | @template.data = :@user
15 | @template.add_node RablRails::Nodes::Attribute.new(name: :name)
16 | end
17 |
18 | it 'extends hash renderer' do
19 | RablRails::Renderers::JSON.ancestors.include?(RablRails::Renderers::Hash)
20 | end
21 |
22 | it 'renders JSON' do
23 | assert_equal %q({"name":"Marty"}), render
24 | end
25 |
26 | it 'uses template root_name option' do
27 | @template.root_name = :user
28 | assert_equal %q({"user":{"name":"Marty"}}), render
29 | end
30 |
31 | it 'ignores template root_name option if include_json_root is disabled' do
32 | @template.root_name = :user
33 | with_configuration :include_json_root, false do
34 | assert_equal %q({"name":"Marty"}), render
35 | end
36 | end
37 |
38 | it 'renders jsonp callback' do
39 | @context.stub :params, { callback: 'some_callback' } do
40 | with_configuration :enable_jsonp_callbacks, true do
41 | assert_equal %q[some_callback({"name":"Marty"})], render
42 | end
43 | end
44 | end
45 | end
46 | end
--------------------------------------------------------------------------------
/test/renderers/test_plist_renderer.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestPListRenderer < Minitest::Test
4 | INDENT_REGEXP = /\n(\s)*/
5 | HEADER_REGEXP = /<\?[^>]+>]+>/
6 |
7 | describe 'PList renderer' do
8 | def render
9 | output = RablRails::Renderers::PLIST.render(@template, @context).to_s.gsub!(INDENT_REGEXP, '')
10 | output.sub!(HEADER_REGEXP, '').gsub!(%r(?plist[^>]*>), '').sub!(%r(), '').sub(%r(), '')
11 | end
12 |
13 | before do
14 | @resource = User.new(1, 'Marty')
15 | @context = Context.new
16 | @context.assigns['user'] = @resource
17 | @template = RablRails::CompiledTemplate.new
18 | @template.data = :@user
19 | @template.add_node RablRails::Nodes::Attribute.new(name: :name)
20 | end
21 |
22 | it 'extends hash renderer' do
23 | RablRails::Renderers::PLIST.ancestors.include?(RablRails::Renderers::Hash)
24 | end
25 |
26 | it 'renders PList' do
27 | assert_equal %q(nameMarty), render
28 | end
29 |
30 | it 'uses template root_name option if include_plist_root is set' do
31 | @template.root_name = :user
32 | with_configuration :include_plist_root, true do
33 | assert_equal %q(usernameMarty), render
34 | end
35 | end
36 |
37 | it 'ignores template root_name by default' do
38 | @template.root_name = :user
39 | assert_equal %q(nameMarty), render
40 | end
41 | end
42 | end
--------------------------------------------------------------------------------
/lib/rabl-rails/configuration.rb:
--------------------------------------------------------------------------------
1 | require 'set'
2 |
3 | module RablRails
4 | class Configuration
5 | attr_accessor :json_engine, :include_json_root, :enable_jsonp_callbacks
6 | attr_accessor :xml_options
7 | attr_accessor :plist_engine, :include_plist_root
8 | attr_accessor :cache_templates
9 | attr_accessor :replace_nil_values_with_empty_strings
10 | attr_accessor :replace_empty_string_values_with_nil
11 | attr_accessor :exclude_nil_values
12 | attr_accessor :non_collection_classes
13 |
14 | def initialize
15 | @json_engine = defined?(::Oj) ? ::Oj : ::JSON
16 | @include_json_root = true
17 | @enable_jsonp_callbacks = false
18 |
19 | @xml_options = { dasherize: true, skip_types: false }
20 |
21 | @plist_engine = defined?(::Plist) ? ::Plist::Emit : nil
22 | @include_plist_root = false
23 |
24 | @cache_templates = ActionController::Base.perform_caching
25 |
26 | @replace_nil_values_with_empty_strings = false
27 | @replace_empty_string_values_with_nil = false
28 | @exclude_nil_values = false
29 |
30 | @non_collection_classes = Set.new(['Struct'])
31 | end
32 |
33 | def result_flags
34 | @result_flags ||= begin
35 | result = 0
36 | result |= 0b001 if @replace_nil_values_with_empty_strings
37 | result |= 0b010 if @replace_empty_string_values_with_nil
38 | result |= 0b100 if @exclude_nil_values
39 | result
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/helper.rb:
--------------------------------------------------------------------------------
1 | ENV['RAILS_ENV'] = 'test'
2 | $:.unshift File.expand_path('../../lib', __FILE__)
3 |
4 | # require 'rspec/mocks'
5 | require 'minitest/mock'
6 | require 'minitest/autorun'
7 |
8 | require 'rabl-rails'
9 | require 'plist'
10 | require 'action_dispatch/http/mime_type'
11 | require 'action_view'
12 |
13 | if RUBY_ENGINE == 'jruby'
14 | require 'nokogiri'
15 | elsif RUBY_ENGINE == 'ruby'
16 | require 'libxml'
17 | end
18 |
19 | ActionView::Template.register_template_handler :rabl, RablRails::Handlers::Rabl
20 |
21 | module Configurable
22 | def with_configuration(key, value)
23 | accessor = "#{key}="
24 | old_value = RablRails.configuration.send(key)
25 | RablRails.configuration.send(accessor, value)
26 | yield
27 | ensure
28 | RablRails.configuration.send(accessor, old_value)
29 | end
30 | end
31 | Minitest::Test.send(:include, Configurable)
32 |
33 | module Rails
34 | def self.cache
35 | end
36 | end
37 |
38 | module ActionController
39 | module Base
40 | def self.perform_caching
41 | false
42 | end
43 | end
44 | end
45 |
46 | class Context
47 | class LookupContext
48 | def initialize(format)
49 | @format = format
50 | end
51 |
52 | def formats
53 | [@format]
54 | end
55 | end
56 |
57 | attr_writer :virtual_path
58 | attr_reader :lookup_context
59 |
60 | def initialize(format = :json)
61 | @_assigns = {}
62 | @virtual_path = nil
63 | @lookup_context = LookupContext.new(format)
64 | end
65 |
66 | def assigns
67 | @_assigns
68 | end
69 |
70 | def params
71 | {}
72 | end
73 |
74 | def context_method
75 | end
76 | end
77 |
78 | class User
79 | attr_accessor :id, :name
80 |
81 | def initialize(id = nil, name = nil)
82 | @id = id
83 | @name = name
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/rabl-rails/library.rb:
--------------------------------------------------------------------------------
1 | require 'singleton'
2 | require 'monitor'
3 |
4 | module RablRails
5 | class Library
6 | include Singleton
7 |
8 | UnknownFormat = Class.new(StandardError)
9 |
10 | RENDERER_MAP = {
11 | json: Renderers::JSON,
12 | xml: Renderers::XML,
13 | ruby: Renderers::Hash,
14 | plist: Renderers::PLIST
15 | }.freeze
16 |
17 | def initialize
18 | @cached_templates = {}
19 | @monitor = Monitor.new
20 | end
21 |
22 | def reset_cache!
23 | @cached_templates = {}
24 | end
25 |
26 | def get_rendered_template(source, view, locals = nil)
27 | compiled_template = compile_template_from_source(source, view)
28 | format = view.lookup_context.formats.first || :json
29 | raise UnknownFormat, "#{format} is not supported in rabl-rails" unless RENDERER_MAP.key?(format)
30 | RENDERER_MAP[format].render(compiled_template, view, locals)
31 | end
32 |
33 | def compile_template_from_source(source, view)
34 | if RablRails.configuration.cache_templates
35 | path = view.instance_variable_get(:@virtual_path)
36 | synchronized_compile(path, source, view)
37 | else
38 | compile(source, view)
39 | end
40 | end
41 |
42 | def compile_template_from_path(path, view)
43 | if RablRails.configuration.cache_templates
44 | synchronized_compile(path, nil, view)
45 | else
46 | source = fetch_source(path, view)
47 | compile(source, view)
48 | end
49 | end
50 |
51 | private
52 |
53 | def synchronized_compile(path, source, view)
54 | @cached_templates[path] || @monitor.synchronize do
55 | # Any thread holding this lock will be compiling the template needed
56 | # by the threads waiting. So re-check the template presence to avoid
57 | # re-compilation
58 | @cached_templates.fetch(path) do
59 | source ||= fetch_source(path, view)
60 | @cached_templates[path] = compile(source, view)
61 | end
62 | end
63 | end
64 |
65 | def compile(source, view)
66 | Compiler.new(view).compile_source(source)
67 | end
68 |
69 | def fetch_source(path, view)
70 | t = view.lookup_context.find_template(path, [], false)
71 | t = t.refresh(view) unless t.source
72 | t.source
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/renderers/test_hash_renderer.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestHashRenderer < Minitest::Test
4 | describe 'hash renderer' do
5 | def render
6 | RablRails::Renderers::Hash.render(@template, @context, {})
7 | end
8 |
9 | def with_cache
10 | ActionController::Base.stub :perform_caching, true do
11 | Rails.stub :cache, @cache do
12 | yield
13 | end
14 | end
15 | end
16 |
17 | before do
18 | @cache = MiniTest::Mock.new
19 | @resource = User.new(1, 'Marty')
20 | @context = Context.new
21 | @context.assigns['user'] = @resource
22 | @template = RablRails::CompiledTemplate.new
23 | @template.data = :@user
24 | @template.add_node RablRails::Nodes::Attribute.new(name: :name)
25 | end
26 |
27 | describe 'cache' do
28 | it 'uses resource cache_key by default' do
29 | def @resource.cache_key; 'marty_cache' end
30 | @template.cache_key = nil
31 | @cache.expect :fetch, { user: 'Marty' }, ['marty_cache']
32 | with_cache {
33 | assert_equal({ user: 'Marty' }, render)
34 | }
35 | @cache.verify
36 | end
37 |
38 | it 'uses template cache_key if present' do
39 | @template.cache_key = ->(u) { u.name }
40 | @cache.expect :fetch, { user: 'Marty' }, ['Marty']
41 | with_cache {
42 | assert_equal({ user: 'Marty' }, render)
43 | }
44 | @cache.verify
45 | end
46 | end
47 |
48 | it 'uses a to_hash visitor' do
49 | visitor = MiniTest::Mock.new
50 | visitor.expect :instance_variable_get, @resource, [:@user]
51 | visitor.expect :reset_for, nil, [@resource]
52 | visitor.expect :visit, nil, [Array]
53 | visitor.expect :result, { some: 'result' }
54 |
55 | Visitors::ToHash.stub :new, visitor do
56 | assert_equal({ some: 'result' }, render)
57 | end
58 |
59 | visitor.verify
60 | end
61 |
62 | it 'retrieves data from context if exist' do
63 | @template.data = :context_method
64 | resource = User.new(2, 'Biff')
65 | @context.stub :context_method, resource do
66 | assert_equal({ name: 'Biff' }, render)
67 | end
68 | end
69 |
70 | it 'uses assigns from context if context has no data method' do
71 | assert_equal({ name: 'Marty' }, render)
72 | end
73 |
74 | it 'uses template root_name option' do
75 | @template.root_name = :user
76 | assert_equal({ user: { name: 'Marty' } }, render)
77 | end
78 |
79 | it 'renders collection' do
80 | @context.assigns['user'] = [@resource]
81 | assert_equal([{ name: 'Marty' }], render)
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/lib/rabl-rails/renderers/hash.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | module Renderers
3 | module Hash
4 | include ::RablRails::Helpers
5 | extend self
6 |
7 | #
8 | # Render a template.
9 | # Uses the compiled template source to get a hash with the actual
10 | # data and then format the result according to the `format_result`
11 | # method defined by the renderer.
12 | #
13 | def render(template, context, locals = nil)
14 | visitor = Visitors::ToHash.new(context)
15 |
16 | collection_or_resource = if template.data
17 | if context.respond_to?(template.data)
18 | context.send(template.data)
19 | else
20 | visitor.instance_variable_get(template.data)
21 | end
22 | end
23 |
24 | render_with_cache(template.cache_key, collection_or_resource) do
25 | output_hash = if collection?(collection_or_resource)
26 | render_collection(collection_or_resource, template.nodes, visitor)
27 | else
28 | render_resource(collection_or_resource, template.nodes, visitor)
29 | end
30 |
31 | format_output(output_hash, root_name: template.root_name, params: context.params)
32 | end
33 | end
34 |
35 | protected
36 |
37 | #
38 | # Format a hash into the desired output.
39 | # Renderer subclasses must implement this method
40 | #
41 | def format_output(hash, options = {})
42 | hash = { options[:root_name] => hash } if options[:root_name]
43 | hash
44 | end
45 |
46 | private
47 |
48 | #
49 | # Render a single resource as a hash, according to the compiled
50 | # template source passed.
51 | #
52 | def render_resource(resource, nodes, visitor)
53 | visitor.reset_for resource
54 | visitor.visit nodes
55 | visitor.result
56 | end
57 |
58 | #
59 | # Call the render_resource mtehod on each object of the collection
60 | # and return an array of the returned values.
61 | #
62 | def render_collection(collection, nodes, visitor)
63 | collection.map { |o| render_resource(o, nodes, visitor) }
64 | end
65 |
66 | def resolve_cache_key(key, data)
67 | return data.cache_key unless key
68 | key.is_a?(Proc) ? instance_exec(data, &key) : key
69 | end
70 |
71 | private
72 |
73 | def render_with_cache(key, collection_or_resource)
74 | if !key.is_a?(FalseClass) && ActionController::Base.perform_caching
75 | Rails.cache.fetch(resolve_cache_key(key, collection_or_resource)) do
76 | yield
77 | end
78 | else
79 | yield
80 | end
81 | end
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/test/test_library.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestLibrary < Minitest::Test
4 | RablRails::Library.send(:attr_reader, :cached_templates)
5 |
6 | describe 'library' do
7 | before do
8 | @library = RablRails::Library.instance
9 | @library.reset_cache!
10 | @context = Context.new
11 | @template = RablRails::CompiledTemplate.new
12 | end
13 |
14 | describe '#get_rendered_template' do
15 | it 'compiles and renders template' do
16 | result = @library.stub :compile_template_from_source, @template do
17 | @library.get_rendered_template '', @context
18 | end
19 |
20 | assert_equal '{}', result
21 | end
22 |
23 | it 'uses for from lookup context' do
24 | context = Context.new(:xml)
25 | result = @library.stub :compile_template_from_source, @template do
26 | RablRails::Renderers::XML.stub :render, '' do
27 | @library.get_rendered_template '', context
28 | end
29 | end
30 |
31 | assert_equal '', result
32 | end
33 |
34 | it 'raises if format is not supported' do
35 | context = Context.new(:unsupported)
36 | @library.stub :compile_template_from_source, @template do
37 | assert_raises(RablRails::Library::UnknownFormat) { @library.get_rendered_template '', context }
38 | end
39 | end
40 | end
41 |
42 | describe '#compile_template_from_source' do
43 | it 'compiles a template' do
44 | compiler = MiniTest::Mock.new
45 | compiler.expect :compile_source, @template, ['attribute :id']
46 |
47 | result = RablRails::Compiler.stub :new, compiler do
48 | @library.compile_template_from_source('attribute :id', @context)
49 | end
50 |
51 | assert_equal @template, result
52 | end
53 |
54 | it 'caches compiled template if option is set' do
55 | @context.virtual_path = 'users/base'
56 | template = with_configuration :cache_templates, true do
57 | @library.compile_template_from_source("attribute :id", @context)
58 | end
59 |
60 | assert_equal(template, @library.cached_templates['users/base'])
61 | end
62 |
63 | it 'compiles source without caching it if options is not set' do
64 | @context.virtual_path = 'users/base'
65 | with_configuration :cache_templates, false do
66 | @library.compile_template_from_source("attribute :id", @context)
67 | end
68 |
69 | assert_empty @library.cached_templates
70 | end
71 |
72 | it 'caches multiple templates in one compilation' do
73 | @context.virtual_path = 'users/show'
74 | with_configuration :cache_templates, true do
75 | @library.stub :fetch_source, 'attributes :id' do
76 | @library.compile_template_from_source("child(:account, partial: 'users/_account')", @context)
77 | end
78 | end
79 |
80 | assert_equal 2, @library.cached_templates.size
81 | end
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## 0.6.2
4 | * Add `fetch` node
5 |
6 | ## 0.6.1
7 | * Fix bug when template contains double quotes
8 |
9 | ## 0.6.0 (yanked)
10 | * Remove Rails 6+ warnings
11 | * Uniformize node options
12 | * Refresh README.md
13 |
14 | ## 0.5.5
15 | * Add `lookup` node
16 |
17 | ## 0.5.4
18 | * Relax concurrent-ruby version dependency (javierjulio)
19 |
20 | ## 0.5.3
21 | * Allow `extends` to accept lambdas
22 |
23 | ## 0.5.2
24 | * Add `const` node
25 |
26 | ## 0.5.1
27 | * Fix bug when trying to compile partials with caching enabled
28 |
29 | ## 0.5.0
30 | * Add requirement ruby >= 2.2
31 | * Drop support for Rails < 4.2
32 | * Replace `thread_safe` with `concurrent_ruby`
33 | * Remove custom responder
34 | * Remove rendering outside of Rails
35 | * Improve Rails 5 compatibility
36 |
37 | ## 0.4.3
38 | * Fix custom responder compatibility with responders 2.1 (itkin)
39 | * Fix bug when template was already loaded by ActionView and causing a nil
40 | error
41 |
42 | ## 0.4.2
43 | * Allow to pass locals to partials
44 | * Add condition to `attributes`
45 |
46 | ## 0.4.1
47 | * Make classes that should not be treated as collection configurable
48 | * Internal change to determine rendering format
49 |
50 | ## 0.4.0
51 | * Internal cleanup and refactor
52 | * Remove the `allow_empty_format_in_template` option, since it has become
53 | the default behavior.
54 | * Remove multi_json dependency
55 | * New options available
56 | * replace_nil_values_with_empty_strings
57 | * replace_empty_string_values_with_nil
58 | * exclude_nil_values
59 |
60 | ## 0.3.4
61 | * Add `xml_options` option to root_level (brettallred)
62 |
63 | * Format can be omitted in template filename
64 |
65 | RablRails.allow_empty_format_in_template = true
66 | RablRails.render(user, 'show') # => app/view/user.rabl
67 |
68 | * Rails 4 support
69 | * Update travis configuration and remove warning in tests (petergoldstein)
70 |
71 | ## 0.3.3
72 | * Add response caching
73 |
74 | ## 0.3.2
75 | * Using child with a nil value will be correctly formatted as nil
76 | * Allow controller's assigns to have symbol keys
77 | * Does not modify in place format extracted from context
78 | * Add JSONP support
79 |
80 | ## 0.3.1
81 | * Add `merge` keywork
82 | * Format can be passed as a string or a symbol
83 | * Avoid to unexpectedly change cached templates (johnbintz)
84 | * Add full template stack support to `glue` (fnordfish)
85 | * Allow format to be a symbol (lloydmeta)
86 |
87 | ## 0.3.0
88 | * Travis integration
89 | * Add test for keywords used as variable names
90 | * Add PList renderer
91 | * Remove location header from post responses in responder
92 | * Fix bug with incomplete template prefixing
93 |
94 | ## 0.2.2
95 | * Add condition blocks
96 |
97 | ## 0.2.1
98 | * Avoid useless render on POST request with custom responder
99 | * Custom responder now fallback to Rails default in case the template is not found
100 |
101 | ## 0.2.0
102 | * Add `root` in DSL to set root without changing the data source
103 | * Add XML renderer
104 | * Use MultiJson's preferred JSON engine as default (shmeltex)
105 | * Default template to render with responder can be set per controller
106 | * Reponder works out of the box with devise
107 | * object or collection can be skipped if use with `respond_to` blocks
108 |
109 | ## 0.1.3
110 | * Render correctly when variables are not passed via the assigns ivar but as helper methods
111 | (decent_exposure, focused_controller)
112 | * Add custom Responder
113 |
114 | ## 0.1.2
115 | * Add RablRails#render method (see README or source code)
116 | * Fix fail when JSON engine is not found. Now fallback to MultiJson.default_adapter
117 | * Warning message printed on logger when JSON engine fail to load
118 |
119 | ## 0.1.1
120 | * Add CHANGELOG
121 | * Remove unused test in loop
122 | * Speed up rendering by not double copying variable from context
123 | * Rename private variable to avoid name conflict
124 | * Remove sqlite3 development dependency
125 |
--------------------------------------------------------------------------------
/lib/rabl-rails/visitors/to_hash.rb:
--------------------------------------------------------------------------------
1 | module Visitors
2 | class ToHash < Visitor
3 | include RablRails::Helpers
4 |
5 | attr_reader :_resource
6 |
7 | def initialize(view_context, resource = nil)
8 | @_context = view_context
9 | @_result = {}
10 | @_resource = resource
11 | @_locals = {}
12 |
13 | copy_instance_variables_from_context
14 | end
15 |
16 | def reset_for(resource)
17 | @_resource = resource
18 | @_result = {}
19 | end
20 |
21 | def visit_Attribute n
22 | if !n.condition || instance_exec(_resource, &(n.condition))
23 | n.each { |k, v| @_result[k] = _resource.send(v) }
24 | end
25 | end
26 |
27 | def visit_Child n
28 | object = object_from_data(_resource, n)
29 |
30 | @_result[n.name] = if object
31 | collection?(object) ? object.map { |o| sub_visit(o, n.nodes) } : sub_visit(object, n.nodes)
32 | else
33 | nil
34 | end
35 | end
36 |
37 | def visit_Glue n
38 | object = object_from_data(_resource, n)
39 | @_result.merge!(sub_visit(object, n.nodes)) if object
40 | end
41 |
42 | def visit_Fetch n
43 | hash = object_from_data(_resource, n)
44 | key = _resource.public_send(n.field)
45 | object = hash[key]
46 |
47 | @_result[n.name] = if object
48 | collection?(object) ? object.map { |o| sub_visit(o, n.nodes) } : sub_visit(object, n.nodes)
49 | else
50 | nil
51 | end
52 | end
53 |
54 | def visit_Code n
55 | if !n.condition || instance_exec(_resource, &(n.condition))
56 | result = instance_exec _resource, &(n.block)
57 |
58 | if n.merge?
59 | raise RablRails::PartialError, '`merge` block should return a hash' unless result.is_a?(Hash)
60 | @_result.merge!(result)
61 | else
62 | @_result[n.name] = result
63 | end
64 | end
65 | end
66 |
67 | def visit_Const n
68 | @_result[n.name] = n.value
69 | end
70 |
71 | def visit_Lookup n
72 | object = object_from_data(_resource, n)
73 | key = _resource.public_send(n.field)
74 | value = object[key]
75 | value = !!value if n.cast_to_boolean?
76 |
77 | @_result[n.name] = value
78 | end
79 |
80 | def visit_Condition n
81 | @_result.merge!(sub_visit(_resource, n.nodes)) if instance_exec _resource, &(n.condition)
82 | end
83 |
84 | def visit_Extend n
85 | @_locals = n.locals
86 | @_result.merge!(sub_visit(_resource, n.nodes))
87 | ensure
88 | @_locals = {}
89 | end
90 |
91 | def visit_Polymorphic n
92 | template_path = n.template_lambda.call(_resource)
93 | template = RablRails::Library.instance.compile_template_from_path(template_path, @_context)
94 | @_result.merge!(sub_visit(_resource, template.nodes))
95 | end
96 |
97 | def result
98 | case RablRails.configuration.result_flags
99 | when 0
100 | @_result
101 | when 1
102 | @_result.each { |k, v| @_result[k] = ''.freeze if v == nil }
103 | when 2, 3
104 | @_result.each { |k, v| @_result[k] = nil if v == ''.freeze }
105 | when 4, 5
106 | @_result.delete_if { |_, v| v == nil }
107 | when 6
108 | @_result.delete_if { |_, v| v == nil || v == ''.freeze }
109 | end
110 | end
111 |
112 | protected
113 |
114 | #
115 | # If a method is called inside a 'node' property or a 'if' lambda
116 | # it will be passed to context if it exists or treated as a standard
117 | # missing method.
118 | #
119 | def method_missing(name, *args, &block)
120 | @_context.respond_to?(name) ? @_context.send(name, *args, &block) : super
121 | end
122 |
123 | def locals
124 | @_locals
125 | end
126 |
127 | #
128 | # Allow to use partial inside of node blocks (they are evaluated at
129 | # rendering time).
130 | #
131 | def partial(template_path, options = {})
132 | raise RablRails::PartialError.new("No object was given to partial #{template_path}") unless options[:object]
133 | object = options[:object]
134 | @_locals = options[:locals].freeze
135 |
136 | return [] if object.respond_to?(:empty?) && object.empty?
137 |
138 | template = RablRails::Library.instance.compile_template_from_path(template_path, @_context)
139 | if object.respond_to?(:each)
140 | object.map { |o| sub_visit o, template.nodes }
141 | else
142 | sub_visit object, template.nodes
143 | end
144 | ensure
145 | @_locals = {}
146 | end
147 |
148 | private
149 |
150 | def copy_instance_variables_from_context
151 | @_context.instance_variable_get(:@_assigns).each_pair { |k, v|
152 | instance_variable_set("@#{k}", v) unless k.to_s.start_with?('_'.freeze)
153 | }
154 | end
155 |
156 | def sub_visit(resource, nodes)
157 | old_result, old_resource, @_result = @_result, @_resource, {}
158 | reset_for resource
159 | visit nodes
160 | result
161 | ensure
162 | @_result, @_resource = old_result, old_resource
163 | end
164 |
165 | def object_from_data(resource, node)
166 | return resource if node.data == nil
167 |
168 | symbol = node.data
169 | if node.instance_variable_data?
170 | instance_variable_get(symbol)
171 | else
172 | resource.respond_to?(symbol) ? resource.send(symbol) : @_context.send(symbol)
173 | end
174 | end
175 | end
176 | end
177 |
--------------------------------------------------------------------------------
/lib/rabl-rails/compiler.rb:
--------------------------------------------------------------------------------
1 | module RablRails
2 | #
3 | # Class that will compile RABL source code into a hash
4 | # representing data structure
5 | #
6 | class Compiler
7 | def initialize(view)
8 | @view = view
9 | end
10 |
11 | #
12 | # Compile from source code and return the CompiledTemplate
13 | # created.
14 | #
15 | def compile_source(source)
16 | @template = CompiledTemplate.new
17 | instance_eval(source)
18 | @template
19 | end
20 |
21 | #
22 | # Sets the object to be used as the data for the template
23 | # Example:
24 | # object :@user
25 | # object :@user, :root => :author
26 | #
27 | def object(data, options = {})
28 | @template.data, @template.root_name = extract_data_and_name(data)
29 | @template.root_name = options[:root] if options.has_key? :root
30 | end
31 | alias_method :collection, :object
32 |
33 | def root(name)
34 | @template.root_name = name
35 | end
36 |
37 | #
38 | # Includes the attribute or method in the output
39 | # Example:
40 | # attributes :id, :name
41 | # attribute :email => :super_secret
42 | #
43 | def attribute(*args)
44 | node = Nodes::Attribute.new
45 |
46 | if args.first.is_a?(Hash)
47 | args.first.each_pair { |k, v| node[v] = k }
48 | else
49 | options = args.extract_options!
50 | args.each { |name|
51 | key = options[:as] || name
52 | node[key] = name
53 | }
54 | node.condition = options[:if]
55 | end
56 |
57 | @template.add_node node
58 | end
59 | alias_method :attributes, :attribute
60 |
61 | #
62 | # Creates a child node to be included in the output.
63 | # name_or data can be an object or collection or a method to call on the data. It
64 | # accepts :root and :partial options.
65 | # Note that partial and blocks are not compatible
66 | # Example:
67 | # child(:@posts, :root => :posts) { attribute :id }
68 | # child(:posts, :partial => 'posts/base')
69 | #
70 | def child(name_or_data, options = {})
71 | data, name = extract_data_and_name(name_or_data)
72 | name = options[:root] if options.has_key? :root
73 | name = options[:as] if options.has_key? :as
74 | template = partial_or_block(data, options) { yield }
75 | @template.add_node Nodes::Child.new(name, template)
76 | end
77 |
78 | #
79 | # Glues data from a child node to the output
80 | # Example:
81 | # glue(:@user) { attribute :name }
82 | #
83 | def glue(data, options = {})
84 | template = partial_or_block(data, options) { yield }
85 | @template.add_node Nodes::Glue.new(template)
86 | end
87 |
88 | #
89 | # Creates a node to be added to the output by fetching an object using
90 | # current resource's field as key to the data, and appliying given
91 | # template to said object
92 | # Example:
93 | # fetch(:@stats, field: :id) { attributes :total }
94 | #
95 | def fetch(name_or_data, options = {})
96 | data, name = extract_data_and_name(name_or_data)
97 | name = options[:as] if options.key?(:as)
98 | field = options.fetch(:field, :id)
99 | template = partial_or_block(data, options) { yield }
100 | @template.add_node Nodes::Fetch.new(name, template, field)
101 | end
102 |
103 | #
104 | # Creates an arbitrary node in the json output.
105 | # It accepts :if option to create conditionnal nodes. The current data will
106 | # be passed to the block so it is advised to use it instead of ivars.
107 | # Example:
108 | # node(:name) { |user| user.first_name + user.last_name }
109 | # node(:role, if: ->(u) { !u.admin? }) { |u| u.role }
110 | #
111 | def node(name = nil, options = {}, &block)
112 | return unless block_given?
113 | @template.add_node Nodes::Code.new(name, block, options[:if])
114 | end
115 | alias_method :code, :node
116 |
117 | #
118 | # Creates a constant node in the json output.
119 | # Example:
120 | # const(:locale, 'fr_FR')
121 | #
122 | def const(name, value)
123 | @template.add_node Nodes::Const.new(name, value)
124 | end
125 |
126 | #
127 | # Create a node `name` by looking the current resource being rendered in the
128 | # `object` hash using, by default, the resource's id.
129 | # Example:
130 | # lookup(:favorite, :@user_favorites, cast: true)
131 | #
132 | def lookup(name, object, field: :id, cast: false)
133 | @template.add_node Nodes::Lookup.new(name, object, field, cast)
134 | end
135 |
136 | #
137 | # Merge arbitrary data into json output. Given block should
138 | # return a hash.
139 | # Example:
140 | # merge { |item| partial("specific/#{item.to_s}", object: item) }
141 | #
142 | def merge(opts = {})
143 | return unless block_given?
144 | node(nil, opts) { yield }
145 | end
146 |
147 | #
148 | # Extends an existing rabl template
149 | # Example:
150 | # extends 'users/base'
151 | # extends ->(item) { "v1/#{item.class}/_core" }
152 | # extends 'posts/base', locals: { hide_comments: true }
153 | #
154 | def extends(path_or_lambda, options = nil)
155 | if path_or_lambda.is_a?(Proc)
156 | @template.add_node Nodes::Polymorphic.new(path_or_lambda)
157 | return
158 | end
159 |
160 | other = Library.instance.compile_template_from_path(path_or_lambda, @view)
161 |
162 | if options && options.is_a?(Hash)
163 | @template.add_node Nodes::Extend.new(other.nodes, options[:locals])
164 | else
165 | @template.extends(other)
166 | end
167 | end
168 |
169 | #
170 | # Provide a conditionnal block
171 | #
172 | # condition(->(u) { u.is_a?(Admin) }) do
173 | # attributes :secret
174 | # end
175 | #
176 | def condition(proc)
177 | return unless block_given?
178 | @template.add_node Nodes::Condition.new(proc, sub_compile(nil, true) { yield })
179 | end
180 | alias_method :_if, :condition
181 |
182 | def cache(&block)
183 | @template.cache_key = block_given? ? block : nil
184 | end
185 |
186 | protected
187 |
188 | def partial_or_block(data, options)
189 | if options&.key?(:partial)
190 | template = Library.instance.compile_template_from_path(options[:partial], @view)
191 | template.data = data
192 | template
193 | elsif block_given?
194 | sub_compile(data) { yield }
195 | end
196 | end
197 |
198 | #
199 | # Extract data root_name and root name
200 | # Example:
201 | # :@users -> [:@users, nil]
202 | # :@users => :authors -> [:@users, :authors]
203 | #
204 | def extract_data_and_name(name_or_data)
205 | case name_or_data
206 | when Symbol
207 | str = name_or_data.to_s
208 | str.start_with?('@') ? [name_or_data, str[1..-1]] : [name_or_data, name_or_data]
209 | when Hash
210 | name_or_data.first
211 | else
212 | name_or_data
213 | end
214 | end
215 |
216 | def sub_compile(data, only_nodes = false)
217 | raise unless block_given?
218 | old_template, @template = @template, CompiledTemplate.new
219 | yield
220 | @template.data = data
221 | only_nodes ? @template.nodes : @template
222 | ensure
223 | @template = old_template
224 | end
225 | end
226 | end
227 |
--------------------------------------------------------------------------------
/test/test_hash_visitor.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 |
3 | class TestHashVisitor < Minitest::Test
4 | describe 'hash visitor' do
5 | def visitor_result
6 | visitor = Visitors::ToHash.new(@context)
7 | visitor.reset_for @resource
8 | visitor.visit @nodes
9 | visitor.result
10 | end
11 |
12 | before do
13 | @context = Context.new
14 | @resource = User.new(1, 'Marty')
15 | @nodes = []
16 | end
17 |
18 | it 'renders empty nodes list' do
19 | assert_equal({}, visitor_result)
20 | end
21 |
22 | it 'renders attributes node' do
23 | @nodes << RablRails::Nodes::Attribute.new(id: :id)
24 | assert_equal({ id: 1 }, visitor_result)
25 | end
26 |
27 | it 'renders attributes with a condition' do
28 | n = RablRails::Nodes::Attribute.new(id: :id)
29 | n.condition = lambda { |o| false }
30 | @nodes << n
31 | assert_equal({}, visitor_result)
32 | end
33 |
34 | it 'renders array of nodes' do
35 | @nodes = [
36 | RablRails::Nodes::Attribute.new(id: :id),
37 | RablRails::Nodes::Attribute.new(name: :name)
38 | ]
39 | assert_equal({ id: 1, name: 'Marty' }, visitor_result)
40 | end
41 |
42 | describe 'with a child node' do
43 | Address = Struct.new(:city)
44 |
45 | before do
46 | @template = RablRails::CompiledTemplate.new
47 | @template.add_node(RablRails::Nodes::Attribute.new(city: :city))
48 | @address = Address.new('Paris')
49 | end
50 |
51 | it 'renders with resource association as data source' do
52 | @template.data = :address
53 | @nodes << RablRails::Nodes::Child.new(:address, @template)
54 | def @resource.address; end
55 | @resource.stub :address, @address do
56 | assert_equal({ address: { city: 'Paris' } }, visitor_result)
57 | end
58 | end
59 |
60 | it 'renders with arbitrary data source' do
61 | @template.data = :@address
62 | @nodes = [RablRails::Nodes::Child.new(:address, @template)]
63 | @context.assigns['address'] = @address
64 | assert_equal({ address: { city: 'Paris' } }, visitor_result)
65 | end
66 |
67 | it 'renders with local method as data source' do
68 | @template.data = :address
69 | @nodes << RablRails::Nodes::Child.new(:address, @template)
70 | def @context.address; end
71 | @context.stub :address, @address do
72 | assert_equal({ address: { city: 'Paris' } }, visitor_result)
73 | end
74 | end
75 |
76 | it 'renders with a collection as data source' do
77 | @template.data = :address
78 | @nodes << RablRails::Nodes::Child.new(:address, @template)
79 | def @context.address; end
80 | @context.stub :address, [@address, @address] do
81 | assert_equal({ address: [
82 | { city: 'Paris' },
83 | { city: 'Paris' }
84 | ]}, visitor_result)
85 | end
86 | end
87 |
88 | it 'renders if the source is nil' do
89 | @template.data = :address
90 | @nodes << RablRails::Nodes::Child.new(:address, @template)
91 | def @resource.address; end
92 | @resource.stub :address, nil do
93 | assert_equal({ address: nil }, visitor_result)
94 | end
95 | end
96 | end
97 |
98 | it 'renders glue nodes' do
99 | template = RablRails::CompiledTemplate.new
100 | template.add_node(RablRails::Nodes::Attribute.new(name: :name))
101 | template.data = :@user
102 |
103 | @nodes << RablRails::Nodes::Glue.new(template)
104 | @context.assigns['user'] = @resource
105 | assert_equal({ name: 'Marty'}, visitor_result)
106 | end
107 |
108 | it 'renders fetch node' do
109 | template = RablRails::CompiledTemplate.new
110 | template.add_node(RablRails::Nodes::Attribute.new(name: :name))
111 | template.data = :@users_hash
112 |
113 | @nodes << RablRails::Nodes::Fetch.new(:user, template, :id)
114 | @context.assigns['users_hash'] = { @resource.id => @resource }
115 |
116 | assert_equal({ user: { name: 'Marty' } }, visitor_result)
117 | end
118 |
119 | describe 'with a code node' do
120 | before do
121 | @proc = ->(object) { object.name }
122 | end
123 |
124 | it 'renders the evaluated proc' do
125 | @nodes << RablRails::Nodes::Code.new(:name, @proc)
126 | assert_equal({ name: 'Marty'}, visitor_result)
127 | end
128 |
129 | it 'renders with a true condition' do
130 | @nodes << RablRails::Nodes::Code.new(:name, @proc, ->(o) { true })
131 | assert_equal({ name: 'Marty'}, visitor_result)
132 | end
133 |
134 | it 'renders nothing with a false condition' do
135 | @nodes << RablRails::Nodes::Code.new(:name, @proc, ->(o) { false })
136 | assert_equal({}, visitor_result)
137 | end
138 |
139 | it 'renders method called from context' do
140 | @proc = ->(object) { context_method }
141 | def @context.context_method; end
142 |
143 | @nodes = [RablRails::Nodes::Code.new(:name, @proc)]
144 | @context.stub :context_method, 'Biff' do
145 | assert_equal({ name: 'Biff'}, visitor_result)
146 | end
147 | end
148 | end
149 |
150 | it 'renders a const node' do
151 | @nodes << RablRails::Nodes::Const.new(:locale, 'fr_FR')
152 | assert_equal({ locale: 'fr_FR' }, visitor_result)
153 | end
154 |
155 | it 'renders a positive lookup node' do
156 | @nodes << RablRails::Nodes::Lookup.new(:favorite, :@user_favorites, :id, true)
157 | @context.assigns['user_favorites'] = { 1 => true }
158 |
159 | assert_equal({ favorite: true }, visitor_result)
160 | end
161 |
162 | it 'renders a negative lookup node' do
163 | @nodes << RablRails::Nodes::Lookup.new(:favorite, :@user_favorites, :id, false)
164 | @context.assigns['user_favorites'] = { 2 => true }
165 |
166 | assert_equal({ favorite: nil }, visitor_result)
167 | end
168 |
169 | describe 'with a condition node' do
170 | before do
171 | @ns = [RablRails::Nodes::Attribute.new(name: :name)]
172 | end
173 |
174 | it 'renders transparently if the condition is met' do
175 | @nodes << RablRails::Nodes::Condition.new(->(o) { true }, @ns)
176 | assert_equal({ name: 'Marty' }, visitor_result)
177 | end
178 |
179 | it 'renders nothing if the condition is not met' do
180 | @nodes << RablRails::Nodes::Condition.new(->(o) { false }, @ns)
181 | assert_equal({}, visitor_result)
182 | end
183 | end
184 |
185 | it 'renders a merge node' do
186 | proc = ->(c) { { custom: c.name } }
187 | @nodes << RablRails::Nodes::Code.new(nil, proc)
188 | assert_equal({ custom: 'Marty' }, visitor_result)
189 | end
190 |
191 | it 'raises an exception when trying to merge a non hash object' do
192 | proc = ->(c) { c.name }
193 | @nodes << RablRails::Nodes::Code.new(nil, proc)
194 | assert_raises(RablRails::PartialError) { visitor_result }
195 | end
196 |
197 | it 'renders partial defined in node' do
198 | template = RablRails::CompiledTemplate.new
199 | template.add_node(RablRails::Nodes::Attribute.new(name: :name))
200 | proc = ->(u) { partial('users/base', object: u) }
201 |
202 | library = MiniTest::Mock.new
203 | library.expect :compile_template_from_path, template, ['users/base', @context]
204 |
205 | @nodes << RablRails::Nodes::Code.new(:user, proc)
206 | RablRails::Library.stub :instance, library do
207 | assert_equal({ user: { name: 'Marty' } }, visitor_result)
208 | end
209 |
210 | library.verify
211 | end
212 |
213 | it 'renders partial defined in node' do
214 | template = RablRails::CompiledTemplate.new
215 | template.add_node(RablRails::Nodes::Attribute.new(name: :name))
216 | library = MiniTest::Mock.new
217 | library.expect :compile_template_from_path, template, ['users/base', @context]
218 |
219 | @nodes << RablRails::Nodes::Polymorphic.new(->(_) { 'users/base' })
220 | RablRails::Library.stub :instance, library do
221 | assert_equal({ name: 'Marty' }, visitor_result)
222 | end
223 |
224 | library.verify
225 | end
226 |
227 | it 'allows uses of locals variables with partials' do
228 | template = RablRails::CompiledTemplate.new
229 | template.add_node(RablRails::Nodes::Code.new(:hide_comments, ->(u) { locals[:hide_comments] }, ->(u) { locals.key?(:hide_comments) }))
230 | proc = ->(u) { partial('users/locals', object: u, locals: { hide_comments: true }) }
231 |
232 | library = MiniTest::Mock.new
233 | library.expect :compile_template_from_path, template, ['users/locals', @context]
234 |
235 | @nodes << RablRails::Nodes::Code.new(:user, proc)
236 | RablRails::Library.stub :instance, library do
237 | assert_equal({ user: { hide_comments: true } }, visitor_result)
238 | end
239 |
240 | library.verify
241 | end
242 |
243 | it 'renders extend with locals' do
244 | n = RablRails::Nodes::Attribute.new(id: :id)
245 | n.condition = lambda { |_| locals[:display_id] }
246 |
247 | @nodes << RablRails::Nodes::Extend.new(n, display_id: true)
248 | assert_equal({ id: 1 }, visitor_result)
249 |
250 | @nodes.first.locals[:display_id] = false
251 | assert_equal({}, visitor_result)
252 | end
253 |
254 | it 'renders partial with empty target' do
255 | proc = ->(u) { partial('users/base', object: []) }
256 | @nodes << RablRails::Nodes::Code.new(:users, proc)
257 | assert_equal({ users: [] }, visitor_result)
258 | end
259 |
260 | it 'raises an exception when calling a partial without a target' do
261 | proc = ->(u) { partial('users/base') }
262 | @nodes << RablRails::Nodes::Code.new(:user, proc)
263 | assert_raises(RablRails::PartialError) { visitor_result }
264 | end
265 |
266 | describe 'when hash options are set' do
267 | before do
268 | RablRails.reset_configuration
269 | @nodes << RablRails::Nodes::Attribute.new(name: :name)
270 | end
271 |
272 | after { RablRails.reset_configuration }
273 |
274 | it 'replaces nil values by strings' do
275 | RablRails.configuration.replace_nil_values_with_empty_strings = true
276 | @resource = User.new(1, nil)
277 |
278 | assert_equal({ name: '' }, visitor_result)
279 | end
280 |
281 | it 'replaces empty string by nil' do
282 | RablRails.configuration.replace_empty_string_values_with_nil = true
283 | @resource = User.new(1, '')
284 |
285 | assert_equal({ name: nil }, visitor_result)
286 | end
287 |
288 | it 'excludes nil values' do
289 | RablRails.configuration.exclude_nil_values = true
290 | @resource = User.new(1, nil)
291 | @nodes << RablRails::Nodes::Attribute.new(id: :id)
292 |
293 | assert_equal({ id: 1 }, visitor_result)
294 | end
295 |
296 | it 'excludes nil values and empty strings' do
297 | RablRails.configuration.replace_empty_string_values_with_nil = true
298 | RablRails.configuration.exclude_nil_values = true
299 | @resource = User.new(nil, '')
300 | @nodes << RablRails::Nodes::Attribute.new(id: :id)
301 |
302 | assert_equal({}, visitor_result)
303 | end
304 | end
305 | end
306 | end
307 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RABL for Rails [](https://travis-ci.org/ccocchi/rabl-rails)
2 |
3 | `rabl-rails` is a ruby templating system for rendering your objects in different format (JSON, XML, PLIST).
4 |
5 | This gem aims for speed and little memory footprint while letting you build complex response with a very intuitive DSL.
6 |
7 | `rabl-rails` targets **Rails 4.2/5/6 application** and have been testing with MRI and jRuby.
8 |
9 | ## Installation
10 |
11 | Install as a gem :
12 |
13 | ```
14 | gem install rabl-rails
15 | ```
16 |
17 | or add directly to your `Gemfile`
18 |
19 | ```
20 | gem 'rabl-rails', '~> 0.6.0'
21 | ```
22 |
23 | ## Overview
24 |
25 | The gem enables you to build responses using views like you would using HTML/erb/haml.
26 | As example, assuming you have a `Post` model filled with blog posts, and a `PostController` that look like this:
27 |
28 | ```ruby
29 | class PostController < ApplicationController
30 | def index
31 | @posts = Post.order('created_at DESC')
32 | end
33 | end
34 | ```
35 |
36 | You can create the following RABL-rails template to express the API output of `@posts`
37 |
38 | ```ruby
39 | # app/views/post/index.rabl
40 | collection :@posts
41 |
42 | attributes :id, :title, :subject
43 | child(:user) { attributes :full_name }
44 | node(:read) { |post| post.read_by?(@user) }
45 | ```
46 |
47 | This would output the following JSON when visiting `http://localhost:3000/posts.json`
48 |
49 | ```js
50 | [{
51 | "id" : 5, title: "...", subject: "...",
52 | "user" : { full_name : "..." },
53 | "read" : true
54 | }]
55 | ```
56 |
57 | ## How it works
58 |
59 | This gem separates compiling, ie. transforming a RABL-rails template into a Ruby hash, and the actual rendering of the object or collection. This allows to only compile the template once (when template caching is enabled) which is the slow part, and only use hashes during rendering.
60 |
61 | The drawback of compiling the template outside of any rendering context is that we can't access instance variables like usual. Instead, you'll mostly use symbols representing your variables and the gem will retrieve them when needed.
62 |
63 | There are places where the gem allows for "dynamic code" -- code that is evaluated at each rendering, such as within `node` or `condition` blocks.
64 |
65 | ```ruby
66 | # We reference the @posts varibles that will be used at rendering time
67 | collection :@posts
68 |
69 | # Here you can use directly the instance variable because it
70 | # will be evaluated when rendering the object
71 | node(:read) { |post| post.read_by?(@user) }
72 | ```
73 |
74 | The same rule applies for view helpers such as `current_user`
75 |
76 | After the template is compiled into a hash, `rabl-rails` will use a renderer to create the actual output. Currently, JSON, XML and PList formats are supported.
77 |
78 | ## Configuration
79 |
80 | RablRails works out of the box, with default options and fastest engine available (oj, libxml). But depending on your needs, you might want to change that or how your output looks like. You can set global configuration in your application:
81 |
82 | ```ruby
83 | # config/initializers/rabl_rails.rb
84 |
85 | RablRails.configure do |config|
86 | # These are the default
87 | # config.cache_templates = true
88 | # config.include_json_root = true
89 | # config.json_engine = ::Oj
90 | # config.xml_options = { :dasherize => true, :skip_types => false }
91 | # config.enable_jsonp_callbacks = false
92 | # config.replace_nil_values_with_empty_strings = false
93 | # config.replace_empty_string_values_with_nil = false
94 | # config.exclude_nil_values = false
95 | # config.non_collection_classes = Set.new(['Struct'])
96 | end
97 | ```
98 |
99 | ## Usage
100 |
101 | ### Data declaration
102 |
103 | To declare data to use in the template, you can use either `object` or `collection` with the symbol name or your data.
104 |
105 | ```ruby
106 | # app/views/users/show.json.rabl
107 | object :@user
108 |
109 | # app/views/users/index.json.rabl
110 | collection :@users
111 | ```
112 |
113 | You can specify root label for the collection using hash or `:root` option
114 |
115 | ```ruby
116 | collection :@posts, root: :articles
117 | #is equivalent to
118 | collection :@posts => :articles
119 |
120 | # => { "articles" : [{...}, {...}] }
121 | ```
122 |
123 | There are rares cases when the template doesn't map directly to any object. In these cases, you can set data to false.
124 |
125 | ```ruby
126 | object false
127 | node(:some_count) { |_| @user.posts.count }
128 | child(:@user) { attribute :name }
129 | ```
130 |
131 | If you use gems like *decent_exposure* or *focused_controller*, you can use your variable directly without the leading `@`
132 |
133 | ```ruby
134 | object :object_exposed
135 | ```
136 |
137 | ### Attributes / Methods
138 |
139 | Adds a new field to the response object, calling the method on the object being rendered. Methods called this way should return natives types from the format you're using (such as `String`, `integer`, etc for JSON). For more complex objects, see `child` nodes.
140 |
141 | ```ruby
142 | attributes :id, :title, :to_s
143 | ```
144 |
145 | You can aliases these attributes in your response
146 |
147 | ```ruby
148 | attributes :my_custom_method, as: :title
149 | # => { "title" : }
150 | ```
151 |
152 | or show attributes based on a condition. The currently rendered object is given to the `proc` condition.
153 |
154 | ```ruby
155 | attributes :published_at, :anchor, if: ->(post) { post.published? }
156 | ```
157 |
158 | ### Child nodes
159 |
160 | Changes the object being rendered for the duration of the block. Depending on if you use `node` or `glue`, the result will be added as a new field or merged respectively.
161 |
162 | Data passed can be a method or a reference to an instance variable.
163 |
164 | For example if you have a `Post` model that belongs to a `User` and want to add the user's name to your response.
165 |
166 | ```ruby
167 | object :@post
168 |
169 | child(:user, as: :author) do
170 | attributes :name
171 | end
172 | # => { "post": { "author" : { "name" : "John D." } } }
173 | ```
174 |
175 | If instead of having an `author` node in your response you wanted the name at the root level, you can use `glue`:
176 |
177 | ```ruby
178 | object :@post
179 |
180 | glue(:user) do
181 | attributes :name, as: :author_name
182 | end
183 | # => { "post": { "author_name" : "John D." } }
184 | ```
185 |
186 | Arbitrary data source can also be passed:
187 |
188 | ```ruby
189 | # in your controller
190 | # @custom_data = [...]
191 |
192 | # in the view
193 | child(:@custom_data) do
194 | attributes :id, :name
195 | end
196 | # => { "custom_data": [...] }
197 | ```
198 |
199 | You can use a Hash-like data source, as long as keys match a method or attribute of your main resource, using the `fetch` keyword:
200 |
201 | ```ruby
202 | # assuming you have something similar in your controller
203 | # @users_hash = { 1 => User.new(pseudo: 'Batman') }
204 |
205 | # in the view
206 | object :@post
207 |
208 | fetch(:@users_hash, as: :user, field: :user_id) do
209 | attributes :pseudo
210 | end
211 | # => { user: { pseudo: 'Batman' } }
212 | ```
213 |
214 | This comes very handy when adding attributes from external queries not really bound to a relation, like statistics.
215 |
216 | ### Constants
217 |
218 | Adds a new field to the response using an immutable value.
219 |
220 | ```ruby
221 | const(:api_version, API::VERSION)
222 | const(:locale, 'fr_FR')
223 | ```
224 |
225 | ### Lookups
226 |
227 | Adds a new field to the response, using rendered resource's id by default or any method to fetch a value from the given hash variable.
228 |
229 | ```ruby
230 | collection :@posts
231 |
232 | lookup(:comments_count, :@comments_count, field: :uuid, cast: false)
233 | # => [{ "comments_count": 3 }, { "comments_count": 6 }]
234 | ```
235 |
236 | In the example above, for each post it will fetch the value from `@comments_count` using the post's `uuid` as key. When the `cast` value is set to `true` (it is `false` by default), the value will be casted to a boolean using `!!`.
237 |
238 |
239 | ### Custom nodes
240 |
241 | Adds a new field to the response with block's result as value.
242 |
243 | ```ruby
244 | object :@user
245 | node(:full_name) { |u| u.first_name + " " + u.last_name }
246 | # => { "user" : { "full_name" : "John Doe" } }
247 | ```
248 |
249 | You can add condition on your custom nodes. If the condition evaluates to a falsey value, the node will not added to the response at all.
250 |
251 | ```ruby
252 | node(:email, if: ->(u) { u.valid_email? }) do |u|
253 | u.email
254 | end
255 | ```
256 |
257 | Nodes are evaluated at rendering time, so you can use any instance variables or view helpers within them
258 |
259 | ```ruby
260 | node(:url) { |post| post_url(post) }
261 | ```
262 |
263 | If the result of the block is a Hash, it can be directly merge into the response using `merge` instead of `node`
264 |
265 | ```ruby
266 | object :@user
267 | merge { |u| { name: u.first_name + " " + u.last_name } }
268 | # => { "user" : { "name" : "John Doe" } }
269 | ```
270 |
271 | ### Extends & Partials
272 |
273 | Often objects have a basic representation that is shared accross different views and enriched according to it. To avoid code redundancy you can extend your template from any other RABL template.
274 |
275 | ```ruby
276 | # app/views/shared/_user.rabl
277 | attributes :id, :name
278 |
279 | # app/views/users/show.rabl
280 | object :@user
281 |
282 | extends('shared/_user')
283 | attributes :super_secret_attribute
284 |
285 | #=> { "id": 1, "name": "John", "super_secret_attribute": "Doe" }
286 | ```
287 |
288 | When used with child node, if they are the only thing added you can instead use the `partial` option directly.
289 |
290 | ```ruby
291 | child(:user, partial: 'shared/_user')
292 |
293 | # is equivalent to
294 |
295 | child(:user) do
296 | extends('shared/_user')
297 | end
298 | ```
299 |
300 | Extends can be used dynamically using rendered object and lambdas.
301 |
302 | ```ruby
303 | extends ->(user) { "shared/_#{user.client_type}_infos" }
304 | ```
305 |
306 | Partials can also be used inside custom nodes. When using partial this way, you MUST declare the `object` associated to the partial
307 |
308 | ```ruby
309 | node(:location) do |user|
310 | { city: user.city, address: partial('users/address', object: m.address) }
311 | end
312 | ```
313 |
314 | When used this way, partials can take locals variables that can be accessed in the included template.
315 |
316 | ```ruby
317 | # _credit_card.rabl
318 | node(:credit_card, if: ->(u) { locals[:display_credit_card] }) do |user|
319 | user.credit_card_info
320 | end
321 |
322 | # user.json.rabl
323 | merge { |u| partial('_credit_card', object: u, locals: { display_credit_card: true }) }
324 | ```
325 |
326 | ### Putting it all together
327 |
328 | `rabl-rails` allows you to format your responses easily, from simple objects to hierarchy of 2 or 3 levels.
329 |
330 | ```ruby
331 | object :@thread
332 |
333 | attribute :caption, as: :title
334 |
335 | child(:@sorted_posts, as: :posts) do
336 | attributes :title, :slug
337 |
338 | child :comments do
339 | extends 'shared/_comment'
340 | lookup(:upvotes, :@upvotes_per_comment)
341 | end
342 | end
343 | ```
344 |
345 | ### Other features
346 |
347 | * [Caching](https://github.com/ccocchi/rabl-rails/wiki/Caching)
348 |
349 | And more in the [WIKI](https://github.com/ccocchi/rabl-rails/wiki)
350 |
351 | ## Performance
352 |
353 | Benchmarks have been made using this [application](http://github.com/ccocchi/rabl-benchmark), with rabl 0.13.1 and rabl-rails 0.5.0
354 |
355 | Overall, rabl-rails is **10% faster and use 10% less memory**, but these numbers skyrockets to **50%** when using `extends` with collection of objects.
356 |
357 | You can see full tests on test application repository.
358 |
359 | ## Authors and contributors
360 |
361 | * [Christopher Cocchi-Perrier](http://github.com/ccocchi) - Creator of the project
362 |
363 | Want to add another format to Rabl-rails ? Checkout [JSON renderer](http://github.com/ccocchi/rabl-rails/blob/master/lib/rabl-rails/renderers/json.rb) for reference
364 | Want to make another change ? Just fork and contribute, any help is very much appreciated. If you found a bug, you can report it via the Github issues.
365 |
366 | ## Original idea
367 |
368 | * [RABL](http://github.com/nesquena/rabl) Standart RABL gem. I used it a lot but I needed to improve my API response time, and since most of the time was spent in view rendering, I decided to implement a faster rabl gem.
369 |
370 | ## Copyright
371 |
372 | Copyright © 2012-2020 Christopher Cocchi-Perrier. See [MIT-LICENSE](http://github.com/ccocchi/rabl-rails/blob/master/MIT-LICENSE) for details.
373 |
--------------------------------------------------------------------------------
/test/test_compiler.rb:
--------------------------------------------------------------------------------
1 | require 'helper'
2 | require 'pathname'
3 | require 'tmpdir'
4 |
5 | class TestCompiler < Minitest::Test
6 | @@tmp_path = Pathname.new(Dir.mktmpdir)
7 |
8 | File.open(@@tmp_path + 'user.rabl', 'w') do |f|
9 | f.puts %q{
10 | attributes :id
11 | }
12 | end
13 |
14 | @@view_class = if ActionView::Base.respond_to?(:with_empty_template_cache)
15 | # From Rails 6.1
16 | ActionView::Base.with_empty_template_cache
17 | else
18 | ActionView::Base
19 | end
20 |
21 | describe 'compiler' do
22 | def extract_attributes(nodes)
23 | nodes.map(&:hash)
24 | end
25 |
26 | before do
27 | @view = @@view_class.new(ActionView::LookupContext.new(@@tmp_path), {}, nil)
28 | @compiler = RablRails::Compiler.new(@view)
29 | end
30 |
31 | it "returns a compiled template instance" do
32 | assert_instance_of RablRails::CompiledTemplate, @compiler.compile_source("")
33 | end
34 |
35 | describe '#object' do
36 | it "sets data for the template" do
37 | t = @compiler.compile_source(%{ object :@user })
38 | assert_equal :@user, t.data
39 | assert_equal([], t.nodes)
40 | end
41 |
42 | it "can define root name" do
43 | t = @compiler.compile_source(%{ object :@user => :author })
44 | assert_equal :@user, t.data
45 | assert_equal :author, t.root_name
46 | assert_equal([], t.nodes)
47 | end
48 | end
49 |
50 | describe '#root' do
51 | it "defines root via keyword" do
52 | t = @compiler.compile_source(%{ root :author })
53 | assert_equal :author, t.root_name
54 | end
55 |
56 | it "overrides object root" do
57 | t = @compiler.compile_source(%{ object :@user ; root :author })
58 | assert_equal :author, t.root_name
59 | end
60 |
61 | it "can set root to false via options" do
62 | t = @compiler.compile_source(%( object :@user, root: false))
63 | assert_equal false, t.root_name
64 | end
65 | end
66 |
67 | describe '#collection' do
68 | it "sets the data for the template" do
69 | t = @compiler.compile_source(%{ collection :@user })
70 | assert_equal :@user, t.data
71 | assert_equal([], t.nodes)
72 | end
73 |
74 | it "can define root name" do
75 | t = @compiler.compile_source(%{ collection :@user => :users })
76 | assert_equal :@user, t.data
77 | assert_equal :users, t.root_name
78 | assert_equal([], t.nodes)
79 | end
80 |
81 | it "can define root name via options" do
82 | t = @compiler.compile_source(%{ collection :@user, :root => :users })
83 | assert_equal :@user, t.data
84 | assert_equal :users, t.root_name
85 | end
86 | end
87 |
88 | it "should not have a cache key if cache is not enable" do
89 | t = @compiler.compile_source('')
90 | assert_equal false, t.cache_key
91 | end
92 |
93 | describe '#cache' do
94 | it "can take no argument" do
95 | t = @compiler.compile_source(%{ cache })
96 | assert_nil t.cache_key
97 | end
98 |
99 | it "sets the given block as cache key" do
100 | t = @compiler.compile_source(%( cache { 'foo' }))
101 | assert_instance_of Proc, t.cache_key
102 | end
103 | end
104 |
105 | # Compilation
106 |
107 | it "compiles single attributes" do
108 | t = @compiler.compile_source(%{ attributes :id, :name })
109 | assert_equal([{ :id => :id, :name => :name }], extract_attributes(t.nodes))
110 | end
111 |
112 | it "compiles attributes with the same name once" do
113 | skip('Failing')
114 | t = @compiler.compile_source(%{ attribute :id ; attribute :id })
115 | assert_equal([{ :id => :id }], extract_attributes(t.nodes))
116 | end
117 |
118 | it "aliases attributes through :as option" do
119 | t = @compiler.compile_source(%{ attribute :foo, :as => :bar })
120 | assert_equal([{ :bar => :foo }], extract_attributes(t.nodes))
121 | end
122 |
123 | it "aliases attributes through a hash" do
124 | t = @compiler.compile_source(%{ attribute :foo => :bar })
125 | assert_equal([{ :bar => :foo }], extract_attributes(t.nodes))
126 | end
127 |
128 | it "aliases multiple attributes" do
129 | t = @compiler.compile_source(%{ attributes :foo => :bar, :id => :uid })
130 | assert_equal([{ :bar => :foo, :uid => :id }], extract_attributes(t.nodes))
131 | end
132 |
133 | it "compiles attribtues with a condition" do
134 | t = @compiler.compile_source(%( attributes :id, if: ->(o) { false } ))
135 | assert_equal([{ id: :id }], extract_attributes(t.nodes))
136 | refute_nil t.nodes.first.condition
137 | end
138 |
139 | it "compiles child with record association" do
140 | t = @compiler.compile_source(%{ child :address do attributes :foo end})
141 |
142 | assert_equal(1, t.nodes.size)
143 | child_node = t.nodes.first
144 |
145 | assert_equal(:address, child_node.name)
146 | assert_equal(:address, child_node.data)
147 | assert_equal([{ foo: :foo }], extract_attributes(child_node.nodes))
148 | end
149 |
150 | it "compiles child with association aliased" do
151 | t = @compiler.compile_source(%{ child :address => :bar do attributes :foo end})
152 | child_node = t.nodes.first
153 |
154 | assert_equal(:bar, child_node.name)
155 | assert_equal(:address, child_node.data)
156 | end
157 |
158 | it "compiles child with root name defined as option" do
159 | t = @compiler.compile_source(%{ child(:user, :root => :author) do attributes :foo end })
160 | child_node = t.nodes.first
161 |
162 | assert_equal(:author, child_node.name)
163 | assert_equal(:user, child_node.data)
164 | end
165 |
166 | it "compiles child with root name defined with `as` option" do
167 | t = @compiler.compile_source(%{ child(:user, as: :author) do attributes :foo end })
168 | child_node = t.nodes.first
169 |
170 | assert_equal(:author, child_node.name)
171 | assert_equal(:user, child_node.data)
172 | end
173 |
174 | it "compiles child with arbitrary source" do
175 | t = @compiler.compile_source(%{ child :@user => :author do attribute :name end })
176 | child_node = t.nodes.first
177 |
178 | assert_equal(:author, child_node.name)
179 | assert_equal(:@user, child_node.data)
180 | end
181 |
182 | it "compiles child with inline partial notation" do
183 | t = @compiler.compile_source(%{child(:user, :partial => 'user') })
184 | child_node = t.nodes.first
185 |
186 | assert_equal(:user, child_node.name)
187 | assert_equal(:user, child_node.data)
188 | assert_equal([{ id: :id }], extract_attributes(child_node.nodes))
189 | end
190 |
191 | it "compiles glue as a child but without a name" do
192 | t = @compiler.compile_source(%{ glue(:@user) do attribute :name end })
193 |
194 | assert_equal(1, t.nodes.size)
195 | glue_node = t.nodes.first
196 |
197 | assert_equal(:@user, glue_node.data)
198 | assert_equal([{ name: :name }], extract_attributes(glue_node.nodes))
199 | end
200 |
201 | it "allows multiple glue within same template" do
202 | t = @compiler.compile_source(%{
203 | glue :@user do attribute :name end
204 | glue :@user do attribute :foo end
205 | })
206 |
207 | assert_equal(2, t.nodes.size)
208 | end
209 |
210 | it "compiles glue with RablRails DSL in its body" do
211 | t = @compiler.compile_source(%{
212 | glue :@user do node(:foo) { |u| u.name } end
213 | })
214 |
215 | glue_node = t.nodes.first
216 | assert_equal(1, glue_node.nodes.size)
217 |
218 | code_node = glue_node.nodes.first
219 | assert_instance_of(RablRails::Nodes::Code, code_node)
220 | assert_equal(:foo, code_node.name)
221 | end
222 |
223 | it "compiles glue with a partial" do
224 | t = @compiler.compile_source(%{
225 | glue(:@user, partial: 'user')
226 | })
227 |
228 | glue_node = t.nodes.first
229 | assert_equal(1, glue_node.nodes.size)
230 | assert_equal([{ :id => :id }], extract_attributes(glue_node.nodes))
231 | end
232 |
233 | it "compiles fetch with record association" do
234 | t = @compiler.compile_source(%{ fetch :address do attributes :foo end})
235 |
236 | assert_equal(1, t.nodes.size)
237 | fetch_node = t.nodes.first
238 |
239 | assert_equal(:address, fetch_node.name)
240 | assert_equal(:address, fetch_node.data)
241 | assert_equal(:id, fetch_node.field)
242 | assert_equal([{ foo: :foo }], extract_attributes(fetch_node.nodes))
243 | end
244 |
245 | it "compiles fetch with options" do
246 | t = @compiler.compile_source(%{
247 | fetch(:user, as: :author, field: :uid) do attributes :foo end
248 | })
249 |
250 | fetch_node = t.nodes.first
251 | assert_equal(:author, fetch_node.name)
252 | assert_equal(:user, fetch_node.data)
253 | assert_equal(:uid, fetch_node.field)
254 | end
255 |
256 | it "compiles constant node" do
257 | t = @compiler.compile_source(%{
258 | const(:locale, 'fr_FR')
259 | })
260 |
261 | const_node = t.nodes.first
262 | assert_equal :locale, const_node.name
263 | assert_equal 'fr_FR', const_node.value
264 | end
265 |
266 | it "compiles lookup node" do
267 | t = @compiler.compile_source(%{
268 | lookup(:favorite, :@user_favorites, cast: true)
269 | })
270 |
271 | lookup_node = t.nodes.first
272 | assert_equal :favorite, lookup_node.name
273 | assert_equal :@user_favorites, lookup_node.data
274 | assert_equal :id, lookup_node.field
275 | assert lookup_node.cast_to_boolean?
276 | end
277 |
278 | it "extends other template" do
279 | t = @compiler.compile_source(%{ extends 'user' })
280 | assert_equal([{ :id => :id }], extract_attributes(t.nodes))
281 | end
282 |
283 | it "extends with a lambda" do
284 | t = @compiler.compile_source(%{ extends -> { 'user' } })
285 | node = t.nodes.first
286 | assert_instance_of(RablRails::Nodes::Polymorphic, node)
287 | assert_equal('user', node.template_lambda.call)
288 | end
289 |
290 | it "compiles extend without overwriting nodes previously defined" do
291 | File.open(@@tmp_path + 'xtnd.rabl', 'w') do |f|
292 | f.puts %q{
293 | condition(-> { true }) { 'foo' }
294 | }
295 | end
296 | t = @compiler.compile_source(%{
297 | condition(-> { false }) { 'bar' }
298 | extends 'xtnd'
299 | })
300 | assert_equal(2, t.nodes.size)
301 | end
302 |
303 | it "extends template that has been compiled previously by ActionView" do
304 | t = @view.lookup_context.find_template('user')
305 | t.send(:compile!, @view)
306 | t = @compiler.compile_source(%{ extends 'user' })
307 | assert_equal([{ :id => :id }], extract_attributes(t.nodes))
308 | end
309 |
310 | it "compiles extends with locals" do
311 | t = @compiler.compile_source(%{ extends 'user', locals: { display_credit_card: false } })
312 | node = t.nodes.first
313 |
314 | assert_instance_of RablRails::Nodes::Extend, node
315 | assert_equal([{ :id => :id }], extract_attributes(node.nodes))
316 | assert_equal({ display_credit_card: false }, node.locals)
317 | end
318 |
319 | it "compiles node" do
320 | t = @compiler.compile_source(%{ node(:foo) { bar } })
321 |
322 | assert_equal(1, t.nodes.size)
323 | code_node = t.nodes.first
324 |
325 | assert_equal(:foo, code_node.name)
326 | assert_instance_of Proc, code_node.block
327 | end
328 |
329 | it "compiles node with condition option" do
330 | t = @compiler.compile_source(%{ node(:foo, :if => lambda { |m| m.foo.present? }) do |m| m.foo end })
331 | code_node = t.nodes.first
332 | assert_instance_of Proc, code_node.condition
333 | end
334 |
335 | it "compiles node with no argument" do
336 | t = @compiler.compile_source(%{ node do |m| m.foo end })
337 | node = t.nodes.first
338 | assert_nil node.name
339 | end
340 |
341 | it "compiles merge like a node" do
342 | t = @compiler.compile_source(%{ merge do |m| m.foo end })
343 | node = t.nodes.first
344 | assert_instance_of RablRails::Nodes::Code, node
345 | assert_nil node.name
346 | end
347 |
348 | it "compiles merge with options" do
349 | t = @compiler.compile_source(%{ merge(->(m) { true }) do |m| m.foo end })
350 | node = t.nodes.first
351 | refute_nil node.condition
352 | end
353 |
354 | it "compiles condition" do
355 | t = @compiler.compile_source(%{ condition(->(u) {}) do attributes :secret end })
356 |
357 | assert_equal(1, t.nodes.size)
358 | node = t.nodes.first
359 |
360 | assert_instance_of RablRails::Nodes::Condition, node
361 | assert_equal([{ secret: :secret }], extract_attributes(node.nodes))
362 | end
363 |
364 | it "compiles with no object" do
365 | t = @compiler.compile_source(%{
366 | object false
367 | child(:@user => :user) do
368 | attribute :id
369 | end
370 | })
371 |
372 | assert_equal false, t.data
373 | end
374 |
375 | describe '#extract_data_and_name' do
376 | it "extracts name from argument" do
377 | assert_equal [:@users, 'users'], @compiler.send(:extract_data_and_name, :@users)
378 | assert_equal [:users, :users], @compiler.send(:extract_data_and_name, :users)
379 | assert_equal [:@users, :authors], @compiler.send(:extract_data_and_name, :@users => :authors)
380 | end
381 | end
382 | end
383 | end
384 |
--------------------------------------------------------------------------------