2024年1月7日日曜日

pedestalでsessionやcookieを使う


背景

pedestalとはclojureというプログラミング言語のライブラリの1つであり、これを利用してwebサーバーのapiを管理できます。

このpedestalはringというapi管理ライブラリを利用して作られており、sessionの機能はringに頼っています。
設定方法、値の書き換え方、関連資料を備忘録として記事に残します。

使ったもの

  • clojure cli環境
    cljコマンドでサーバーを起動します。
    この記事のプログラムはdbを使わないのでclojure cli環境だけで動きます。
  • webブラウザ
    作ったページを閲覧します。

全体像

200行弱ありますが全体が見れると分かりやすいと思うので、deps.ednとmain関数があるファイルの全体を共有してから要所を解説します。
このファイルを含み実行可能なプロジェクトは下記のリポジトリに上げています。

clj-pedestal-session-practice

deps.edn
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
io.pedestal/pedestal.service {:mvn/version "0.6.2"}
io.pedestal/pedestal.jetty {:mvn/version "0.6.2"}
hiccup/hiccup {:mvn/version "1.0.5"}
ns-tracker/ns-tracker {:mvn/version "0.4.0"}}
:aliases
{:run-m {:main-opts ["-m" "clj-pedestal-session-practice.server"]}
:run-dev {:ns-default clj-pedestal-session-practice.server
:exec-fn -run-dev
:exec-args {:name "Clojure"}}
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.9.4"}}
:ns-default build}
:test {:extra-paths ["test"]
:extra-deps {org.clojure/test.check {:mvn/version "1.1.1"}
io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}}}

src/clj_pedestal_practice/server.clj
(ns clj-pedestal-session-practice.server
(:gen-class)
(:require [io.pedestal.http :as http :refer [html-body]]
[io.pedestal.http.route :as route :refer [url-for]]
[io.pedestal.http.body-params :refer [body-params]]
[ring.middleware.session.cookie :as cookie]
[ring.util.response :refer [redirect]]
[io.pedestal.http.ring-middlewares :as middlewares]
[hiccup.page :refer [html5]]
[ns-tracker.core :refer [ns-tracker]]))

(def watched-namespaces (ns-tracker "src"))

(defn handler-redirect-with-injecting-data
[_request]
(-> (redirect (url-for :home-page-with-interceptors))
(assoc :cookies {:some {:value "data-in-cookie" :http-only true}})
(assoc-in [:session :some] {:value "data-in-session"})))

(defn handler-page-inject-without-redirect [_request]
{:status 200
:cookies {:some {:value "data-in-cookie-without-redirect" :http-only true}}
:session {:some "data-in-session-without-redirect"}
:body (html5
[:p "injected"]
[:a {:href (url-for :home-page-with-interceptors)} "home"])})

(defn handler-page-check
[request]
{:status 200
:body (html5
[:a {:href (url-for :home-page-with-interceptors)} "home"] [:br]
[:a {:href (url-for :home-page-without-interceptor)} "home-without-interceptor"] [:br]
[:a {:href (url-for :inject)} "inject"] [:br]
[:a {:href (url-for :inject-without-redirect)} "inject-without-redirect"] [:br]
[:a {:href (url-for :clear)} "clear"] [:br]
[:a {:href (url-for :clear-without-redirect)} "clear-withtout-redirect"] [:br]
[:p "check page"]
[:p "request"]
[:p (str request)]
[:p "cookies"]
[:p (str (:cookies request))]
[:p "session"]
[:p (str (:session request))])})

(defn handler-redirect-with-clear [_request]
(-> (redirect (url-for :home-page-with-interceptors))
(assoc :cookies {:some {:max-age 0}})
(assoc :session nil)))

(defn handler-page-clear-without-redirect [_request]
{:status 200
:session nil
:cookies {:some {:max-age 0}}
:body (html5
[:p "clear-without-redirect"]
[:a {:href (url-for :home-page-with-interceptors)} "home"])})

(def interceptor-session (middlewares/session {:store (cookie/cookie-store)}))
(def interceptors-common [(body-params)
html-body
interceptor-session])

