Creating a Backend in Ruby on Rails

This tutorial covers the backend portion of a PerformanceBridge platform app using the Ruby on Rails framework as the backend. Rails can be used to deliver both HTML, CSS, and JavaScript (including full frontend frameworks), but this tutorial focuses on building a service layer and accompanying tests for your application.

It assumes familiarity with Ruby and Ruby on Rails concepts such as its model/view/controller organization and architecture. If you are not familiar with ruby or ruby on rails here are some guides to get you started:

  • Try Ruby - An interactive ruby tutorial with articles linking to further learning material.
  • Ruby on Rails Getting Started - The ruby on rails getting started guide. This covers concepts, terminology, and writing an application in the rails framework.
  • rspec - An introduction to automated testing with rspec.

Rails App setup

If you would like to set up your own asset pipeline or utilize a front end framework other than react you should use the "Starting from scratch" setup directions. If you are planning utilize react as a front end framework we highly recommend starting with our base rails application. It will take care of both the development setup and the production pipeline for your application. It will also provide some helpful development tools.

Note - As of the writing of this tutorial the current version of ruby on Rails was 6.0.2.

From pb-base-rails

The pb-base-rails start application is a rails backend that serves a react frontend using yarn for asset compilation. It's the fastest way to get started with your application. It comes with documentation in its README.md that will explain how to use it to build your application and have it ready for production quickly. Download the latest pb-base-rails application here.

Starting from scratch

If you have an existing application or if you are starting from rails new this tutorial assumes you're familiary with rails. It also assumes you will handle the production setup according the the PerformanceBridge Application Specification.

To incorporate performance bridge into your application, start by adding the PerformanceBridge gem source and the bridge-client and harbinger-rails-extensions gems to your Gemfile.

source 'http://gem.analytical.info:9292/'
# ...

gem 'harbinger-rails-extensions', '>= 4.0.0', source: "http://sparkdocker1.us-620.lan.philips.com:3019/"
gem 'bridge-client', '>= 0.1.1'

After running bundle install you will need to set up the credentials these gems need. This is done via environment variables so they can be easily set in a container. In the pb-base-rails app this is done in a .env file in the root of the rails application. This is the sourced whenever running a rails command (ex: rails console). Here is an example .env file:

# The cononical name of the application
# This must the the application name with which
# this app is registered in PerformanceBridge
export PB_APP_NAME=""

# The HMAC secret key used by this application
export PB_API_SECRET_KEY=""

# The location of the API
# If developing in a docker container this needs to be reachable which
# may require extra_hosts being specified in the docker-compose.yml
export PB_API_URL="http://localhost:3999"

# APM realtime data service
# The location of the apm host. This is often the same as PB_API_URL
export PB_APM_HOST="http://localhost"
# The port number for APM's realtime data service (default 4000)
export PB_APM_PORT="4000"

# AMQP
# When receiving platform messaging on the serverside
# and for sending messages to the messaging bus these credentials
# are required.
# export PB_AMQP_HOST="localhost"
# export PB_AMQP_USERNAME=""
# export PB_AMQP_PASSWORD=""

# DEPRICATED - cometd
# If the amqp-cometd bridge is being used for realtime
# data this variable must be set
# export PB_COMETD_URL=""

Note - You will need to be sure your application is registered with the PerformanceBridge API you are pointing at (see PB_API_URL).

Troubleshooting - If your rails server or console is not getting your environment variable changes then it's likely that the spring gem is to blame. Spring is a gem that improves start up times, but does so with various runtime starting/caching strategies. Try running bundle exec spring stop and then restarting your console or server.

We'll also be using rspec in this tutorial for testing. To follow along you'll want to add the rspec-rails gem to your development and testing enviroment blocks in your Gemfile.

group :development, :test do
  gem 'rspec-rails', '>= 4.0.1'
end

Rerun bundle install if you've had to add it. Then run the rails generator to install rspec:

bundle install
rails generate rspec:install

Exams service

We'll start by creating an exams service. There will be two GET endpoints that will provide our frontend application with radiology exam information. We want to get a list of the completed exams (those that have had images acquired and are available for the radiologist to interpret). First we'll make our routes and controller files. Then we'll build some simple tests. Finally, we'll build our queries and render the results as JSON.

