No Rush

Posted on Oct 15, 2024

Adventures in Ruter API land 🚆🚌:

A no-BS train & bus timetable website, tailored for my family.

Code at https://github.com/narvalotech/no-rush

---- Blommenholm -> work ----
16:21 [rail] line L1 to Oslo S
16:51 [rail] line L1 to Oslo S
17:21 [rail] line L1 to Oslo S
17:51 [rail] line L1 to Oslo S

---- Blommenholm -> sandvika ----
15:59 [rail] line L1 to Asker
16:28 [rail] line L1 to Asker
16:58 [rail] line L1 to Asker
17:28 [rail] line L1 to Asker

---- Nationaltheatret ----
16:02 [metro] line 5 to Vestli
16:13 [rail] line L1 to Asker
16:16 [metro] line 5 to Vestli
16:30 [metro] line 5 to Vestli
16:43 [rail] line L1 to Asker
16:44 [metro] line 5 to Vestli
16:59 [metro] line 5 to Vestli

---- Hasle ----
16:02 [metro] line 5 to Ringen via Tøyen
16:16 [metro] line 5 to Ringen via Tøyen
16:31 [metro] line 5 to Ringen via Tøyen
16:46 [metro] line 5 to Ringen via Tøyen
17:01 [metro] line 5 to Ringen via Tøyen
17:16 [metro] line 5 to Ringen via Tøyen
17:31 [metro] line 5 to Ringen via Tøyen
17:46 [metro] line 5 to Ringen via Tøyen

Outline

Why not use the app

I had switched jobs in october, and found myself commuting a bunch. I have a train->subway transfer, and did not want to memorize the timetables for both to know whether I should run or not when getting off the train.

We live in Oslo, and Ruter handles public transport here. They have an app and website that they somewhat recently redesigned to use humoungous amounts of whitespace (as is the design trend lately).

This means that I had to every single time:

  • tap to open the app
  • wait for it to load a map that I don’t use
  • scroll down to the favorites section
  • tap the train/metro, for the correct direction
  • finally read the departure time

Now that app is fine if you are exploring the transit system and don’t really know where you’re going or what bus/metro/train you’re going to take. But it is really frustrating if you know exactly what you want.

Also, I needed an excuse to play with the transit API 😄

The API

EnTur is nice enough to maintain an open API.

The displays at the stations are just running google chrome in kiosk mode, showing a javascript front-end that uses this very API. I mean it certainly looks like they use the API, but not 100% sure.

Ruter also is nice enough to allow anyone to build their own station display, at this address. You can customize it with a combination of trains, busses, trams, you name it.

Example of display

So, back to the API:

The current version is hosted at api.entur.io/journey-planner/v3/GraphQL and is of the GraphQL variety. They also have a query builder that serves as up-to-date documentation for all the fields.

There are some other documentation pages, but I’ve found playing around the query builder to be the fastest way to grok the API.

There are two main APIs (actually more like three):

  • point-to-point journey planner
  • transit stop information
  • transit stop search

The first two are co-located at the same “journey planner v3” endpoint.

We already have a few routes we know, and are interested in the departure times. So that’s a combination of the stop search API (in order to get the IDs of the stops) and the stop information API to get the departure times.

An API call to get the departures for a given ID looks like this:

query ($id: String!) {
  stopPlace(
    id: $id
  ) {
    name
    id
    estimatedCalls {
      expectedDepartureTime
      actualDepartureTime
      destinationDisplay {
        frontText
      }
      serviceJourney {
        line {
          publicCode
          transportMode
        } } } } }

That returns json with data for each departure.

