DEV Community

Cover image for Adding A Confirmation Interstitial On Create in REST
Michael Chaney
Michael Chaney

Posted on

Adding A Confirmation Interstitial On Create in REST

On X, Tom Rossi asked about handling a confirmation page on a form while conforming to RESTful routes.

This is an excellent question and one which I've answered in a couple of different ways in various projects. But, I'm a bit of a REST snob these days and I want any solution to be as RESTy as possible.

What's UnRESTy About It?

Well, that's a good question. Let's take the kind of straightforward method of doing this and consider it in the context of REST and Ruby on Rails, with a simple "todo list" application.

The TodoList application has a single model called "Todo". Each todo record has a varchar field called "body" and a boolean field called "finished". Very simple.

class CreateTodos < ActiveRecord::Migration[7.1]
  def change
    create_table :todos do |t|
      t.string :body, limit: 100, null: false
      t.boolean :finished, null: false, default: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The model:

class Todo < ApplicationRecord
  normalizes :body, with: ->(value) { value.strip }

  validates :body, presence: true, length: { maximum: 100 }
end
Enter fullscreen mode Exit fullscreen mode

With the standard scaffold, we can now create, list, examine, edit, and delete todos.

If we hit the route /todos/new, we'll get a form which we can fill in to create a new item. When we submit the form - which is a POST request to the /todos route, the item is checked for validity and created if it's valid. This is standard REST.

How do you add a confirmation page in there?

This is where it gets unRESTy. Let's consider the flow with the interstitial in place:

  1. User hits the "new" route, gets the form
  2. User fills out the form, hits "Save"
  3. If information doesn't pass validation, the user is shown the form again along with error information so they can correct it.
  4. If the information passes validation, the user is shown the information again, and given the choice to a) Really Save or b) Make Changes
  5. If "Make Changes", go back to the form with the information filled in to allow changes
  6. If "Really Save", save the information to the database

One aspect of this to consider is that the user wants to see any validation errors at step 3. This brings up an issue because we then have to validate the data somehow after step 2 but before it's actually saved. With RoR, this requires a bit of thought because it's easier to wait until step 6. But users will complain that your application is moronic if they don't see errors until that late in the game. I might agree with them.

There are multiple ways to handle this. I have created a simple Rails app to demonstrate all of them:

GitHub logo mdchaney / todo_with_confirmation

This is a sample rails app for a todo app with confirmation






The "please" parameter

Add a "please" parameter to the form, and only perform the "create" action if it is present. This allows you to show the information again and stick it all in hidden fields along with your "please" parameter. Then, the user can hit "Really Save" and it'll hit the same create route and actually save it.

This works (I know, I did it 20 something years ago) but it's unRESTy and makes me want to shower after I write it. The "create" route may or may not create the item depending on the presence of the "please" parameter.

A challenge with this method is that after you have the data payload it's required to use the HTTP post method to make sure your data is moved forward continuously. That means that if they hit "Make Changes" you need to either a) allow the "new" route to accept a post with some data or b) add a different route. In the sample app I use a "redo" route that accepts a post.

If you think about it in terms of an API, the whole thing is kind of silly because the client can simply send the "please" parameter the first time and not worry about confirmation, anyway. More on this later.

Hit A Different Route For the Interstitial

This is RESTier than the former solution. The idea here is that the form doesn't just POST to the /todos route (which is "create" in RESTland). Instead, it posts to another route - such as /todos/confirm - which validates the data and handles steps 3 and 4. Then, if the user is happy with the data, they can hit "Really Save" and the data is posted to the real "create" route. Note that this still requires the extra "redo" route as well.

This is definitely the RESTier way to do it. But one issue that this and the former method have in common is that the data is changeable and the user still has the capability of doing silly stuff. They're not constrained by the interface.

Hit A Different Route For the Interstitial, and Sign the Data

This fixes the problems with the second method. In this method, the data from the form is encrypted and signed, then stored in a hidden field. The data is shown to the user, but the only data that is transmitted back to the server on the "create" route is an encrypted (or just signed) blob that can be used to recreate the data.

