├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── Appraisals ├── CONTRIBUTING.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin ├── release └── test ├── gemfiles ├── rails_7_0.gemfile ├── rails_7_1.gemfile ├── rails_8_0.gemfile └── rails_head.gemfile ├── jbuilder.gemspec ├── lib ├── generators │ └── rails │ │ ├── jbuilder_generator.rb │ │ ├── scaffold_controller_generator.rb │ │ └── templates │ │ ├── api_controller.rb │ │ ├── controller.rb │ │ ├── index.json.jbuilder │ │ ├── partial.json.jbuilder │ │ └── show.json.jbuilder ├── jbuilder.rb └── jbuilder │ ├── blank.rb │ ├── collection_renderer.rb │ ├── errors.rb │ ├── jbuilder.rb │ ├── jbuilder_dependency_tracker.rb │ ├── jbuilder_template.rb │ ├── key_formatter.rb │ ├── railtie.rb │ └── version.rb └── test ├── jbuilder_dependency_tracker_test.rb ├── jbuilder_generator_test.rb ├── jbuilder_template_test.rb ├── jbuilder_test.rb ├── scaffold_api_controller_generator_test.rb ├── scaffold_controller_generator_test.rb └── test_helper.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Ruby ${{ matrix.ruby }} (${{ matrix.gemfile }}) 8 | runs-on: ubuntu-latest 9 | continue-on-error: ${{ matrix.gemfile == 'rails_head' }} 10 | env: 11 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 12 | BUNDLE_JOBS: 4 13 | BUNDLE_RETRY: 3 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: 18 | - "3.0" 19 | - "3.1" 20 | - "3.2" 21 | - "3.3" 22 | - "3.4" 23 | 24 | gemfile: 25 | - "rails_7_0" 26 | - "rails_7_1" 27 | - "rails_8_0" 28 | - "rails_head" 29 | 30 | exclude: 31 | - ruby: '3.0' 32 | gemfile: rails_8_0 33 | - ruby: '3.0' 34 | gemfile: rails_head 35 | - ruby: '3.1' 36 | gemfile: rails_8_0 37 | - ruby: '3.1' 38 | gemfile: rails_head 39 | - ruby: '3.4' 40 | gemfile: rails_7_0 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ${{ matrix.ruby }} 48 | bundler-cache: true 49 | 50 | - name: Ruby test 51 | run: bundle exec rake 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | /log 3 | gemfiles/.bundle 4 | gemfiles/*.lock 5 | Gemfile.lock 6 | .ruby-version 7 | pkg 8 | *.gem 9 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-7-0" do 2 | gem "rails", "~> 7.0.0" 3 | gem "concurrent-ruby", "< 1.3.5" # to avoid problem described in https://github.com/rails/rails/pull/54264 4 | end 5 | 6 | appraise "rails-7-1" do 7 | gem "rails", "~> 7.1.0" 8 | end 9 | 10 | appraise "rails-8-0" do 11 | gem "rails", "~> 8.0.0" 12 | end 13 | 14 | appraise "rails-head" do 15 | gem "rails", github: "rails/rails", branch: "main" 16 | end 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to Jbuilder 2 | ===================== 3 | 4 | [![Build Status](https://github.com/rails/jbuilder/workflows/Ruby%20test/badge.svg)][test] 5 | [![Gem Version](https://badge.fury.io/rb/jbuilder.svg)][gem] 6 | [![Code Climate](https://codeclimate.com/github/rails/jbuilder/badges/gpa.svg)][codeclimate] 7 | 8 | [test]: https://github.com/rails/jbuilder/actions?query=branch%3Amaster 9 | [gem]: https://rubygems.org/gems/jbuilder 10 | [codeclimate]: https://codeclimate.com/github/rails/jbuilder 11 | 12 | Jbuilder is work of [many contributors](https://github.com/rails/jbuilder/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/rails/jbuilder/pulls), [propose features and discuss issues](https://github.com/rails/jbuilder/issues). 13 | 14 | #### Fork the Project 15 | 16 | Fork the [project on GitHub](https://github.com/rails/jbuilder) and check out your copy. 17 | 18 | ``` 19 | git clone https://github.com/contributor/jbuilder.git 20 | cd jbuilder 21 | git remote add upstream https://github.com/rails/jbuilder.git 22 | ``` 23 | 24 | #### Create a Topic Branch 25 | 26 | Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. 27 | 28 | ``` 29 | git checkout master 30 | git pull upstream master 31 | git checkout -b my-feature-branch 32 | ``` 33 | 34 | #### Bundle Install and Test 35 | 36 | Ensure that you can build the project and run tests using `bin/test`. 37 | 38 | #### Write Tests 39 | 40 | Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [test](test). 41 | 42 | We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. 43 | 44 | #### Write Code 45 | 46 | Implement your feature or bug fix. 47 | 48 | Make sure that `appraisal rake test` completes without errors. 49 | 50 | #### Write Documentation 51 | 52 | Document any external behavior in the [README](README.md). 53 | 54 | #### Commit Changes 55 | 56 | Make sure git knows your name and email address: 57 | 58 | ``` 59 | git config --global user.name "Your Name" 60 | git config --global user.email "contributor@example.com" 61 | ``` 62 | 63 | Writing good commit logs is important. A commit log should describe what changed and why. 64 | 65 | ``` 66 | git add ... 67 | git commit 68 | ``` 69 | 70 | #### Push 71 | 72 | ``` 73 | git push origin my-feature-branch 74 | ``` 75 | 76 | #### Make a Pull Request 77 | 78 | Visit your forked repo and click the 'New pull request' button. Select your feature branch, fill out the form, and click the 'Create pull request' button. Pull requests are usually reviewed within a few days. 79 | 80 | #### Rebase 81 | 82 | If you've been working on a change for a while, rebase with upstream/master. 83 | 84 | ``` 85 | git fetch upstream 86 | git rebase upstream/master 87 | git push origin my-feature-branch -f 88 | ``` 89 | 90 | #### Check on Your Pull Request 91 | 92 | Go back to your pull request after a few minutes and see whether it passed muster with GitHub Actions. Everything should look green, otherwise fix issues and amend your commit as described above. 93 | 94 | #### Be Patient 95 | 96 | It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang in there! 97 | 98 | #### Thank You 99 | 100 | Please do know that we really appreciate and value your time and work. We love you, really. 101 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "mocha", require: false 7 | gem "appraisal" 8 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2018 David Heinemeier Hansson, 37signals 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 | # Jbuilder 2 | 3 | Jbuilder gives you a simple DSL for declaring JSON structures that beats 4 | manipulating giant hash structures. This is particularly helpful when the 5 | generation process is fraught with conditionals and loops. Here's a simple 6 | example: 7 | 8 | ```ruby 9 | # app/views/messages/show.json.jbuilder 10 | 11 | json.content format_content(@message.content) 12 | json.(@message, :created_at, :updated_at) 13 | 14 | json.author do 15 | json.name @message.creator.name.familiar 16 | json.email_address @message.creator.email_address_with_name 17 | json.url url_for(@message.creator, format: :json) 18 | end 19 | 20 | if current_user.admin? 21 | json.visitors calculate_visitors(@message) 22 | end 23 | 24 | json.comments @message.comments, :content, :created_at 25 | 26 | json.attachments @message.attachments do |attachment| 27 | json.filename attachment.filename 28 | json.url url_for(attachment) 29 | end 30 | ``` 31 | 32 | This will build the following structure: 33 | 34 | ```javascript 35 | { 36 | "content": "

This is serious monkey business

", 37 | "created_at": "2011-10-29T20:45:28-05:00", 38 | "updated_at": "2011-10-29T20:45:28-05:00", 39 | 40 | "author": { 41 | "name": "David H.", 42 | "email_address": "'David Heinemeier Hansson' ", 43 | "url": "http://example.com/users/1-david.json" 44 | }, 45 | 46 | "visitors": 15, 47 | 48 | "comments": [ 49 | { "content": "Hello everyone!", "created_at": "2011-10-29T20:45:28-05:00" }, 50 | { "content": "To you my good sir!", "created_at": "2011-10-29T20:47:28-05:00" } 51 | ], 52 | 53 | "attachments": [ 54 | { "filename": "forecast.xls", "url": "http://example.com/downloads/forecast.xls" }, 55 | { "filename": "presentation.pdf", "url": "http://example.com/downloads/presentation.pdf" } 56 | ] 57 | } 58 | ``` 59 | 60 | ## Dynamically Defined Attributes 61 | 62 | To define attribute and structure names dynamically, use the `set!` method: 63 | 64 | ```ruby 65 | json.set! :author do 66 | json.set! :name, 'David' 67 | end 68 | 69 | # => {"author": { "name": "David" }} 70 | ``` 71 | 72 | ## Merging Existing Hash or Array 73 | 74 | To merge existing hash or array to current context: 75 | 76 | ```ruby 77 | hash = { author: { name: "David" } } 78 | json.post do 79 | json.title "Merge HOWTO" 80 | json.merge! hash 81 | end 82 | 83 | # => "post": { "title": "Merge HOWTO", "author": { "name": "David" } } 84 | ``` 85 | 86 | ## Top Level Arrays 87 | 88 | Top level arrays can be handled directly. Useful for index and other collection actions. 89 | 90 | ```ruby 91 | # @comments = @post.comments 92 | 93 | json.array! @comments do |comment| 94 | next if comment.marked_as_spam_by?(current_user) 95 | 96 | json.body comment.body 97 | json.author do 98 | json.first_name comment.author.first_name 99 | json.last_name comment.author.last_name 100 | end 101 | end 102 | 103 | # => [ { "body": "great post...", "author": { "first_name": "Joe", "last_name": "Bloe" }} ] 104 | ``` 105 | 106 | ## Array Attributes 107 | 108 | You can also extract attributes from array directly. 109 | 110 | ```ruby 111 | # @people = People.all 112 | 113 | json.array! @people, :id, :name 114 | 115 | # => [ { "id": 1, "name": "David" }, { "id": 2, "name": "Jamie" } ] 116 | ``` 117 | 118 | ## Plain Arrays 119 | 120 | To make a plain array without keys, construct and pass in a standard Ruby array. 121 | 122 | ```ruby 123 | my_array = %w(David Jamie) 124 | 125 | json.people my_array 126 | 127 | # => "people": [ "David", "Jamie" ] 128 | ``` 129 | 130 | ## Child Objects 131 | 132 | You don't always have or need a collection when building an array. 133 | 134 | ```ruby 135 | json.people do 136 | json.child! do 137 | json.id 1 138 | json.name 'David' 139 | end 140 | json.child! do 141 | json.id 2 142 | json.name 'Jamie' 143 | end 144 | end 145 | 146 | # => { "people": [ { "id": 1, "name": "David" }, { "id": 2, "name": "Jamie" } ] } 147 | ``` 148 | 149 | ## Nested Jbuilder Objects 150 | 151 | Jbuilder objects can be directly nested inside each other. Useful for composing objects. 152 | 153 | ```ruby 154 | class Person 155 | # ... Class Definition ... # 156 | def to_builder 157 | Jbuilder.new do |person| 158 | person.(self, :name, :age) 159 | end 160 | end 161 | end 162 | 163 | class Company 164 | # ... Class Definition ... # 165 | def to_builder 166 | Jbuilder.new do |company| 167 | company.name name 168 | company.president president.to_builder 169 | end 170 | end 171 | end 172 | 173 | company = Company.new('Doodle Corp', Person.new('John Stobs', 58)) 174 | company.to_builder.target! 175 | 176 | # => {"name":"Doodle Corp","president":{"name":"John Stobs","age":58}} 177 | ``` 178 | 179 | ## Rails Integration 180 | 181 | You can either use Jbuilder stand-alone or directly as an ActionView template 182 | language. When required in Rails, you can create views à la show.json.jbuilder 183 | (the json is already yielded): 184 | 185 | ```ruby 186 | # Any helpers available to views are available to the builder 187 | json.content format_content(@message.content) 188 | json.(@message, :created_at, :updated_at) 189 | 190 | json.author do 191 | json.name @message.creator.name.familiar 192 | json.email_address @message.creator.email_address_with_name 193 | json.url url_for(@message.creator, format: :json) 194 | end 195 | 196 | if current_user.admin? 197 | json.visitors calculate_visitors(@message) 198 | end 199 | ``` 200 | 201 | ## Partials 202 | 203 | You can use partials as well. The following will render the file 204 | `views/comments/_comments.json.jbuilder`, and set a local variable 205 | `comments` with all this message's comments, which you can use inside 206 | the partial. 207 | 208 | ```ruby 209 | json.partial! 'comments/comments', comments: @message.comments 210 | ``` 211 | 212 | It's also possible to render collections of partials: 213 | 214 | ```ruby 215 | json.array! @posts, partial: 'posts/post', as: :post 216 | 217 | # or 218 | json.partial! 'posts/post', collection: @posts, as: :post 219 | 220 | # or 221 | json.partial! partial: 'posts/post', collection: @posts, as: :post 222 | 223 | # or 224 | json.comments @post.comments, partial: 'comments/comment', as: :comment 225 | ``` 226 | 227 | The `as: :some_symbol` is used with partials. It will take care of mapping the passed in object to a variable for the 228 | partial. If the value is a collection either implicitly or explicitly by using the `collection:` option, then each 229 | value of the collection is passed to the partial as the variable `some_symbol`. If the value is a singular object, 230 | then the object is passed to the partial as the variable `some_symbol`. 231 | 232 | Be sure not to confuse the `as:` option to mean nesting of the partial. For example: 233 | 234 | ```ruby 235 | # Use the default `views/comments/_comment.json.jbuilder`, putting @comment as the comment local variable. 236 | # Note, `comment` attributes are "inlined". 237 | json.partial! @comment, as: :comment 238 | ``` 239 | 240 | is quite different from: 241 | 242 | ```ruby 243 | # comment attributes are nested under a "comment" property 244 | json.comment do 245 | json.partial! "/comments/comment.json.jbuilder", comment: @comment 246 | end 247 | ``` 248 | 249 | You can pass any objects into partial templates with or without `:locals` option. 250 | 251 | ```ruby 252 | json.partial! 'sub_template', locals: { user: user } 253 | 254 | # or 255 | 256 | json.partial! 'sub_template', user: user 257 | ``` 258 | 259 | ## Null Values 260 | 261 | You can explicitly make Jbuilder object return null if you want: 262 | 263 | ```ruby 264 | json.extract! @post, :id, :title, :content, :published_at 265 | json.author do 266 | if @post.anonymous? 267 | json.null! # or json.nil! 268 | else 269 | json.first_name @post.author_first_name 270 | json.last_name @post.author_last_name 271 | end 272 | end 273 | ``` 274 | 275 | To prevent Jbuilder from including null values in the output, you can use the `ignore_nil!` method: 276 | 277 | ```ruby 278 | json.ignore_nil! 279 | json.foo nil 280 | json.bar "bar" 281 | # => { "bar": "bar" } 282 | ``` 283 | 284 | ## Caching 285 | 286 | Fragment caching is supported, it uses `Rails.cache` and works like caching in 287 | HTML templates: 288 | 289 | ```ruby 290 | json.cache! ['v1', @person], expires_in: 10.minutes do 291 | json.extract! @person, :name, :age 292 | end 293 | ``` 294 | 295 | You can also conditionally cache a block by using `cache_if!` like this: 296 | 297 | ```ruby 298 | json.cache_if! !admin?, ['v1', @person], expires_in: 10.minutes do 299 | json.extract! @person, :name, :age 300 | end 301 | ``` 302 | 303 | Aside from that, the `:cached` options on collection rendering is available on Rails >= 6.0. This will cache the 304 | rendered results effectively using the multi fetch feature. 305 | 306 | ```ruby 307 | json.array! @posts, partial: "posts/post", as: :post, cached: true 308 | 309 | # or: 310 | json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true 311 | ``` 312 | 313 | If your collection cache depends on multiple sources (try to avoid this to keep things simple), you can name all these dependencies as part of a block that returns an array: 314 | 315 | ```ruby 316 | json.array! @posts, partial: "posts/post", as: :post, cached: -> post { [post, current_user] } 317 | ``` 318 | 319 | This will include both records as part of the cache key and updating either of them will expire the cache. 320 | 321 | ## Formatting Keys 322 | 323 | Keys can be auto formatted using `key_format!`, this can be used to convert 324 | keynames from the standard ruby_format to camelCase: 325 | 326 | ```ruby 327 | json.key_format! camelize: :lower 328 | json.first_name 'David' 329 | 330 | # => { "firstName": "David" } 331 | ``` 332 | 333 | You can set this globally with the class method `key_format` (from inside your 334 | environment.rb for example): 335 | 336 | ```ruby 337 | Jbuilder.key_format camelize: :lower 338 | ``` 339 | 340 | By default, key format is not applied to keys of hashes that are 341 | passed to methods like `set!`, `array!` or `merge!`. You can opt into 342 | deeply transforming these as well: 343 | 344 | ```ruby 345 | json.key_format! camelize: :lower 346 | json.deep_format_keys! 347 | json.settings([{some_value: "abc"}]) 348 | 349 | # => { "settings": [{ "someValue": "abc" }]} 350 | ``` 351 | 352 | You can set this globally with the class method `deep_format_keys` (from inside your 353 | environment.rb for example): 354 | 355 | ```ruby 356 | Jbuilder.deep_format_keys true 357 | ``` 358 | 359 | ## Testing JBuilder Response body with RSpec 360 | 361 | To test the response body of your controller spec, enable `render_views` in your RSpec context. This [configuration](https://rspec.info/features/6-0/rspec-rails/controller-specs/render-views) renders the views in a controller test. 362 | 363 | ## Contributing to Jbuilder 364 | 365 | Jbuilder is the work of many contributors. You're encouraged to submit pull requests, propose 366 | features and discuss issues. 367 | 368 | See [CONTRIBUTING](CONTRIBUTING.md). 369 | 370 | ## License 371 | 372 | Jbuilder is released under the [MIT License](http://www.opensource.org/licenses/MIT). 373 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | require "rake/testtask" 4 | 5 | if !ENV["APPRAISAL_INITIALIZED"] && !ENV["CI"] 6 | require "appraisal/task" 7 | Appraisal::Task.new 8 | task default: :appraisal 9 | else 10 | Rake::TestTask.new do |test| 11 | require "rails/version" 12 | 13 | test.libs << "test" 14 | 15 | test.test_files = FileList["test/*_test.rb"] 16 | end 17 | 18 | task default: :test 19 | end 20 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | printf "class Jbuilder\n VERSION = \"$VERSION\"\nend\n" > ./lib/jbuilder/version.rb 6 | bundle 7 | git add lib/jbuilder/version.rb 8 | git commit -m "Bump version for $VERSION" 9 | git push 10 | git tag v$VERSION 11 | git push --tags 12 | gem build jbuilder.gemspec 13 | gem push "jbuilder-$VERSION.gem" --host https://rubygems.org 14 | rm "jbuilder-$VERSION.gem" 15 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | set -e 3 | 4 | bundle install 5 | appraisal install 6 | appraisal rake test 7 | -------------------------------------------------------------------------------- /gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "mocha", require: false 7 | gem "appraisal" 8 | gem "rails", "~> 7.0.0" 9 | gem "concurrent-ruby", "< 1.3.5" 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "mocha", require: false 7 | gem "appraisal" 8 | gem "rails", "~> 7.1.0" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "mocha", require: false 7 | gem "appraisal" 8 | gem "rails", "~> 8.0.0" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /gemfiles/rails_head.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rake" 6 | gem "mocha", require: false 7 | gem "appraisal" 8 | gem "rails", github: "rails/rails", branch: "main" 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /jbuilder.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/jbuilder/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'jbuilder' 5 | s.version = Jbuilder::VERSION 6 | s.authors = 'David Heinemeier Hansson' 7 | s.email = 'david@basecamp.com' 8 | s.summary = 'Create JSON structures via a Builder-style DSL' 9 | s.homepage = 'https://github.com/rails/jbuilder' 10 | s.license = 'MIT' 11 | 12 | s.required_ruby_version = '>= 2.2.2' 13 | 14 | s.add_dependency 'activesupport', '>= 5.0.0' 15 | s.add_dependency 'actionview', '>= 5.0.0' 16 | 17 | if RUBY_ENGINE == 'rbx' 18 | s.add_development_dependency('racc') 19 | s.add_development_dependency('json') 20 | s.add_development_dependency('rubysl') 21 | end 22 | 23 | s.files = `git ls-files`.split("\n") 24 | s.test_files = `git ls-files -- test/*`.split("\n") 25 | 26 | s.metadata = { 27 | "bug_tracker_uri" => "https://github.com/rails/jbuilder/issues", 28 | "changelog_uri" => "https://github.com/rails/jbuilder/releases/tag/v#{s.version}", 29 | "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", 30 | "source_code_uri" => "https://github.com/rails/jbuilder/tree/v#{s.version}", 31 | "rubygems_mfa_required" => "true", 32 | } 33 | end 34 | -------------------------------------------------------------------------------- /lib/generators/rails/jbuilder_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators/named_base' 2 | require 'rails/generators/resource_helpers' 3 | 4 | module Rails 5 | module Generators 6 | class JbuilderGenerator < NamedBase # :nodoc: 7 | include Rails::Generators::ResourceHelpers 8 | 9 | source_root File.expand_path('../templates', __FILE__) 10 | 11 | argument :attributes, type: :array, default: [], banner: 'field:type field:type' 12 | 13 | class_option :timestamps, type: :boolean, default: true 14 | 15 | def create_root_folder 16 | path = File.join('app/views', controller_file_path) 17 | empty_directory path unless File.directory?(path) 18 | end 19 | 20 | def copy_view_files 21 | %w(index show).each do |view| 22 | filename = filename_with_extensions(view) 23 | template filename, File.join('app/views', controller_file_path, filename) 24 | end 25 | template filename_with_extensions('partial'), File.join('app/views', controller_file_path, filename_with_extensions("_#{singular_table_name}")) 26 | end 27 | 28 | 29 | protected 30 | def attributes_names 31 | [:id] + super 32 | end 33 | 34 | def filename_with_extensions(name) 35 | [name, :json, :jbuilder] * '.' 36 | end 37 | 38 | def full_attributes_list 39 | if options[:timestamps] 40 | attributes_list(attributes_names + %w(created_at updated_at)) 41 | else 42 | attributes_list(attributes_names) 43 | end 44 | end 45 | 46 | def attributes_list(attributes = attributes_names) 47 | if self.attributes.any? {|attr| attr.name == 'password' && attr.type == :digest} 48 | attributes = attributes.reject {|name| %w(password password_confirmation).include? name} 49 | end 50 | 51 | attributes.map { |a| ":#{a}"} * ', ' 52 | end 53 | 54 | def virtual_attributes 55 | attributes.select {|name| name.respond_to?(:virtual?) && name.virtual? } 56 | end 57 | 58 | def partial_path_name 59 | [controller_file_path, singular_table_name].join("/") 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/generators/rails/scaffold_controller_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/rails/scaffold_controller/scaffold_controller_generator' 3 | 4 | module Rails 5 | module Generators 6 | class ScaffoldControllerGenerator 7 | source_paths << File.expand_path('../templates', __FILE__) 8 | 9 | hook_for :jbuilder, type: :boolean, default: true 10 | 11 | private 12 | 13 | def permitted_params 14 | attributes_names.map { |name| ":#{name}" }.join(', ') 15 | end unless private_method_defined? :permitted_params 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/api_controller.rb: -------------------------------------------------------------------------------- 1 | <% if namespaced? -%> 2 | require_dependency "<%= namespaced_path %>/application_controller" 3 | 4 | <% end -%> 5 | <% module_namespacing do -%> 6 | class <%= controller_class_name %>Controller < ApplicationController 7 | before_action :set_<%= singular_table_name %>, only: %i[ show update destroy ] 8 | 9 | # GET <%= route_url %> 10 | # GET <%= route_url %>.json 11 | def index 12 | @<%= plural_table_name %> = <%= orm_class.all(class_name) %> 13 | end 14 | 15 | # GET <%= route_url %>/1 16 | # GET <%= route_url %>/1.json 17 | def show 18 | end 19 | 20 | # POST <%= route_url %> 21 | # POST <%= route_url %>.json 22 | def create 23 | @<%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %> 24 | 25 | if @<%= orm_instance.save %> 26 | render :show, status: :created, location: <%= "@#{singular_table_name}" %> 27 | else 28 | render json: <%= "@#{orm_instance.errors}" %>, status: :unprocessable_entity 29 | end 30 | end 31 | 32 | # PATCH/PUT <%= route_url %>/1 33 | # PATCH/PUT <%= route_url %>/1.json 34 | def update 35 | if @<%= orm_instance.update("#{singular_table_name}_params") %> 36 | render :show, status: :ok, location: <%= "@#{singular_table_name}" %> 37 | else 38 | render json: <%= "@#{orm_instance.errors}" %>, status: :unprocessable_entity 39 | end 40 | end 41 | 42 | # DELETE <%= route_url %>/1 43 | # DELETE <%= route_url %>/1.json 44 | def destroy 45 | @<%= orm_instance.destroy %> 46 | end 47 | 48 | private 49 | # Use callbacks to share common setup or constraints between actions. 50 | def set_<%= singular_table_name %> 51 | <%- if Rails::VERSION::MAJOR >= 8 -%> 52 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params.expect(:id)") %> 53 | <%- else -%> 54 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params[:id]") %> 55 | <%- end -%> 56 | end 57 | 58 | # Only allow a list of trusted parameters through. 59 | def <%= "#{singular_table_name}_params" %> 60 | <%- if attributes_names.empty? -%> 61 | params.fetch(<%= ":#{singular_table_name}" %>, {}) 62 | <%- elsif Rails::VERSION::MAJOR >= 8 -%> 63 | params.expect(<%= singular_table_name %>: [ <%= permitted_params %> ]) 64 | <%- else -%> 65 | params.require(<%= ":#{singular_table_name}" %>).permit(<%= permitted_params %>) 66 | <%- end -%> 67 | end 68 | end 69 | <% end -%> 70 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/controller.rb: -------------------------------------------------------------------------------- 1 | <% if namespaced? -%> 2 | require_dependency "<%= namespaced_path %>/application_controller" 3 | 4 | <% end -%> 5 | <% module_namespacing do -%> 6 | class <%= controller_class_name %>Controller < ApplicationController 7 | before_action :set_<%= singular_table_name %>, only: %i[ show edit update destroy ] 8 | 9 | # GET <%= route_url %> or <%= route_url %>.json 10 | def index 11 | @<%= plural_table_name %> = <%= orm_class.all(class_name) %> 12 | end 13 | 14 | # GET <%= route_url %>/1 or <%= route_url %>/1.json 15 | def show 16 | end 17 | 18 | # GET <%= route_url %>/new 19 | def new 20 | @<%= singular_table_name %> = <%= orm_class.build(class_name) %> 21 | end 22 | 23 | # GET <%= route_url %>/1/edit 24 | def edit 25 | end 26 | 27 | # POST <%= route_url %> or <%= route_url %>.json 28 | def create 29 | @<%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %> 30 | 31 | respond_to do |format| 32 | if @<%= orm_instance.save %> 33 | format.html { redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully created.") %> } 34 | format.json { render :show, status: :created, location: <%= "@#{singular_table_name}" %> } 35 | else 36 | format.html { render :new, status: :unprocessable_entity } 37 | format.json { render json: <%= "@#{orm_instance.errors}" %>, status: :unprocessable_entity } 38 | end 39 | end 40 | end 41 | 42 | # PATCH/PUT <%= route_url %>/1 or <%= route_url %>/1.json 43 | def update 44 | respond_to do |format| 45 | if @<%= orm_instance.update("#{singular_table_name}_params") %> 46 | format.html { redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully updated.") %>, status: :see_other } 47 | format.json { render :show, status: :ok, location: <%= "@#{singular_table_name}" %> } 48 | else 49 | format.html { render :edit, status: :unprocessable_entity } 50 | format.json { render json: <%= "@#{orm_instance.errors}" %>, status: :unprocessable_entity } 51 | end 52 | end 53 | end 54 | 55 | # DELETE <%= route_url %>/1 or <%= route_url %>/1.json 56 | def destroy 57 | @<%= orm_instance.destroy %> 58 | 59 | respond_to do |format| 60 | format.html { redirect_to <%= index_helper %>_path, notice: <%= %("#{human_name} was successfully destroyed.") %>, status: :see_other } 61 | format.json { head :no_content } 62 | end 63 | end 64 | 65 | private 66 | # Use callbacks to share common setup or constraints between actions. 67 | def set_<%= singular_table_name %> 68 | <%- if Rails::VERSION::MAJOR >= 8 -%> 69 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params.expect(:id)") %> 70 | <%- else -%> 71 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params[:id]") %> 72 | <%- end -%> 73 | end 74 | 75 | # Only allow a list of trusted parameters through. 76 | def <%= "#{singular_table_name}_params" %> 77 | <%- if attributes_names.empty? -%> 78 | params.fetch(<%= ":#{singular_table_name}" %>, {}) 79 | <%- elsif Rails::VERSION::MAJOR >= 8 -%> 80 | params.expect(<%= singular_table_name %>: [ <%= permitted_params %> ]) 81 | <%- else -%> 82 | params.require(<%= ":#{singular_table_name}" %>).permit(<%= permitted_params %>) 83 | <%- end -%> 84 | end 85 | end 86 | <% end -%> 87 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @<%= plural_table_name %>, partial: "<%= partial_path_name %>", as: :<%= singular_table_name %> 2 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/partial.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.extract! <%= singular_table_name %>, <%= full_attributes_list %> 2 | json.url <%= singular_table_name %>_url(<%= singular_table_name %>, format: :json) 3 | <%- virtual_attributes.each do |attribute| -%> 4 | <%- if attribute.type == :rich_text -%> 5 | json.<%= attribute.name %> <%= singular_table_name %>.<%= attribute.name %>.to_s 6 | <%- elsif attribute.type == :attachment -%> 7 | json.<%= attribute.name %> url_for(<%= singular_table_name %>.<%= attribute.name %>) 8 | <%- elsif attribute.type == :attachments -%> 9 | json.<%= attribute.name %> do 10 | json.array!(<%= singular_table_name %>.<%= attribute.name %>) do |<%= attribute.singular_name %>| 11 | json.id <%= attribute.singular_name %>.id 12 | json.url url_for(<%= attribute.singular_name %>) 13 | end 14 | end 15 | <%- end -%> 16 | <%- end -%> 17 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.partial! "<%= partial_path_name %>", <%= singular_table_name %>: @<%= singular_table_name %> 2 | -------------------------------------------------------------------------------- /lib/jbuilder.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'jbuilder/jbuilder' 3 | require 'jbuilder/blank' 4 | require 'jbuilder/key_formatter' 5 | require 'jbuilder/errors' 6 | require 'jbuilder/version' 7 | require 'json' 8 | require 'active_support/core_ext/hash/deep_merge' 9 | 10 | class Jbuilder 11 | @@key_formatter = nil 12 | @@ignore_nil = false 13 | @@deep_format_keys = false 14 | 15 | def initialize(options = {}) 16 | @attributes = {} 17 | 18 | @key_formatter = options.fetch(:key_formatter){ @@key_formatter ? @@key_formatter.clone : nil} 19 | @ignore_nil = options.fetch(:ignore_nil, @@ignore_nil) 20 | @deep_format_keys = options.fetch(:deep_format_keys, @@deep_format_keys) 21 | 22 | yield self if ::Kernel.block_given? 23 | end 24 | 25 | # Yields a builder and automatically turns the result into a JSON string 26 | def self.encode(*args, &block) 27 | new(*args, &block).target! 28 | end 29 | 30 | BLANK = Blank.new 31 | 32 | def set!(key, value = BLANK, *args, &block) 33 | result = if ::Kernel.block_given? 34 | if !_blank?(value) 35 | # json.comments @post.comments { |comment| ... } 36 | # { "comments": [ { ... }, { ... } ] } 37 | _scope{ array! value, &block } 38 | else 39 | # json.comments { ... } 40 | # { "comments": ... } 41 | _merge_block(key){ yield self } 42 | end 43 | elsif args.empty? 44 | if ::Jbuilder === value 45 | # json.age 32 46 | # json.person another_jbuilder 47 | # { "age": 32, "person": { ... } 48 | _format_keys(value.attributes!) 49 | else 50 | # json.age 32 51 | # { "age": 32 } 52 | _format_keys(value) 53 | end 54 | elsif _is_collection?(value) 55 | # json.comments @post.comments, :content, :created_at 56 | # { "comments": [ { "content": "hello", "created_at": "..." }, { "content": "world", "created_at": "..." } ] } 57 | _scope{ array! value, *args } 58 | else 59 | # json.author @post.creator, :name, :email_address 60 | # { "author": { "name": "David", "email_address": "david@loudthinking.com" } } 61 | _merge_block(key){ extract! value, *args } 62 | end 63 | 64 | _set_value key, result 65 | end 66 | 67 | def method_missing(*args, &block) 68 | if ::Kernel.block_given? 69 | set!(*args, &block) 70 | else 71 | set!(*args) 72 | end 73 | end 74 | 75 | # Specifies formatting to be applied to the key. Passing in a name of a function 76 | # will cause that function to be called on the key. So :upcase will upper case 77 | # the key. You can also pass in lambdas for more complex transformations. 78 | # 79 | # Example: 80 | # 81 | # json.key_format! :upcase 82 | # json.author do 83 | # json.name "David" 84 | # json.age 32 85 | # end 86 | # 87 | # { "AUTHOR": { "NAME": "David", "AGE": 32 } } 88 | # 89 | # You can pass parameters to the method using a hash pair. 90 | # 91 | # json.key_format! camelize: :lower 92 | # json.first_name "David" 93 | # 94 | # { "firstName": "David" } 95 | # 96 | # Lambdas can also be used. 97 | # 98 | # json.key_format! ->(key){ "_" + key } 99 | # json.first_name "David" 100 | # 101 | # { "_first_name": "David" } 102 | # 103 | def key_format!(*args) 104 | @key_formatter = KeyFormatter.new(*args) 105 | end 106 | 107 | # Same as the instance method key_format! except sets the default. 108 | def self.key_format(*args) 109 | @@key_formatter = KeyFormatter.new(*args) 110 | end 111 | 112 | # If you want to skip adding nil values to your JSON hash. This is useful 113 | # for JSON clients that don't deal well with nil values, and would prefer 114 | # not to receive keys which have null values. 115 | # 116 | # Example: 117 | # json.ignore_nil! false 118 | # json.id User.new.id 119 | # 120 | # { "id": null } 121 | # 122 | # json.ignore_nil! 123 | # json.id User.new.id 124 | # 125 | # {} 126 | # 127 | def ignore_nil!(value = true) 128 | @ignore_nil = value 129 | end 130 | 131 | # Same as instance method ignore_nil! except sets the default. 132 | def self.ignore_nil(value = true) 133 | @@ignore_nil = value 134 | end 135 | 136 | # Deeply apply key format to nested hashes and arrays passed to 137 | # methods like set!, merge! or array!. 138 | # 139 | # Example: 140 | # 141 | # json.key_format! camelize: :lower 142 | # json.settings({some_value: "abc"}) 143 | # 144 | # { "settings": { "some_value": "abc" }} 145 | # 146 | # json.key_format! camelize: :lower 147 | # json.deep_format_keys! 148 | # json.settings({some_value: "abc"}) 149 | # 150 | # { "settings": { "someValue": "abc" }} 151 | # 152 | def deep_format_keys!(value = true) 153 | @deep_format_keys = value 154 | end 155 | 156 | # Same as instance method deep_format_keys! except sets the default. 157 | def self.deep_format_keys(value = true) 158 | @@deep_format_keys = value 159 | end 160 | 161 | # Turns the current element into an array and yields a builder to add a hash. 162 | # 163 | # Example: 164 | # 165 | # json.comments do 166 | # json.child! { json.content "hello" } 167 | # json.child! { json.content "world" } 168 | # end 169 | # 170 | # { "comments": [ { "content": "hello" }, { "content": "world" } ]} 171 | # 172 | # More commonly, you'd use the combined iterator, though: 173 | # 174 | # json.comments(@post.comments) do |comment| 175 | # json.content comment.formatted_content 176 | # end 177 | def child! 178 | @attributes = [] unless ::Array === @attributes 179 | @attributes << _scope{ yield self } 180 | end 181 | 182 | # Turns the current element into an array and iterates over the passed collection, adding each iteration as 183 | # an element of the resulting array. 184 | # 185 | # Example: 186 | # 187 | # json.array!(@people) do |person| 188 | # json.name person.name 189 | # json.age calculate_age(person.birthday) 190 | # end 191 | # 192 | # [ { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } ] 193 | # 194 | # You can use the call syntax instead of an explicit extract! call: 195 | # 196 | # json.(@people) { |person| ... } 197 | # 198 | # It's generally only needed to use this method for top-level arrays. If you have named arrays, you can do: 199 | # 200 | # json.people(@people) do |person| 201 | # json.name person.name 202 | # json.age calculate_age(person.birthday) 203 | # end 204 | # 205 | # { "people": [ { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } ] } 206 | # 207 | # If you omit the block then you can set the top level array directly: 208 | # 209 | # json.array! [1, 2, 3] 210 | # 211 | # [1,2,3] 212 | def array!(collection = [], *attributes, &block) 213 | array = if collection.nil? 214 | [] 215 | elsif ::Kernel.block_given? 216 | _map_collection(collection, &block) 217 | elsif attributes.any? 218 | _map_collection(collection) { |element| extract! element, *attributes } 219 | else 220 | _format_keys(collection.to_a) 221 | end 222 | 223 | @attributes = _merge_values(@attributes, array) 224 | end 225 | 226 | # Extracts the mentioned attributes or hash elements from the passed object and turns them into attributes of the JSON. 227 | # 228 | # Example: 229 | # 230 | # @person = Struct.new(:name, :age).new('David', 32) 231 | # 232 | # or you can utilize a Hash 233 | # 234 | # @person = { name: 'David', age: 32 } 235 | # 236 | # json.extract! @person, :name, :age 237 | # 238 | # { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } 239 | # 240 | # You can also use the call syntax instead of an explicit extract! call: 241 | # 242 | # json.(@person, :name, :age) 243 | def extract!(object, *attributes) 244 | if ::Hash === object 245 | _extract_hash_values(object, attributes) 246 | else 247 | _extract_method_values(object, attributes) 248 | end 249 | end 250 | 251 | def call(object, *attributes, &block) 252 | if ::Kernel.block_given? 253 | array! object, &block 254 | else 255 | extract! object, *attributes 256 | end 257 | end 258 | 259 | # Returns the nil JSON. 260 | def nil! 261 | @attributes = nil 262 | end 263 | 264 | alias_method :null!, :nil! 265 | 266 | # Returns the attributes of the current builder. 267 | def attributes! 268 | @attributes 269 | end 270 | 271 | # Merges hash, array, or Jbuilder instance into current builder. 272 | def merge!(object) 273 | hash_or_array = ::Jbuilder === object ? object.attributes! : object 274 | @attributes = _merge_values(@attributes, _format_keys(hash_or_array)) 275 | end 276 | 277 | # Encodes the current builder as JSON. 278 | def target! 279 | @attributes.to_json 280 | end 281 | 282 | private 283 | 284 | def _extract_hash_values(object, attributes) 285 | attributes.each{ |key| _set_value key, _format_keys(object.fetch(key)) } 286 | end 287 | 288 | def _extract_method_values(object, attributes) 289 | attributes.each{ |key| _set_value key, _format_keys(object.public_send(key)) } 290 | end 291 | 292 | def _merge_block(key) 293 | current_value = _blank? ? BLANK : @attributes.fetch(_key(key), BLANK) 294 | ::Kernel.raise NullError.build(key) if current_value.nil? 295 | new_value = _scope{ yield self } 296 | _merge_values(current_value, new_value) 297 | end 298 | 299 | def _merge_values(current_value, updates) 300 | if _blank?(updates) 301 | current_value 302 | elsif _blank?(current_value) || updates.nil? || current_value.empty? && ::Array === updates 303 | updates 304 | elsif ::Array === current_value && ::Array === updates 305 | current_value + updates 306 | elsif ::Hash === current_value && ::Hash === updates 307 | current_value.deep_merge(updates) 308 | else 309 | ::Kernel.raise MergeError.build(current_value, updates) 310 | end 311 | end 312 | 313 | def _key(key) 314 | @key_formatter ? @key_formatter.format(key) : key.to_s 315 | end 316 | 317 | def _format_keys(hash_or_array) 318 | return hash_or_array unless @deep_format_keys 319 | 320 | if ::Array === hash_or_array 321 | hash_or_array.map { |value| _format_keys(value) } 322 | elsif ::Hash === hash_or_array 323 | ::Hash[hash_or_array.collect { |k, v| [_key(k), _format_keys(v)] }] 324 | else 325 | hash_or_array 326 | end 327 | end 328 | 329 | def _set_value(key, value) 330 | ::Kernel.raise NullError.build(key) if @attributes.nil? 331 | ::Kernel.raise ArrayError.build(key) if ::Array === @attributes 332 | return if @ignore_nil && value.nil? or _blank?(value) 333 | @attributes = {} if _blank? 334 | @attributes[_key(key)] = value 335 | end 336 | 337 | def _map_collection(collection) 338 | collection.map do |element| 339 | _scope{ yield element } 340 | end - [BLANK] 341 | end 342 | 343 | def _scope 344 | parent_attributes, parent_formatter, parent_deep_format_keys = @attributes, @key_formatter, @deep_format_keys 345 | @attributes = BLANK 346 | yield 347 | @attributes 348 | ensure 349 | @attributes, @key_formatter, @deep_format_keys = parent_attributes, parent_formatter, parent_deep_format_keys 350 | end 351 | 352 | def _is_collection?(object) 353 | _object_respond_to?(object, :map, :count) && !(::Struct === object) 354 | end 355 | 356 | def _blank?(value=@attributes) 357 | BLANK == value 358 | end 359 | 360 | def _object_respond_to?(object, *methods) 361 | methods.all?{ |m| object.respond_to?(m) } 362 | end 363 | end 364 | 365 | require 'jbuilder/railtie' if defined?(Rails) 366 | -------------------------------------------------------------------------------- /lib/jbuilder/blank.rb: -------------------------------------------------------------------------------- 1 | class Jbuilder 2 | class Blank 3 | def ==(other) 4 | super || Blank === other 5 | end 6 | 7 | def empty? 8 | true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/jbuilder/collection_renderer.rb: -------------------------------------------------------------------------------- 1 | require 'delegate' 2 | require 'active_support/concern' 3 | require 'action_view' 4 | 5 | begin 6 | require 'action_view/renderer/collection_renderer' 7 | rescue LoadError 8 | require 'action_view/renderer/partial_renderer' 9 | end 10 | 11 | class Jbuilder 12 | module CollectionRenderable # :nodoc: 13 | extend ActiveSupport::Concern 14 | 15 | class_methods do 16 | def supported? 17 | superclass.private_method_defined?(:build_rendered_template) && self.superclass.private_method_defined?(:build_rendered_collection) 18 | end 19 | end 20 | 21 | private 22 | 23 | def build_rendered_template(content, template, layout = nil) 24 | super(content || json.attributes!, template) 25 | end 26 | 27 | def build_rendered_collection(templates, _spacer) 28 | json.merge!(templates.map(&:body)) 29 | end 30 | 31 | def json 32 | @options[:locals].fetch(:json) 33 | end 34 | 35 | class ScopedIterator < ::SimpleDelegator # :nodoc: 36 | include Enumerable 37 | 38 | def initialize(obj, scope) 39 | super(obj) 40 | @scope = scope 41 | end 42 | 43 | # Rails 6.0 support: 44 | def each 45 | return enum_for(:each) unless block_given? 46 | 47 | __getobj__.each do |object| 48 | @scope.call { yield(object) } 49 | end 50 | end 51 | 52 | # Rails 6.1 support: 53 | def each_with_info 54 | return enum_for(:each_with_info) unless block_given? 55 | 56 | __getobj__.each_with_info do |object, info| 57 | @scope.call { yield(object, info) } 58 | end 59 | end 60 | end 61 | 62 | private_constant :ScopedIterator 63 | end 64 | 65 | if defined?(::ActionView::CollectionRenderer) 66 | # Rails 6.1 support: 67 | class CollectionRenderer < ::ActionView::CollectionRenderer # :nodoc: 68 | include CollectionRenderable 69 | 70 | def initialize(lookup_context, options, &scope) 71 | super(lookup_context, options) 72 | @scope = scope 73 | end 74 | 75 | private 76 | def collection_with_template(view, template, layout, collection) 77 | super(view, template, layout, ScopedIterator.new(collection, @scope)) 78 | end 79 | end 80 | else 81 | # Rails 6.0 support: 82 | class CollectionRenderer < ::ActionView::PartialRenderer # :nodoc: 83 | include CollectionRenderable 84 | 85 | def initialize(lookup_context, options, &scope) 86 | super(lookup_context) 87 | @options = options 88 | @scope = scope 89 | end 90 | 91 | def render_collection_with_partial(collection, partial, context, block) 92 | render(context, @options.merge(collection: collection, partial: partial), block) 93 | end 94 | 95 | private 96 | def collection_without_template(view) 97 | @collection = ScopedIterator.new(@collection, @scope) 98 | 99 | super(view) 100 | end 101 | 102 | def collection_with_template(view, template) 103 | @collection = ScopedIterator.new(@collection, @scope) 104 | 105 | super(view, template) 106 | end 107 | end 108 | end 109 | 110 | class EnumerableCompat < ::SimpleDelegator 111 | # Rails 6.1 requires this. 112 | def size(*args, &block) 113 | __getobj__.count(*args, &block) 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/jbuilder/errors.rb: -------------------------------------------------------------------------------- 1 | require 'jbuilder/jbuilder' 2 | 3 | class Jbuilder 4 | class NullError < ::NoMethodError 5 | def self.build(key) 6 | message = "Failed to add #{key.to_s.inspect} property to null object" 7 | new(message) 8 | end 9 | end 10 | 11 | class ArrayError < ::StandardError 12 | def self.build(key) 13 | message = "Failed to add #{key.to_s.inspect} property to an array" 14 | new(message) 15 | end 16 | end 17 | 18 | class MergeError < ::StandardError 19 | def self.build(current_value, updates) 20 | message = "Can't merge #{updates.inspect} into #{current_value.inspect}" 21 | new(message) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jbuilder/jbuilder.rb: -------------------------------------------------------------------------------- 1 | Jbuilder = Class.new(BasicObject) 2 | -------------------------------------------------------------------------------- /lib/jbuilder/jbuilder_dependency_tracker.rb: -------------------------------------------------------------------------------- 1 | class Jbuilder::DependencyTracker 2 | EXPLICIT_DEPENDENCY = /# Template Dependency: (\S+)/ 3 | 4 | # Matches: 5 | # json.partial! "messages/message" 6 | # json.partial!('messages/message') 7 | # 8 | DIRECT_RENDERS = / 9 | \w+\.partial! # json.partial! 10 | \(?\s* # optional parenthesis 11 | (['"])([^'"]+)\1 # quoted value 12 | /x 13 | 14 | # Matches: 15 | # json.partial! partial: "comments/comment" 16 | # json.comments @post.comments, partial: "comments/comment", as: :comment 17 | # json.array! @posts, partial: "posts/post", as: :post 18 | # = render partial: "account" 19 | # 20 | INDIRECT_RENDERS = / 21 | (?::partial\s*=>|partial:) # partial: or :partial => 22 | \s* # optional whitespace 23 | (['"])([^'"]+)\1 # quoted value 24 | /x 25 | 26 | def self.call(name, template, view_paths = nil) 27 | new(name, template, view_paths).dependencies 28 | end 29 | 30 | def initialize(name, template, view_paths = nil) 31 | @name, @template, @view_paths = name, template, view_paths 32 | end 33 | 34 | def dependencies 35 | direct_dependencies + indirect_dependencies + explicit_dependencies 36 | end 37 | 38 | private 39 | 40 | attr_reader :name, :template 41 | 42 | def direct_dependencies 43 | source.scan(DIRECT_RENDERS).map(&:second) 44 | end 45 | 46 | def indirect_dependencies 47 | source.scan(INDIRECT_RENDERS).map(&:second) 48 | end 49 | 50 | def explicit_dependencies 51 | dependencies = source.scan(EXPLICIT_DEPENDENCY).flatten.uniq 52 | 53 | wildcards, explicits = dependencies.partition { |dependency| dependency.end_with?("/*") } 54 | 55 | (explicits + resolve_directories(wildcards)).uniq 56 | end 57 | 58 | def resolve_directories(wildcard_dependencies) 59 | return [] unless @view_paths 60 | return [] if wildcard_dependencies.empty? 61 | 62 | # Remove trailing "/*" 63 | prefixes = wildcard_dependencies.map { |query| query[0..-3] } 64 | 65 | @view_paths.flat_map(&:all_template_paths).uniq.filter_map { |path| 66 | path.to_s if prefixes.include?(path.prefix) 67 | }.sort 68 | end 69 | 70 | def source 71 | template.source 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/jbuilder/jbuilder_template.rb: -------------------------------------------------------------------------------- 1 | require 'jbuilder/jbuilder' 2 | require 'jbuilder/collection_renderer' 3 | require 'action_dispatch/http/mime_type' 4 | require 'active_support/cache' 5 | 6 | class JbuilderTemplate < Jbuilder 7 | class << self 8 | attr_accessor :template_lookup_options 9 | end 10 | 11 | self.template_lookup_options = { handlers: [:jbuilder] } 12 | 13 | def initialize(context, *args) 14 | @context = context 15 | @cached_root = nil 16 | super(*args) 17 | end 18 | 19 | # Generates JSON using the template specified with the `:partial` option. For example, the code below will render 20 | # the file `views/comments/_comments.json.jbuilder`, and set a local variable comments with all this message's 21 | # comments, which can be used inside the partial. 22 | # 23 | # Example: 24 | # 25 | # json.partial! 'comments/comments', comments: @message.comments 26 | # 27 | # There are multiple ways to generate a collection of elements as JSON, as ilustrated below: 28 | # 29 | # Example: 30 | # 31 | # json.array! @posts, partial: 'posts/post', as: :post 32 | # 33 | # # or: 34 | # json.partial! 'posts/post', collection: @posts, as: :post 35 | # 36 | # # or: 37 | # json.partial! partial: 'posts/post', collection: @posts, as: :post 38 | # 39 | # # or: 40 | # json.comments @post.comments, partial: 'comments/comment', as: :comment 41 | # 42 | # Aside from that, the `:cached` options is available on Rails >= 6.0. This will cache the rendered results 43 | # effectively using the multi fetch feature. 44 | # 45 | # Example: 46 | # 47 | # json.array! @posts, partial: "posts/post", as: :post, cached: true 48 | # 49 | # json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true 50 | # 51 | def partial!(*args) 52 | if args.one? && _is_active_model?(args.first) 53 | _render_active_model_partial args.first 54 | else 55 | _render_explicit_partial(*args) 56 | end 57 | end 58 | 59 | # Caches the json constructed within the block passed. Has the same signature as the `cache` helper 60 | # method in `ActionView::Helpers::CacheHelper` and so can be used in the same way. 61 | # 62 | # Example: 63 | # 64 | # json.cache! ['v1', @person], expires_in: 10.minutes do 65 | # json.extract! @person, :name, :age 66 | # end 67 | def cache!(key=nil, options={}) 68 | if @context.controller.perform_caching 69 | value = _cache_fragment_for(key, options) do 70 | _scope { yield self } 71 | end 72 | 73 | merge! value 74 | else 75 | yield 76 | end 77 | end 78 | 79 | # Caches the json structure at the root using a string rather than the hash structure. This is considerably 80 | # faster, but the drawback is that it only works, as the name hints, at the root. So you cannot 81 | # use this approach to cache deeper inside the hierarchy, like in partials or such. Continue to use #cache! there. 82 | # 83 | # Example: 84 | # 85 | # json.cache_root! @person do 86 | # json.extract! @person, :name, :age 87 | # end 88 | # 89 | # # json.extra 'This will not work either, the root must be exclusive' 90 | def cache_root!(key=nil, options={}) 91 | if @context.controller.perform_caching 92 | ::Kernel.raise "cache_root! can't be used after JSON structures have been defined" if @attributes.present? 93 | 94 | @cached_root = _cache_fragment_for([ :root, key ], options) { yield; target! } 95 | else 96 | yield 97 | end 98 | end 99 | 100 | # Conditionally caches the json depending in the condition given as first parameter. Has the same 101 | # signature as the `cache` helper method in `ActionView::Helpers::CacheHelper` and so can be used in 102 | # the same way. 103 | # 104 | # Example: 105 | # 106 | # json.cache_if! !admin?, @person, expires_in: 10.minutes do 107 | # json.extract! @person, :name, :age 108 | # end 109 | def cache_if!(condition, *args, &block) 110 | condition ? cache!(*args, &block) : yield 111 | end 112 | 113 | def target! 114 | @cached_root || super 115 | end 116 | 117 | def array!(collection = [], *args) 118 | options = args.first 119 | 120 | if args.one? && _partial_options?(options) 121 | partial! options.merge(collection: collection) 122 | else 123 | super 124 | end 125 | end 126 | 127 | def set!(name, object = BLANK, *args) 128 | options = args.first 129 | 130 | if args.one? && _partial_options?(options) 131 | _set_inline_partial name, object, options 132 | else 133 | super 134 | end 135 | end 136 | 137 | private 138 | 139 | def _render_partial_with_options(options) 140 | options.reverse_merge! locals: options.except(:partial, :as, :collection, :cached) 141 | options.reverse_merge! ::JbuilderTemplate.template_lookup_options 142 | as = options[:as] 143 | 144 | if as && options.key?(:collection) && CollectionRenderer.supported? 145 | collection = options.delete(:collection) || [] 146 | partial = options.delete(:partial) 147 | options[:locals].merge!(json: self) 148 | collection = EnumerableCompat.new(collection) if collection.respond_to?(:count) && !collection.respond_to?(:size) 149 | 150 | if options.has_key?(:layout) 151 | ::Kernel.raise ::NotImplementedError, "The `:layout' option is not supported in collection rendering." 152 | end 153 | 154 | if options.has_key?(:spacer_template) 155 | ::Kernel.raise ::NotImplementedError, "The `:spacer_template' option is not supported in collection rendering." 156 | end 157 | 158 | if collection.present? 159 | results = CollectionRenderer 160 | .new(@context.lookup_context, options) { |&block| _scope(&block) } 161 | .render_collection_with_partial(collection, partial, @context, nil) 162 | 163 | array! if results.respond_to?(:body) && results.body.nil? 164 | else 165 | array! 166 | end 167 | elsif as && options.key?(:collection) && !CollectionRenderer.supported? 168 | # For Rails <= 5.2: 169 | as = as.to_sym 170 | collection = options.delete(:collection) 171 | 172 | if collection.present? 173 | locals = options.delete(:locals) 174 | array! collection do |member| 175 | member_locals = locals.clone 176 | member_locals.merge! collection: collection 177 | member_locals.merge! as => member 178 | _render_partial options.merge(locals: member_locals) 179 | end 180 | else 181 | array! 182 | end 183 | else 184 | _render_partial options 185 | end 186 | end 187 | 188 | def _render_partial(options) 189 | options[:locals].merge! json: self 190 | @context.render options 191 | end 192 | 193 | def _cache_fragment_for(key, options, &block) 194 | key = _cache_key(key, options) 195 | _read_fragment_cache(key, options) || _write_fragment_cache(key, options, &block) 196 | end 197 | 198 | def _read_fragment_cache(key, options = nil) 199 | @context.controller.instrument_fragment_cache :read_fragment, key do 200 | ::Rails.cache.read(key, options) 201 | end 202 | end 203 | 204 | def _write_fragment_cache(key, options = nil) 205 | @context.controller.instrument_fragment_cache :write_fragment, key do 206 | yield.tap do |value| 207 | ::Rails.cache.write(key, value, options) 208 | end 209 | end 210 | end 211 | 212 | def _cache_key(key, options) 213 | name_options = options.slice(:skip_digest, :virtual_path) 214 | key = _fragment_name_with_digest(key, name_options) 215 | 216 | if @context.respond_to?(:combined_fragment_cache_key) 217 | key = @context.combined_fragment_cache_key(key) 218 | else 219 | key = url_for(key).split('://', 2).last if ::Hash === key 220 | end 221 | 222 | ::ActiveSupport::Cache.expand_cache_key(key, :jbuilder) 223 | end 224 | 225 | def _fragment_name_with_digest(key, options) 226 | if @context.respond_to?(:cache_fragment_name) 227 | @context.cache_fragment_name(key, **options) 228 | else 229 | key 230 | end 231 | end 232 | 233 | def _partial_options?(options) 234 | ::Hash === options && options.key?(:as) && options.key?(:partial) 235 | end 236 | 237 | def _is_active_model?(object) 238 | object.class.respond_to?(:model_name) && object.respond_to?(:to_partial_path) 239 | end 240 | 241 | def _set_inline_partial(name, object, options) 242 | value = if object.nil? 243 | [] 244 | elsif _is_collection?(object) 245 | _scope{ _render_partial_with_options options.merge(collection: object) } 246 | else 247 | locals = ::Hash[options[:as], object] 248 | _scope{ _render_partial_with_options options.merge(locals: locals) } 249 | end 250 | 251 | set! name, value 252 | end 253 | 254 | def _render_explicit_partial(name_or_options, locals = {}) 255 | case name_or_options 256 | when ::Hash 257 | # partial! partial: 'name', foo: 'bar' 258 | options = name_or_options 259 | else 260 | # partial! 'name', locals: {foo: 'bar'} 261 | if locals.one? && (locals.keys.first == :locals) 262 | options = locals.merge(partial: name_or_options) 263 | else 264 | options = { partial: name_or_options, locals: locals } 265 | end 266 | # partial! 'name', foo: 'bar' 267 | as = locals.delete(:as) 268 | options[:as] = as if as.present? 269 | options[:collection] = locals[:collection] if locals.key?(:collection) 270 | end 271 | 272 | _render_partial_with_options options 273 | end 274 | 275 | def _render_active_model_partial(object) 276 | @context.render object, json: self 277 | end 278 | end 279 | 280 | class JbuilderHandler 281 | cattr_accessor :default_format 282 | self.default_format = :json 283 | 284 | def self.call(template, source = nil) 285 | source ||= template.source 286 | # this juggling is required to keep line numbers right in the error 287 | %{__already_defined = defined?(json); json||=JbuilderTemplate.new(self); #{source}; 288 | json.target! unless (__already_defined && __already_defined != "method")} 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /lib/jbuilder/key_formatter.rb: -------------------------------------------------------------------------------- 1 | require 'jbuilder/jbuilder' 2 | require 'active_support/core_ext/array' 3 | 4 | class Jbuilder 5 | class KeyFormatter 6 | def initialize(*args) 7 | @format = {} 8 | @cache = {} 9 | 10 | options = args.extract_options! 11 | args.each do |name| 12 | @format[name] = [] 13 | end 14 | options.each do |name, parameters| 15 | @format[name] = parameters 16 | end 17 | end 18 | 19 | def initialize_copy(original) 20 | @cache = {} 21 | end 22 | 23 | def format(key) 24 | @cache[key] ||= @format.inject(key.to_s) do |result, args| 25 | func, args = args 26 | if ::Proc === func 27 | func.call result, *args 28 | else 29 | result.send func, *args 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/jbuilder/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | require 'jbuilder/jbuilder_template' 3 | 4 | class Jbuilder 5 | class Railtie < ::Rails::Railtie 6 | initializer :jbuilder do 7 | ActiveSupport.on_load :action_view do 8 | ActionView::Template.register_template_handler :jbuilder, JbuilderHandler 9 | require 'jbuilder/jbuilder_dependency_tracker' 10 | end 11 | 12 | if Rails::VERSION::MAJOR >= 5 13 | module ::ActionController 14 | module ApiRendering 15 | include ActionView::Rendering 16 | end 17 | end 18 | 19 | ActiveSupport.on_load :action_controller do 20 | if name == 'ActionController::API' 21 | include ActionController::Helpers 22 | include ActionController::ImplicitRender 23 | end 24 | end 25 | end 26 | end 27 | 28 | if Rails::VERSION::MAJOR >= 4 29 | generators do |app| 30 | Rails::Generators.configure! app.config.generators 31 | Rails::Generators.hidden_namespaces.uniq! 32 | require 'generators/rails/scaffold_controller_generator' 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/jbuilder/version.rb: -------------------------------------------------------------------------------- 1 | class Jbuilder 2 | VERSION = "2.13.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/jbuilder_dependency_tracker_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'jbuilder/jbuilder_dependency_tracker' 3 | 4 | class FakeTemplate 5 | attr_reader :source, :handler 6 | def initialize(source, handler = :jbuilder) 7 | @source, @handler = source, handler 8 | end 9 | end 10 | 11 | 12 | class JbuilderDependencyTrackerTest < ActiveSupport::TestCase 13 | def make_tracker(name, source) 14 | template = FakeTemplate.new(source) 15 | Jbuilder::DependencyTracker.new(name, template) 16 | end 17 | 18 | def track_dependencies(source) 19 | make_tracker('jbuilder_template', source).dependencies 20 | end 21 | 22 | test 'detects dependency via direct partial! call' do 23 | dependencies = track_dependencies <<-RUBY 24 | json.partial! 'path/to/partial', foo: bar 25 | json.partial! 'path/to/another/partial', :fizz => buzz 26 | RUBY 27 | 28 | assert_equal %w[path/to/partial path/to/another/partial], dependencies 29 | end 30 | 31 | test 'detects dependency via direct partial! call with parens' do 32 | dependencies = track_dependencies <<-RUBY 33 | json.partial!("path/to/partial") 34 | RUBY 35 | 36 | assert_equal %w[path/to/partial], dependencies 37 | end 38 | 39 | test 'detects partial with options (1.9 style)' do 40 | dependencies = track_dependencies <<-RUBY 41 | json.partial! hello: 'world', partial: 'path/to/partial', foo: :bar 42 | RUBY 43 | 44 | assert_equal %w[path/to/partial], dependencies 45 | end 46 | 47 | test 'detects partial with options (1.8 style)' do 48 | dependencies = track_dependencies <<-RUBY 49 | json.partial! :hello => 'world', :partial => 'path/to/partial', :foo => :bar 50 | RUBY 51 | 52 | assert_equal %w[path/to/partial], dependencies 53 | end 54 | 55 | test 'detects partial in indirect collection calls' do 56 | dependencies = track_dependencies <<-RUBY 57 | json.comments @post.comments, partial: 'comments/comment', as: :comment 58 | RUBY 59 | 60 | assert_equal %w[comments/comment], dependencies 61 | end 62 | 63 | test 'detects explicit dependency' do 64 | dependencies = track_dependencies <<-RUBY 65 | # Template Dependency: path/to/partial 66 | json.foo 'bar' 67 | RUBY 68 | 69 | assert_equal %w[path/to/partial], dependencies 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/jbuilder_generator_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails/generators/test_case' 3 | require 'generators/rails/jbuilder_generator' 4 | 5 | class JbuilderGeneratorTest < Rails::Generators::TestCase 6 | tests Rails::Generators::JbuilderGenerator 7 | arguments %w(Post title body:text password:digest) 8 | destination File.expand_path('../tmp', __FILE__) 9 | setup :prepare_destination 10 | 11 | test 'views are generated' do 12 | run_generator 13 | 14 | %w(index show).each do |view| 15 | assert_file "app/views/posts/#{view}.json.jbuilder" 16 | end 17 | assert_file "app/views/posts/_post.json.jbuilder" 18 | end 19 | 20 | test 'index content' do 21 | run_generator 22 | 23 | assert_file 'app/views/posts/index.json.jbuilder' do |content| 24 | assert_match %r{json\.array! @posts, partial: "posts/post", as: :post}, content 25 | end 26 | 27 | assert_file 'app/views/posts/show.json.jbuilder' do |content| 28 | assert_match %r{json\.partial! "posts/post", post: @post}, content 29 | end 30 | 31 | assert_file 'app/views/posts/_post.json.jbuilder' do |content| 32 | assert_match %r{json\.extract! post, :id, :title, :body}, content 33 | assert_match %r{:created_at, :updated_at}, content 34 | assert_match %r{json\.url post_url\(post, format: :json\)}, content 35 | end 36 | end 37 | 38 | test 'timestamps are not generated in partial with --no-timestamps' do 39 | run_generator %w(Post title body:text --no-timestamps) 40 | 41 | assert_file 'app/views/posts/_post.json.jbuilder' do |content| 42 | assert_match %r{json\.extract! post, :id, :title, :body$}, content 43 | assert_no_match %r{:created_at, :updated_at}, content 44 | end 45 | end 46 | 47 | test 'namespaced views are generated correctly for index' do 48 | run_generator %w(Admin::Post --model-name=Post) 49 | 50 | assert_file 'app/views/admin/posts/index.json.jbuilder' do |content| 51 | assert_match %r{json\.array! @posts, partial: "admin/posts/post", as: :post}, content 52 | end 53 | 54 | assert_file 'app/views/admin/posts/show.json.jbuilder' do |content| 55 | assert_match %r{json\.partial! "admin/posts/post", post: @post}, content 56 | end 57 | end 58 | 59 | if Rails::VERSION::MAJOR >= 6 60 | test 'handles virtual attributes' do 61 | run_generator %w(Message content:rich_text video:attachment photos:attachments) 62 | 63 | assert_file 'app/views/messages/_message.json.jbuilder' do |content| 64 | assert_match %r{json\.content message\.content\.to_s}, content 65 | assert_match %r{json\.video url_for\(message\.video\)}, content 66 | assert_match %r{json\.photos do\n json\.array!\(message\.photos\) do \|photo\|\n json\.id photo\.id\n json\.url url_for\(photo\)\n end\nend}, content 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/jbuilder_template_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "action_view/testing/resolvers" 3 | 4 | class JbuilderTemplateTest < ActiveSupport::TestCase 5 | POST_PARTIAL = <<-JBUILDER 6 | json.extract! post, :id, :body 7 | json.author do 8 | first_name, last_name = post.author_name.split(nil, 2) 9 | json.first_name first_name 10 | json.last_name last_name 11 | end 12 | JBUILDER 13 | 14 | COLLECTION_PARTIAL = <<-JBUILDER 15 | json.extract! collection, :id, :name 16 | JBUILDER 17 | 18 | RACER_PARTIAL = <<-JBUILDER 19 | json.extract! racer, :id, :name 20 | JBUILDER 21 | 22 | PARTIALS = { 23 | "_partial.json.jbuilder" => "json.content content", 24 | "_post.json.jbuilder" => POST_PARTIAL, 25 | "racers/_racer.json.jbuilder" => RACER_PARTIAL, 26 | "_collection.json.jbuilder" => COLLECTION_PARTIAL, 27 | 28 | # Ensure we find only Jbuilder partials from within Jbuilder templates. 29 | "_post.html.erb" => "Hello world!" 30 | } 31 | 32 | AUTHORS = [ "David Heinemeier Hansson", "Pavel Pravosud" ].cycle 33 | POSTS = (1..10).collect { |i| Post.new(i, "Post ##{i}", AUTHORS.next) } 34 | 35 | setup { Rails.cache.clear } 36 | 37 | test "basic template" do 38 | result = render('json.content "hello"') 39 | assert_equal "hello", result["content"] 40 | end 41 | 42 | test "partial by name with top-level locals" do 43 | result = render('json.partial! "partial", content: "hello"') 44 | assert_equal "hello", result["content"] 45 | end 46 | 47 | test "partial by name with nested locals" do 48 | result = render('json.partial! "partial", locals: { content: "hello" }') 49 | assert_equal "hello", result["content"] 50 | end 51 | 52 | test "partial by name with hash value omission (punning) as last statement [3.1+]" do 53 | major, minor, _ = RUBY_VERSION.split(".").map(&:to_i) 54 | return unless (major == 3 && minor >= 1) || major > 3 55 | 56 | result = render(<<-JBUILDER) 57 | content = "hello" 58 | json.partial! "partial", content: 59 | JBUILDER 60 | assert_equal "hello", result["content"] 61 | end 62 | 63 | test "partial by options containing nested locals" do 64 | result = render('json.partial! partial: "partial", locals: { content: "hello" }') 65 | assert_equal "hello", result["content"] 66 | end 67 | 68 | test "partial by options containing top-level locals" do 69 | result = render('json.partial! partial: "partial", content: "hello"') 70 | assert_equal "hello", result["content"] 71 | end 72 | 73 | test "partial for Active Model" do 74 | result = render('json.partial! @racer', racer: Racer.new(123, "Chris Harris")) 75 | assert_equal 123, result["id"] 76 | assert_equal "Chris Harris", result["name"] 77 | end 78 | 79 | test "partial collection by name with symbol local" do 80 | result = render('json.partial! "post", collection: @posts, as: :post', posts: POSTS) 81 | assert_equal 10, result.count 82 | assert_equal "Post #5", result[4]["body"] 83 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 84 | assert_equal "Pavel", result[5]["author"]["first_name"] 85 | end 86 | 87 | test "partial collection by name with caching" do 88 | result = render('json.partial! "post", collection: @posts, cached: true, as: :post', posts: POSTS) 89 | assert_equal 10, result.count 90 | assert_equal "Post #5", result[4]["body"] 91 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 92 | assert_equal "Pavel", result[5]["author"]["first_name"] 93 | end 94 | 95 | test "partial collection by name with string local" do 96 | result = render('json.partial! "post", collection: @posts, as: "post"', posts: POSTS) 97 | assert_equal 10, result.count 98 | assert_equal "Post #5", result[4]["body"] 99 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 100 | assert_equal "Pavel", result[5]["author"]["first_name"] 101 | end 102 | 103 | test "partial collection by options" do 104 | result = render('json.partial! partial: "post", collection: @posts, as: :post', posts: POSTS) 105 | assert_equal 10, result.count 106 | assert_equal "Post #5", result[4]["body"] 107 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 108 | assert_equal "Pavel", result[5]["author"]["first_name"] 109 | end 110 | 111 | test "nil partial collection by name" do 112 | Jbuilder::CollectionRenderer.expects(:new).never 113 | assert_equal [], render('json.partial! "post", collection: @posts, as: :post', posts: nil) 114 | end 115 | 116 | test "nil partial collection by options" do 117 | Jbuilder::CollectionRenderer.expects(:new).never 118 | assert_equal [], render('json.partial! partial: "post", collection: @posts, as: :post', posts: nil) 119 | end 120 | 121 | test "array of partials" do 122 | result = render('json.array! @posts, partial: "post", as: :post', posts: POSTS) 123 | assert_equal 10, result.count 124 | assert_equal "Post #5", result[4]["body"] 125 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 126 | assert_equal "Pavel", result[5]["author"]["first_name"] 127 | end 128 | 129 | test "empty array of partials from empty collection" do 130 | Jbuilder::CollectionRenderer.expects(:new).never 131 | assert_equal [], render('json.array! @posts, partial: "post", as: :post', posts: []) 132 | end 133 | 134 | test "empty array of partials from nil collection" do 135 | Jbuilder::CollectionRenderer.expects(:new).never 136 | assert_equal [], render('json.array! @posts, partial: "post", as: :post', posts: nil) 137 | end 138 | 139 | test "array of partials under key" do 140 | result = render('json.posts @posts, partial: "post", as: :post', posts: POSTS) 141 | assert_equal 10, result["posts"].count 142 | assert_equal "Post #5", result["posts"][4]["body"] 143 | assert_equal "Heinemeier Hansson", result["posts"][2]["author"]["last_name"] 144 | assert_equal "Pavel", result["posts"][5]["author"]["first_name"] 145 | end 146 | 147 | test "empty array of partials under key from nil collection" do 148 | Jbuilder::CollectionRenderer.expects(:new).never 149 | result = render('json.posts @posts, partial: "post", as: :post', posts: nil) 150 | assert_equal [], result["posts"] 151 | end 152 | 153 | test "empty array of partials under key from an empy collection" do 154 | Jbuilder::CollectionRenderer.expects(:new).never 155 | result = render('json.posts @posts, partial: "post", as: :post', posts: []) 156 | assert_equal [], result["posts"] 157 | end 158 | 159 | test "object fragment caching" do 160 | render(<<-JBUILDER) 161 | json.cache! "cache-key" do 162 | json.name "Hit" 163 | end 164 | JBUILDER 165 | 166 | hit = render('json.cache! "cache-key" do; end') 167 | assert_equal "Hit", hit["name"] 168 | end 169 | 170 | test "conditional object fragment caching" do 171 | render(<<-JBUILDER) 172 | json.cache_if! true, "cache-key" do 173 | json.a "Hit" 174 | end 175 | 176 | json.cache_if! false, "cache-key" do 177 | json.b "Hit" 178 | end 179 | JBUILDER 180 | 181 | result = render(<<-JBUILDER) 182 | json.cache_if! true, "cache-key" do 183 | json.a "Miss" 184 | end 185 | 186 | json.cache_if! false, "cache-key" do 187 | json.b "Miss" 188 | end 189 | JBUILDER 190 | 191 | assert_equal "Hit", result["a"] 192 | assert_equal "Miss", result["b"] 193 | end 194 | 195 | test "object fragment caching with expiry" do 196 | travel_to Time.iso8601("2018-05-12T11:29:00-04:00") 197 | 198 | render <<-JBUILDER 199 | json.cache! "cache-key", expires_in: 1.minute do 200 | json.name "Hit" 201 | end 202 | JBUILDER 203 | 204 | travel 30.seconds 205 | 206 | result = render(<<-JBUILDER) 207 | json.cache! "cache-key", expires_in: 1.minute do 208 | json.name "Miss" 209 | end 210 | JBUILDER 211 | 212 | assert_equal "Hit", result["name"] 213 | 214 | travel 31.seconds 215 | 216 | result = render(<<-JBUILDER) 217 | json.cache! "cache-key", expires_in: 1.minute do 218 | json.name "Miss" 219 | end 220 | JBUILDER 221 | 222 | assert_equal "Miss", result["name"] 223 | end 224 | 225 | test "object root caching" do 226 | render <<-JBUILDER 227 | json.cache_root! "cache-key" do 228 | json.name "Hit" 229 | end 230 | JBUILDER 231 | 232 | assert_equal JSON.dump(name: "Hit"), Rails.cache.read("jbuilder/root/cache-key") 233 | 234 | result = render(<<-JBUILDER) 235 | json.cache_root! "cache-key" do 236 | json.name "Miss" 237 | end 238 | JBUILDER 239 | 240 | assert_equal "Hit", result["name"] 241 | end 242 | 243 | test "array fragment caching" do 244 | render <<-JBUILDER 245 | json.cache! "cache-key" do 246 | json.array! %w[ a b c ] 247 | end 248 | JBUILDER 249 | 250 | assert_equal %w[ a b c ], render('json.cache! "cache-key" do; end') 251 | end 252 | 253 | test "array root caching" do 254 | render <<-JBUILDER 255 | json.cache_root! "cache-key" do 256 | json.array! %w[ a b c ] 257 | end 258 | JBUILDER 259 | 260 | assert_equal JSON.dump(%w[ a b c ]), Rails.cache.read("jbuilder/root/cache-key") 261 | 262 | assert_equal %w[ a b c ], render(<<-JBUILDER) 263 | json.cache_root! "cache-key" do 264 | json.array! %w[ d e f ] 265 | end 266 | JBUILDER 267 | end 268 | 269 | test "failing to cache root after JSON structures have been defined" do 270 | assert_raises ActionView::Template::Error, "cache_root! can't be used after JSON structures have been defined" do 271 | render <<-JBUILDER 272 | json.name "Kaboom" 273 | json.cache_root! "cache-key" do 274 | json.name "Miss" 275 | end 276 | JBUILDER 277 | end 278 | end 279 | 280 | test "empty fragment caching" do 281 | render 'json.cache! "nothing" do; end' 282 | 283 | result = nil 284 | 285 | assert_nothing_raised do 286 | result = render(<<-JBUILDER) 287 | json.foo "bar" 288 | json.cache! "nothing" do; end 289 | JBUILDER 290 | end 291 | 292 | assert_equal "bar", result["foo"] 293 | end 294 | 295 | test "cache instrumentation" do 296 | payloads = {} 297 | 298 | ActiveSupport::Notifications.subscribe("read_fragment.action_controller") { |*args| payloads[:read] = args.last } 299 | ActiveSupport::Notifications.subscribe("write_fragment.action_controller") { |*args| payloads[:write] = args.last } 300 | 301 | render <<-JBUILDER 302 | json.cache! "cache-key" do 303 | json.name "Cache" 304 | end 305 | JBUILDER 306 | 307 | assert_equal "jbuilder/cache-key", payloads[:read][:key] 308 | assert_equal "jbuilder/cache-key", payloads[:write][:key] 309 | end 310 | 311 | test "camelized keys" do 312 | result = render(<<-JBUILDER) 313 | json.key_format! camelize: [:lower] 314 | json.first_name "David" 315 | JBUILDER 316 | 317 | assert_equal "David", result["firstName"] 318 | end 319 | 320 | if JbuilderTemplate::CollectionRenderer.supported? 321 | test "returns an empty array for an empty collection" do 322 | Jbuilder::CollectionRenderer.expects(:new).never 323 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: []) 324 | 325 | # Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array. 326 | assert_equal [], result 327 | end 328 | 329 | test "works with an enumerable object" do 330 | enumerable_class = Class.new do 331 | include Enumerable 332 | 333 | def each(&block) 334 | [].each(&block) 335 | end 336 | end 337 | 338 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: enumerable_class.new) 339 | 340 | # Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array. 341 | assert_equal [], result 342 | end 343 | 344 | test "supports the cached: true option" do 345 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS) 346 | 347 | assert_equal 10, result.count 348 | assert_equal "Post #5", result[4]["body"] 349 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 350 | assert_equal "Pavel", result[5]["author"]["first_name"] 351 | 352 | expected = { 353 | "id" => 1, 354 | "body" => "Post #1", 355 | "author" => { 356 | "first_name" => "David", 357 | "last_name" => "Heinemeier Hansson" 358 | } 359 | } 360 | 361 | assert_equal expected, Rails.cache.read("post-1") 362 | 363 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS) 364 | 365 | assert_equal 10, result.count 366 | assert_equal "Post #5", result[4]["body"] 367 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 368 | assert_equal "Pavel", result[5]["author"]["first_name"] 369 | end 370 | 371 | test "supports the cached: ->() {} option" do 372 | result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS) 373 | 374 | assert_equal 10, result.count 375 | assert_equal "Post #5", result[4]["body"] 376 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 377 | assert_equal "Pavel", result[5]["author"]["first_name"] 378 | 379 | expected = { 380 | "id" => 1, 381 | "body" => "Post #1", 382 | "author" => { 383 | "first_name" => "David", 384 | "last_name" => "Heinemeier Hansson" 385 | } 386 | } 387 | 388 | assert_equal expected, Rails.cache.read("post-1/foo") 389 | 390 | result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS) 391 | 392 | assert_equal 10, result.count 393 | assert_equal "Post #5", result[4]["body"] 394 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 395 | assert_equal "Pavel", result[5]["author"]["first_name"] 396 | end 397 | 398 | test "raises an error on a render call with the :layout option" do 399 | error = assert_raises NotImplementedError do 400 | render('json.array! @posts, partial: "post", as: :post, layout: "layout"', posts: POSTS) 401 | end 402 | 403 | assert_equal "The `:layout' option is not supported in collection rendering.", error.message 404 | end 405 | 406 | test "raises an error on a render call with the :spacer_template option" do 407 | error = assert_raises NotImplementedError do 408 | render('json.array! @posts, partial: "post", as: :post, spacer_template: "template"', posts: POSTS) 409 | end 410 | 411 | assert_equal "The `:spacer_template' option is not supported in collection rendering.", error.message 412 | end 413 | end 414 | 415 | private 416 | def render(*args) 417 | JSON.load render_without_parsing(*args) 418 | end 419 | 420 | def render_without_parsing(source, assigns = {}) 421 | view = build_view(fixtures: PARTIALS.merge("source.json.jbuilder" => source), assigns: assigns) 422 | view.render(template: "source") 423 | end 424 | 425 | def build_view(options = {}) 426 | resolver = ActionView::FixtureResolver.new(options.fetch(:fixtures)) 427 | lookup_context = ActionView::LookupContext.new([ resolver ], {}, [""]) 428 | controller = ActionView::TestCase::TestController.new 429 | 430 | # TODO: Use with_empty_template_cache unconditionally after dropping support for Rails <6.0. 431 | view = if ActionView::Base.respond_to?(:with_empty_template_cache) 432 | ActionView::Base.with_empty_template_cache.new(lookup_context, options.fetch(:assigns, {}), controller) 433 | else 434 | ActionView::Base.new(lookup_context, options.fetch(:assigns, {}), controller) 435 | end 436 | 437 | def view.view_cache_dependencies; []; end 438 | def view.combined_fragment_cache_key(key) [ key ] end 439 | def view.cache_fragment_name(key, *) key end 440 | def view.fragment_name_with_digest(key) key end 441 | 442 | view 443 | end 444 | end 445 | -------------------------------------------------------------------------------- /test/jbuilder_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'active_support/inflector' 3 | require 'jbuilder' 4 | 5 | def jbuild(*args, &block) 6 | Jbuilder.new(*args, &block).attributes! 7 | end 8 | 9 | Comment = Struct.new(:content, :id) 10 | 11 | class NonEnumerable 12 | def initialize(collection) 13 | @collection = collection 14 | end 15 | 16 | delegate :map, :count, to: :@collection 17 | end 18 | 19 | class VeryBasicWrapper < BasicObject 20 | def initialize(thing) 21 | @thing = thing 22 | end 23 | 24 | def method_missing(name, *args, &block) 25 | @thing.send name, *args, &block 26 | end 27 | end 28 | 29 | # This is not Struct, because structs are Enumerable 30 | class Person 31 | attr_reader :name, :age 32 | 33 | def initialize(name, age) 34 | @name, @age = name, age 35 | end 36 | end 37 | 38 | class RelationMock 39 | include Enumerable 40 | 41 | def each(&block) 42 | [Person.new('Bob', 30), Person.new('Frank', 50)].each(&block) 43 | end 44 | 45 | def empty? 46 | false 47 | end 48 | end 49 | 50 | 51 | class JbuilderTest < ActiveSupport::TestCase 52 | teardown do 53 | Jbuilder.send :class_variable_set, '@@key_formatter', nil 54 | end 55 | 56 | test 'single key' do 57 | result = jbuild do |json| 58 | json.content 'hello' 59 | end 60 | 61 | assert_equal 'hello', result['content'] 62 | end 63 | 64 | test 'single key with false value' do 65 | result = jbuild do |json| 66 | json.content false 67 | end 68 | 69 | assert_equal false, result['content'] 70 | end 71 | 72 | test 'single key with nil value' do 73 | result = jbuild do |json| 74 | json.content nil 75 | end 76 | 77 | assert result.has_key?('content') 78 | assert_nil result['content'] 79 | end 80 | 81 | test 'multiple keys' do 82 | result = jbuild do |json| 83 | json.title 'hello' 84 | json.content 'world' 85 | end 86 | 87 | assert_equal 'hello', result['title'] 88 | assert_equal 'world', result['content'] 89 | end 90 | 91 | test 'extracting from object' do 92 | person = Struct.new(:name, :age).new('David', 32) 93 | 94 | result = jbuild do |json| 95 | json.extract! person, :name, :age 96 | end 97 | 98 | assert_equal 'David', result['name'] 99 | assert_equal 32, result['age'] 100 | end 101 | 102 | test 'extracting from object using call style' do 103 | person = Struct.new(:name, :age).new('David', 32) 104 | 105 | result = jbuild do |json| 106 | json.(person, :name, :age) 107 | end 108 | 109 | assert_equal 'David', result['name'] 110 | assert_equal 32, result['age'] 111 | end 112 | 113 | test 'extracting from hash' do 114 | person = {:name => 'Jim', :age => 34} 115 | 116 | result = jbuild do |json| 117 | json.extract! person, :name, :age 118 | end 119 | 120 | assert_equal 'Jim', result['name'] 121 | assert_equal 34, result['age'] 122 | end 123 | 124 | test 'nesting single child with block' do 125 | result = jbuild do |json| 126 | json.author do 127 | json.name 'David' 128 | json.age 32 129 | end 130 | end 131 | 132 | assert_equal 'David', result['author']['name'] 133 | assert_equal 32, result['author']['age'] 134 | end 135 | 136 | test 'empty block handling' do 137 | result = jbuild do |json| 138 | json.foo 'bar' 139 | json.author do 140 | end 141 | end 142 | 143 | assert_equal 'bar', result['foo'] 144 | assert !result.key?('author') 145 | end 146 | 147 | test 'blocks are additive' do 148 | result = jbuild do |json| 149 | json.author do 150 | json.name 'David' 151 | end 152 | 153 | json.author do 154 | json.age 32 155 | end 156 | end 157 | 158 | assert_equal 'David', result['author']['name'] 159 | assert_equal 32, result['author']['age'] 160 | end 161 | 162 | test 'nested blocks are additive' do 163 | result = jbuild do |json| 164 | json.author do 165 | json.name do 166 | json.first 'David' 167 | end 168 | end 169 | 170 | json.author do 171 | json.name do 172 | json.last 'Heinemeier Hansson' 173 | end 174 | end 175 | end 176 | 177 | assert_equal 'David', result['author']['name']['first'] 178 | assert_equal 'Heinemeier Hansson', result['author']['name']['last'] 179 | end 180 | 181 | test 'support merge! method' do 182 | result = jbuild do |json| 183 | json.merge! 'foo' => 'bar' 184 | end 185 | 186 | assert_equal 'bar', result['foo'] 187 | end 188 | 189 | test 'support merge! method in a block' do 190 | result = jbuild do |json| 191 | json.author do 192 | json.merge! 'name' => 'Pavel' 193 | end 194 | end 195 | 196 | assert_equal 'Pavel', result['author']['name'] 197 | end 198 | 199 | test 'support merge! method with Jbuilder instance' do 200 | obj = jbuild do |json| 201 | json.foo 'bar' 202 | end 203 | 204 | result = jbuild do |json| 205 | json.merge! obj 206 | end 207 | 208 | assert_equal 'bar', result['foo'] 209 | end 210 | 211 | test 'blocks are additive via extract syntax' do 212 | person = Person.new('Pavel', 27) 213 | 214 | result = jbuild do |json| 215 | json.author person, :age 216 | json.author person, :name 217 | end 218 | 219 | assert_equal 'Pavel', result['author']['name'] 220 | assert_equal 27, result['author']['age'] 221 | end 222 | 223 | test 'arrays are additive' do 224 | result = jbuild do |json| 225 | json.array! %w[foo] 226 | json.array! %w[bar] 227 | end 228 | 229 | assert_equal %w[foo bar], result 230 | end 231 | 232 | test 'nesting multiple children with block' do 233 | result = jbuild do |json| 234 | json.comments do 235 | json.child! { json.content 'hello' } 236 | json.child! { json.content 'world' } 237 | end 238 | end 239 | 240 | assert_equal 'hello', result['comments'].first['content'] 241 | assert_equal 'world', result['comments'].second['content'] 242 | end 243 | 244 | test 'nesting single child with inline extract' do 245 | person = Person.new('David', 32) 246 | 247 | result = jbuild do |json| 248 | json.author person, :name, :age 249 | end 250 | 251 | assert_equal 'David', result['author']['name'] 252 | assert_equal 32, result['author']['age'] 253 | end 254 | 255 | test 'nesting multiple children from array' do 256 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 257 | 258 | result = jbuild do |json| 259 | json.comments comments, :content 260 | end 261 | 262 | assert_equal ['content'], result['comments'].first.keys 263 | assert_equal 'hello', result['comments'].first['content'] 264 | assert_equal 'world', result['comments'].second['content'] 265 | end 266 | 267 | test 'nesting multiple children from array when child array is empty' do 268 | comments = [] 269 | 270 | result = jbuild do |json| 271 | json.name 'Parent' 272 | json.comments comments, :content 273 | end 274 | 275 | assert_equal 'Parent', result['name'] 276 | assert_equal [], result['comments'] 277 | end 278 | 279 | test 'nesting multiple children from array with inline loop' do 280 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 281 | 282 | result = jbuild do |json| 283 | json.comments comments do |comment| 284 | json.content comment.content 285 | end 286 | end 287 | 288 | assert_equal ['content'], result['comments'].first.keys 289 | assert_equal 'hello', result['comments'].first['content'] 290 | assert_equal 'world', result['comments'].second['content'] 291 | end 292 | 293 | test 'handles nil-collections as empty arrays' do 294 | result = jbuild do |json| 295 | json.comments nil do |comment| 296 | json.content comment.content 297 | end 298 | end 299 | 300 | assert_equal [], result['comments'] 301 | end 302 | 303 | test 'nesting multiple children from a non-Enumerable that responds to #map' do 304 | comments = NonEnumerable.new([ Comment.new('hello', 1), Comment.new('world', 2) ]) 305 | 306 | result = jbuild do |json| 307 | json.comments comments, :content 308 | end 309 | 310 | assert_equal ['content'], result['comments'].first.keys 311 | assert_equal 'hello', result['comments'].first['content'] 312 | assert_equal 'world', result['comments'].second['content'] 313 | end 314 | 315 | test 'nesting multiple children from a non-Enumerable that responds to #map with inline loop' do 316 | comments = NonEnumerable.new([ Comment.new('hello', 1), Comment.new('world', 2) ]) 317 | 318 | result = jbuild do |json| 319 | json.comments comments do |comment| 320 | json.content comment.content 321 | end 322 | end 323 | 324 | assert_equal ['content'], result['comments'].first.keys 325 | assert_equal 'hello', result['comments'].first['content'] 326 | assert_equal 'world', result['comments'].second['content'] 327 | end 328 | 329 | test 'array! casts array-like objects to array before merging' do 330 | wrapped_array = VeryBasicWrapper.new(%w[foo bar]) 331 | 332 | result = jbuild do |json| 333 | json.array! wrapped_array 334 | end 335 | 336 | assert_equal %w[foo bar], result 337 | end 338 | 339 | test 'nesting multiple children from array with inline loop on root' do 340 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 341 | 342 | result = jbuild do |json| 343 | json.call(comments) do |comment| 344 | json.content comment.content 345 | end 346 | end 347 | 348 | assert_equal 'hello', result.first['content'] 349 | assert_equal 'world', result.second['content'] 350 | end 351 | 352 | test 'array nested inside nested hash' do 353 | result = jbuild do |json| 354 | json.author do 355 | json.name 'David' 356 | json.age 32 357 | 358 | json.comments do 359 | json.child! { json.content 'hello' } 360 | json.child! { json.content 'world' } 361 | end 362 | end 363 | end 364 | 365 | assert_equal 'hello', result['author']['comments'].first['content'] 366 | assert_equal 'world', result['author']['comments'].second['content'] 367 | end 368 | 369 | test 'array nested inside array' do 370 | result = jbuild do |json| 371 | json.comments do 372 | json.child! do 373 | json.authors do 374 | json.child! do 375 | json.name 'david' 376 | end 377 | end 378 | end 379 | end 380 | end 381 | 382 | assert_equal 'david', result['comments'].first['authors'].first['name'] 383 | end 384 | 385 | test 'directly set an array nested in another array' do 386 | data = [ { :department => 'QA', :not_in_json => 'hello', :names => ['John', 'David'] } ] 387 | 388 | result = jbuild do |json| 389 | json.array! data do |object| 390 | json.department object[:department] 391 | json.names do 392 | json.array! object[:names] 393 | end 394 | end 395 | end 396 | 397 | assert_equal 'David', result[0]['names'].last 398 | assert !result[0].key?('not_in_json') 399 | end 400 | 401 | test 'nested jbuilder objects' do 402 | to_nest = Jbuilder.new{ |json| json.nested_value 'Nested Test' } 403 | 404 | result = jbuild do |json| 405 | json.value 'Test' 406 | json.nested to_nest 407 | end 408 | 409 | expected = {'value' => 'Test', 'nested' => {'nested_value' => 'Nested Test'}} 410 | assert_equal expected, result 411 | end 412 | 413 | test 'nested jbuilder object via set!' do 414 | to_nest = Jbuilder.new{ |json| json.nested_value 'Nested Test' } 415 | 416 | result = jbuild do |json| 417 | json.value 'Test' 418 | json.set! :nested, to_nest 419 | end 420 | 421 | expected = {'value' => 'Test', 'nested' => {'nested_value' => 'Nested Test'}} 422 | assert_equal expected, result 423 | end 424 | 425 | test 'top-level array' do 426 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 427 | 428 | result = jbuild do |json| 429 | json.array! comments do |comment| 430 | json.content comment.content 431 | end 432 | end 433 | 434 | assert_equal 'hello', result.first['content'] 435 | assert_equal 'world', result.second['content'] 436 | end 437 | 438 | test 'it allows using next in array block to skip value' do 439 | comments = [ Comment.new('hello', 1), Comment.new('skip', 2), Comment.new('world', 3) ] 440 | result = jbuild do |json| 441 | json.array! comments do |comment| 442 | next if comment.id == 2 443 | json.content comment.content 444 | end 445 | end 446 | 447 | assert_equal 2, result.length 448 | assert_equal 'hello', result.first['content'] 449 | assert_equal 'world', result.second['content'] 450 | end 451 | 452 | test 'extract attributes directly from array' do 453 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 454 | 455 | result = jbuild do |json| 456 | json.array! comments, :content, :id 457 | end 458 | 459 | assert_equal 'hello', result.first['content'] 460 | assert_equal 1, result.first['id'] 461 | assert_equal 'world', result.second['content'] 462 | assert_equal 2, result.second['id'] 463 | end 464 | 465 | test 'empty top-level array' do 466 | comments = [] 467 | 468 | result = jbuild do |json| 469 | json.array! comments do |comment| 470 | json.content comment.content 471 | end 472 | end 473 | 474 | assert_equal [], result 475 | end 476 | 477 | test 'dynamically set a key/value' do 478 | result = jbuild do |json| 479 | json.set! :each, 'stuff' 480 | end 481 | 482 | assert_equal 'stuff', result['each'] 483 | end 484 | 485 | test 'dynamically set a key/nested child with block' do 486 | result = jbuild do |json| 487 | json.set! :author do 488 | json.name 'David' 489 | json.age 32 490 | end 491 | end 492 | 493 | assert_equal 'David', result['author']['name'] 494 | assert_equal 32, result['author']['age'] 495 | end 496 | 497 | test 'dynamically sets a collection' do 498 | comments = [ Comment.new('hello', 1), Comment.new('world', 2) ] 499 | 500 | result = jbuild do |json| 501 | json.set! :comments, comments, :content 502 | end 503 | 504 | assert_equal ['content'], result['comments'].first.keys 505 | assert_equal 'hello', result['comments'].first['content'] 506 | assert_equal 'world', result['comments'].second['content'] 507 | end 508 | 509 | test 'query like object' do 510 | result = jbuild do |json| 511 | json.relations RelationMock.new, :name, :age 512 | end 513 | 514 | assert_equal 2, result['relations'].length 515 | assert_equal 'Bob', result['relations'][0]['name'] 516 | assert_equal 50, result['relations'][1]['age'] 517 | end 518 | 519 | test 'initialize via options hash' do 520 | jbuilder = Jbuilder.new(key_formatter: 1, ignore_nil: 2) 521 | assert_equal 1, jbuilder.instance_eval{ @key_formatter } 522 | assert_equal 2, jbuilder.instance_eval{ @ignore_nil } 523 | end 524 | 525 | test 'key_format! with parameter' do 526 | result = jbuild do |json| 527 | json.key_format! camelize: [:lower] 528 | json.camel_style 'for JS' 529 | end 530 | 531 | assert_equal ['camelStyle'], result.keys 532 | end 533 | 534 | test 'key_format! with parameter not as an array' do 535 | result = jbuild do |json| 536 | json.key_format! :camelize => :lower 537 | json.camel_style 'for JS' 538 | end 539 | 540 | assert_equal ['camelStyle'], result.keys 541 | end 542 | 543 | test 'key_format! propagates to child elements' do 544 | result = jbuild do |json| 545 | json.key_format! :upcase 546 | json.level1 'one' 547 | json.level2 do 548 | json.value 'two' 549 | end 550 | end 551 | 552 | assert_equal 'one', result['LEVEL1'] 553 | assert_equal 'two', result['LEVEL2']['VALUE'] 554 | end 555 | 556 | test 'key_format! resets after child element' do 557 | result = jbuild do |json| 558 | json.level2 do 559 | json.key_format! :upcase 560 | json.value 'two' 561 | end 562 | json.level1 'one' 563 | end 564 | 565 | assert_equal 'two', result['level2']['VALUE'] 566 | assert_equal 'one', result['level1'] 567 | end 568 | 569 | test 'key_format! can be changed in child elements' do 570 | result = jbuild do |json| 571 | json.key_format! camelize: :lower 572 | 573 | json.level_one do 574 | json.key_format! :upcase 575 | json.value 'two' 576 | end 577 | end 578 | 579 | assert_equal ['levelOne'], result.keys 580 | assert_equal ['VALUE'], result['levelOne'].keys 581 | end 582 | 583 | test 'key_format! can be changed in array!' do 584 | result = jbuild do |json| 585 | json.key_format! camelize: :lower 586 | 587 | json.level_one do 588 | json.array! [{value: 'two'}] do |object| 589 | json.key_format! :upcase 590 | json.value object[:value] 591 | end 592 | end 593 | end 594 | 595 | assert_equal ['levelOne'], result.keys 596 | assert_equal ['VALUE'], result['levelOne'][0].keys 597 | end 598 | 599 | test 'key_format! with no parameter' do 600 | result = jbuild do |json| 601 | json.key_format! :upcase 602 | json.lower 'Value' 603 | end 604 | 605 | assert_equal ['LOWER'], result.keys 606 | end 607 | 608 | test 'key_format! with multiple steps' do 609 | result = jbuild do |json| 610 | json.key_format! :upcase, :pluralize 611 | json.pill 'foo' 612 | end 613 | 614 | assert_equal ['PILLs'], result.keys 615 | end 616 | 617 | test 'key_format! with lambda/proc' do 618 | result = jbuild do |json| 619 | json.key_format! ->(key){ key + ' and friends' } 620 | json.oats 'foo' 621 | end 622 | 623 | assert_equal ['oats and friends'], result.keys 624 | end 625 | 626 | test 'key_format! is not applied deeply by default' do 627 | names = { first_name: 'camel', last_name: 'case' } 628 | result = jbuild do |json| 629 | json.key_format! camelize: :lower 630 | json.set! :all_names, names 631 | end 632 | 633 | assert_equal %i[first_name last_name], result['allNames'].keys 634 | end 635 | 636 | test 'applying key_format! deeply can be enabled per scope' do 637 | names = { first_name: 'camel', last_name: 'case' } 638 | result = jbuild do |json| 639 | json.key_format! camelize: :lower 640 | json.scope do 641 | json.deep_format_keys! 642 | json.set! :all_names, names 643 | end 644 | json.set! :all_names, names 645 | end 646 | 647 | assert_equal %w[firstName lastName], result['scope']['allNames'].keys 648 | assert_equal %i[first_name last_name], result['allNames'].keys 649 | end 650 | 651 | test 'applying key_format! deeply can be disabled per scope' do 652 | names = { first_name: 'camel', last_name: 'case' } 653 | result = jbuild do |json| 654 | json.key_format! camelize: :lower 655 | json.deep_format_keys! 656 | json.set! :all_names, names 657 | json.scope do 658 | json.deep_format_keys! false 659 | json.set! :all_names, names 660 | end 661 | end 662 | 663 | assert_equal %w[firstName lastName], result['allNames'].keys 664 | assert_equal %i[first_name last_name], result['scope']['allNames'].keys 665 | end 666 | 667 | test 'applying key_format! deeply can be enabled globally' do 668 | names = { first_name: 'camel', last_name: 'case' } 669 | 670 | Jbuilder.deep_format_keys true 671 | result = jbuild do |json| 672 | json.key_format! camelize: :lower 673 | json.set! :all_names, names 674 | end 675 | 676 | assert_equal %w[firstName lastName], result['allNames'].keys 677 | Jbuilder.send(:class_variable_set, '@@deep_format_keys', false) 678 | end 679 | 680 | test 'deep key_format! with merge!' do 681 | hash = { camel_style: 'for JS' } 682 | result = jbuild do |json| 683 | json.key_format! camelize: :lower 684 | json.deep_format_keys! 685 | json.merge! hash 686 | end 687 | 688 | assert_equal ['camelStyle'], result.keys 689 | end 690 | 691 | test 'deep key_format! with merge! deep' do 692 | hash = { camel_style: { sub_attr: 'for JS' } } 693 | result = jbuild do |json| 694 | json.key_format! camelize: :lower 695 | json.deep_format_keys! 696 | json.merge! hash 697 | end 698 | 699 | assert_equal ['subAttr'], result['camelStyle'].keys 700 | end 701 | 702 | test 'deep key_format! with set! array of hashes' do 703 | names = [{ first_name: 'camel', last_name: 'case' }] 704 | result = jbuild do |json| 705 | json.key_format! camelize: :lower 706 | json.deep_format_keys! 707 | json.set! :names, names 708 | end 709 | 710 | assert_equal %w[firstName lastName], result['names'][0].keys 711 | end 712 | 713 | test 'deep key_format! with set! extracting hash from object' do 714 | comment = Struct.new(:author).new({ first_name: 'camel', last_name: 'case' }) 715 | result = jbuild do |json| 716 | json.key_format! camelize: :lower 717 | json.deep_format_keys! 718 | json.set! :comment, comment, :author 719 | end 720 | 721 | assert_equal %w[firstName lastName], result['comment']['author'].keys 722 | end 723 | 724 | test 'deep key_format! with array! of hashes' do 725 | names = [{ first_name: 'camel', last_name: 'case' }] 726 | result = jbuild do |json| 727 | json.key_format! camelize: :lower 728 | json.deep_format_keys! 729 | json.array! names 730 | end 731 | 732 | assert_equal %w[firstName lastName], result[0].keys 733 | end 734 | 735 | test 'deep key_format! with merge! array of hashes' do 736 | names = [{ first_name: 'camel', last_name: 'case' }] 737 | new_names = [{ first_name: 'snake', last_name: 'case' }] 738 | result = jbuild do |json| 739 | json.key_format! camelize: :lower 740 | json.deep_format_keys! 741 | json.array! names 742 | json.merge! new_names 743 | end 744 | 745 | assert_equal %w[firstName lastName], result[1].keys 746 | end 747 | 748 | test 'deep key_format! is applied to hash extracted from object' do 749 | comment = Struct.new(:author).new({ first_name: 'camel', last_name: 'case' }) 750 | result = jbuild do |json| 751 | json.key_format! camelize: :lower 752 | json.deep_format_keys! 753 | json.extract! comment, :author 754 | end 755 | 756 | assert_equal %w[firstName lastName], result['author'].keys 757 | end 758 | 759 | test 'deep key_format! is applied to hash extracted from hash' do 760 | comment = {author: { first_name: 'camel', last_name: 'case' }} 761 | result = jbuild do |json| 762 | json.key_format! camelize: :lower 763 | json.deep_format_keys! 764 | json.extract! comment, :author 765 | end 766 | 767 | assert_equal %w[firstName lastName], result['author'].keys 768 | end 769 | 770 | test 'deep key_format! is applied to hash extracted directly from array' do 771 | comments = [Struct.new(:author).new({ first_name: 'camel', last_name: 'case' })] 772 | result = jbuild do |json| 773 | json.key_format! camelize: :lower 774 | json.deep_format_keys! 775 | json.array! comments, :author 776 | end 777 | 778 | assert_equal %w[firstName lastName], result[0]['author'].keys 779 | end 780 | 781 | test 'default key_format!' do 782 | Jbuilder.key_format camelize: :lower 783 | result = jbuild{ |json| json.camel_style 'for JS' } 784 | assert_equal ['camelStyle'], result.keys 785 | end 786 | 787 | test 'do not use default key formatter directly' do 788 | Jbuilder.key_format 789 | jbuild{ |json| json.key 'value' } 790 | formatter = Jbuilder.send(:class_variable_get, '@@key_formatter') 791 | cache = formatter.instance_variable_get('@cache') 792 | assert_empty cache 793 | end 794 | 795 | test 'ignore_nil! without a parameter' do 796 | result = jbuild do |json| 797 | json.ignore_nil! 798 | json.test nil 799 | end 800 | 801 | assert_empty result.keys 802 | end 803 | 804 | test 'ignore_nil! with parameter' do 805 | result = jbuild do |json| 806 | json.ignore_nil! true 807 | json.name 'Bob' 808 | json.dne nil 809 | end 810 | 811 | assert_equal ['name'], result.keys 812 | 813 | result = jbuild do |json| 814 | json.ignore_nil! false 815 | json.name 'Bob' 816 | json.dne nil 817 | end 818 | 819 | assert_equal ['name', 'dne'], result.keys 820 | end 821 | 822 | test 'default ignore_nil!' do 823 | Jbuilder.ignore_nil 824 | 825 | result = jbuild do |json| 826 | json.name 'Bob' 827 | json.dne nil 828 | end 829 | 830 | assert_equal ['name'], result.keys 831 | Jbuilder.send(:class_variable_set, '@@ignore_nil', false) 832 | end 833 | 834 | test 'nil!' do 835 | result = jbuild do |json| 836 | json.key 'value' 837 | json.nil! 838 | end 839 | 840 | assert_nil result 841 | end 842 | 843 | test 'null!' do 844 | result = jbuild do |json| 845 | json.key 'value' 846 | json.null! 847 | end 848 | 849 | assert_nil result 850 | end 851 | 852 | test 'null! in a block' do 853 | result = jbuild do |json| 854 | json.author do 855 | json.name 'David' 856 | end 857 | 858 | json.author do 859 | json.null! 860 | end 861 | end 862 | 863 | assert result.key?('author') 864 | assert_nil result['author'] 865 | end 866 | 867 | test 'empty attributes respond to empty?' do 868 | attributes = Jbuilder.new.attributes! 869 | assert attributes.empty? 870 | assert attributes.blank? 871 | assert !attributes.present? 872 | end 873 | 874 | test 'throws ArrayError when trying to add a key to an array' do 875 | assert_raise Jbuilder::ArrayError do 876 | jbuild do |json| 877 | json.array! %w[foo bar] 878 | json.fizz "buzz" 879 | end 880 | end 881 | end 882 | 883 | test 'throws NullError when trying to add properties to null' do 884 | assert_raise Jbuilder::NullError do 885 | jbuild do |json| 886 | json.null! 887 | json.foo 'bar' 888 | end 889 | end 890 | end 891 | 892 | test 'throws NullError when trying to add properties to null using block syntax' do 893 | assert_raise Jbuilder::NullError do 894 | jbuild do |json| 895 | json.author do 896 | json.null! 897 | end 898 | 899 | json.author do 900 | json.name "Pavel" 901 | end 902 | end 903 | end 904 | end 905 | 906 | test "throws MergeError when trying to merge array with non-empty hash" do 907 | assert_raise Jbuilder::MergeError do 908 | jbuild do |json| 909 | json.name "Daniel" 910 | json.merge! [] 911 | end 912 | end 913 | end 914 | 915 | test "throws MergeError when trying to merge hash with array" do 916 | assert_raise Jbuilder::MergeError do 917 | jbuild do |json| 918 | json.array! 919 | json.merge!({}) 920 | end 921 | end 922 | end 923 | 924 | test "throws MergeError when trying to merge invalid objects" do 925 | assert_raise Jbuilder::MergeError do 926 | jbuild do |json| 927 | json.name "Daniel" 928 | json.merge! "Nope" 929 | end 930 | end 931 | end 932 | 933 | if RUBY_VERSION >= "2.2.10" 934 | test "respects JSON encoding customizations" do 935 | # Active Support overrides Time#as_json for custom formatting. 936 | # Ensure we call #to_json on the final attributes instead of JSON.dump. 937 | result = JSON.load(Jbuilder.encode { |json| json.time Time.parse("2018-05-13 11:51:00.485 -0400") }) 938 | assert_equal "2018-05-13T11:51:00.485-04:00", result["time"] 939 | end 940 | end 941 | end 942 | -------------------------------------------------------------------------------- /test/scaffold_api_controller_generator_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails/generators/test_case' 3 | require 'generators/rails/scaffold_controller_generator' 4 | 5 | if Rails::VERSION::MAJOR > 4 6 | 7 | class ScaffoldApiControllerGeneratorTest < Rails::Generators::TestCase 8 | tests Rails::Generators::ScaffoldControllerGenerator 9 | arguments %w(Post title body:text images:attachments --api) 10 | destination File.expand_path('../tmp', __FILE__) 11 | setup :prepare_destination 12 | 13 | test 'controller content' do 14 | run_generator 15 | 16 | assert_file 'app/controllers/posts_controller.rb' do |content| 17 | assert_instance_method :index, content do |m| 18 | assert_match %r{@posts = Post\.all}, m 19 | end 20 | 21 | assert_instance_method :show, content do |m| 22 | assert m.blank? 23 | end 24 | 25 | assert_instance_method :create, content do |m| 26 | assert_match %r{@post = Post\.new\(post_params\)}, m 27 | assert_match %r{@post\.save}, m 28 | assert_match %r{render :show, status: :created, location: @post}, m 29 | assert_match %r{render json: @post\.errors, status: :unprocessable_entity}, m 30 | end 31 | 32 | assert_instance_method :update, content do |m| 33 | assert_match %r{render :show, status: :ok, location: @post}, m 34 | assert_match %r{render json: @post.errors, status: :unprocessable_entity}, m 35 | end 36 | 37 | assert_instance_method :destroy, content do |m| 38 | assert_match %r{@post\.destroy}, m 39 | end 40 | 41 | assert_match %r{def set_post}, content 42 | if Rails::VERSION::MAJOR >= 8 43 | assert_match %r{params\.expect\(:id\)}, content 44 | else 45 | assert_match %r{params\[:id\]}, content 46 | end 47 | 48 | assert_match %r{def post_params}, content 49 | if Rails::VERSION::MAJOR >= 8 50 | assert_match %r{params\.expect\(post: \[ :title, :body, images: \[\] \]\)}, content 51 | elsif Rails::VERSION::MAJOR >= 6 52 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, images: \[\]\)}, content 53 | else 54 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, :images\)}, content 55 | end 56 | end 57 | end 58 | 59 | test "don't use require and permit if there are no attributes" do 60 | run_generator %w(Post --api) 61 | 62 | assert_file 'app/controllers/posts_controller.rb' do |content| 63 | assert_match %r{def post_params}, content 64 | assert_match %r{params\.fetch\(:post, \{\}\)}, content 65 | end 66 | end 67 | 68 | 69 | if Rails::VERSION::MAJOR >= 6 70 | test 'handles virtual attributes' do 71 | run_generator ["Message", "content:rich_text", "video:attachment", "photos:attachments"] 72 | 73 | assert_file 'app/controllers/messages_controller.rb' do |content| 74 | if Rails::VERSION::MAJOR >= 8 75 | assert_match %r{params\.expect\(message: \[ :content, :video, photos: \[\] \]\)}, content 76 | else 77 | assert_match %r{params\.require\(:message\)\.permit\(:content, :video, photos: \[\]\)}, content 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/scaffold_controller_generator_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails/generators/test_case' 3 | require 'generators/rails/scaffold_controller_generator' 4 | 5 | class ScaffoldControllerGeneratorTest < Rails::Generators::TestCase 6 | tests Rails::Generators::ScaffoldControllerGenerator 7 | arguments %w(Post title body:text images:attachments) 8 | destination File.expand_path('../tmp', __FILE__) 9 | setup :prepare_destination 10 | 11 | test 'controller content' do 12 | run_generator 13 | 14 | assert_file 'app/controllers/posts_controller.rb' do |content| 15 | assert_instance_method :index, content do |m| 16 | assert_match %r{@posts = Post\.all}, m 17 | end 18 | 19 | assert_instance_method :show, content do |m| 20 | assert m.blank? 21 | end 22 | 23 | assert_instance_method :new, content do |m| 24 | assert_match %r{@post = Post\.new}, m 25 | end 26 | 27 | assert_instance_method :edit, content do |m| 28 | assert m.blank? 29 | end 30 | 31 | assert_instance_method :create, content do |m| 32 | assert_match %r{@post = Post\.new\(post_params\)}, m 33 | assert_match %r{@post\.save}, m 34 | assert_match %r{format\.html \{ redirect_to @post, notice: "Post was successfully created\." \}}, m 35 | assert_match %r{format\.json \{ render :show, status: :created, location: @post \}}, m 36 | assert_match %r{format\.html \{ render :new, status: :unprocessable_entity \}}, m 37 | assert_match %r{format\.json \{ render json: @post\.errors, status: :unprocessable_entity \}}, m 38 | end 39 | 40 | assert_instance_method :update, content do |m| 41 | assert_match %r{format\.html \{ redirect_to @post, notice: "Post was successfully updated\.", status: :see_other \}}, m 42 | assert_match %r{format\.json \{ render :show, status: :ok, location: @post \}}, m 43 | assert_match %r{format\.html \{ render :edit, status: :unprocessable_entity \}}, m 44 | assert_match %r{format\.json \{ render json: @post.errors, status: :unprocessable_entity \}}, m 45 | end 46 | 47 | assert_instance_method :destroy, content do |m| 48 | assert_match %r{@post\.destroy}, m 49 | assert_match %r{format\.html \{ redirect_to posts_path, notice: "Post was successfully destroyed\.", status: :see_other \}}, m 50 | assert_match %r{format\.json \{ head :no_content \}}, m 51 | end 52 | 53 | assert_match %r{def set_post}, content 54 | if Rails::VERSION::MAJOR >= 8 55 | assert_match %r{params\.expect\(:id\)}, content 56 | else 57 | assert_match %r{params\[:id\]}, content 58 | end 59 | 60 | assert_match %r{def post_params}, content 61 | if Rails::VERSION::MAJOR >= 8 62 | assert_match %r{params\.expect\(post: \[ :title, :body, images: \[\] \]\)}, content 63 | elsif Rails::VERSION::MAJOR >= 6 64 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, images: \[\]\)}, content 65 | else 66 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, :images\)}, content 67 | end 68 | end 69 | end 70 | 71 | if Rails::VERSION::MAJOR >= 6 72 | test 'controller with namespace' do 73 | run_generator %w(Admin::Post --model-name=Post) 74 | assert_file 'app/controllers/admin/posts_controller.rb' do |content| 75 | assert_instance_method :create, content do |m| 76 | assert_match %r{format\.html \{ redirect_to \[:admin, @post\], notice: "Post was successfully created\." \}}, m 77 | end 78 | 79 | assert_instance_method :update, content do |m| 80 | assert_match %r{format\.html \{ redirect_to \[:admin, @post\], notice: "Post was successfully updated\.", status: :see_other \}}, m 81 | end 82 | 83 | assert_instance_method :destroy, content do |m| 84 | assert_match %r{format\.html \{ redirect_to admin_posts_path, notice: "Post was successfully destroyed\.", status: :see_other \}}, m 85 | end 86 | end 87 | end 88 | end 89 | 90 | test "don't use require and permit if there are no attributes" do 91 | run_generator %w(Post) 92 | 93 | assert_file 'app/controllers/posts_controller.rb' do |content| 94 | assert_match %r{def post_params}, content 95 | assert_match %r{params\.fetch\(:post, \{\}\)}, content 96 | end 97 | end 98 | 99 | if Rails::VERSION::MAJOR >= 6 100 | test 'handles virtual attributes' do 101 | run_generator %w(Message content:rich_text video:attachment photos:attachments) 102 | 103 | assert_file 'app/controllers/messages_controller.rb' do |content| 104 | if Rails::VERSION::MAJOR >= 8 105 | assert_match %r{params\.expect\(message: \[ :content, :video, photos: \[\] \]\)}, content 106 | else 107 | assert_match %r{params\.require\(:message\)\.permit\(:content, :video, photos: \[\]\)}, content 108 | end 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | require "rails" 4 | 5 | require "jbuilder" 6 | 7 | require "active_support/core_ext/array/access" 8 | require "active_support/cache/memory_store" 9 | require "active_support/json" 10 | require "active_model" 11 | require 'action_controller/railtie' 12 | require 'action_view/railtie' 13 | 14 | require "active_support/testing/autorun" 15 | require "mocha/minitest" 16 | 17 | ActiveSupport.test_order = :random 18 | 19 | ENV["RAILS_ENV"] ||= "test" 20 | 21 | class << Rails 22 | def cache 23 | @cache ||= ActiveSupport::Cache::MemoryStore.new 24 | end 25 | end 26 | 27 | Jbuilder::CollectionRenderer.collection_cache = Rails.cache 28 | 29 | class Post < Struct.new(:id, :body, :author_name) 30 | def cache_key 31 | "post-#{id}" 32 | end 33 | end 34 | 35 | class Racer < Struct.new(:id, :name) 36 | extend ActiveModel::Naming 37 | include ActiveModel::Conversion 38 | end 39 | 40 | # Instantiate an Application in order to trigger the initializers 41 | Class.new(Rails::Application) do 42 | config.secret_key_base = 'secret' 43 | config.eager_load = false 44 | end.initialize! 45 | 46 | # Touch AV::Base in order to trigger :action_view on_load hook before running the tests 47 | ActionView::Base.inspect 48 | --------------------------------------------------------------------------------