Skip to content

Lesson 1 — GeoJSON as Rails data

Module 3 left us with the database. Now it’s time to get the data out of the database and onto the map. This lesson is the first half of that pipeline — turning service area records into GeoJSON that a Rails controller can serve. The second half, where the map consumes the GeoJSON, comes in Lesson 2.

By the end of this lesson, hitting /service_areas.json returns a valid GeoJSON FeatureCollection containing all 340 service areas. You’ll see real spatial data flowing through Rails, with PostGIS doing the geometry conversion server-side and a service object assembling the response.

What GeoJSON is, briefly

GeoJSON is a JSON format for spatial data. We met it in Module 1 and again in Module 3 when looking at PostGIS’s ST_AsGeoJSON function. The format has two key shapes:

A Feature represents a single spatial thing — a polygon, a point, a line — with its associated properties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "type": "Feature",
  "geometry": {
    "type": "Polygon",
    "coordinates": [[[151.0, -33.0], [151.1, -33.0], ...]]
  },
  "properties": {
    "id": 1,
    "name": "Sydney - City and Inner South",
    "population": 87234
  }
}

A FeatureCollection is a collection of features:

1
2
3
4
5
6
7
{
  "type": "FeatureCollection",
  "features": [
    { "type": "Feature", ... },
    { "type": "Feature", ... }
  ]
}

That’s the shape we’ll produce — one FeatureCollection containing all 340 service area features.

GeoJSON is what MapLibre expects to consume directly. Module 3 produced the data; this lesson turns that data into the wire format the map will need.

Service objects in the dispatch deck

Before writing the action, a small architectural decision worth making explicitly. Building a GeoJSON FeatureCollection from ActiveRecord records isn’t the controller’s job — the controller’s job is to handle the HTTP request and render a response. The work of “turn records into GeoJSON” deserves its own object.

The dispatch deck uses a service object pattern for these operations: a module with extend self, exposing a single call method. The module form fits because there’s no instance state to carry between method calls — the service is a stateless transformation. Files live in app/services/ and follow a verb-first naming convention: SerializeServiceAreas, ImportSa3Boundaries, FindNearestTechnicians, and so on.

Future lessons will add more service objects following the same shape. When a service is genuinely stateless (most are), it’ll be a module. The few that need to accumulate state across method calls — an importer that tracks errors and imported counts, say — will be classes instead.

Generating the controller

1
bin/rails generate controller ServiceAreas

Add a single route:

1
2
# config/routes.rb
resources :service_areas, only: [:index]

This generates GET /service_areas mapped to ServiceAreasController#index.

Writing the service object

Create app/services/serialize_service_areas.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
module SerializeServiceAreas
  extend self

  def call
    {
      type:     "FeatureCollection",
      features: build_features
    }
  end

  private

  def build_features
    service_areas.map do |area|
      {
        type:       "Feature",
        id:         area.id,
        geometry:   JSON.parse(area.geojson),
        properties: {
          id:         area.id,
          code:       area.code,
          name:       area.name,
          population: area.population
        }
      }
    end
  end

  def service_areas
    ServiceArea
      .select("id, code, name, population, ST_AsGeoJSON(boundary) AS geojson")
      .order(:name)
  end
end

Three pieces are doing real work.

The query in service_areas. ServiceArea.select(...) builds an ActiveRecord relation that selects the columns we want plus a computed ST_AsGeoJSON(boundary) AS geojson column. Each record returned will have the usual ActiveRecord attributes (id, code, etc.) plus a geojson attribute containing the GeoJSON string for the boundary.

The reason for using ST_AsGeoJSON in the query rather than asking the RGeo geometry object to convert itself is performance. With 340 polygon features, asking RGeo to convert each in Ruby would take a visible amount of time; doing it in the SELECT lets PostGIS do the work in its native C implementation, completing in a few milliseconds. Use PostGIS for spatial format conversion when you can; reach for Ruby spatial libraries only for things PostGIS can’t do.

The mapping in build_features. We iterate over the relation, building one Feature hash per service area. Each feature has a top-level id: (which MapLibre uses for feature-state tracking — hover effects, selection, and other per-feature visual state) plus the geometry and properties. The geometry key gets the parsed GeoJSON (as Ruby data, so Rails can re-serialise it as part of the response — without JSON.parse, the geometry would end up as a quoted string in the output). The properties key gets the columns we want to expose to the map: id again (used for click handlers and popup content, where reading from properties is more natural than from the feature root), code (the SA3 code), name (display label), and population (used for choropleth styling in later modules).

The call method. Builds and returns the FeatureCollection hash. That’s the public API of the service.