This strikes me as less RESTy, and the reason is that the I feel (yes, this is purely subjective) that the data that is sent to the "create" route should be the data for the record in form data or JSON format.

There is another way that gets around that issue, and that is to simply create a cryptographic signature on the back end in step 4 which is sent back to the confirmation form along with the field data, then verified before the item is created. That's a little more work as you have to make sure to create the signature in a repeatable manner.

So, this is a reasonable way to do it, but it's still not as RESTy as I'd like. But I think it's RESTy enough.

I know I sound a bit anal about making sure the data wasn't changed, but this really is a UI issue that we're handling between the front end and the back end. The back end ultimately decides whether to accept a set of data or not based on its validations, and I personally believe that the UI part that makes this tricky is handling the fact that after the back end vouches for a set of data and says "it validated" we want to make sure that's the set of data that is later sent to it to be saved. It'll help prevent some weird errors.

Going back to the API - if you're creating something like this using React then the front end has to really handle everything. In that case, you can think about the discrete components easier:

  1. The front end sends a set of data to the back end for validation
  2. If it's not valid, a set of errors are returned
  3. If it is valid, a signature is sent to the front end that can be sent along with the final "create" to make it save
  4. When the user hits "Really Save", the signature is sent along with the form data and the database saves the information.

Alternately, this also works if the server just sends the binary blob representing the marshalled/encrypted/signed data.

But, isn't this just the "please" parameter all over again? No. The reason it isn't is that the "please" parameter can be easily faked and sent to the server at any time. This method forces the front end to first ask the back end "is this data valid?", and then send the proof of validity (the signature) back along with the data so the server can trust that it validated it before.

How To Accomplish This

Again, for me the easiest way to handle this is the binary blob. I use this pattern in multiple places, and sometimes it's not for confirmation directly. I have some multi-step forms that require some data to be entered, and then saved across the next page which might do something completely different. It's again important to ensure that after the server has vouched for the data that it get the exact same data next time.

Another common place to use this is in ordering. I don't want to create the order in the database until payment is secured. And I don't want the user to be able to edit the form and get free items or something like that.

Using MessageEncryptor

Rails provides ActiveSupport::MessageEncryptor to help encrypt and sign. The basic framework is this:

  1. Generate a key based on your secret key base
  2. Generate a secret based on a salt value that you provide
  3. Use this to encrypt and sign your value

Later:

  1. Use the same "secret" to decrypt and verify the signature
class TodoWithBlob < ApplicationRecord
  normalizes :body, with: ->(value) { value.strip }

  def self.encryptor
    @encryptor ||=
      begin
        salt = "very salty salt"
        key_gen = ActiveSupport::KeyGenerator.new(Rails.application.credentials.secret_key_base, iterations: 1000)
        secret = key_gen.generate_key(salt,32)
        @encryptor = ActiveSupport::MessageEncryptor.new(secret)
      end
  end

  def as_json_string
    attributes.except("id", "created_at", "updated_at").to_json
  end

  def as_encrypted_blob
    self.class.encryptor.encrypt_and_sign(self.as_json_string)
  end

  def self.encrypted_blob_to_json(encrypted_blob)
    JSON.parse(encryptor.decrypt_and_verify(encrypted_blob))
  end

  def self.from_encrypted_blob(encrypted_blob)
    new(encrypted_blob_to_json(encrypted_blob))
  end

  validates :body, presence: true, length: { maximum: 100 }
end
Enter fullscreen mode Exit fullscreen mode

I also use this basic pattern when handling orders that need to be validated but not saved in the database until the payment has processed.

The Universal ID gem will be usable here as well when signing is added to it.

Summary

We've looked at some various ways to try to keep our app as RESTy as possible while providing a decent UI to the user. The UI shows the user any errors immediately at form submission and assumes that when they confirm their input on the next page it will be valid. I strongly recommend one of the latter two options (signature or signed blob) to ensure that the data isn't tampered with intentionally or accidentally between validation on the server and ultimately saving it.

When I get time I'll use a similar technique to make a multi-page form.

Top comments (0)