Aurelia and Ruby On Rails

Introduction

Aurelia is a convention-based front-end JavaScript framework.
Ruby on Rails is a convention-based Web Application framework.

It's like pairing a medium-rare Filet Mignon with a bold Cabernet Sauvignon. It is a perfect match.

I'm going to explore a single vertical slice using Aurelia and Rails 5 below. This is not an exhaustive post on using the two technologies, just a dive in to how everything works together and how powerful the stack can be for a developer.

Server-side Models

In the beginning there were model representations of our data. In RoR when building out an application and generating the models we create a simple .rb file. I'm using a Mongoid example here -

class User  
  include Mongoid::Document
  include Mongoid::Timestamps

  field :first_name, type: String
  field :last_name, type: String
  field :email, type: String

  has_many :roles
end  

The API Layer

By convention a users table will be created based on our model name. When we want to expose the data we can create a simple user controller -

class UsersController < ApplicationController  
  before_action :set_user, only: [:show, :edit, :update, :destroy]

  def index
    @users = User.all
  end

  def show
  end

  def create
    @user = User.new(user_params)
      if @user.save
        render json: { user: @user }
      else
        render json: @user.errors, status: :unprocessable_entity
      end
    end
  end

  def update
    if @user.update(user_params)
      render json: { user: @user }
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @user.destroy
    render { head :no_content }
  end

  private
    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params.require(:user).permit(:first_name, :last_name, :email)
    end
end  

This is a standard rails controller. You can see we are exposing our data via JSON. What's interesting here is the only thing I really need to update is the user_params when I create a new model. Everything else is conventional and therefore we can simply rename User => Role and user => role when we want to make a roles controller.

In Rails 5 we can use jBuilder to both restrict what our API returns as well as camelCase our properties.

Consuming the API (Service Layer)

Next we want our Aurelia app to be able to consume the data from our Rails API. We can do this by adding a service layer to our Aurelia app. The only thing specific to Aurelia here is the HttpClient, everything else is just pure JavaScript -

import {HttpClient} from 'aurelia-http-client';  
import {User} from '../models/user';

export class UsersService {  
  httpClient;

  static inject = [HttpClient];
  constructor(httpClient) {
    this.httpClient = httpClient;
  }
  getById(id) {
    return this.httpClient.get(`users/${id}`).then(result => {
      return new User(result);
    });
  }
  getAll() {
    return this.httpClient.get('users').then(result => {
      return result.map(item => {
        return new User(item);
      });
    });
  }
  create(user) {
    return this.httpClient.post('users.json', user).then(result => {
      let newUser = new User(result.user);
      Object.assign(user, newUser);
      return user;
    });
  }
  save(user) {
    return this.httpClient.patch(`users/${user.id}.json`, user).then(result => {
      return Object.assign(user, result);
    });
  }
}

You can see we have all of the main operations we need - getAll, getById, create, and save. Adding delete should be trivial. Again when we want to create a service for another model (such as role) it's as simple as replacing User with Role and user with role.

You can see we are importing a User class and serializing our JSON result from an anonymous object to a typed class. For this we want to have a corresponding client-side model.

The Client-side Model

The model is the base building block of our client application. It defines the foundation for how we refer to our users everywhere we use them. It promotes testability, re-usability, and ensures our objects are constructed the way we want them to be, always.

export class User {  
  firstName = '';
  lastName = '';
  email = '';
  roles = [];

  constructor(data) {
    Object.assign(this, data);
  }
  @computedFrom('firstName', 'lastName')
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
  addRole(role) {
    this.roles.push(role);
  }
}

Here in our client-side model you can see we have a fullName property. Anywhere we need to display the users' full name we now have access to a common way of displaying it. We also have an addRole method which doesn't do much but anywhere we need to add a role we now have a common method for doing it. DRYing up the code improves testability.

The Presentation Layer

Finally we need to show our data. In our Aurelia view-model all we need to do is call our service and set the local value -

import {User} from './model/user';  
import {UsersService} from './services/users';

export class Users {  
  users = [];

  static inject = [UsersService];
  constructor(usersService) {
    this.usersService = usersService;
  }
  activate() {
    this.usersService.getAll().then(result => {
      this.users.splice(0, this.users.length, ...result);
    });
  }  
  addUser() {
    this.users.push(new User());
  }
}

You can see when we get our result we have a nice clean way of replacing the array without losing a reference to the original. We take the result of our services' call and splice them in to our array.

Our markup is a simple example as well -

<template>  
  <h2>Users</h2>
  <ul>
    <li repeat.for="user of users">
      ${user.fullName}
    </li>
  </ul>
  <button click.trigger="addUser()">Add user</button>
</template>  

Conclusion

What's so neat is we've barely written anything but a bare minimum of boilerplate code to create a vertical slice in our application. Our code isn't littered with framework configuration because we don't need it yet. Once we get to the point where we do, configuration takes over for convention.

When we want to scaffold out a new slice or implement a set of CRUD operations for a new type of entity we can basically copy over our boilerplate and replace the type and it just works.

I can't explain enough how powerful this is. We don't have to worry spending a bunch of time getting the plumbing in place, we can focus on adding features instead.

Feedback welcome below in the comments!