Setting up new routes

Prerequisites - While an understanding of rails routes is not required it helps to understand the request lifecycle in a rails application and the possible routing rules that can be used. We are using the most basic options below. For more on rails routes see the rails guide.

Edit your config/routes files to add two GET endpoints that we'll call exams/completed and exams/:id.

# We'll use this to get our completed exams
get "exams/completed", to: "exams#completed"
# We'll use this later to get all the relevant information
# when the real time data feed lets us to know there's been a change
# this route is expecting an id to be passed and will be set to
# params[:id] in the controller
get "exams/:id", to: "exams#show"

Create our controller and service endpoints

Next create your controller by editing app/controllers/exams_controller.rb and adding the following:

class ExamsController < ApplicationController

  def completed
    render json: []
  end

  def show
    render json: {}
  end

end

You should now be able to reach your endpoints when your rails server is running. You can try it out with your browser or your favorite http request tool.

Create some tests

Let's create two controller tests in our spec directory. To keep things organized we'll create a new folder called spec/controllers for our controller tests to live in. Then we'll edit a new file spec/controllers/exams_controller_spec.rb and add some tests:

require 'spec_helper'
require 'rails_helper'

RSpec.describe ExamsController, type: :controller do

  describe "GET exams/completed" do
    it "should provide a list of completed exams" do
      get :completed
      expect(JSON.parse(response.body)).to eq([])
    end
  end

  describe "GET exams/1" do
    it "should return an exam object" do
      get :exam, params: {id: 1}
      expect(JSON.parse(response.body)).to eq({})
    end
  end

end

Right now these tests don't do much. They're just confirming that the routes work and our controllers are returning the example content we provided (an empty list and an empty object in json). But running these test with rspec should give us 2 example with 0 failures.

Querying the API

Now that we have the boiler plate out of the way let's query our API for data. We will describe the API's features as they apply to what we're doing in this tutorial. For a more comprehensive guide to the API including a link to the OpenAPI documentation visit the API reference page.

Best Practice - While our API queries for this part of our application are simple it's good practice in Rails to make controllers as small as possible. Every request of a rails application is an instatiation of the controller class. As such, if the controller class contains a great deal of code (even when it's not directly associated with our request) it increases instatiation time and thus slows down our response times.

We'll create a model for our API queries and then call it from our controller. Edit app/model/exam.rb and create a query method for each of our endpoints:

module Exam

  # Build a base query that includes all the fields
  # we want to select for each exam
  def self.base_select
    BridgeClient::Query.from("rad_exams")
      .select("accession")
      .select(".id")
      .select(".site_classes.patient_types.patient_type")
      .select(".patient_mrns.mrn")
      .select(".patient_mrns.patients.name")
  end

  # A list of completed exams that respects a given limit/offset
  def self.completed(limit=100,offset=0)
    base_select
      .where(".current_status_id.universal_event_types.event_type = 'complete'")
      .limit(limit)
      .offset(offset)
      .list
  end

  # Select a single exam by internal identifier (rad_exams.id)
  def self.single_exam(id)
    base_select
      .where(".id = ?",id)
      .first || {}
  end

end

Now let's explain the code above with a walkthrough of basic PerformanceBridge querying.

The first class of note is the BridgeClient::Query class. This is a query builder. It does not interact with the API until we ask for data. It is simply a way to concatenate strings query strings and capturing any values we wish to pass along as part of a prepared statement. BridgeClient::Query's functions are immutable. Each time a method is run on the instance it returns a new instance. As such any return from a select, where, etc method can be saved to a variable and reused elsewhere without the fear of side effects. These functions can also take any number of arguments after the first and will update the first argument (the sql) and any ?'s in it as prepared statement values referencing the extra arguments. .select("id = ? AND mrn = ?",5,"A1234") would result in a SQL statment where id is compared to 5 and mrn is compared to "A1234". To see what the actual SQL you've created looks like simply run .to_s on your instantiated Query object.

