Introduction
I prefer to keep the UI code separate from the Ruby on Rails server. The advantages are that the complexity of using Ruby on Rails to serve up assets disappears and also that development work on the front end can be done without knowing anything about ruby. More than one app can use the same API, perhaps an Ionic app for mobile and an Angular app for desktop admin.
Its possible to send raw JSON backwards and forwards, but we quickly get into decisions about pagination, sorting and filtering, and the API itself can become a bit inconsistent and expensive to own and maintain.
JSON-API solves this problem, but you need implementations of this standard in both ruby and javascript. This article gives example code that uses JSON-API to unify the two.
The finished app
To ease the suspense, here are some screenshots of the finished app. It is part of a larger project to help people build templates for checklists that will be completed using another mobile app.
The UI is Angular 2 and Material design. The server is a Rails app that uses an engine containing the API. All the code is available.
The screenshot shows a signature component being dragged into a checklist template called ‘Inspection of a rented house …’.
The template will be used by an Ionic mobile app to collect the information, photos, descriptions and signatures from inspections in the real world. The Ionic app can use the same Rails API.
The API
A Rails engine provides three resources, audit_type, audit_type_components and available_component_types. The app does not have any authentication because its Rails Engine and Angular components are meant to be used in larger projects.
The routes file looks like this.
CheckListEngine::Engine.routes.draw do namespace :api do jsonapi_resources :audit_type_components jsonapi_resources :audit_types do jsonapi_related_resources :audit_type_components end end end
and the routes are
Prefix Verb URI Pattern Controller#Action check_list_engine /check_list_engine CheckListEngine::Engine Routes for CheckListEngine::Engine: api_audit_type_component_relationships_audit_type GET /api/audit_type_components/:audit_type_component_id/relationships/audit_type(.:format) check_list_engine/api/audit_type_components#show_relationship {:relationship=>"audit_type"} PUT|PATCH /api/audit_type_components/:audit_type_component_id/relationships/audit_type(.:format) check_list_engine/api/audit_type_components#update_relationship {:relationship=>"audit_type"} DELETE /api/audit_type_components/:audit_type_component_id/relationships/audit_type(.:format) check_list_engine/api/audit_type_components#destroy_relationship {:relationship=>"audit_type"} api_audit_type_component_audit_type GET /api/audit_type_components/:audit_type_component_id/audit_type(.:format) check_list_engine/api/audit_types#get_related_resource {:relationship=>"audit_type", :source=>"check_list_engine/api/audit_type_components"} api_audit_type_component_relationships_available_component_type GET /api/audit_type_components/:audit_type_component_id/relationships/available_component_type(.:format) check_list_engine/api/audit_type_components#show_relationship {:relationship=>"available_component_type"} PUT|PATCH /api/audit_type_components/:audit_type_component_id/relationships/available_component_type(.:format) check_list_engine/api/audit_type_components#update_relationship {:relationship=>"available_component_type"} DELETE /api/audit_type_components/:audit_type_component_id/relationships/available_component_type(.:format) check_list_engine/api/audit_type_components#destroy_relationship {:relationship=>"available_component_type"} api_audit_type_component_available_component_type GET /api/audit_type_components/:audit_type_component_id/available_component_type(.:format) check_list_engine/api/available_component_types#get_related_resource {:relationship=>"available_component_type", :source=>"check_list_engine/api/audit_type_components"} api_audit_type_components GET /api/audit_type_components(.:format) check_list_engine/api/audit_type_components#index POST /api/audit_type_components(.:format) check_list_engine/api/audit_type_components#create api_audit_type_component GET /api/audit_type_components/:id(.:format) check_list_engine/api/audit_type_components#show PATCH /api/audit_type_components/:id(.:format) check_list_engine/api/audit_type_components#update PUT /api/audit_type_components/:id(.:format) check_list_engine/api/audit_type_components#update DELETE /api/audit_type_components/:id(.:format) check_list_engine/api/audit_type_components#destroy api_available_component_types GET /api/available_component_types(.:format) check_list_engine/api/available_component_types#index POST /api/available_component_types(.:format) check_list_engine/api/available_component_types#create api_available_component_type GET /api/available_component_types/:id(.:format) check_list_engine/api/available_component_types#show PATCH /api/available_component_types/:id(.:format) check_list_engine/api/available_component_types#update PUT /api/available_component_types/:id(.:format) check_list_engine/api/available_component_types#update DELETE /api/available_component_types/:id(.:format) check_list_engine/api/available_component_types#destroy api_audit_type_audit_type_components GET /api/audit_types/:audit_type_id/audit_type_components(.:format) check_list_engine/api/audit_type_components#get_related_resources {:relationship=>"audit_type_components", :source=>"check_list_engine/api/audit_types"} api_audit_types GET /api/audit_types(.:format) check_list_engine/api/audit_types#index POST /api/audit_types(.:format) check_list_engine/api/audit_types#create api_audit_type GET /api/audit_types/:id(.:format) check_list_engine/api/audit_types#show PATCH /api/audit_types/:id(.:format) check_list_engine/api/audit_types#update PUT /api/audit_types/:id(.:format) check_list_engine/api/audit_types#update DELETE /api/audit_types/:id(.:format) check_list_engine/api/audit_types#destroy
Using JSON API on both sides of the API
JSON API is complicated internally, and very pedantic about the structure of data and relationships. So its a bad idea to assemble or parse the json with your own code.
There is a great gem called JSONAPI::Resources to simplify the server code, and a corresponding adaptor for Angular called Angular2 JSON API. Together they provide slick way to move representations of objects from ruby to javascript and back again, without having to delve into the structure of the JSON itself.
Server Side: JSON API Resources
This lovely gem lets you define resources for use by the API. Instead of writing controllers to handle the REST actions, we leave it up to the gem.
The routes.rb file, shown above, defines resources with the jsonapi_resources method which orchestrates which controller to call.
The controllers are greatly simplified because they just need to inherit from JSONAPI::ResourceController.
module CheckListEngine module Api class AuditTypesController < JSONAPI::ResourceController end end end
JSONAPI::Resources::Matchers is a neat gem that provides spec matchers to test the API, although at the time of writing, the tests just check the response status and any objects returned in the data.
JSON API Resources takes a huge amount of complexity out of the hands of the developer, but the error messages can be so vague that you may have to dive into the gem’s code to figure out what is wrong with the call. For instance I ran into a mix up with the use of underscores in the names of routes and keys. The fix was to explicitly set underscores in the initializer.
JSONAPI.configure do |config| config.default_paginator = :paged config.top_level_links_include_pagination = true config.default_page_size = 10 config.maximum_page_size = 20 # Javascript prefers underscore, but hyphen is standard. :underscored_key, :camelized_key, :dasherized_key, or custom config.json_key_format = :underscored_key #:underscored_route, :camelized_route, :dasherized_route, or custom config.route_format = :underscored_route end
Client Side: Angular 2 JSON API
Again, this component lets you abstract the models used by the JSON API. First create a a Datastore service by extending JsonApiDatastore
import { Injectable } from '@angular/core'; import { Http } from '@angular/http'; import { AuditType } from '../models/audit_type.model'; import { AuditTypeComponent } from '../models/audit_type_component.model'; import { AvailableComponentTypes } from '../models/available_component_type.model'; import { JsonApiDatastoreConfig, JsonApiDatastore, DatastoreConfig } from 'angular2-jsonapi'; const config: DatastoreConfig = { /* TODO: put baseUrl in an environment variable */ baseUrl: 'http://localhost:3000/check_list_engine/api/', models: { audit_type: AuditType, audit_type_components: AuditTypeComponent, available_component_types: AvailableComponentTypes } } @Injectable() @JsonApiDatastoreConfig(config) export class Datastore extends JsonApiDatastore { constructor(http: Http) { super(http); } }
Then define models corresponding to the resources on the server side
import { JsonApiModelConfig, JsonApiModel, Attribute, BelongsTo } from 'angular2-jsonapi'; import { AuditType } from './audit_type.model'; @JsonApiModelConfig({ type: 'audit_type_components' }) export class AuditTypeComponent extends JsonApiModel { @Attribute() title: string; @Attribute() help_text: string; @Attribute() choices: string; @Attribute() has_image: boolean; @Attribute() is_mandatory: boolean; @Attribute() position: string; @BelongsTo() audit_type: AuditType; }
In the Angular view controller, you can call the API and wait for the results
import { Component, OnInit } from '@angular/core'; import { JsonApiQueryData } from 'angular2-jsonapi'; import { Datastore } from '../../services/datastore'; import { AuditType } from '../../models/audit_type.model'; @Component({ selector: 'app-audit-type-list', templateUrl: './audit-type-list.component.html', styleUrls: ['./audit-type-list.component.css'] }) export class AuditTypeListComponent implements OnInit { audit_types: any; selectedAuditType: AuditType; constructor(private datastore: Datastore) { } ngOnInit() { this.getAuditTypes(); } onSelect(audit_type: AuditType): void { this.selectedAuditType = audit_type; } getAuditTypes() { this.datastore.findAll(AuditType, { include: 'audit_type_components' }).subscribe( (audit_types: JsonApiQueryData<AuditType>) => { this.audit_types = audit_types.getModels(); } ); } }
In both the ruby and Typescript code, we’re spared from parsing or building the complicated json relationships and lists of associated objects.
On several occasions I needed to look into the Angular 2 JSON API code to figure out why something was not working for me. It was always because I was calling the server with the wrong route name, but this was time consuming to understand.
For the drag and drop I used ng2-dragular, which was fun to use.
Development tools
Another advantage of clearly separating the server and client code is that you can use the right development tool for the job.
I use Rubymine to develop the Rails project and WebStorm for the Angular work. Both of these JetBrains IDEs are really similar to use, but specialised for the languages used.
Angular view code can become verbose, but Angular 2 has a neat way of defining components that can nested.
Material Design proved to be a pain when laying out admin pages. For Desktop apps I think that Bootstrap is easier to use.
The code is on Github
There are three repositories for this project.
- A rails engine to encapsulates the models and controllers
- Material Design / Angular 2 app
- Rails app which includes both of the above
The rails app is just a shell that mounts the engine and includes the UI code in its public directory. (Copy the contents of the dist directory of the UI into the public directory of the Rails app).
The UI assumes that Rails will be started on port 3000, this needs to be stored in an environment variable.
I put the server side models into an engine so that it can used in more than one future project.
Installing and running the demo app
The demo app uses Postgres.
git clone [email protected]:guy-roberts/check_list_full_app.git cd check_list_full_app bundle install rake db:setup Create some seed data rake check_list_engine:create_audit_data rails s
Then navigate to localhost:3000, choose a checklist template and drag the components around. They should be saved to the database.
Rationale for the work
I kept coming across the need for a convenient and cheap way to build checklists that describe how to do a specific task. For instance;
– for an engineer servicing a burglar alarm
– a pest control person visiting a property to remove an infestation
– an estate agent checking on the state of a rental property
Checklists are used to tell the agent where the job is, who it is for, what steps to carry out. The same report can also be used to record the customer’s signature, any materials used and time starting and stopping work.
Although each of these reports are different, they all have the same component parts
- Title
- Address
- Photo
- Text description
- Signature
- Yes / No box answering a question
- Choice / Select box
- Sections
So the admin app provides a way to drag and drop these components into an ordered checklist.
Once a template is ready, it can be used by somebody with another mobile app to carry out the checklist and save the results back to the API. This is done by another app written in Ionic. Authentication is by token, not described here. Hosting is on Heroku.
A basis for other projects
Projects sometimes feel like walks through a forest, if you know what you’re doing then its great, but step off into the under growth and you’re in for a long, scratchy afternoon.
The JSONAPI:Resources gem and the Angular 2 JSON API adapter work together to hide the complexity of JSON API from the server and client code. They provide ways to paginate, sort and filter resources.
Best of all, they free up time for the developer to spend on designing a cool user interface and talk to the users.