diff options
Diffstat (limited to 'src/backend/hunchentoot.lisp')
-rw-r--r-- | src/backend/hunchentoot.lisp | 323 |
1 files changed, 323 insertions, 0 deletions
diff --git a/src/backend/hunchentoot.lisp b/src/backend/hunchentoot.lisp new file mode 100644 index 0000000..acb15cb --- /dev/null +++ b/src/backend/hunchentoot.lisp @@ -0,0 +1,323 @@ +;; Copyright (C) 2022 colin@cicadas.surf + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU Affero General Public License as +;; published by the Free Software Foundation, either version 3 of the +;; License, or (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU Affero General Public License for more details. + +;; You should have received a copy of the GNU Affero General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +(defpackage #:lazybones-backend.hunchentoot + (:use #:cl #:lazybones-backend) + (:local-nicknames (#:h #:hunchentoot) + (#:lzb #:lazybones) + (#:a #:alexandria))) + +(in-package :lazybones-backend.hunchentoot) + +;;; Hunchentoot Acceptor Subclass + +(defvar %server% nil + "unexported defvar holding the lazybones-acceptor instance.") + +(defclass lazybones-acceptor (h:acceptor) + ((installed-apps + :accessor acceptor-apps + :initform nil + :documentation "Instances of LAZYBONES:APP installed to this + acceptor. APPs are, among other things, collections of ENDPOINT + instances. The acceptor instance uses them to dispatch handlers + on requests.") + (canned-responses + :accessor canned-responses + :initarg :canned-responses + :initform nil + :documentation "an alist of (CODE CONTENT-FUNCTION CONTENT-TYPE)") + (domain + :accessor domain + :initarg :domain + :initform nil + :documentation "A specific domain to associate with this server + for the purposes of cookie handling. NIL by default, which is + fine.")) + (:default-initargs + :address "127.0.0.1")) + +(defvar %request-body-cache% nil + "Internal use. Dynamically bound per request. Caches the request + body after the first call to REQUEST-BODY so that subsequent calls + return the same thing, even if they've already been read off the + stream.") + +(defmethod h:acceptor-dispatch-request ((%server% lazybones-acceptor) request) + (let* ((%request-body-cache% nil) + (route + (request-path request)) + (apps + (remove-if-not (lambda (app) (a:starts-with-subseq (lzb::app-prefix app) route)) + (acceptor-apps %server%)))) + (handler-case + (loop for app in apps + for (endpoint . args) = (lzb::find-endpoint app request) + when endpoint + return (lzb::run-endpoint endpoint args request h:*reply* app) + ;; if no endpoint was found, call next method. + finally (let ((lzb:*request* request) + (lzb:*response* h:*reply*)) + (lzb:http-err 404))) + (lzb::http-error (http-error) + (let ((lzb:*request* request) + (lzb:*response* h:*reply*)) + (with-slots (lzb::code lzb::content) http-error + (http-respond lzb::content lzb::code)))) + (error (e) + (declare (ignorable e)) + (let ((lzb:*request* request) + (lzb:*response* h:*reply*)) + (if lzb:*debugging* + (invoke-debugger e) + (http-respond nil 500))))))) + +;;; SERVER FUNCTIONS + +(defun create-server (&key (port 8888) (address "127.0.0.1") (domain "localhost")) + "Creates an opaque server on port PORT, and returns it. Servers are +backend specific, but each may be passed in to INSTALL-APP, +UNINSTALL-APP, START-SERVER, and STOP-SERVER." + (let ((server (make-instance 'lazybones-acceptor + :port port + :address address + :domain domain))) + (set-canned-response server 404 "Not Found" "text/plain") + (set-canned-response server 500 "Server Error" "text/plain") + server)) + +(defun start-server (server) + (h:start server)) + +(defun stop-server (server) + (h:stop server)) + +(defun install-app (server app) + "Installs a LAZYBONES:APP instance to SERVER, first checking that +the app exists. If app is already installed, does nothing." + (a:if-let (app (and app (if (symbolp app) (lzb:app app) app))) + (pushnew app (acceptor-apps server) :key 'lzb::app-name) + (error () "No app to install."))) + +(defun uninstall-app (server app) + (setf (acceptor-apps server) + (delete (if (symbolp app) (lzb:app app) app) (acceptor-apps server)))) + +(defun canned-response (server code) + "If a canned response is installed to the server for the HTTP +response code CODE, return it as a list (RESPONSE-SOURCE CONTENT-TYPE). + +RESPONSE-SOURCE is either a function designator for a function taking +zero arguments that is expected to return data that matches the +CONTENT-TYPE. Such a function can always make use of *REQUEST*. + +RESPONSE-SOURCE can also be a pathname to a file to serve." + (cdr (assoc code (canned-responses server)))) + +(defun set-canned-response (server code content-source content-type) + "Set a new canned response for the code CODE." + (push (list code content-source content-type) (canned-responses server))) + +(defun server-domain (&optional (server %server%)) + (domain server)) + +;;; HTTP REQUEST FUNCTIONS + +(defun request-path (&optional (request lzb:*request* )) + "Returns the PATH part of the REQUEST URL. + +See Also: https://en.wikipedia.org/wiki/URL#Syntax." + (h:script-name request)) + +(defun request-host (&optional (request lzb:*request*)) + "Returns the HOST part of the REQUEST URL. + +See Also: https://en.wikipedia.org/wiki/URL#Syntax" + (h:host request)) + +(defun request-url (&optional (request lzb:*request*)) + "Returns the full url of REQUST" + (h:request-uri* request)) + +(defun request-port (&optional (request lzb:*request*)) + "The port associated with REQUEST." + (h:local-port* request)) + +(defun request-query-string (&optional (request lzb:*request*)) + "Returns the full query string of the URL associated with REQUEST + +See Also: https://en.wikipedia.org/wiki/URL#Syntax" + (h:query-string request)) + +(defun request-parameter (name &optional (request lzb:*request*)) + "Returns the the value of the query parameter named NAME, or NIL + if there there is none." + (h:get-parameter name request)) + +(defun request-parameters (&optional (request lzb:*request*)) + "Returns an alist of parameters associated with REQUEST. Each +member of the list looks like (NAME . VALUE) where both are strings." + (h:get-parameters request)) + +(defun request-headers (&optional (request lzb:*request*)) + "Returns an alist of headers associated with REQUEST. Each member of +the list looks like (HEADER-NAME . VALUE) where HEADER-NAME is a +keyword or a string and VALUE is a string." + (h:headers-in request)) + +(defun request-header (header-name &optional (request lzb:*request*)) + "Returns the string value of the REQUEST header named HEADER-NAME. +HEADER-NAME can be a keyword or a string." + (h:header-in header-name request)) + +(defun request-cookie (name &optional (request lzb:*request*)) + "Returns the cookie with NAME sent with the REQUEST" + (h:cookie-in name request)) + +(defun request-method (&optional (request lzb:*request*)) + "Returns a keyword representing the http method of the request." + (h:request-method request)) + + +(defparameter +hunchentoot-pre-decoded-content-types+ + '("multipart/form-data" "application/x-www-form-urlencoded")) + +(defun pre-decoded-body-p (request) + (let ((header (request-header :content-type request))) + (when (stringp header) + (loop for prefix in +hunchentoot-pre-decoded-content-types+ + thereis (a:starts-with-subseq prefix header))))) + +(defparameter +hunchentoot-methods-with-body+ + '(:post :put :patch)) + +(defun request-body (&key (request lzb:*request*) (want-stream-p nil)) + "Returns the decoded request body. The value returned depends upon +the value of the Content-Type request header. + +If WANT-STREAM-P is non-null, then an attempt is made to return a +stream from which the body content can be read. This may be impossible +if the Content-Type of the request is one of multipart/form-data or +application/x-www-form-urlencoded. + +If the body's Content-Type is application/json, multipart/form-data, +or application/x-www-form-urlencoded then a property-list +representation of the body is returned. + +Otherwise a bytevector of the body is returned. + +Work to unpack the body is performed once per request. Calling this" + (if %request-body-cache% %request-body-cache% + (setf %request-body-cache% + (when (member (request-method request) +hunchentoot-methods-with-body+) + (let ((pre-decoded-body-p + (pre-decoded-body-p request)) + (content-type + (request-header :content-type request))) + (cond + ;; try to get a stream on request + (want-stream-p + ;; can't do it if the body is already decoded - return nil so + ;; that request-body can be called again + (unless pre-decoded-body-p + (h:raw-post-data :request request :want-stream t))) + + (pre-decoded-body-p + (format-as-lazybones-document + (h:post-parameters request))) + + ((string-equal "application/json" content-type) + (jonathan:parse + (h:raw-post-data :request request :external-format :utf8) + :as :plist + :keywords-to-read *allowed-keywords*)) + + (t + ;; default case is to return a bytevector + (h:raw-post-data :request request :force-binary t)))))))) + +(defun format-as-lazybones-document (post-parameters) + "internal function. Formats all the post parmaeters (see docstring + on hunchentoot:post-parameters) into a plist with keyword keys, as + is the convention for lazybones." + (loop for (k . value) in post-parameters + collect (alexandria:make-keyword k) + collect value)) + +;;; HTTP RESPONSE FUNCTIONS + +(defun response-code (&optional (response lzb:*response*)) + "Access the return code of the resposne. Return code should be an integer." + (h:return-code response)) + +(defun (setf response-code) (code &optional (response lzb:*response*)) + (setf (h:return-code response) code)) + +(defun response-header (name &optional (response lzb:*response*)) + "Access the response header that has NAME, which can be a keyword (recommended) or a string." + (h:header-out name response)) + +(defun (setf response-header) (value name &optional (response lzb:*response*)) + (setf (h:header-out name response) value)) + +(defun response-cookie (name &optional (response lzb:*response*)) + "Access the cookie with NAME in the response object." + (h:cookie-out name response)) + +(defun set-response-cookie + (name value + &key expires max-age path domain secure http-only (response lzb:*response*)) + "Sets the response cookie" + (apply 'h:set-cookie name + :value value + :reply response + (nconc (when expires (list :expires expires)) + (when max-age (list :max-age max-age)) + (when path (list :path path)) + (cond + (domain (list :domain domain)) + ((server-domain) (list :domain (server-domain)))) + (when secure (list :secure secure)) + (when http-only (list :http-only http-only))))) + +(defun http-respond (content) + "Final step preparing response before backend does the rest. For +Hunchentoot set a few headers. If content is a pathname, pass off to +HUNCHENTOOT:HANDLE-STATIC-FILE, otherwise just return the content." + + ;; When http-err is called, the content is likely to be null. If + ;; that is the case, look for the default content for the error + ;; code, and set content and content-type appropriately + (a:when-let (data + (and (null content) + (canned-response %server% (response-code)))) + (destructuring-bind (source content-type) data + (setf (response-header :content-type) content-type + content (if (or (functionp source) (symbolp source)) + (funcall source) + source)))) + + ;; set the response code and header. + (setf + (response-header :content-type) (or (response-header :content-type) + (when (pathnamep content) + (h:mime-type content)) + (when lzb:*app* + (lzb::default-content-type lzb:*app*)) + "text/plain")) + (if (pathnamep content) + (h:handle-static-file content) + content)) + |