The controller

Replace the generated app/controllers/service_areas_controller.rb with:

1
2
3
4
5
class ServiceAreasController < ApplicationController
  def index
    render json: SerializeServiceAreas.call
  end
end

That’s it. The controller’s job is to handle the request and render the response; the actual GeoJSON construction lives in the service object. The line render json: SerializeServiceAreas.call takes the hash returned by the service and lets Rails handle JSON serialisation.

Trying it out

Start the Rails server:

1
bin/rails server

Open http://localhost:3000/service_areas.json in your browser.

You’ll see a single, large JSON document. Modern browsers automatically format JSON for inspection — Firefox shows a collapsible tree by default; Chrome and Safari show the formatted text and can be enhanced with extensions if you want tree views.

Activity 1 — Inspect the response in your browser

Open http://localhost:3000/service_areas.json and look at:

The top-level structure. You should see the response begin with:

1
2
3
4
{
  "type": "FeatureCollection",
  "features": [ ... ]
}

The features array should have 340 entries. (Browsers vary on how they let you see the array length — Firefox’s JSON viewer shows it directly; in Chrome you can search for “Feature” and count, or use Developer Tools.)

The first feature. Expand features[0]. You should see:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "type": "Feature",
  "id: ...,
  "geometry": { "type": "MultiPolygon", "coordinates": [...] },
  "properties": {
    "id": ...,
    "code": "...",
    "name": "...",
    "population": ...
  }
}

The geometry.type is "MultiPolygon" — all ABS SA3 boundaries are multipolygons (we discussed this in Module 3 Lesson 4 — some SA3s have multiple disconnected pieces, like coastal regions with offshore islands).

The properties should show real ABS data — a sensible SA3 name (probably starting with “A” since we sorted alphabetically), a five-character SA3 code, and a synthetic population somewhere between 30,000 and 130,000.

Here’s a sample from a browser:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": 335,
      "geometry": {
        "type": "MultiPolygon",
        "coordinates": [
          [
            [
              [138.587921943, -34.899198507],
              [138.576994503, -34.909169486],
              [138.580058892, -34.924470017],
              [138.582884183, -34.942384497],
              [138.615674393, -34.940568776],
              [138.624476773, -34.940106906],
              [138.623564162, -34.927903436],
              [138.615718343, -34.922590477],
              [138.612825893, -34.909504687],
              [138.612620513, -34.905285166],
              [138.609936003, -34.902173876],
              [138.597328802, -34.898616896],
              [138.587921943, -34.899198507]
            ]
          ]
        ]
      },
      "properties": {
        "id": 335,
        "code": "40101",
        "name": "Adelaide City",
        "population": 99000
      }
    },

The response size. Open your browser’s Developer Tools (F12 or right-click → Inspect), go to the Network tab, refresh the page, and click on the service_areas.json request. The size should be around 2-6 MB depending on the level of simplification used during data preparation.

This is the whole national set of SA3 boundaries flowing through one HTTP response. Browsers handle this fine for inspection purposes; we’ll see in later lessons that real users won’t typically need all 340 polygons, and the controller will gain scoping when authentication arrives.

A note on response size

Six megabytes is a lot of JSON for a single response. For the dispatch deck’s actual use cases — field staff seeing only their assigned service areas, dispatchers seeing one state’s worth — we’ll never serve all 340 polygons at once. The all-340 response we’re producing now is for our own sanity during development, where “show me everything” is the easiest thing to verify against.

When we get to roles and authorisation in later modules, this controller action will gain scoping. A dispatcher will hit the same endpoint and get back only their state’s ~90 polygons. A field officer will get back only their assigned areas. The shape of the controller stays identical; the change happens inside the service object — service_areas gains a where(...) clause based on the current user’s role and assignment.

That kind of change is exactly why the work belongs in a service object rather than the controller. The controller stays unchanged through every iteration of authorisation and scoping; the service object absorbs the real evolution of what “service areas relevant to this request” means.

Where this leaves us

You now have a working endpoint serving real spatial data, backed by a service object that handles the actual conversion. The flow:

  1. ServiceArea records sit in the database with boundary columns (PostGIS geometry, SRID 4326)
  2. The service object’s query selects the relevant columns plus the GeoJSON form of the boundary, computed by PostGIS
  3. The service builds a GeoJSON FeatureCollection hash
  4. The controller renders it as JSON

The next lesson takes this endpoint and wires it up to a map. The Lab page’s Vera::Map will gain a GeoJSON source pointing at /service_areas.json and a layer rendering the polygons. Real Australian boundaries will appear on the screen for the first time.