diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..236a056 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper --color --format documentation diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..6b7f2ab --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,75 @@ +AllCops: + Exclude: + - config/initializers/forbidden_yaml.rb + - !ruby/regexp /(vendor|bundle|bin|db|tmp)\/.*/ + DisplayCopNames: true + DisplayStyleGuide: true + TargetRubyVersion: 2.3 + +Lint/AssignmentInCondition: + Enabled: false + +Lint/NestedMethodDefinition: + Enabled: false + +Style/FrozenStringLiteralComment: + EnforcedStyle: always + +Style/StringLiterals: + EnforcedStyle: single_quotes + +Metrics/AbcSize: + Max: 35 # TODO: Lower to 15 + +Metrics/ClassLength: + Max: 261 # TODO: Lower to 100 + Exclude: + - test/**/*.rb + +Metrics/CyclomaticComplexity: + Max: 7 # TODO: Lower to 6 + +Metrics/LineLength: + Max: 110 # TODO: Lower to 80 + +Metrics/MethodLength: + Max: 25 + +Metrics/BlockLength: + Exclude: + - spec/**/* + +Metrics/PerceivedComplexity: + Max: 9 # TODO: Lower to 7 + +Style/AlignParameters: + EnforcedStyle: with_fixed_indentation + +Style/ClassAndModuleChildren: + EnforcedStyle: nested + +Style/Documentation: + Enabled: false + +Style/DoubleNegation: + Enabled: false + +Style/MissingElse: + Enabled: false # TODO: maybe enable this? + EnforcedStyle: case + +Style/EmptyElse: + EnforcedStyle: empty + +Style/MultilineOperationIndentation: + EnforcedStyle: indented + +Style/BlockDelimiters: + Enabled: true + EnforcedStyle: line_count_based + +Style/PredicateName: + Enabled: false # TODO: enable with correct prefixes + +Style/ClassVars: + Enabled: false diff --git a/Gemfile b/Gemfile index fa75df1..097e16d 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,7 @@ source 'https://rubygems.org' +# Add a Gemfile.local to locally bundle gems outside of version control +local_gemfile = File.join(File.expand_path('..', __FILE__), 'Gemfile.local') +eval_gemfile local_gemfile if File.readable?(local_gemfile) + gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..300fa20 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Lucas Hosseini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jsonapi-rails.gemspec b/jsonapi-rails.gemspec index 71cd2c2..e5af391 100644 --- a/jsonapi-rails.gemspec +++ b/jsonapi-rails.gemspec @@ -13,12 +13,17 @@ Gem::Specification.new do |spec| spec.files = Dir['README.md', 'lib/**/*'] spec.require_path = 'lib' - spec.add_dependency 'jsonapi-renderer', '0.1.1.beta2' - spec.add_dependency 'jsonapi-parser', '0.1.1.beta3' - spec.add_dependency 'jsonapi-serializable', '0.1.1.beta2' - spec.add_dependency 'jsonapi-deserializable', '0.1.1.beta3' + spec.add_dependency 'jsonapi-renderer', '~> 0.1.1.beta2' + spec.add_dependency 'jsonapi-parser', '~> 0.1.1.beta3' + spec.add_dependency 'jsonapi-serializable', '~> 0.1.1.beta2' + spec.add_dependency 'jsonapi-deserializable', '~> 0.1.1.beta3' + + # because this gem is intended for rails use, active_support will + # already be included + spec.add_dependency 'activesupport', '> 4.0' spec.add_development_dependency 'activerecord', '>=5' + spec.add_development_dependency 'rails', '>=5' spec.add_development_dependency 'sqlite3', '>= 1.3.12' spec.add_development_dependency 'rake', '>=0.9' spec.add_development_dependency 'rspec', '~>3.4' diff --git a/lib/jsonapi.rb b/lib/jsonapi.rb new file mode 100644 index 0000000..6f91298 --- /dev/null +++ b/lib/jsonapi.rb @@ -0,0 +1,3 @@ +module JSONAPI + require_relative 'jsonapi/rails' +end diff --git a/lib/jsonapi/deserializable.rb b/lib/jsonapi/deserializable.rb new file mode 100644 index 0000000..ba27bcd --- /dev/null +++ b/lib/jsonapi/deserializable.rb @@ -0,0 +1,17 @@ +module JSONAPI + module Deserializable + require_relative 'deserializable/active_record' + + module_function + + def to_active_record_hash(hash, options: {}, klass: nil) + + # TODO: maybe JSONAPI::Document::Deserialization.to_active_record_hash(...)? + JSONAPI::Deserializable::ActiveRecord.new( + hash, + options: options, + klass: klass + ).to_hash + end + end +end diff --git a/lib/jsonapi/deserializable/active_record.rb b/lib/jsonapi/deserializable/active_record.rb new file mode 100644 index 0000000..87b7c19 --- /dev/null +++ b/lib/jsonapi/deserializable/active_record.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true +require 'jsonapi/deserializable' + +module JSONAPI + module Deserializable + # This does not validate a JSON API document, so errors may happen. + # To truely ensure valid documents are used, it would be recommended to + # use either of + # - JSONAPI::Parser - for general parsing and validating + # - JSONAPI::Validations - for defining validation logic. + # + # for 'filtereing' of fields, use ActionController::Parameters + # + # TODO: + # - add option for type-seperator string + # - add options for specifying polymorphic relationships + # - this will try to be inferred based on the klass's associations + # - cache deserializable_for_class + # - allow custom deserializable_classes? + # - then this gem would just be a very light weight wrapper around + # jsonapi/deserializable + class ActiveRecord + require_relative 'active_record/builder' + + class << self + def deserializable_cache + @deserializable_cache ||= {} + end + + # Creates a DeserializableResource class based off all the + # attributes and relationships + # + # @example + # JSONAPI::Deserializable::ActiveRecord[Post].new(params) + def [](klass) + deserializable_cache[klass.name] ||= deserializable_for(klass) + end + + def deserializable_for(klass) + JSONAPI::Deserializable::ActiveRecord::Builder.for_class(klass) + end + + def deserializable_class(type, klass) + klass || type_to_model(type) + end + + def type_to_model(type) + type.classify.safe_constantize + end + end + + # if this class is instatiated directly, i.e.: without a spceified + # class via + # JSONAPI::Deserializable::ActiveRecord[ExampleClass] + # then when to_hash is called, the class will be derived, and + # a class will be used for deserialization as if the + # user specified the deserialization target class. + def initialize(hash, options: {}, klass: nil) + @hash = hash + @options = options + @klass = klass + end + + def to_hash + type = @hash['data']['type'] + klass = self.class.deserializable_class(type, @klass) + + if klass.nil? + raise "FATAL: class not found for type of `#{type}` or specified @klass `#{@klass&.name}`" + end + + self.class[klass].call(@hash).with_indifferent_access + end + end + end +end diff --git a/lib/jsonapi/deserializable/active_record/builder.rb b/lib/jsonapi/deserializable/active_record/builder.rb new file mode 100644 index 0000000..18225bf --- /dev/null +++ b/lib/jsonapi/deserializable/active_record/builder.rb @@ -0,0 +1,84 @@ +require 'jsonapi/deserializable/resource' + +module JSONAPI + module Deserializable + class ActiveRecord + module Builder + require 'active_support/core_ext/string' + + module_function + + def for_class(klass) + builder = self + Class.new(JSONAPI::Deserializable::Resource) do + # All Attributes + builder.define_attributes(self, klass) + + # All Associations + builder.define_associations(self, klass) + end + end + + def define_attributes(deserializable, klass) + attributes = attributes_for_class(klass) + + deserializable.class_eval do + attributes.each do |attribute_name| + attribute attribute_name + end + end + end + + def define_associations(deserializable, klass) + associations = associations_for_class(klass) + + deserializable.class_eval do + associations.each do |name, reflection| + if reflection.collection? + has_many name do |rel| + field "#{name}_ids" => rel['data'].map { |ri| ri['id'] } + # field "#{name}_type" => rel['data'] && rel['data']['type'] + end + else + has_one name do |rel| + field "#{name}_id" => rel['data'] && rel['data']['id'] + + if reflection.polymorphic? + field "#{name}_type" => rel['data'] && rel['data']['type'].classify + end + end + end + end + end + end + + def self.attributes_for_class(klass) + klass.columns.map(&:name) + end + + # @return [Hash] + # example: + # { + # 'author' => #, + # 'comments' => # + # } + # + # for a reflection, the import parts for deserialization may be as follows: + # - Reflection (BelongsTo / HasMany) + # - name - symbol version of the association name (e.g.: :author) + # - collection? - if the reflection is a collection of records + # - class_name - AR Class of the association + # - foreign_type - name of the polymorphic type column + # - foreign_key - name of the foreign_key column + # - polymorphic? - true/false/nil + # - type - name of the type column (for STI) + # + # To see a full list of reflection methods: + # ap klass.reflections['reflection_name'].methods - Object.methods + def self.associations_for_class(klass) + klass.reflections + end + end # Builder + end # DeserializableResource + end # Rails +end # JSONAPI diff --git a/spec/integration/active_record_spec.rb b/spec/integration/active_record_spec.rb new file mode 100644 index 0000000..0090cda --- /dev/null +++ b/spec/integration/active_record_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe JSONAPI::Deserializable::ActiveRecord do + let(:klass) { JSONAPI::Deserializable::ActiveRecord } + + around(:each) do |example| + with_temporary_database(lambda do + create_table :posts do |t| + t.string :name + t.string :body + t.references :author, polymorphic: true + t.references :user + end + + create_table :users do |t| + t.string :name + end + end) do + # Clear cache just in case a test runs before this one + klass.instance_variable_set('@deserializable_cache', {}) + class Post < ActiveRecord::Base + belongs_to :author, polymorphic: true + belongs_to :user + end + class User < ActiveRecord::Base; has_many :posts; end + example.run + end + end + + after(:each) do + # clear the cache, just in case the next test needs + # to interact with the cache + klass.instance_variable_set('@deserializable_cache', {}) + end + + context 'deserializing a jsonapi document' do + context 'with attributes' do + before(:all) do + @payload = { + 'data' => { + 'id' => '1', + 'type' => 'posts', + 'attributes' => { + 'name' => 'Name', + 'body' => 'content' + }, + 'relationships' => {} + } + } + end + + it 'pulls out the attributes' do + result = JSONAPI::Deserializable.to_active_record_hash(@payload, options: {}, klass: Post) + expected = { 'name' => 'Name', 'body' => 'content' } + + expect(result).to eq expected + end + end + + context 'with polymorphic relationships' do + before(:all) do + @payload = { + 'data' => { + 'id' => '1', + 'type' => 'posts', + 'attributes' => { + 'name' => 'Name', + 'body' => 'content' + }, + 'relationships' => { + 'author' => { + 'data' => { + 'id' => 1, + 'type' => 'users' + } + }, + 'user' => { + 'data' => { + 'id' => 2, + 'type' => 'users' + } + } + } + } + } + end + + it 'pulls out the attributes' do + result = JSONAPI::Deserializable.to_active_record_hash(@payload, options: {}, klass: Post) + expected = { + 'name' => 'Name', + 'body' => 'content', + 'author_id' => 1, + 'author_type' => 'User', + 'user_id' => 2 + } + + expect(result).to eq expected + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..af9652a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +require 'jsonapi/rails' +require 'active_support' +require 'active_record' + +require 'support/temporary_database' diff --git a/spec/support/temporary_database.rb b/spec/support/temporary_database.rb new file mode 100644 index 0000000..c229681 --- /dev/null +++ b/spec/support/temporary_database.rb @@ -0,0 +1,48 @@ +def with_temporary_database(db) + setup_database(&db) + reset_column_information + yield + delete_database +end + +def setup_database(&block) + # don't output all the migration activity + ActiveRecord::Migration.verbose = false + + # switch the active database connection to an SQLite, in-memory database + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + + # execute the migration, creating a table (dirty_items) and columns (body, email, name) + ActiveRecord::Schema.define(version: 1) do + instance_exec(&block) + end +end + +def reset_column_information + all_tables.each do |table_name| + class_name = table_name.classify + ar_class = class_name.safe_constantize + if ar_class + ar_class.reset_column_information + # unset it, so we can also clear active_record relationships + # and other class-level stuff + Object.send(:remove_const, class_name.to_sym) + end + end +end + +def delete_database + all_tables.each do |table| + ActiveRecord::Base.connection.execute("DELETE FROM #{table} WHERE 1 = 1") + end +end + +def all_tables + ActiveRecord::Base. + connection.execute( + %Q{ + SELECT name + FROM sqlite_master + WHERE type='table'; + }).map { |r| r[0] } +end diff --git a/spec/unit/active_record_spec.rb b/spec/unit/active_record_spec.rb new file mode 100644 index 0000000..e04ea38 --- /dev/null +++ b/spec/unit/active_record_spec.rb @@ -0,0 +1,90 @@ +describe JSONAPI::Deserializable::ActiveRecord do + let(:klass) { JSONAPI::Deserializable::ActiveRecord } + describe '.[]' do + context 'creates a DeserializableResource class' do + around(:each) do |example| + with_temporary_database(lambda do + create_table :posts do |t| + t.string :title + end + end) do + # Clear cache just in case a test runs before this one + klass.instance_variable_set('@deserializable_cache', {}) + class Post < ActiveRecord::Base; end + example.run + end + end + + after(:each) do + # clear the cache, just in case the next test needs + # to interact with the cache + klass.instance_variable_set('@deserializable_cache', {}) + end + + it 'without a specified type' do + json = { + 'data' => { + 'id' => 1, + 'type' => 'posts', + 'attributes' => { + 'title' => 'a title' + } + } + } + + expect { klass.new(json).to_hash } + .to change(klass.deserializable_cache, :length).by 1 + + expect(klass.deserializable_cache.keys.first).to eq 'Post' + end + + it 'changes the cache' do + expect(klass).to receive(:deserializable_for).once.and_call_original + + expect { klass[Post] } + .to change(klass.deserializable_cache, :length).by 1 + end + + it 'does not add to the cache' do + expect(klass).to receive(:deserializable_for).once.and_call_original + + expect { 3.times { klass[Post] } } + .to change(klass.deserializable_cache, :length).by 1 + end + end + end + + context 'options' do + context 'polymorphic' do + + end + + context 'whitelist fields' do + + end + end + + describe '.deserializable_class' do + it 'returns klass if specified' do + result = klass.deserializable_class('jsonapi-types', 'anything') + + expect(result).to eq 'anything' + end + end + + describe '.type_to_model' do + class A; end + class A::B; end + class C < A; end + class D < A::B; end + + let(:to_model) { ->(str) { klass.type_to_model(str) } } + + it 'converts plural types to a class' do + expect(to_model.call('as')).to eq A + expect(to_model.call('a/bs')).to eq A::B + expect(to_model.call('cs')).to eq C + expect(to_model.call('d')).to eq D + end + end +end diff --git a/spec/unit/builder_spec.rb b/spec/unit/builder_spec.rb new file mode 100644 index 0000000..35627e1 --- /dev/null +++ b/spec/unit/builder_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +describe JSONAPI::Deserializable::ActiveRecord::Builder do + let(:klass) { JSONAPI::Deserializable::ActiveRecord::Builder } + + describe '.for_class' do + it 'returns a DeserializableResource' do + with_temporary_database(lambda do + create_table :posts + end) do + class Post < ActiveRecord::Base; end + deserializer = klass.for_class(Post) + + dummy_payload = { + 'data' => { + 'id' => '1', + 'type' => 'posts', + 'attributes' => {} + } + } + + expect(deserializer.new(dummy_payload)).to be_a_kind_of JSONAPI::Deserializable::Resource + end + end + + it 'defines all the attributes' do + with_temporary_database(lambda do + create_table :posts do |t| + t.string :name + t.string :body + end + end) do + class Post < ActiveRecord::Base; end + deserializer = klass.for_class(Post) + # defined in JSONAPI/DeserializableResource + result = deserializer.attr_blocks.keys + expected = %w(id name body) + expect(result).to eq expected + end + end + + it 'defines all relationships' do + with_temporary_database(lambda do + create_table :posts do |t| + t.string :name + t.string :body + t.references :author + end + + create_table :comments do |t| + t.references :post + end + + create_table :authors + end) do + class Author < ActiveRecord::Base; end + class Comment < ActiveRecord::Base; end + class Post < ActiveRecord::Base + has_many :comments + belongs_to :author + end + + deserializer = klass.for_class(Post) + # defined in JSONAPI/DeserializableResource + result_has_many = deserializer.has_many_rel_blocks.keys + result_has_one = deserializer.has_one_rel_blocks.keys + + expect(result_has_many).to eq ['comments'] + expect(result_has_one).to eq ['author'] + end + end + end + + describe '.attributes_for_class' do + it 'finds the attributes' do + with_temporary_database(lambda do + create_table :posts do |t| + t.text :body + t.text :name + end + end) do + class Post < ActiveRecord::Base; end + attributes = klass.attributes_for_class(Post) + + expect(attributes).to eq %w(id body name) + end + end + + it 'finds the associations' do + with_temporary_database(lambda do + create_table :posts do |t| + t.references :post + end + end) do + class Post < ActiveRecord::Base; belongs_to :post; end + associations = klass.associations_for_class(Post) + + expect(associations.keys).to eq ['post'] + end + end + end +end