The other major feature used above is not actually a part of the Query class. It's built into the PerformanceBridge API's language. "Dot Notation" is a language feature that enables the quick traversal of a normalized relational schema. When starting a field name with . we are telling the API that either we want to disambiguate the field we are about to refer to or that we are planning to join another table. In the above code we refer to .site_classes.patient_types.patient_type. This means we want to join the site_classes table, join the patient_types table onto that, and finally select the patient_type field as part of our query results. The PerformanceBridge API is able to expand this syntax into LEFT JOINs and keep track of the appropriate references in case a table is joined more than once by different keys. It's important to note that this feature of the PerforanceBridge Query language is optional. You can query using traditional SQL joins and get the same results.

When the .first or .list method is called the Query instance makes a call through BridgeClient::API to the select endpoint of the PerforanceBridge API. .first will override any limit already set in the query to 1 and will return that one result. If the result of the query is empty it will return nil instead of an empty list. .list returns a list of hashes representing the key/value pairs of the database rows.

If you wanted to bypass the Query class entirely you can do so by calling the API directly. Here's an example of the exams query using the API directly with and without utilizing dot notation.

# With explicity joins
BridgeClient::API.select("select re.id, re.accession, pt.patient_type, pmrn.mrn, p.name FROM rad_exams re LEFT JOIN patient_mrns pmrn ON pmrn.id = re.patient_mrn_id LEFT JOIN patients p ON p.id = pmrn.patient_id LEFT JOIN site_classes sc ON sc.id = re.site_class_id LEFT JOIN patient_types pt ON pt.id = sc.patient_type_id LEFT JOIN external_system_statuses ess ON ess.id = re.current_status_id LEFT JOIN universal_event_types uet ON uet.id = ess.universal_event_type_id WHERE uet.event_type = 'complete' LIMIT 100 OFFSET 0")

# With dot notation
BridgeClient::API.select("select .id, accession, .site_classes.patient_types.patient_type, .patient_mrns.mrn, .patient_mrns.patients.name FROM rad_exams WHERE .current_status_id.universal_event_types.event_type = 'complete' LIMIT 100 OFFSET 0")

Best Practice - You are not querying the database direclty when using the PerformanceBridge API. As such you could put 'DROP TABLES' into the query and you would get a parsing error returned without any damage being done. As such you are protected from SQL injection attacks. However, it is still good practice to utilize prepared statements.

For more information please see the API reference page and the Ruby Bridge Client page for more details.

Updating controllers and tests

Now that we have a model for our queries we want to incorporate them into our controller:

class ExamsController < ApplicationController

  def completed
    # nil.to_i or string.to_i that doesn't parse = 0
    limit = (params[:limit].to_i > 0 && params[:limit].to_i < 1000) ? params[:limit].to_i : 100
    offset = params[:offset].to_i
    render json: Exam.completed(limit,offset)
  end

  def show
    if params[:id].to_i > 0
      render json: Exam.single_exam(params[:id].to_i)
    else
      render status: 401, json: {error: "bad id given"}
    end
  end

end

We should also update out tests to be more specific to what we're expecting. We do have a problem however. We are relaying on an external service (PerformanceBridge API) for data to validate our controllers are working. Since we are just writing controller tests we will mock our model.

require 'spec_helper'
require 'rails_helper'

RSpec.describe ExamsController, type: :controller do

  before(:all) do
    # Don't mutate this data in the function
    # If you start you should use before(:each) instead
    @data = [{"patient_type"=>"I",
              "name"=>"John",
              "mrn"=>"493",
              "id"=>123,
              "accession"=>"817"}]
  end

  describe "GET exams/completed" do
    it "should provide a list of completed exams" do

      # Mock our query model
      allow(Exam).to receive(:completed)
                           .with(100,0)
                           .and_return(@data)

      get :completed
      expect(JSON.parse(response.body)).to eq(@data)
    end
  end

  describe "GET exams/1" do
    it "should return an exam object" do
      # Mock our query model
      allow(Exam).to receive(:exam)
                           .with(1)
                           .and_return(@data.first)

      get :exam, params: {id: 1}
      expect(JSON.parse(response.body)).to eq(@data.first)
    end
  end

end

Author Placeholder - Describe how to set up a test data set as part of the circle production pipeline etc