{
  "data": {
    "stopPlace": {
      "name": "Oslo S",
      "id": "NSR:StopPlace:337",
      "estimatedCalls": [
        {
          "expectedDepartureTime": "2025-04-21T00:16:00+02:00",
          "actualDepartureTime": "2025-04-21T00:16:00+02:00",
          "destinationDisplay": {
            "frontText": "Ski"
          },
          "serviceJourney": {
            "line": {
              "publicCode": "L2",
              "transportMode": "rail"
            }
          }
        },
        {
          "expectedDepartureTime": "2025-04-21T00:17:00+02:00",
          "actualDepartureTime": "2025-04-21T00:17:00+02:00",
          "destinationDisplay": {
            "frontText": "Stabekk"
          },
          "serviceJourney": {
            "line": {
              "publicCode": "L2",
              "transportMode": "rail"
            }
          }
        },

So that’s the API. Now let’s figure out how to talk to it.

Implementation choices

I discovered common lisp a few years ago and the development experience (ie “conversing” with the program) has really grown on me. This strangeloop talk touches on some similar points about “dead” programs.

Soooo, this whole thing could’ve been 1-200 lines of python with some appropriate libs. But I’m an idiot and wanted to use lisp “in production”.

Writing GraphQL without writing GraphQL

Feel free to skip this section if you have parenthesisitis.

At first, I’m thinking I’m reading JSON. Turns out GraphQL looks like JSON from a distance, but is not.

Since I would be building queries from lisp, the natural format is an s-expression.

Looking a bit at the structure of the query above, a pattern emerges: the GraphQL string is a list (or tree) of elements that each have:

  • a name
  • a list of optional parameters
  • a list of children elements

Let’s make a cursed s-exp -> graphql converter:

(defun args->str (arglist)
  (if arglist
      (format nil "(~{~A~^, ~})"
              (mapcar (lambda (arg)
                        (format nil "~A: ~A" (car arg) (cdr arg))) arglist))
      ""))
      
(defun sexp->gql (sx stream)
  (let* ((el (car sx))
         (name (nth 0 el))
         (params (nth 1 el))
         (children (nth 2 el)))

    (format stream " ~A" name)

    (when params
      (format stream (args->str params)))

    (when children
      (format stream " {")
      (sexp->gql children stream)
      (format stream " }"))

    (when (> (length sx) 1)
      (sexp->gql (cdr sx) stream))))

With a simple example:

  • “a”, “b” and “c” are the top-level elements
  • “c” has two parameters: “p1” and “p2”
  • “c” has two child elements, “c1” and “c2”
(sexp->gql
 '(("a")
   ("b")
   ("c"
    (("p1" . 10)
     ("p2" . 1000))
    (("c1")
     ("c2"))))
 *standard-output*)

Translates to

a b c(p1: 10, p2: 1000) { c1 c2 }

Sure it’s a lot more to type and looks uglier for now, but at least I can use CL’s quote-templating. It’s a work in progress okay!

Building the query

Now that we have a way to build a query, we need to use a template that has the fields we’re interested in.

We want: the departure time for sure, the name/number of the line and its direction.

The main parameter is the stop ID (which we’ll get to in a minute). Some optional params are the number of responses and how far in the future to query.

(defun gql-departures (nsr-id &key (max 5) (seconds (* 2 60 60)))
  (let ((quoted-id (format nil "\"~A\"" nsr-id)))
    `(("stopPlace" (("id" . ,quoted-id))
       (("name")
        ("id")
        ("estimatedCalls" (("numberOfDepartures" . ,max)
                           ("timeRange" . ,seconds))
                          (("expectedDepartureTime")
                           ;; ("actualDepartureTime") ; always empty it seems
                           ("destinationDisplay" ()
                                                 (("frontText")))
                           ("serviceJourney" ()
                                             (("line" ()
                                                      (("publicCode")
                                                       ("transportMode")))))))
        )))))

With default params and stop ID “NSR:StopPlace:59651”

(make-gql (gql-departures "NSR:StopPlace:59651"))

Expands to:

query {
  stopPlace(id: "NSR:StopPlace:59651") {
    name
    id
    estimatedCalls(numberOfDepartures: 5, timeRange: 7200) {
      expectedDepartureTime
      destinationDisplay {
        frontText
      }
      serviceJourney {
        line {
          publicCode
          transportMode
        }}}}}

Remember how I said that GraphQL is not JSON? Well, once the query is built, we still have to wrap it in a JSON payload for the API. The schema is simple if I may say so: there is a single key, “query” that has the GraphQL payload as value.

(defun package-gql (gql-str)
  "Package a GraphQL query string into a json object ready for sending."
  (json:encode-json-alist-to-string (list (cons "query" gql-str))))

(defun make-gql-json (params)
  (package-gql (make-gql params)))
  
(make-gql-json (gql-departures "NSR:StopPlace:59651"))

Which gives us the final API payload:

{"query":"query { stopPlace(id: \"NSR:StopPlace:59651\") { name id estimatedCalls(numberOfDepartures: 5, timeRange: 7200) { expectedDepartureTime destinationDisplay { frontText } serviceJourney { line { publicCode transportMode } } } } }"}

Now we need to parse the JSON response..

Extracting data from the response

Parsing

I use cl-json to parse the response into an assoc-list.

(defun send-query (query-json-str)
  (let* ((data query-json-str)
         (url "https://api.entur.io/journey-planner/v3/GraphQL"))
    (format t "Send query: ~A~%" data)

    (json:decode-json-from-string
     (octets-to-string
      (drakma:http-request url
                           :method :post
                           :content-type "application/json"
                           :accept "application/json"
                           :connection-timeout 2
                           :content data)))))

Here’s how (parts of) a parsed query looks like for skøyen station:

((:DATA
  (:STOP-PLACE (:NAME . "Skøyen stasjon") (:ID . "NSR:StopPlace:59651")
   (:ESTIMATED-CALLS
    ((:EXPECTED-DEPARTURE-TIME . "2024-10-06T23:01:04+02:00")
     (:ACTUAL-DEPARTURE-TIME) (:DESTINATION-DISPLAY (:FRONT-TEXT . "Skøyen"))
     (:SERVICE-JOURNEY
      (:LINE (:PUBLIC-CODE . "130") (:TRANSPORT-MODE . "bus"))))
    ((:EXPECTED-DEPARTURE-TIME . "2024-10-06T23:02:10+02:00")
     (:ACTUAL-DEPARTURE-TIME)
     (:DESTINATION-DISPLAY (:FRONT-TEXT . "Lillestrøm"))
     (:SERVICE-JOURNEY
      (:LINE (:PUBLIC-CODE . "L1") (:TRANSPORT-MODE . "rail"))))
    ((:EXPECTED-DEPARTURE-TIME . "2024-10-06T23:04:00+02:00")
     (:ACTUAL-DEPARTURE-TIME) (:DESTINATION-DISPLAY (:FRONT-TEXT . "Dal"))
     (:SERVICE-JOURNEY
      (:LINE (:PUBLIC-CODE . "R13") (:TRANSPORT-MODE . "rail"))))))))

Slicing and dicing

One thing I really like is that the Common Lisp REPL allows me to inspect any object, all the way down to hitting the bytes backing the object in memory. It’s just like having a debugger’s “local variables” window open, but without having to hit pause.

When exploring a data format, this allows me to save the whole response into a variable. Then I can take a subset of that data, and start writing the functions without having to hit the API again and again. If you read the source (it’s only 500L), you’ll see traces of this workflow.

I know it’s possible to do in e.g. python, but I find it to have less friction in the CL + Emacs environment.

Okay, okay, it’s also easy in javascript. But we don’t talk about javascript. Damn they have some good dev tools.

With lisp proselytism out of the way (λ > 🦀), let’s go back to work: we have some departure times but we are not interested in all of them.

We want to build a focused time table, grouped by line+destination combo. Let’s build some functions to filter that response:

(defun is-transport-mode (modes element)
  "Tests if the element's :TRANSPORT-MODE matches one of the MODES"
  (let ((result nil)
        (el-mode (extract-type element)))
    (dolist (test-mode modes result)
      (setf result (or result (equalp test-mode el-mode))))))

(defun filter-by-type (transport-types departures)
  (if (not transport-types) departures
      (remove-if-not
       (lambda (el) (is-transport-mode transport-types el))
       departures)))

Here we start with a list of departures (ie. the API response) and a list of transport-types. The types can be e.g., metro, bus, rail, ferry, etc..

FILTER-BY-TYPE (yes, CL functions are refered to in YELL-CASE) tests every departure “object”, and removes that element from the list if it doesn’t match one of the given transport modes.

IS-TRANSPORT-MODE extracts the mode (bus, rail, etc) from the departure object and tests if any transport mode given in the mode parameter matches.

Filtering for line name/number and destination works exactly the same (I should refactor at some point). We can then combine all of them:

(defun filter-departures (departures
                          &key types destinations lines)
  (filter-by-line
   lines (filter-by-destination
          destinations (filter-by-type types departures))))

Getting the stop ID

The stop ID is fetched from a so-called “geocoder” API endpoint. This one is not GraphQL. As the name suggests, it is intended to be used by the app while the user is typing in the name of the stop.

You can try it out in your browser by clicking here:

https://api.entur.io/geocoder/v1/autocomplete?text=skoyen%20stasjon&lang=en

We build this function to hit the API

(defun send-geocoder-query (name)
  "Send a Entur geocoder API to retrieve stops matching NAME"
  (let* ((url "https://api.entur.io/geocoder/v1/autocomplete"))
    (format t "Send geocoder query: ~A~%" name)

    (json:decode-json-from-string
     (octets-to-string
      (drakma:http-request url
                           :method :get
                           :parameters (list (cons "text" name)
                                             (cons "lang" "en"))
                           :connection-timeout 2)))))

And some helpers to convert the JSON response back into, you guessed it: a list.

We use the opportunity to only keep the fields we’re interested in: ID string, name and available transport types.

(defun g-get-props (query-response)
  "Extracts stop properties from a geocoder API response. Always picks the first entry."
  (cdr (assoc :properties (nth 1 (assoc :features query-response)))))

(defun g-props->plist (prop-alist)
  "Extracts geocoder properties into a nice alist."
  (flet ((get-val (key) (cdr (assoc key prop-alist))))
    (let ((id (get-val :id))
          (name (get-val :name))
          (category (get-val :category)))
      (list
       :id id
       :name name
       :types category))))

Sample query

(g-props->plist (g-get-props (send-geocoder-query "skoyen stasjon")))
(:ID "NSR:StopPlace:59651" :NAME "Skøyen stasjon" :TYPES
  ("railStation" "onstreetBus" "onstreetBus" "onstreetBus"))

Tying it all together

So far, we’ve been able to: search for a stop, query and filter the available departures.

The last thing is to display the departure times in a simple way. Nothing simpler than a text file IMO; so let’s do that.

(defun format-departure (departure)
  (format nil "~A [~A] line ~A to ~A"
          (timestamp->human-readable
           (extract-timestamp departure))
          (extract-type departure)
          (extract-line departure)
          (extract-destination departure)))

(format-departure *test-vestli*)
 ; => "08:29 [metro] line 5 to Vestli"

(defun print-departures (stream departures)
  (with-output-to-string (s)
    (mapcar (lambda (d)
              (format (if stream stream s) "~A~%" (format-departure d)))
            departures)))

Now let’s try a sample train station, keeping only two lines of subway and train:

(print-departures t
 (filter-departures
  (get-departures "NSR:StopPlace:58404")
  :types '("rail" "metro")
  :destinations '("vestli" "asker" "spikkestad")
  :lines '("5" "L1")))

Ta-daa

23:43 [rail] line L1 to Asker
23:45 [metro] line 5 to Vestli
00:00 [metro] line 5 to Vestli
00:13 [rail] line L1 to Asker
00:14 [metro] line 5 to Vestli

That’s enough for me, now let’s ~doxx myself and print my commute~ generate the text at the top of the post:

(defun favorites ()
  (let ((blommenholm (get-departures *blommenholm-stop*)))
    (with-output-to-string (s)
      (format s "~%---- Blommenholm -> work ----~%")
      (print-departures
       s (filter-departures
          blommenholm
          :destinations '("lillestrøm" "oslo s")))

      (format s "~%---- Blommenholm -> sandvika ----~%")
      (print-departures
       s (filter-departures
          blommenholm
          :destinations '("asker" "spikkestad")))

      (format s "~%---- Nationaltheatret ----~%")
      (print-departures
       s (filter-departures
          (get-departures *nationaltheatret-stop*)
          :types '("metro" "rail")
          :destinations '("vestli" "asker" "spikkestad")
          :lines '("5" "L1")))

      (format s "~%---- Hasle ----~%")
      (print-departures
       s (filter-departures
          (get-departures *hasle-stop*)
          :types '("metro")
          :lines '("5")
          :destinations '("ringen via tøyen"))))))

Deploying to the interwebs

The final step is to put this online. This is the first time I’m deploying any Common Lisp code to a public server. I don’t know what I’m doing.

After some googling (and phinding), we’re able to get an idea of what to do.

We’re building a minimal docker image, with the CL implementation (SBCL) and the libraries used in the project.

FROM clfoundation/sbcl:2.2.2-slim
ARG QUICKLISP_DIST_VERSION=2022-02-20

WORKDIR /app
COPY . /app

ADD https://beta.quicklisp.org/quicklisp.lisp /root/quicklisp.lisp

RUN set -x; \
  sbcl --load /root/quicklisp.lisp \
    --eval '(quicklisp-quickstart:install)' \
    --eval '(ql:uninstall-dist "quicklisp")' \
    --eval "(ql-dist:install-dist \"http://beta.quicklisp.org/dist/quicklisp/${QUICKLISP_DIST_VERSION}/distinfo.txt\" :prompt nil)" \
    --quit && \
  echo '#-quicklisp (load #P"/root/quicklisp/setup.lisp")' > /root/.sbclrc && \
  rm /root/quicklisp.lisp

# Expose web server + slynk port
EXPOSE 80 42069

In docker-compose, we then mount the code directory at /app in the image and run the app by invoking sbcl, which loads the source file.

  norush:
    image: clf-sbcl-local
    container_name: norush
    restart: ${RESTART_MODE}
    command: sbcl --non-interactive --load commute.lisp --eval "(loop while t do (sleep 10))"
    networks:
      - dnet
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ./apps/no-rush:/app
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    security_opt:
      - no-new-privileges:true

Was it worth it?

Absolutely! I’ve been using this tiny service multiple times a day for 6 months now. Plus I got to play around with lisp in a web context 🌎.

Definitely recommend doing this sort of thing in your favorite language, it’s pretty fun, and sometimes even useful too!