← Blog of oldlisper

Изменение макроса define-route в RESTAS

· 28.12.2012 00:00
· original author: archimag

Изменение макроса define-route в RESTAS

Существенно переработал макрос restas:define-route в RESTAS. Раньше он имел следующие аргументы:

restas:define-route (name (template &key (method :get) content-type render-method requirement parse-vars decorators) &body body)

Т.е. много-много key-параметров. Если при создании маршрута использовалось хотя бы несколько из них, то выглядело это довольно жутко. В итоге я решил отказаться от такой формы, оставил всего два key-параметра: method и content-type, а всё остальное теперь задаётся с помощью "деклараций". Декларации это специальные формы, начинающиеся с keyword-символа и расположенные в начале тела маршрута. Вот простейший пример из кода arblog:

(restas:define-route posts-feed ("feeds/atom" :content-type "application/atom+xml")
  (:render-method #'arblog.feed.tmpl:atom-feed)
  (list :name *blog-name*
        :href-atom (restas:gen-full-url 'posts-feed)
        :href-html (restas:gen-full-url 'entry)
        :posts (mapcar #'feed-post-info (ds.list-recent-posts 0 50))
)
)

Вместо старого параметра parse-vars теперь используется декларация :sift-variables. Например поначалу я использовал такой код:

(restas:define-route one-post (":year/:month/:day/:urlname")
  (:sift-variables (year #'parse-integer)
                   (month #'parse-integer)
                   (day #'parse-integer)
)

  (:render-method #'render.one-post)
  (ds.find-single-post year month day urlname)
)

Здесь указывается, что переменные year, month и day после извлечения их из переданного URL должны быть преобразованы с помощью функции parse-integer.

Однако, можно сделать намного лучше:

(restas:define-route one-post (":year/:month/:day/:urlname")
  (:sift-variables (year 'integer)
                   (month '(integer :min-value 1 :max-value 12))
                   (day ('integer :min-value 1 :max-value 31))
)

  (:render-method #'render.one-post)
  (ds.find-single-post year month day urlname)
)

Здесь не только указывается, что переменная должна быть преобразована к integer, но также задаются разумные ограничения на month и day - если переданный URL не соответствует этим ограничениям, то данный маршрут будет признан не соответствующим запрошенному URL и клиенту скорей всего (если не найдётся другого подходящего маршрута, а в arblog не найдётся) будет отправлено Not Found. Данная возможность основана на использовании библиотеки data-sift, которая пока находиться на ранней стадии развития, но что-то уже может.

Если в разных маршрутах используются одинаковые ограничения на переменные (что бывает часто), то можно избавиться от ненужного дублирования следующим образом:

(defmethod data-sift:compile-rule ((rule (eql 'year)) &key)
  (data-sift:compile-rule 'integer)
)


(defmethod data-sift:compile-rule ((rule (eql 'month)) &key)
  (data-sift:compile-rule '(integer :min-value 1 :max-value 12))
)


(defmethod data-sift:compile-rule ((rule (eql 'day)) &key)
  (data-sift:compile-rule '(integer :min-value 1 :max-value 31))
)


(restas:define-route one-post (":year/:month/:day/:urlname")
  (:sift-variables (year 'year) (month 'month) (day 'day))
  (:render-method #'render.one-post)
  (ds.find-single-post year month day urlname)
)

Для объяснения следующего типа деклараций надо вспомнить, что ранее при компиляции маршрута создавалась функция с таким же именем и набором key-параметров. Например, объявление

(restas:define-route one-post (":year/:month/:day/:urlname")
  (:sift-variables (year 'year) (month 'month) (day 'day))
  (:render-method #'render.one-post)
  (ds.find-single-post year month day urlname)
)

приводило к создании функции

one-post (&key
          (year (cdr (assoc :year restas:*bindings*)))
          (month (cdr (assoc :month restas:*bindings*)))
          (day (cdr (assoc :day restas:*bindings*)))
          (urlname (cdr (assoc :urlname restas:*bindings*)))
)

Просто жуть. Теперь же создаваемая функция будет иметь следующие параметры:

one-post (year month day urlname)

Создание данной функции является важным для процесса разработки, поскольку позволяет тестировать код маршрутов непосредственно в REPL (если правильно задать окружение). Пример:

CL-USER> (let ((arblog:*datastore* (make-instance 'arblog.datastore.mongodb:arblog-mongo-datastore)))
           (arblog:one-post 2012 12 18 "ARBLOG")
)

Это очень удобно, но имеет ограничение: часто для обработки маршрута необходимо извлекать дополнительную информацию из запроса, одних только параметров в URL недостаточно, а значит такие маршруты нельзя тестировать непосредственно в REPL. Для устранения данной проблемы я добавил новую возможность - декларация :additional-variables. Пример:

(restas:define-route posts-with-tag ("tags/:tag")
  (:apply-render-method #'render.posts-with-tag)
  (:additional-variables (skip (ignore-errors (parse-integer (hunchentoot:get-parameter "skip"))) 0))
  (list tag
        (ds.list-recent-posts skip *posts-on-page* :tag tag)
        (navigation (restas:genurl 'posts-with-tag :tag tag)
                    skip
                    (ds.count-posts tag)
)
)
)

Здесь указывается, что переменная SKIP является дополнительной переменной маршрута и должна вычислять при передаче параметров в обработчик маршрута. Как видно, здесь также можно указать значение по-умолчанию. Создаваемая при этом функция будет иметь следующие параметры:

posts-with-tag (tag &key (skip 0))

Т.е. параметры, указанные в декларации :additional-variables, превращаются в key-параметры, которые можно указывать при экспериментах в REPL. Например:

CL-USER> (let ((arblog:*datastore* (make-instance 'arblog.datastore.mongodb:arblog-mongo-datastore)))
           (arblog:posts-with-tag "lisp" :skip 20)
)

Между прочим, если в описании маршрута указана декларация :render-method (для вызова используется funcall)) или :apply-render-method (для вызова используется apply), то вызов функции маршрута не сопряжён с генераций разметки, что существенно упрощается не только экскременты в REPL, то также и написание пресловутых unit-тестов для контролёров. Но об этом в другой раз, у меня пока в голове вертится схема с возможностью генерации Mock-объектов из спецификации указываемой в restas:define-policy, что вкупе с вышесказанным открывает первоклассные возможности для тестирования логики разрабатываемого веб-приложения.

Декларация :requirement позволяет указать дополнительные условия при проверке соответствия запроса маршруту, а :decorators используется для указания применяемых к маршруту декораторов. Вот комплексный пример:

(defclass admin-route (routes:proxy-route) ())

(defmethod restas:process-route :before ((route admin-route) bindings)
  (multiple-value-bind (user password) (hunchentoot:authorization)
    (unless (ds.check-admin user password)
      (hunchentoot:require-authorization)
)
)
)


(defun @admin (route)
  (make-instance 'admin-route :target route)
)


(restas:define-route admin-preview-create-post ("admin/create-post" :method :post)
  (:requirement (hunchentoot:post-parameter "preview"))
  (:decorators '@admin)
  (:apply-render-method #'render.admin-edit-post)
  (:additional-variables (markup (hunchentoot:post-parameter "content"))
                         (title (hunchentoot:post-parameter "title"))
                         (tags (post-parameter-tags))
)

  (list :title title
        :markup markup
        :tags tags
        :preview (markup.render-content markup)
)
)

В данный момент я использую arblog в качестве тестовой площадки, на которой обкатываю новые решения. Цель - привести исходный код arblog к некому идеальному состоянию за счёт доработки RESTAS, а затем переключиться на что-нибудь более сложное, например на lisper.ru. Соответственно, arblog зависит от самых свежих версий RESTAS, data-sift и cl-closure-template.