├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── lib ├── rabl-rails.rb ├── rabl-rails │ ├── compiler.rb │ ├── configuration.rb │ ├── exceptions.rb │ ├── handler.rb │ ├── helpers.rb │ ├── library.rb │ ├── nodes.rb │ ├── nodes │ │ ├── attribute.rb │ │ ├── child.rb │ │ ├── code.rb │ │ ├── condition.rb │ │ ├── const.rb │ │ ├── extend.rb │ │ ├── fetch.rb │ │ ├── glue.rb │ │ ├── lookup.rb │ │ └── polymorphic.rb │ ├── railtie.rb │ ├── renderers │ │ ├── hash.rb │ │ ├── json.rb │ │ ├── plist.rb │ │ └── xml.rb │ ├── template.rb │ ├── version.rb │ ├── visitors.rb │ └── visitors │ │ ├── to_hash.rb │ │ └── visitor.rb └── tasks │ └── rabl-rails.rake ├── rabl-rails.gemspec └── test ├── helper.rb ├── renderers ├── test_hash_renderer.rb ├── test_json_renderer.rb ├── test_plist_renderer.rb └── test_xml_renderer.rb ├── test_compiler.rb ├── test_configuration.rb ├── test_hash_visitor.rb ├── test_helpers.rb └── test_library.rb /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RABL for Rails [![Build Status](https://travis-ci.org/ccocchi/rabl-rails.svg?branch=master)](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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/rabl-rails/exceptions.rb: -------------------------------------------------------------------------------- 1 | module RablRails 2 | class PartialError < StandardError; end 3 | end 4 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/version.rb: -------------------------------------------------------------------------------- 1 | module RablRails 2 | VERSION = '0.6.2' 3 | end 4 | -------------------------------------------------------------------------------- /lib/rabl-rails/visitors.rb: -------------------------------------------------------------------------------- 1 | require 'rabl-rails/visitors/visitor' 2 | require 'rabl-rails/visitors/to_hash' 3 | -------------------------------------------------------------------------------- /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/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/tasks/rabl-rails.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :rabl-rails do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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(]*>), '').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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------