├── 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 | [][test]
5 | [][gem]
6 | [][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 |
--------------------------------------------------------------------------------