(def routes
#{["/"
:get (conj interceptors-common handler-page-check)
:route-name :home-page-with-interceptors]
["/home-without-interceptor"
:get [html-body handler-page-check]
:route-name :home-page-without-interceptor]
["/inject"
:get (conj interceptors-common handler-redirect-with-injecting-data)
:route-name :inject]
["/inject-without-redirect"
:get (conj interceptors-common handler-page-inject-without-redirect)
:route-name :inject-without-redirect]
["/clear"
:get (conj interceptors-common handler-redirect-with-clear)
:route-name :clear]
["/clear-without-redirect"
:get (conj interceptors-common handler-page-clear-without-redirect)
:route-name :clear-without-redirect]})

(defn routes-watched []
(doseq [ns-sym (watched-namespaces)]
(require ns-sym :reload))
(route/expand-routes routes))

(def service {:env :prod
::http/routes routes
::http/resource-path "/public"
::http/type :jetty
::http/port 8080})

(defonce runnable-service (http/create-server service))

(defn -run-dev
[& _args]
(println "\nCreating your [DEV] server...")
(-> service ;; start with production configuration
(merge {:env :dev
::http/routes routes-watched
;; do not block thread that starts web server
::http/join? false
;; Routes can be a function that resolve routes,
;; we can use this to set the routes to be reloadable
;; ::http/routes #(deref #'routes)
;; all origins are allowed in dev mode
::http/allowed-origins {:creds true :allowed-origins (constantly true)}})
;; Wire up interceptor chains
http/default-interceptors
http/dev-interceptors
http/create-server
http/start))

(defn -main
[& _args]
(println "\nCreating your server...")
(http/start runnable-service))

下記のコマンドで開発版(deps.ednで指定した-run-dev関数の呼び出し)を実行できます。
clj -X:run-dev

実行すると http://localhost:8080 でサーバーが起動するのでブラウザで確認します。

ring.middleware.session.cookieでinterceptorを作って埋め込む

ringのcookieを下記のように記述してinterceptorとして埋め込んだパスでsessionやcookieが利用可能です。
  (:require [ring.middleware.session.cookie :as cookie]
[io.pedestal.http.ring-middlewares :as middlewares])
(def interceptor-session (middlewares/session {:store (cookie/cookie-store)}))
(def interceptors-common [(body-params)
html-body
interceptor-session])
    ["/"
:get (conj interceptors-common handler-page-check)
:route-name :home-page-with-interceptors]
["/home-without-interceptor"
:get [html-body handler-page-check]
:route-name :home-page-without-interceptor]

interceptorを適用した「/」ではsessionとcookieを見れますが、適用していない「/home-without-interceptor」では見れません。
sessionのinterceptorを適用していないと、値の代入や削除もできません。

/と/home-without-interceptorでは、下記のhandlerでrequestに含まれるcookiesとsessionを表示しています。
(defn handler-page-check
[request]
{:status 200
:body (html5
[:a {:href (url-for :home-page-with-interceptors)} "home"] [:br]
[:a {:href (url-for :home-page-without-interceptor)} "home-without-interceptor"] [:br]
[:a {:href (url-for :inject)} "inject"] [:br]
[:a {:href (url-for :inject-without-redirect)} "inject-without-redirect"] [:br]
[:a {:href (url-for :clear)} "clear"] [:br]
[:a {:href (url-for :clear-without-redirect)} "clear-withtout-redirect"] [:br]
[:p "check page"]
[:p "request"]
[:p (str request)]
[:p "cookies"]
[:p (str (:cookies request))]
[:p "session"]
[:p (str (:session request))])})

interceptorを適用している/だと値を見れます。


interceptorを適用していない/home-without-interceptorでは値を見れません。


参考
pedestalのサンプルコードを参考にしました。(これに合わせてcookie-storeを設定していますが、cookie-storeを渡さなくてもsessionとcookieが両方使えるのが謎です。)
pedestal/samples/ring-middleware/src/ring_middleware/service.clj

pedestalのmiddleware/sessionの説明ページです。
Ring Middleware

redirect時はassocで、ページ表示時はresponseのmapに記述して値を配置