That should wrap up our exams service. At this point you could move to a front end tutorial and build out some of the exam features or you can continue to build out the backend.

Notes service

Next we want to provide a way to save notes about a specific exam. To do that we'll need to:

  1. Define a schema to store our notes
  2. Write a model to handle interacting with our schema through the PerformanceBridge API
  3. Write a controller to handle new notes
  4. Update our existing exams queries to include our new notes

Define a schema for notes

PerformanceBridge stores clinical data in a PostgreSQL relational database. PostgreSQL databases have namespaces for data called schema. Queries can only be made in a single database, but queries can traverse multiple schemas. The core clinical data model in Bridge is in the public schema (public is the default schema name in a PostgreSQL database). Applications can have their own schema for storing application-specific information, such as configurations and user preferences. We are going to use this application specific schema to store notes.

We want a table that will store our notes that has a tie back to the exam and the employee creating the note. We're going to use the following schema for our exam notes:

/db_scripts/schema.sql

CREATE SEQUENCE tutorial_notes_id_seq
        START WITH 1
        INCREMENT BY 1
        NO MAXVALUE
        NO MINVALUE
        CACHE 1;

CREATE TABLE tutorial_notes (
        id bigint DEFAULT nextval('tutorial_notes_id_seq'::regclass) NOT NULL,
        employee_id bigint NOT NULL,
        rad_exam_id bigint NOT NULL,
        note text NOT NULL,
        created timestamp with time zone NOT NULL DEFAULT now()
);

ALTER TABLE tutorial_notes
        ADD CONSTRAINT tutorial_notes_id PRIMARY KEY (id);

Best Practice - The columns rad_exam_id and employee_id (both type bigint) are foreign keys to the clinical data in the public schema. DO NOT store patient MRNs, accession numbers, or other clinical information that could substitute for a foreign key. Changes to the clinical data caused by an event like a patient merge would not be reflected in an application copying clinical data, whereas applications using foreign keys will be maintained by the platform.

Installing your schema

At this point, we want to create an application specific Postgres user to install our schema into the database. To do this, create a file called create.sql with the Postgres commands to create a database user for this application with the right permissions:

/db_scripts/create.sql

create user railsdevelopertutorial password '123randompass5768';
create schema AUTHORIZATION railsdevelopertutorial;
revoke all on all tables in schema public from railsdevelopertutorial;
grant usage on schema public to railsdevelopertutorial;
grant select on all tables in schema public to railsdevelopertutorial;
alter user railsdevelopertutorial set search_path to railsdevelopertutorial,public;

Now, we want to create an install script to be able to run our psql commands in create.sql and then install our notes schema we created above.

/db_scripts/install.sh

#!/bin/bash
echo 'Creating schema, user, tables, etc.'
psql -e -f create.sql -U postgres harbinger
export PGPASSWORD=123randompass5768
psql -e -f schema.sql -U railsdevelopertutorial harbinger

Now, you can run the script ./db_scripts/install.sh to create the new Postgres user and install your notes schema.

To add additional schemas, you can add the schema SQL under /db_scripts/migrations/test-migration.sql and make install scripts for them separately:

/db_scripts/test-migration.sh

psql -e -f /migrations/test-migration.sql -U user railsdevelopertutorial

This will apply the schema changes described in test-migration.sql to the database.

Creating a Note model

Now that we have a place to store our notes let's create a model to handle the creation of notes. We'll make this model in app/models/note.rb. We are going to create a class to represent our notes, where an instance of a Note represents a record in the tutorial_notes table. We could do this fairly easily from scratch using the BridgeClient::API functions, however the ruby BridgeClient also provides a small object relational mapping (ORM) library. The library provides some helpful functions for inserts, updates, and deletes. We'll use this by inheriting from BridgeClient::ORM::Base and specifying the table name for our model.

class Note < BridgeClient::ORM::Base
  # specify our table name
  self.from = "tutorial_notes"

end

Note - There are a number of helpful functions that BridgeClient::ORM::Base has built in. These are described in the ruby BridgeClient documentation in greater detail.

That's it for our Note model. We can now create notes using the create class method. We'll do this in our controller, but here is an example you can try out on the rails console:

