Implementing and consuming APIs is very error-prone. I've now worked on multiple single page applications written in Elm and I've grown to love the constraint of having to explicitly define JSON decoders. They are very helpful in exposing bugs and errors in documentation.

In this post, I describe a strategy I've adopted to catch compatibility mistakes as early as possible. It involves JSON Schema in automated tests for both the backend and frontend. JSON Schema is not a silver bullet, but it has been useful in my personal experience and is worth the trouble to gain some extra confidence in a program.

The main ideas behind the approach are:

the API producer and consumers should always comply with the same schema version maintaining and sharing the schema and validating against the schema should be very convenient, even automated if possible, otherwise you will stop doing it. tests should fail when either side breaks the contract

This post uses Elm 0.18 and Ruby on Rails, but it may be relevant to future Elm versions and certainly applies to other backend programming languages and ecosystems.

Describing JSON schemas in Elm

json-elm-schema is an Elm library for defining JSON schemas. For example:

module Schema.Profile exposing ( schema )



import Json.Encode as Encode

import JsonSchema exposing ( .. )





schema : Schema

schema =

object

[ title "User profile"

, properties

[ required "name" <| string [ ]

, optional "age" <|

integer

[ description "Age in years"

, minimum 0

]

]

, examples Encode.object

[ [ ( "name" , Encode.string "Jane Doe" )

, ( "age" , Encode.int 42 )

]

, [ ( "name" , Encode.string "John Smith" )

, ( "age" , Encode.int 25 )

]

]

]

This package helps:

prevent schema authoring mistakes

validate JSON structures against a schema

generate JSON structures that correspond to a schema

convert (in both directions) between a schema written in Elm like above and a normal JSON Schema

It is going to be the foundation of the entire approach.

Easy fuzz tests for JSON decoders

Say we have a function allowing us to decode the API response described above into an Elm record.

module Profile exposing ( Profile , decoder )



import Json.Decode as Decode exposing ( Decoder )





type alias Profile =

{ name : String

, age : Int

}





decoder : Decoder Profile

decoder =

Decode.map2

Profile

( Decode.field "name" Decode.string )

( Decode.field "age" Decode.int )

One way to test the decoder is to give it some example input and assert that we get correctly decoded data back.

decoderTest =

test "profile decoder" <|

\_ ->

Json.Decode.decodeString Profile.decoder

"""

{ "name": "Jane Doe", "age": 42 }

"""

|> Expect.equal

{ name = "Jane Doe" , age = 42 }

Instead of writing examples by hand, we can generate a large number of random JSON documents that conform to the schema we wrote before and make sure that we can decode all of them. The JsonSchema.Fuzz module provides just what we need.

module ProfileTest exposing ( suite )



import Test exposing ( .. )

import JsonSchema.Fuzz exposing ( schemaValue )

import Schema.Profile

import Profile

import Json.Decode exposing ( decodeValue )

import Helpers exposing ( expectOk )





suite : Test

suite =

describe "Profile"

[ fuzz

( schemaValue Schema.Profile.schema )

"decoder complies with the profile JSON schema"

<|

\ value ->

decodeValue Profile.decoder value

|> expectOk

]

(Read more about fuzzers and fuzz tests in the elm-test documentation.)

expectOk is a custom expectation which takes a Result (which decodeValue returns) and only succeeds if that result is not an error.

module Helpers exposing ( .. )



import Expect exposing ( Expectation )





expectOk : Result String value -> Expect.Expectation

expectOk val =

case val of

Ok _ ->

Expect.pass



Err err ->

Expect.fail err

I use this helper because JSON values are randomly generated and we do not know anything about their contents except whether they are valid according to the schema. Some decoders may still benefit from example-based tests where we do make assertions about the concrete output values.

I have not yet needed JSON encoders on the Elm side, but json-elm-schema also has a validator module which can help test them.

Sharing the JSON schemas with Ruby

Next, we want to make the schemas available to the Ruby code. Elm cannot do file system operations, but the elm-json-schema-compiler npm module allows you to compile the schemas defined in Elm code to regular JSON schema files. Unfortunately, I could not use it directly since I wanted some extra flexibility. Namely, I want to generate all the schemas in one go and keep the Elm json-elm-schema package as a test dependency only.

