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:
|
|
A FeatureCollection is a collection of features:
|
|
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
|
|
Add a single route:
|
|
This generates GET /service_areas mapped to
ServiceAreasController#index.
Writing the service object
Create app/services/serialize_service_areas.rb:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
- ServiceArea records sit in the database with
boundarycolumns (PostGIS geometry, SRID 4326) - The service object’s query selects the relevant columns plus the GeoJSON form of the boundary, computed by PostGIS
- The service builds a GeoJSON FeatureCollection hash
- 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.