Routing dengan Reitit

Menyusun rute berbasis data dengan reitit, dari pohon rute hingga coercion.

Daftar isi

Topik ini membahas routing dengan pustaka reitit, yaitu pemetaan dari jalur URL menuju handler yang dinyatakan sebagai data, bukan sebagai kode. Pembahasan mencakup pohon rute, pencocokan manual dengan match-by-path dan match-by-name, pembungkusan router menjadi handler Ring, serta coercion untuk mengubah parameter jalur menjadi tipe yang benar. Seluruh contoh diuji dengan memanggil handler secara langsung, tanpa server.

Rute sebagai data#

Seluruh fungsi pada topik ini berasal dari pustaka metosin/reitit yang tersedia pada repositori Clojars. Dependensinya dinyatakan pada berkas deps.edn berikut, dan berkas yang sama ikut menjadi bagian bundle topik ini. Pemanggilan clojure -M berkas.clj dari dalam direktori topik dengan demikian sudah membawa reitit pada classpath tanpa penyetelan tambahan.

edn deps.edn
{:deps {metosin/reitit {:mvn/version "0.11.0"}}}

Reitit menyusun routing sebagai pohon vektor. Setiap rute berupa vektor dua elemen, yaitu string jalur dan map datanya, sedangkan kelompok rute berupa vektor berisi beberapa rute. Data rute menyimpan handler per metode HTTP, nama rute, dan metadata lain. Karena rute hanyalah data, pohon tersebut dapat dibangun dengan fungsi biasa, digabung antar berkas, dan diperiksa pada REPL sebelum digunakan.

Router dibuat dengan reitit.core/router. Fungsi match-by-path mencocokkan string jalur terhadap seluruh rute dan mengembalikan match, yakni map berisi templat rute, data rute, dan parameter jalur. Fungsi match-by-name bekerja sebaliknya, yaitu menghasilkan match dari nama rute beserta parameternya sehingga URL dapat dibangun tanpa mengetik string secara manual.

