├── .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] <title>" 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] <title>" 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] <title>" 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 <your.email@example.com>` 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<Symbol, Blueprinter::Reflection::View>] 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<Symbol, Blueprinter::Reflection::Field>] 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<Symbol, Blueprinter::Reflection::Association>] 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 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg width="400px" height="400px" viewBox="0 0 400 400" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> 3 | <!-- Generator: Sketch 49 (51002) - http://www.bohemiancoding.com/sketch --> 4 | <title>Blueprinter_option3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------