Cuprum::Rails
An integration between Rails and the Cuprum library.
Cuprum::Rails defines the following objects:
- Collections: A collection for performing operations on ActiveRecord models using the standard
Cuprum::Collections
interface.- Commands: Each collection is comprised of
Cuprum
commands, which implement common collection operations such as inserting or querying data.
- Commands: Each collection is comprised of
- Controllers: Decouples controller responsibilities for precise control, reusability, and reduction of boilerplate code.
- Actions: Implement a controller's actions as a
Cuprum
command. - Middleware: Wraps a controller's actions with additional functionality.
- Requests: Encapsulates a controller request.
- Resources and Routes: Configuration for a resourceful controller.
- Responders and Responses: Generate controller responses from action results.
- Serializers: Recursively convert entities and data structures into serialized data.
- Actions: Implement a controller's actions as a
About
Cuprum::Rails provides a toolkit for using the Cuprum command pattern and the flexibility of Cuprum::Collections to build Rails applications. Using the Cuprum::Rails::Records::Collection
, you can perform operations on ActiveRecord models, leveraging a standard interface to control where your data is stored and how it is queried. For example, you can inject a mock collection into unit tests for precise control over queried values and blinding fast tests without having to hit the database directly.
Using Cuprum::Rails::Controller
takes this one step further, breaking apart the traditional controller into a sequence of steps with individual responsibilities. This has two main benefits. First, being explicit about how your controllers perform and respond to actions allows for precise control at each step of the process. Second, each step is encapsulated, which allows for easier testing and reuse. This not only makes testing simpler - you can test your business logic by examining an Action result, rather than parsing a rendered HTML page - but allows you to reuse individual components. The goal is to reduce the boilerplate inherent in writing a Rails application by allowing you to define only the code that is unique to the controller, action, or process.
Why Cuprum::Rails?
Rails is a highly opinionated framework: one of the pillars of The Rails Doctrine is the principle that "The menu is omakase". This is one of the keys to the framework's success, providing a welcoming environment for new developers as well as powerful tools for developing applications - as long as those applications are built The Rails Way.
This is great for rapidly developing prototypes, proof of concept or proof of market applications, or even smaller applications for content management, e-commerce, and so on. There are good reasons why Rails has made so much headway against established behemoths such as WordPress. That being said, many companies are using Rails to build applications that are much more ambitious, and at that scale the standard Rails patterns start to fall apart. Omakase is no longer just right.
Cuprum::Rails is intended to address two of the pain points of Big Rails. The first is architectural: any Rails developer of a certain age will remember the wars over Fat Controllers versus Fat Models. The rise of Service Objects provides a way forward, but in practice this can be something of a Wild West - everything gets dumped in an app/services
directory, each file looks and works differently. The Cuprum gem is designed to provide a solution to this chaos. Defining a command gives you the benefits of encapsulation, control flow, and consistency - every command defines one #call
method and returns a result.
The second benefit is reusability. Breaking down a controller into its constituent steps means you don't have to reimplement each of those steps each time you create a controller or add an action. You can define what it means to respond to an HTML or JSON request once, and modify it on a per-action basis when you need custom behavior. You can subclass the resourceful action commands to leverage basic controller functionality, such as performing filtered queries. And, of course, you gain all the benefits of decoupling commands from your controller - you can use the same functionality in a controller action, as an asynchronous job, or as a command-line function.
Compatibility
Cuprum::Rails is tested against Ruby (MRI) 3.1 through 3.3, and Rails 7.0 through 7.2.
Documentation
Documentation is generated using YARD, and can be generated locally using the yard
gem.
License
Copyright (c) 2021-2024 Rob Smith
Stannum is released under the MIT License.
Contribute
The canonical repository for this gem is located at https://github.com/sleepingkingstudios/cuprum-rails.
To report a bug or submit a feature request, please use the Issue Tracker.
To contribute code, please fork the repository, make the desired updates, and then provide a Pull Request. Pull requests must include appropriate tests for consideration, and all code must be properly formatted.
Code of Conduct
Please note that the Cuprum::Collections
project is released with a Contributor Code of Conduct. By contributing to this project, you agree to abide by its terms.
Reference
Collections
require 'cuprum/rails/records/collection'
A Cuprum::Rails::Records::Collection
implements the Cuprum::Collections interface for ActiveRecord
models. It defines a set of commands that implement persistence and query operations, and a #query
method to directly perform queries on the data.
collection = Cuprum::Rails::Records::Collection.new(record_class: Book)
# Add an item to the collection.
steps do
# Build the book from attributes.
book = step do
collection.build_one.call(
attributes: { id: 10, title: 'Gideon the Ninth', author: 'Tamsyn Muir' }
)
end
# Validate the book using its default validations.
step { collection.validate_one.call(entity: book) }
# Insert the validated book to the collection.
step { collection.insert_one.call(entity: book) }
end
# Find an item by primary key.
book = step { collection.find_one.call(primary_key: 10) }
# Find items matching a filter.
books = step do
collection.find_matching.call(
limit: 10,
order: [:author, { title: :descending }],
where: lambda do
published_at: greater_than('1950-01-01')
end
)
end
Initializing a collection requires the :record_class
keyword, which should be a Class that inherits from ActiveRecord::Base
. You can also specify some optional keywords:
- The
:collection_name
parameter sets the name of the collection. It is used to create an envelope for query commands, such as theFindMany
,FindMatching
andFindOne
commands. - The
:default_contract
parameter sets a default contract for validating collection entities. If no:contract
keyword is passed to theValidateOne
command, it will use the default contract to validate the entity instead of the validation constraints defined for the model. - The
:member_name
parameter is used to create an envelope for singular query commands such as theFindOne
command. If not given, the member name will be generated automatically as a singular form of the collection name. - The
:primary_key_name
parameter specifies the attribute that serves as the primary key for the collection entities. The default value is:id
. - The
:primary_key_type
parameter specifies the type of the primary key attribute. The default value isInteger
. - The
:qualified_name
parameter acts as a unique identifier for the collection. It is used as the unique key in repositories.
Commands
Structurally, a collection is a set of commands, which are instances of Cuprum::Command
that implement a persistence or querying operation and wrap that operation with parameter validation and error handling. For more information on Cuprum commands, see the Cuprum gem.
Assign One
The AssignOne
command takes an attributes hash and a record, assigns the given attributes to the record, and returns the record.
book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
attributes = { 'title' => 'Harrow the Ninth', 'published_at' => '2020-08-04' }
result = collection.assign_one.call(attributes: attributes, entity: entity)
result.value.class
#=> Book
result.value.attributes
#=> {
# 'id' => 10,
# 'title' => 'Harrow the Ninth',
# 'author' => 'Tamsyn Muir',
# 'series' => nil,
# 'category' => nil,
# 'published_at' => '2020-08-04'
# }
If the attributes hash includes one or more attributes that are not defined for that record class, the #assign_one
command can return a failing result with an ExtraAttributes
error.
Build One
The BuildOne
command takes an attributes hash and returns a new record whose attributes are equal to the given attributes. This does not validate or persist the record; it is equivalent to calling record_class.new
with the attributes.
attributes = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir' }
result = collection.build_one.call(attributes: attributes, entity: entity)
result.value.class
#=> Book
result.value.attributes
#=> {
# 'id' => 10,
# 'title' => 'Gideon the Ninth',
# 'author' => 'Tamsyn Muir',
# 'series' => nil,
# 'category' => nil,
# 'published_at' => nil
# }
If the attributes hash includes one or more attributes that are not defined for that record class, the #build_one
command can return a failing result with an ExtraAttributes
error.
Destroy One
The DestroyOne
command takes a primary key value and removes the record with the specified primary key from the collection.
result = collection.destroy_one.call(primary_key: 0)
collection.query.where(id: 0).exists?
#=> false
If the collection does not include a record with the specified primary key, the #destroy_one
command will return a failing result with a NotFound
error.
Find Many
The FindMany
command takes an array of primary key values and returns the records with the specified primary keys. The entities are returned in the order of the specified primary keys.
result = collection.find_many.call(primary_keys: [0, 1, 2])
result.value
#=> [
# #<Book
# id: 0,
# title: 'The Hobbit',
# author: 'J.R.R. Tolkien',
# series: nil,
# category: 'Science Fiction and Fantasy',
# published_at: '1937-09-21'
# >,
# #<Book
# id: 1,
# title: 'The Silmarillion',
# author: 'J.R.R. Tolkien',
# series: nil,
# category: 'Science Fiction and Fantasy',
# published_at: '1977-09-15'
# >,
# #<Book
# id: 2,
# title: 'The Fellowship of the Ring',
# author: 'J.R.R. Tolkien',
# series: 'The Lord of the Rings',
# category: 'Science Fiction and Fantasy',
# published_at: '1954-07-29'
# >
# ]
The FindMany
command has several options:
-
The
:allow_partial
keyword allows the command to return a passing result if at least one of the entities is found. By default, the command will return a failing result unless an entity is found for each primary key value. -
The
:envelope
keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.result = collection.find_many.call(primary_keys: [0, 1, 2], envelope: true) result.value #=> { books: [#<Book>, #<Book>, #<Book>] }
-
The
:scope
keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by#find_many
.
If the collection does not include an entity with each of the specified primary keys, the #find_many
command will return a failing result with a NotFound
error.
Find Matching
The FindMatching
command takes a set of query parameters and queries data from the collection. You can specify filters using the :where
keyword or by passing a block, sort the results using the :order
keyword, or return a subset of the results using the :limit
and :offset
keywords. For full details on performing queries, see Queries, below.
result =
collection
.find_matching
.call(order: :published_at, where: { series: 'Earthsea' })
result.value
#=> [
# #<Book
# id: 7,
# title: 'A Wizard of Earthsea',
# author: 'Ursula K. LeGuin',
# series: 'Earthsea',
# category: 'Science Fiction and Fantasy',
# published_at: '1968-11-01'
# >,
# #<Book
# id: 8,
# title: 'The Tombs of Atuan',
# author: 'Ursula K. LeGuin',
# series: 'Earthsea',
# category: 'Science Fiction and Fantasy',
# published_at: '1970-12-01'
# >,
# #<Book
# id: 9,
# title: 'The Farthest Shore',
# author: 'Ursula K. LeGuin',
# series: 'Earthsea',
# category: 'Science Fiction and Fantasy',
# published_at: '1972-09-01'
# >
# ]
The FindMatching
command has several options:
-
The
:envelope
keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.result = collection.find_matching.call(where: { series: 'Earthsea' }, envelope: true) result.value #=> { books: [#<Book>, #<Book>, #<Book>] }
-
The
:limit
keyword caps the number of results returned. -
The
:offset
keyword skips the specified number of results. -
The
:order
keyword specifies the order of results. -
The
:scope
keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by#find_matching
. -
The
:where
keyword defines filters for which results are to be returned.
Find One
The FindOne
command takes a primary key value and returns the record with the specified primary key.
result = collection.find_one.call(primary_key: 1)
result.value
#=> #<Book
# id: 1,
# title: 'The Silmarillion',
# author: 'J.R.R. Tolkien',
# series: nil,
# category: 'Science Fiction and Fantasy',
# published_at: '1977-09-15'
# >
The FindOne
command has several options:
-
The
:envelope
keyword wraps the result value in an envelope hash, with a key equal to the singular name of the collection and whose value is the returned record.result = collection.find_one.call(primary_key: 1, envelope: true) result.value #=> { book: #<Book> }
-
The
:scope
keyword allows you to pass a query to the command. Only an entity that match the given scope will be found and returned by#find_one
.
If the collection does not include a record with the specified primary key, the #find_one
command will return a failing result with a NotFound
error.
Insert One
The InsertOne
command takes a record and inserts that record into the collection.
book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
result = collection.insert_one.call(entity: book)
result.value
#=> #<Book
# id: 10,
# title: 'Gideon the Ninth',
# author: 'Tamsyn Muir',
# series: nil,
# category: nil,
# published_at: nil
# >
collection.query.where(id: 10).exists?
#=> true
If the collection already includes a record with the specified primary key, the #insert_one
command will return a failing result with an AlreadyExists
error.
Update One
The UpdateOne
command takes a record and updates the corresponding record in the collection.
book = collection.find_one.call(1).value
book = book.assign_attributes('author' => 'John Ronald Reuel Tolkien')
result = collection.update_one(entity: book)
result.value
#=> #<Book
# id: 1,
# title: 'The Silmarillion',
# author: 'J.R.R. Tolkien',
# series: nil,
# category: 'Science Fiction and Fantasy',
# published_at: '1977-09-15'
# >
collection
.query
.where(title: 'The Silmarillion', author: 'John Ronald Reuel Tolkien')
.exists?
#=> true
If the collection does not include a record with the specified records's primary key, the #update_one
command will return a failing result with a NotFound
error.
Validate One
The ValidateOne
command takes an entity and an optional Stannum
contract. If the :contract
keyword is given, the record is matched against the contract; otherwise, the record is matched using the native validations defined for the record class.
book = Book.new('id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tamsyn Muir')
result = collection.validate_one.call(entity: book)
result.success?
#=> true
If the contract does not match the entity, the #validate_one
command will return a failing result with a ValidationFailed
error.
Repositories
require 'cuprum/rails/records/repository'
A Cuprum::Rails::Records::Repository
is a group of Rails collections. A single repository might represent all or a subset of the tables in your database.
repository = Cuprum::Rails::Records::Repository.new
repository.key?('books')
#=> false
collection = repository.find_or_create(entity_class: Book)
#=> a Cuprum::Rails::Records::Collection
collection.collection_name
#=> 'books'
collection.qualified_name
#=> 'books'
repository['books']
#=> the books collection
If the model has a namespace, e.g. Authentication::User
, the #collection_name
will be based on the last name segment, while the #qualified_name
will be based on the entire name.
repository = Cuprum::Rails::Records::Repository.new
repository.key?('authentication/users')
#=> false
collection = repository.find_or_create(entity_class: Authentication::User)
#=> a Cuprum::Rails::Records::Collection
collection.collection_name
#=> 'users'
collection.qualified_name
#=> 'authentication/users'
repository['authentication/users']
#=> the users collection
You can also pass the #collection_name
and #qualified_name
as parameters.
Controllers
require 'cuprum/rails/controller'
Important Note
Cuprum::Rails
is a pre-release gem, and there may be breaking changes between minor versions and until the API is finalized by version 1.0.0. TheController
API is particularly likely to experience changes as additional use cases are discovered and supported.
The Rails approach to controllers is to embrace Convention over Configuration. Cuprum::Rails::Controller
inverts this pattern, using configuration to precisely define behavior.
class BooksController
include Cuprum::Rails::Controller
def self.resource
@resource ||= Cuprum::Rails::Resource.new(
collection: Cuprum::Rails::Records::Collection.new(record_class: Book),
permitted_attributes: %i[title author series category published_at],
resource_class: Book
)
end
def self.serializers
serializers = super()
json = serializers.fetch(:json, {})
record_serializer =
Cuprum::Rails::Serializers::Json::ActiveRecordSerializer.instance
serializers.merge(
json: json.merge(ActiveRecord::Base => record_serializer)
)
end
responder :html, Cuprum::Rails::Responders::Html::Resource
responder :json, Cuprum::Rails::Responders::Json::Resource
action :create, Cuprum::Rails::Actions::Create
action :destroy, Cuprum::Rails::Actions::Destroy, member: true
action :edit, Cuprum::Rails::Actions::Edit, member: true
action :new, Cuprum::Rails::Actions::New
action :index, Cuprum::Rails::Actions::Index
action :show, Cuprum::Rails::Actions::Show, member: true
action :update, Cuprum::Rails::Actions::Update, member: true
end
Here, we are defining a typical Rails resourceful controller, which implements the CRUD actions for Book
s and responds to HTML and JSON requests. As you can see, Cuprum::Rails::Controller
is a mix of Actions and configuration (the Resource, Responders, and Serializers). In a full application, some of that configuration (the responders and serializers) could be handled in an abstract base controller, such as an APIController
that defined a JSON responder and serializers. Note also that the implementation of the actions happens elsewhere - the controller references existing commands to define the actions.
Configuring Controllers
Each controller has three main points of configuration: a Resource
, a set of Responders
, and a set of Serializers
.
The Resource provides some metadata about the controller, such as a #resource_name
, a set of #routes
, and whether the controller represents a singular or a plural resource. Generally speaking, each controller should have a unique resource, which is defined by overriding the .resource
class method.
The Responders determine what request formats are accepted by the controller and how the corresponding responses are generated. Responders can and should be shared between controllers, and are defined using the .responder
class method. .responder
takes two parameters: a format
, which should be either a string or a symbol (e.g. :json
) and a responder_class
, which will be used to generate responses for the specified format.
The Serializers are used in API responses (such as a JSON response) to convert application data into a serialized format. Cuprum::Rails
defines a base set of serializers for simple data; applications can either set a generic serializer for records (as in BooksController
, above) or set specific serializers for each record class on a per-controller basis. Serializers are defined by overriding the .serializers
class method - make sure to call super()
and merge the results, unless you specifically want to override the default values.
You can also define .default_format
, which sets a default value for when the request does not specify a format. For example, a request to /api/books.html
specifies the :html
format, while there is no format specified for /api/books
.
class BooksController
default_format :html
end
Defining Actions
A non-abstract controller should define at least one Action, corresponding to a page, process, or API endpoint for the application. Actions are defined using the .action
class method, which takes two parameters: an action_name
, which should be either a string or a symbol (e.g. :publish
), and an action_class
, which is a subclass of Cuprum::Rails::Action
.
class BooksController
action :published, Actions::Books::Published
end
In addition, .action
accepts the following keywords:
-
:member
: Iftrue
, the action is a member action and acts on a member of the collection, rather than the collection as a whole. In a classic controller, the:edit
,:destroy
,:show
, and:update
actions are member actions.
class BooksController
action :publish, Actions::Books::Publish, member: true
end
Defining Middleware
You can use middleware to insert functionality before, after, or around controller actions. Think of it as a supercharged alternative to the traditional Rails before_action
and after_action
hooks, but without the magic behavior. Use cases for middleware include:
- Authentication
- Logging
- Profiling
Middleware commands have a specific interface. See Middleware, below, for how to define your own middleware commands.
class BooksController
middleware LoggingMiddleware
middleware AuthenticationMiddleware, except: %i[index show]
middleware ProfilingMiddleware, only: %i[create update]
end
Adding middleware to a controller is straightforward. In our example above, the LoggingMiddleware
will run for all actions, the AuthenticationMiddleware
will run for all actions except for :index
and :show
, and the ProfilingMiddleware
will run for the :create
and :update
actions.
Each middleware command can have functionality that runs before, after, or around the action (and subsequent middleware). Code that runs before the action has access to the request:
, and can modify the request passed to the next command or even skip the action and return its own result. Code that runs after the action has access to the request:
and the action result
, and can modify or replace the result.
The middleware is executed in the order it is defined. For the BooksController#create
action, the code would run as follows:
-
LoggingMiddleware
: Any code that executes before the action. -
AuthenticationMiddleware
: Any code that executes before the action. -
ProfilingMiddleware
: Any code that executes before the action. Books::CreateAction
-
ProfilingMiddleware
: Any code that executes after the action. -
AuthenticationMiddleware
: Any code that executes after the action. -
LoggingMiddleware
: Any code that executes after the action.
Code that runs before or around the action can skip the action and return its own result. For example, the AuthenticationMiddleware
will check for a valid session. If there is not a valid session, it will return a failing result rather than calling the action. In this case, the code would run as follows:
-
LoggingMiddleware
: Any code that executes before the action. -
AuthenticationMiddleware
: The session is not found, so the action is not called. -
AuthenticationMiddleware
: Any code that executes after the action. -
LoggingMiddleware
: Any code that executes after the action.
The Action Lifecycle
Inside a controller action, Cuprum::Rails
splits up the responsibilities of responding to a request.
- The Action
- The
action_class
is initialized, passing the controllerresource
to the constructor and returning theaction
. - The
action
is wrapped with anymiddleware
that is defined by the controller for that action. - The controller
#request
is wrapped in aCuprum::Rails::Request
, which is passed to theaction
's#call
method, returning theresult
.
- The
- The Responder
- The
responder_class
is found for the request based on the request'sformat
and the configuredresponders
. - The
responder_class
is initialized with theaction_name
,controller_name
,resource
, andserializers
, returning theresponder
. - The
responder
is called with the actionresult
, and finds a matchingresponse
based on the action name, the result's success or failure, and the result error (if any).
- The
- The Response
- The
response
is then called with the controller, which allows it to reference native Rails controller methods for rendering or redirecting.
- The
Let's walk through this step by step. We start by making a POST
request to /books
, which corresponds to the BooksController#create
endpoint with parameters { book: { title: 'Gideon the Ninth' } }
.
- The Action
- We initialize our configured action class, which is
Cuprum::Rails::Actions::Index
. - We wrap the request in a
Cuprum::Rails::Request
, and call ouraction
with the wrappedrequest
. The action performs the business logic (building, validating, and persisting a newBook
) and returns an instance ofCuprum::Result
. In our case, the book's attributes are valid, so the result has a:status
of:success
and a value of{ 'book' => #<Book id: 0, title: 'Gideon the Ninth'> }
.
- We initialize our configured action class, which is
- The Responder
- We're making an HTML request, so our controller will use the responder configured for the
:html
format. In our case, this isCuprum::Rails::Responders::Html::Resource
, which defines default behavior for responding to resourceful requests. - Our
Responders::Html::Resource
is initialized, giving us aresponder
. - The
responder
is called with ourresult
. There is a match for a successful:create
action, which returns an instance ofCuprum::Rails::Responses::Html::RedirectResponse
with apath
of/books/0
.
- We're making an HTML request, so our controller will use the responder configured for the
- The Response
- Finally, our
response
object is called. TheRedirectResponse
directs the controller to redirect to/books/0
, which is the:show
page for our newly createdBook
.
- Finally, our
Controller Actions
require 'cuprum/rails/action'
Cuprum::Rails
extracts the business logic of controllers into dedicated Cuprum::Rails::Action
s. Each action is a Cuprum::Command
that is initialized with a Resource, called with a Request, and returns a Cuprum::Result
that is then passed to the responder.
class PublishedBooks < Cuprum::Rails::Action
private
def process(request)
super
resource.collection.find_matching.call(order: params[:order]) do
{
'published_at' => not_equal(nil)
}
end
end
end
Each action has access to the resource
via the constructor, the request
, and the request's params
. Above, we are defining a simple action for returning books that have a non-nil
publication date. Like any Cuprum::Command
, the heart of the class is the #process
method, which for an action takes the request
as its sole parameter. Inside the method, we call super
to setup the action. We then access the configured resource
, which grants us access to the collection
of books. Finally, we call the collection's find_matching
command, with an optional ordering coming from the params.
The Cuprum::Rails::Actions::ResourceAction
provides some helper methods for defining resourceful actions.
class PublishBook < Cuprum::Rails::Actions::ResourceAction
private
def process(request)
super
step { require_resource_id }
book = step { collection.find_one.call(primary_key: resource_id) }
book.published_at = DateTime.current
step { collection.validate_one.call(entity: book) }
step { collection.update_one.call(entity: book) }
end
end
ResourceAction
delegates #collection
, #resource_name
, and #singular_resource_name
to the #resource
. In addition, it defines the following helper methods. Each method returns a Cuprum::Result
, so you can use the #step
control flow to handle command errors.
-
#resource_id
: Returnsparams[:id]
. -
#resource_params
: Filtersparams[singular_resource_name]
and usingresource.permitted_attributes
.
Transactions
Cuprum::Rails
integrates with ActiveRecord
to support database transactions. The #transaction
method integrates native transactions with the Cuprum
control flow:
class ReturnBook < Cuprum::Rails::Actions::ResourceAction
private
def books_collection
@books_collection ||= repository['books']
end
def process(request)
super
step { require_resource_id }
loan = step { collection.find_one.call(primary_key: resource_id) }
transaction do
step { return_book(loan.book_id) }
step { collection.destroy_one.call(entity: loan) }
end
end
def return_book(book_id)
step do
books_collection.assign_one.call(
attributes: { 'borrowed' => false },
entity: book
)
end
books_collection.update_one.call(entity: book)
end
end
Here, we are defining a custom action for returning a borrowed library book. Inside our transaction, we are defining two steps. First, we are marking the book as no longer borrowed, so other patrons will be able to check it out or request it. Second, we destroy the join model between the user and the book. If either of these steps returns a failing result, the transaction will automatically roll back.
If you do not want to roll back on a failed step, use the native ActiveRecord.transaction
method instead.
Actions
Cuprum::Rails
also provides some pre-defined actions to implement classic resourceful controllers. Each resource action calls one or more commands from the resource collection to query or persist the record or records.
Create
The Create
action passes the resource params to collection.build_one
, validates the record using collection.validate_one
, and finally inserts the new record into the collection using the collection.insert_one
command. The action returns a Hash containing the created record.
action = Cuprum::Rails::Actions::Create.new(resource)
attributes = { 'book' => { 'title' => 'Gideon the Ninth' } }
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book title: 'Gideon the Ninth'> }
Book.where(title: 'Gideon the Ninth').exist?
#=> true
If the params do not include attributes for the resource, the action returns a failing result with a Cuprum::Rails::Errors::InvalidParameters
error.
If the created record is not valid, the action returns a failing result with a Cuprum::Collections::Errors::FailedValidation
error.
Destroy
The Destroy
action removes the record from the collection via collection.destroy_one
. The action returns a Hash containing the deleted record.
action = Cuprum::Rails::Actions::Destroy.new(resource)
attributes = { 'id' => 0 }
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book id: 0> }
Book.where(id: 0).exist?
#=> false
If the params do not include a primary key for the resource, the action returns a failing result with a Cuprum::Rails::Errors::InvalidParameters
error.
If the record with the given primary key does not exist, the action returns a failing result with a Cuprum::Collections::Errors::NotFound
error.
Edit
The Edit
action finds the record with the given primary key via collection.find_one
and returns a Hash containing the found record.
action = Cuprum::Rails::Actions::Edit.new(resource)
attributes = { 'id' => 0 }
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book id: 0> }
If the params do not include a primary key for the resource, the action returns a failing result with a Cuprum::Rails::Errors::InvalidParameters
error.
If the record with the given primary key does not exist, the action returns a failing result with a Cuprum::Collections::Errors::NotFound
error.
Index
The Index
action performs a query on the records using collection.find_matching
, and returns a Hash containing the found records. You can pass :limit
, :offset
, :order
, and :where
parameters to filter the results.
action = Cuprum::Rails::Actions::Index.new(resource)
attributes = {
'limit' => 3,
'order' => { 'title' => :asc },
'where' => { 'author' => 'Ursula K. LeGuin' }
}
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'books' => [#<Book>, #<Book>, #<Book>] }
New
The New
action builds a new record with empty attributes using collection.build_one
, and returns a Hash containing the new record.
action = Cuprum::Rails::Actions::New.new(resource)
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book> }
Show
The Show
action finds the record with the given primary key via collection.find_one
and returns a Hash containing the found record.
action = Cuprum::Rails::Actions::Show.new(resource)
attributes = { 'id' => 0 }
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book id: 0> }
If the params do not include a primary key for the resource, the action returns a failing result with a Cuprum::Rails::Errors::InvalidParameters
error.
If the record with the given primary key does not exist, the action returns a failing result with a Cuprum::Collections::Errors::NotFound
error.
Update
The Update
action finds the record with the given primary key via collection.find_one
, assigns the given attributes using collection.assign_one
, validates the record using collection.validate_one
, and finally updates the record in the collection using the collection.update_one
command. The action returns a Hash containing the created record.
action = Cuprum::Rails::Actions::Update.new(resource)
attributes = { 'id' => 0, 'book' => { 'title' => 'Gideon the Ninth' } }
result = action.call(request)
result.success?
#=> true
result.value
#=> { 'book' => #<Book id: 0, title: 'Gideon the Ninth'> }
Book.find(0).title
#=> 'Gideon the Ninth'
If the params do not include a primary key and attributes for the resource, the action returns a failing result with a Cuprum::Rails::Errors::InvalidParameters
error.
If the record with the given primary key does not exist, the action returns a failing result with a Cuprum::Collections::Errors::NotFound
error.
If the updated record is not valid, the action returns a failing result with a Cuprum::Collections::Errors::FailedValidation
error.
Middleware
A middleware command takes two parameters. First, a next_command
argument, which is the next item in the middleware chain (or the controller action if the middleware is the last one in the chain). Second, a request:
keyword - this is the request passed down from the controller.
See Defining Middleware, above, for using middleware in a Cuprum::Rails::Controller
, or see Cuprum for more information on middleware.
Creating Middleware
Each middleware class should be a subclass of Cuprum::Command
and include Cuprum::Middleware
. The constructor can optionally take either :repository
and :resource
keywords; if these are defined, they are passed the relevant controller property when the middleware is initialized.
class ExampleMiddleware < Cuprum::Command
include Cuprum::Middleware
def initialize(repository:, resource:)
super()
@repository = repository
@resource = resource
end
end
Before An Action
Middleware commands can run before an action, similar to a native Rails before_action
filter.
class AuthenticationMiddleware < Cuprum::Command
include Cuprum::Middleware
private def process(next_command, request:)
step { Authentication::RequireUser.call(request: request) }
super
end
end
Here, we are creating a basic middleware command. We call our authentication command in a step
, meaning that if the authentication command returns a failing result, we will immediately return that result. This means that our action will not run if the session is invalid.
If the authentication command returns a passing result, we call super
to invoke the default behavior of Cuprum::Middleware
. This calls next_command.call(request: request)
to continue the middleware or invoke the action.
After An Action
Likewise, middleware commands can run after an action, similar to a native Rails after_action
filter.
class LoggingMiddleware < Cuprum::Command
include Cuprum::Middleware
private def process(next_command, request:)
result = next_command.call(request: request)
if result.success?
Rails.logger.info(
"Successful Request: controller: #{request.controller_name}, action:" \
" #{request.action_name}"
)
else
Rails.logger.error(
"Failed Request: controller: #{request.controller_name}, action:" \
" #{request.action_name}, error: #{result.error.as_json}"
)
end
result
end
end
This middleware is a little more complicated. Instead of intercepting the request before the action, here we are taking the result of the action and implementing some custom behavior based on the success or failure of the action. Finally, make sure to return the result.
Note that we are explicitly calling next_command.call(request: request)
rather than relying on super
. This is because super
calls the next command inside a step
, and will immediately return a failing result rather than continuing through #process
. For our logging middleware, however, we actually want to handle both passing and failing results.
Around An Action
Finally, we can run middleware around an action, similiar to a native Rails around_action
filter.
class ProfilingMiddleware < Cuprum::Command
include Cuprum::Middleware
private
def process(next_command, request:)
start_time = Time.current
value = super(next_command, request: request)
return if value.nil?
end_time = Time.current
value.merge('time_elapsed' => time_elapsed(start_time, end_time))
end
def time_elapsed(start_time, end_time)
difference = ((end_time - start_time).round(3) * 1_000).to_i
"#{difference} milliseconds"
end
end
We start by capturing the current time, before the action is run. We then call the action via super
; this means that the middleware will return immediately on a failed result. Once the action has run, we calculate how long the action took to run and merge that into the result value. In a production environment, we would probably pass that data to a monitoring service.
Requests
require 'cuprum/rails/request'
A Cuprum::Rails::Request
is a value object that encapsulates the details of a controller request, such as the request format
, the headers
, and the parameters
. Generally speaking, users should not instantiate requests directly; they are used as part of the Controller action lifecycle.
Each request defines the following properties:
-
#authorization
: The value of the"AUTHORIZATION"
header, if any, as aString
. -
#body_parameters
: (also#body_params
) The parameters derived from the request body, such as a JSON payload or form data. AHash
withString
keys. -
#format
: The format of the request as aSymbol
, e.g.:html
or:json
. -
#headers
: The request headers, as aHash
withString
keys. -
#method
: The HTTP method used for the request as aSymbol
, e.g.:get
or:post
. -
#parameters
: (also#params
) The complete parameters for the request, including both params from the request body and from the query string. AHash
withString
keys. -
#path
: The relative path of the request, including query params. -
#path_parameters
: (also#path_params
) The path parameters for the request, minus the Rails-providedaction
andcontroller
params. AHash
withString
keys. -
#query_parameters
: (also#query_params
) The query parameters for the request. AHash
withString
keys.
The request properties can also be accessed via the #[]
method (using either String or Symbol keys), or updated via the #[]=
method. The #properties
method returns all of the request properties as a Hash
.
Resources
require 'cuprum/rails/resource'
A Cuprum::Rails::Resource
defines the configuration for a resourceful controller.
resource = Cuprum::Rails::Resource.new(
collection: Cuprum::Rails::Records::Collection.new(record_class: Book),
resource_class: Book
)
resource.resource_name
#=> 'books'
A resource must be initialized with either a resource_class
or a resource_name
. It defines the following properties:
-
#base_url
: The base url for the collection, used when generating routes. -
#collection
: ACuprum::Collections
collection, used to perform queries and persistence operations on the resource data. If not given and the collection has a#resource_class
, then aCuprum::Rails::Records::Collection
is automatically generated. -
#resource_class
: TheClass
of items in the resource. -
#resource_name
: The name of the resource as aString
. If the resource is initialized with aresource_class
, theresource_name
is derived from the given class. -
#routes
: A Cuprum::Rails::Routes object for the resource. If not given, a default routes object is generated for the resource. -
#singular
: If true, the resource is a singular resource (e.g./user
, as opposed to the plural/books
resource). Also defines the#singular?
and#plural
predicates.
Routes
Each resource has a Cuprum::Rails::Routes
object that represents the routes implemented for the controller. The routes are typically used in responders when generating the controller response (see Responders, below).
routes = Cuprum::Rails::Routes.new(base_path: '/books') do
route :published, 'published'
route :publish, ':id/publish'
end
routes.published_path
#=> '/books/published'
Some routes include wildcards, such as the :publish
route above which requires an :id
wildcard; nested resources will require a wildcard value (the parent resource id) for all resourceful routes. Wildcards are assigned using the #with_wildcards
method, which creates a copy of the routes object with the assigned wildcards.
routes.publish_path
#=> raises a Cuprum::Rails::Routes::MissingWildcardError exception
routes.with_wildcards(id: 0).publish_path
#=> /books/0/publish
Cuprum::Rails
defines templates for defining resourceful routes for both singular and plural resources. These define the standard CRUD operations for a resource.
routes = Cuprum::Rails::Routing::PluralRoutes.new(base_path: '/books')
routes = routes.with_wildcards(id: 0)
routes.create_path
#=> '/books'
routes.destroy_path
#=> '/books'
routes.edit_path
#=> '/books/0/edit'
routes.index_path
#=> '/books'
routes.new_path
#=> '/books/new'
routes.show_path
#=> '/books/0'
routes.update_path
#=> '/books/0'
routes = Cuprum::Rails::Routing::SingularRoutes.new(base_path: '/book')
routes.create_path
#=> '/book'
routes.destroy_path
#=> '/book'
routes.edit_path
#=> '/book/edit'
routes.new_path
#=> '/book/new'
routes.show_path
#=> '/book'
routes.update_path
#=> '/book'
Responders
In a Cuprum::Rails
controller, the responder is responsible for turning the action result into a response (see The Action Lifecycle, above). Each request format should have a dedicated responder, e.g. an HtmlResponder
is used to respond to HTML requests.
class CustomResponder < Cuprum::Rails::Responders::HtmlResponder
action :publish do
match :success do
redirect_to(resource.routes.show_path)
end
match :failure do
render 'show'
end
end
match :failure, error: Authorization::NotAuthorizedError do
redirect_to(login_path)
end
private
def login_path
'/login'
end
end
First, we are using the .action
class method to define responses for the :publish
action. If the result is successful, it redirects to the :show
page. If the result is failing, it instead renders the :show
page and assigns the error (if any) to @error
. Next, we are using the .match
class method to define a response for a failing result with an Authorization::NotAuthorizedError
.
A result will be matched to a response in order of specificity:
- An
.action
clause with a matchingerror:
(if any). - A generic
.match
clause with a matchingerror:
. - An
.action
clause with a matching status, either:success
or:failure
. - A generic
.match
clause with a matching status.
In our case, consider a :publish
request that fails with an Authorization::NotAuthorizedError
. The responder will first check for a clause matching both the action and the error. It will then check for a generic action response with the error, which the .match
clause we defined. If the request failed with a different error, the responder would not find a match for the error, and would fall back to the generic :failure
clause for the action. Finally, if there was no .action
clause for the action, or the clause did not specify a :failure
clause, it would perform the generic :failure
clause for any action.
Cuprum::Rails
also defines the following built-in responders:
Cuprum::Rails::Responders::HtmlResponder
Provides default responses for HTML requests.
- For a successful result, renders the template for the action and assigns the result value as local variables.
- For a failing result, redirects to the resource
:index
page (for a collection action) or the resource:show
page (for a member action).
Cuprum::Rails::Responders::Html::Resource
Provides some additional response handling for resources.
If the resource is plural:
- For a failed
#create
result, renders the:new
template. - For a successful
#create
result, redirects to the:show
page. - For a successful
#destroy
result, redirects to the:show
page. - For a failed
#index
result, redirects to the root page. - For a failed
#update
result, renders the:edit
template. - For a successful
#update
result, redirects to the:show
page.
If the resource is singular:
- For a failed
#create
result, renders the:new
template. - For a successful
#create
result, redirects to the:show
page. - For a successful
#destroy
result, redirects to the parent resource. - For a failed
#update
result, renders the:edit
template. - For a successful
#update
result, redirects to the:show
page.
Cuprum::Rails::Responders::JsonResponder
Provides default responses for JSON requests.
- For a successful result, serializes the result value and generates a JSON object of the form
{ ok: true, data: serialized_value }
. - For a failing result, creates and serializes a generic error and generates a JSON object of the form
{ ok: false, error: serialized_error }
and a status of500 Internal Server Error
. If the Rails environment is:development
, it will instead serialize the error from the result.
Cuprum::Rails::Responders::Json::Resource
- For a successful
#create
result, serializes the result value with a status of201 Created
. - For a failed result with an
AlreadyExists
error, serializes the error with a status of422 Unprocessable Entity
. - For a failed result with a
FailedValidation
error, serializes the error with a status of422 Unprocessable Entity
. - For a failed result with an
InvalidParameters
error, serializes the error with a status of400 Bad Request
. - For a failed result with a
NotFound
error, serializes the error with a status of404 Not Found
.
Responses
Response objects implement the final step of the Action Lifecycle, and are returned when a Responder is #call
ed. Each response class implements a specific type of response, such as an HTML redirect or a serialized JSON response, and encapsulates the data necessary to perform that response.
Internally, each response delegates to the renderer
, which must be passed to the #call
method. This delegation allows the response to abstract out the details of generating a response to the renderer. During the action lifecycle, the renderer will be the controller instance.
data = {
'ok' => 'true',
'data' => { 'book' => { 'title' => 'Gideon the Ninth' } }
}
response = Cuprum::Rails::Responses::JsonResponse.new(data: data)
renderer = instance_double(ActionController::Base, render: nil)
response.call(renderer)
expect(renderer).to have_received(:render).with(json: data)
#=> true
Responses should not be generated directly; they are created as part of the action lifecycle.
Cuprum::Rails
defines the following responses:
Cuprum::Rails::Responses::Html::RedirectResponse
A response for an HTML redirect. Takes the redirect path
and an optional :status
keyword, and calls renderer.redirect_to
.
Cuprum::Rails::Responses::Html::RenderResponse
A response for an HTML rendered view. Takes the template
to render, as well as optional keywords for the :layout
, the :status
, and the :assigns
to assign as local variables. Calls renderer.render
.
Cuprum::Rails::Responses::JsonResponse
A response for a JSON request. Takes the serialized :data
to return as well as an optional :status
keyword. Calls renderer.render
with the json:
option.
Serializers
Serializers convert entities and data structures into serialized data. Each serializer is specific to one format and one type of object - for example, the Cuprum::Rails::Serializers::Json::ErrorSerializer
generates a JSON representation of a Cuprum::Error
.
Serialization is context-specific - one controller may use one serializer for a particular record class, while another controller may use a limited set of attributes, such as an admin versus a user-facing controller. To handle this, the #call
method must accept a :context
keyword, which is an instance of Cuprum::Rails::Serializers::Context
. Each context is initialized with a set of serializers that are used to serialize attributes, array items or hash values, associated models, or otherwise nested properties. All of this is handled automatically inside the controller action.
class StructSerializer < Cuprum::Rails::Serializers::JsonSerializer
def call(struct, context:)
struct.each_pair.with_object do |(key, value), hsh|
hsh[key] = super(value, context: context)
end
end
end
serializer = StructSerializer.new
context = Cuprum::Rails::Serializers::Context.new(
serializers: Cuprum::Rails::Serializers::Json.default_serializers
)
struct =
Struct
.new(:series, :author, :titles)
.new('The Locked Tomb', 'Tamsyn Muir', ['Gideon the Ninth', 'Harrow the Ninth'])
serializer.call(struct, context: context)
#=> {
# 'series' => 'The Locked Tomb',
# 'author' => 'Tamsyn Muir',
# 'titles' => ['Gideon the Ninth', 'Harrow the Ninth']
# }
Above, we define a custom serializer for serializing Struct
instances. We then use the serializer on our Book-like struct by passing it to the #call
method, along with a serialization context that contains the default JSON serializers. The #call
method takes each pair of keys and values and calls super()
, which finds the configured serializer for each value. In our case, the default serializer for a String
returns the string, while the default serializer for an Array
returns a new array whose items are the serialized array items. Finally, a Hash
with String
keys is generated, which is our Struct
serialized into a JSON-compatible object.
Cuprum::Rails
defines the following serializers:
Cuprum::Rails::Serializers::Json::Serializer
The base class for JSON serializers. Takes a configured context:
and finds the serializer for the given object, then calls that serializer with the object and the given context.
The serializer for an object is determined based on the object's class. Specifically, for each ancestor of the object's class, the configured serializers are checked for a key matching that ancestor. If that class or module is a key in the configured hash, then the corresponding serializer is used to serialize the object. If the configured serializers do not include a serializer for any of the object class's ancestors, raises an UndefinedSerializerError
.
Cuprum::Rails::Serializers::Json::AttributesSerializer
Serializes an object by finding and calling the configured serializer (see above) for each attribute defined for the serializer. See Attribute Serializers below.
Cuprum::Rails::Serializers::Json::ActiveRecordSerializer
Serializes an ActiveRecord
model by delegating to the #as_json
method. An alternative to defining a specific AttributeSerializer
(see above) for each model class.
Cuprum::Rails::Serializers::Json::ArraySerializer
Serializes an Array
by finding and calling the configured serializer for each array item (see above). This is the default serializer for Array
s.
Cuprum::Rails::Serializers::Json::ErrorSerializer
Serializes a Cuprum::Error
by delegating to the #as_json
method. This is the default serializer for errors.
Cuprum::Rails::Serializers::Json::HashSerializer
Serializes a Hash
with String
keys by finding and calling the configured serializer for each hash value (see above). This is the default serializer for Hash
es.
Cuprum::Rails::Serializers::Json::IdentitySerializer
Serializes a value object by returning the object. This is the default serializer for nil
, true
, false
, Integer
s, Float
s, and String
s.
Attribute Serializers
Attribute serializers define a set of attributes to be serialized. This is useful for whitelisting a specific set of attributes to return in the serialized object.
class RecordSerializer < Cuprum::Rails::Serializers::Json::AttributesSerializer
attribute :id
end
class BookSerializer < RecordSerializer
attributes \
:title,
:author,
:series
end
class DetailedBookSerializer < BookSerializer
attributes \
:category,
published_at: :iso8601
end
context = Cuprum::Rails::Serializers::Context.new(
serializers: Cuprum::Rails::Serializers::Json.default_serializers
)
book = Book.new(
id: 0,
title: 'Nona The Ninth',
author: 'Tamsyn Muir',
series: 'The Locked Tomb',
category: 'Science Fiction and Fantasy',
published_at: Date.new(2022, 9, 13)
)
BookSerializer.new.call(book, context: context)
#=> {
# 'id' => 0,
# 'title' => 'Nona The Ninth',
# 'author' => 'Tamsyn Muir',
# 'series' => 'The Locked Tomb'
# }
DetailedBookSerializer.new.call(book, context: context)
#=> {
# 'id' => 0,
# 'title' => 'Nona The Ninth',
# 'author' => 'Tamsyn Muir',
# 'series' => 'The Locked Tombs',
# 'category' => 'Science Fiction and Fantasy',
# 'published_at' => '2022-09-13'
# }
Above, we define an abstract RecordSerializer
and a BookSerializer
, which inherits the :id
attribute and defines the :title
, :author
, and :series
attributes. When the book serializer is called, it serializes the values of each attribute using the configured serializers; any attributes that are not defined on the serializer are ignored.
We also define a DetailedBookSerializer
which inherits from BookSerializer
. This allows us to reuse the attributes defined for our basic book serializer.
Attribute serializers also inherit from PropertiesSerializer
(see below), and can use the .property
method. This allows the user to serialize compound properties, or to handle cases where the desired serialization key is different from the name of the attribute.
Property Serializers
Property serializers define a set of properties to be serialized. This is useful for serializing data structures such as database models.
class EmployeeSerializer < Cuprum::Rails::Serializers::Json::PropertiesSerializer
property :first_name, scope: :first_name
property(:last_name, &:last_name)
property(:full_name) { |user| "#{user.first_name} #{user.last_name}" }
property(:hire_date, scope: :hire_date, &:iso8601)
property :salary, serializer: BigDecimalSerializer.new
property :department, scope: %i[department name]
end
context = Cuprum::Rails::Serializers::Context.new(
serializers: Cuprum::Rails::Serializers::Json.default_serializers
)
employee = Employee.new(
first_name: 'Alan',
last_name: 'Bradley',
hire_date: Date.new(1977, 5, 25)
salary: BigDecimal.new('100000')
department: Department.new(name: 'Engineering')
)
EmployeeSerializer.new.call(employee, context: context)
#=> {
# first_name: 'Alan',
# last_name: 'Bradley',
# full_name: 'Alan Bradley',
# hire_date: '1977-05-25',
# salary: '0.1e6',
# department: 'Engineering'
# }
Here, we're creating a serializer for our Employee
model, which serializes each employee into a Hash
with the configured property
keys.
- The property name determines the key used to serialize the value in the resulting Hash.
- The
:scope
keyword determines the initial value to be serialized.- If the scope is
nil
, the object as a whole will be passed to the mapping and then the serializer. - If the scope is a String or a Symbol, then the value of the object property with that key will be mapped. Above, the
first_name
property is defined withscope: :first_name
, so the initial value will beemployee.first_name
. - If the scope is an Array, then the value of the nested property at those keys will be mapped. Above, the
department
property is defined withscope: %i[department name]
, so the initial value will beemployee.department.name
.
- If the scope is
- The
:serializer
keyword specifies how the mapped value is to be serialized. It should either be an instance ofCuprum::Rails::Serializers::BaseSerializer
or aProc
that accepts two parameters: anobject
argument, and a:context
keyword that is the currentCuprum::Rails::Serializers::Context
. - The block, if any, is used to map the scoped value before passing it to the serializer. Above, the
full_name
property is generated by combining theemployee.first_name
andemployee.last_name
.
When the property
does not specify a scope
, a serializer
, or provide a block, it will raise an ArgumentError
. This would otherwise serialize the object itself using the default serializers. If, for some reason, this is the desired behavior, pass an identity block or &:itself
as the mapping block.