sessionのinterceptorを埋め込んでいるhandlerのrequestのmapに対して、:cookies でcookieを、:session でsessionを操作可能です。
(defn handler-redirect-with-injecting-data
[_request]
(-> (redirect (url-for :home-page-with-interceptors))
(assoc :cookies {:some {:value "data-in-cookie" :http-only true}})
(assoc-in [:session :some] {:value "data-in-session"})))

(defn handler-page-inject-without-redirect [_request]
{:status 200
:cookies {:some {:value "data-in-cookie-without-redirect" :http-only true}}
:session {:some "data-in-session-without-redirect"}
:body (html5
[:p "injected"]
[:a {:href (url-for :home-page-with-interceptors)} "home"])})
    ["/inject"
:get (conj interceptors-common handler-redirect-with-injecting-data)
:route-name :inject]
["/inject-without-redirect"
:get (conj interceptors-common handler-page-inject-without-redirect)
:route-name :inject-without-redirect]

cookieはringの機能を通して一般的なcookie関連の設定が可能です。
下記のringの説明ページのwrap-cookiesに詳しい設定が書かれています。

ring.middleware.cookies

このコードではhttp-onlyを有効にしてjsからは値を参照できなくしています。
認証情報などはhttpOnlyやsecureを有効にして保持するのが悪用を防げて良いです。
   :cookies {:some {:value "data-in-cookie-without-redirect" :http-only true}}

http://localhost:8080/inject にアクセス後に表示される情報がこちらです。
http-onlyを有効にしているので、handlerのrequestでは値を見れますが、jsでdocument.cookieを参照しても空です。


参考
ringのcookieに関する説明ページです。
ring.middleware.cookies

pedestalのresponse mapの説明ページです。
Response Map

sessionは全体を削除可能だが、cookieの削除はキーとmax-ageの指定が必要

sessionはnilや{}を代入すると全体を削除可能です。
session部分的に削除したい場合は、削除を施したmapを渡します。
しかし、cookieは全体の削除は対応しておらず、キーにnilを代入しても空文字を持つキーができるので、消したいキーにmax-ageを0設定して消します。
(defn handler-redirect-with-clear [_request]
(-> (redirect (url-for :home-page-with-interceptors))
(assoc :cookies {:some {:max-age 0}})
(assoc :session nil)))

(defn handler-page-clear-without-redirect [_request]
{:status 200
:session nil
:cookies {:some {:max-age 0}}
:body (html5
[:p "clear-without-redirect"]
[:a {:href (url-for :home-page-with-interceptors)} "home"])})
    ["/clear"
:get (conj interceptors-common handler-redirect-with-clear)
:route-name :clear]
["/clear-without-redirect"
:get (conj interceptors-common handler-page-clear-without-redirect)
:route-name :clear-without-redirect]

max-ageに小さい数を指定するのがcookieの消し方です。
   :cookies {:some {:max-age 0}}

参考
In clojure/ring, how do I delete a cookie?

サーバーを再起動するとsessionは消えるがcookieは残る

sessionはclojure cliのメモリで管理されているため再起動で消えます。
cookieはブラウザが保有しているので、サーバーを再起動しても残ります。

余談: コードの自動更新

変更の反映にサーバーを起動しなおすのは時間がかかって嬉しくないので、以前取り組んだns-reloadを利用した自動更新の仕組みを適用しています。
(def watched-namespaces (ns-tracker "src"))
(defn routes-watched []
(doseq [ns-sym (watched-namespaces)]
(require ns-sym :reload))
(route/expand-routes routes))
(defn -run-dev
[& _args]
(-> service
(merge {:env :dev
::http/routes routes-watched

参考
pedestalでコード変更時に自動更新

おわり

pedestalでsessionやcookieを扱う方法を大まかに把握できました。

参考

今回紹介したコードを管理しているリポジトリです。
clj-pedestal-session-practice

pedestalのsessionに関するサンプルコードです。
pedestal/samples/ring-middleware/src/ring_middleware/service.clj

pedestalのmiddleware/sessionの説明ページです。
Ring Middleware

ringのcookieの扱い方説明ページです。
ring.middleware.cookies

pedestalのresponse mapの説明ページです。
Response Map

ringでのcookieの消し方を質問しているページです。
In clojure/ring, how do I delete a cookie?

サーバーの自動更新解説ページです。
pedestalでコード変更時に自動更新

0 件のコメント :