diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md
index d7513dce8a..3cb290fdd0 100644
--- a/activemodel/CHANGELOG.md
+++ b/activemodel/CHANGELOG.md
@@ -1,3 +1,10 @@
+* Introduce `ActiveModel::API`.
+
+ Make `ActiveModel::API` the minimum API to talk with Action Pack and Action View.
+ This will allow adding more functionality to `ActiveModel::Model`.
+
+ *Petrik de Heus*, *Nathaniel Watts*
+
* Fix dirty check for Float::NaN and BigDecimal::NaN.
Float::NaN and BigDecimal::NaN in Ruby are [special values](https://bugs.ruby-lang.org/issues/1720)
diff --git a/activemodel/README.rdoc b/activemodel/README.rdoc
index f68c5bfcd0..12e3a927f1 100644
--- a/activemodel/README.rdoc
+++ b/activemodel/README.rdoc
@@ -16,10 +16,10 @@ Model solves this by defining an explicit API. You can read more about the
API in ActiveModel::Lint::Tests.
Active Model provides a default module that implements the basic API required
-to integrate with Action Pack out of the box: ActiveModel::Model.
+to integrate with Action Pack out of the box: ActiveModel::API.
class Person
- include ActiveModel::Model
+ include ActiveModel::API
attr_accessor :name, :age
validates_presence_of :name
@@ -32,7 +32,7 @@ to integrate with Action Pack out of the box: ActiveModel::Model.
It includes model name introspections, conversions, translations and
validations, resulting in a class suitable to be used with Action Pack.
-See ActiveModel::Model for more examples.
+See ActiveModel::API for more examples.
Active Model also provides the following functionality to have ORM-like
behavior out of the box:
diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb
index c93c248591..8af205bf59 100644
--- a/activemodel/lib/active_model.rb
+++ b/activemodel/lib/active_model.rb
@@ -30,6 +30,7 @@ require "active_model/version"
module ActiveModel
extend ActiveSupport::Autoload
+ autoload :API
autoload :Attribute
autoload :Attributes
autoload :AttributeAssignment
diff --git a/activemodel/lib/active_model/api.rb b/activemodel/lib/active_model/api.rb
new file mode 100644
index 0000000000..b058526f54
--- /dev/null
+++ b/activemodel/lib/active_model/api.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module ActiveModel
+ # == Active \Model \API
+ #
+ # Includes the required interface for an object to interact with
+ # Action Pack and Action View, using different Active Model modules.
+ # It includes model name introspections, conversions, translations and
+ # validations. Besides that, it allows you to initialize the object with a
+ # hash of attributes, pretty much like Active Record does.
+ #
+ # A minimal implementation could be:
+ #
+ # class Person
+ # include ActiveModel::API
+ # attr_accessor :name, :age
+ # end
+ #
+ # person = Person.new(name: 'bob', age: '18')
+ # person.name # => "bob"
+ # person.age # => "18"
+ #
+ # Note that, by default, ActiveModel::API implements persisted?
+ # to return +false+, which is the most common case. You may want to override
+ # it in your class to simulate a different scenario:
+ #
+ # class Person
+ # include ActiveModel::API
+ # attr_accessor :id, :name
+ #
+ # def persisted?
+ # self.id.present?
+ # end
+ # end
+ #
+ # person = Person.new(id: 1, name: 'bob')
+ # person.persisted? # => true
+ #
+ # Also, if for some reason you need to run code on initialize, make
+ # sure you call +super+ if you want the attributes hash initialization to
+ # happen.
+ #
+ # class Person
+ # include ActiveModel::API
+ # attr_accessor :id, :name, :omg
+ #
+ # def initialize(attributes={})
+ # super
+ # @omg ||= true
+ # end
+ # end
+ #
+ # person = Person.new(id: 1, name: 'bob')
+ # person.omg # => true
+ #
+ # For more detailed information on other functionalities available, please
+ # refer to the specific modules included in ActiveModel::API
+ # (see below).
+ module API
+ extend ActiveSupport::Concern
+ include ActiveModel::AttributeAssignment
+ include ActiveModel::Validations
+ include ActiveModel::Conversion
+
+ included do
+ extend ActiveModel::Naming
+ extend ActiveModel::Translation
+ end
+
+ # Initializes a new model with the given +params+.
+ #
+ # class Person
+ # include ActiveModel::API
+ # attr_accessor :name, :age
+ # end
+ #
+ # person = Person.new(name: 'bob', age: '18')
+ # person.name # => "bob"
+ # person.age # => "18"
+ def initialize(attributes = {})
+ assign_attributes(attributes) if attributes
+
+ super()
+ end
+
+ # Indicates if the model is persisted. Default is +false+.
+ #
+ # class Person
+ # include ActiveModel::API
+ # attr_accessor :id, :name
+ # end
+ #
+ # person = Person.new(id: 1, name: 'bob')
+ # person.persisted? # => false
+ def persisted?
+ false
+ end
+ end
+end
diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb
index fc52cd4fdf..2caac82e52 100644
--- a/activemodel/lib/active_model/model.rb
+++ b/activemodel/lib/active_model/model.rb
@@ -3,11 +3,10 @@
module ActiveModel
# == Active \Model \Basic \Model
#
- # Includes the required interface for an object to interact with
- # Action Pack and Action View, using different Active Model modules.
- # It includes model name introspections, conversions, translations and
- # validations. Besides that, it allows you to initialize the object with a
- # hash of attributes, pretty much like Active Record does.
+ # Allows implementing models similar to ActiveRecord::Base.
+ # Includes ActiveModel::API for the required interface for an
+ # object to interact with Action Pack and Action View, but can be
+ # extended with other functionalities.
#
# A minimal implementation could be:
#
@@ -20,23 +19,7 @@ module ActiveModel
# person.name # => "bob"
# person.age # => "18"
#
- # Note that, by default, ActiveModel::Model implements persisted?
- # to return +false+, which is the most common case. You may want to override
- # it in your class to simulate a different scenario:
- #
- # class Person
- # include ActiveModel::Model
- # attr_accessor :id, :name
- #
- # def persisted?
- # self.id == 1
- # end
- # end
- #
- # person = Person.new(id: 1, name: 'bob')
- # person.persisted? # => true
- #
- # Also, if for some reason you need to run code on initialize, make
+ # If for some reason you need to run code on initialize, make
# sure you call +super+ if you want the attributes hash initialization to
# happen.
#
@@ -58,42 +41,6 @@ module ActiveModel
# (see below).
module Model
extend ActiveSupport::Concern
- include ActiveModel::AttributeAssignment
- include ActiveModel::Validations
- include ActiveModel::Conversion
-
- included do
- extend ActiveModel::Naming
- extend ActiveModel::Translation
- end
-
- # Initializes a new model with the given +params+.
- #
- # class Person
- # include ActiveModel::Model
- # attr_accessor :name, :age
- # end
- #
- # person = Person.new(name: 'bob', age: '18')
- # person.name # => "bob"
- # person.age # => "18"
- def initialize(attributes = {})
- assign_attributes(attributes) if attributes
-
- super()
- end
-
- # Indicates if the model is persisted. Default is +false+.
- #
- # class Person
- # include ActiveModel::Model
- # attr_accessor :id, :name
- # end
- #
- # person = Person.new(id: 1, name: 'bob')
- # person.persisted? # => false
- def persisted?
- false
- end
+ include ActiveModel::API
end
end
diff --git a/activemodel/test/cases/api_test.rb b/activemodel/test/cases/api_test.rb
new file mode 100644
index 0000000000..5c0cc7fb37
--- /dev/null
+++ b/activemodel/test/cases/api_test.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+
+class APITest < ActiveModel::TestCase
+ include ActiveModel::Lint::Tests
+
+ module DefaultValue
+ def self.included(klass)
+ klass.class_eval { attr_accessor :hello }
+ end
+
+ def initialize(*args)
+ @attr ||= "default value"
+ super
+ end
+ end
+
+ class BasicModel
+ include DefaultValue
+ include ActiveModel::API
+ attr_accessor :attr
+ end
+
+ class BasicModelWithReversedMixins
+ include ActiveModel::API
+ include DefaultValue
+ attr_accessor :attr
+ end
+
+ class SimpleModel
+ include ActiveModel::API
+ attr_accessor :attr
+ end
+
+ def setup
+ @model = BasicModel.new
+ end
+
+ def test_initialize_with_params
+ object = BasicModel.new(attr: "value")
+ assert_equal "value", object.attr
+ end
+
+ def test_initialize_with_params_and_mixins_reversed
+ object = BasicModelWithReversedMixins.new(attr: "value")
+ assert_equal "value", object.attr
+ end
+
+ def test_initialize_with_nil_or_empty_hash_params_does_not_explode
+ assert_nothing_raised do
+ BasicModel.new()
+ BasicModel.new(nil)
+ BasicModel.new({})
+ SimpleModel.new(attr: "value")
+ end
+ end
+
+ def test_persisted_is_always_false
+ object = BasicModel.new(attr: "value")
+ assert object.persisted? == false
+ end
+
+ def test_mixin_inclusion_chain
+ object = BasicModel.new
+ assert_equal "default value", object.attr
+ end
+
+ def test_mixin_initializer_when_args_exist
+ object = BasicModel.new(hello: "world")
+ assert_equal "world", object.hello
+ end
+
+ def test_mixin_initializer_when_args_dont_exist
+ assert_raises(ActiveModel::UnknownAttributeError) do
+ SimpleModel.new(hello: "world")
+ end
+ end
+end
diff --git a/guides/source/active_model_basics.md b/guides/source/active_model_basics.md
index 05d87bfd82..2ca2c52405 100644
--- a/guides/source/active_model_basics.md
+++ b/guides/source/active_model_basics.md
@@ -24,6 +24,52 @@ Active Model is a library containing various modules used in developing
classes that need some features present on Active Record.
Some of these modules are explained below.
+### API
+
+`ActiveModel::API` adds the ability for a class to work with Action Pack and
+Action View right out of the box.
+
+```ruby
+class EmailContact
+ include ActiveModel::API
+
+ attr_accessor :name, :email, :message
+ validates :name, :email, :message, presence: true
+
+ def deliver
+ if valid?
+ # deliver email
+ end
+ end
+end
+```
+
+When including `ActiveModel::API` you get some features like:
+
+- model name introspection
+- conversions
+- translations
+- validations
+
+It also gives you the ability to initialize an object with a hash of attributes,
+much like any Active Record object.
+
+```irb
+irb> email_contact = EmailContact.new(name: 'David', email: 'david@example.com', message: 'Hello World')
+irb> email_contact.name
+=> "David"
+irb> email_contact.email
+=> "david@example.com"
+irb> email_contact.valid?
+=> true
+irb> email_contact.persisted?
+=> false
+```
+
+Any class that includes `ActiveModel::API` can be used with `form_with`,
+`render` and any other Action View helper methods, just like Active Record
+objects.
+
### Attribute Methods
The `ActiveModel::AttributeMethods` module can add custom prefixes and suffixes
@@ -276,8 +322,7 @@ Person.model_name.singular_route_key # => "person"
### Model
-`ActiveModel::Model` adds the ability for a class to work with Action Pack and
-Action View right out of the box.
+`ActiveModel::Model` allows implementing models similar to `ActiveRecord::Base`.
```ruby
class EmailContact
@@ -294,31 +339,7 @@ class EmailContact
end
```
-When including `ActiveModel::Model` you get some features like:
-
-- model name introspection
-- conversions
-- translations
-- validations
-
-It also gives you the ability to initialize an object with a hash of attributes,
-much like any Active Record object.
-
-```irb
-irb> email_contact = EmailContact.new(name: 'David', email: 'david@example.com', message: 'Hello World')
-irb> email_contact.name
-=> "David"
-irb> email_contact.email
-=> "david@example.com"
-irb> email_contact.valid?
-=> true
-irb> email_contact.persisted?
-=> false
-```
-
-Any class that includes `ActiveModel::Model` can be used with `form_with`,
-`render` and any other Action View helper methods, just like Active Record
-objects.
+When including `ActiveModel::Model` you get all the features from `ActiveModel::API`.
### Serialization