├── .rspec
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── docs.yml
│ ├── question-support.yml
│ ├── feature-request.yml
│ └── bug.yml
├── workflows
│ ├── auto-assign-author.yaml
│ ├── test.yaml
│ ├── codeql.yaml
│ ├── stale.yaml
│ └── release.yaml
├── PULL_REQUEST_TEMPLATE.md
└── dependabot.yaml
├── lib
├── blueprinter
│ ├── version.rb
│ ├── blueprinter_error.rb
│ ├── errors
│ │ ├── invalid_blueprint.rb
│ │ ├── invalid_root.rb
│ │ └── meta_requires_root.rb
│ ├── errors.rb
│ ├── helpers
│ │ └── type_helpers.rb
│ ├── extractors
│ │ ├── hash_extractor.rb
│ │ ├── public_send_extractor.rb
│ │ ├── block_extractor.rb
│ │ ├── association_extractor.rb
│ │ └── auto_extractor.rb
│ ├── extractor.rb
│ ├── transformer.rb
│ ├── empty_types.rb
│ ├── extension.rb
│ ├── deprecation.rb
│ ├── blueprint_validator.rb
│ ├── formatters
│ │ └── date_time_formatter.rb
│ ├── extensions.rb
│ ├── association.rb
│ ├── field.rb
│ ├── configuration.rb
│ ├── view.rb
│ ├── reflection.rb
│ ├── view_collection.rb
│ ├── rendering.rb
│ └── base.rb
├── generators
│ └── blueprinter
│ │ ├── templates
│ │ └── blueprint.erb
│ │ └── blueprint_generator.rb
└── blueprinter.rb
├── spec
├── generators
│ ├── shared.rb
│ └── blueprint_generator_spec.rb
├── support
│ └── mock_field.rb
├── benchmark_helper.rb
├── factories
│ └── model_factories.rb
├── benchmarks
│ ├── big_o_test.rb
│ ├── active_record_ips_test.rb
│ ├── active_record_big_o_test.rb
│ └── ips_test.rb
├── spec_helper.rb
├── generator_helper.rb
├── activerecord_helper.rb
├── units
│ ├── blueprint_validator_spec.rb
│ ├── association_spec.rb
│ ├── deprecation_spec.rb
│ ├── view_spec.rb
│ ├── extensions_spec.rb
│ ├── configuration_spec.rb
│ ├── date_time_formatter_spec.rb
│ ├── reflection_spec.rb
│ └── view_collection_spec.rb
└── integrations
│ ├── shared
│ └── base_render_examples.rb
│ └── base_spec.rb
├── bin
└── console
├── .gitignore
├── EMERITUS.md
├── .rubocop.yml
├── SECURITY.md
├── Gemfile
├── Rakefile
├── LICENSE.md
├── blueprinter.gemspec
├── CONTRIBUTING.md
├── blueprinter_logo.svg
├── CODE_OF_CONDUCT.md
└── CHANGELOG.md
/.rspec:
--------------------------------------------------------------------------------
1 | --require spec_helper
2 | --format documentation
3 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @procore-oss/procore-blueprinter @ritikesh
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/lib/blueprinter/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | VERSION = '1.2.1'
5 | end
6 |
--------------------------------------------------------------------------------
/lib/blueprinter/blueprinter_error.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | class BlueprinterError < StandardError; end
5 | end
6 |
--------------------------------------------------------------------------------
/spec/generators/shared.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.shared_examples "generated_file" do
4 | it { is_expected.to have_correct_syntax }
5 | end
6 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require 'bundler/setup'
5 | require 'blueprinter'
6 |
7 | require 'irb'
8 |
9 | IRB.start(__FILE__)
10 |
--------------------------------------------------------------------------------
/lib/blueprinter/errors/invalid_blueprint.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | module Errors
5 | class InvalidBlueprint < BlueprinterError; end
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/blueprinter/errors.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | module Errors
5 | autoload :InvalidBlueprint, 'blueprinter/errors/invalid_blueprint'
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/spec/support/mock_field.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class MockField
4 | attr_reader :name, :method
5 | def initialize(method, name = nil)
6 | @method = method
7 | @name = name || method
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | log/*.log
3 | pkg/
4 | spec/dummy/db/*.sqlite3
5 | spec/dummy/db/*.sqlite3-journal
6 | spec/dummy/log/*.log
7 | spec/dummy/tmp/
8 | doc/
9 | .yardoc/
10 | Gemfile.lock
11 | .vscode
12 | .DS_Store
13 | tmp/
14 | vendor/*
15 | .ruby-version
16 | .ruby-gemset
17 | *.gem
18 | .tool-versions
19 |
--------------------------------------------------------------------------------
/lib/blueprinter/helpers/type_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | module TypeHelpers
5 | private
6 |
7 | def array_like?(object)
8 | Blueprinter.configuration.array_like_classes.any? do |klass|
9 | object.is_a?(klass)
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractors/hash_extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/extractor'
4 |
5 | module Blueprinter
6 | # @api private
7 | class HashExtractor < Extractor
8 | def extract(field_name, object, _local_options, _options = {})
9 | object[field_name]
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/benchmark_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'minitest/autorun'
4 | require 'minitest/benchmark'
5 |
6 | module BenchmarkHelper
7 | def iterate
8 | start = Time.now
9 | count = 0
10 | while Time.now - start <= 1 do
11 | yield
12 | count += 1
13 | end
14 | count
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/blueprinter/errors/invalid_root.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/blueprinter_error'
4 |
5 | module Blueprinter
6 | module Errors
7 | class InvalidRoot < BlueprinterError
8 | def initialize(message = 'root key must be a Symbol or a String')
9 | super
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractors/public_send_extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/extractor'
4 |
5 | module Blueprinter
6 | # @api private
7 | class PublicSendExtractor < Extractor
8 | def extract(field_name, object, _local_options, _options = {})
9 | object.public_send(field_name)
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/blueprinter/errors/meta_requires_root.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/blueprinter_error'
4 |
5 | module Blueprinter
6 | module Errors
7 | class MetaRequiresRoot < BlueprinterError
8 | def initialize(message = 'adding metadata requires that a root key is set')
9 | super
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/.github/workflows/auto-assign-author.yaml:
--------------------------------------------------------------------------------
1 | name: 'Auto Author Assign'
2 | on:
3 | pull_request_target:
4 | types: [opened, reopened]
5 | jobs:
6 | assign-author:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
10 | with:
11 | repo-token: "${{ secrets.GITHUB_TOKEN }}"
12 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | class Extractor
5 | def extract(_field_name, _object, _local_options, _options = {})
6 | raise NotImplementedError, 'An Extractor must implement #extract'
7 | end
8 |
9 | def self.extract(field_name, object, local_options, options = {})
10 | new.extract(field_name, object, local_options, options)
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/blueprinter/transformer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | # @api private
5 | class Transformer
6 | def transform(_result_hash, _primary_obj, _options = {})
7 | raise NotImplementedError, 'A Transformer must implement #transform'
8 | end
9 |
10 | def self.transform(result_hash, primary_obj, options = {})
11 | new.transform(result_hash, primary_obj, options)
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/generators/blueprinter/templates/blueprint.erb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class <%= class_name %>Blueprint < Blueprinter::Base
4 | <% if identifier_symbol -%>
5 | <%= indent -%>identifier :<%= identifier_symbol %>
6 |
7 | <% end -%>
8 | <% if fields.any? -%>
9 | <%= indent -%>fields<%= formatted_fields %>
10 |
11 | <% end -%>
12 | <% associations.each do |a| -%>
13 | <%= indent -%>association :<%= a -%><%= association_blueprint(a) %>
14 |
15 | <% end -%>
16 | end
17 |
--------------------------------------------------------------------------------
/EMERITUS.md:
--------------------------------------------------------------------------------
1 | # Emeritus Approvers
2 |
3 | These are the people who have been approvers in the past, and have since retired from the role.
4 |
5 | We thank them for their service to the project.
6 |
7 | | Emeritus | GitHub ID |
8 | | -------- | --------- |
9 | | Adam Hess | [hparker](https://github.com/hparker) |
10 | | Derek Carter - Original Author | [dlcarter](https://github.com/dlcarter) |
11 | | Michael Clayton| [mcclayton](https://github.com/mcclayton) |
12 | | Philip Q Nguyen | [philipqnguyen](https://github.com/philipqnguyen) |
13 |
--------------------------------------------------------------------------------
/spec/factories/model_factories.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'factory_bot'
4 |
5 | FactoryBot.define do
6 | factory :user do
7 | first_name { 'Meg' }
8 | last_name { 'Ryan' }
9 | position { 'Manager' }
10 | description { 'A person' }
11 | company { 'Procore' }
12 | birthday { Date.new(1994, 3, 4) }
13 | deleted_at { nil }
14 | active { false }
15 | end
16 |
17 | factory :vehicle do
18 | make { 'Super Car' }
19 | association :user
20 |
21 | trait(:with_model) do
22 | model { 'ACME' }
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | TargetRubyVersion: 3.1
3 | NewCops: enable
4 | Exclude:
5 | - 'lib/generators/**/*'
6 | - 'tmp/**/*'
7 | - 'spec/**/*'
8 | - 'vendor/**/*' # added by github actions
9 |
10 | Style/Documentation:
11 | Enabled: false
12 |
13 | Style/SafeNavigation:
14 | Enabled: false
15 |
16 | Layout/LineLength:
17 | Max: 125
18 |
19 | Lint/MissingSuper:
20 | Enabled: false
21 |
22 | Metrics/MethodLength:
23 | Max: 15
24 |
25 | Metrics/AbcSize:
26 | Enabled: false
27 |
28 | Metrics/ParameterLists:
29 | Max: 10
30 |
31 | Naming/MemoizedInstanceVariableName:
32 | EnforcedStyleForLeadingUnderscores: required
33 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Ruby versions that are currently being supported with security updates.
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | <=3.0 | :x: |
10 | | 3.1 | :white_check_mark: |
11 | | 3.2 | :white_check_mark: |
12 | | 3.3 | :white_check_mark: |
13 |
14 | ## Reporting a Vulnerability
15 |
16 | Please click the `Report a vulnerability` button [here](https://github.com/procore-oss/blueprinter/security) to report a vulnerability.
17 |
18 | A maintainer will respond to you as soon as possible and discuss the process to get the vulnerability fixed.
19 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | Checklist:
2 |
3 | * [ ] I have updated the necessary documentation
4 | * [ ] I have signed off all my commits as required by [DCO](https://github.com/procore-oss/blueprinter/blob/main/CONTRIBUTING.md)
5 | * [ ] My build is green
6 |
7 |
16 |
--------------------------------------------------------------------------------
/spec/benchmarks/big_o_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'benchmark_helper'
4 | require 'blueprinter'
5 | require 'ostruct'
6 |
7 | class Blueprinter::BigOTest < Minitest::Benchmark
8 | def setup
9 | @blueprinter = Class.new(Blueprinter::Base) do
10 | field :id
11 | field :name
12 | end
13 | @prepared_objects = self.class.bench_range.inject({}) do |hash, n|
14 | hash.merge n => n.times.map {|i| OpenStruct.new(id: i, name: "obj #{i}")}
15 | end
16 | end
17 |
18 | def bench_render_basic
19 | assert_performance_linear(0.98) do |n|
20 | @blueprinter.render(@prepared_objects[n])
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/docs.yml:
--------------------------------------------------------------------------------
1 | name: 📚 Documentation or README.md issue report
2 | description: File a bug/issue for docs or README.md
3 | title: "[bug]
"
4 | labels: [docs, needs-triage]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Is there an existing issue for this?
9 | description: Please search to see if an issue already exists for the bug you encountered.
10 | options:
11 | - label: I have searched the existing issues
12 | required: true
13 | - type: textarea
14 | attributes:
15 | label: Docs/README.md Part to update
16 | description: A concise description of what you thing should be updated
17 | validations:
18 | required: true
19 |
--------------------------------------------------------------------------------
/spec/benchmarks/active_record_ips_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'activerecord_helper'
4 | require 'benchmark_helper'
5 | require 'blueprinter'
6 |
7 | class Blueprinter::ActiveRecordIPSTest < Minitest::Test
8 | include FactoryBot::Syntax::Methods
9 | include BenchmarkHelper
10 |
11 | def setup
12 | @blueprinter = Class.new(Blueprinter::Base) do
13 | fields :first_name, :last_name
14 | end
15 | @prepared_objects = 10.times.map {create(:user)}
16 | end
17 |
18 | def test_render
19 | result = iterate {@blueprinter.render(@prepared_objects)}
20 | puts "\nActiveRecord IPS: #{result}"
21 | assert_operator(result, :>=, 2000)
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question-support.yml:
--------------------------------------------------------------------------------
1 | name: ❓ Question or Support Request
2 | description: Questions and requests for support
3 | title: "[Question/Support] "
4 | labels: [question, support, needs-triage]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Is there an existing issue for this?
9 | description: Please search to see if an issue already exists for the bug you encountered.
10 | options:
11 | - label: I have searched the existing issues
12 | required: true
13 | - type: textarea
14 | attributes:
15 | label: Describe your question or ask for support
16 | description: A concise description of what you would like support with
17 | validations:
18 | required: true
19 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
4 | require 'blueprinter'
5 |
6 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each { |file| require file }
7 |
8 | module SpecHelpers
9 | def reset_blueprinter_config!
10 | Blueprinter.reset_configuration!
11 | end
12 | end
13 |
14 | RSpec.configure do |config|
15 | config.include SpecHelpers
16 |
17 | config.expect_with :rspec do |expectations|
18 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
19 | end
20 |
21 | config.mock_with :rspec do |mocks|
22 | mocks.verify_partial_doubles = true
23 | end
24 |
25 | config.shared_context_metadata_behavior = :apply_to_host_groups
26 | end
27 |
--------------------------------------------------------------------------------
/lib/blueprinter/empty_types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/helpers/type_helpers'
4 |
5 | module Blueprinter
6 | EMPTY_COLLECTION = 'empty_collection'
7 | EMPTY_HASH = 'empty_hash'
8 | EMPTY_STRING = 'empty_string'
9 |
10 | module EmptyTypes
11 | include TypeHelpers
12 |
13 | private
14 |
15 | def use_default_value?(value, empty_type)
16 | return value.nil? unless empty_type
17 |
18 | case empty_type
19 | when Blueprinter::EMPTY_COLLECTION
20 | array_like?(value) && value.empty?
21 | when Blueprinter::EMPTY_HASH
22 | value.is_a?(Hash) && value.empty?
23 | when Blueprinter::EMPTY_STRING
24 | value.to_s == ''
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/benchmarks/active_record_big_o_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'activerecord_helper'
4 | require 'benchmark_helper'
5 | require 'blueprinter'
6 |
7 | class Blueprinter::ActiveRecordBigOTest < Minitest::Benchmark
8 | include FactoryBot::Syntax::Methods
9 |
10 | def setup
11 | @blueprinter = Class.new(Blueprinter::Base) do
12 | identifier :id
13 | fields :first_name, :last_name
14 | end
15 | @prepared_objects = self.class.bench_range.inject({}) do |hash, n|
16 | hash.merge n => n.times.map { create(:user) }
17 | end
18 | end
19 |
20 | def bench_render_active_record
21 | assert_performance_linear do |n|
22 | @blueprinter.render(@prepared_objects[n])
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/blueprinter/extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | #
5 | # Base class for all extensions. All extension methods are implemented as no-ops.
6 | #
7 | class Extension
8 | #
9 | # Called eary during "render", this method receives the object to be rendered and
10 | # may return a modified (or new) object to be rendered.
11 | #
12 | # @param object [Object] The object to be rendered
13 | # @param _blueprint [Class] The Blueprinter class
14 | # @param _view [Symbol] The blueprint view
15 | # @param _options [Hash] Options passed to "render"
16 | # @return [Object] The object to continue rendering
17 | #
18 | def pre_render(object, _blueprint, _view, _options)
19 | object
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/blueprinter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | autoload :Base, 'blueprinter/base'
5 | autoload :BlueprinterError, 'blueprinter/blueprinter_error'
6 | autoload :Configuration, 'blueprinter/configuration'
7 | autoload :Deprecation, 'blueprinter/deprecation'
8 | autoload :Errors, 'blueprinter/errors'
9 | autoload :Extension, 'blueprinter/extension'
10 | autoload :Transformer, 'blueprinter/transformer'
11 |
12 | class << self
13 | # @return [Configuration]
14 | def configuration
15 | @_configuration ||= Configuration.new
16 | end
17 |
18 | def configure
19 | yield(configuration) if block_given?
20 | end
21 |
22 | # Resets global configuration.
23 | def reset_configuration!
24 | @_configuration = nil
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | push:
4 | branches: [main]
5 | pull_request:
6 | branches: [main]
7 | permissions:
8 | contents: read
9 | jobs:
10 | test:
11 | strategy:
12 | matrix:
13 | os: [ubuntu-latest]
14 | ruby: ['3.1', '3.2', '3.3']
15 | runs-on: ${{ matrix.os }}
16 | steps:
17 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
18 | - name: Set up Ruby ${{ matrix.ruby }}
19 | uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1
20 | with:
21 | ruby-version: ${{ matrix.ruby }}
22 | bundler-cache: true
23 | - name: Installing dependencies
24 | run: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle
25 | - name: Run tests
26 | run: bundle exec rake
27 | - name: Benchmarks
28 | run: bundle exec rake benchmarks
29 |
--------------------------------------------------------------------------------
/spec/benchmarks/ips_test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'benchmark_helper'
4 | require 'blueprinter'
5 | require 'ostruct'
6 |
7 | class Blueprinter::IPSTest < Minitest::Test
8 | include BenchmarkHelper
9 |
10 | def setup
11 | @blueprinter = Class.new(Blueprinter::Base) do
12 | transformer = Class.new(Blueprinter::Transformer) do
13 | define_method :transform do |result_hash, _obj, _options|
14 | {
15 | foo: :bar,
16 | **result_hash
17 | }
18 | end
19 | end
20 |
21 | field :id
22 | field :name
23 |
24 | transform transformer
25 | end
26 | @prepared_objects = 10.times.map {|i| OpenStruct.new(id: i, name: "obj #{i}")}
27 | end
28 |
29 | def test_render
30 | result = iterate {@blueprinter.render(@prepared_objects)}
31 | puts "\nBasic IPS: #{result}"
32 | assert_operator(result, :>=, 2500)
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: /
5 | schedule:
6 | interval: "weekly"
7 | timezone: "America/Los_Angeles"
8 | labels:
9 | - "dependabot"
10 | - "dependencies"
11 | - "github-actions"
12 | commit-message:
13 | prefix: "chore(deps)"
14 | groups:
15 | dependencies:
16 | applies-to: version-updates
17 | update-types:
18 | - "minor"
19 | - "patch"
20 | - package-ecosystem: "bundler"
21 | directory: /
22 | schedule:
23 | interval: "weekly"
24 | timezone: "America/Los_Angeles"
25 | labels:
26 | - "dependabot"
27 | - "dependencies"
28 | - "bundler"
29 | commit-message:
30 | prefix: "chore(deps)"
31 | groups:
32 | dependencies:
33 | applies-to: version-updates
34 | update-types:
35 | - "minor"
36 | - "patch"
37 |
--------------------------------------------------------------------------------
/lib/blueprinter/deprecation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # @api private
4 | module Blueprinter
5 | class Deprecation
6 | class << self
7 | VALID_BEHAVIORS = %i[silence stderror raise].freeze
8 | MESSAGE_PREFIX = '[DEPRECATION::WARNING] Blueprinter:'
9 |
10 | def report(message)
11 | full_msg = qualified_message(message)
12 |
13 | case behavior
14 | when :silence
15 | # Silence deprecation (noop)
16 | when :stderror
17 | warn full_msg
18 | when :raise
19 | raise BlueprinterError, full_msg
20 | end
21 | end
22 |
23 | private
24 |
25 | def qualified_message(message)
26 | "#{MESSAGE_PREFIX} #{message}"
27 | end
28 |
29 | def behavior
30 | configured = Blueprinter.configuration.deprecations
31 | return configured if VALID_BEHAVIORS.include?(configured)
32 |
33 | :stderror
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | # Declare your gem's dependencies in blueprinter.gemspec.
6 | # Bundler will treat runtime dependencies like base dependencies, and
7 | # development dependencies will be added by default to the :development group.
8 | gemspec
9 |
10 | # Officially supported gems for serialization
11 | gem 'oj', '~> 3.13'
12 | gem 'yajl-ruby', '~> 1.4'
13 |
14 | # Declare any dependencies that are still in development here instead of in
15 | # your gemspec. These might include edge Rails or gems from your path or
16 | # Git. Remember to move these dependencies to your gemspec before releasing
17 | # your gem to rubygems.org.
18 |
19 | gem 'activerecord', '>= 5.2'
20 | gem 'ammeter', '~> 1.1'
21 | gem 'factory_bot', '~> 6.2'
22 | gem 'minitest', '~> 5.25'
23 | gem 'pry', '~> 0.14'
24 | gem 'rspec', '~> 3.12'
25 | gem 'rspec-rails', '~> 7.0'
26 | gem 'rubocop', '~> 1.44'
27 | gem 'rubocop-rake'
28 | gem 'sqlite3', '~> 2.1'
29 | gem 'yard', '>= 0.9.34'
30 |
--------------------------------------------------------------------------------
/spec/generator_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_record/railtie' # see https://github.com/rspec/rspec-rails/issues/1690 for vague hints
4 | require 'ammeter/init'
5 | require 'generators/shared'
6 |
7 | RSpec.shared_context "generator_destination", :shared_context => :metadata do
8 | destination File.expand_path("../../tmp", __FILE__)
9 | before do
10 | prepare_destination
11 | FileUtils.cd(File.expand_path("../../tmp", __FILE__)) # force all generator output into .gitignored tmp, don't pollute gem source
12 | end
13 | after do
14 | FileUtils.cd("..")
15 | end
16 | end
17 |
18 | RSpec.shared_context "vehicle_subject", :shared_context => :metadata do
19 | let (:model) { "vehicle" }
20 | subject { file('app/blueprints/vehicle_blueprint.rb') }
21 | end
22 |
23 | RSpec.configure do |rspec|
24 | rspec.include_context "generator_destination", :include_shared => true
25 | rspec.include_context "vehicle_subject", :include_shared => true
26 | end
27 |
28 | require 'spec_helper'
29 |
--------------------------------------------------------------------------------
/lib/blueprinter/blueprint_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | # @api private
5 | class BlueprintValidator
6 | class << self
7 | # Determines whether the provided object is a valid Blueprint.
8 | #
9 | # @param blueprint [Object] The object to validate.
10 | # @return [Boolean] true if object is a valid Blueprint
11 | # @raise [Blueprinter::Errors::InvalidBlueprint] if the object is not a valid Blueprint.
12 | def validate!(blueprint)
13 | valid_blueprint?(blueprint) || raise(
14 | Errors::InvalidBlueprint,
15 | "#{blueprint} is not a valid blueprint. Please ensure it subclasses Blueprinter::Base or is a Proc."
16 | )
17 | end
18 |
19 | private
20 |
21 | def valid_blueprint?(blueprint)
22 | return false unless blueprint
23 | return true if blueprint.is_a?(Proc)
24 | return false unless blueprint.is_a?(Class)
25 |
26 | blueprint <= Blueprinter::Base
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/blueprinter/formatters/date_time_formatter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | class DateTimeFormatter
5 | InvalidDateTimeFormatterError = Class.new(BlueprinterError)
6 |
7 | def format(value, options)
8 | return value if value.nil?
9 |
10 | field_format = options[:datetime_format]
11 | if value.respond_to?(:strftime)
12 | value = format_datetime(value, field_format)
13 | elsif field_format
14 | raise InvalidDateTimeFormatterError, 'Cannot format invalid DateTime object'
15 | end
16 | value
17 | end
18 |
19 | private
20 |
21 | def format_datetime(value, field_format)
22 | format = field_format || Blueprinter.configuration.datetime_format
23 |
24 | case format
25 | when NilClass then value
26 | when Proc then format.call(value)
27 | when String then value.strftime(format)
28 | else
29 | raise InvalidDateTimeFormatterError, "Cannot format DateTime object with invalid formatter: #{format.class}"
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rdoc/task'
4 | require 'bundler/gem_tasks'
5 | require 'rake/testtask'
6 | require 'rspec/core/rake_task'
7 | require 'yard'
8 | require 'rubocop/rake_task'
9 |
10 | begin
11 | require 'bundler/setup'
12 | rescue LoadError
13 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
14 | end
15 |
16 | RDoc::Task.new(:rdoc) do |rdoc|
17 | rdoc.rdoc_dir = 'rdoc'
18 | rdoc.title = 'Blueprinter'
19 | rdoc.options << '--line-numbers'
20 | rdoc.rdoc_files.include('README.md')
21 | rdoc.rdoc_files.include('lib/**/*.rb')
22 | end
23 |
24 | RSpec::Core::RakeTask.new(:spec) do |t|
25 | t.rspec_opts = '--pattern spec/**/*_spec.rb --warnings'
26 | end
27 |
28 | RuboCop::RakeTask.new
29 |
30 | YARD::Rake::YardocTask.new do |t|
31 | t.files = Dir['lib/**/*'].reject do |file|
32 | file.include?('lib/generators')
33 | end
34 | end
35 |
36 | Rake::TestTask.new(:benchmarks) do |t|
37 | t.libs.append('lib', 'spec')
38 | t.pattern = 'spec/benchmarks/**/*_test.rb'
39 | t.verbose = false
40 | end
41 |
42 | task default: %i[spec rubocop]
43 |
--------------------------------------------------------------------------------
/lib/blueprinter/extensions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | #
5 | # Stores and runs Blueprinter extensions. An extension is any object that implements one or more of the
6 | # extension methods:
7 | #
8 | # The Render Extension intercepts an object before rendering begins. The return value from this
9 | # method is what is ultimately rendered.
10 | #
11 | # def pre_render(object, blueprint, view, options)
12 | # # returns original, modified, or new object
13 | # end
14 | #
15 | class Extensions
16 | def initialize(extensions = [])
17 | @extensions = extensions
18 | end
19 |
20 | def to_a
21 | @extensions.dup
22 | end
23 |
24 | # Appends an extension
25 | def <<(ext)
26 | @extensions << ext
27 | self
28 | end
29 |
30 | # Runs the object through all Render Extensions and returns the final result
31 | def pre_render(object, blueprint, view, options = {})
32 | @extensions.reduce(object) do |acc, ext|
33 | ext.pre_render(acc, blueprint, view, options)
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2017 Procore Technologies, Inc.
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 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yaml:
--------------------------------------------------------------------------------
1 | name: "Custom CodeQL"
2 | on:
3 | workflow_dispatch:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 | branches: ["main"]
8 | permissions:
9 | contents: read
10 | jobs:
11 | analyze:
12 | name: Analyze
13 | runs-on: Ubuntu-latest
14 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 | strategy:
20 | fail-fast: false
21 | matrix:
22 | language: ['ruby']
23 | steps:
24 | - name: Checkout repository
25 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
26 | - name: Initialize CodeQL
27 | uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v3
28 | with:
29 | languages: ${{ matrix.language }}
30 | - name: Autobuild
31 | uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v3
32 | - name: Perform CodeQL Analysis
33 | uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v3
34 | with:
35 | category: "/language:${{matrix.language}}"
36 |
--------------------------------------------------------------------------------
/blueprinter.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $LOAD_PATH.push File.expand_path('lib', __dir__)
4 |
5 | # Maintain your gem's version:
6 | require 'blueprinter/version'
7 |
8 | # Describe your gem and declare its dependencies:
9 | Gem::Specification.new do |s|
10 | s.name = 'blueprinter'
11 | s.version = Blueprinter::VERSION
12 | s.authors = ['Procore Technologies, Inc.']
13 | s.email = ['opensource@procore.com']
14 | s.homepage = 'https://github.com/procore-oss/blueprinter'
15 | s.summary = 'Simple Fast Declarative Serialization Library'
16 | s.description = 'Blueprinter is a JSON Object Presenter for Ruby that takes business objects ' \
17 | 'and breaks them down into simple hashes and serializes them to JSON. ' \
18 | 'It can be used in Rails in place of other serializers (like JBuilder or ActiveModelSerializers). ' \
19 | 'It is designed to be simple, direct, and performant.'
20 | s.license = 'MIT'
21 | s.metadata['allowed_push_host'] = 'https://rubygems.org'
22 |
23 | s.files = Dir['{app,config,db,lib}/**/*', 'CHANGELOG.md', 'LICENSE.md', 'Rakefile', 'README.md']
24 |
25 | s.required_ruby_version = '>= 3.1'
26 | s.metadata['rubygems_mfa_required'] = 'true'
27 | end
28 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractors/block_extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/extractor'
4 |
5 | module Blueprinter
6 | # @api private
7 | class BlockExtractor < Extractor
8 | def extract(_field_name, object, local_options, options = {})
9 | block = options[:block]
10 |
11 | # Symbol#to_proc creates procs with signature [[:req], [:rest]]
12 | # These procs forward ALL arguments to the method, which causes
13 | # issues when we call block.call(object, local_options) because
14 | # it becomes object.method_name(local_options), and most methods
15 | # don't accept extra arguments.
16 | #
17 | # For Symbol#to_proc, we only pass the object.
18 | # For regular blocks, we pass both object and local_options.
19 | if symbol_to_proc?(block)
20 | block.call(object)
21 | else
22 | block.call(object, local_options)
23 | end
24 | end
25 |
26 | private
27 |
28 | def symbol_to_proc?(block)
29 | # Symbol#to_proc has a characteristic signature:
30 | # - Parameters: [[:req], [:rest]] (one required + rest args)
31 | # - This is different from regular blocks which typically have
32 | # optional parameters like [[:opt, :obj], [:opt, :options]]
33 | block.parameters == [[:req], [:rest]]
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/blueprinter/association.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/field'
4 | require 'blueprinter/blueprint_validator'
5 | require 'blueprinter/extractors/association_extractor'
6 |
7 | module Blueprinter
8 | # @api private
9 | class Association < Field
10 | # @param method [Symbol] The method to call on the source object to retrieve the associated data
11 | # @param name [Symbol] The name of the association as it will appear when rendered
12 | # @param blueprint [Blueprinter::Base] The blueprint to use for rendering the association
13 | # @param view [Symbol] The view to use in conjunction with the blueprint
14 | # @param parent_blueprint [Blueprinter::Base] The blueprint that this association is being defined within
15 | # @param extractor [Blueprinter::Extractor] The extractor to use when retrieving the associated data
16 | # @param options [Hash]
17 | #
18 | # @return [Blueprinter::Association]
19 | def initialize(method:, name:, blueprint:, view:, parent_blueprint:, extractor: AssociationExtractor.new, options: {})
20 | BlueprintValidator.validate!(blueprint)
21 |
22 | super(
23 | method,
24 | name,
25 | extractor,
26 | parent_blueprint,
27 | options.merge(
28 | blueprint: blueprint,
29 | view: view,
30 | association: true
31 | )
32 | )
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: 🚀🆕 Feature Request
2 | description: Suggest an idea or possible new feature for this project
3 | title: "[Feature Request] "
4 | labels: [feature, needs-triage]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Is there an existing issue for this?
9 | description: Please search to see if an issue already exists for the bug you encountered.
10 | options:
11 | - label: I have searched the existing issues
12 | required: true
13 | - type: textarea
14 | attributes:
15 | label: Is your feature request related to a problem? Please describe
16 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
17 | validations:
18 | required: true
19 | - type: textarea
20 | attributes:
21 | label: Describe the feature you'd like to see implemented
22 | description: A clear and concise description of what you want to happen
23 | validations:
24 | required: true
25 | - type: textarea
26 | attributes:
27 | label: Describe alternatives you've considered
28 | description: A clear and concise description of any alternative solutions or features you've considered
29 | validations:
30 | required: false
31 | - type: textarea
32 | attributes:
33 | label: Additional context
34 | description: Add any other context or additional information about the problem here
35 | validations:
36 | required: false
37 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractors/association_extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/extractor'
4 | require 'blueprinter/empty_types'
5 |
6 | module Blueprinter
7 | # @api private
8 | class AssociationExtractor < Extractor
9 | include EmptyTypes
10 |
11 | def initialize
12 | @extractor = Blueprinter.configuration.extractor_default.new
13 | end
14 |
15 | def extract(association_name, object, local_options, options = {})
16 | options_without_default = options.except(:default, :default_if)
17 | # Merge in assocation options hash
18 | local_options = local_options.merge(options[:options]) if options[:options].is_a?(Hash)
19 | value = @extractor.extract(association_name, object, local_options, options_without_default)
20 | return default_value(options) if use_default_value?(value, options[:default_if])
21 |
22 | view = options[:view] || :default
23 | blueprint = association_blueprint(options[:blueprint], value)
24 | blueprint.hashify(value, view_name: view, local_options: local_options)
25 | end
26 |
27 | private
28 |
29 | def default_value(association_options)
30 | return association_options.fetch(:default) if association_options.key?(:default)
31 |
32 | Blueprinter.configuration.association_default
33 | end
34 |
35 | def association_blueprint(blueprint, value)
36 | blueprint.is_a?(Proc) ? blueprint.call(value) : blueprint
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/spec/activerecord_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_record'
4 | require 'factories/model_factories'
5 |
6 | ActiveRecord::Base.establish_connection(
7 | adapter: 'sqlite3',
8 | database: ':memory:'
9 | )
10 |
11 | class Vehicle < ActiveRecord::Base
12 | belongs_to :user
13 | end
14 |
15 | module Electric # must move above require 'factories/model_factories.rb' to enable a factory
16 | class Truck < ::Vehicle
17 | end
18 | end
19 |
20 | class User < ActiveRecord::Base
21 | attr_accessor :company, :description, :position, :active
22 |
23 | has_many :vehicles
24 |
25 | def dynamic_fields
26 | { 'full_name' => "#{first_name} #{last_name}" }
27 | end
28 | end
29 |
30 | ActiveRecord::Schema.define(version: 20_181_116_094_242) do
31 | create_table :users, force: :cascade do |t|
32 | t.string :first_name
33 | t.string :last_name
34 | t.string :email
35 | t.text :address
36 | t.datetime :birthday
37 | t.datetime :created_at, null: false
38 | t.datetime :updated_at, null: false
39 | t.datetime :deleted_at
40 | t.boolean :active
41 | end
42 |
43 | create_table :vehicles, force: :cascade do |t|
44 | t.string :make
45 | t.string :model
46 | t.integer :miles
47 | t.integer :user_id
48 | t.datetime :created_at, null: false
49 | t.datetime :updated_at, null: false
50 | t.index [:user_id], name: :index_vehicles_on_user_id
51 | end
52 | end
53 |
54 | ActiveRecord::Migration.maintain_test_schema!
55 |
--------------------------------------------------------------------------------
/lib/blueprinter/extractors/auto_extractor.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/extractor'
4 | require 'blueprinter/empty_types'
5 | require 'blueprinter/extractors/block_extractor'
6 | require 'blueprinter/extractors/hash_extractor'
7 | require 'blueprinter/extractors/public_send_extractor'
8 | require 'blueprinter/formatters/date_time_formatter'
9 |
10 | module Blueprinter
11 | # @api private
12 | class AutoExtractor < Extractor
13 | include EmptyTypes
14 |
15 | def initialize
16 | @hash_extractor = HashExtractor.new
17 | @public_send_extractor = PublicSendExtractor.new
18 | @block_extractor = BlockExtractor.new
19 | @datetime_formatter = DateTimeFormatter.new
20 | end
21 |
22 | def extract(field_name, object, local_options, options = {})
23 | extraction = extractor(object, options).extract(field_name, object, local_options, options)
24 | value = @datetime_formatter.format(extraction, options)
25 | use_default_value?(value, options[:default_if]) ? default_value(options) : value
26 | end
27 |
28 | private
29 |
30 | def default_value(field_options)
31 | field_options.key?(:default) ? field_options.fetch(:default) : Blueprinter.configuration.field_default
32 | end
33 |
34 | def extractor(object, options)
35 | if options[:block]
36 | @block_extractor
37 | elsif object.is_a?(Hash)
38 | @hash_extractor
39 | else
40 | @public_send_extractor
41 | end
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/spec/units/blueprint_validator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/blueprint_validator'
4 |
5 | describe Blueprinter::BlueprintValidator do
6 | describe 'validate!' do
7 | context 'when provided object subclasses Blueprinter::Base' do
8 | it 'returns true' do
9 | expect(described_class.validate!(Class.new(Blueprinter::Base))).to eq(true)
10 | end
11 | end
12 |
13 | context 'when provided object is a Proc' do
14 | it 'returns true' do
15 | expect(
16 | described_class.validate!(
17 | -> (obj) { obj }
18 | )
19 | ).to eq(true)
20 | end
21 | end
22 |
23 | context 'when provided object is not a class nor a Proc' do
24 | it 'raises a Blueprinter::Errors::InvalidBlueprint exception' do
25 | expect { described_class.validate!({}) }.
26 | to raise_error(Blueprinter::Errors::InvalidBlueprint) do
27 | '{} is not a valid blueprint. Please ensure it subclasses Blueprinter::Base or is a Proc.'
28 | end
29 | end
30 | end
31 |
32 | context 'when provided object is not a Proc nor inherits from Blueprinter::Base' do
33 | it 'raises a Blueprinter::Errors::InvalidBlueprint exception' do
34 | expect { described_class.validate!(Integer) }.
35 | to raise_error(Blueprinter::Errors::InvalidBlueprint) do
36 | 'Integer is not a valid blueprint. Please ensure it subclasses Blueprinter::Base or is a Proc.'
37 | end
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yaml:
--------------------------------------------------------------------------------
1 | ## Reference: https://github.com/actions/stale
2 | name: Mark stale issues and pull requests
3 | on:
4 | schedule:
5 | - cron: "30 1 * * *"
6 | permissions:
7 | contents: read
8 | jobs:
9 | stale:
10 | permissions:
11 | issues: write # for actions/stale to close stale issues
12 | pull-requests: write # for actions/stale to close stale PRs
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v9
16 | with:
17 | repo-token: ${{ secrets.GITHUB_TOKEN }}
18 | # Number of days of inactivity before an issue becomes stale
19 | days-before-stale: 60
20 | # Number of days of inactivity before a stale issue is closed
21 | days-before-close: 7
22 | # Issues with these labels will never be considered stale
23 | exempt-issue-labels: "on-hold,pinned,security"
24 | exempt-pr-labels: "on-hold,pinned,security"
25 | # Comment to post when marking an issue as stale.
26 | stale-issue-message: >
27 | This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
28 |
29 | stale-pr-message: >
30 | This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
31 |
32 | # Label to use when marking an issue as stale
33 | stale-issue-label: 'no-issue-activity'
34 | stale-pr-label: 'no-pr-activity'
35 |
--------------------------------------------------------------------------------
/lib/blueprinter/field.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # @api private
4 | module Blueprinter
5 | class Field
6 | attr_reader :method, :name, :extractor, :options, :blueprint
7 |
8 | def initialize(method, name, extractor, blueprint, options = {})
9 | @method = method
10 | @name = name
11 | @extractor = extractor
12 | @blueprint = blueprint
13 | @options = options
14 | end
15 |
16 | def extract(object, local_options)
17 | extractor.extract(method, object, local_options, options)
18 | end
19 |
20 | def skip?(field_name, object, local_options)
21 | return true if if_callable && !if_callable.call(field_name, object, local_options)
22 |
23 | unless_callable && unless_callable.call(field_name, object, local_options)
24 | end
25 |
26 | private
27 |
28 | def if_callable
29 | @_if_callable ||= callable_from(:if)
30 | end
31 |
32 | def unless_callable
33 | @_unless_callable ||= callable_from(:unless)
34 | end
35 |
36 | def callable_from(condition)
37 | config = Blueprinter.configuration
38 |
39 | # Use field-level callable, or when not defined, try global callable
40 | tmp = if options.key?(condition)
41 | options.fetch(condition)
42 | elsif config.valid_callable?(condition)
43 | config.public_send(condition)
44 | end
45 |
46 | return false unless tmp
47 |
48 | case tmp
49 | when Proc then tmp
50 | when Symbol then blueprint.method(tmp)
51 | else
52 | raise ArgumentError, "#{tmp.class} is passed to :#{condition}"
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/blueprinter/configuration.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'json'
4 | require 'blueprinter/extensions'
5 | require 'blueprinter/extractors/auto_extractor'
6 |
7 | module Blueprinter
8 | class Configuration
9 | attr_accessor(
10 | :association_default,
11 | :custom_array_like_classes,
12 | :datetime_format,
13 | :default_transformers,
14 | :deprecations,
15 | :extractor_default,
16 | :field_default,
17 | :generator,
18 | :if,
19 | :method,
20 | :sort_fields_by,
21 | :unless
22 | )
23 | attr_reader :extensions
24 |
25 | VALID_CALLABLES = %i[if unless].freeze
26 |
27 | def initialize
28 | @deprecations = :stderror
29 | @association_default = nil
30 | @datetime_format = nil
31 | @field_default = nil
32 | @generator = JSON
33 | @if = nil
34 | @method = :generate
35 | @sort_fields_by = :name_asc
36 | @unless = nil
37 | @extractor_default = AutoExtractor
38 | @default_transformers = []
39 | @custom_array_like_classes = []
40 | @extensions = Extensions.new
41 | end
42 |
43 | def extensions=(list)
44 | @extensions = Extensions.new(list)
45 | end
46 |
47 | def array_like_classes
48 | @_array_like_classes ||= [
49 | Array,
50 | defined?(ActiveRecord::Relation) && ActiveRecord::Relation,
51 | *custom_array_like_classes
52 | ].compact
53 | end
54 |
55 | def jsonify(blob)
56 | generator.public_send(method, blob)
57 | end
58 |
59 | def valid_callable?(callable_name)
60 | VALID_CALLABLES.include?(callable_name)
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | workflow_run:
4 | workflows: [Test]
5 | types: [completed]
6 | branches: [main]
7 | workflow_dispatch: # allow manual deployment through GitHub Action UI
8 | jobs:
9 | version-check:
10 | runs-on: ubuntu-latest
11 | if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
12 | outputs:
13 | changed: ${{ steps.check.outputs.any_changed }}
14 | steps:
15 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
16 | - name: Check if version has been updated
17 | id: check
18 | uses: tj-actions/changed-files@70069877f29101175ed2b055d210fe8b1d54d7d7 # v44
19 | with:
20 | files: lib/blueprinter/version.rb
21 | release:
22 | runs-on: ubuntu-latest
23 | needs: version-check
24 | if: ${{ github.event_name == 'workflow_dispatch' || needs.version-check.outputs.changed == 'true' }}
25 | steps:
26 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
27 | - name: Set up Ruby
28 | uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1
29 | with:
30 | ruby-version: 3.2
31 | bundler-cache: true
32 | - name: Installing dependencies
33 | run: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle
34 | - name: Build gem file
35 | run: bundle exec rake build
36 | - uses: fac/ruby-gem-setup-credentials-action@5f62d5f2f56a11c7422a92f81fbb29af01e1c00f # v2
37 | with:
38 | user: ""
39 | key: rubygems
40 | token: ${{secrets.RUBY_GEMS_API_KEY}}
41 | - uses: fac/ruby-gem-push-action@81d77bf568ff6659d7fae0f0c5a036bb0aeacb1a # v2
42 | with:
43 | key: rubygems
44 |
--------------------------------------------------------------------------------
/spec/units/association_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/association'
4 |
5 | describe Blueprinter::Association do
6 | describe '#initialize' do
7 | let(:blueprint) { Class.new(Blueprinter::Base) }
8 | let(:parent_blueprint) { Class.new(Blueprinter::Base) }
9 | let(:if_condition) { -> { true } }
10 | let(:args) do
11 | {
12 | method: :method,
13 | name: :name,
14 | extractor: :extractor,
15 | blueprint: blueprint,
16 | parent_blueprint: parent_blueprint,
17 | view: :view,
18 | options: { if: if_condition }
19 | }
20 | end
21 |
22 | it 'returns an instance of Blueprinter::Association with expected values', aggregate_failures: true do
23 | association = described_class.new(**args)
24 | expect(association).to be_instance_of(described_class)
25 | expect(association.method).to eq(:method)
26 | expect(association.name).to eq(:name)
27 | expect(association.extractor).to eq(:extractor)
28 | expect(association.blueprint).to eq(parent_blueprint)
29 | expect(association.options).to eq({ if: if_condition, blueprint: blueprint, view: :view, association: true })
30 | end
31 |
32 | context 'when provided :blueprint is invalid' do
33 | let(:blueprint) { Class.new }
34 |
35 | it 'raises a Blueprinter::InvalidBlueprintError' do
36 | expect { described_class.new(**args) }.
37 | to raise_error(Blueprinter::Errors::InvalidBlueprint)
38 | end
39 | end
40 |
41 | context 'when an extractor is not provided' do
42 | it 'defaults to using AssociationExtractor' do
43 | expect(described_class.new(**args.except(:extractor)).extractor).
44 | to be_an_instance_of(Blueprinter::AssociationExtractor)
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/spec/units/deprecation_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/deprecation'
4 |
5 | describe 'Blueprinter::Deprecation' do
6 | describe '#report' do
7 | TEST_MESSAGE = "Test Message"
8 |
9 | after { reset_blueprinter_config! }
10 |
11 | describe "when deprecation behavior is `:stderror`" do
12 | before do
13 | Blueprinter.configure { |config| config.deprecations = :stderror }
14 | @orig_stderr = $stderr
15 | $stderr = StringIO.new
16 | end
17 |
18 | it('writes deprecation warning message to $stderr') do
19 | Blueprinter::Deprecation.report(TEST_MESSAGE)
20 | $stderr.rewind
21 | stderr_output = $stderr.string.chomp
22 | expect(stderr_output).to eql("[DEPRECATION::WARNING] Blueprinter: #{TEST_MESSAGE}")
23 | end
24 |
25 | after do
26 | $stderr = @orig_stderr
27 | end
28 | end
29 |
30 | describe "when deprecation behavior is `:silence`" do
31 | before do
32 | Blueprinter.configure { |config| config.deprecations = :silence }
33 | @orig_stderr = $stderr
34 | $stderr = StringIO.new
35 | end
36 |
37 | it('does not warn or raise deprecation message') do
38 | expect {Blueprinter::Deprecation.report(TEST_MESSAGE)}.not_to raise_error
39 | $stderr.rewind
40 | stderr_output = $stderr.string.chomp
41 | expect(stderr_output).not_to include("[DEPRECATION::WARNING] Blueprinter: #{TEST_MESSAGE}")
42 | end
43 |
44 | after do
45 | $stderr = @orig_stderr
46 | end
47 | end
48 |
49 | describe "when deprecation behavior is `:raise`" do
50 | before do
51 | Blueprinter.configure { |config| config.deprecations = :raise }
52 | end
53 |
54 | it('raises BlueprinterDeprecationError with deprecation message') do
55 | expect {Blueprinter::Deprecation.report(TEST_MESSAGE)}.
56 | to raise_error(Blueprinter::BlueprinterError, "[DEPRECATION::WARNING] Blueprinter: #{TEST_MESSAGE}")
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: 🐞 Bug
2 | description: File a bug/issue
3 | title: "[bug] "
4 | labels: [bug, needs-triage]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Is there an existing issue for this?
9 | description: Please search to see if an issue already exists for the bug you encountered.
10 | options:
11 | - label: I have searched the existing issues
12 | required: true
13 | - type: checkboxes
14 | attributes:
15 | label: Is this a regression?
16 | description: Did this behavior work before?
17 | options:
18 | - label: Yes, this used to work before
19 | required: false
20 | - type: textarea
21 | attributes:
22 | label: Current Behavior
23 | description: A concise description of what you're experiencing.
24 | validations:
25 | required: false
26 | - type: textarea
27 | attributes:
28 | label: Expected Behavior
29 | description: A concise description of what you expected to happen.
30 | validations:
31 | required: false
32 | - type: textarea
33 | attributes:
34 | label: Steps To Reproduce
35 | description: Steps to reproduce the behavior.
36 | placeholder: |
37 | 1.
38 | 2.
39 | 3.
40 | 4.
41 | validations:
42 | required: true
43 | - type: textarea
44 | attributes:
45 | label: Environment
46 | description: |
47 | examples:
48 | - **OS**: OSX 13.3.1
49 | - **Browser Name and Version**: Chrome Version 112.0.5615.49 (Official Build) (arm64)
50 | - **Ruby Version**: 3.0.0
51 | value: |
52 | - OS:
53 | - Browser Name and version:
54 | - Ruby Version:
55 | render: markdown
56 | validations:
57 | required: true
58 | - type: textarea
59 | attributes:
60 | label: Anything else?
61 | description: |
62 | Links? References? Anything that will give us more context about the issue you are encountering!
63 |
64 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
65 | validations:
66 | required: false
67 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Procore Projects
2 |
3 | This document explains the common procedures expected by contributors while submitting code to Procore open source projects.
4 |
5 | ## Code of Conduct
6 |
7 | Please read and abide by the [Code of Conduct](CODE_OF_CONDUCT.md)
8 |
9 | ## General workflow
10 |
11 | Once a GitHub issue is accepted and assigned to you, please follow general workflow in order to submit your contribution:
12 |
13 | 1. Fork the target repository under your GitHub username.
14 | 2. Create a branch in your forked repository for the changes you are about to make.
15 | 3. Commit your changes in the branch you created in step 2. All commits need to be signed-off. Check the [legal](#legal) section below for more details.
16 | 4. Push your commits to your remote fork.
17 | 5. Create a Pull Request from your remote fork pointing to the HEAD branch (usually `main` branch) of the target repository.
18 | 6. Check the GitHub build and ensure that all checks are green.
19 |
20 | ## Legal
21 |
22 | Procore projects use Developer Certificate of Origin ([DCO](https://GitHub.com/apps/dco/)).
23 |
24 | Please sign-off your contributions by doing ONE of the following:
25 |
26 | * Use `git commit -s ...` with each commit to add the sign-off or
27 | * Manually add a `Signed-off-by: Your Name ` to each commit message.
28 |
29 | The email address must match your primary GitHub email. You do NOT need cryptographic (e.g. gpg) signing.
30 |
31 | * Use `git commit -s --amend ...` to add a sign-off to the latest commit, if you forgot.
32 |
33 | *Note*: Some projects will provide specific configuration to ensure all commits are signed-off. Please check the project's documentation for more details.
34 |
35 | ## Tests
36 |
37 | Make sure your changes are properly covered by automated tests. We aim to build an efficient test suite that is low cost to maintain and bring value to project. Prefer writing unit-tests over heavy end-to-end (e2e) tests. However, sometimes e2e tests are necessary. If you aren't sure, ask one of the maintainers about the requirements for your pull-request.
38 |
--------------------------------------------------------------------------------
/lib/blueprinter/view.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | # @api private
5 | DefinitionPlaceholder = Struct.new :name, :view?
6 | class View
7 | attr_reader :excluded_field_names, :fields, :included_view_names, :name, :view_transformers, :definition_order
8 |
9 | def initialize(name, fields: {}, included_view_names: [], excluded_view_names: [], transformers: [])
10 | @name = name
11 | @fields = fields
12 | @included_view_names = included_view_names
13 | @excluded_field_names = excluded_view_names
14 | @view_transformers = transformers
15 | @definition_order = []
16 | @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
17 | end
18 |
19 | def track_definition_order(method, viewable: true)
20 | return unless @sort_by_definition
21 |
22 | @definition_order << DefinitionPlaceholder.new(method, viewable)
23 | end
24 |
25 | def inherit(view)
26 | view.fields.each_value do |field|
27 | self << field
28 | end
29 |
30 | view.included_view_names.each do |view_name|
31 | include_view(view_name)
32 | end
33 |
34 | view.excluded_field_names.each do |field_name|
35 | exclude_field(field_name)
36 | end
37 |
38 | view.view_transformers.each do |transformer|
39 | add_transformer(transformer)
40 | end
41 | end
42 |
43 | def include_view(view_name)
44 | track_definition_order(view_name)
45 | included_view_names << view_name
46 | end
47 |
48 | def include_views(view_names)
49 | view_names.each do |view_name|
50 | track_definition_order(view_name)
51 | included_view_names << view_name
52 | end
53 | end
54 |
55 | def exclude_field(field_name)
56 | excluded_field_names << field_name
57 | end
58 |
59 | def exclude_fields(field_names)
60 | field_names.each do |field_name|
61 | excluded_field_names << field_name
62 | end
63 | end
64 |
65 | def add_transformer(custom_transformer)
66 | view_transformers << custom_transformer
67 | end
68 |
69 | def <<(field)
70 | track_definition_order(field.name, viewable: false)
71 | fields[field.name] = field
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/blueprinter/reflection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | #
5 | # Public methods for reflecting on a Blueprint.
6 | #
7 | module Reflection
8 | Field = Struct.new(:name, :display_name, :options)
9 | Association = Struct.new(:name, :display_name, :blueprint, :view, :options)
10 |
11 | #
12 | # Returns a Hash of views keyed by name.
13 | #
14 | # Example:
15 | #
16 | # widget_view = WidgetBlueprint.reflections[:default]
17 | # category = widget_view.associations[:category]
18 | # category.blueprint
19 | # => CategoryBlueprint
20 | # category.view
21 | # => :default
22 | #
23 | # @return [Hash]
24 | #
25 | def reflections
26 | @_reflections ||= view_collection.views.transform_values do |view|
27 | View.new(view.name, view_collection)
28 | end
29 | end
30 |
31 | #
32 | # Represents a view within a Blueprint.
33 | #
34 | class View
35 | attr_reader :name
36 |
37 | def initialize(name, view_collection)
38 | @name = name
39 | @view_collection = view_collection
40 | end
41 |
42 | #
43 | # Returns a Hash of fields in this view (recursive) keyed by method name.
44 | #
45 | # @return [Hash]
46 | #
47 | def fields
48 | @_fields ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj|
49 | next if field.options[:association]
50 |
51 | obj[field.name] = Field.new(field.method, field.name, field.options)
52 | end
53 | end
54 |
55 | #
56 | # Returns a Hash of associations in this view (recursive) keyed by method name.
57 | #
58 | # @return [Hash]
59 | #
60 | def associations
61 | @_associations ||= @view_collection.fields_for(name).each_with_object({}) do |field, obj|
62 | next unless field.options[:association]
63 |
64 | blueprint = field.options.fetch(:blueprint)
65 | view = field.options[:view] || :default
66 | obj[field.name] = Association.new(field.method, field.name, blueprint, view, field.options)
67 | end
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/spec/units/view_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | describe '::View' do
4 | let(:view) { Blueprinter::View.new('Basic View') }
5 | let(:field) { MockField.new(:first_name) }
6 |
7 | describe '#include_view(:view_name)' do
8 | it 'should return [:view_name]' do
9 | expect(view.include_view(:extended)).to eq([:extended])
10 | end
11 | it 'should set #included_view_names to [:view_name]' do
12 | view.include_view(:extended)
13 | expect(view.included_view_names).to eq([:extended])
14 | end
15 | end
16 |
17 | describe '#include_views(:view_name)' do
18 | it 'should return [:view_name]' do
19 | expect(view.include_views([:normal, :special])).to eq([:normal, :special])
20 | end
21 | it 'should set #included_view_names to [:view_name]' do
22 | view.include_views([:normal, :special])
23 | expect(view.included_view_names).to eq([:normal, :special])
24 | end
25 | end
26 |
27 | describe '#exclude_field(:view_name)' do
28 | it 'should return [:view_name]' do
29 | expect(view.exclude_field(:last_name)).to eq([:last_name])
30 | end
31 | it 'should set #excluded_field_names to [:view_name]' do
32 | view.exclude_field(:last_name)
33 | expect(view.excluded_field_names).to eq([:last_name])
34 | end
35 | end
36 |
37 | describe '#exclude_fields(:view_name)' do
38 | it 'should return [:view_name]' do
39 | expect(view.exclude_fields([:last_name,:middle_name])).to eq([:last_name,:middle_name])
40 | end
41 | it 'should set #excluded_field_names to [:view_name]' do
42 | view.exclude_fields([:last_name,:middle_name])
43 | expect(view.excluded_field_names).to eq([:last_name,:middle_name])
44 | end
45 | end
46 |
47 | describe '#<<(field)' do
48 | context 'Given a field that does not exist' do
49 | it('should return field') { expect(view << field).to eq(field) }
50 | it('should set #fields to {field.name => field}') do
51 | view << field
52 | expect(view.fields).to eq({first_name: field})
53 | end
54 | end
55 |
56 | context 'Given a field that already exists' do
57 | let(:aliased_field) { MockField.new(:fname, :first_name) }
58 |
59 | before { view << field }
60 |
61 | it 'overrides previous definition' do
62 | view << aliased_field
63 |
64 | expect(view.fields).to eq(first_name: aliased_field)
65 | end
66 | end
67 | end
68 |
69 | describe '#fields' do
70 | context 'Given no fields' do
71 | it { expect(view.fields).to eq({}) }
72 | end
73 |
74 | context 'Given existing fields' do
75 | before { view << field }
76 | it('should eq {field.name => field}') do
77 | expect(view.fields).to eq({first_name: field})
78 | end
79 | end
80 | end
81 |
82 | context 'with default transform' do
83 | let(:default_transform) do
84 | class DefaultTransform < Blueprinter::Transformer; end
85 | DefaultTransform
86 | end
87 | let(:override_transform) do
88 | class OverrideTransform < Blueprinter::Transformer; end
89 | OverrideTransform
90 | end
91 | let(:view_with_default_transform) do
92 | Blueprinter::View.new('View with default transform')
93 | end
94 | let(:view_with_override_transform) do
95 | Blueprinter::View.new('View with override transform', transformers: [override_transform])
96 | end
97 |
98 | before do
99 | Blueprinter.configure { |config| config.default_transformers = [default_transform] }
100 | end
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/blueprinter_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/spec/units/extensions_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'ostruct'
4 | require 'blueprinter/extensions'
5 |
6 | describe Blueprinter::Extensions do
7 | let(:all_extensions) {
8 | [
9 | foo_extension.new,
10 | bar_extension.new,
11 | zar_extension.new,
12 | ]
13 | }
14 |
15 | let(:foo_extension) {
16 | Class.new(Blueprinter::Extension) do
17 | def pre_render(object, _blueprint, _view, _options)
18 | obj = object.dup
19 | obj.foo = "Foo"
20 | obj
21 | end
22 | end
23 | }
24 |
25 | let(:bar_extension) {
26 | Class.new(Blueprinter::Extension) do
27 | def pre_render(object, _blueprint, _view, _options)
28 | obj = object.dup
29 | obj.bar = "Bar"
30 | obj
31 | end
32 | end
33 | }
34 |
35 | let(:zar_extension) {
36 | Class.new(Blueprinter::Extension) do
37 | def self.something_else(object, _blueprint, _view, _options)
38 | object
39 | end
40 | end
41 | }
42 |
43 | it 'should append extensions' do
44 | extensions = Blueprinter::Extensions.new
45 | extensions << foo_extension.new
46 | extensions << bar_extension.new
47 | extensions << zar_extension.new
48 | expect(extensions.to_a.map(&:class)).to eq [
49 | foo_extension,
50 | bar_extension,
51 | zar_extension,
52 | ]
53 | end
54 |
55 | it "should initialize with extensions, removing any that don't have recognized extension methods" do
56 | extensions = Blueprinter::Extensions.new(all_extensions)
57 | expect(extensions.to_a.map(&:class)).to eq [
58 | foo_extension,
59 | bar_extension,
60 | zar_extension,
61 | ]
62 | end
63 |
64 | context '#pre_render' do
65 | before :each do
66 | Blueprinter.configure do |config|
67 | config.extensions = all_extensions
68 | end
69 | end
70 |
71 | after :each do
72 | Blueprinter.configure do |config|
73 | config.extensions = []
74 | end
75 | end
76 |
77 | let(:test_blueprint) {
78 | Class.new(Blueprinter::Base) do
79 | field :id
80 | field :name
81 | field :foo
82 |
83 | view :with_bar do
84 | field :bar
85 | end
86 | end
87 | }
88 |
89 | it 'should run all pre_render extensions' do
90 | extensions = Blueprinter::Extensions.new(all_extensions)
91 | obj = OpenStruct.new(id: 42, name: 'Jack')
92 | obj = extensions.pre_render(obj, test_blueprint, :default, {})
93 | expect(obj.id).to be 42
94 | expect(obj.name).to eq 'Jack'
95 | expect(obj.foo).to eq 'Foo'
96 | expect(obj.bar).to eq 'Bar'
97 | end
98 |
99 | it 'should run with Blueprinter.render using default view' do
100 | obj = OpenStruct.new(id: 42, name: 'Jack')
101 | res = JSON.parse(test_blueprint.render(obj))
102 | expect(res['id']).to be 42
103 | expect(res['name']).to eq 'Jack'
104 | expect(res['foo']).to eq 'Foo'
105 | expect(res['bar']).to be_nil
106 | end
107 |
108 | it 'should run with Blueprinter.render using with_bar view' do
109 | obj = OpenStruct.new(id: 42, name: 'Jack')
110 | res = JSON.parse(test_blueprint.render(obj, view: :with_bar))
111 | expect(res['id']).to be 42
112 | expect(res['name']).to eq 'Jack'
113 | expect(res['foo']).to eq 'Foo'
114 | expect(res['bar']).to eq 'Bar'
115 | end
116 |
117 | it 'should run with Blueprinter.render_as_hash' do
118 | obj = OpenStruct.new(id: 42, name: 'Jack')
119 | res = test_blueprint.render_as_hash(obj, view: :with_bar)
120 | expect(res[:id]).to be 42
121 | expect(res[:name]).to eq 'Jack'
122 | expect(res[:foo]).to eq 'Foo'
123 | expect(res[:bar]).to eq 'Bar'
124 | end
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/spec/units/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'oj'
4 | require 'yajl'
5 |
6 | describe 'Blueprinter' do
7 | describe '#configure' do
8 | before { Blueprinter.configure { |config| config.generator = JSON } }
9 | after { reset_blueprinter_config! }
10 |
11 | let(:extractor) do
12 | class FoodDehydrator < Blueprinter::AutoExtractor; end
13 | end
14 | let(:transform) do
15 | class UpcaseTransform < Blueprinter::Transformer; end
16 | end
17 |
18 | it 'should set the `generator`' do
19 | Blueprinter.configure { |config| config.generator = Oj }
20 | expect(Blueprinter.configuration.generator).to be(Oj)
21 | end
22 |
23 | it 'should set the `generator` and `method`' do
24 | Blueprinter.configure { |config|
25 | config.generator = Yajl::Encoder
26 | config.method = :encode
27 | }
28 | expect(Blueprinter.configuration.generator).to be(Yajl::Encoder)
29 | expect(Blueprinter.configuration.method).to be(:encode)
30 | end
31 |
32 | it 'should set the `sort_fields_by`' do
33 | Blueprinter.configure { |config|
34 | config.sort_fields_by = :definition
35 | }
36 | expect(Blueprinter.configuration.sort_fields_by).to be(:definition)
37 | end
38 |
39 | it 'should set the `if` option' do
40 | if_lambda = -> _field_name, obj, options { true }
41 | Blueprinter.configure { |config|
42 | config.if = if_lambda
43 | }
44 | expect(Blueprinter.configuration.if).to be(if_lambda)
45 | end
46 |
47 | it 'should set the `unless` option' do
48 | unless_lambda = -> _field_name, obj, options { false }
49 | Blueprinter.configure { |config|
50 | config.unless = unless_lambda
51 | }
52 | expect(Blueprinter.configuration.unless).to be(unless_lambda)
53 | end
54 |
55 | it 'should set the `field_default` option' do
56 | Blueprinter.configure { |config| config.field_default = "N/A" }
57 | expect(Blueprinter.configuration.field_default).to eq("N/A")
58 | end
59 |
60 | it 'should set the `deprecations` option' do
61 | Blueprinter.configure { |config| config.deprecations = :silence }
62 | expect(Blueprinter.configuration.deprecations).to eq(:silence)
63 | end
64 |
65 | it 'should set the `association_default` option' do
66 | Blueprinter.configure { |config| config.association_default = {} }
67 | expect(Blueprinter.configuration.association_default).to eq({})
68 | end
69 |
70 | it 'should set the `datetime_format` option' do
71 | Blueprinter.configure { |config| config.datetime_format = "%m/%d/%Y" }
72 | expect(Blueprinter.configuration.datetime_format).to eq("%m/%d/%Y")
73 | end
74 |
75 | it 'should set the `extractor_default` option' do
76 | Blueprinter.configure { |config| config.extractor_default = extractor }
77 | expect(Blueprinter.configuration.extractor_default).to eq(extractor)
78 | end
79 |
80 | it 'should default the `extractor_default` option' do
81 | Blueprinter.reset_configuration!
82 | expect(Blueprinter.configuration.extractor_default).to eq(Blueprinter::AutoExtractor)
83 | end
84 |
85 | it 'should set the `default_transformers` option' do
86 | Blueprinter.configure { |config| config.default_transformers = [transform] }
87 | expect(Blueprinter.configuration.default_transformers).to eq([transform])
88 | end
89 | end
90 |
91 | describe "::Configuration" do
92 | describe '#valid_callable?' do
93 | it 'should return true for valid callables' do
94 | [:if, :unless].each do |option|
95 | actual = Blueprinter.configuration.valid_callable?(option)
96 | expect(actual).to be(true)
97 | end
98 | end
99 |
100 | it 'should return false for invalid callable' do
101 | actual = Blueprinter.configuration.valid_callable?(:invalid_option)
102 | expect(actual).to be(false)
103 | end
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/lib/blueprinter/view_collection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/view'
4 |
5 | module Blueprinter
6 | # @api private
7 | class ViewCollection
8 | attr_reader :views, :sort_by_definition
9 |
10 | def initialize
11 | @views = {
12 | identifier: View.new(:identifier),
13 | default: View.new(:default)
14 | }
15 | @sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
16 | end
17 |
18 | def inherit(view_collection)
19 | view_collection.views.each do |view_name, view|
20 | self[view_name].inherit(view)
21 | end
22 | end
23 |
24 | def view?(view_name)
25 | views.key? view_name
26 | end
27 |
28 | def fields_for(view_name)
29 | return identifier_fields if view_name == :identifier
30 |
31 | fields, excluded_fields = sortable_fields(view_name)
32 | sorted_fields = sort_by_definition ? sort_by_def(view_name, fields) : fields.values.sort_by(&:name)
33 |
34 | (identifier_fields + sorted_fields).tap do |fields_array|
35 | fields_array.reject! { |field| excluded_fields.include?(field.name) }
36 | end
37 | end
38 |
39 | def transformers(view_name)
40 | included_transformers = gather_transformers_from_included_views(view_name).reverse
41 | all_transformers = [*views[:default].view_transformers, *included_transformers].uniq
42 | all_transformers.empty? ? Blueprinter.configuration.default_transformers : all_transformers
43 | end
44 |
45 | def [](view_name)
46 | @views[view_name] ||= View.new(view_name)
47 | end
48 |
49 | private
50 |
51 | def identifier_fields
52 | views[:identifier].fields.values
53 | end
54 |
55 | # @param [String] view_name
56 | # @return [Array<(Hash, Hash)>] fields, excluded_fields
57 | def sortable_fields(view_name)
58 | excluded_fields = {}
59 | fields = views[:default].fields.clone
60 | views[view_name].included_view_names.each do |included_view_name|
61 | next if view_name == included_view_name
62 |
63 | view_fields, view_excluded_fields = sortable_fields(included_view_name)
64 | fields.merge!(view_fields)
65 | excluded_fields.merge!(view_excluded_fields)
66 | end
67 | fields.merge!(views[view_name].fields) unless view_name == :default
68 |
69 | views[view_name].excluded_field_names.each { |name| excluded_fields[name] = nil }
70 |
71 | [fields, excluded_fields]
72 | end
73 |
74 | # select and order members of fields according to traversal of the definition_orders
75 | def sort_by_def(view_name, fields)
76 | ordered_fields = {}
77 | views[:default].definition_order.each do |definition|
78 | add_to_ordered_fields(ordered_fields, definition, fields, view_name)
79 | end
80 | ordered_fields.values
81 | end
82 |
83 | # view_name_filter allows to follow definition order all the way down starting from the view_name given to sort_by_def()
84 | # but include no others at the top-level
85 | def add_to_ordered_fields(ordered_fields, definition, fields, view_name_filter = nil)
86 | if definition.view?
87 | if view_name_filter.nil? || view_name_filter == definition.name
88 | views[definition.name].definition_order.each do |defined|
89 | add_to_ordered_fields(ordered_fields, defined, fields)
90 | end
91 | end
92 | else
93 | ordered_fields[definition.name] = fields[definition.name]
94 | end
95 | end
96 |
97 | def gather_transformers_from_included_views(view_name)
98 | current_view = views[view_name]
99 | already_included_transformers = current_view.included_view_names.flat_map do |included_view_name|
100 | next [] if view_name == included_view_name
101 |
102 | gather_transformers_from_included_views(included_view_name)
103 | end
104 | [*already_included_transformers, *current_view.view_transformers].uniq
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/lib/generators/blueprinter/blueprint_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Blueprinter
4 | module Generators
5 | class BlueprintGenerator < ::Rails::Generators::NamedBase
6 | desc 'Generates blueprint for ActiveRecord model with the given NAME.'
7 |
8 | attr_accessor :options
9 |
10 | source_root File.expand_path('templates', __dir__)
11 |
12 | class_option :blueprints_dir, default: 'app/blueprints', desc: 'path to new blueprint', aliases: '-d'
13 |
14 | class_option :identifier, default: nil,
15 | desc: 'Add an identifer to the generated blueprint, either uses :id or your specified value', aliases: '-i', banner: 'id'
16 |
17 | class_option :fields, type: :array, default: [], desc: 'Manually add specified fields'
18 |
19 | class_option :detect_fields, type: :boolean, default: false,
20 | desc: 'Introspect on the model to set fields in the generated blueprint. Will be merged with any manually specified'
21 |
22 | class_option :associations, type: :array, default: [], desc: 'Manually add specified associations', aliases: '-a'
23 |
24 | class_option :detect_associations, type: :boolean, default: false,
25 | desc: 'Introspect on the model to set associations in the generated blueprint. Will be merged with any manually specified'
26 |
27 | class_option :wrap_at, type: :numeric, default: 80, desc: 'Maximum length of generated fields line', aliases: '-w'
28 |
29 | class_option :indentation, type: :string, default: 'two', desc: 'Indentation of generated file',
30 | banner: 'two|four|tab'
31 |
32 | remove_class_option :skip_namespace
33 |
34 | def ensure_blueprint_dir
35 | FileUtils.mkdir_p(path) unless File.directory?(path)
36 | end
37 |
38 | def create_blueprint
39 | template 'blueprint.erb', File.join(path, "#{file_path}_blueprint.rb")
40 | end
41 |
42 | private
43 |
44 | def path
45 | options['blueprints_dir']
46 | end
47 |
48 | def identifier_symbol
49 | return unless options['identifier']
50 |
51 | options['identifier'] == 'identifier' ? :id : options['identifier']
52 | end
53 |
54 | def fields
55 | fs = if options['detect_fields']
56 | Array.new(options['fields']).concat(introspected_fields)
57 | else
58 | options['fields']
59 | end
60 | fs.reject(&:blank?).uniq
61 | end
62 |
63 | def introspected_fields
64 | class_name.constantize.columns_hash.keys
65 | end
66 |
67 | # split at wrap_at chars, two indentations
68 | def formatted_fields
69 | two_indents = indent * 2
70 | fields_string = fields.each_with_object([]) do |f, memo|
71 | if memo.last.nil?
72 | memo << " :#{f},"
73 | else
74 | now = "#{memo.last} :#{f},"
75 | if now.length > options['wrap_at'].to_i
76 | memo << ":#{f},"
77 | else
78 | memo[memo.length - 1] = now
79 | end
80 | end
81 | end.join("\n#{two_indents}")
82 |
83 | fields_string[0, fields_string.length - 1]
84 | end
85 |
86 | def associations
87 | as = if options['detect_associations']
88 | Array.new(options['associations']).concat(introspected_associations.keys)
89 | else
90 | options['associations']
91 | end
92 | as.reject(&:blank?).uniq
93 | end
94 |
95 | def introspected_associations
96 | class_name.constantize.reflections
97 | end
98 |
99 | def association_blueprint(association_name)
100 | ", blueprint: #{association_class(association_name)}"
101 | end
102 |
103 | def association_class(association_name)
104 | introspected_name = if introspected_associations[association_name].respond_to?(:klass)
105 | introspected_associations[association_name].klass.to_s
106 | end
107 | "#{introspected_name || association_name.camelcase}Blueprint"
108 | end
109 |
110 | def indent
111 | user_intended = { two: ' ', four: ' ', tab: "\t" }[options['indentation'].intern]
112 | user_intended.nil? ? ' ' : user_intended
113 | end
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/spec/units/date_time_formatter_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'date'
4 | require 'blueprinter/formatters/date_time_formatter'
5 |
6 | describe '::DateTimeFormatter' do
7 | let(:formatter) { Blueprinter::DateTimeFormatter.new }
8 | let(:valid_date) { Date.new(1994, 3, 4) }
9 | let(:invalid_date) { "invalid_date" }
10 | let(:invalid_field_options) { { datetime_format: 5 } }
11 |
12 | describe '#format(datetime, options)' do
13 | context 'Given no datetime format' do
14 | it 'should return original date' do
15 | expect(formatter.format(valid_date, {})).to eq(valid_date)
16 | end
17 | end
18 |
19 | context 'Given string datetime format' do
20 | let(:field_options) { { datetime_format: "%m/%d/%Y" } }
21 |
22 | context 'and given valid datetime' do
23 | it 'should return formatted date via strftime' do
24 | expect(formatter.format(valid_date, field_options)).to eq("03/04/1994")
25 | end
26 | end
27 |
28 | context 'and given invalid datetime' do
29 | it 'raises an InvalidDateTimeFormatterError' do
30 | expect{formatter.format(invalid_date, field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
31 | end
32 | end
33 |
34 | context 'and given invalid format' do
35 | it 'raises an InvalidDateTimeFormatterError' do
36 | expect{formatter.format(valid_date, invalid_field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
37 | end
38 | end
39 | end
40 |
41 | context 'Given Proc datetime format' do
42 | let(:field_options) { { datetime_format: -> datetime { datetime.year.to_s } } }
43 |
44 | context 'and given valid datetime' do
45 | it 'should return formatted date via proc' do
46 | expect(formatter.format(valid_date, field_options)).to eq("1994")
47 | end
48 | end
49 |
50 | context 'and Proc fails to process date' do
51 | let(:invalid_proc_field_options) { { datetime_format: -> datetime { datetime.invalid_method } } }
52 | it 'raises original error from Proc' do
53 | expect{formatter.format(valid_date, invalid_proc_field_options)}.to raise_error(NoMethodError)
54 | end
55 | end
56 |
57 | context 'and given invalid datetime' do
58 | it 'raises an InvalidDateTimeFormatterError' do
59 | expect{formatter.format(invalid_date, field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
60 | end
61 | end
62 |
63 | context 'and given invalid format' do
64 | it 'raises an InvalidDateTimeFormatterError' do
65 | expect{formatter.format(valid_date, invalid_field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
66 | end
67 | end
68 | end
69 |
70 | context 'Given invalid datetime format' do
71 | let(:field_options) { { datetime_format: 5 } }
72 |
73 | context 'and given valid datetime' do
74 | it 'raises an InvalidDateTimeFormatterError' do
75 | expect{formatter.format(valid_date, field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
76 | end
77 | end
78 |
79 | context 'and given invalid datetime' do
80 | it 'raises an InvalidDateTimeFormatterError' do
81 | expect{formatter.format(invalid_date, field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
82 | end
83 | end
84 |
85 | context 'and given invalid format' do
86 | it 'raises an InvalidDateTimeFormatterError' do
87 | expect{formatter.format(invalid_date, invalid_field_options)}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError)
88 | end
89 | end
90 | end
91 |
92 | context 'when global datetime_format is set' do
93 | let(:field_options) { { datetime_format: "%Y" } }
94 | before { Blueprinter.configure { |config| config.datetime_format = "%m/%d/%Y" } }
95 | after { reset_blueprinter_config! }
96 |
97 | context 'and when field datetime_format is not set' do
98 | it 'should use the global datetime_format to format date' do
99 | expect(formatter.format(valid_date, {})).to eq("03/04/1994")
100 | end
101 | end
102 |
103 | context 'and when field datetime_format is set' do
104 | it 'should use the field-level datetime_format to format date' do
105 | expect(formatter.format(valid_date, field_options)).to eq("1994")
106 | end
107 | end
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/spec/units/reflection_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/reflection'
4 |
5 | describe Blueprinter::Reflection do
6 | let(:category_blueprint) {
7 | Class.new(Blueprinter::Base) do
8 | fields :id, :name
9 | end
10 | }
11 |
12 | let(:part_blueprint) {
13 | Class.new(Blueprinter::Base) do
14 | fields :id, :name
15 |
16 | view :extended do
17 | field :description
18 | end
19 | end
20 | }
21 |
22 | let(:widget_blueprint) {
23 | cat_bp = category_blueprint
24 | part_bp = part_blueprint
25 | Class.new(Blueprinter::Base) do
26 | fields :id, :name
27 | association :category, blueprint: cat_bp
28 |
29 | view :extended do
30 | association :parts, blueprint: part_bp, view: :extended
31 | end
32 |
33 | view :extended_plus do
34 | include_view :extended
35 | field :foo
36 | association :foos, blueprint: part_bp
37 | end
38 |
39 | view :extended_plus_plus do
40 | include_view :extended_plus
41 | field :bar
42 | association :bars, blueprint: part_bp
43 | end
44 |
45 | view :legacy do
46 | association :parts, blueprint: part_bp, name: :pieces
47 | end
48 |
49 | view :aliased_names do
50 | field :name, name: :aliased_name
51 | association :category, blueprint: cat_bp, name: :aliased_category
52 | end
53 |
54 | view :overridden_fields do
55 | field :override_field, name: :name
56 | association :overridden_category, name: :category, blueprint: cat_bp
57 | end
58 | end
59 | }
60 |
61 | it 'should list views' do
62 | expect(widget_blueprint.reflections.keys.sort).to eq [
63 | :identifier,
64 | :default,
65 | :extended,
66 | :extended_plus,
67 | :extended_plus_plus,
68 | :legacy,
69 | :aliased_names,
70 | :overridden_fields
71 | ].sort
72 | end
73 |
74 | it 'should list fields' do
75 | expect(part_blueprint.reflections.fetch(:extended).fields.keys.sort).to eq [
76 | :id,
77 | :name,
78 | :description,
79 | ].sort
80 | end
81 |
82 | it 'should list fields from included views' do
83 | expect(widget_blueprint.reflections.fetch(:extended_plus_plus).fields.keys.sort).to eq [
84 | :id,
85 | :name,
86 | :foo,
87 | :bar,
88 | ].sort
89 | end
90 |
91 | it 'should list aliased fields also included in default view' do
92 | fields = widget_blueprint.reflections.fetch(:aliased_names).fields
93 | expect(fields.keys.sort).to eq [
94 | :id,
95 | :name,
96 | :aliased_name,
97 | ].sort
98 | end
99 |
100 | it "should list overridden fields" do
101 | fields = widget_blueprint.reflections.fetch(:overridden_fields).fields
102 | expect(fields.keys.sort).to eq [
103 | :id,
104 | :name,
105 | ].sort
106 | name_field = fields[:name]
107 | expect(name_field.name).to eq :override_field
108 | expect(name_field.display_name).to eq :name
109 | end
110 |
111 | it 'should list associations' do
112 | associations = widget_blueprint.reflections.fetch(:default).associations
113 | expect(associations.keys).to eq [:category]
114 | end
115 |
116 | it 'should list associations from included views' do
117 | associations = widget_blueprint.reflections.fetch(:extended_plus_plus).associations
118 | expect(associations.keys.sort).to eq [:category, :parts, :foos, :bars].sort
119 | end
120 |
121 | it 'should list associations using custom names' do
122 | associations = widget_blueprint.reflections.fetch(:legacy).associations
123 | expect(associations.keys).to eq [:category, :pieces]
124 | expect(associations[:pieces].display_name).to eq :pieces
125 | expect(associations[:pieces].name).to eq :parts
126 | end
127 |
128 | it 'should list aliased associations also included in default view' do
129 | associations = widget_blueprint.reflections.fetch(:aliased_names).associations
130 | expect(associations.keys.sort).to eq [
131 | :category,
132 | :aliased_category
133 | ].sort
134 | end
135 |
136 | it 'should list overridden associations' do
137 | associations = widget_blueprint.reflections.fetch(:overridden_fields).associations
138 | expect(associations.keys.sort).to eq [
139 | :category,
140 | ].sort
141 | category_association = associations[:category]
142 | expect(category_association.name).to eq :overridden_category
143 | expect(category_association.display_name).to eq :category
144 | end
145 |
146 | it 'should get a blueprint and view from an association' do
147 | assoc = widget_blueprint.reflections[:extended].associations[:parts]
148 | expect(assoc.name).to eq :parts
149 | expect(assoc.display_name).to eq :parts
150 | expect(assoc.blueprint).to eq part_blueprint
151 | expect(assoc.view).to eq :extended
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Spam or other deceptive practices that take advantage of the community
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at
64 | .
65 | All complaints will be reviewed and investigated promptly and fairly by
66 | the Procore Open Source Program Office (OSPO)
67 |
68 | All community leaders are obligated to respect the privacy and security of the
69 | reporter of any incident.
70 |
71 | ## Enforcement Guidelines
72 |
73 | Community leaders will follow these Community Impact Guidelines in determining
74 | the consequences for any action they deem in violation of this Code of Conduct:
75 |
76 | ### 1. Correction
77 |
78 | **Community Impact**: Use of inappropriate language or other behavior deemed
79 | unprofessional or unwelcome in the community.
80 |
81 | **Consequence**: A private, written warning from community leaders, providing
82 | clarity around the nature of the violation and an explanation of why the
83 | behavior was inappropriate. A public apology may be requested.
84 |
85 | ### 2. Warning
86 |
87 | **Community Impact**: A violation through a single incident or series
88 | of actions.
89 |
90 | **Consequence**: A warning with consequences for continued behavior. No
91 | interaction with the people involved, including unsolicited interaction with
92 | those enforcing the Code of Conduct, for a specified period of time. This
93 | includes avoiding interactions in community spaces as well as external channels
94 | like social media. Violating these terms may lead to a temporary or
95 | permanent ban.
96 |
97 | ### 3. Temporary Ban
98 |
99 | **Community Impact**: A serious violation of community standards, including
100 | sustained inappropriate behavior.
101 |
102 | **Consequence**: A temporary ban from any sort of interaction or public
103 | communication with the community for a specified period of time. No public or
104 | private interaction with the people involved, including unsolicited interaction
105 | with those enforcing the Code of Conduct, is allowed during this period.
106 | Violating these terms may lead to a permanent ban.
107 |
108 | ### 4. Permanent Ban
109 |
110 | **Community Impact**: Demonstrating a pattern of violation of community
111 | standards, including sustained inappropriate behavior, harassment of an
112 | individual, or aggression toward or disparagement of classes of individuals.
113 |
114 | **Consequence**: A permanent ban from any sort of public interaction within
115 | the community.
116 |
117 | ## Attribution
118 |
119 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
120 | version 2.0, available at
121 | .
122 |
123 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
124 | enforcement ladder](https://github.com/mozilla/diversity).
125 |
126 | [homepage]: https://www.contributor-covenant.org
127 |
128 | For answers to common questions about this code of conduct, see the FAQ at
129 | . Translations are available at
130 | .
131 |
--------------------------------------------------------------------------------
/spec/units/view_collection_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/view_collection'
4 |
5 | describe 'ViewCollection' do
6 | subject(:view_collection) { Blueprinter::ViewCollection.new }
7 |
8 | let!(:default_view) { view_collection[:default] }
9 | let!(:view) { view_collection[:view] }
10 |
11 | let(:default_field) { MockField.new(:default_field) }
12 | let(:view_field) { MockField.new(:view_field) }
13 | let(:new_field) { MockField.new(:new_field) }
14 |
15 | let(:default_transformer) { Blueprinter::Transformer.new }
16 |
17 | before do
18 | default_view << default_field
19 | view << view_field
20 | end
21 |
22 | describe '#initialize' do
23 | it 'should create an identifier, view, and default view' do
24 | expect(view_collection.views.keys).to eq([:identifier, :default, :view])
25 | end
26 | end
27 |
28 | describe '#[]' do
29 | it 'should return the view if it exists' do
30 | expect(view_collection.views[:default]).to eq(default_view)
31 | end
32 |
33 | it 'should create the view if it does not exist' do
34 | new_view = view_collection[:new_view]
35 | expect(view_collection.views[:new_view]).to eq(new_view)
36 | end
37 | end
38 |
39 | describe '#view?' do
40 | it 'should return true if the view exists' do
41 | expect(view_collection.view?(:default)).to eq(true)
42 | end
43 |
44 | it 'should return false if the view does not exist' do
45 | expect(view_collection.view?(:missing_view)).to eq(false)
46 | end
47 | end
48 |
49 | describe '#inherit' do
50 | let(:parent_view_collection) { Blueprinter::ViewCollection.new }
51 |
52 | before do
53 | parent_view_collection[:view] << new_field
54 | end
55 |
56 | it 'should inherit the fields from the parent view collection' do
57 | view_collection.inherit(parent_view_collection)
58 | expect(view.fields).to include(parent_view_collection[:view].fields)
59 | end
60 | end
61 |
62 | describe '#fields_for' do
63 | it 'should return the fields for the view' do
64 | expect(view_collection.fields_for(:view)).to eq([default_field, view_field])
65 | end
66 | end
67 |
68 | describe '#transformers' do
69 | let(:transformer) { Blueprinter::Transformer.new }
70 |
71 | before do
72 | view.add_transformer(transformer)
73 | end
74 |
75 | it 'should return the transformers for the view' do
76 | expect(view_collection.transformers(:view)).to eq([transformer])
77 | end
78 |
79 | it 'should not return any transformers for another view' do
80 | view_collection[:foo]
81 | expect(view_collection.transformers(:foo)).to eq([])
82 | end
83 |
84 | context 'default view transformer' do
85 | before do
86 | default_view.add_transformer(default_transformer)
87 | end
88 |
89 | it 'should return the transformers for the default view' do
90 | expect(view_collection.transformers(:default)).to eq([default_transformer])
91 | end
92 |
93 | it 'should return both the view transformer and default transformers for the view' do
94 | expect(view_collection.transformers(:view)).to eq([default_transformer, transformer])
95 | end
96 |
97 | it 'should not alter view transformers of the view on subsequent fetches' do
98 | view_collection.transformers(:default)
99 | expect { view_collection.transformers(:default) }
100 | .not_to change(default_view.view_transformers, :count)
101 | end
102 | end
103 |
104 | context 'include view transformer' do
105 | let!(:includes_view) { view_collection[:includes_view] }
106 | let!(:nested_view) { view_collection[:nested_view] }
107 |
108 | before do
109 | includes_view.include_view(:view)
110 | nested_view.include_view(:includes_view)
111 | end
112 |
113 | it 'should return the transformers for the included view' do
114 | expect(view_collection.transformers(:includes_view)).to include(transformer)
115 | end
116 |
117 | it 'should return the transformers for the nested included view' do
118 | expect(view_collection.transformers(:nested_view)).to include(transformer)
119 | end
120 |
121 | it 'should only return unique transformers' do
122 | includes_view.add_transformer(transformer)
123 | transformers = view_collection.transformers(:nested_view)
124 | expect(transformers.uniq.length == transformers.length).to eq(true)
125 | end
126 |
127 | it 'should return transformers in the correct order' do
128 | includes_view_transformer = Blueprinter::Transformer.new
129 | nested_view_transformer = Blueprinter::Transformer.new
130 |
131 | default_view.add_transformer(default_transformer)
132 | includes_view.add_transformer(includes_view_transformer)
133 | nested_view.add_transformer(nested_view_transformer)
134 |
135 | expect(view_collection.transformers(:nested_view)).to eq([
136 | default_transformer, nested_view_transformer, includes_view_transformer, transformer
137 | ])
138 | end
139 | end
140 |
141 | context 'global default transformers' do
142 | before do
143 | Blueprinter.configure { |config| config.default_transformers = [default_transformer] }
144 | end
145 |
146 | context 'with no view transformers' do
147 | let!(:new_view) { view_collection[:new_view] }
148 |
149 | it 'should return the global default transformers' do
150 | expect(view_collection.transformers(:new_view)).to include(default_transformer)
151 | end
152 | end
153 |
154 | context 'with view transformers' do
155 | it 'should not return the global default transformers' do
156 | expect(view_collection.transformers(:view)).to_not include(default_transformer)
157 | end
158 | end
159 | end
160 | end
161 | end
162 |
--------------------------------------------------------------------------------
/spec/generators/blueprint_generator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'generator_helper'
4 |
5 | require 'generators/blueprinter/blueprint_generator'
6 |
7 | RSpec.describe Blueprinter::Generators::BlueprintGenerator, :type => :generator do
8 | include_context "generator_destination"
9 |
10 | it 'runs at all' do
11 | gen = generator %w(:vehicle)
12 | expect(gen).to receive :ensure_blueprint_dir
13 | expect(gen).to receive :create_blueprint
14 | gen.invoke_all
15 | end
16 |
17 | include_context "vehicle_subject" do
18 | describe 'generates an empty blueprint' do
19 | include_examples "generated_file"
20 | before do
21 | run_generator %W(#{model})
22 | end
23 |
24 | it "file" do
25 | is_expected.to exist
26 | end
27 |
28 | it "class declaration" do
29 | is_expected.to contain(/class VehicleBlueprint/)
30 | end
31 | end
32 |
33 | describe "given -d" do
34 | include_examples "generated_file"
35 | before do
36 | run_generator %W(#{model} -d wherevs)
37 | end
38 |
39 | subject { file('wherevs/vehicle_blueprint.rb') }
40 |
41 | it "file is created where we say" do
42 | is_expected.to exist
43 | end
44 | end
45 |
46 | describe "given -i" do
47 | include_examples "generated_file"
48 | before do
49 | run_generator %W(#{model} -i)
50 | end
51 |
52 | it "blueprint file has identifier" do
53 | is_expected.to contain(/identifier :id/)
54 | end
55 | end
56 |
57 | describe "given -i customid" do
58 | include_examples "generated_file"
59 | before do
60 | run_generator %W(#{model} -i customid)
61 | end
62 |
63 | it "blueprint file has customid" do
64 | is_expected.to contain(/customid/)
65 | end
66 | end
67 |
68 | describe "given --fields=yoohoo hi_there" do
69 | include_examples "generated_file"
70 | before do
71 | run_generator %W(#{model} --fields=yoohoo hi_there)
72 | end
73 |
74 | it "blueprint file has manual fields" do
75 | is_expected.to contain(/fields :yoohoo, :hi_there/)
76 | end
77 | end
78 |
79 | describe "given --detect_fields" do
80 | include_examples "generated_file"
81 | before do
82 | run_generator %W(#{model} --detect_fields)
83 | end
84 |
85 | it "blueprint file has detected fields" do
86 | is_expected.to contain(/:make, :model, :miles/)
87 | end
88 | end
89 |
90 | describe "given --detect_fields --fields=mooo" do
91 | include_examples "generated_file"
92 | before do
93 | run_generator %W(#{model} --detect_fields --fields=mooo)
94 | end
95 |
96 | it "blueprint file has detected fields and manual field" do
97 | is_expected.to contain(/:make, :model, :miles/)
98 | is_expected.to contain(/:mooo/)
99 | end
100 | end
101 |
102 | describe "given --detect_fields --fields=make model moooo" do
103 | include_examples "generated_file"
104 | before do
105 | run_generator %W(#{model} --detect_fields --fields=make model moooo)
106 | end
107 |
108 | it "blueprint file has deduped detected fields and manual fields" do
109 | File.open(subject) do |f|
110 | generated = f.read
111 | expect(generated.scan('make').size).to eq(1)
112 | expect(generated.scan('model').size).to eq(1)
113 | end
114 | is_expected.to contain(/:mooo/)
115 | end
116 | end
117 |
118 | describe "given -a=not_really" do
119 | include_examples "generated_file"
120 | before do
121 | run_generator %W(#{model} -a=not_really)
122 | end
123 |
124 | it "blueprint file has manual association" do
125 | is_expected.to contain(/association :not_really/)
126 | is_expected.to contain(/blueprint: NotReallyBlueprint/)
127 | end
128 | end
129 |
130 | describe "given -a=user" do
131 | include_examples "generated_file"
132 | before do
133 | run_generator %W(#{model} -a=user)
134 | end
135 |
136 | it "blueprint file has manual association" do
137 | is_expected.to contain(/association :user/)
138 | is_expected.to contain(/blueprint: UserBlueprint/)
139 | end
140 | end
141 |
142 | describe "given --detect_associations" do
143 | include_examples "generated_file"
144 | before do
145 | run_generator %W(#{model} --detect_associations)
146 | end
147 |
148 | it "blueprint file has detected association" do
149 | is_expected.to contain(/association :user/)
150 | is_expected.to contain(/blueprint: UserBlueprint/)
151 | end
152 | end
153 |
154 | describe "given --detect_associations --detect_fields --indentation=tab" do
155 | include_examples "generated_file"
156 | before do
157 | run_generator %W(#{model} --detect_associations --detect_fields --indentation=tab)
158 | end
159 |
160 | it "blueprint file has tab indent" do
161 | is_expected.to contain(/\tfields/)
162 | is_expected.to contain(/\tassociation/)
163 | end
164 | end
165 |
166 | describe "given --detect_associations --detect_fields -w 10" do
167 | include_examples "generated_file"
168 | before do
169 | run_generator %W(#{model} --detect_associations --detect_fields -w 10)
170 | end
171 |
172 | it "blueprint file has wrapped fields declaration" do
173 | is_expected.to contain(/:make,\n :model,\n :miles/)
174 | end
175 | end
176 | end
177 |
178 | describe "given namespaced model " do
179 | include_examples "generated_file"
180 | before do
181 | run_generator %w(electric/truck)
182 | end
183 |
184 | subject { file('app/blueprints/electric/truck_blueprint.rb') }
185 |
186 | it "blueprint file has namespace directory / class" do
187 | is_expected.to contain(/class Electric::TruckBlueprint/)
188 | end
189 | end
190 |
191 | end
192 |
--------------------------------------------------------------------------------
/lib/blueprinter/rendering.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'blueprinter/errors/invalid_root'
4 | require 'blueprinter/errors/meta_requires_root'
5 | require 'blueprinter/deprecation'
6 |
7 | module Blueprinter
8 | # Encapsulates the rendering logic for Blueprinter.
9 | module Rendering
10 | include TypeHelpers
11 |
12 | # Generates a JSON formatted String represantation of the provided object.
13 | #
14 | # @param object [Object] the Object to serialize.
15 | # @param options [Hash] the options hash which requires a :view. Any
16 | # additional key value pairs will be exposed during serialization.
17 | # @option options [Symbol] :view Defaults to :default.
18 | # The view name that corresponds to the group of
19 | # fields to be serialized.
20 | # @option options [Symbol|String] :root Defaults to nil.
21 | # Render the json/hash with a root key if provided.
22 | # @option options [Any] :meta Defaults to nil.
23 | # Render the json/hash with a meta attribute with provided value
24 | # if both root and meta keys are provided in the options hash.
25 | #
26 | # @example Generating JSON with an extended view
27 | # post = Post.all
28 | # Blueprinter::Base.render post, view: :extended
29 | # # => "[{\"id\":1,\"title\":\"Hello\"},{\"id\":2,\"title\":\"My Day\"}]"
30 | #
31 | # @return [String] JSON formatted String
32 | def render(object, options = {})
33 | jsonify(build_result(object: object, options: options))
34 | end
35 |
36 | # Generates a Hash representation of the provided object.
37 | # Takes a required object and an optional view.
38 | #
39 | # @param object [Object] the Object to serialize upon.
40 | # @param options [Hash] the options hash which requires a :view. Any
41 | # additional key value pairs will be exposed during serialization.
42 | # @option options [Symbol] :view Defaults to :default.
43 | # The view name that corresponds to the group of
44 | # fields to be serialized.
45 | # @option options [Symbol|String] :root Defaults to nil.
46 | # Render the json/hash with a root key if provided.
47 | # @option options [Any] :meta Defaults to nil.
48 | # Render the json/hash with a meta attribute with provided value
49 | # if both root and meta keys are provided in the options hash.
50 | #
51 | # @example Generating a hash with an extended view
52 | # post = Post.all
53 | # Blueprinter::Base.render_as_hash post, view: :extended
54 | # # => [{id:1, title: Hello},{id:2, title: My Day}]
55 | #
56 | # @return [Hash]
57 | def render_as_hash(object, options = {})
58 | build_result(object: object, options: options)
59 | end
60 |
61 | # Generates a JSONified hash.
62 | # Takes a required object and an optional view.
63 | #
64 | # @param object [Object] the Object to serialize upon.
65 | # @param options [Hash] the options hash which requires a :view. Any
66 | # additional key value pairs will be exposed during serialization.
67 | # @option options [Symbol] :view Defaults to :default.
68 | # The view name that corresponds to the group of
69 | # fields to be serialized.
70 | # @option options [Symbol|String] :root Defaults to nil.
71 | # Render the json/hash with a root key if provided.
72 | # @option options [Any] :meta Defaults to nil.
73 | # Render the json/hash with a meta attribute with provided value
74 | # if both root and meta keys are provided in the options hash.
75 | #
76 | # @example Generating a hash with an extended view
77 | # post = Post.all
78 | # Blueprinter::Base.render_as_json post, view: :extended
79 | # # => [{"id" => "1", "title" => "Hello"},{"id" => "2", "title" => "My Day"}]
80 | #
81 | # @return [Hash]
82 | def render_as_json(object, options = {})
83 | build_result(object: object, options: options).as_json
84 | end
85 |
86 | # Converts an object into a Hash representation based on provided view.
87 | #
88 | # @param object [Object] the Object to convert into a Hash.
89 | # @param view_name [Symbol] the view
90 | # @param local_options [Hash] the options hash which requires a :view. Any
91 | # additional key value pairs will be exposed during serialization.
92 | # @return [Hash]
93 | def hashify(object, view_name:, local_options:)
94 | raise BlueprinterError, "View '#{view_name}' is not defined" unless view_collection.view?(view_name)
95 |
96 | object = Blueprinter.configuration.extensions.pre_render(object, self, view_name, local_options)
97 | prepare_data(object, view_name, local_options)
98 | end
99 |
100 | # @deprecated This method is no longer supported, and was not originally intended to be public. This will be removed
101 | # in the next minor release. If similar functionality is needed, use `.render_as_hash` instead.
102 | #
103 | # This is the magic method that converts complex objects into a simple hash
104 | # ready for JSON conversion.
105 | #
106 | # Note: we accept view (public interface) that is in reality a view_name,
107 | # so we rename it for clarity
108 | #
109 | # @api private
110 | def prepare(object, view_name:, local_options:)
111 | Blueprinter::Deprecation.report(
112 | <<~MESSAGE
113 | The `prepare` method is no longer supported will be removed in the next minor release.
114 | If similar functionality is needed, use `.render_as_hash` instead.
115 | MESSAGE
116 | )
117 | render_as_hash(object, view_name:, local_options:)
118 | end
119 |
120 | private
121 |
122 | attr_reader :blueprint, :options
123 |
124 | def prepare_data(object, view_name, local_options)
125 | if array_like?(object)
126 | object.map do |obj|
127 | object_to_hash(
128 | obj,
129 | view_name: view_name,
130 | local_options: local_options
131 | )
132 | end
133 | else
134 | object_to_hash(
135 | object,
136 | view_name: view_name,
137 | local_options: local_options
138 | )
139 | end
140 | end
141 |
142 | def object_to_hash(object, view_name:, local_options:)
143 | result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash|
144 | next if field.skip?(field.name, object, local_options)
145 |
146 | value = field.extract(object, local_options.merge(view: view_name))
147 | next if value.nil? && field.options[:exclude_if_nil]
148 |
149 | hash[field.name] = value
150 | end
151 | view_collection.transformers(view_name).each do |transformer|
152 | transformer.transform(result_hash, object, local_options)
153 | end
154 | result_hash
155 | end
156 |
157 | def jsonify(data)
158 | Blueprinter.configuration.jsonify(data)
159 | end
160 |
161 | def apply_root_key(object:, root:)
162 | return object unless root
163 | return { root => object } if root.is_a?(String) || root.is_a?(Symbol)
164 |
165 | raise(Errors::InvalidRoot)
166 | end
167 |
168 | def add_metadata(object:, metadata:, root:)
169 | return object unless metadata
170 | return object.merge(meta: metadata) if root
171 |
172 | raise(Errors::MetaRequiresRoot)
173 | end
174 |
175 | def build_result(object:, options:)
176 | view_name = options.fetch(:view, :default) || :default
177 |
178 | prepared_object = hashify(
179 | object,
180 | view_name: view_name,
181 | local_options: options.except(:view, :root, :meta)
182 | )
183 | object_with_root = apply_root_key(
184 | object: prepared_object,
185 | root: options[:root]
186 | )
187 | add_metadata(
188 | object: object_with_root,
189 | metadata: options[:meta],
190 | root: options[:root]
191 | )
192 | end
193 | end
194 | end
195 |
--------------------------------------------------------------------------------
/lib/blueprinter/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'association'
4 | require_relative 'extractors/association_extractor'
5 | require_relative 'field'
6 | require_relative 'reflection'
7 | require_relative 'rendering'
8 | require_relative 'view_collection'
9 |
10 | module Blueprinter
11 | class Base
12 | extend Reflection
13 | extend Rendering
14 |
15 | class << self
16 | # Specify a field or method name used as an identifier. Usually, this is
17 | # something like `:id`.
18 | #
19 | # Note: identifiers are always rendered and considered their own view,
20 | # similar to the :default view.
21 | #
22 | # @param method [Symbol] the method or field used as an identifier that you
23 | # want to set for serialization.
24 | # @param name [Symbol] to rename the identifier key in the JSON
25 | # output. Defaults to method given.
26 | # @param extractor [AssociationExtractor,AutoExtractor,BlockExtractor,HashExtractor,PublicSendExtractor]
27 | # @yield [object, options] The object and the options passed to render are
28 | # also yielded to the block.
29 | #
30 | # Kind of extractor to use.
31 | # Either define your own or use Blueprinter's premade extractors.
32 | # Defaults to AutoExtractor
33 | #
34 | # @example Specifying a uuid as an identifier.
35 | # class UserBlueprint < Blueprinter::Base
36 | # identifier :uuid
37 | # # other code
38 | # end
39 | #
40 | # @example Passing a block to be evaluated as the value.
41 | # class UserBlueprint < Blueprinter::Base
42 | # identifier :uuid do |user, options|
43 | # options[:current_user].anonymize(user.uuid)
44 | # end
45 | # end
46 | #
47 | # @return [Field] A Field object
48 | def identifier(method, name: method, extractor: Blueprinter.configuration.extractor_default.new, &block)
49 | view_collection[:identifier] << Field.new(
50 | method,
51 | name,
52 | extractor,
53 | self,
54 | block:
55 | )
56 | end
57 |
58 | # Specify a field or method name to be included for serialization.
59 | # Takes a required method and an option.
60 | #
61 | # @param method [Symbol] the field or method name you want to include for
62 | # serialization.
63 | # @param options [Hash] options to overide defaults.
64 | # @option options [AssociationExtractor,BlockExtractor,HashExtractor,PublicSendExtractor] :extractor
65 | # Kind of extractor to use.
66 | # Either define your own or use Blueprinter's premade extractors. The
67 | # Default extractor is AutoExtractor
68 | # @option options [Symbol] :name Use this to rename the method. Useful if
69 | # if you want your JSON key named differently in the output than your
70 | # object's field or method name.
71 | # @option options [String,Proc] :datetime_format Format Date or DateTime object
72 | # If the option provided is a String, the object will be formatted with given strftime
73 | # formatting.
74 | # If this option is a Proc, the object will be formatted by calling the provided Proc
75 | # on the Date/DateTime object.
76 | # @option options [Symbol,Proc] :if Specifies a method, proc or string to
77 | # call to determine if the field should be included (e.g.
78 | # `if: :include_name?, or if: Proc.new { |_field_name, user, options| options[:current_user] == user }).
79 | # The method, proc or string should return or evaluate to a true or false value.
80 | # @option options [Symbol,Proc] :unless Specifies a method, proc or string
81 | # to call to determine if the field should be included (e.g.
82 | # `unless: :include_name?, or unless: Proc.new { |_field_name, user, options| options[:current_user] != user }).
83 | # The method, proc or string should return or evaluate to a true or false value.
84 | # @yield [object, options] The object and the options passed to render are
85 | # also yielded to the block.
86 | #
87 | # @example Specifying a user's first_name to be serialized.
88 | # class UserBlueprint < Blueprinter::Base
89 | # field :first_name
90 | # # other code
91 | # end
92 | #
93 | # @example Passing a block to be evaluated as the value.
94 | # class UserBlueprint < Blueprinter::Base
95 | # field :full_name do |object, options|
96 | # "options[:title_prefix] #{object.first_name} #{object.last_name}"
97 | # end
98 | # # other code
99 | # end
100 | #
101 | # @example Passing an if proc and unless method.
102 | # class UserBlueprint < Blueprinter::Base
103 | # def skip_first_name?(_field_name, user, options)
104 | # user.first_name == options[:first_name]
105 | # end
106 | #
107 | # field :first_name, unless: :skip_first_name?
108 | # field :last_name, if: ->(_field_name, user, options) { user.first_name != options[:first_name] }
109 | # # other code
110 | # end
111 | #
112 | # @return [Field] A Field object
113 | def field(method, options = {}, &block)
114 | method = method.to_sym
115 |
116 | current_view << Field.new(
117 | method,
118 | options.fetch(:name) { method },
119 | options.fetch(:extractor) { Blueprinter.configuration.extractor_default.new },
120 | self,
121 | options.merge(block:)
122 | )
123 | end
124 |
125 | # Specify an associated object to be included for serialization.
126 | # Takes a required method and an option.
127 | #
128 | # @param method [Symbol] the association name
129 | # @param options [Hash] options to overide defaults.
130 | # @option options [Symbol] :blueprint Required. Use this to specify the
131 | # blueprint to use for the associated object.
132 | # @option options [Symbol] :name Use this to rename the association in the
133 | # JSON output.
134 | # @option options [Symbol] :view Specify the view to use or fall back to
135 | # to the :default view.
136 | # @yield [object, options] The object and the options passed to render are
137 | # also yielded to the block.
138 | #
139 | # @example Specifying an association
140 | # class UserBlueprint < Blueprinter::Base
141 | # # code
142 | # association :vehicles, view: :extended, blueprint: VehiclesBlueprint
143 | # # code
144 | # end
145 | #
146 | # @example Passing a block to be evaluated as the value.
147 | # class UserBlueprint < Blueprinter::Base
148 | # association :vehicles, blueprint: VehiclesBlueprint do |user, opts|
149 | # user.vehicles + opts[:additional_vehicles]
150 | # end
151 | # end
152 | #
153 | # @return [Association] An object
154 | # @raise [Blueprinter::Errors::InvalidBlueprint] if provided blueprint is not valid
155 | def association(method, options = {}, &block)
156 | raise ArgumentError, ':blueprint must be provided when defining an association' unless options[:blueprint]
157 |
158 | method = method.to_sym
159 | current_view << Association.new(
160 | method:,
161 | name: options.fetch(:name) { method },
162 | extractor: options.fetch(:extractor) { AssociationExtractor.new },
163 | blueprint: options.fetch(:blueprint),
164 | parent_blueprint: self,
165 | view: options.fetch(:view, :default),
166 | options: options.except(:name, :extractor, :blueprint, :view).merge(block:)
167 | )
168 | end
169 |
170 | # Specify one or more field/method names to be included for serialization.
171 | # Takes at least one field or method names.
172 | #
173 | # @param method [Symbol] the field or method name you want to include for
174 | # serialization.
175 | #
176 | # @example Specifying a user's first_name and last_name to be serialized.
177 | # class UserBlueprint < Blueprinter::Base
178 | # fields :first_name, :last_name
179 | # # other code
180 | # end
181 | #
182 | # @return [Array] an array of field names
183 | def fields(*field_names)
184 | field_names.each do |field_name|
185 | field(field_name)
186 | end
187 | end
188 |
189 | # Specify one transformer to be included for serialization.
190 | # Takes a class which extends Blueprinter::Transformer
191 | #
192 | # @param class name [Class] which implements the method transform to include for
193 | # serialization.
194 | #
195 | #
196 | # @example Specifying a DynamicFieldTransformer transformer for including dynamic fields to be serialized.
197 | # class User
198 | # def custom_columns
199 | # dynamic_fields # which is an array of some columns
200 | # end
201 | #
202 | # def custom_fields
203 | # custom_columns.each_with_object({}) { |col,result| result[col] = send(col) }
204 | # end
205 | # end
206 | #
207 | # class UserBlueprint < Blueprinter::Base
208 | # fields :first_name, :last_name
209 | # transform DynamicFieldTransformer
210 | # # other code
211 | # end
212 | #
213 | # class DynamicFieldTransformer < Blueprinter::Transformer
214 | # def transform(hash, object, options)
215 | # hash.merge!(object.dynamic_fields)
216 | # end
217 | # end
218 | #
219 | # @return [Array] an array of transformers
220 | def transform(transformer)
221 | current_view.add_transformer(transformer)
222 | end
223 |
224 | # Specify another view that should be mixed into the current view.
225 | #
226 | # @param view_name [Symbol] the view to mix into the current view.
227 | #
228 | # @example Including a normal view into an extended view.
229 | # class UserBlueprint < Blueprinter::Base
230 | # # other code...
231 | # view :normal do
232 | # fields :first_name, :last_name
233 | # end
234 | # view :extended do
235 | # include_view :normal # include fields specified from above.
236 | # field :description
237 | # end
238 | # #=> [:first_name, :last_name, :description]
239 | # end
240 | #
241 | # @return [Array] an array of view names.
242 | def include_view(view_name)
243 | current_view.include_view(view_name)
244 | end
245 |
246 | # Specify additional views that should be mixed into the current view.
247 | #
248 | # @param view_name [Array] the views to mix into the current view.
249 | #
250 | # @example Including the normal and special views into an extended view.
251 | # class UserBlueprint < Blueprinter::Base
252 | # # other code...
253 | # view :normal do
254 | # fields :first_name, :last_name
255 | # end
256 | # view :special do
257 | # fields :birthday, :company
258 | # end
259 | # view :extended do
260 | # include_views :normal, :special # include fields specified from above.
261 | # field :description
262 | # end
263 | # #=> [:first_name, :last_name, :birthday, :company, :description]
264 | # end
265 | #
266 | # @return [Array] an array of view names.
267 | def include_views(*view_names)
268 | current_view.include_views(view_names)
269 | end
270 |
271 | # Exclude a field that was mixed into the current view.
272 | #
273 | # @param field_name [Symbol] the field to exclude from the current view.
274 | #
275 | # @example Excluding a field from being included into the current view.
276 | # view :normal do
277 | # fields :position, :company
278 | # end
279 | # view :special do
280 | # include_view :normal
281 | # field :birthday
282 | # exclude :position
283 | # end
284 | # #=> [:company, :birthday]
285 | #
286 | # @return [Array] an array of field names
287 | def exclude(field_name)
288 | current_view.exclude_field(field_name)
289 | end
290 |
291 | # When mixing multiple views under a single view, some fields may required to be excluded from
292 | # current view
293 | #
294 | # @param [Array] the fields to exclude from the current view.
295 | #
296 | # @example Excluding mutiple fields from being included into the current view.
297 | # view :normal do
298 | # fields :name,:address,:position,
299 | # :company, :contact
300 | # end
301 | # view :special do
302 | # include_view :normal
303 | # fields :birthday,:joining_anniversary
304 | # excludes :position,:address
305 | # end
306 | # => [:name, :company, :contact, :birthday, :joining_anniversary]
307 | #
308 | # @return [Array] an array of field names
309 | def excludes(*field_names)
310 | current_view.exclude_fields(field_names)
311 | end
312 |
313 | # Specify a view and the fields it should have.
314 | # It accepts a view name and a block. The block should specify the fields.
315 | #
316 | # @param view_name [Symbol] the view name
317 | # @yieldreturn [#fields,#field,#include_view,#exclude] Use this block to
318 | # specify fields, include fields from other views, or exclude fields.
319 | #
320 | # @example Using views
321 | # view :extended do
322 | # fields :position, :company
323 | # include_view :normal
324 | # exclude :first_name
325 | # end
326 | #
327 | # @return [View] a Blueprinter::View object
328 | def view(view_name)
329 | self.view_scope = view_collection[view_name]
330 | view_collection[:default].track_definition_order(view_name)
331 | yield
332 | self.view_scope = view_collection[:default]
333 | end
334 |
335 | # Check whether or not a Blueprint supports the supplied view.
336 | # It accepts a view name.
337 | #
338 | # @param view_name [Symbol] the view name
339 | #
340 | # @example With the following Blueprint
341 | #
342 | # class ExampleBlueprint < Blueprinter::Base
343 | # view :custom do
344 | # end
345 | # end
346 | #
347 | # ExampleBlueprint.view?(:custom) => true
348 | # ExampleBlueprint.view?(:doesnt_exist) => false
349 | #
350 | # @return [Boolean] a boolean value indicating if the view is
351 | # supported by this Blueprint.
352 | def view?(view_name)
353 | view_collection.view?(view_name)
354 | end
355 |
356 | def view_collection
357 | @_view_collection ||= ViewCollection.new
358 | end
359 |
360 | private
361 |
362 | attr_accessor :view_scope
363 |
364 | # Returns the current view during Blueprint definition based on the view_scope.
365 | def current_view
366 | view_scope || view_collection[:default]
367 | end
368 |
369 | def inherited(subclass)
370 | subclass.send(:view_collection).inherit(view_collection)
371 | end
372 | end
373 | end
374 | end
375 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Unreleased
2 | --
3 |
4 | ## 1.2.1 - 2025/09/11
5 | - 🐛 [BUGFIX] Adds back `Blueprinter.prepare` method with a deprecated warning. This method was previously public, but was removed as part of **1.2.0**.
6 |
7 | ## [REMOVED] 1.2.0 - 2025/09/10
8 | - ‼️ [BREAKING] Drops support for Ruby 3.0. See [#496](https://github.com/procore-oss/blueprinter/pull/496)
9 | - 💅 [ENHANCEMENT] Allows for the current view to be accessible from within the `options` hash provided to the `field` block. See [#503](https://github.com/procore-oss/blueprinter/pull/503). Thanks to [@neo87cs](https://github.com/neo87cs).
10 | - 🐛 [BUGFIX] Fixes an issue where specifying fields using a mix of symbols and strings would cause an `ArgumentError` when rendering. See [#505](https://github.com/procore-oss/blueprinter/pull/505). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
11 | - 🚜 [REFACTOR] Reorganizes rendering/serialization logic and removes `BaseHelper` module. See [#476](https://github.com/procore-oss/blueprinter/pull/476). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
12 |
13 | ## 1.1.2 - 2024/10/3
14 | - 🐛 [BUGFIX] Fixes an issue where a `Blueprinter::BlueprinterError` would be raised on render when providing `view: nil`, instead of falling back on the `:default` view. See
15 | [#472](https://github.com/procore-oss/blueprinter/pull/472). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
16 |
17 | ## 1.1.1 - 2024/10/2
18 | * 🐛 [BUGFIX] Fixes an issue when when calling `.render` multiple times on a Blueprint using the same `options` hash, which would result in the `options` changing unexpectedly between calls. See [#453](https://github.com/procore-oss/blueprinter/pull/453). Thanks to [@ryanmccarthypdx](https://github.com/ryanmccarthypdx).
19 | * 🐛 [BUGFIX] Fixes an issue when passing in a `Symbol` (representing a method) to the `if:` condition on an association. The provided `Symbol` would be erroneously sent to the association's Blueprint, instead of the Blueprint in which the association was defined within. See [#464](https://github.com/procore-oss/blueprinter/pull/464). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
20 |
21 | ## 1.1.0 - 2024/08/02
22 | * ‼️ [BREAKING] Drops support for Ruby 2.7. See [#402](https://github.com/procore-oss/blueprinter/pull/402). Thanks to [@jmeridth](https://github.com/jmeridth)
23 | * 🚜 [REFACTOR] Cleans up Blueprint validation logic and implements an `Association` class with a clearer interface. See [#414](https://github.com/procore-oss/blueprinter/pull/414). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
24 | * 💅 [ENHANCEMENT] Updates **Transform Classes** documentation to provide a more understandable example. See [#415](https://github.com/procore-oss/blueprinter/pull/415). Thanks to [@SaxtonDrey](https://github.com/SaxtonDrey).
25 | * 💅 [ENHANCEMENT] Implements field-level configuration option for excluding an attribute from the result of a render if its value is `nil`. See [#425](https://github.com/procore-oss/blueprinter/pull/425). Thanks to [jamesst20](https://github.com/jamesst20).
26 | * 🚜 [REFACTOR] Adds explicit dependency on `json` within `Blueprinter::Configuration`. See [#444](https://github.com/procore-oss/blueprinter/pull/444). Thanks to [@lessthanjacob](https://github.com/lessthanjacob).
27 | * 🚜 [REFACTOR] Alters file loading to leverage `autoload` instead of `require` for (future) optional, top-level constants. See [#445](https://github.com/procore-oss/blueprinter/pull/445). Thanks to [@jhollinger](https://github.com/jhollinger).
28 |
29 | ## 1.0.2 - 2024/02/02
30 | * 🐛 [BUGFIX] [BREAKING] Fixes an issue with reflection where fields are incorrectly override by their definitions in the default view. Note: this may be a breaking change for users of the extensions API, but restores the intended functionality. See [#391](https://github.com/procore-oss/blueprinter/pull/391). Thanks to [@elliothursh](https://github.com/elliothursh).
31 |
32 | ## 1.0.1 - 2024/01/19
33 | * 🐛 [BUGFIX] Fixes an issue where serialization performance would become degraded when using a Blueprint that leverages transformers. See [#381](https://github.com/procore-oss/blueprinter/pull/381). Thanks to [@Pritilender](https://github.com/Pritilender).
34 |
35 | ## 1.0.0 - 2024/01/17
36 | * ‼️ [BREAKING] Allow transformers to be included across views. See [README](https://github.com/procore-oss/blueprinter#transform-across-views), PR [#372](https://github.com/procore-oss/blueprinter/pull/372) and issue [#225](https://github.com/procore-oss/blueprinter/issues/225) for details. Note this changes the behavior of transformers which were previously only applied to the view they were defined on. Thanks to [@njbbaer](https://github.com/njbbaer) and [@bhooshiek-narendiran](https://github.com/bhooshiek-narendiran).
37 | * 🚀 [FEATURE] Introduce extension API, with initial support for pre_render hook. See [#358](https://github.com/procore-oss/blueprinter/pull/358) for details. Thanks to [@jhollinger](https://github.com/jhollinger).
38 | * 💅 [ENHANCEMENT] Add reflection on views, fields, and associations. See PR [#357](https://github.com/procore-oss/blueprinter/pull/357), and issue [#341](https://github.com/procore-oss/blueprinter/issues/341) for details. Thanks to [@jhollinger](https://github.com/jhollinger).
39 |
40 | ## 0.30.0 - 2023/09/16
41 | * 🚀 [FEATURE] Allow configuring custom array-like classes to be treated as collections when serializing. More details can be found [here](https://github.com/procore-oss/blueprinter/pull/327). Thanks to [@toddnestor](https://github.com/toddnestor).
42 | * 💅 [ENHANCEMENT] Reduce object allocations in fields calculations to save some memory. More details can be found [here](https://github.com/procore-oss/blueprinter/pull/327). Thanks to [@nametoolong](https://github.com/nametoolong).
43 | * 💅 [ENHANCEMENT] Introduce rubocop
44 | * 💅 [ENHANCEMENT] if/:unless procs with two arguments and invalid empty type deprecations are now removed
45 | ## 0.26.0 - 2023/08/17
46 | * ‼️ [BREAKING] Transition to GitHub Actions from CircleCI and update to handle Ruby versions 2.7, 3.0, 3.1, 3.2. Drop support for any ruby version less than 2.7. See [#307](https://github.com/procore-oss/blueprinter/pull/307)
47 |
48 | ## 0.25.3 - 2021/03/03
49 | * 🐛 [BUGFIX] Fixes issue where fields and associations that are redefined by later views were not properly overwritten. See [#201](https://github.com/procore-oss/blueprinter/pull/201) thanks to [@Berardpi](https://github.com/Berardpi).
50 |
51 | ## 0.25.2 - 2020/11/19
52 | * 🚀 [FEATURE] Make deprecation behavior configurable (`:silence`, `:stderror`, `:raise`). See [#248](https://github.com/procore-oss/blueprinter/pull/248) thanks to [@mcclayton](https://github.com/mcclayton).
53 |
54 | ## 0.25.1 - 2020/08/18
55 | * 🐛 [BUGFIX] Raise Blueprinter::BlueprinterError if Blueprint given is not of class Blueprinter::Base. Before it just raised a generic `undefined method 'prepare'`. See [#233](https://github.com/procore-oss/blueprinter/pull/233) thanks to [@caws](https://github.com/caws).
56 |
57 | ## 0.25.0 - 2020/07/06
58 | * 🚀 [FEATURE] Enable default `Blueprinter::Transformer`s to be set in the global configuration. [#222](https://github.com/procore-oss/blueprinter/pull/222). Thanks to [@supremebeing7](https://github.com/supremebeing7).
59 |
60 | ## 0.24.0 - 2020/06/22
61 | * 🚀 [FEATURE] Add an `options` option to associations to facilitate passing options from one blueprint to another. [#220](https://github.com/procore-oss/blueprinter/pull/220). Thanks to [@mcclayton](https://github.com/mcclayton).
62 |
63 | ## 0.23.4 - 2020/04/28
64 | * 🚀 [FEATURE] Public class method `has_view?` on Blueprinter::Base subclasses introduced in [#213](https://github.com/procore-oss/blueprinter/pull/213). Thanks to [@spencerneste](https://github.com/spencerneste).
65 |
66 | ## 0.23.3 - 2020/04/07
67 | * 🐛 [BUGFIX] Fixes issue where `exclude` fields in deeply nested views were not respected. Resolved issue [207](https://github.com/procore-oss/blueprinter/issues/207) in [#208](https://github.com/procore-oss/blueprinter/pull/208) by [@tpltn](https://github.com/tpltn).
68 |
69 | ## 0.23.2 - 2020/03/16
70 | * 🐛 [BUGFIX] Fixes issue where fields "bled" into other views due to merge side-effects. Resolved issue [205](https://github.com/procore-oss/blueprinter/issues/205) in [#204](https://github.com/procore-oss/blueprinter/pull/204) by [@trevorrjohn](https://github.com/trevorrjohn).
71 |
72 | ## 0.23.1 - 2020/03/13
73 | * 🐛 [BUGFIX] Fixes #172 where views would unintentionally ignore `sort_fields_by: :definition` configuration. Resolved in [#197](https://github.com/procore-oss/blueprinter/pull/197) by [@wlkrw](https://github.com/wlkrw).
74 |
75 | ## 0.23.0 - 2020/01/31
76 | * 🚀 [FEATURE] Configurable default extractor introduced in [#198](https://github.com/procore-oss/blueprinter/pull/198) by [@wlkrw](https://github.com/wlkrw). You can now set a default extractor like so:
77 | ```
78 | Blueprinter.configure do |config|
79 | config.extractor_default = MyAutoExtractor
80 | end
81 | ```
82 |
83 | ## 0.22.0 - 2019/12/26
84 | * 🚀 [FEATURE] Add rails generators. See `rails g blueprinter:blueprint --help` for usage. Introduced in [#176](https://github.com/procore-oss/blueprinter/pull/176) by [@wlkrw](https://github.com/wlkrw).
85 |
86 | ## 0.21.0 - 2019/12/19
87 | * 🚀 [FEATURE] Ability to specify `default_if` field/association option for more control on when the default value is applied. [191](https://github.com/procore-oss/blueprinter/pull/191). Thanks to [@mcclayton](https://github.com/mcclayton).
88 |
89 | ## 0.20.0 - 2019/10/15
90 | * 🚀 [FEATURE] Ability to include multiple views in a single method call with `include_views`. [184](https://github.com/procore-oss/blueprinter/pull/184). Thanks to [@narendranvelmurugan](https://github.com/narendranvelmurugan).
91 |
92 | * 💅 [ENHANCEMENT] Update field-level conditional settings to reflect new three-argument syntax. [183](https://github.com/procore-oss/blueprinter/pull/183). Thanks to [@danirod](https://github.com/danirod).
93 |
94 | * 💅 [ENHANCEMENT] Modify Extractor access control in documentation. [182](https://github.com/procore-oss/blueprinter/pull/182). Thanks to [@cagmz](https://github.com/cagmz).
95 |
96 | * 💅 [ENHANCEMENT] Fix the Transformer example documentation. [174](https://github.com/procore-oss/blueprinter/pull/174). Thanks to [@tjwallace](https://github.com/tjwallace).
97 |
98 | ## 0.19.0 - 2019/07/24
99 | * 🚀 [FEATURE] Added ability to specify transformers for Blueprinter views to further process the resulting hash before serialization. [#164](https://github.com/procore-oss/blueprinter/pull/164). Thanks to [@amalarayfreshworks](https://github.com/amalarayfreshworks).
100 |
101 | ## 0.18.0 - 2019/05/29
102 |
103 | * ⚠️ [DEPRECATION] :if/:unless procs with two arguments are now deprecated. *These procs now take in three arguments (field_name, obj, options) instead of just (obj, options).*
104 | In order to be compliant with the the next major release, all conditional :if/:unless procs must be augmented to take in three arguments instead of two. i.e. `(obj, options)` to `(field_name, obj, options)`.
105 |
106 | ## 0.17.0 - 2019/05/23
107 | * 🐛 [BUGFIX] Fixing view: :identifier including non-identifier fields. [#154](https://github.com/procore-oss/blueprinter/pull/154). Thanks to [@AllPurposeName](https://github.com/AllPurposeName).
108 |
109 | * 💅 [ENHANCEMENT] Add ability to override :extractor option for an ::association. [#152](https://github.com/procore-oss/blueprinter/pull/152). Thanks to [@hugopeixoto](https://github.com/hugopeixoto).
110 |
111 | ## 0.16.0 - 2019/04/03
112 | * 🚀 [FEATURE] Add ability to exclude multiple fields inline using `excludes`. [#141](https://github.com/procore-oss/blueprinter/pull/141). Thanks to [@pabhinaya](https://github.com/pabhinaya).
113 |
114 | ## 0.15.0 - 2019/04/01
115 | * 🚀 [FEATURE] Add ability to pass in `datetime_format` field option as either a string representing the strftime format, or a Proc which takes in the Date or DateTime object and returns the formatted date. [#145](https://github.com/procore-oss/blueprinter/pull/145). Thanks to [@mcclayton](https://github.com/mcclayton).
116 |
117 | ## 0.14.0 - 2019/04/01
118 | * 🚀 [FEATURE] Added a global `datetime_format` option. [#135](https://github.com/procore-oss/blueprinter/pull/143). Thanks to [@ritikesh](https://github.com/ritikesh).
119 |
120 | ## 0.13.2 - 2019/03/14
121 | * 🐛 [BUGFIX] Replacing use of rails-specific method `Hash::except` so that Blueprinter continues to work in non-Rails environments. [#140](https://github.com/procore-oss/blueprinter/pull/140). Thanks to [@checkbutton](https://github.com/checkbutton).
122 |
123 | ## 0.13.1 - 2019/03/02
124 | * 💅 [MAINTENANCE | ENHANCEMENT] Cleaning up the `include_associations` section. This is not a documented/supported feature and is calling `respond_to?(:klass)` on every object passed to blueprinter. [#139](https://github.com/procore-oss/blueprinter/pull/139). Thanks to [@ritikesh](https://github.com/ritikesh).
125 |
126 | ## 0.13.0 - 2019/02/07
127 |
128 | * 🚀 [FEATURE] Added an option to render with a root key. [#135](https://github.com/procore-oss/blueprinter/pull/135). Thanks to [@ritikesh](https://github.com/ritikesh).
129 | * 🚀 [FEATURE] Added an option to render with a top-level meta attribute. [#135](https://github.com/procore-oss/blueprinter/pull/135). Thanks to [@ritikesh](https://github.com/ritikesh).
130 |
131 | ## 0.12.1 - 2019/01/24
132 |
133 | * 🐛 [BUGFIX] Fix boolean `false` values getting serialized as `null`. Please see PR [#132](https://github.com/procore-oss/blueprinter/pull/132). Thanks to [@samsongz](https://github.com/samsongz).
134 |
135 | ## 0.12.0 - 2019/01/16
136 |
137 | * 🚀 [FEATURE] Enables the setting of global `:field_default` and `:association_default` option value in the Blueprinter Configuration that will be used as default values for fields and associations that evaluate to nil. [#128](https://github.com/procore-oss/blueprinter/pull/128). Thanks to [@mcclayton](https://github.com/mcclayton).
138 |
139 | ## 0.11.0 - 2019/01/15
140 |
141 | * 🚀 [FEATURE] Enables the setting of a global `:if`/`:unless` proc in the Blueprinter Configuration that will be used to evaluate the conditional render of all fields. [#127](https://github.com/procore-oss/blueprinter/pull/127). Thanks to [@mcclayton](https://github.com/mcclayton).
142 |
143 | ## 0.10.0 - 2018/12/20
144 |
145 | * 🚀 [FEATURE] Association Blueprints can be dynamically evaluated using a proc. [#122](https://github.com/procore-oss/blueprinter/pull/122). Thanks to [@ritikesh](https://github.com/ritikesh).
146 |
147 | ## 0.9.0 - 2018/11/29
148 |
149 | * 🚀 [FEATURE] Added a `render_as_json` API. Similar to `render_as_hash` but returns a JSONified hash. Please see pr [#119](https://github.com/procore-oss/blueprinter/pull/119). Thanks to [@ritikesh](https://github.com/ritikesh).
150 | * 🚀 [FEATURE] Sorting of fields in the response is now configurable to sort by definition or by name(asc only). Please see pr [#119](https://github.com/procore-oss/blueprinter/pull/119). Thanks to [@ritikesh](https://github.com/ritikesh).
151 | * 💅 [ENHANCEMENT] Updated readme for above features and some existing undocumented features like `exclude fields`, `render_as_hash`. Please see pr [#119](https://github.com/procore-oss/blueprinter/pull/119). Thanks to [@ritikesh](https://github.com/ritikesh).
152 |
153 | ## 0.8.0 - 2018/11/19
154 |
155 | * 🚀 [FEATURE] Extend Support for other JSON encoders like yajl-ruby. Please see pr [#118](https://github.com/procore-oss/blueprinter/pull/118). Thanks to [@ritikesh](https://github.com/ritikesh).
156 | * 🐛 [BUGFIX] Do not raise error on null date with `date_format` option. Please see pr [#117](https://github.com/procore-oss/blueprinter/pull/117). Thanks to [@tpltn](https://github.com/tpltn).
157 | * 🚀 [FEATURE] Add `default` option to `field`s which will be used as the serialized value instead of `null` when the field evaluates to null. Please see pr [#115](https://github.com/procore-oss/blueprinter/pull/115). Thanks to [@mcclayton](https://github.com/mcclayton).
158 | * 🐛 [BUGFIX] Made Base.associations completely private since they are not used outside of the Blueprinter base. Please see pr [#112](https://github.com/procore-oss/blueprinter/pull/112). Thanks to [@philipqnguyen](https://github.com/philipqnguyen).
159 | * 🐛 [BUGFIX] Fix issue where entire Blueprinter module was marked api private. Please see pr [#111](https://github.com/procore-oss/blueprinter/pull/111). Thanks to [@philipqnguyen](https://github.com/philipqnguyen).
160 | * 🚀 [FEATURE] Allow identifiers to be defined with a block. Please see pr [#110](https://github.com/procore-oss/blueprinter/pull/110). Thanks to [@hugopeixoto](https://github.com/hugopeixoto).
161 | * 💅 [ENHANCEMENT] Update docs regarding the args yielded to blocks. Please see pr [#108](https://github.com/procore-oss/blueprinter/pull/108). Thanks to [@philipqnguyen](https://github.com/philipqnguyen).
162 | * 💅 [ENHANCEMENT] Use `field` method in fields. Please see pr [#107](https://github.com/procore-oss/blueprinter/pull/107). Thanks to [@hugopeixoto](https://github.com/hugopeixoto).
163 |
164 | ## 0.7.0 - 2018/10/17
165 |
166 | * [FEATURE] Allow associations to be defined with a block. Please see pr [#106](https://github.com/procore-oss/blueprinter/pull/106). Thanks to [@hugopeixoto](https://github.com/hugopeixoto).
167 | * [FEATURE] Inherit view definition when using inheritance. Please see pr [#105](https://github.com/procore-oss/blueprinter/pull/105). Thanks to [@hugopeixoto](https://github.com/hugopeixoto).
168 |
169 | ## 0.6.0 - 2018/06/05
170 |
171 | * 🚀 [FEATURE] Add `date_time` format as an option to `field`. Please see pr #68. Thanks to [@njbbaer](https://github.com/njbbaer).
172 | * 🚀 [FEATURE] Add conditional field support `:unless` and `:if` as an option to `field`. Please see pr [#86](https://github.com/procore-oss/blueprinter/pull/86). Thanks to [@ojab](https://github.com/ojab).
173 | * 🐛 [BUGFIX] Fix case where miscellaneous options were not being passed through the `AutoExtractor`. See pr [#83](https://github.com/procore-oss/blueprinter/pull/83).
174 |
175 | ## 0.5.0 - 2018/05/15
176 |
177 | * 🚀 [FEATURE] Add `default` option to `association` which will be used as the serialized value instead of `null` when the association evaluates to null.
178 | See PR [#78](https://github.com/procore-oss/blueprinter/pull/78) by [@vinaya-procore](https://github.com/vinaya-procore).
179 |
180 | ## 0.4.0 - 2018/05/02
181 |
182 | * 🚀 [FEATURE] Add `render_as_hash` which will output a hash instead of
183 | a JSON String. See PR [#76](https://github.com/procore-oss/blueprinter/pull/76) by [@amayer171](https://github.com/amayer171) and Issue [#73](https://github.com/procore-oss/blueprinter/issues/73).
184 |
185 | ## 0.3.0 - 2018/04/05
186 |
187 | ‼️ [BREAKING] Sort of a breaking Change. Serializer classes has been renamed to Extractor. To upgrade, if you passed in a specific serializer to `field` or `identifier` such as:
188 |
189 | ```
190 | field(:first_name, serializer: CustomSerializer)
191 | ```
192 |
193 | Please rename that to:
194 |
195 | ```
196 | field(:first_name, extractor: CustomExtractor)
197 | ```
198 |
199 | * 💅 [ENHANCEMENT] Renamed Serializer classes to Extractor. See #72.
200 | * 💅 [ENHANCEMENT] Updated README. See pr [#66](https://github.com/procore-oss/blueprinter/pull/66), [#65](https://github.com/procore-oss/blueprinter/pull/65)
201 |
202 | ## 0.2.0 - 2018/01/22
203 |
204 | ‼️ [BREAKING] Breaking Changes. To upgrade, ensure that any associated objects have a blueprint. For example:
205 | ```
206 | association :comments, blueprint: CommentsBlueprint
207 | ```
208 |
209 | * 🐛 [BUGFIX] Remove Optimizer class. See [#61](https://github.com/procore-oss/blueprinter/pull/61).
210 | * 🐛 [BUGFIX] Require associated objects to have a Blueprint, so that objects will always serialize properly. See [#60](https://github.com/procore-oss/blueprinter/pull/60).
211 |
212 | ## 0.1.0 - 2018/01/17
213 |
214 | * 🚀 [FEATURE] Initial release of Blueprinter
215 |
--------------------------------------------------------------------------------
/spec/integrations/shared/base_render_examples.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | shared_examples 'Base::render' do
4 | context 'Given blueprint has ::identifier' do
5 | let(:result) { '{"id":' + obj_id + '}' }
6 | let(:blueprint) do
7 | Class.new(Blueprinter::Base) do
8 | identifier :id
9 | end
10 | end
11 | it('returns json with an identifier') { should eq(result) }
12 |
13 | context 'with a provided block' do
14 | let(:blueprint) do
15 | Class.new(Blueprinter::Base) do
16 | identifier :id do |object, _options|
17 | "THE ID IS #{object.respond_to?(:id) ? object.id : object[:id]}"
18 | end
19 | end
20 | end
21 | it('returns json with an identifier') { should eq('{"id":"THE ID IS ' + obj_id + '"}') }
22 | end
23 | end
24 |
25 | context 'Given blueprint has ::field' do
26 | let(:result) { '{"first_name":"Meg","id":' + obj_id + '}' }
27 | let(:blueprint) do
28 | Class.new(Blueprinter::Base) do
29 | field :id
30 | field :first_name
31 | end
32 | end
33 | it('returns json with specified fields') { should eq(result) }
34 | end
35 |
36 | context 'Given blueprint has ::field with all data types' do
37 | let(:result) { '{"active":false,"birthday":"1994-03-04","deleted_at":null,"first_name":"Meg","id":' + obj_id + '}' }
38 | let(:blueprint) do
39 | Class.new(Blueprinter::Base) do
40 | field :id # number
41 | field :first_name # string
42 | field :active # boolean
43 | field :birthday # date
44 | field :deleted_at # null
45 | end
46 | end
47 | it('returns json with the correct values for each data type') { should eq(result) }
48 | end
49 |
50 | context 'Given blueprint has ::fields' do
51 | let(:result) do
52 | '{"id":' + obj_id + ',"description":"A person","first_name":"Meg"}'
53 | end
54 | let(:blueprint) do
55 | Class.new(Blueprinter::Base) do
56 | identifier :id
57 | fields :first_name, :description
58 | end
59 | end
60 | it('returns json with specified fields') { should eq(result) }
61 | end
62 |
63 | context 'Given blueprint has ::field with a :name argument' do
64 | let(:result) { '{"first_name":"Meg","identifier":' + obj_id + '}' }
65 | let(:blueprint) do
66 | Class.new(Blueprinter::Base) do
67 | field :id, name: :identifier
68 | field :first_name
69 | end
70 | end
71 | it('returns json with a renamed field') { should eq(result) }
72 | end
73 |
74 | context 'when field methods use a mix of symbols and strings' do
75 | let(:result) { '{"first_name":"Meg","id":' + obj_id + '}' }
76 | let(:blueprint) do
77 | Class.new(Blueprinter::Base) do
78 | field 'id'
79 | field :first_name
80 | end
81 | end
82 | it('renders as expected') { should eq(result) }
83 | end
84 |
85 | context 'non-default extractor' do
86 | let(:extractor) do
87 | Class.new(Blueprinter::Extractor) do
88 | def extract(field_name, object, _local_options, _options={})
89 | object[field_name].respond_to?(:upcase) ? object[field_name].upcase : object[field_name]
90 | end
91 | end
92 | end
93 | let(:result) { '{"first_name":"MEG","id":' + obj_id + '}' }
94 |
95 | context 'Given blueprint has ::field with a :extractor argument' do
96 | let(:blueprint) do
97 | ex = extractor
98 | Class.new(Blueprinter::Base) do
99 | field :id
100 | field :first_name, extractor: ex
101 | end
102 | end
103 | it('returns json derived from a custom extractor') { should eq(result) }
104 | end
105 |
106 | context 'Given a non-default global extractor configured' do
107 | before { Blueprinter.configure { |config| config.extractor_default = extractor } }
108 | after { reset_blueprinter_config! }
109 |
110 | let(:blueprint) do
111 | Class.new(Blueprinter::Base) do
112 | field :id
113 | field :first_name
114 | end
115 | end
116 | it('returns json derived from a custom extractor') { should eq(result) }
117 | end
118 | end
119 |
120 | context 'Given blueprint has ::fields with :datetime_format argument and global datetime_format' do
121 | before { Blueprinter.configure { |config| config.datetime_format = -> datetime { datetime.strftime("%s").to_i } } }
122 | after { reset_blueprinter_config! }
123 |
124 | let(:result) do
125 | '{"id":' + obj_id + ',"birthday":762739200,"deleted_at":null}'
126 | end
127 | let(:blueprint) do
128 | Class.new(Blueprinter::Base) do
129 | identifier :id
130 | field :birthday
131 | field :deleted_at, datetime_format: '%FT%T%:z'
132 | end
133 | end
134 | it('returns json with a formatted field') { should eq(result) }
135 | end
136 |
137 | context 'Given blueprint has a string :datetime_format argument on an invalid ::field' do
138 | let(:blueprint) do
139 | Class.new(Blueprinter::Base) do
140 | identifier :id
141 | field :first_name, datetime_format: "%m/%d/%Y"
142 | end
143 | end
144 | it('raises an InvalidDateTimeFormatterError') { expect{subject}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError) }
145 | end
146 |
147 | context 'Given blueprint has ::field with a Proc :datetime_format argument' do
148 | let(:result) do
149 | '{"id":' + obj_id + ',"birthday":762739200,"deleted_at":null}'
150 | end
151 | let(:blueprint) do
152 | Class.new(Blueprinter::Base) do
153 | identifier :id
154 | field :birthday, datetime_format: -> datetime { datetime.strftime("%s").to_i }
155 | field :deleted_at, datetime_format: -> datetime { datetime.strftime("%s").to_i }
156 | end
157 | end
158 | it('returns json with a formatted field') { should eq(result) }
159 | end
160 |
161 | context 'Given blueprint has a Proc :datetime_format argument on an invalid ::field' do
162 | let(:blueprint) do
163 | Class.new(Blueprinter::Base) do
164 | identifier :id
165 | field :first_name, datetime_format: -> datetime { datetime.capitalize }
166 | end
167 | end
168 | it('raises an InvalidDateTimeFormatterError') { expect{subject}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError) }
169 | end
170 |
171 | context 'Given blueprint has a Proc :datetime_format which fails to process date' do
172 | let(:blueprint) do
173 | Class.new(Blueprinter::Base) do
174 | identifier :id
175 | field :birthday, datetime_format: -> datetime { datetime.invalid_method }
176 | end
177 | end
178 | it('raises original error from Proc') { expect{subject}.to raise_error(NoMethodError) }
179 | end
180 |
181 | context 'Given blueprint has ::field with an invalid :datetime_format argument' do
182 | let(:blueprint) do
183 | Class.new(Blueprinter::Base) do
184 | identifier :id
185 | field :birthday, datetime_format: :invalid_symbol_format
186 | end
187 | end
188 | it('raises an InvalidDateTimeFormatterError') { expect{subject}.to raise_error(Blueprinter::DateTimeFormatter::InvalidDateTimeFormatterError) }
189 | end
190 |
191 | context "Given default_if option is Blueprinter::EMPTY_STRING" do
192 | before do
193 | obj[:first_name] = ""
194 | obj[:last_name] = ""
195 | end
196 |
197 | let(:result) { '{"first_name":"Unknown","id":' + obj_id + ',"last_name":null}' }
198 | let(:blueprint) do
199 | Class.new(Blueprinter::Base) do
200 | field :id
201 | field :first_name, default_if: Blueprinter::EMPTY_STRING, default: "Unknown"
202 | field :last_name, default_if: Blueprinter::EMPTY_STRING
203 | end
204 | end
205 | it('uses the correct default values') { should eq(result) }
206 | end
207 |
208 | context 'Given default_if option is invalid' do
209 | before do
210 | obj[:first_name] = ""
211 | end
212 |
213 | let(:result) { %({"first_name":"","id":#{obj_id}}) }
214 | let(:blueprint) do
215 | Class.new(Blueprinter::Base) do
216 | field :id
217 | field :first_name, default_if: "INVALID_EMPTY_TYPE", default: "Unknown"
218 | end
219 | end
220 | it('does not use the default value') { should eq(result) }
221 | end
222 |
223 | context "Given blueprint has ::field with nil value" do
224 | before do
225 | obj[:first_name] = nil
226 | end
227 |
228 | context "Given global default field value is specified" do
229 | before { Blueprinter.configure { |config| config.field_default = "N/A" } }
230 | after { reset_blueprinter_config! }
231 |
232 | context "Given default field value is not provided" do
233 | let(:result) { '{"first_name":"N/A","id":' + obj_id + '}' }
234 | let(:blueprint) do
235 | Class.new(Blueprinter::Base) do
236 | field :id
237 | field :first_name
238 | end
239 | end
240 | it('global default value is rendered for nil field') { should eq(result) }
241 | end
242 |
243 | context "Given default field value is provided" do
244 | let(:result) { '{"first_name":"Unknown","id":' + obj_id + '}' }
245 | let(:blueprint) do
246 | Class.new(Blueprinter::Base) do
247 | field :id
248 | field :first_name, default: "Unknown"
249 | end
250 | end
251 | it('field-level default value is rendered for nil field') { should eq(result) }
252 | end
253 |
254 | context "Given default field value is provided but is nil" do
255 | let(:result) { '{"first_name":null,"id":' + obj_id + '}' }
256 | let(:blueprint) do
257 | Class.new(Blueprinter::Base) do
258 | field :id
259 | field :first_name, default: nil
260 | end
261 | end
262 | it('field-level default value is rendered for nil field') { should eq(result) }
263 | end
264 | end
265 |
266 | context "Given global default value is not specified" do
267 | context "Given default field value is not provided" do
268 | let(:result) { '{"first_name":null,"id":' + obj_id + '}' }
269 | let(:blueprint) do
270 | Class.new(Blueprinter::Base) do
271 | field :id
272 | field :first_name
273 | end
274 | end
275 | it('returns json with specified fields') { should eq(result) }
276 | end
277 |
278 | context "Given default field value is provided" do
279 | let(:result) { '{"first_name":"Unknown","id":' + obj_id + '}' }
280 | let(:blueprint) do
281 | Class.new(Blueprinter::Base) do
282 | field :id
283 | field :first_name, default: "Unknown"
284 | end
285 | end
286 | it('field-level default value is rendered for nil field') { should eq(result) }
287 | end
288 | end
289 | end
290 |
291 | context 'Given blueprint has ::field with a conditional argument' do
292 | context 'Given conditional proc has three argument signature' do
293 | variants = %i[proc method].product([true, false])
294 |
295 | let(:if_value) { true }
296 | let(:unless_value) { false }
297 | let(:field_options) { {} }
298 | let(:local_options) { { x: 1, y: 2 } }
299 | let(:if_proc) { ->(_field_name, _obj, _local_opts) { if_value } }
300 | let(:unless_proc) { ->(_field_name, _obj, _local_opts) { unless_value } }
301 | let(:blueprint) do
302 | f_options = field_options
303 |
304 | bp = Class.new(Blueprinter::Base) do
305 | field :id
306 | field :first_name, f_options
307 | end
308 | bp.instance_eval <<-RUBY, __FILE__, __LINE__ + 1
309 | def self.if_method(_field_name, _object, _options)
310 | #{if_value}
311 | end
312 |
313 | def self.unless_method(_field_name, _object, _options)
314 | #{unless_value}
315 | end
316 | RUBY
317 | bp
318 | end
319 | let(:result_with_first_name) do
320 | %({"first_name":"Meg","id":#{obj_id}})
321 | end
322 | let(:result_without_first_name) { %({"id":#{obj_id}}) }
323 | subject { blueprint.render(obj, local_options) }
324 |
325 | shared_examples 'serializes the conditional field' do
326 | it 'serializes the conditional field' do
327 | should eq(result_with_first_name)
328 | end
329 | end
330 |
331 | shared_examples 'does not serialize the conditional field' do
332 | it 'does not serialize the conditional field' do
333 | should eq(result_without_first_name)
334 | end
335 | end
336 |
337 | variants.each do |type, value|
338 | context "Given the conditional is :if #{type} returning #{value}" do
339 | let(:if_value) { value }
340 |
341 | before do
342 | field_options[:if] = type == :method ? :if_method : if_proc
343 | end
344 |
345 | context 'and no :unless conditional' do
346 | if value
347 | include_examples 'serializes the conditional field'
348 | else
349 | include_examples 'does not serialize the conditional field'
350 | end
351 | end
352 |
353 | variants.each do |other_type, other_value|
354 | context "and :unless conditional is #{other_type} returning #{other_value}" do
355 | let(:unless_value) { other_value }
356 | before do
357 | field_options[:unless] = if type == :method then :unless_method
358 | else unless_proc
359 | end
360 | end
361 |
362 | if value && !other_value
363 | include_examples 'serializes the conditional field'
364 | else
365 | include_examples 'does not serialize the conditional field'
366 | end
367 | end
368 | end
369 | end
370 |
371 | context "Given the conditional is :unless #{type} returning #{value} and no :if conditional" do
372 | let(:unless_value) { value }
373 | before do
374 | field_options[:unless] = type == :method ? :unless_method : unless_proc
375 | end
376 |
377 | if value
378 | include_examples 'does not serialize the conditional field'
379 | else
380 | include_examples 'serializes the conditional field'
381 | end
382 | end
383 | end
384 | end
385 | end
386 |
387 | context 'Given blueprint has ::view' do
388 | let(:identifier) do
389 | '{"id":' + obj_id + '}'
390 | end
391 | let(:no_view) do
392 | ['{"id":' + obj_id + '', '"first_name":"Meg"' + '', '"points":0' + '}'].join(',')
393 | end
394 | let(:normal) do
395 | ['{"id":' + obj_id + '', '"employer":"Procore"', '"first_name":"Meg"',
396 | '"last_name":"' + obj[:last_name] + '"', '"points":1', '"position":"Manager"}'].join(',')
397 | end
398 | let(:ext) do
399 | ['{"id":' + obj_id + '', '"description":"A person"', '"employer":"Procore"',
400 | '"first_name":"Meg"', '"points":2', '"position":"Manager"}'].join(',')
401 | end
402 | let(:special) do
403 | ['{"id":' + obj_id + '', '"description":"A person"',
404 | '"first_name":"Meg"', '"points":2}'].join(',')
405 | end
406 | let(:blueprint) do
407 | Class.new(Blueprinter::Base) do
408 | identifier :id
409 | field :first_name
410 | field :points do 0 end
411 |
412 | view :normal do
413 | fields :last_name, :position
414 | field :company, name: :employer
415 | field :points do 1 end
416 | end
417 | view :extended do
418 | include_view :normal
419 | field :description
420 | exclude :last_name
421 | field :points do 2 end
422 | end
423 | view :special do
424 | include_view :extended
425 | excludes :employer, :position
426 | end
427 | end
428 | end
429 | it('returns json derived from a view') do
430 | expect(blueprint.render(obj)).to eq(no_view)
431 | expect(blueprint.render(obj, view: :identifier)).to eq(identifier)
432 | expect(blueprint.render(obj, view: :normal)).to eq(normal)
433 | expect(blueprint.render(obj, view: :extended)).to eq(ext)
434 | expect(blueprint.render(obj, view: :special)).to eq(special)
435 | expect(blueprint.render(obj)).to eq(no_view)
436 | end
437 | end
438 |
439 | context 'Given blueprint has :root' do
440 | let(:result) { '{"root":{"id":' + obj_id + ',"position_and_company":"Manager at Procore"}}' }
441 | let(:blueprint) { blueprint_with_block }
442 | it('returns json with a root') do
443 | expect(blueprint.render(obj, root: :root)).to eq(result)
444 | end
445 | end
446 |
447 | context 'Given blueprint has :meta' do
448 | let(:result) { '{"root":{"id":' + obj_id + ',"position_and_company":"Manager at Procore"},"meta":"meta_value"}' }
449 | let(:blueprint) { blueprint_with_block }
450 | it('returns json with a root') do
451 | expect(blueprint.render(obj, root: :root, meta: 'meta_value')).to eq(result)
452 | end
453 | end
454 |
455 | context 'Given blueprint has fields with if conditional' do
456 | let(:result) { '{"id":' + obj_id + '}' }
457 | let(:blueprint) do
458 | Class.new(Blueprinter::Base) do
459 | identifier :id
460 | field :first_name, if: ->(_field_name, _object, _local_opts) { false }
461 | end
462 | end
463 | it 'does not render the field if condition is false' do
464 | expect(blueprint.render(obj)).to eq(result)
465 | end
466 |
467 | context 'when if value is a symbol' do
468 | let(:result) { '{"id":' + obj_id + '}' }
469 | let(:blueprint) do
470 | Class.new(Blueprinter::Base) do
471 | identifier :id
472 | field :first_name, if: :if_method
473 |
474 | def self.if_method(_field_name, _object, _local_opts)
475 | false
476 | end
477 | end
478 | end
479 | it 'does not render the field if the result of sending symbol to Blueprint is false' do
480 | should eq(result)
481 | end
482 | end
483 | end
484 |
485 | context 'Given blueprint has :meta without :root' do
486 | let(:blueprint) { blueprint_with_block }
487 | it('raises a MetaRequiresRoot error') {
488 | expect{blueprint.render(obj, meta: 'meta_value')}.to raise_error(Blueprinter::Errors::MetaRequiresRoot)
489 | }
490 | end
491 |
492 | context 'Given blueprint has root as a non-supported object' do
493 | let(:blueprint) { blueprint_with_block }
494 | it('raises a InvalidRoot error') {
495 | expect{blueprint.render(obj, root: {some_key: "invalid root"})}.to raise_error(Blueprinter::Errors::InvalidRoot)
496 | }
497 | end
498 |
499 | context 'Given blueprint has ::field with a block' do
500 | let(:result) { '{"id":' + obj_id + ',"position_and_company":"Manager at Procore"}' }
501 | let(:blueprint) { blueprint_with_block }
502 | it('returns json with values derived from a block') { should eq(result) }
503 | end
504 |
505 | context 'Given ::render with options' do
506 | subject { blueprint.render(obj, vehicle: vehicle) }
507 | let(:result) { '{"id":' + obj_id + ',"vehicle_make":"Super Car"}' }
508 | let(:blueprint) do
509 | Class.new(Blueprinter::Base) do
510 | identifier :id
511 | field :vehicle_make do |_obj, options|
512 | "#{options[:vehicle][:make]}"
513 | end
514 | end
515 | end
516 | it('returns json with values derived from options') { should eq(result) }
517 | end
518 |
519 | context 'Given ::render in a nested included view, original view is accessible in options' do
520 | let(:blueprint) do
521 | Class.new(Blueprinter::Base) do
522 | identifier :id
523 | field :view_trigger do |_obj, options|
524 | "#{options[:view]}"
525 | end
526 |
527 | view :nested do
528 | include_view :default
529 | end
530 | end
531 | end
532 |
533 | describe 'with non default view' do
534 | subject { blueprint.render(obj, view: :nested) }
535 | let(:result) { '{"id":' + obj_id + ',"view_trigger":"nested"}' }
536 |
537 | it('returns json with values derived from options') { should eq(result) }
538 | end
539 |
540 | describe 'with explicit default view' do
541 | subject { blueprint.render(obj, view: :default) }
542 | let(:result) { '{"id":' + obj_id + ',"view_trigger":"default"}' }
543 |
544 | it('returns json with values derived from options') { should eq(result) }
545 | end
546 |
547 | describe 'with non explicit default view' do
548 | subject { blueprint.render(obj) }
549 | let(:result) { '{"id":' + obj_id + ',"view_trigger":"default"}' }
550 |
551 | it('returns json with values derived from options') { should eq(result) }
552 | end
553 | end
554 |
555 | context 'Given blueprint has a transformer' do
556 | subject { blueprint.render(obj) }
557 | let(:result) { '{"id":' + obj_id + ',"full_name":"Meg Ryan"}' }
558 | let(:blueprint) do
559 | DynamicFieldsTransformer = Class.new(Blueprinter::Transformer) do
560 | def transform(result_hash, object, options={})
561 | dynamic_fields = (object.is_a? Hash) ? object[:dynamic_fields] : object.dynamic_fields
562 | result_hash.merge!(dynamic_fields)
563 | end
564 | end
565 | Class.new(Blueprinter::Base) do
566 | identifier :id
567 | transform DynamicFieldsTransformer
568 | end
569 | end
570 | it('returns json with values derived from options') { should eq(result) }
571 | end
572 |
573 | context 'Given blueprint has a transformer with a default configured' do
574 | let(:default_transform) do
575 | UpcaseKeysTransformer = Class.new(Blueprinter::Transformer) do
576 | def transform(hash, _object, _options)
577 | hash.transform_keys! { |key| key.to_s.upcase.to_sym }
578 | end
579 | end
580 | end
581 | before do
582 | Blueprinter.configure { |config| config.default_transformers = [default_transform] }
583 | end
584 | after { reset_blueprinter_config! }
585 | subject { blueprint.render(obj) }
586 | let(:result) { '{"id":' + obj_id + ',"full_name":"Meg Ryan"}' }
587 | let(:blueprint) do
588 | DynamicFieldsTransformer = Class.new(Blueprinter::Transformer) do
589 | def transform(result_hash, object, options={})
590 | dynamic_fields = (object.is_a? Hash) ? object[:dynamic_fields] : object.dynamic_fields
591 | result_hash.merge!(dynamic_fields)
592 | end
593 | end
594 | Class.new(Blueprinter::Base) do
595 | identifier :id
596 | transform DynamicFieldsTransformer
597 | end
598 | end
599 | it('overrides the configured default transformer') { should eq(result) }
600 | end
601 |
602 | context "Ordering of fields from inside a view by definition" do
603 | before { Blueprinter.configure { |config| config.sort_fields_by = :definition } }
604 | after { reset_blueprinter_config! }
605 |
606 |
607 | let(:view_default) do
608 | Class.new(Blueprinter::Base) do
609 | view :expanded do
610 | field :company
611 | end
612 | field :first_name
613 | field :last_name
614 | end
615 | end
616 | let(:view_default_keys) { [:first_name, :last_name] }
617 |
618 | let(:view_first) do
619 | Class.new(Blueprinter::Base) do
620 | view :expanded do
621 | field :company
622 | end
623 | identifier :id
624 | field :first_name
625 | field :last_name
626 | end
627 | end
628 | let(:view_first_keys) { [:id, :company, :first_name, :last_name] }
629 |
630 | let(:view_last) do
631 | Class.new(Blueprinter::Base) do
632 | field :first_name
633 | field :last_name
634 | view :expanded do
635 | field :company
636 | end
637 | end
638 | end
639 | let(:view_last_keys) { [:first_name, :last_name , :company] }
640 |
641 | let(:view_middle) do
642 | Class.new(Blueprinter::Base) do
643 | field :first_name
644 | view :expanded do
645 | field :company
646 | end
647 | field :last_name
648 | end
649 | end
650 | let(:view_middle_keys) { [:first_name, :company, :last_name] }
651 |
652 | let(:view_middle_include) do
653 | Class.new(Blueprinter::Base) do
654 | field :first_name
655 | view :active do
656 | field :active
657 | end
658 | view :expanded do
659 | field :company
660 | include_view :active
661 | end
662 | field :last_name
663 | end
664 | end
665 | let(:view_middle_include_keys) { [:first_name, :company, :active, :last_name] }
666 |
667 | let(:view_middle_includes) do
668 | Class.new(Blueprinter::Base) do
669 | field :first_name
670 | view :active do
671 | field :active
672 | end
673 | view :description do
674 | field :description
675 | end
676 | view :expanded do
677 | field :company
678 | include_views :active, :description
679 | end
680 | field :last_name
681 | end
682 | end
683 | let(:view_middle_includes_keys) { [:first_name, :company, :active, :description, :last_name] }
684 |
685 | let(:view_middle_and_last) do
686 | Class.new(Blueprinter::Base) do
687 | view :description do
688 | field :description
689 | end
690 | view :active do
691 | field :active
692 | field :deleted_at
693 | end
694 |
695 | field :first_name
696 | view :expanded do
697 | field :company
698 | include_view :active
699 | end
700 | field :last_name
701 | view :expanded do
702 | include_view :description
703 | end
704 | end
705 | end
706 | # all :expanded blocks' fields got into the order at the point where the :expanded block was entered the first time
707 | # bc of depth first traversal at sorting time and not tracking state of @definition_order at time of each block entry
708 | let(:view_middle_and_last_keys) { [:first_name, :company, :active, :deleted_at, :description, :last_name] }
709 |
710 | let(:view_include_cycle) do
711 | Class.new(Blueprinter::Base) do
712 | view :description do
713 | field :description
714 | include_view :active
715 | end
716 | view :active do
717 | field :active
718 | include_view :expanded
719 | end
720 | view :expanded do
721 | field :last_name
722 | include_view :description
723 | end
724 | end
725 | end
726 | let(:view_include_cycle_keys) {[:last_name, :description, :active, :foo]}
727 |
728 | subject { blueprint.render_as_hash(object_with_attributes, view: :expanded).keys }
729 |
730 | context "Middle" do
731 | let(:blueprint) { view_middle }
732 | it "order preserved" do
733 | should(eq(view_middle_keys))
734 | end
735 | end
736 | context "First" do
737 | let(:blueprint) { view_first }
738 | it "order preserved" do
739 | should(eq(view_first_keys))
740 | end
741 | end
742 | context "Last" do
743 | let(:blueprint) { view_last }
744 | it "order preserved" do
745 | should(eq(view_last_keys))
746 | end
747 | end
748 | context "include_view" do
749 | let(:blueprint) { view_middle_include }
750 | it "order preserved" do
751 | should(eq(view_middle_include_keys))
752 | end
753 | end
754 | context "include_views" do
755 | let(:blueprint) { view_middle_includes }
756 | it "order preserved" do
757 | should(eq(view_middle_includes_keys))
758 | end
759 | end
760 | context "Middle and Last" do
761 | let(:blueprint) { view_middle_and_last }
762 | it "order preserved" do
763 | should(eq(view_middle_and_last_keys))
764 | end
765 | end
766 | context "Cycle" do
767 | let(:blueprint) { view_include_cycle }
768 | it "falls over and dies" do
769 | #should(eq(view_include_cycle_keys))
770 | expect {should}.to raise_error(SystemStackError)
771 | end
772 | end
773 |
774 | context "Default" do
775 | context "explicit" do
776 | subject { blueprint.render_as_hash(object_with_attributes, view: :default).keys }
777 | let(:blueprint) { view_default }
778 | it "order preserved" do
779 | should(eq(view_default_keys))
780 | end
781 | end
782 | context "implicit" do
783 | subject { blueprint.render_as_hash(object_with_attributes).keys }
784 | let(:blueprint) { view_default }
785 | it "order preserved" do
786 | should(eq(view_default_keys))
787 | end
788 | end
789 | end
790 |
791 | end
792 |
793 | context 'field exclusion' do
794 | let(:view) do
795 | Class.new(Blueprinter::Base) do
796 | view :exclude_first_name do
797 | exclude :first_name
798 | end
799 |
800 | identifier :id
801 | field :first_name
802 | field :last_name
803 |
804 | view :excluded do
805 | field :middle_name
806 | exclude :id
807 | include_view :exclude_first_name
808 | end
809 | end
810 | end
811 | let(:excluded_view_keys) { %i[last_name middle_name] }
812 | let(:blueprint) { view }
813 |
814 | subject { blueprint.render_as_hash(object_with_attributes, view: :excluded).keys }
815 |
816 | it 'excludes fields' do
817 | should(eq(excluded_view_keys))
818 | end
819 | end
820 | end
821 |
--------------------------------------------------------------------------------
/spec/integrations/base_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'activerecord_helper'
4 | require 'ostruct'
5 | require_relative 'shared/base_render_examples'
6 |
7 | describe '::Base' do
8 | let(:blueprint_with_block) do
9 | Class.new(Blueprinter::Base) do
10 | identifier :id
11 | field :position_and_company do |obj|
12 | "#{obj.position} at #{obj.company}"
13 | end
14 | end
15 | end
16 | let(:obj_hash) do
17 | {
18 | id: 1,
19 | first_name: 'Meg',
20 | last_name: 'Ryan',
21 | position: 'Manager',
22 | description: 'A person',
23 | company: 'Procore',
24 | birthday: Date.new(1994, 3, 4),
25 | deleted_at: nil,
26 | active: false,
27 | dynamic_fields: {"full_name" => "Meg Ryan"}
28 | }
29 | end
30 | let(:object_with_attributes) { OpenStruct.new(obj_hash) }
31 |
32 | describe '::render' do
33 | subject { blueprint.render(obj) }
34 |
35 | context 'when providing a view' do
36 | let(:blueprint) do
37 | Class.new(Blueprinter::Base) do
38 | identifier :id
39 | field :first_name
40 |
41 | view :extended do
42 | field :last_name
43 | end
44 | end
45 | end
46 | it 'renders the data based on the view definition' do
47 | expect(blueprint.render(object_with_attributes, view: :extended)).
48 | to eq('{"id":1,"first_name":"Meg","last_name":"Ryan"}')
49 | end
50 | context 'and the value is nil' do
51 | it 'falls back to the :default view' do
52 | expect(blueprint.render(object_with_attributes, view: nil)).
53 | to eq(blueprint.render(object_with_attributes))
54 | end
55 | end
56 | end
57 |
58 | context 'when using Symbol#to_proc syntax' do
59 | let(:obj) { object_with_attributes }
60 |
61 | context 'with field using &:method_name' do
62 | let(:blueprint) do
63 | Class.new(Blueprinter::Base) do
64 | identifier :id
65 | field :name, &:first_name
66 | field :job_position, &:position
67 | end
68 | end
69 |
70 | it 'renders the field by calling the method on the object' do
71 | result = JSON.parse(blueprint.render(obj))
72 | expect(result['id']).to eq(1)
73 | expect(result['name']).to eq('Meg')
74 | expect(result['job_position']).to eq('Manager')
75 | end
76 | end
77 |
78 | context 'with identifier using &:method_name' do
79 | let(:blueprint) do
80 | Class.new(Blueprinter::Base) do
81 | identifier :user_id, &:id
82 | field :first_name
83 | end
84 | end
85 |
86 | it 'renders the identifier using Symbol#to_proc' do
87 | expect(blueprint.render(obj)).to eq('{"user_id":1,"first_name":"Meg"}')
88 | end
89 | end
90 |
91 | context 'when mixing Symbol#to_proc and regular blocks' do
92 | let(:blueprint) do
93 | Class.new(Blueprinter::Base) do
94 | identifier :id
95 | field :name, &:first_name
96 | field :full_name do |obj|
97 | "#{obj.first_name} #{obj.last_name}"
98 | end
99 | field :info do |obj, options|
100 | "#{obj.position} (options: #{options.inspect})"
101 | end
102 | end
103 | end
104 |
105 | it 'handles both block types correctly' do
106 | result = JSON.parse(blueprint.render(obj))
107 | expect(result['id']).to eq(1)
108 | expect(result['name']).to eq('Meg')
109 | expect(result['full_name']).to eq('Meg Ryan')
110 | expect(result['info']).to include('Manager')
111 | end
112 | end
113 | end
114 |
115 | context 'Outside Rails project' do
116 | context 'Given passed object has dot notation accessible attributes' do
117 | let(:obj) { object_with_attributes }
118 | let(:obj_id) { obj.id.to_s }
119 | let(:vehicle) { OpenStruct.new(id: 1, make: 'Super Car') }
120 |
121 | include_examples 'Base::render'
122 | end
123 |
124 | context 'Given passed object is a Hash' do
125 | let(:blueprint_with_block) do
126 | Class.new(Blueprinter::Base) do
127 | identifier :id
128 | field :position_and_company do |obj|
129 | "#{obj[:position]} at #{obj[:company]}"
130 | end
131 | end
132 | end
133 | let(:obj) { obj_hash }
134 | let(:vehicle) { { id: 1, make: 'Super Car' } }
135 | let(:obj_id) { obj[:id].to_s }
136 |
137 | include_examples 'Base::render'
138 | end
139 | end
140 |
141 | context 'Given passed object is array-like' do
142 | let(:blueprint) { blueprint_with_block }
143 | let(:additional_object) { OpenStruct.new(obj_hash.merge(id: 2)) }
144 | let(:obj) { Set.new([object_with_attributes, additional_object]) }
145 |
146 | context 'and is an instance of a configured array-like class' do
147 | before do
148 | reset_blueprinter_config!
149 | Blueprinter.configure { |config| config.custom_array_like_classes = [Set] }
150 | end
151 | after { reset_blueprinter_config! }
152 |
153 | it 'should return the expected array of hashes' do
154 | should eq('[{"id":1,"position_and_company":"Manager at Procore"},{"id":2,"position_and_company":"Manager at Procore"}]')
155 | end
156 | end
157 |
158 | context 'and is not an instance of a configured array-like class' do
159 | it 'should raise an error' do
160 | expect { blueprint.render(obj) }.to raise_error(NoMethodError)
161 | end
162 | end
163 | end
164 |
165 | context 'Given exclude_if_nil is passed' do
166 | let(:obj) { OpenStruct.new(obj_hash.merge(category: nil, label: 'not nil')) }
167 |
168 | context 'and exclude_if_nil is true' do
169 | let(:blueprint) do
170 | Class.new(Blueprinter::Base) do
171 | field :category, exclude_if_nil: true
172 | field :label, exclude_if_nil: true
173 | end
174 | end
175 | let(:result) { '{"label":"not nil"}' }
176 | it { expect(blueprint.render(obj)).to eq(result) }
177 | end
178 |
179 | context 'and exclude_if_nil is false' do
180 | let(:blueprint) do
181 | Class.new(Blueprinter::Base) do
182 | field :category, exclude_if_nil: false
183 | field :label, exclude_if_nil: true
184 | end
185 | end
186 | let(:result) { '{"category":null,"label":"not nil"}' }
187 | it { expect(blueprint.render(obj)).to eq(result) }
188 | end
189 | end
190 |
191 | context 'Inside Rails project' do
192 | include FactoryBot::Syntax::Methods
193 | let(:obj) { create(:user) }
194 | let(:obj_id) { obj.id.to_s }
195 | let(:vehicle) { create(:vehicle) }
196 |
197 | include_examples 'Base::render'
198 |
199 | context 'Given blueprint has ::association' do
200 | let(:result) do
201 | '{"id":' + obj_id + ',"vehicles":[{"make":"Super Car"}]}'
202 | end
203 | let(:blueprint_without_associated_blueprint) do
204 | Class.new(Blueprinter::Base) do
205 | identifier :id
206 | association :vehicles
207 | end
208 | end
209 | before { vehicle.update(user: obj) }
210 | context 'Given associated blueprint is given' do
211 | let(:blueprint) do
212 | vehicle_blueprint = Class.new(Blueprinter::Base) do
213 | fields :make
214 | end
215 | Class.new(Blueprinter::Base) do
216 | identifier :id
217 | association :vehicles, blueprint: vehicle_blueprint
218 | end
219 | end
220 | it('returns json with association') { should eq(result) }
221 | end
222 | context 'Given associated blueprint does not inherit from Blueprinter::Base' do
223 | let(:blueprint) do
224 | vehicle_invalid_blueprint = Class.new
225 | Class.new(Blueprinter::Base) do
226 | identifier :id
227 | association :vehicles, blueprint: vehicle_invalid_blueprint
228 | end
229 | end
230 | it { expect { subject }.to raise_error(Blueprinter::Errors::InvalidBlueprint) }
231 | end
232 | context "Given association with dynamic blueprint" do
233 | class UserBlueprint < Blueprinter::Base
234 | fields :id
235 | end
236 | class User < ActiveRecord::Base
237 | def blueprint
238 | UserBlueprint
239 | end
240 | end
241 | let(:blueprint) do
242 | Class.new(Blueprinter::Base) do
243 | association :user, blueprint: ->(obj) { obj.blueprint }
244 | end
245 | end
246 | it "should render the association with dynamic blueprint" do
247 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq({"id"=>obj.id})
248 | end
249 | end
250 | context "Given default_if option is Blueprinter::EMPTY_HASH" do
251 | before do
252 | expect(vehicle).to receive(:user).and_return({})
253 | end
254 |
255 | context "Given a default value" do
256 | let(:blueprint) do
257 | Class.new(Blueprinter::Base) do
258 | association :user,
259 | blueprint: Class.new(Blueprinter::Base) { identifier :id },
260 | default: "empty_hash",
261 | default_if: Blueprinter::EMPTY_HASH
262 | end
263 | end
264 | it('uses the correct default value') do
265 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq("empty_hash")
266 | end
267 | end
268 |
269 | context "Given no default value" do
270 | let(:blueprint) do
271 | Class.new(Blueprinter::Base) do
272 | association :user,
273 | blueprint: Class.new(Blueprinter::Base) { identifier :id },
274 | default_if: Blueprinter::EMPTY_HASH
275 | end
276 | end
277 | it('uses the correct default value') do
278 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq(nil)
279 | end
280 | end
281 | end
282 | context "Given default_if option is Blueprinter::EMPTY_COLLECTION" do
283 | before { vehicle.update(user: nil) }
284 | after { vehicle.update(user: obj) }
285 |
286 | context "Given a default value" do
287 | let(:result) do
288 | '{"id":' + obj_id + ',"vehicles":"foo"}'
289 | end
290 | let(:blueprint) do
291 | vehicle_blueprint = Class.new(Blueprinter::Base) {}
292 | Class.new(Blueprinter::Base) do
293 | identifier :id
294 | association :vehicles, blueprint: vehicle_blueprint, default: "foo", default_if: Blueprinter::EMPTY_COLLECTION
295 | end
296 | end
297 | it('returns json with association') { should eq(result) }
298 | end
299 |
300 | context "Given no default value" do
301 | let(:result) do
302 | '{"id":' + obj_id + ',"vehicles":null}'
303 | end
304 | let(:blueprint) do
305 | vehicle_blueprint = Class.new(Blueprinter::Base) {}
306 | Class.new(Blueprinter::Base) do
307 | identifier :id
308 | association :vehicles, blueprint: vehicle_blueprint, default_if: Blueprinter::EMPTY_COLLECTION
309 | end
310 | end
311 | it('returns json with association') { should eq(result) }
312 | end
313 | end
314 | context 'Given block is passed' do
315 | let(:blueprint) do
316 | vehicle_blueprint = Class.new(Blueprinter::Base) do
317 | fields :make
318 | end
319 |
320 | Class.new(Blueprinter::Base) do
321 | identifier :id
322 | association(:automobiles, blueprint: vehicle_blueprint) { |o| o.vehicles }
323 | end
324 | end
325 | let(:result) do
326 | '{"id":' + obj_id + ',"automobiles":[{"make":"Super Car"}]}'
327 | end
328 | it('returns json with aliased association') { should eq(result) }
329 | end
330 | context 'Given no associated blueprint is given' do
331 | let(:blueprint) do
332 | Class.new(Blueprinter::Base) do
333 | identifier :id
334 | association :vehicles
335 | end
336 | end
337 | it 'raises an ArgumentError' do
338 | expect { subject }.
339 | to raise_error(ArgumentError, /:blueprint must be provided when defining an association/)
340 | end
341 | end
342 | context 'Given an association :options option' do
343 | let(:result) { '{"id":' + obj_id + ',"vehicles":[{"make":"Super Car Enhanced"}]}' }
344 | let(:blueprint) do
345 | vehicle_blueprint = Class.new(Blueprinter::Base) do
346 | field :make do |vehicle, options|
347 | "#{vehicle.make} #{options[:modifier]}"
348 | end
349 | end
350 |
351 | Class.new(Blueprinter::Base) do
352 | field :id
353 | association :vehicles, blueprint: vehicle_blueprint, options: { modifier: 'Enhanced' }
354 | end
355 | end
356 | it('returns json using the association options') { should eq(result) }
357 | end
358 | context 'Given an association :extractor option' do
359 | let(:result) { '{"id":' + obj_id + ',"vehicles":[{"make":"SUPER CAR"}]}' }
360 | let(:blueprint) do
361 | extractor = Class.new(Blueprinter::Extractor) do
362 | def extract(association_name, object, _local_options, _options={})
363 | object.send(association_name).map { |vehicle| { make: vehicle.make.upcase } }
364 | end
365 | end
366 |
367 | vehicle_blueprint = Class.new(Blueprinter::Base) { fields :make }
368 |
369 | Class.new(Blueprinter::Base) do
370 | field :id
371 | association :vehicles, blueprint: vehicle_blueprint, extractor: extractor
372 | end
373 | end
374 | it('returns json derived from a custom extractor') { should eq(result) }
375 | end
376 | context 'when a view is specified' do
377 | let(:vehicle) { create(:vehicle, :with_model) }
378 | let(:blueprint) do
379 | vehicle_blueprint = Class.new(Blueprinter::Base) do
380 | fields :make
381 |
382 | view :with_model do
383 | field :model
384 | end
385 | end
386 | Class.new(Blueprinter::Base) do
387 | identifier :id
388 | association :vehicles, blueprint: vehicle_blueprint, view: :with_model
389 | end
390 | end
391 | let(:result) do
392 | "{\"id\":#{obj.id},\"vehicles\":[{\"make\":\"Super Car\",\"model\":\"ACME\"}]}"
393 | end
394 | it 'leverages the specified view when rendering the association' do
395 | expect(blueprint.render(obj)).to eq(result)
396 | end
397 | end
398 | context 'Given included view with re-defined association' do
399 | let(:blueprint) do
400 | vehicle_blueprint = Class.new(Blueprinter::Base) do
401 | fields :make
402 |
403 | view :with_size do
404 | field :size do 10 end
405 | end
406 |
407 | view :with_height do
408 | field :height do 2 end
409 | end
410 | end
411 | Class.new(Blueprinter::Base) do
412 | identifier :id
413 | association :vehicles, blueprint: vehicle_blueprint
414 |
415 | view :with_size do
416 | association :vehicles, blueprint: vehicle_blueprint, view: :with_size
417 | end
418 |
419 | view :with_height do
420 | include_view :with_size
421 | association :vehicles, blueprint: vehicle_blueprint, view: :with_height
422 | end
423 | end
424 | end
425 |
426 | let(:result_default) do
427 | '{"id":' + obj_id + ',"vehicles":[{"make":"Super Car"}]}'
428 | end
429 | let(:result_with_size) do
430 | '{"id":' + obj_id + ',"vehicles":[{"make":"Super Car","size":10}]}'
431 | end
432 | let(:result_with_height) do
433 | '{"id":' + obj_id + ',"vehicles":[{"height":2,"make":"Super Car"}]}'
434 | end
435 |
436 | it 'returns json with association' do
437 | expect(blueprint.render(obj)).to eq(result)
438 | expect(blueprint.render(obj, view: :with_size)).to eq(result_with_size)
439 | expect(blueprint.render(obj, view: :with_height)).to eq(result_with_height)
440 | end
441 | end
442 | context 'when if option is provided' do
443 | let(:vehicle) { create(:vehicle, make: 'Super Car') }
444 | let(:user_without_cars) { create(:user, vehicles: []) }
445 | let(:user_with_cars) { create(:user, vehicles: [vehicle]) }
446 |
447 | let(:blueprint) do
448 | vehicle_blueprint = Class.new(Blueprinter::Base) do
449 | fields :make
450 | end
451 | Class.new(Blueprinter::Base) do
452 | identifier :id
453 | association :vehicles, blueprint: vehicle_blueprint, if: ->(_field_name, object, _local_opts) { object.vehicles.present? }
454 | end
455 | end
456 | it 'does not render the association if the if condition is not met' do
457 | expect(blueprint.render(user_without_cars)).to eq("{\"id\":#{user_without_cars.id}}")
458 | end
459 | it 'renders the association if the if condition is met' do
460 | expect(blueprint.render(user_with_cars)).to eq("{\"id\":#{user_with_cars.id},\"vehicles\":[{\"make\":\"Super Car\"}]}")
461 | end
462 |
463 | context 'and if option is a symbol' do
464 | let(:blueprint) do
465 | vehicle_blueprint = Class.new(Blueprinter::Base) do
466 | fields :make
467 | end
468 | Class.new(Blueprinter::Base) do
469 | identifier :id
470 | association :vehicles, blueprint: vehicle_blueprint, if: :has_vehicles?
471 | association :vehicles, name: :cars, blueprint: vehicle_blueprint, if: :has_cars?
472 |
473 | def self.has_vehicles?(_field_name, object, local_options)
474 | false
475 | end
476 |
477 | def self.has_cars?(_field_name, object, local_options)
478 | true
479 | end
480 | end
481 | end
482 |
483 | it 'renders the association based on evaluating the symbol as a method on the blueprint' do
484 | expect(blueprint.render(user_with_cars)).
485 | to eq("{\"id\":#{user_with_cars.id},\"cars\":[{\"make\":\"Super Car\"}]}")
486 | end
487 | end
488 | end
489 | end
490 |
491 | context "Given association is nil" do
492 | before do
493 | expect(vehicle).to receive(:user).and_return(nil)
494 | end
495 |
496 | context "Given global default association value is specified" do
497 | before { Blueprinter.configure { |config| config.association_default = "N/A" } }
498 | after { reset_blueprinter_config! }
499 |
500 | context "Given default association value is not provided" do
501 | let(:blueprint) do
502 | Class.new(Blueprinter::Base) do
503 | fields :make
504 | association :user, blueprint: Class.new(Blueprinter::Base) { identifier :id }
505 | end
506 | end
507 |
508 | it "should render the association using the default global association value" do
509 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq("N/A")
510 | end
511 | end
512 |
513 | context "Given default association value is provided" do
514 | let(:blueprint) do
515 | Class.new(Blueprinter::Base) do
516 | fields :make
517 | association :user,
518 | blueprint: Class.new(Blueprinter::Base) { identifier :id },
519 | default: {}
520 | end
521 | end
522 |
523 | it "should render the default value provided for the association" do
524 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq({})
525 | end
526 | end
527 |
528 | context "Given default association value is provided and is nil" do
529 | let(:blueprint) do
530 | Class.new(Blueprinter::Base) do
531 | fields :make
532 | association :user,
533 | blueprint: Class.new(Blueprinter::Base) { identifier :id },
534 | default: nil
535 | end
536 | end
537 |
538 | it "should render the default value provided for the association" do
539 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to be_nil
540 | end
541 | end
542 | end
543 |
544 | context "Given global default association value is not specified" do
545 | context "Given default association value is not provided" do
546 | let(:blueprint) do
547 | Class.new(Blueprinter::Base) do
548 | fields :make
549 | association :user, blueprint: Class.new(Blueprinter::Base) { identifier :id }
550 | end
551 | end
552 |
553 | it "should render the association as nil" do
554 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to be_nil
555 | end
556 | end
557 |
558 | context "Given default association value is provided" do
559 | let(:blueprint) do
560 | Class.new(Blueprinter::Base) do
561 | fields :make
562 | association :user,
563 | blueprint: Class.new(Blueprinter::Base) { identifier :id },
564 | default: {}
565 | end
566 | end
567 |
568 | it "should render the default value provided for the association" do
569 | expect(JSON.parse(blueprint.render(vehicle))["user"]).to eq({})
570 | end
571 | end
572 | end
573 | end
574 |
575 | context 'Given passed object is an instance of a configured array-like class' do
576 | let(:blueprint) do
577 | Class.new(Blueprinter::Base) do
578 | identifier :id
579 | fields :make
580 | end
581 | end
582 | let(:vehicle1) { build(:vehicle, id: 1) }
583 | let(:vehicle2) { build(:vehicle, id: 2, make: 'Mediocre Car') }
584 | let(:vehicle3) { build(:vehicle, id: 3, make: 'Terrible Car') }
585 | let(:vehicles) { [vehicle1, vehicle2, vehicle3] }
586 | let(:obj) { Set.new(vehicles) }
587 | let(:result) do
588 | vehicles_json = vehicles.map do |vehicle|
589 | "{\"id\":#{vehicle.id},\"make\":\"#{vehicle.make}\"}"
590 | end.join(',')
591 | "[#{vehicles_json}]"
592 | end
593 |
594 | before do
595 | reset_blueprinter_config!
596 | Blueprinter.configure do |config|
597 | config.custom_array_like_classes = [Set]
598 | end
599 | end
600 | after { reset_blueprinter_config! }
601 |
602 | it('returns the expected result') { should eq(result) }
603 |
604 | context 'Given options containing `view` and rendered multiple times (as in batching)' do
605 | let(:blueprint) do
606 | Class.new(Blueprinter::Base) do
607 | field :id
608 | view :with_make do
609 | field :make
610 | end
611 | end
612 | end
613 |
614 | let(:options) { { view: :with_make } }
615 |
616 | subject do
617 | obj.map do |vehicle|
618 | blueprint.render_as_hash(vehicle, options)
619 | end.to_json
620 | end
621 |
622 | it('returns the expected result') { should eq(result) }
623 | end
624 | end
625 | end
626 | end
627 |
628 | describe '::render_as_hash' do
629 | subject { blueprint_with_block.render_as_hash(object_with_attributes) }
630 | context 'Outside Rails project' do
631 | context 'Given passed object has dot notation accessible attributes' do
632 | let(:obj) { object_with_attributes }
633 | it 'returns a hash with expected format' do
634 | expect(subject).to eq({ id: obj.id, position_and_company: "#{obj.position} at #{obj.company}"})
635 | end
636 | end
637 | end
638 | end
639 |
640 | describe '.prepare' do
641 | subject { blueprint_with_block.prepare(object_with_attributes, view_name: :default, local_options: {}) }
642 | it 'returns a hash with expected format' do
643 | expect(subject).to eq({ id: object_with_attributes.id, position_and_company: "#{object_with_attributes.position} at #{object_with_attributes.company}"})
644 | end
645 |
646 | it 'logs a deprecation warning' do
647 | expect(Blueprinter::Deprecation).to receive(:report).with(
648 | <<~MESSAGE
649 | The `prepare` method is no longer supported will be removed in the next minor release.
650 | If similar functionality is needed, use `.render_as_hash` instead.
651 | MESSAGE
652 | )
653 | subject
654 | end
655 | end
656 |
657 | describe '::render_as_json' do
658 | subject { blueprint_with_block.render_as_json(object_with_attributes) }
659 | context 'Outside Rails project' do
660 | context 'Given passed object has dot notation accessible attributes' do
661 | let(:obj) { object_with_attributes }
662 | it 'returns a hash with expected format' do
663 | expect(subject).to eq({ "id" => obj.id, "position_and_company" => "#{obj.position} at #{obj.company}"})
664 | end
665 | end
666 | end
667 | end
668 |
669 | describe 'identifier' do
670 | let(:rendered) do
671 | blueprint.render_as_hash(OpenStruct.new(uid: 42))
672 | end
673 |
674 | let(:blueprint) do
675 | Class.new(Blueprinter::Base) do
676 | identifier :uid
677 | end
678 | end
679 |
680 | it "renders identifier" do
681 | expect(rendered).to eq(uid: 42)
682 | end
683 |
684 | describe 'Given a block is passed' do
685 | let(:blueprint) do
686 | Class.new(Blueprinter::Base) do
687 | identifier(:id) { |object, _| object.uid * 2 }
688 | end
689 | end
690 |
691 | it "renders result of block" do
692 | expect(rendered).to eq(id: 84)
693 | end
694 | end
695 | end
696 |
697 | describe 'has_view?' do
698 | subject { blueprint.view?(view) }
699 |
700 | let(:blueprint) do
701 | Class.new(Blueprinter::Base) do
702 | identifier :uid
703 | view :custom do
704 | end
705 | end
706 | end
707 |
708 | context 'when the blueprint has the supplied view' do
709 | let(:view) { :custom }
710 | it { is_expected.to eq(true) }
711 | end
712 |
713 | context 'when the blueprint does not have the supplied view' do
714 | let(:view) { :does_not_exist }
715 | it { is_expected.to eq(false) }
716 | end
717 | end
718 |
719 | describe 'Using the ApplicationBlueprint pattern' do
720 | let(:obj) { OpenStruct.new(id: 1, first_name: 'Meg',last_name:'Ryan', age: 32) }
721 | let(:transformer) do
722 | Class.new(Blueprinter::Transformer) do
723 | def transform(result_hash, object, options={})
724 | result_hash.merge!({full_name: "#{object.first_name} #{object.last_name}"})
725 | end
726 | end
727 | end
728 | let(:application_blueprint) do
729 | custom_transformer = transformer
730 | Class.new(Blueprinter::Base) do
731 | identifier :id
732 | field :first_name
733 | field(:overridable) { |o| o.name }
734 |
735 | view :with_age do
736 | field :age
737 | transform custom_transformer
738 | end
739 |
740 | view :anonymous_age do
741 | include_view :with_age
742 | exclude :first_name
743 | end
744 | end
745 | end
746 |
747 | let(:blueprint) do
748 | Class.new(application_blueprint) do
749 | field(:overridable) { |o| o.age }
750 |
751 | view :only_age do
752 | include_view :with_age
753 | exclude :first_name
754 | end
755 |
756 | view :with_age do
757 | field :last_name
758 | end
759 | end
760 | end
761 |
762 | subject { blueprint.render_as_hash(obj) }
763 |
764 | it('inherits identifier') { expect(subject[:id]).to eq(obj.id) }
765 | it('inherits field') { expect(subject[:first_name]).to eq(obj.first_name) }
766 | it('overrides field definition') { expect(subject[:overridable]).to eq(obj.age) }
767 |
768 | describe 'Inheriting views' do
769 | let(:view) { :with_age }
770 | subject { blueprint.render_as_hash(obj, view: view) }
771 |
772 | it('includes identifier') { expect(subject[:id]).to eq(obj.id) }
773 | it('includes base fields') { expect(subject[:first_name]).to eq(obj.first_name) }
774 | it('includes view fields') { expect(subject[:age]).to eq(obj.age) }
775 | it('inherits base fields') { expect(subject[:last_name]).to eq(obj.last_name) }
776 | it('inherits transformer fields') { expect(subject[:full_name]).to eq("#{obj.first_name} #{obj.last_name}") }
777 |
778 | describe 'With complex views' do
779 | let(:view) { :anonymous_age }
780 |
781 | it('includes identifier') { expect(subject[:id]).to eq(obj.id) }
782 | it('includes include_view fields') { expect(subject[:age]).to eq(obj.age) }
783 | it('excludes excluded fields') { expect(subject).to_not have_key(:first_name) }
784 | end
785 |
786 | describe 'Referencing views from parent blueprint' do
787 | let(:view) { :only_age }
788 |
789 | it('includes include_view fields') { expect(subject[:age]).to eq(obj.age) }
790 | it('excludes excluded fields') { expect(subject).not_to have_key(:first_name) }
791 | end
792 | end
793 | end
794 | end
795 |
--------------------------------------------------------------------------------