Note.create({employee_id: 1, rad_exam_id: 1, note: "here is the body of our note"})

Notes route and controller

Now we need to set up an endpoint for creating notes. First let's add a new route to config/routes.rb:

# This is where we will post new notes
post "notes/create", to: "notes#create"
# We will also create a route to get notes by id
get "notes/:id" => "notes#show"

Now let's create our NotesController in app/controllers/notes_controller.rb to handle posts coming from our front end.

Best Practice - In the Ruby on Rails community there is a naming convention for models and controllers that needs to be followed to take advantage of some shortcuts in things like routes. The convention is that when a controller is meant to interact with a single model then the model uses a singular form of the noun and controllers are plural. Following this convention in our case doesn't change our program, but it is keeping with rails coding conventions. So we do it.

class NotesController < ApplicationController

  def create
    note_hash = {employee_id: 1,
                 rad_exam_id: params[:rad_exam_id],
                 note: params[:note]}
    note = Note.create(note_hash)
    render json: note.record # .record returns a hash of field/value pairs
  end

  def show
      note = Note.find(params[:id].to_i)
      render json: note
  end

end

Next lets create a test for our new controller by editing spec/controllers/notes_controller_spec.rb:

Updating exam queries with notes

Now that we have a way of adding notes we want to be sure those notes are included in our exams. We could do this a couple of ways. We could design our interface to only show notes on the exam with interaction and then query a seperate notes service with an exam id to get the list. We could also do a seperate query for each returning exam to get a list of notes and smash them together. The former forces a design decision on our UI to effectively implement the latter, and the latter is not very performant. So lets add the following method to our Exam model to include notes. Everything else should stay the same.

module Exam
  def self.select_with_notes
    json_agg_select = <<-SQL
                    (SELECT json_agg(test.row_to_json)
                      FROM (
                        SELECT row_to_json(note_agg)
                        FROM (
                          SELECT tn.id, tn.note, tn.employee_id, employees.name AS employee_name
                          FROM tutorial_notes tn
                          LEFT JOIN employees ON employees.id = tn.employee_id
                          WHERE tn.rad_exam_id = rad_exams.id
                        ) AS note_agg
                      ) AS test
                    ) AS notes
                    SQL

    self.base_select.select(json_agg_select)
  end

The results are not typical of a relational database, but are exactly what we want from an API. Instead of doing a join on the base table we're doing a subselect that uses some helpful functions for aggregating table information into a list of JSON objects. If we had done a traditional join here we would end up with our exam information duplicated for every note because there are many notes for each exam.

Roadmap - As described above this is an effective and performant way to get all the data in an easily consumable format for front end frameworks. However, the syntax is confusing. The syntax is built for maximum flexibility, but as this is a very common operation for applications that create their own data we are looking into simplifying the syntax for the common cases.

Our tests, due to the API mocking, still function the same. But if we want to be sure we're pushing the same data through our controllers we should update them in spec/controllers/exams_controller_spec.rb.

#...
  before(:all) do
    # Don't mutate this data in the function
    # If you start you should use before(:each) instead
    @data = [{"patient_type"=>"I",
              "name"=>"John",
              "mrn"=>"493",
              "id"=>123,
              "accession"=>"817",
              "notes"=>[{"employee_id"=>2,
                         "employee_name"=>"Bob",
                         "id"=>1,
                         "note"=>"my notes"}]}]
  end
#...

Securing services

Part of the PerformanceBridge platform is single sign on (SSO). Any and all instances where protected health information (PHI) is sent needs to be audited. The PerformanceBridge API provides a service for these audit logs. We need to secure our services. We can do that by ensuring the people must login and are authorized for the particular service.

Authentication is handled via a SSO system that is integrated with customer identity providers. The login page is handled by the SSO. As an application we need to use the authentication services to validate a user has a valid token. However, the harbinger-rails-extensions gem provides functions that do this for us and fit nicely into the rails filtering system. To perform authentication only we'll use the authenticate method as a before_filter in our controller.

class ExamsController < ApplicationController

  before_filter :authenticate

  #... all the method definitions we've already built

end

Now if we direct our browser to the any of our exams end points we are redirected to the login page for the PerformanceBridge SSO. After we log in we are redirected back to the service that originally redirected us.