I modified the command line tool as shown below. There are two parts: an Elm program that encodes the schemas and sends them through a port and a Node.js program that compiles and runs the Elm program, saving the JSON strings to files as they come in through the port.

port module Main exposing ( .. )



import JsonSchema exposing ( Schema )

import JsonSchema.Encoder exposing ( encode )

import Schema.Profile





type alias NamedSchema =

( String , Schema )





schemas : List NamedSchema

schemas =

[ ( "profile" , Schema.Profile.schema ) ]





emitSchemas : List NamedSchema -> Cmd ( )

emitSchemas namedSchemas =

namedSchemas

|> List.map ( encodeNamedSchema >> emit )

|> Cmd.batch





encodeNamedSchema : NamedSchema -> ( String , String )

encodeNamedSchema ( title , schema ) =

( title , encode schema )





main : Platform.Program Never ( ) ( )

main =

Platform.program

{ init = ( ( ) , emitSchemas schemas )

, update = \_ _ -> ( ( ) , Cmd.none )

, subscriptions = \_ -> Sub.none

}









port emit : ( String , String ) -> Cmd a

The important part for everyday use is the schemas function where we declare the file name for each schema (the node script takes care of adding the .json suffixes). In this example we only have one schema:

[ ( "profile" , Schema.Profile.schema ) ]

The program which does all the input/output work takes two arguments: the path to the Elm file presented above and an output directory for the generated schemas.

# ! / usr / bin / env node



if ( process . argv . length < 4 ) {

fail ( ` Generate JSON schema files using json-elm-schema

Usage:

bin/generate-schemas <elm-schema-file> <output-directory>

` )

}



const fs = require ( 'fs' )



const path = require ( 'path' )

const temp = require ( 'temp' ) . track ( )

const compiler = require ( 'node-elm-compiler' )



const sourcePath = path . resolve ( process . argv [ 2 ] )

const prefixPath = path . resolve ( process . argv [ 3 ] )



const targetPath = temp . path ( { suffix : '.js' } )



compiler . compileSync ( sourcePath , {

yes : true ,

output : targetPath ,

cwd : 'tests' ,

} )



const Elm = require ( targetPath )

const app = Elm . Main . worker ( )



app . ports . emit . subscribe ( function ( payload ) {

const title = payload [ 0 ]

const json = payload [ 1 ]

const fileName = title + '.json'

const schemaPath = path . join ( prefixPath , fileName )



fs . writeFile ( schemaPath , json , ( err ) => {

if ( err ) throw err



console . log ( 'Wrote schema for "' +

title + '" to ' + schemaPath )

} )

} )



function fail ( msg ) {

process . stderr . write ( msg )

process . exit ( 1 )

}

In hindsight, there is probably a way to solve using the original npm package and a bash script, but on the upside I learned how to run "headless" Elm programs and carry out side-effects for them.

Schema validation in Ruby tests

Thanks to the json_schema gem we can verify that the API server complies with the schema. Generate the schemas into a pre-defined directory such as test/schemas .

$ bin/generate-schemas tests/Schema/Main.elm test/schemas

Don't forget to include this as a build step in your continuous integration pipeline to ensure you are always using the latest schemas.

Define a test helper like so:

require 'json_schema'



module SchemaHelper

def assert_response_schema ( schema_name )

full_path =

Rails . root . join ( 'test' , 'schemas' , schema_name )



schema_data = JSON . parse ( File . read ( full_path ) )

schema = JsonSchema . parse ! ( schema_data )

schema . expand_references !



payload = JSON . parse ( response . body )



schema . validate ! ( payload )

end

end

This loads the specified schema and validates the server response against it. It is meant for use in a controller test.

require 'test_helper'

require 'schema_helper'



class ProfilesControllerTest < ActionDispatch : : IntegrationTest

include SchemaHelper



test 'the output corresponds to the schema' do

get '/profile'



assert_response :success

assert_response_schema 'profile.json'

end

end

You could also modify the helper assertion to explicitly take the JSON structure as an argument, allowing you to unit test your JSON serializers.