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::Collectionsinterface.- Commands: Each collection is comprised of
Cuprumcommands, 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
Cuprumcommand. - 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::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.0 through 3.2, and Rails 6.1 through 7.1.
Documentation
Documentation is generated using YARD, and can be generated locally using the yard gem.
License
Copyright (c) 2021-2022 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/collection'
A Cuprum::Rails::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::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_nameparameter sets the name of the collection. It is used to create an envelope for query commands, such as theFindMany,FindMatchingandFindOnecommands. - The
:default_contractparameter sets a default contract for validating collection entities. If no:contractkeyword is passed to theValidateOnecommand, it will use the default contract to validate the entity instead of the validation constraints defined for the model. - The
:member_nameparameter is used to create an envelope for singular query commands such as theFindOnecommand. If not given, the member name will be generated automatically as a singular form of the collection name. - The
:primary_key_nameparameter specifies the attribute that serves as the primary key for the collection entities. The default value is:id. - The
:primary_key_typeparameter specifies the type of the primary key attribute. The default value isInteger. - The
:qualified_nameparameter 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_partialkeyword 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
:envelopekeyword 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
:scopekeyword 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
:envelopekeyword 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
:limitkeyword caps the number of results returned.The
:offsetkeyword skips the specified number of results.The
:orderkeyword specifies the order of results.The
:scopekeyword 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
:wherekeyword 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
:envelopekeyword 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
:scopekeyword 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/repository'
A Cuprum::Rails::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::Repository.new
repository.key?('books')
#=> false
collection = repository.find_or_create(entity_class: Book)
#=> a Cuprum::Rails::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::Repository.new
repository.key?('authentication/users')
#=> false
collection = repository.find_or_create(entity_class: Authentication::User)
#=> a Cuprum::Rails::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::Railsis a pre-release gem, and there may be breaking changes between minor versions and until the API is finalized by version 1.0.0. TheControllerAPI 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::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 Books 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:updateactions 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::CreateActionProfilingMiddleware: 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_classis initialized, passing the controllerresourceto the constructor and returning theaction. - The
actionis wrapped with anymiddlewarethat is defined by the controller for that action. - The controller
#requestis wrapped in aCuprum::Rails::Request, which is passed to theaction's#callmethod, returning theresult.
- The
- The Responder
- The
responder_classis found for the request based on the request'sformatand the configuredresponders. - The
responder_classis initialized with theaction_name,controller_name,resource, andserializers, returning theresponder. - The
responderis called with the actionresult, and finds a matchingresponsebased on the action name, the result's success or failure, and the result error (if any).
- The
- The Response
- The
responseis 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 ouractionwith 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:statusof:successand 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
:htmlformat. In our case, this isCuprum::Rails::Responders::Html::Resource, which defines default behavior for responding to resourceful requests. - Our
Responders::Html::Resourceis initialized, giving us aresponder. - The
responderis called with ourresult. There is a match for a successful:createaction, which returns an instance ofCuprum::Rails::Responses::Html::RedirectResponsewith apathof/books/0.
- We're making an HTML request, so our controller will use the responder configured for the
- The Response
- Finally, our
responseobject is called. TheRedirectResponsedirects the controller to redirect to/books/0, which is the:showpage for our newly createdBook.
- Finally, our
Controller Actions
require 'cuprum/rails/action'
Cuprum::Rails extracts the business logic of controllers into dedicated Cuprum::Rails::Actions. 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. AHashwithStringkeys.#format: The format of the request as aSymbol, e.g.:htmlor:json.#headers: The request headers, as aHashwithStringkeys.#method: The HTTP method used for the request as aSymbol, e.g.:getor:post.#parameters: (also#params) The complete parameters for the request, including both params from the request body and from the query string. AHashwithStringkeys.#path: The relative path of the request, including query params.#path_parameters: (also#path_params) The path parameters for the request, minus the Rails-providedactionandcontrollerparams. AHashwithStringkeys.#query_parameters: (also#query_params) The query parameters for the request. AHashwithStringkeys.
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::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::Collectionscollection, used to perform queries and persistence operations on the resource data. If not given and the collection has a#resource_class, then aCuprum::Rails::Collectionis automatically generated.#resource_class: TheClassof items in the resource.#resource_name: The name of the resource as aString. If the resource is initialized with aresource_class, theresource_nameis 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/booksresource). Also defines the#singular?and#pluralpredicates.
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
.actionclause with a matchingerror:(if any). - A generic
.matchclause with a matchingerror:. - An
.actionclause with a matching status, either:successor:failure. - A generic
.matchclause 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
:indexpage (for a collection action) or the resource:showpage (for a member action).
Cuprum::Rails::Responders::Html::Resource
Provides some additional response handling for resources.
If the resource is plural:
- For a failed
#createresult, renders the:newtemplate. - For a successful
#createresult, redirects to the:showpage. - For a successful
#destroyresult, redirects to the:showpage. - For a failed
#indexresult, redirects to the root page. - For a failed
#updateresult, renders the:edittemplate. - For a successful
#updateresult, redirects to the:showpage.
If the resource is singular:
- For a failed
#createresult, renders the:newtemplate. - For a successful
#createresult, redirects to the:showpage. - For a successful
#destroyresult, redirects to the parent resource. - For a failed
#updateresult, renders the:edittemplate. - For a successful
#updateresult, redirects to the:showpage.
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
#createresult, serializes the result value with a status of201 Created. - For a failed result with an
AlreadyExistserror, serializes the error with a status of422 Unprocessable Entity. - For a failed result with a
FailedValidationerror, serializes the error with a status of422 Unprocessable Entity. - For a failed result with an
InvalidParameterserror, serializes the error with a status of400 Bad Request. - For a failed result with a
NotFounderror, 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 #called. 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 Arrays.
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 Hashes.
Cuprum::Rails::Serializers::Json::IdentitySerializer
Serializes a value object by returning the object. This is the default serializer for nil, true, false, Integers, Floats, and Strings.
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
:scopekeyword 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_nameproperty 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
departmentproperty is defined withscope: %i[department name], so the initial value will beemployee.department.name.
- If the scope is
- The
:serializerkeyword specifies how the mapped value is to be serialized. It should either be an instance ofCuprum::Rails::Serializers::BaseSerializeror aProcthat accepts two parameters: anobjectargument, and a:contextkeyword 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_nameproperty is generated by combining theemployee.first_nameandemployee.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.