1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
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))
|