Troubleshooting - Single sign on systems use fully qualified domain names as part of ensuring secure login. As such you will need to use a fully qualified domain name that has the same top level domain as the PerformanceBridge server. In development this is usually accomplished using host file entries.

Authentication is required before we start showing any PHI to users. However, because we are using the customers identity provider there are likely a lot of users that can log in, but shouldn't have access to the whole application or a given set of features. We may also wish to show a different view of the application depending on what kind of user is logging in. To do this PerformanceBridge provides roles mapped to employee records in the system. These roles are a consistent lexicon across sites and the mappings are managed in service tools. To ensure a user is in a given role we can use the authorize endpoint. This can be done without authenticating that user. However, for most cases we will want to both authenticate and authorize a user. There are endpoints to do this at the same time. We'll use controller methods provided by harbinger-rails-extensions to take care of this in our application.

class ExamsController < ApplicationController

  before_filter :all_auth

  def all_auth
    authenticate_and_authorize(["radiologist","attending"])
  end

  #... all the method definitions we've already built

end

Now our service is only accessible by someone that is in both the radiologist and attending roles. It's possible to combine these roles with different boolean logic via sending a different data structure to the API that's detailed in the API documentation.

Roadmap - Many applications today build abstractions for these role combinations to make their application logic easier to understand/manage. This takes the form of function names or even as configurable mappings to application defined roles that are managed by the administrative users. As a result of this pattern emerging in the future PerformanceBridge will centralize role based management into serice tools. Applications will then publish their own roles and those roles can be mapped within service tools to clinical roles (like today) or to individual users.

Audit logging

Now that we have secured our service we want to log any PHI being shown to the users. This kind of audit logging is a requirement of many healthcare compliance bodies such as HIPAA. PerformanceBridge provides a centralized auditing service and an interface to query it should the need arise. This way the applications do not have to manage the storage or display of audit information.

We're going to submit audit information about each exam being shown to the user. To do this we need to capture:

  • table_name - The table for which the ids (primary keys) belong. This represents that granularity at which we're auditing. Valid tables include rad_exams, orders, visits, patient_mrns, and claims.
  • requesting_info - This is the url + query string associated with the audit. In the case of this application on example would be "http://ourserver.com/exams/completed".
  • requesting_ip - This is the IP address of the client making the request to our service. This is not the IP address of the server.
  • employee_id - (option 1) As part of the return from the authentication and authorization PerformanceBridge API endpoints is an employee_id. We use that employee ID to associated records we are showing in the application with the user seeing them.
  • user_login and user_domain - (option 2) The API can determine the employee_id based on the user's login and the user's domain (available in session[:username] and session[:domain]) once a user is logged in via the harbinger-rails-extensions methods.

Let's edit our exams controller to add auditing.

Note - Within your controller you can access the session via the session instance method and the request via the request object.

class ExamsController < ApplicationController

  before_filter :auth_all

  def auth_all
    authenticate_and_authorize(["radiologist","attending"])
  end

  def completed
    # nil.to_i or string.to_i that doesn't parse = 0
    limit = (params[:limit].to_i > 0 && params[:limit].to_i < 1000) ? params[:limit].to_i : 100
    offset = params[:offset].to_i
    results = Exam.completed(limit,offset)
    ids = results.map {|exam| exam[:id] }
    BridgeClient::API.hipaa_log(request.original_fullpath,
                                request.remote_ip,
                                session[:username],
                                session[:domain] || 'not set in session',
                                "rad_exams",
                                ids)
    render json: results
  end

  def exam
    if params[:id].to_i > 0
      BridgeClient::API.hipaa_log(request.original_fullpath,
                                request.remote_ip,
                                session[:username],
                                session[:domain] || 'not set in session',
                                "rad_exams",
                                params[:id].to_i)
      render json: Exam.show(params[:id].to_i)
    else
      render status: 401, json: {error: "bad id given"}
    end
  end

end

Usage logging

For logging application usage statistics, add the following line to your application_contoller:

  after_filter :log_usage_data

The log_usage_data method will take care of capturing and logging the necessary information. You can view the application usage data within the Service Tools application.