Pedestal が簡単そうなので、これを使ってコメント投稿とコメント取得のみのシンプルなAPIを作ってみたいと思います。

方針

  • APIの出力はJSON形式
  • DBはとりあえずSQLite
  • クエリはHugSQLで扱う
  • コメントはページごとに扱えるようにする
  • 承認前のコメントは表示させない
  • APIをGETしたりPOSTしたりする側はjQueryでいいか

実装

まずは leiningen で Pedestal プロジェクトを作る。

lein new pedestal-service comment-api-server

project.cljの:dependenciesに下記を追加。

[com.layerware/hugsql "0.5.1"]
[org.xerial/sqlite-jdbc "3.28.0"]
[org.clojure/java.jdbc "0.7.10"]

同じく :resource-paths に sql を追加。
sql ディレクトリも作成しておく。

:resource-paths ["config", "resources", "sql"]

sql/comments.sql を作ってクエリを書く。

-- :name create-comments-table
-- :command :execute
-- :result :raw
-- :doc Create comments table
create table comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  page_id TEXT,
  name TEXT,
  mail TEXT,
  comment TEXT,
  ip TEXT,
  approved INTEGER DEFAULT 0,
  created_at DATETIME DEFAULT (datetime('now', 'localtime'))
)

-- A :result value of :n below will return affected rows:
-- :name insert-comment :! :n
-- :doc Insert a single comment returning affected row count
insert into comments (page_id, name, mail, comment, ip)
values (:page_id, :name, :mail, :comment, :ip)

-- :name comments-by-page :? :*
-- :doc Comments
select *
  from comments
 where page_id = :page_id and approved = 1
 order by id desc
 limit 100 offset 0

-- :name not-approved-comments :? :*
-- :doc Comments
select *
  from comments
 where approved = 0

DB 接続周りを書く。
ひとまず固定で書いてるけどそのうち外に出す想定で。

data/db.clj を作成する。

(ns data.db
  (:require [clojure.java.jdbc :as jdbc]))

(def db-spec
  {:subprotocol "sqlite",
   :subname "path/to/comment.sqlite"})

次は HugSQL でクエリを読み込む辺り。

data/comments.clj を作成。

(ns data.comments
  (:require [hugsql.core :as hugsql]))

(hugsql/def-db-fns "comments.sql")

次はプロジェクト作成時に作られている service.clj を変更。

comment-api-server/service.clj

:require に追加。

[io.pedestal.log :as log]
[data.comments :as comments]
[data.db :as db]
[clojure.walk :as walk]

バリデーションを作っておく。

(defn validate-email-pattern
  [mail]
  (let [pattern #"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"]
    (and (string? mail) (re-matches pattern mail))))

(defn validate-email
  [mail]
  (if (validate-email-pattern mail)
    nil
    "mail pattern mismatch"))

(defn validate-name
  [name]
  (if (and (string? name) (> (count name) 0))
    nil
    "empty name"))

(defn validate-comment
  [comment]
  (if (and (string? comment) (> (count comment) 0))
    nil
    "empty comment"))

(defn new-comment-validator
  [input]
  (let [name-error (validate-name (:name input))
        email-error (validate-email (:mail input))
        comment-error (validate-comment (:comment input))]
    (if (or name-error email-error comment-error)
      [name-error email-error comment-error]
      nil)))

DB アクセス周りの関数は分離しておく。

(defn exists-db
  [path]
  (.exists (clojure.java.io/as-file path)))

(defn create-db-if-not-exists
  []
  (if (not (exists-db (:subname db/db-spec)))
    (comments/create-comments-table db/db-spec)))

(defn db-comments
  [page-id]
  (create-db-if-not-exists)
  (let [cs (seq (comments/comments-by-page db/db-spec {:page_id page-id}))]
    (if cs cs '())))

(defn db-new-comment
  [page-id ip params]
  (create-db-if-not-exists)
  (let [p (assoc (walk/keywordize-keys params) :page_id page-id :ip ip)
        validation-errors (new-comment-validator p)]
    (if validation-errors
      {:success 0, :messages (filter #(not (nil? %)) validation-errors)}
      (if (comments/insert-comment db/db-spec p)
        {:success 1}
        {:success 0, :messages ["insert failure"]}))))

ルートに定義する関数を実装する。

(defn get-comments
  [request]
  (let [page-id (:page-id (:path-params request))
        comments (db-comments page-id)]
    (ring-resp/response comments)))

(defn add-comment
  [request]
  (let [page-id (:page-id (:path-params request))
        result (db-new-comment page-id (:remote-addr request) (:params request))]
    (ring-resp/response {:success result})))

ルートを定義する。

(def common-interceptors [(body-params/body-params) http/json-body])

(def routes #{["/comment/:page-id" :get (conj common-interceptors `get-comments) :constraints {:page-id #"[a-zA-Z0-9\-_/]+"}]
              ["/comment/:page-id" :post (conj common-interceptors `add-comment) :constraints {:page-id #"[a-zA-Z0-9\-_/]+"}]})

後は def service 中の ::http/allowed-origins を無効かして設定する。

::http/allowed-origins {:creds true :allowed-origins ["https://www.sysbe.net"]}

動作テスト。 現状ではオリジンをハードコーディングしてるので、テストは run-dev で。

lein run-dev

run-dev の定義は server.clj にあって、その中で ::server/allowed-origins を上書きしているようです。

これでAPI自体はだいたい完成。

コメント承認周りのフォームは作ってないけど、とりあえずはクエリー直接打てばいいよね。

その後

現在の版では、コンフィグファイルを読み込んでDB接続情報、::http/allowed-origins を定義するようにしています。

config.edn の例。

{:db-spec {:subprotocol "sqlite", :subname "target/sample_db.sqlite"}
 :allowed-origins {:creds true :allowed-origins ["https://www.example.com"]}
 :admin {:allowed-ip ["127.0.0.1"]}}

また、ちょっとしたバリデーションも追加しています。

リポジトリ
リリース

起動方法は自動化したほうがいいんだろうけど、とりあえず手動で。

nohup java -Dfile.encoding=utf-8 -jar comment-api-server-0.0.2-SNAPSHOT-standalone.jar config.edn &

Origin周りでトラブったりしてましたが、それに関しては次の記事で。