Configuring "convention over configuration" to our conventions
by Alexa Grey
This article explores how Cast Iron leverages Rails, GraphQL, and the dry.rb suite to build scalable APIs for complex web applications. By combining graphql-ruby's self-documenting schema definitions with dry-validation contracts and custom operation classes, our Backend team has developed a convention-based framework that consistently handles authentication, authorization, validation, and error handling. This approach maintains clear contracts between our frontend and backend teams, which makes our development process more efficient.
Ruby on Rails (more commonly just Rails) has been a staple for the kind of work we do at Cast Iron Coding for the past decade and a bit. For those unfamiliar, Rails is a framework for building web applications written in the Ruby programming language. It has been around since 2004, and has endured through the decades as a reliable framework to build on the web with. Ruby itself is a joy to work in, it's an expressive language with a robust and mature ecosystem of available packages (suitably called gems). Its syntax is declarative and easy to read, often appearing like plain English.
One of the guiding principles of Rails that we embrace is convention over configuration. In short, convention over configuration allows a developer to focus more on the actual problems they need to solve (say, taking a customer's order and sending them an email with their receipt) instead of spending too much time on the minutiae (how do we store the order? where does the logic live?). An experienced Rails developer can look at the source code for an application written with this principle in mind and very quickly get up to speed with how it works. This is vital for the kind of work we do at CIC. Being able to rely upon these conventions gives us alacrity and the ability to take on multiple complex projects at the same time with confidence. These guardrails are not restrictive, though. A significant reason we've stuck with Rails all these years is its ability to get out of the way when we need it to. To quote the Rails doctrine:
To be so ideologically flexible is what enables Rails to tackle such a wide array of problems. Most individual paradigms do very well within a certain slice of the problem space, but become awkward or rigid when applied beyond its natural sphere of comfort. By applying many overlapping paradigms, we cover the flanks and guard the rear. The final framework is far stronger and more capable than any individual paradigm would have allowed it to be.
The Rails Doctrine
Article | DRYing up GraphQL Mutations in Ruby on Rails
There are conventions to follow to let you focus on what is important, but you can break the ones you don't need in predictable ways in order to get your project done. And with time, it allows an experienced team to develop their own conventions built atop Rails' foundations.
At Cast Iron, our backend and frontend teams are largely separate. We have Rubyists working on the APIs behind the scenes, and JavaScript and CSS developers building gorgeous interfaces that make those backends actually functional. While we're in constant communication, we've found a tremendous amount of efficiency and reliability in building our applications with this sort of "great divide" between the two sides of a web application. For the backend team, it encourages building robust, well-documented (and well-tested!) APIs for the frontend to consume. For the frontend team, it encourages being able to clearly express exactly the kind of data and interactions with the API they need in order to build the best user experience they can.
Towards this end, on many of our projects, we've increasingly used GraphQL as the tool for building these APIs. It's a self-documenting technology that allows querying (reading data), mutations (changing data), and subscriptions (listening for changes to data). The backend team doesn't need to worry about how to document or organize model relationships, and the frontend team doesn't need to hunt through a list of routes to figure out how to make a change to some part of the application. Convention over configuration. Sound familiar?
The tool we use to accomplish this is graphql-ruby, which has a clear and composable framework for implementing all the pieces of a GraphQL API through plain Ruby classes and modules. Take, for instance, the query type implementation for a "permalink" in a recent project:
Fields on the query type are defined with the method field. It takes a name, a type, some options (whether or not the value can ever be null, for instance), and a description for the field. The types are primitives (string, boolean, integer, etc), or other types we've defined. Types::PermalinkableType in this instance is a GraphQL interface.GraphQL Interfaces define a certain set of fields that a type must include to implement it. Types::PermalinkableKindType is a GraphQL enum.GraphQL Enum types are a special kind of scalar that is restricted to a particular set of allowed values. There's also a description for the type itself. The load_association! call at the end is an extension we've added in order to handle the loading of related database records in a performant way when querying multiple permalinks.
These field definitions are clear, easy to maintain, and they allow our backend developers to provide a well-documented contract about how the API is intended to work. The documentation is invaluable. Not just for the frontend team, but also for the same backend team a year from now when they need to circle back and add new functionality.
When it comes to changing data with graphql-ruby, it provides a similarly fluent approach for defining the interface insofar as the input and output of a mutation:
For our create and update mutations, we usually define a base mutate class to cover the (often many) shared fields between the two actions. Here, the field describes what is being returned by the mutation. The argument method defines inputs to the mutation with basic type safety. The permalinkable_id argument is doing some heavy lifting powered by convention: when a client sends a valid globally-unique ID to this field, graphql-ruby loads the actual record in its place because of the loads: keyword. The framework will ensure that the loaded record matches the type (the interface, in this instance) and throw an error back to the client otherwise. That saves a few lines of code from being written for every mutation that needs to load database records from arguments provided to the mutation, which adds up over time. Moreover, the backend team does not have to tell the frontend team that these mutations exist, what they do, or how to invoke them. GraphQL takes this simple definition and presents it in a way that the frontend can read and process on their own.
When it comes to the business logic of what the mutations actually do, though, graphql-ruby's mutations use pure and simple ruby to handle things. Out of the box, you define a method like this:
And in a world where everyone was authenticated, authorized, sending valid data, and errors never happened? That would work perfectly. But in the real world, those four concerns are something we need to handle predictably every time we want to allow a client to make a change to data within the system.
Authentication: Verifying the identity of the person making the request, the user, employee, et al.
Authorization: Confirming that the person making the request has permission to perform the action they are trying to do.
Validation: Making sure that the data coming in meets expectations. Whether length, format, content, file type, etc.
Error handling: Developers, users, and technology itself are all imperfect. Mistakes happen. We need to be able to communicate back to the client when they do in a predictable way.
To that end, we build upon our conventions using a framework of our own design, utilizing atop a suite of gems that form the next piece of our puzzle: dry.rb. For each mutation, we have one or more "contract" classes, which handle validations via dry-validation, and an "operation" class, which handles the actual business logic.
A contractRemember, contracts are in charge of validating the inputs to the mutation. for creating a permalink looks like this:
Rails' ActiveModel (and ActiveRecord by proxy) has a powerful suite of validations out of the box, but it isn't always the right fit for handling forms and user interaction. It ties the shape of a request's parameters very closely to the shape of the model in the database, which is not always a pattern you want to follow. Sometimes a mutation may affect multiple different models. Sometimes it may need to validate based on the current state the model it is in (for instance, a draft/publishing article workflow). There are ways to make all that logic work within an ActiveRecord model, but it requires a lot of conditional hooks that make the model class itself inordinately more complicated.
dry-validation provides a mean to surface that logic outside of the model and focus solely on what logic is required to satisfy a specific request. You can validate parameter types, formats with regular expressions, length, inclusion/exclusion, etc. And with the rule syntax, you can perform more complex validations across multiple fields, or on the global / base of the request (with base.failure). Error keys like :must_be_unique are defined in locale files for easy internationalization. The contracts matching the shape of the GraphQL inputs also allows errors to come back on the expected keys, and gives us an abstraction layer in the event we need to make any internal adjustments to models while keeping the API stable.
Our operationWith our approach, operations store the business logic associated with the mutation. It's where we actually change models in the database or trigger side effects. class looks like this:
Our framework uses a combination of tools provided by graphql-ruby, dry.rb, and Rails itself. Rails' ActiveModel::Callbacks gives us things like before_prepare and authorizes!, which allow us to control what happens during the lifecycle of a mutation.
In this case, we set up a new record that we can then check to see if an already-authenticated user is allowed to (authorized to) :create? the previously-prepared :permalink record. If not, the mutation will bail out early and not even bother validating the data. The person isn't supposed to be here. authorizes! can be called multiple times for as many conditions that a user must satisfy in order to be allowed to proceed.
use_contract! brings our earlier contract class into play. Mutations can call use_contract! multiple times in cases where different types of validation are needed, or validation is shared across multiple different mutations. The arguments provided to the mutation, along with any adjustments we might have made during preparation, get validated against these contracts. If anything looks off, we report back to the client with a predictable format that allows the frontend to not only report the error, but in the case of forms, put errors right alongside the problematic fields where applicable.
Finally, we get to the meat of the mutation in the call method. Our framework has some syntactic helpers for logic like attribute assignment, calling out to other operations in the application, and database persistence. Attribute assignment is handled like this in order to be able to intercept special cases like file attachments, which can be defined similar with similar syntax to use_contract! call. Our persistence method ensures the record actually saves correctly and attaches our newly-created record to the mutation's output payload.
This is, understandably, quite a bit more complex than the simple def resolve(...) example earlier, but it gives us much-needed conventions for handling those four concerns in a way that's held up for several years now.
Of course, no API can be considered remotely suitable for deployment without testing. At Cast Iron, we primarily use RSpec for testing for the same reasons we use Rails and everything else mentioned here. It has remarkably good out-of-the-box defaults that can be pushed aside or adjusted when needed in order to get the job done. We have conventions for testing our mutations as well:
With this configuration, we can easily test for all the logic branches our mutations may take while keeping the specs manageable and maintainable for the future.
We can define the query to use with mutation_query!. This includes a standard GraphQL fragment we use for handling errors automatically without having to repeat it in every single spec.
We can define our inputs (which can be overridden in different RSpec contexts) with let_mutation_input!.
We can define the expected shapes of our responses with the gql helper, making sure what's invalid is reported as invalid and what's unauthorized is reported as unauthorized.
We can test for the side effects of our mutations with the expect_request! helper, which pulls the query, inputs, and current user from the RSpec context all together, makes the request, and processes the response against our expected shape.
On all of our GraphQL applications, we have achieved close to 100% test coverage as a result of being able to write specs as succinctly as this. That level of test coverage itself is not a guarantor that an application is error-free, of course, but it gives us a lot of confidence in being able to efficiently develop new features and get them out into the real world.
This framework for handling GraphQL mutations does require a bit more ceremony than just a single file. That's where we return to Rails to tie it all together, using one of its most powerful core features: generators. Since our mutations, contracts, operations, and specs are conventions-based, we've built a generator that allows one to simply bin/rails g mutation PermalinkCreate and get all of these classes set up for us, ready to implement. And for even more ease of use, bin/rails g mutation_scaffold Permalink will take care of the PermalinkCreate, PermalinkUpdate, and PermalinkDestroy mutations.
This combination of graphql-ruby, dry.rb, and Rails generators has allowed Cast Iron to build a number of robust APIs in Rails for the past several years, and we have many more planned. Libraries like Hotwire and view_component have made vast improvements to traditional MVC Rails applications, which we plan to cover in the future, but we love Rails as a headless API. For the myriad ways that web applications continue to grow more complex in the browser, clients still need to talk to servers. We appreciate the rock-like stability of Rails to build around, for the last ten years, and for the next.
ruby
module Types class PermalinkType < Types::AbstractModel description <<~TEXT A permalink is a persistant link to a resource with a human-readable URI. Each resource can have multiple permalinks, but only one can be marked as canonical. TEXT field :permalinkable, ::Types::PermalinkableType, null: false do description <<~TEXT The resource this permalink points to. TEXT end field :canonical, Boolean, null: false do description <<~TEXT Whether this permalink is the canonical one for the `permalinkable`. TEXT end field :uri, String, null: false do description <<~TEXT The URI of the permalink. Used for generating routes and also serves as a unique identifier. **Note**: URIs are _case-insensitive_ and may only contain alphanumeric characters and hyphens. Hyphens may not be consecutive nor may they appear at the start nor the end of the URI. TEXT end field :kind, Types::PermalinkableKindType, null: false do description <<~TEXT The type of resource this permalink points to. TEXT end field :permalinkable_slug, String, null: false do description <<~TEXT The slug of the `permalinkable` record. It can be used for quickly generating non-canonical links to the resource based on the `kind` without needing to load the associated record. TEXT end load_association! :permalinkable endend
module Types class PermalinkType < Types::AbstractModel description <<~TEXT A permalink is a persistant link to a resource with a human-readable URI. Each resource can have multiple permalinks, but only one can be marked as canonical. TEXT field :permalinkable, ::Types::PermalinkableType, null: false do description <<~TEXT The resource this permalink points to. TEXT end field :canonical, Boolean, null: false do description <<~TEXT Whether this permalink is the canonical one for the `permalinkable`. TEXT end field :uri, String, null: false do description <<~TEXT The URI of the permalink. Used for generating routes and also serves as a unique identifier. **Note**: URIs are _case-insensitive_ and may only contain alphanumeric characters and hyphens. Hyphens may not be consecutive nor may they appear at the start nor the end of the URI. TEXT end field :kind, Types::PermalinkableKindType, null: false do description <<~TEXT The type of resource this permalink points to. TEXT end field :permalinkable_slug, String, null: false do description <<~TEXT The slug of the `permalinkable` record. It can be used for quickly generating non-canonical links to the resource based on the `kind` without needing to load the associated record. TEXT end load_association! :permalinkable endend
ruby
module Mutations # @abstract class PermalinkMutate < Mutations::BaseMutation description <<~TEXT A base mutation that is used to share fields between `permalinkCreate` and `permalinkUpdate`. TEXT field :permalink, Types::PermalinkType, null: true do description <<~TEXT The newly-modified permalink, if successful. TEXT end argument :permalinkable_id, ID, loads: ::Types::PermalinkableType, required: true do description <<~TEXT The ID of the resource to which this permalink will belong. It can be changed. TEXT end argument :uri, String, required: true do description <<~TEXT The URI for the permalink. It is case-insensitive and must be unique system-wide. It may only contain letters, numbers, and hyphens. It may not begin nor end with a hyphen, nor contain consecutive hyphens. TEXT end argument :canonical, Boolean, required: false, default_value: false, replace_null_with_default: true do description <<~TEXT Whether this permalink should be the canonical permalink for its resource. If true, any existing canonical permalink for the resource will be demoted to a non-canonical permalink. TEXT end endend
module Mutations # @abstract class PermalinkMutate < Mutations::BaseMutation description <<~TEXT A base mutation that is used to share fields between `permalinkCreate` and `permalinkUpdate`. TEXT field :permalink, Types::PermalinkType, null: true do description <<~TEXT The newly-modified permalink, if successful. TEXT end argument :permalinkable_id, ID, loads: ::Types::PermalinkableType, required: true do description <<~TEXT The ID of the resource to which this permalink will belong. It can be changed. TEXT end argument :uri, String, required: true do description <<~TEXT The URI for the permalink. It is case-insensitive and must be unique system-wide. It may only contain letters, numbers, and hyphens. It may not begin nor end with a hyphen, nor contain consecutive hyphens. TEXT end argument :canonical, Boolean, required: false, default_value: false, replace_null_with_default: true do description <<~TEXT Whether this permalink should be the canonical permalink for its resource. If true, any existing canonical permalink for the resource will be demoted to a non-canonical permalink. TEXT end endend
module Mutations module Contracts class PermalinkCreate < MutationOperations::Contract json do required(:permalinkable).value(:permalinkable) required(:uri).filled(:string) { str? & min_size?(3) & max_size?(250) } required(:canonical).value(:bool) end rule(:uri) do key.failure(:must_be_unique) if Permalink.exists?(uri: value) key.failure(:must_be_valid_permalink_uri) unless Permalink::URI_FORMAT.match?(value) end end endend
module Mutations module Contracts class PermalinkCreate < MutationOperations::Contract json do required(:permalinkable).value(:permalinkable) required(:uri).filled(:string) { str? & min_size?(3) & max_size?(250) } required(:canonical).value(:bool) end rule(:uri) do key.failure(:must_be_unique) if Permalink.exists?(uri: value) key.failure(:must_be_valid_permalink_uri) unless Permalink::URI_FORMAT.match?(value) end end endend
RSpec.describe Mutations::PermalinkCreate, type: :request, graphql: :mutation do mutation_query! <<~GRAPHQL mutation PermalinkCreate($input: PermalinkCreateInput!) { permalinkCreate(input: $input) { permalink { id slug uri kind canonical permalinkableSlug permalinkable { __typename ... on Community { id permalinks { id } canonicalPermalink { id } } } } ... ErrorFragment } } GRAPHQL let_it_be(:community, refind: true) { FactoryBot.create(:community) } let_it_be(:existing_permalink, refind: true) { FactoryBot.create(:permalink, :canon, permalinkable: community) } let_mutation_input!(:permalinkable_id) { community.to_encoded_id } let_mutation_input!(:uri) { "brand-new-link" } let_mutation_input!(:canonical) { true } let(:valid_mutation_shape) do gql.mutation(:permalink_create) do |m| m.prop(:permalink) do |p| p[:id] = be_an_encoded_id.of_an_existing_model p[:slug] = be_an_encoded_slug p[:canonical] = canonical p[:kind] = "COMMUNITY" p[:uri] = uri p[:permalinkable_slug] = community.system_slug p.prop(:permalinkable) do |pl| pl.typename("Community") end end end end let(:empty_mutation_shape) do gql.empty_mutation :permalink_create end shared_examples_for "a successful mutation" do let(:expected_shape) { valid_mutation_shape } it "creates the permalink" do expect_request! do |req| req.effect! change(Permalink, :count).by(1) req.effect! change { community.reload.canonical_permalink.id }.from(existing_permalink.id) req.effect! change { community.permalinks.count }.by(1) req.data! expected_shape end end context "when the URI is already taken" do let_mutation_input!(:uri) { existing_permalink.uri } let(:expected_shape) do gql.mutation(:permalink_create) do |m| m[:permalink] = be_blank m.attribute_errors do |ae| ae.error :uri, :must_be_unique end end end it "fails to create a permalink" do expect_request! do |req| req.effect! keep_the_same(Permalink, :count) req.effect! keep_the_same { community.reload.canonical_permalink.id } req.effect! keep_the_same { community.permalinks.count } req.data! expected_shape end end end context "when the URI is in an invalid format" do let_mutation_input!(:uri) { "invalid uri" } let(:expected_shape) do gql.mutation(:permalink_create) do |m| m[:permalink] = be_blank m.attribute_errors do |ae| ae.error :uri, :must_be_valid_permalink_uri end end end it "fails to create a permalink" do expect_request! do |req| req.effect! keep_the_same(Permalink, :count) req.effect! keep_the_same { community.reload.canonical_permalink.id } req.effect! keep_the_same { community.permalinks.count } req.data! expected_shape end end end end shared_examples_for "an unauthorized mutation" do let(:expected_shape) { empty_mutation_shape } it "is not authorized" do expect_request! do |req| req.effect! execute_safely req.effect! keep_the_same(Permalink, :count) req.unauthorized! req.data! expected_shape end end end as_an_admin_user do it_behaves_like "a successful mutation" end as_an_anonymous_user do it_behaves_like "an unauthorized mutation" endend
RSpec.describe Mutations::PermalinkCreate, type: :request, graphql: :mutation do mutation_query! <<~GRAPHQL mutation PermalinkCreate($input: PermalinkCreateInput!) { permalinkCreate(input: $input) { permalink { id slug uri kind canonical permalinkableSlug permalinkable { __typename ... on Community { id permalinks { id } canonicalPermalink { id } } } } ... ErrorFragment } } GRAPHQL let_it_be(:community, refind: true) { FactoryBot.create(:community) } let_it_be(:existing_permalink, refind: true) { FactoryBot.create(:permalink, :canon, permalinkable: community) } let_mutation_input!(:permalinkable_id) { community.to_encoded_id } let_mutation_input!(:uri) { "brand-new-link" } let_mutation_input!(:canonical) { true } let(:valid_mutation_shape) do gql.mutation(:permalink_create) do |m| m.prop(:permalink) do |p| p[:id] = be_an_encoded_id.of_an_existing_model p[:slug] = be_an_encoded_slug p[:canonical] = canonical p[:kind] = "COMMUNITY" p[:uri] = uri p[:permalinkable_slug] = community.system_slug p.prop(:permalinkable) do |pl| pl.typename("Community") end end end end let(:empty_mutation_shape) do gql.empty_mutation :permalink_create end shared_examples_for "a successful mutation" do let(:expected_shape) { valid_mutation_shape } it "creates the permalink" do expect_request! do |req| req.effect! change(Permalink, :count).by(1) req.effect! change { community.reload.canonical_permalink.id }.from(existing_permalink.id) req.effect! change { community.permalinks.count }.by(1) req.data! expected_shape end end context "when the URI is already taken" do let_mutation_input!(:uri) { existing_permalink.uri } let(:expected_shape) do gql.mutation(:permalink_create) do |m| m[:permalink] = be_blank m.attribute_errors do |ae| ae.error :uri, :must_be_unique end end end it "fails to create a permalink" do expect_request! do |req| req.effect! keep_the_same(Permalink, :count) req.effect! keep_the_same { community.reload.canonical_permalink.id } req.effect! keep_the_same { community.permalinks.count } req.data! expected_shape end end end context "when the URI is in an invalid format" do let_mutation_input!(:uri) { "invalid uri" } let(:expected_shape) do gql.mutation(:permalink_create) do |m| m[:permalink] = be_blank m.attribute_errors do |ae| ae.error :uri, :must_be_valid_permalink_uri end end end it "fails to create a permalink" do expect_request! do |req| req.effect! keep_the_same(Permalink, :count) req.effect! keep_the_same { community.reload.canonical_permalink.id } req.effect! keep_the_same { community.permalinks.count } req.data! expected_shape end end end end shared_examples_for "an unauthorized mutation" do let(:expected_shape) { empty_mutation_shape } it "is not authorized" do expect_request! do |req| req.effect! execute_safely req.effect! keep_the_same(Permalink, :count) req.unauthorized! req.data! expected_shape end end end as_an_admin_user do it_behaves_like "a successful mutation" end as_an_anonymous_user do it_behaves_like "an unauthorized mutation" endend