clojure Contoh: router reitit dan dua arah pencocokan
(require '[reitit.core :as r])

(def router
  (r/router
    [["/beranda" {:name :beranda}]
     ["/anggota" {:name :daftar-anggota}]
     ["/anggota/:id" {:name :lihat-anggota}]]))

(prn (:template (r/match-by-path router "/anggota/A1")))
(prn (:path-params (r/match-by-path router "/anggota/A1")))
(prn (r/match-by-path router "/rusak"))
(prn (:path (r/match-by-name router :lihat-anggota {:id "A1"})))
(prn (:path (r/match-by-name router :daftar-anggota)))
text Keluaran
"/anggota/:id"
{:id "A1"}
nil
"/anggota/A1"
"/anggota"

Jalur /anggota/A1 dicocokkan ke templat /anggota/:id, dan segmen A1 tersimpan pada :path-params dengan kunci keyword. Jalur yang tidak dicocokkan mengembalikan nil. Pada arah sebaliknya, nama :lihat-anggota beserta parameter menghasilkan string /anggota/A1, sehingga tautan internal aplikasi selalu konsisten dengan definisi rutenya.

Rute juga dapat disarangkan. Vektor yang diletakkan di dalam data rute membentuk sub-pohon yang jalurnya digabungkan dengan induknya, sehingga kelompok rute yang berbagi awalan dapat ditulis tanpa mengulang string jalurnya. Metadata pada rute induk diwarisi anak-anaknya melalui penggabungan map, sehingga opsi yang berlaku untuk satu kelompok rute cukup dideklarasikan sekali pada induknya.

ring-handler dan pencocokan langsung#

Router reitit.core hanyalah mesin pencocokan, sedangkan aplikasi Ring membutuhkan handler. Fungsi reitit.ring/ring-handler membungkus router menjadi handler Ring yang menerima request map, mencocokkan :uri, lalu memanggil handler milik metode HTTP yang sesuai. Parameter jalur disimpan pada request di kunci :path-params dengan keyword sebagai kunci dan string sebagai nilai.

clojure Contoh: ring-handler dipanggil langsung dengan request map
(require '[reitit.ring :as ring])

(defn daftar [_]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "daftar anggota: A1, A2"})

(defn lihat [request]
  (let [id (get-in request [:path-params :id])]
    {:status 200
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body (str "detail anggota " id)}))

(def app
  (ring/ring-handler
    (ring/router
      [["/anggota" {:get daftar}]
       ["/anggota/:id" {:get lihat}]])
    (ring/create-default-handler)))

(prn (app {:request-method :get :uri "/anggota"}))
(prn (app {:request-method :get :uri "/anggota/A1"}))
(prn (app {:request-method :get :uri "/rusak"}))
text Keluaran
{:status 200, :headers {"Content-Type" "text/plain; charset=utf-8"}, :body "daftar anggota: A1, A2"}
{:status 200, :headers {"Content-Type" "text/plain; charset=utf-8"}, :body "detail anggota A1"}
{:status 404, :body "", :headers {}}

Argumen kedua ring-handler berupa handler fallback. Fungsi ring/create-default-handler menyediakan fallback tersebut, sehingga jalur yang tidak dicocokkan menghasilkan status 404 dan metode yang tidak didukung menghasilkan status 405. Tanpa fallback, handler mengembalikan nil untuk jalur yang tidak dikenal, dan kondisi itu tidak dapat dikirim server sebagai response.

Data rute dapat memuat lebih dari satu metode HTTP sekaligus, misalnya :get untuk pembacaan dan :post untuk pembuatan data pada jalur yang sama. Router memisahkan keduanya berdasarkan :request-method, sedangkan dua rute dengan pasangan jalur dan metode yang sama persis ditolak ketika router dibangun karena pencocokannya menjadi tidak jelas. Penolakan tersebut terjadi sebelum permintaan pertama tiba, sehingga konflik rute ditemukan lebih awal.

Coercion parameter jalur#

Parameter jalur selalu berupa string, sedangkan logika aplikasi biasanya mengharapkan angka atau bentuk lain. Coercion adalah mekanisme reitit untuk mengonversi parameter tersebut berdasarkan skema yang dideklarasikan pada data rute, sekaligus menolak nilai yang tidak sah. Skema ditulis pada kunci :parameters, dan implementasi coercion dipilih melalui :coercion, misalnya reitit.coercion.spec/coercion yang menggunakan clojure.spec.

Coercion dijalankan oleh middleware. Konfigurasi lengkapnya memasang tiga middleware pada kunci :data router, yaitu parameters-middleware untuk mengurai query string, coerce-exceptions-middleware yang mengubah kegagalan coercion menjadi response 400, serta coerce-request-middleware untuk mengonversi dan memverifikasi parameter. Urutannya penting, karena middleware penangkap error harus berada di luar middleware yang mungkin melemparkannya. Implementasi coercion lain juga tersedia, misalnya malli yang dipanggil melalui namespace coercion-nya sendiri, dan pemilihannya cukup dengan mengganti nilai :coercion pada data router.

clojure Contoh: coercion parameter jalur dengan spec
(require '[reitit.ring :as ring]
         '[reitit.coercion.spec]
         '[reitit.ring.coercion :as rrc]
         '[reitit.ring.middleware.parameters :as parameters])

(defn lihat-anggota [request]
  (let [id (get-in request [:parameters :path :id])]
    {:status 200
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body (str "anggota nomor " id " bertipe " (.getName (type id)))}))

(def app
  (ring/ring-handler
    (ring/router
      [["/anggota/:id" {:get {:handler lihat-anggota
                              :parameters {:path {:id int?}}}}]]
      {:data {:coercion reitit.coercion.spec/coercion
              :middleware [parameters/parameters-middleware
                           rrc/coerce-exceptions-middleware
                           rrc/coerce-request-middleware]}})))

(prn (app {:request-method :get :uri "/anggota/42"}))
(prn (:status (app {:request-method :get :uri "/anggota/abc"})))
text Keluaran
{:status 200, :headers {"Content-Type" "text/plain; charset=utf-8"}, :body "anggota nomor 42 bertipe java.lang.Long"}
400

Pada jalur /anggota/42, string "42" dikonversi menjadi java.lang.Long sebelum handler membacanya, sehingga handler menerima request dengan kunci :parameters berisi nilai bertipe benar. Pada jalur /anggota/abc, string itu tidak memenuhi int?, dan middleware penangkap error mengubah kegagalannya menjadi status 400 tanpa menyentuh handler sama sekali. Dengan demikian, handler cukup menangani data yang sudah sah.

Kesalahan umum#

Membaca parameter dari :path-params lalu memperlakukannya sebagai angka. Nilainya tetap berupa string pada ring-handler tanpa coercion, sehingga operasi aritmetika atasnya melempar error. Perbaikannya dengan menambahkan :parameters beserta middleware coercion, atau mengonversi string secara eksplisit di dalam handler.

Melupakan kunci :request-method pada request buatan sendiri. Pencocokan reitit.ring menggabungkan :uri dengan :request-method, dan request tanpa metode tidak cocok dengan rute mana pun sehingga handler mengembalikan response 404. Perbaikannya dengan selalu mengisi :request-method saat menguji handler secara manual.

Memasang coerce-exceptions-middleware setelah coerce-request-middleware. Kegagalan coercion melempar exception sebelum sempat ditangkap, sehingga request berakhir sebagai error yang tidak tertangani di lapisan mana pun. Perbaikannya dengan menempatkan middleware penangkap error lebih dulu dalam vektor :middleware.

Ringkasan#

  • Rute reitit dinyatakan sebagai pohon vektor berisi pasangan jalur dan data, sehingga definisi routing dapat dimanipulasi seperti data lain.
  • match-by-path mencocokkan jalur ke templat beserta parameter jalurnya, sedangkan match-by-name membangun URL dari nama rute.
  • reitit.ring/ring-handler mengubah router menjadi handler Ring yang menerima request map biasa dan memasang parameter pada :path-params.
  • Coercion mengonversi parameter berdasarkan skema pada :parameters dan menolak nilai tidak sah dengan response 400 melalui middleware yang urutannya menentukan hasilnya.

Referensi#

  • Reitit — Metosin — Repositori resmi reitit beserta dokumentasi routing berbasis data dan coercion.
  • Ring — GitHub — Spesifikasi handler Ring yang dibungkus oleh reitit.ring/ring-handler.