├── lib ├── jbuilder │ ├── jbuilder.rb │ ├── version.rb │ ├── blank.rb │ ├── errors.rb │ ├── key_formatter.rb │ ├── railtie.rb │ ├── collection_renderer.rb │ ├── jbuilder_dependency_tracker.rb │ └── jbuilder_template.rb ├── generators │ └── rails │ │ ├── templates │ │ ├── show.json.jbuilder │ │ ├── index.json.jbuilder │ │ ├── partial.json.jbuilder │ │ ├── api_controller.rb │ │ └── controller.rb │ │ ├── scaffold_controller_generator.rb │ │ └── jbuilder_generator.rb └── jbuilder.rb ├── bin ├── test └── release ├── .gitignore ├── Gemfile ├── gemfiles ├── rails_7_1.gemfile ├── rails_7_2.gemfile ├── rails_8_0.gemfile ├── rails_head.gemfile └── rails_7_0.gemfile ├── Rakefile ├── Appraisals ├── .devcontainer └── devcontainer.json ├── MIT-LICENSE ├── test ├── test_helper.rb ├── jbuilder_dependency_tracker_test.rb ├── jbuilder_generator_test.rb ├── scaffold_api_controller_generator_test.rb ├── scaffold_controller_generator_test.rb ├── jbuilder_template_test.rb └── jbuilder_test.rb ├── jbuilder.gemspec ├── .github └── workflows │ └── ruby.yml ├── CONTRIBUTING.md └── README.md /lib/jbuilder/jbuilder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jbuilder/version' 4 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | bundle install 5 | appraisal install 6 | appraisal rake test 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | /log 3 | gemfiles/.bundle 4 | gemfiles/*.lock 5 | Gemfile.lock 6 | .ruby-version 7 | pkg 8 | *.gem 9 | -------------------------------------------------------------------------------- /lib/jbuilder/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Jbuilder < BasicObject 4 | VERSION = "2.14.1" 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/show.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.partial! "<%= partial_path_name %>", <%= singular_table_name %>: @<%= singular_table_name %> 2 | -------------------------------------------------------------------------------- /lib/generators/rails/templates/index.json.jbuilder: -------------------------------------------------------------------------------- 1 | json.array! @<%= plural_table_name %>, partial: "<%= partial_path_name %>", as: :<%= singular_table_name %> 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | gem "mocha", require: false 9 | gem "appraisal" 10 | -------------------------------------------------------------------------------- /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_7_2.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.2.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 | -------------------------------------------------------------------------------- /lib/jbuilder/blank.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Jbuilder 4 | class Blank 5 | def ==(other) 6 | super || Blank === other 7 | end 8 | 9 | def empty? 10 | true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "bundler/gem_tasks" 5 | require "rake/testtask" 6 | 7 | if !ENV["APPRAISAL_INITIALIZED"] && !ENV["CI"] 8 | require "appraisal/task" 9 | Appraisal::Task.new 10 | task default: :appraisal 11 | else 12 | Rake::TestTask.new do |test| 13 | require "rails/version" 14 | 15 | test.libs << "test" 16 | 17 | test.test_files = FileList["test/*_test.rb"] 18 | end 19 | 20 | task default: :test 21 | end 22 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "rails-7-0" do 4 | gem "rails", "~> 7.0.0" 5 | gem "concurrent-ruby", "< 1.3.5" # to avoid problem described in https://github.com/rails/rails/pull/54264 6 | end 7 | 8 | appraise "rails-7-1" do 9 | gem "rails", "~> 7.1.0" 10 | end 11 | 12 | appraise "rails-7-2" do 13 | gem "rails", "~> 7.2.0" 14 | end 15 | 16 | appraise "rails-8-0" do 17 | gem "rails", "~> 8.0.0" 18 | end 19 | 20 | appraise "rails-head" do 21 | gem "rails", github: "rails/rails", branch: "main" 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/rails/scaffold_controller_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators' 4 | require 'rails/generators/rails/scaffold_controller/scaffold_controller_generator' 5 | 6 | module Rails 7 | module Generators 8 | class ScaffoldControllerGenerator 9 | source_paths << File.expand_path('../templates', __FILE__) 10 | 11 | hook_for :jbuilder, type: :boolean, default: true 12 | 13 | private 14 | 15 | def permitted_params 16 | attributes_names.map { |name| ":#{name}" }.join(', ') 17 | end unless private_method_defined? :permitted_params 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/jbuilder/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jbuilder/version' 4 | 5 | class Jbuilder 6 | class NullError < ::NoMethodError 7 | def self.build(key) 8 | message = "Failed to add #{key.to_s.inspect} property to null object" 9 | new(message) 10 | end 11 | end 12 | 13 | class ArrayError < ::StandardError 14 | def self.build(key) 15 | message = "Failed to add #{key.to_s.inspect} property to an array" 16 | new(message) 17 | end 18 | end 19 | 20 | class MergeError < ::StandardError 21 | def self.build(current_value, updates) 22 | message = "Can't merge #{updates.inspect} into #{current_value.inspect}" 23 | new(message) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /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/jbuilder/key_formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jbuilder/jbuilder' 4 | 5 | class Jbuilder 6 | class KeyFormatter 7 | def initialize(*formats, **formats_with_options) 8 | @mutex = Mutex.new 9 | @formats = formats 10 | @formats_with_options = formats_with_options 11 | @cache = {} 12 | end 13 | 14 | def format(key) 15 | @mutex.synchronize do 16 | @cache[key] ||= begin 17 | value = key.is_a?(Symbol) ? key.name : key.to_s 18 | 19 | @formats.each do |func| 20 | value = func.is_a?(Proc) ? func.call(value) : value.send(func) 21 | end 22 | 23 | @formats_with_options.each do |func, params| 24 | value = func.is_a?(Proc) ? func.call(value, *params) : value.send(func, *params) 25 | end 26 | 27 | value 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/ruby 3 | { 4 | "name": "jbuilder", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "ghcr.io/rails/devcontainer/images/ruby:3.4.5", 7 | "features": { 8 | "ghcr.io/devcontainers/features/github-cli:1": {} 9 | } 10 | 11 | // Features to add to the dev container. More info: https://containers.dev/features. 12 | // "features": {}, 13 | 14 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 15 | // "forwardPorts": [], 16 | 17 | // Use 'postCreateCommand' to run commands after the container is created. 18 | // "postCreateCommand": "ruby --version", 19 | 20 | // Configure tool-specific properties. 21 | // "customizations": {}, 22 | 23 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 24 | // "remoteUser": "root" 25 | } 26 | -------------------------------------------------------------------------------- /lib/jbuilder/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails' 4 | require 'jbuilder/jbuilder_template' 5 | 6 | class Jbuilder 7 | class Railtie < ::Rails::Railtie 8 | initializer :jbuilder do 9 | ActiveSupport.on_load :action_view do 10 | ActionView::Template.register_template_handler :jbuilder, JbuilderHandler 11 | require 'jbuilder/jbuilder_dependency_tracker' 12 | end 13 | 14 | module ::ActionController 15 | module ApiRendering 16 | include ActionView::Rendering 17 | end 18 | end 19 | 20 | ActiveSupport.on_load :action_controller_api do 21 | include ActionController::Helpers 22 | include ActionController::ImplicitRender 23 | helper_method :combined_fragment_cache_key 24 | helper_method :view_cache_dependencies 25 | end 26 | end 27 | 28 | generators do |app| 29 | Rails::Generators.configure! app.config.generators 30 | Rails::Generators.hidden_namespaces.uniq! 31 | require 'generators/rails/scaffold_controller_generator' 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | redefine_method :cache do 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 | -------------------------------------------------------------------------------- /jbuilder.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/jbuilder/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'jbuilder' 7 | s.version = Jbuilder::VERSION 8 | s.authors = 'David Heinemeier Hansson' 9 | s.email = 'david@basecamp.com' 10 | s.summary = 'Create JSON structures via a Builder-style DSL' 11 | s.homepage = 'https://github.com/rails/jbuilder' 12 | s.license = 'MIT' 13 | 14 | s.required_ruby_version = '>= 3.0.0' 15 | 16 | s.add_dependency 'activesupport', '>= 7.0.0' 17 | s.add_dependency 'actionview', '>= 7.0.0' 18 | 19 | if RUBY_ENGINE == 'rbx' 20 | s.add_development_dependency('racc') 21 | s.add_development_dependency('json') 22 | s.add_development_dependency('rubysl') 23 | end 24 | 25 | s.files = `git ls-files`.split("\n") 26 | s.test_files = `git ls-files -- test/*`.split("\n") 27 | 28 | s.metadata = { 29 | "bug_tracker_uri" => "https://github.com/rails/jbuilder/issues", 30 | "changelog_uri" => "https://github.com/rails/jbuilder/releases/tag/v#{s.version}", 31 | "mailing_list_uri" => "https://discuss.rubyonrails.org/c/rubyonrails-talk", 32 | "source_code_uri" => "https://github.com/rails/jbuilder/tree/v#{s.version}", 33 | "rubygems_mfa_required" => "true", 34 | } 35 | end 36 | -------------------------------------------------------------------------------- /.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_7_2" 28 | - "rails_8_0" 29 | - "rails_head" 30 | 31 | exclude: 32 | - ruby: '3.0' 33 | gemfile: rails_7_2 34 | - ruby: '3.0' 35 | gemfile: rails_8_0 36 | - ruby: '3.0' 37 | gemfile: rails_head 38 | - ruby: '3.1' 39 | gemfile: rails_7_2 40 | - ruby: '3.1' 41 | gemfile: rails_8_0 42 | - ruby: '3.1' 43 | gemfile: rails_head 44 | - ruby: '3.4' 45 | gemfile: rails_7_0 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: ${{ matrix.ruby }} 53 | bundler-cache: true 54 | 55 | - name: Ruby test 56 | run: bundle exec rake 57 | -------------------------------------------------------------------------------- /lib/jbuilder/collection_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'delegate' 4 | require 'action_view' 5 | require 'action_view/renderer/collection_renderer' 6 | 7 | class Jbuilder 8 | class CollectionRenderer < ::ActionView::CollectionRenderer # :nodoc: 9 | class ScopedIterator < ::SimpleDelegator # :nodoc: 10 | include Enumerable 11 | 12 | def initialize(obj, scope) 13 | super(obj) 14 | @scope = scope 15 | end 16 | 17 | def each_with_info 18 | return enum_for(:each_with_info) unless block_given? 19 | 20 | __getobj__.each_with_info do |object, info| 21 | @scope.call { yield(object, info) } 22 | end 23 | end 24 | end 25 | 26 | private_constant :ScopedIterator 27 | 28 | def initialize(lookup_context, options, &scope) 29 | super(lookup_context, options) 30 | @scope = scope 31 | end 32 | 33 | private 34 | 35 | def build_rendered_template(content, template, layout = nil) 36 | super(content || json.attributes!, template) 37 | end 38 | 39 | def build_rendered_collection(templates, _spacer) 40 | json.merge!(templates.map(&:body)) 41 | end 42 | 43 | def json 44 | @options[:locals].fetch(:json) 45 | end 46 | 47 | def collection_with_template(view, template, layout, collection) 48 | super(view, template, layout, ScopedIterator.new(collection, @scope)) 49 | end 50 | end 51 | 52 | class EnumerableCompat < ::SimpleDelegator 53 | # Rails 6.1 requires this. 54 | def size(*args, &block) 55 | __getobj__.count(*args, &block) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/generators/rails/jbuilder_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/named_base' 4 | require 'rails/generators/resource_helpers' 5 | 6 | module Rails 7 | module Generators 8 | class JbuilderGenerator < NamedBase # :nodoc: 9 | include Rails::Generators::ResourceHelpers 10 | 11 | source_root File.expand_path('../templates', __FILE__) 12 | 13 | argument :attributes, type: :array, default: [], banner: 'field:type field:type' 14 | 15 | class_option :timestamps, type: :boolean, default: true 16 | 17 | def create_root_folder 18 | path = File.join('app/views', controller_file_path) 19 | empty_directory path unless File.directory?(path) 20 | end 21 | 22 | def copy_view_files 23 | %w(index show).each do |view| 24 | filename = filename_with_extensions(view) 25 | template filename, File.join('app/views', controller_file_path, filename) 26 | end 27 | template filename_with_extensions('partial'), File.join('app/views', controller_file_path, filename_with_extensions("_#{singular_table_name}")) 28 | end 29 | 30 | 31 | protected 32 | def attributes_names 33 | [:id] + super 34 | end 35 | 36 | def filename_with_extensions(name) 37 | [name, :json, :jbuilder] * '.' 38 | end 39 | 40 | def full_attributes_list 41 | if options[:timestamps] 42 | attributes_list(attributes_names + %w(created_at updated_at)) 43 | else 44 | attributes_list(attributes_names) 45 | end 46 | end 47 | 48 | def attributes_list(attributes = attributes_names) 49 | if self.attributes.any? {|attr| attr.name == 'password' && attr.type == :digest} 50 | attributes = attributes.reject {|name| %w(password password_confirmation).include? name} 51 | end 52 | 53 | attributes.map { |a| ":#{a}"} * ', ' 54 | end 55 | 56 | def virtual_attributes 57 | attributes.select {|name| name.respond_to?(:virtual?) && name.virtual? } 58 | end 59 | 60 | def partial_path_name 61 | [controller_file_path, singular_table_name].join("/") 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/jbuilder/jbuilder_dependency_tracker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Jbuilder::DependencyTracker 4 | EXPLICIT_DEPENDENCY = /# Template Dependency: (\S+)/ 5 | 6 | # Matches: 7 | # json.partial! "messages/message" 8 | # json.partial!('messages/message') 9 | # 10 | DIRECT_RENDERS = / 11 | \w+\.partial! # json.partial! 12 | \(?\s* # optional parenthesis 13 | (['"])([^'"]+)\1 # quoted value 14 | /x 15 | 16 | # Matches: 17 | # json.partial! partial: "comments/comment" 18 | # json.comments @post.comments, partial: "comments/comment", as: :comment 19 | # json.array! @posts, partial: "posts/post", as: :post 20 | # = render partial: "account" 21 | # 22 | INDIRECT_RENDERS = / 23 | (?::partial\s*=>|partial:) # partial: or :partial => 24 | \s* # optional whitespace 25 | (['"])([^'"]+)\1 # quoted value 26 | /x 27 | 28 | def self.call(name, template, view_paths = nil) 29 | new(name, template, view_paths).dependencies 30 | end 31 | 32 | def initialize(name, template, view_paths = nil) 33 | @name, @template, @view_paths = name, template, view_paths 34 | end 35 | 36 | def dependencies 37 | direct_dependencies + indirect_dependencies + explicit_dependencies 38 | end 39 | 40 | private 41 | 42 | attr_reader :name, :template 43 | 44 | def direct_dependencies 45 | source.scan(DIRECT_RENDERS).map(&:second) 46 | end 47 | 48 | def indirect_dependencies 49 | source.scan(INDIRECT_RENDERS).map(&:second) 50 | end 51 | 52 | def explicit_dependencies 53 | dependencies = source.scan(EXPLICIT_DEPENDENCY).flatten.uniq 54 | 55 | wildcards, explicits = dependencies.partition { |dependency| dependency.end_with?("/*") } 56 | 57 | (explicits + resolve_directories(wildcards)).uniq 58 | end 59 | 60 | def resolve_directories(wildcard_dependencies) 61 | return [] unless @view_paths 62 | return [] if wildcard_dependencies.empty? 63 | 64 | # Remove trailing "/*" 65 | prefixes = wildcard_dependencies.map { |query| query[0..-3] } 66 | 67 | @view_paths.flat_map(&:all_template_paths).uniq.filter_map { |path| 68 | path.to_s if prefixes.include?(path.prefix) 69 | }.sort 70 | end 71 | 72 | def source 73 | template.source 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | test 'handles virtual attributes' do 60 | run_generator %w(Message content:rich_text video:attachment photos:attachments) 61 | 62 | assert_file 'app/views/messages/_message.json.jbuilder' do |content| 63 | assert_match %r{json\.content message\.content\.to_s}, content 64 | assert_match %r{json\.video url_for\(message\.video\)}, content 65 | 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 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /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 | class ScaffoldApiControllerGeneratorTest < Rails::Generators::TestCase 6 | tests Rails::Generators::ScaffoldControllerGenerator 7 | arguments %w(Post title body:text images:attachments --api --skip-routes) 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 :create, content do |m| 24 | assert_match %r{@post = Post\.new\(post_params\)}, m 25 | assert_match %r{@post\.save}, m 26 | assert_match %r{render :show, status: :created, location: @post}, m 27 | assert_match %r{render json: @post\.errors, status: :unprocessable_entity}, m 28 | end 29 | 30 | assert_instance_method :update, content do |m| 31 | assert_match %r{render :show, status: :ok, location: @post}, m 32 | assert_match %r{render json: @post.errors, status: :unprocessable_entity}, m 33 | end 34 | 35 | assert_instance_method :destroy, content do |m| 36 | assert_match %r{@post\.destroy}, m 37 | end 38 | 39 | assert_match %r{def set_post}, content 40 | if Rails::VERSION::MAJOR >= 8 41 | assert_match %r{params\.expect\(:id\)}, content 42 | else 43 | assert_match %r{params\[:id\]}, content 44 | end 45 | 46 | assert_match %r{def post_params}, content 47 | if Rails::VERSION::MAJOR >= 8 48 | assert_match %r{params\.expect\(post: \[ :title, :body, images: \[\] \]\)}, content 49 | else 50 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, images: \[\]\)}, content 51 | end 52 | end 53 | end 54 | 55 | test "don't use require and permit if there are no attributes" do 56 | run_generator %w(Post --api --skip-routes) 57 | 58 | assert_file 'app/controllers/posts_controller.rb' do |content| 59 | assert_match %r{def post_params}, content 60 | assert_match %r{params\.fetch\(:post, \{\}\)}, content 61 | end 62 | end 63 | 64 | test 'handles virtual attributes' do 65 | run_generator %w(Message content:rich_text video:attachment photos:attachments --skip-routes) 66 | 67 | assert_file 'app/controllers/messages_controller.rb' do |content| 68 | if Rails::VERSION::MAJOR >= 8 69 | assert_match %r{params\.expect\(message: \[ :content, :video, photos: \[\] \]\)}, content 70 | else 71 | assert_match %r{params\.require\(:message\)\.permit\(:content, :video, photos: \[\]\)}, content 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 --skip-routes) 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 | else 64 | assert_match %r{params\.require\(:post\)\.permit\(:title, :body, images: \[\]\)}, content 65 | end 66 | end 67 | end 68 | 69 | test 'controller with namespace' do 70 | run_generator %w(Admin::Post --model-name=Post --skip-routes) 71 | assert_file 'app/controllers/admin/posts_controller.rb' do |content| 72 | assert_instance_method :create, content do |m| 73 | assert_match %r{format\.html \{ redirect_to \[:admin, @post\], notice: "Post was successfully created\." \}}, m 74 | end 75 | 76 | assert_instance_method :update, content do |m| 77 | assert_match %r{format\.html \{ redirect_to \[:admin, @post\], notice: "Post was successfully updated\.", status: :see_other \}}, m 78 | end 79 | 80 | assert_instance_method :destroy, content do |m| 81 | assert_match %r{format\.html \{ redirect_to admin_posts_path, notice: "Post was successfully destroyed\.", status: :see_other \}}, m 82 | end 83 | end 84 | end 85 | 86 | test "don't use require and permit if there are no attributes" do 87 | run_generator %w(Post --skip-routes) 88 | 89 | assert_file 'app/controllers/posts_controller.rb' do |content| 90 | assert_match %r{def post_params}, content 91 | assert_match %r{params\.fetch\(:post, \{\}\)}, content 92 | end 93 | end 94 | 95 | test 'handles virtual attributes' do 96 | run_generator %w(Message content:rich_text video:attachment photos:attachments --skip-routes) 97 | 98 | assert_file 'app/controllers/messages_controller.rb' do |content| 99 | if Rails::VERSION::MAJOR >= 8 100 | assert_match %r{params\.expect\(message: \[ :content, :video, photos: \[\] \]\)}, content 101 | else 102 | assert_match %r{params\.require\(:message\)\.permit\(:content, :video, photos: \[\]\)}, content 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/jbuilder/jbuilder_template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jbuilder/jbuilder' 4 | require 'jbuilder/collection_renderer' 5 | require 'action_dispatch/http/mime_type' 6 | require 'active_support/cache' 7 | 8 | class JbuilderTemplate < Jbuilder 9 | class << self 10 | attr_accessor :template_lookup_options 11 | end 12 | 13 | self.template_lookup_options = { handlers: [:jbuilder] } 14 | 15 | def initialize(context, options = nil) 16 | @context = context 17 | @cached_root = nil 18 | 19 | options.nil? ? super() : super(**options) 20 | end 21 | 22 | # Generates JSON using the template specified with the `:partial` option. For example, the code below will render 23 | # the file `views/comments/_comments.json.jbuilder`, and set a local variable comments with all this message's 24 | # comments, which can be used inside the partial. 25 | # 26 | # Example: 27 | # 28 | # json.partial! 'comments/comments', comments: @message.comments 29 | # 30 | # There are multiple ways to generate a collection of elements as JSON, as ilustrated below: 31 | # 32 | # Example: 33 | # 34 | # json.array! @posts, partial: 'posts/post', as: :post 35 | # 36 | # # or: 37 | # json.partial! 'posts/post', collection: @posts, as: :post 38 | # 39 | # # or: 40 | # json.partial! partial: 'posts/post', collection: @posts, as: :post 41 | # 42 | # # or: 43 | # json.comments @post.comments, partial: 'comments/comment', as: :comment 44 | # 45 | # Aside from that, the `:cached` options is available on Rails >= 6.0. This will cache the rendered results 46 | # effectively using the multi fetch feature. 47 | # 48 | # Example: 49 | # 50 | # json.array! @posts, partial: "posts/post", as: :post, cached: true 51 | # 52 | # json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true 53 | # 54 | def partial!(*args) 55 | if args.one? && _is_active_model?(args.first) 56 | _render_active_model_partial args.first 57 | else 58 | options = args.extract_options!.dup 59 | options[:partial] = args.first if args.present? 60 | _render_partial_with_options options 61 | end 62 | end 63 | 64 | # Caches the json constructed within the block passed. Has the same signature as the `cache` helper 65 | # method in `ActionView::Helpers::CacheHelper` and so can be used in the same way. 66 | # 67 | # Example: 68 | # 69 | # json.cache! ['v1', @person], expires_in: 10.minutes do 70 | # json.extract! @person, :name, :age 71 | # end 72 | def cache!(key=nil, options={}) 73 | if @context.controller.perform_caching 74 | value = _cache_fragment_for(key, options) do 75 | _scope { yield self } 76 | end 77 | 78 | merge! value 79 | else 80 | yield 81 | end 82 | end 83 | 84 | # Caches the json structure at the root using a string rather than the hash structure. This is considerably 85 | # faster, but the drawback is that it only works, as the name hints, at the root. So you cannot 86 | # use this approach to cache deeper inside the hierarchy, like in partials or such. Continue to use #cache! there. 87 | # 88 | # Example: 89 | # 90 | # json.cache_root! @person do 91 | # json.extract! @person, :name, :age 92 | # end 93 | # 94 | # # json.extra 'This will not work either, the root must be exclusive' 95 | def cache_root!(key=nil, options={}) 96 | if @context.controller.perform_caching 97 | ::Kernel.raise "cache_root! can't be used after JSON structures have been defined" if @attributes.present? 98 | 99 | @cached_root = _cache_fragment_for([ :root, key ], options) { yield; target! } 100 | else 101 | yield 102 | end 103 | end 104 | 105 | # Conditionally caches the json depending in the condition given as first parameter. Has the same 106 | # signature as the `cache` helper method in `ActionView::Helpers::CacheHelper` and so can be used in 107 | # the same way. 108 | # 109 | # Example: 110 | # 111 | # json.cache_if! !admin?, @person, expires_in: 10.minutes do 112 | # json.extract! @person, :name, :age 113 | # end 114 | def cache_if!(condition, *args, &block) 115 | condition ? cache!(*args, &block) : yield 116 | end 117 | 118 | def target! 119 | @cached_root || super 120 | end 121 | 122 | def array!(collection = [], *args) 123 | options = args.first 124 | 125 | if args.one? && _partial_options?(options) 126 | options = options.dup 127 | options[:collection] = collection 128 | _render_partial_with_options options 129 | else 130 | super 131 | end 132 | end 133 | 134 | def set!(name, object = BLANK, *args) 135 | options = args.first 136 | 137 | if args.one? && _partial_options?(options) 138 | _set_inline_partial name, object, options.dup 139 | else 140 | super 141 | end 142 | end 143 | 144 | private 145 | 146 | alias_method :method_missing, :set! 147 | 148 | def _render_partial_with_options(options) 149 | options[:locals] ||= options.except(:partial, :as, :collection, :cached) 150 | options[:handlers] ||= ::JbuilderTemplate.template_lookup_options[:handlers] 151 | as = options[:as] 152 | 153 | if as && options.key?(:collection) 154 | collection = options.delete(:collection) || [] 155 | partial = options.delete(:partial) 156 | options[:locals][:json] = self 157 | collection = EnumerableCompat.new(collection) if collection.respond_to?(:count) && !collection.respond_to?(:size) 158 | 159 | if options.has_key?(:layout) 160 | ::Kernel.raise ::NotImplementedError, "The `:layout' option is not supported in collection rendering." 161 | end 162 | 163 | if options.has_key?(:spacer_template) 164 | ::Kernel.raise ::NotImplementedError, "The `:spacer_template' option is not supported in collection rendering." 165 | end 166 | 167 | if collection.present? 168 | results = CollectionRenderer 169 | .new(@context.lookup_context, options) { |&block| _scope(&block) } 170 | .render_collection_with_partial(collection, partial, @context, nil) 171 | 172 | array! if results.respond_to?(:body) && results.body.nil? 173 | else 174 | array! 175 | end 176 | else 177 | _render_partial options 178 | end 179 | end 180 | 181 | def _render_partial(options) 182 | options[:locals][:json] = self 183 | @context.render options 184 | end 185 | 186 | def _cache_fragment_for(key, options, &block) 187 | key = _cache_key(key, options) 188 | _read_fragment_cache(key, options) || _write_fragment_cache(key, options, &block) 189 | end 190 | 191 | def _read_fragment_cache(key, options = nil) 192 | @context.controller.instrument_fragment_cache :read_fragment, key do 193 | ::Rails.cache.read(key, options) 194 | end 195 | end 196 | 197 | def _write_fragment_cache(key, options = nil) 198 | @context.controller.instrument_fragment_cache :write_fragment, key do 199 | yield.tap do |value| 200 | ::Rails.cache.write(key, value, options) 201 | end 202 | end 203 | end 204 | 205 | def _cache_key(key, options) 206 | name_options = options.slice(:skip_digest, :virtual_path) 207 | key = _fragment_name_with_digest(key, name_options) 208 | 209 | if @context.respond_to?(:combined_fragment_cache_key) 210 | key = @context.combined_fragment_cache_key(key) 211 | else 212 | key = url_for(key).split('://', 2).last if ::Hash === key 213 | end 214 | 215 | ::ActiveSupport::Cache.expand_cache_key(key, :jbuilder) 216 | end 217 | 218 | def _fragment_name_with_digest(key, options) 219 | if @context.respond_to?(:cache_fragment_name) 220 | @context.cache_fragment_name(key, **options) 221 | else 222 | key 223 | end 224 | end 225 | 226 | def _partial_options?(options) 227 | ::Hash === options && options.key?(:as) && options.key?(:partial) 228 | end 229 | 230 | def _is_active_model?(object) 231 | object.class.respond_to?(:model_name) && object.respond_to?(:to_partial_path) 232 | end 233 | 234 | def _set_inline_partial(name, object, options) 235 | value = if object.nil? 236 | [] 237 | elsif _is_collection?(object) 238 | _scope do 239 | options[:collection] = object 240 | _render_partial_with_options options 241 | end 242 | else 243 | _scope do 244 | options[:locals] = { options[:as] => object } 245 | _render_partial_with_options options 246 | end 247 | end 248 | 249 | _set_value name, value 250 | end 251 | 252 | def _render_active_model_partial(object) 253 | @context.render object, json: self 254 | end 255 | end 256 | 257 | class JbuilderHandler 258 | cattr_accessor :default_format 259 | self.default_format = :json 260 | 261 | def self.call(template, source = nil) 262 | source ||= template.source 263 | # this juggling is required to keep line numbers right in the error 264 | %{__already_defined = defined?(json); json||=JbuilderTemplate.new(self); #{source}; 265 | json.target! unless (__already_defined && __already_defined != "method")} 266 | end 267 | end 268 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/jbuilder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support' 4 | require 'jbuilder/jbuilder' 5 | require 'jbuilder/blank' 6 | require 'jbuilder/key_formatter' 7 | require 'jbuilder/errors' 8 | require 'json' 9 | require 'active_support/core_ext/hash/deep_merge' 10 | 11 | class Jbuilder 12 | @@key_formatter = nil 13 | @@ignore_nil = false 14 | @@deep_format_keys = false 15 | 16 | def initialize( 17 | key_formatter: @@key_formatter, 18 | ignore_nil: @@ignore_nil, 19 | deep_format_keys: @@deep_format_keys, 20 | &block 21 | ) 22 | @attributes = {} 23 | @key_formatter = key_formatter 24 | @ignore_nil = ignore_nil 25 | @deep_format_keys = deep_format_keys 26 | 27 | yield self if block 28 | end 29 | 30 | # Yields a builder and automatically turns the result into a JSON string 31 | def self.encode(...) 32 | new(...).target! 33 | end 34 | 35 | BLANK = Blank.new 36 | 37 | def set!(key, value = BLANK, *args, &block) 38 | result = if ::Kernel.block_given? 39 | if !_blank?(value) 40 | # json.comments @post.comments { |comment| ... } 41 | # { "comments": [ { ... }, { ... } ] } 42 | _scope{ array! value, &block } 43 | else 44 | # json.comments { ... } 45 | # { "comments": ... } 46 | _merge_block(key){ yield self } 47 | end 48 | elsif args.empty? 49 | if ::Jbuilder === value 50 | # json.age 32 51 | # json.person another_jbuilder 52 | # { "age": 32, "person": { ... } 53 | _format_keys(value.attributes!) 54 | else 55 | # json.age 32 56 | # { "age": 32 } 57 | _format_keys(value) 58 | end 59 | elsif _is_collection?(value) 60 | # json.comments @post.comments, :content, :created_at 61 | # { "comments": [ { "content": "hello", "created_at": "..." }, { "content": "world", "created_at": "..." } ] } 62 | _scope{ array! value, *args } 63 | else 64 | # json.author @post.creator, :name, :email_address 65 | # { "author": { "name": "David", "email_address": "david@loudthinking.com" } } 66 | _merge_block(key){ _extract value, args } 67 | end 68 | 69 | _set_value key, result 70 | end 71 | 72 | # Specifies formatting to be applied to the key. Passing in a name of a function 73 | # will cause that function to be called on the key. So :upcase will upper case 74 | # the key. You can also pass in lambdas for more complex transformations. 75 | # 76 | # Example: 77 | # 78 | # json.key_format! :upcase 79 | # json.author do 80 | # json.name "David" 81 | # json.age 32 82 | # end 83 | # 84 | # { "AUTHOR": { "NAME": "David", "AGE": 32 } } 85 | # 86 | # You can pass parameters to the method using a hash pair. 87 | # 88 | # json.key_format! camelize: :lower 89 | # json.first_name "David" 90 | # 91 | # { "firstName": "David" } 92 | # 93 | # Lambdas can also be used. 94 | # 95 | # json.key_format! ->(key){ "_" + key } 96 | # json.first_name "David" 97 | # 98 | # { "_first_name": "David" } 99 | # 100 | def key_format!(...) 101 | @key_formatter = KeyFormatter.new(...) 102 | end 103 | 104 | # Same as the instance method key_format! except sets the default. 105 | def self.key_format(...) 106 | @@key_formatter = KeyFormatter.new(...) 107 | end 108 | 109 | # If you want to skip adding nil values to your JSON hash. This is useful 110 | # for JSON clients that don't deal well with nil values, and would prefer 111 | # not to receive keys which have null values. 112 | # 113 | # Example: 114 | # json.ignore_nil! false 115 | # json.id User.new.id 116 | # 117 | # { "id": null } 118 | # 119 | # json.ignore_nil! 120 | # json.id User.new.id 121 | # 122 | # {} 123 | # 124 | def ignore_nil!(value = true) 125 | @ignore_nil = value 126 | end 127 | 128 | # Same as instance method ignore_nil! except sets the default. 129 | def self.ignore_nil(value = true) 130 | @@ignore_nil = value 131 | end 132 | 133 | # Deeply apply key format to nested hashes and arrays passed to 134 | # methods like set!, merge! or array!. 135 | # 136 | # Example: 137 | # 138 | # json.key_format! camelize: :lower 139 | # json.settings({some_value: "abc"}) 140 | # 141 | # { "settings": { "some_value": "abc" }} 142 | # 143 | # json.key_format! camelize: :lower 144 | # json.deep_format_keys! 145 | # json.settings({some_value: "abc"}) 146 | # 147 | # { "settings": { "someValue": "abc" }} 148 | # 149 | def deep_format_keys!(value = true) 150 | @deep_format_keys = value 151 | end 152 | 153 | # Same as instance method deep_format_keys! except sets the default. 154 | def self.deep_format_keys(value = true) 155 | @@deep_format_keys = value 156 | end 157 | 158 | # Turns the current element into an array and yields a builder to add a hash. 159 | # 160 | # Example: 161 | # 162 | # json.comments do 163 | # json.child! { json.content "hello" } 164 | # json.child! { json.content "world" } 165 | # end 166 | # 167 | # { "comments": [ { "content": "hello" }, { "content": "world" } ]} 168 | # 169 | # More commonly, you'd use the combined iterator, though: 170 | # 171 | # json.comments(@post.comments) do |comment| 172 | # json.content comment.formatted_content 173 | # end 174 | def child! 175 | @attributes = [] unless ::Array === @attributes 176 | @attributes << _scope{ yield self } 177 | end 178 | 179 | # Turns the current element into an array and iterates over the passed collection, adding each iteration as 180 | # an element of the resulting array. 181 | # 182 | # Example: 183 | # 184 | # json.array!(@people) do |person| 185 | # json.name person.name 186 | # json.age calculate_age(person.birthday) 187 | # end 188 | # 189 | # [ { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } ] 190 | # 191 | # You can use the call syntax instead of an explicit extract! call: 192 | # 193 | # json.(@people) { |person| ... } 194 | # 195 | # It's generally only needed to use this method for top-level arrays. If you have named arrays, you can do: 196 | # 197 | # json.people(@people) do |person| 198 | # json.name person.name 199 | # json.age calculate_age(person.birthday) 200 | # end 201 | # 202 | # { "people": [ { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } ] } 203 | # 204 | # If you omit the block then you can set the top level array directly: 205 | # 206 | # json.array! [1, 2, 3] 207 | # 208 | # [1,2,3] 209 | def array!(collection = [], *attributes, &block) 210 | array = if collection.nil? 211 | [] 212 | elsif ::Kernel.block_given? 213 | _map_collection(collection, &block) 214 | elsif attributes.any? 215 | _map_collection(collection) { |element| _extract element, attributes } 216 | else 217 | _format_keys(collection.to_a) 218 | end 219 | 220 | @attributes = _merge_values(@attributes, array) 221 | end 222 | 223 | # Extracts the mentioned attributes or hash elements from the passed object and turns them into attributes of the JSON. 224 | # 225 | # Example: 226 | # 227 | # @person = Struct.new(:name, :age).new('David', 32) 228 | # 229 | # or you can utilize a Hash 230 | # 231 | # @person = { name: 'David', age: 32 } 232 | # 233 | # json.extract! @person, :name, :age 234 | # 235 | # { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } 236 | # 237 | # You can also use the call syntax instead of an explicit extract! call: 238 | # 239 | # json.(@person, :name, :age) 240 | def extract!(object, *attributes) 241 | _extract object, attributes 242 | end 243 | 244 | def call(object, *attributes, &block) 245 | if ::Kernel.block_given? 246 | array! object, &block 247 | else 248 | _extract object, attributes 249 | end 250 | end 251 | 252 | # Returns the nil JSON. 253 | def nil! 254 | @attributes = nil 255 | end 256 | 257 | alias_method :null!, :nil! 258 | 259 | # Returns the attributes of the current builder. 260 | def attributes! 261 | @attributes 262 | end 263 | 264 | # Merges hash, array, or Jbuilder instance into current builder. 265 | def merge!(object) 266 | hash_or_array = ::Jbuilder === object ? object.attributes! : object 267 | @attributes = _merge_values(@attributes, _format_keys(hash_or_array)) 268 | end 269 | 270 | # Encodes the current builder as JSON. 271 | def target! 272 | @attributes.to_json 273 | end 274 | 275 | private 276 | 277 | alias_method :method_missing, :set! 278 | 279 | def _extract(object, attributes) 280 | if ::Hash === object 281 | _extract_hash_values(object, attributes) 282 | else 283 | _extract_method_values(object, attributes) 284 | end 285 | end 286 | 287 | def _extract_hash_values(object, attributes) 288 | attributes.each{ |key| _set_value key, _format_keys(object.fetch(key)) } 289 | end 290 | 291 | def _extract_method_values(object, attributes) 292 | attributes.each{ |key| _set_value key, _format_keys(object.public_send(key)) } 293 | end 294 | 295 | def _merge_block(key) 296 | current_value = _blank? ? BLANK : @attributes.fetch(_key(key), BLANK) 297 | ::Kernel.raise NullError.build(key) if current_value.nil? 298 | new_value = _scope{ yield self } 299 | _merge_values(current_value, new_value) 300 | end 301 | 302 | def _merge_values(current_value, updates) 303 | if _blank?(updates) 304 | current_value 305 | elsif _blank?(current_value) || updates.nil? || current_value.empty? && ::Array === updates 306 | updates 307 | elsif ::Array === current_value && ::Array === updates 308 | current_value + updates 309 | elsif ::Hash === current_value && ::Hash === updates 310 | current_value.deep_merge(updates) 311 | else 312 | ::Kernel.raise MergeError.build(current_value, updates) 313 | end 314 | end 315 | 316 | def _key(key) 317 | if @key_formatter 318 | @key_formatter.format(key) 319 | elsif key.is_a?(::Symbol) 320 | key.name 321 | else 322 | key.to_s 323 | end 324 | end 325 | 326 | def _format_keys(hash_or_array) 327 | return hash_or_array unless @deep_format_keys 328 | 329 | if ::Array === hash_or_array 330 | hash_or_array.map { |value| _format_keys(value) } 331 | elsif ::Hash === hash_or_array 332 | ::Hash[hash_or_array.collect { |k, v| [_key(k), _format_keys(v)] }] 333 | else 334 | hash_or_array 335 | end 336 | end 337 | 338 | def _set_value(key, value) 339 | ::Kernel.raise NullError.build(key) if @attributes.nil? 340 | ::Kernel.raise ArrayError.build(key) if ::Array === @attributes 341 | return if @ignore_nil && value.nil? or _blank?(value) 342 | @attributes = {} if _blank? 343 | @attributes[_key(key)] = value 344 | end 345 | 346 | def _map_collection(collection) 347 | collection.map do |element| 348 | _scope{ yield element } 349 | end - [BLANK] 350 | end 351 | 352 | def _scope 353 | parent_attributes, parent_formatter, parent_deep_format_keys = @attributes, @key_formatter, @deep_format_keys 354 | @attributes = BLANK 355 | yield 356 | @attributes 357 | ensure 358 | @attributes, @key_formatter, @deep_format_keys = parent_attributes, parent_formatter, parent_deep_format_keys 359 | end 360 | 361 | def _is_collection?(object) 362 | object.respond_to?(:map) && object.respond_to?(:count) && !(::Struct === object) 363 | end 364 | 365 | def _blank?(value=@attributes) 366 | BLANK == value 367 | end 368 | end 369 | 370 | require 'jbuilder/railtie' if defined?(Rails) 371 | -------------------------------------------------------------------------------- /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 | test "returns an empty array for an empty collection" do 321 | Jbuilder::CollectionRenderer.expects(:new).never 322 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: []) 323 | 324 | # Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array. 325 | assert_equal [], result 326 | end 327 | 328 | test "works with an enumerable object" do 329 | enumerable_class = Class.new do 330 | include Enumerable 331 | 332 | def each(&block) 333 | [].each(&block) 334 | end 335 | end 336 | 337 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: enumerable_class.new) 338 | 339 | # Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array. 340 | assert_equal [], result 341 | end 342 | 343 | test "supports the cached: true option" do 344 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS) 345 | 346 | assert_equal 10, result.count 347 | assert_equal "Post #5", result[4]["body"] 348 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 349 | assert_equal "Pavel", result[5]["author"]["first_name"] 350 | 351 | expected = { 352 | "id" => 1, 353 | "body" => "Post #1", 354 | "author" => { 355 | "first_name" => "David", 356 | "last_name" => "Heinemeier Hansson" 357 | } 358 | } 359 | 360 | assert_equal expected, Rails.cache.read("post-1") 361 | 362 | result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS) 363 | 364 | assert_equal 10, result.count 365 | assert_equal "Post #5", result[4]["body"] 366 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 367 | assert_equal "Pavel", result[5]["author"]["first_name"] 368 | end 369 | 370 | test "supports the cached: ->() {} option" do 371 | result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS) 372 | 373 | assert_equal 10, result.count 374 | assert_equal "Post #5", result[4]["body"] 375 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 376 | assert_equal "Pavel", result[5]["author"]["first_name"] 377 | 378 | expected = { 379 | "id" => 1, 380 | "body" => "Post #1", 381 | "author" => { 382 | "first_name" => "David", 383 | "last_name" => "Heinemeier Hansson" 384 | } 385 | } 386 | 387 | assert_equal expected, Rails.cache.read("post-1/foo") 388 | 389 | result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS) 390 | 391 | assert_equal 10, result.count 392 | assert_equal "Post #5", result[4]["body"] 393 | assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"] 394 | assert_equal "Pavel", result[5]["author"]["first_name"] 395 | end 396 | 397 | test "raises an error on a render call with the :layout option" do 398 | error = assert_raises NotImplementedError do 399 | render('json.array! @posts, partial: "post", as: :post, layout: "layout"', posts: POSTS) 400 | end 401 | 402 | assert_equal "The `:layout' option is not supported in collection rendering.", error.message 403 | end 404 | 405 | test "raises an error on a render call with the :spacer_template option" do 406 | error = assert_raises NotImplementedError do 407 | render('json.array! @posts, partial: "post", as: :post, spacer_template: "template"', posts: POSTS) 408 | end 409 | 410 | assert_equal "The `:spacer_template' option is not supported in collection rendering.", error.message 411 | end 412 | 413 | private 414 | def render(*args) 415 | JSON.load render_without_parsing(*args) 416 | end 417 | 418 | def render_without_parsing(source, assigns = {}) 419 | view = build_view(fixtures: PARTIALS.merge("source.json.jbuilder" => source), assigns: assigns) 420 | view.render(template: "source") 421 | end 422 | 423 | def build_view(options = {}) 424 | resolver = ActionView::FixtureResolver.new(options.fetch(:fixtures)) 425 | lookup_context = ActionView::LookupContext.new([ resolver ], {}, [""]) 426 | controller = ActionView::TestCase::TestController.new 427 | 428 | view = ActionView::Base.with_empty_template_cache.new(lookup_context, options.fetch(:assigns, {}), controller) 429 | 430 | def view.view_cache_dependencies; []; end 431 | def view.combined_fragment_cache_key(key) [ key ] end 432 | def view.cache_fragment_name(key, *) key end 433 | def view.fragment_name_with_digest(key) key end 434 | 435 | view 436 | end 437 | end 438 | -------------------------------------------------------------------------------- /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 'use default key formatter when configured' 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_includes cache, :key 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 | test "respects JSON encoding customizations" do 934 | # Active Support overrides Time#as_json for custom formatting. 935 | # Ensure we call #to_json on the final attributes instead of JSON.dump. 936 | result = JSON.load(Jbuilder.encode { |json| json.time Time.parse("2018-05-13 11:51:00.485 -0400") }) 937 | assert_equal "2018-05-13T11:51:00.485-04:00", result["time"] 938 | end 939 | 940 | test "encode forwards options to new" do 941 | Jbuilder.encode(key_formatter: 1, ignore_nil: 2) do |json| 942 | assert_equal 1, json.instance_eval{ @key_formatter } 943 | assert_equal 2, json.instance_eval{ @ignore_nil } 944 | end 945 | end 946 | end 947 | --------------------------------------------------------------------------------