cl-jingle

2023-10-21

jingle -- ningle with bells and whistles

Upstream URL

github.com/dnaeon/cl-jingle

Author

Marin Atanasov Nikolov <dnaeon@gmail.com>

Maintainer

Marin Atanasov Nikolov <dnaeon@gmail.com>

License

BSD 2-Clause
README

1jingle

jingle is ningle, but with bells and whistles.

2Requirements

3Installation

jingle is not yet in Quicklisp, so in order to install it you will need to clone the repo and add it to your Quicklisp local-projects.

  cd ~/quicklisp/local-projects
  git clone https://github.com/dnaeon/cl-jingle.git

4Demo

The JINGLE.DEMO system provides a ready-to-run example REST API, which comes with an OpenAPI 3.x spec, Swagger UI, and a command-line interface app built with clingon.

In order to build the demo application, simply execute this command.

  make demo

This will build the jingle-demo app, which you can find in the bin/ directory.

In order to start the HTTP server, execute the following command.

  bin/jingle-demo serve

Once the HTTP server is up and running navigate to http://localhost:5000/api/docs/ which should take you to the Swagger UI.

./images/jingle-swagger-ui.png

Make sure to check the various sub-commands provided by jingle-demo, which allow you to interface with the REST APIs.

You can also run the demo app in Docker. First, build the image.

  make demo-docker

Once the image is built you can start up the service by executing the following command.

   docker run -p 5000:5000 cl-jingle:latest serve --address 0.0.0.0

You can also view a short demo of the command-line interface application, which interfaces with the REST API here.

./images/jingle-demo.gif

5Usage

Start up your REPL and load the JINGLE system.

  CL-USER> (ql:quickload :jingle)
  To load "jingle":
    Load 1 ASDF system:
      jingle
  ; Loading "jingle"
  ..................................................
  [package jingle.core].............................
  [package jingle]

  (:JINGLE)

First thing we need to do is to create a new JINGLE:APP instance.

  CL-USER> (defparameter *app* (jingle:make-app))
  *APP*

When creating a new instance of JINGLE:APP you can provide additional keyword args, which specify what HTTP server to use, address to bind to, the port to listen on, middlewares, etc..

A very simple HTTP handler, which returns Hello, World! looks like this.

  (defun hello-handler (params)
    (declare (ignore params))
    "Hello, World!")

The following is an example of an HTTP handler which echoes back the payload you send to it.

  (defun echo-handler (params)
    "A simple handler which echoes back the payload you send to it"
    (declare (ignore params))
    (jingle:set-response-header :content-type (jingle:request-content-type jingle:*request*))
    (jingle:set-response-header :content-length (jingle:request-content-length jingle:*request*))
    (maphash (lambda (k v)
               (jingle:set-response-header k v))
             (jingle:request-headers jingle:*request*))
    (jingle:request-content jingle:*request*))

Next thing we need to do is register our handlers.

  CL-USER> (setf (jingle:route *app* "/hello") #'hello-handler)
  CL-USER> (setf (jingle:route *app* "/echo" :method :post) #'echo-handler)

And now we can start the app.

  CL-USER> (jingle:start *app*)

Trying out our endpoints using curl(1) gives us this result.

  $ curl -vvv --get http://localhost:5000/hello
  *   Trying 127.0.0.1:5000...
  * Connected to localhost (127.0.0.1) port 5000 (#0)
  > GET /hello HTTP/1.1
  > Host: localhost:5000
  > User-Agent: curl/7.86.0
  > Accept: */*
  > 
  * Mark bundle as not supporting multiuse
  < HTTP/1.1 200 OK
  < Date: Fri, 09 Dec 2022 09:46:13 GMT
  < Server: Hunchentoot 1.3.0
  < Transfer-Encoding: chunked
  < Content-Type: text/html; charset=utf-8
  < 
  * Connection #0 to host localhost left intact
  Hello, World!

And this is our echo handler.

  $ curl -v -s --data '{"foo": "bar", "baz": "42"}' -H "My-Header: SomeValue" -H "Content-Type: application/json" -X POST http://localhost:5000/echo
  *   Trying 127.0.0.1:5000...
  * Connected to localhost (127.0.0.1) port 5000 (#0)
  > POST /echo HTTP/1.1
  > Host: localhost:5000
  > User-Agent: curl/7.86.0
  > Accept: */*
  > My-Header: SomeValue
  > Content-Type: application/json
  > Content-Length: 27
  > 
  * Mark bundle as not supporting multiuse
  < HTTP/1.1 200 OK
  < Date: Fri, 09 Dec 2022 13:57:30 GMT
  < Server: Hunchentoot 1.3.0
  < My-Header: SomeValue
  < Accept: */*
  < User-Agent: curl/7.86.0
  < Host: localhost:5000
  < Content-Length: 27
  < Content-Type: application/json
  < 
  * Connection #0 to host localhost left intact
  {"foo": "bar", "baz": "42"}

In order to stop the application, evaluate the following expression.

  CL-USER> (jingle:stop *app*)

5.1Handlers

Handlers are regular ningle routes, which accept a single argument, representing the request parameters.

5.2Environment

jingle exports the special variable JINGLE:*ENV* which is dynamically bound to the request environment of Lack. You can query the environment directly from jingle and don't have to worry about where the environment is coming from.

5.3Headers

jingle provides the JINGLE:SET-RESPONSE-HEADER function for setting up HTTP response headers.

A simple handler which sets the Content-Type header to text/plain looks like this.

  (defun hello (params)
    (declare (ignore params))
    (jingle:set-response-header :content-type "text/plain")
    "Hello, World!")

Other useful functions which operate on HTTP headers are JINGLE:GET-REQUEST-HEADER and JINGLE:GET-RESPONSE-HEADER, which retrieve the value of the HTTP header associated with the request and response respectively.

5.4Status Codes

The JINGLE:SET-RESPONSE-STATUS function sets the Status Code for the HTTP Response.

  (defun foo-handler (params)
    (declare (ignore params))
    (jingle:set-response-status :accepted)
    "Task accepted")

Arguments passed to JINGLE:SET-RESPONSE-STATUS may be a number (e.g. 400), a keyword (e.g. :bad-request), or a string (e.g. Bad Request) of the status code. The following three expressions are equivalent, and they all set the HTTP Status Code to 400 (Bad Request).

  (jingle:set-response-status 400)
  (jingle:set-response-status :bad-request)
  (jingle:set-response-status "Bad Request")

Another useful function operating on HTTP Status Codes is JINGLE:EXPLAIN-STATUS-CODE.

  CL-USER> (jingle:explain-status-code 400)
  "Bad Request"
  CL-USER> (jingle:explain-status-code :bad-request)
  "Bad Request"

JINGLE:STATUS-CODE-KIND returns the kind of the HTTP Status Code as classified by IANA, e.g.

  CL-USER> (jingle:status-code-kind 400)
  :CLIENT-ERROR
  CL-USER> (jingle:status-code-kind :unauthorized)
  :CLIENT-ERROR
  CL-USER> (jingle:status-code-kind :internal-server-error)
  :SERVER-ERROR
  CL-USER> (jingle:status-code-kind :moved-permanently)
  :REDIRECTION
  CL-USER> (jingle:status-code-kind 100)
  :INFORMATIONAL
  CL-USER> (jingle:status-code-kind "Accepted")
  :SUCCESS

Other HTTP status code predicates you may find useful are JINGLE:INFORMATIONAL-CODE-P, JINGLE:SUCCESS-CODE-P, JINGLE:REDIRECTION-CODE-P, JINGLE:CLIENT-ERROR-CODE-P and JINGLE:SERVER-ERROR-CODE-P.

5.5Static Resources

Static resources can be served by adding them using JINGLE:STATIC-PATH method, e.g.

  (jingle:static-path *app* "/static/" "~/public_html/")

You can serve static resources from multiple directories as well. In order to do that simply install them, before you start up the app.

  (jingle:static-path *app* "/static-1/" "/path/to/static-1/")
  (jingle:static-path *app* "/static-2/" "/path/to/static-2/")
  (jingle:static-path *app* "/static-3/" "/path/to/static-3/")

5.6Directory Browser

The JINGLE:SERVE-DIRECTORY method installs a middleware which allows you to browse the contents of a given path. For example the following code exposes the ~/Documents and ~/Projects directories.

  (jingle:serve-directory *app* "/docs" "~/Documents")
  (jingle:serve-directory *app* "/projects" "~/Projects")

When accessing the directories from the browser make sure to add a slash at the end of the paths. For example the above directories will have to accessed at http://localhost:5000/docs/ and http://localhost:5000/projects/ respectively, if you are using the default HTTP port when starting up the app.

5.7Middlewares

You can use regular Lack middlewares with jingle as well. Simply install them using the JINGLE:INSTALL-MIDDLEWARE method.

The following simple middleware pushes a new property to the request environment, which can be queried by the HTTP handlers.

First, implement the middleware.

  (defun my-middleware (app)
    "A custom middleware which pushes a new property to the request
  environment and exposes it to HTTP handlers."
    (lambda (env)
      (setf (getf env :my-middleware/message) "my middleware message")
      (funcall app env)))

Then we create a JINGLE:APP and install it.

  CL-USER> (defparameter *app* (jingle:make-app))
  CL-USER> (jingle:install-middleware *app* #'my-middleware)

An example handler which uses the message placed by our middleware may look like this.

  (defun my-handler (params)
    (declare (ignore params))
    (jingle:set-response-status :ok)
    (jingle:set-response-header :content-type "text/plain")
    (getf jingle:*env* :my-middleware/message))

Finally we have to register our handler and start the app.

  CL-USER> (setf (jingle:route *app* "/my-middleware") #'my-handler)
  CL-USER> (jingle:start *app*)

Trying it out using curl(1) returns the following response.

  $ curl -vvv --get http://localhost:5000/my-middleware
  *   Trying 127.0.0.1:5000...
  * Connected to localhost (127.0.0.1) port 5000 (#0)
  > GET /my-middleware HTTP/1.1
  > Host: localhost:5000
  > User-Agent: curl/7.86.0
  > Accept: */*
  > 
  * Mark bundle as not supporting multiuse
  < HTTP/1.1 200 OK
  < Date: Fri, 09 Dec 2022 11:42:17 GMT
  < Server: Hunchentoot 1.3.0
  < Transfer-Encoding: chunked
  < Content-Type: text/plain
  < 
  * Connection #0 to host localhost left intact
  my middleware message

Here's an example which uses Lack's accesslog middleware and how to use it with jingle. First, load the respective system, which provides the middleware, and then simply install it into the jingle app.

  CL-USER> (ql:quickload :lack-middleware-accesslog)
  CL-USER> (jingle:install-middleware *app* lack.middleware.accesslog:*lack-middleware-accesslog*)

Search for other middlewares you can already use in Quicklisp, e.g.

  CL-USER> (ql:system-apropos "lack-middleware")

You can use middlewares to push metadata into the environment for HTTP handlers to use. For example, if your HTTP handlers need to read from and write to a database, you may want to create a middleware, which pushes a CL-DBI connection into the environment, so that HTTP handlers can use it, when needed.

In order to clear out all installed middlewares you can use the JINGLE:CLEAR-MIDDLEWARES method, e.g.

  CL-USER> (jingle:clear-middlewares *app*)

5.8Redirects

Redirects in jingle are handled by the JINGLE:REDIRECT function.

An example HTTP handler which redirects to The Common Lisp Cookbook looks like this.

  (defun to-the-cookbook (params)
    (declare (ignore params))
    (jingle:redirect "https://lispcookbook.github.io/cl-cookbook/"))

Register the HTTP handler and start the app.

  CL-USER> (setf (jingle:route *app* "/cookbook") #'to-the-cookbook)
  CL-USER> (jingle:start *app*)

Navigate to http://localhost:5000/cookbook and you will be automatically redirected.

There is also another way for defining redirects using JINGLE:REDIRECT-ROUTE. The following example shows how to install two redirect routes to your jingle app, without having to explicitely define the HTTP handlers in advance.

  CL-USER> (jingle:redirect-route *app* "/sbcl" "https://sbcl.org/")
  CL-USER> (jingle:redirect-route *app* "/ecl" "https://ecl.common-lisp.dev/")

5.9Request Parameters

The JINGLE:GET-REQUEST-PARAM function may be used within HTTP handlers to get the value associated with a given parameter.

Suppose we have the following example HTTP handler, which returns information about supported products and is exposed via the /api/v1/product/:name endpoint.

  (defparameter *products*
    '((:|id| 1 :|name| "foo")
      (:|id| 2 :|name| "bar")
      (:|id| 3 :|name| "baz")
      (:|id| 4 :|name| "qux")
      (:|id| 5 :|name| "foo v2")
      (:|id| 6 :|name| "bar v3")
      (:|id| 7 :|name| "baz v4")
      (:|id| 8 :|name| "qux v5"))
    "The list of our supported products")

  (defun find-product-by-name (name)
    "Finds a product by name"
    (find name
          *products*
          :key (lambda (item) (getf item :|name|))
          :test #'string=))

  (defun product-handler (params)
    "Handles requests for /api/v1/product/:name endpoint"
    (jingle:set-response-status :ok)
    (jingle:set-response-header :content-type "application/json")
    (let* ((name (jingle:get-request-param params :name))
           (product (find-product-by-name name)))
      (if product
          (jonathan:to-json product)
          (progn
            (jingle:set-response-status :not-found)
            (jonathan:to-json '(:|error| "Product not found"))))))

Register the HTTP handler and start the app.

  CL-USER> (setf (jingle:route *app* "/api/v1/product/:name") #'product-handler)
  CL-USER> (jingle:start *app*)

Testing it out with different product names using curl(1).

  $ curl -s --get http://localhost:5000/api/v1/product/foo | jq '.'
  {
    "id": 1,
    "name": "foo"
  }

  $ curl -s --get http://localhost:5000/api/v1/product/bar | jq '.'
  {
    "id": 2,
    "name": "bar"
  }

  $ curl -s --get http://localhost:5000/api/v1/product/unknown | jq '.'
  {
    "error": "Product not found"
  }

Another example HTTP handler which returns a list of products in a paginated way, exposed via the /api/v1/products endpoint.

  (defun take (items from to)
    "A helper function to return the ITEMS between FROM and TO range"
    (let* ((len (length items))
           (to (if (>= to len) len to)))
      (if (>= from len)
          nil
          (subseq items from to))))

  (defun products-handler (params)
    "Handles requests for /api/v1/product and returns a page of products"
    (jingle:set-response-status :ok)
    (jingle:set-response-header :content-type "application/json")
    ;; Parse the `FROM' and `TO' query parameters. Use default values of
    ;; 0 and 5 for the params.
    (let ((from (parse-integer (jingle:get-request-param params "from" "0") :junk-allowed t))
          (to (parse-integer (jingle:get-request-param params "to" "5") :junk-allowed t)))
      (cond
        ((or (null from) (null to)) (jingle:set-response-status :bad-request) nil) ;; NIL added here for the response body
        ((or (minusp from) (minusp to)) (jingle:set-response-status :bad-request) nil) ;; NIL added here for the response body
        (t (jonathan:to-json (take *products* from to))))))

Register the new API endpoint.

  CL-USER> (setf (jingle:route *app* "/api/v1/products") #'products-handler)

Testing it out using curl(1) with different values for from and to query params.

  $ curl -s --get 'http://localhost:5000/api/v1/products?from=0&to=2' | jq '.'
  [
    {
      "id": 1,
      "name": "foo"
    },
    {
      "id": 2,
      "name": "bar"
    }
  ]

  $ curl -s --get 'http://localhost:5000/api/v1/products?from=2&to=4' | jq '.'
  [
    {
      "id": 3,
      "name": "baz"
    },
    {
      "id": 4,
      "name": "qux"
    }
  ]

Another way to retrieve request parameter values is to use the JINGLE:WITH-REQUEST-PARAMS macro. The previous example handler can be rewritten this way.

  (defun products-handler (params)
    (jingle:with-json-response
      (jingle:with-request-params ((from-param "from" "0") (to-param "to" "5")) params
        ;; Parse the query parameters and make sure we've got good values
        (let ((from (parse-integer from-param :junk-allowed t))
              (to (parse-integer to-param :junk-allowed t)))
          (cond
            ((or (null from) (null to))
             (jingle:set-response-status :bad-request)
             nil) ;; NIL added here for the response body
            ((or (minusp from) (minusp to))
             (jingle:set-response-status :bad-request)
             nil) ;; NIL added here for the response body
            (t (take *products* from to)))))))

5.10Macros

The following helper macros are available in jingle.

  • JINGLE:WITH-JSON-RESPONSE
  • JINGLE:WITH-REQUEST-PARAMS
  • JINGLE:WITH-HTML-RESPONSE

The JINGLE:WITH-JSON-RESPONSE macro sets up various HTTP headers such as Content-Type to application/json for you and evaluates the body. The last evaluated expression from the body is encoded as a JSON object using JONATHAN:TO-JSON.

The following example uses LOCAL-TIME and JONATHAN systems, so make sure you have them loaded already.

  (defclass ping-response ()
    ((message
      :initarg :message
      :initform "pong"
      :reader ping-response-message
      :documentation "Message to send as part of the response")
     (timestamp
      :initarg :timestamp
      :initform (local-time:now)
      :reader ping-response-timestamp))
    (:documentation "A response sent as part of a PING request"))

  (defmethod jonathan:%to-json ((object ping-response))
    (jonathan:with-object
      (jonathan:write-key-value "message" (ping-response-message object))
      (jonathan:write-key-value "timestamp" (ping-response-timestamp object))))

  (defun ping-handler (params)
    (declare (ignore params))
    (jingle:with-json-response
      (make-instance 'ping-response)))

Register the HTTP handler and start the app.

  CL-USER> (setf (jingle:route *app* "/api/v1/ping") #'ping-handler)
  CL-USER> (jingle:start *app*)

Trying it you should see results similar to the ones below.

  $ curl -s --get http://localhost:5000/api/v1/ping | jq '.'
  {
    "message": "pong",
    "timestamp": 1670593969
  }

  $ curl -s --get http://localhost:5000/api/v1/ping | jq '.'
  {
    "message": "pong",
    "timestamp": 1670593974
  }

  $ curl -s --get http://localhost:5000/api/v1/ping | jq '.'
  {
    "message": "pong",
    "timestamp": 1670593976
  }

The JINGLE:WITH-REQUEST-PARAMS macro provides an easy way to bind symbols to request params from within HTTP handlers.

  (defun foo-handler (params)
    (jingle:with-request-params ((foo "foo") (bar "bar")) params
      ;; Use FOO and BAR params in order to ...
      ...))

The JINGLE:WITH-HTML-RESPONSE is similar to JINGLE:WITH-JSON-RESPONSE, but sets up the response with a Content-Type: text/html; charset=utf-8 header.

5.11Error Handling

The JINGLE:BASE-HTTP-ERROR condition may be used as the base for user-defined conditions.

If a condition is signalled from within HTTP handlers and the condition is a sub-class of JINGLE:BASE-HTTP-ERROR, then the JINGLE:HANDLE-ERROR method will be invoked.

The purpose of JINGLE:HANDLE-ERROR is to handle the error and set up an appropriate HTTP response, which will be returned to the client.

The rest of this section describes how to create and use custom errors for a very simple REST API. The API we will develop provides the following endpoints.

  GET /api/v1/product        => Returns a list of products (supports `from` and `to` query params)
  GET /api/v1/product/:name  => Returns a product by name, if found

The error responses which we will return to clients would look like this.

  {
      "error": "<Reason for the error response>"
  }

First we will define our API-ERROR condition, and then define the JINGLE:HANDLE-ERROR method on it, so that we return consistent error responses to our API clients.

  (define-condition api-error (jingle:base-http-error)
    ()
    (:documentation "Represents a condition which will be signalled on API errors"))

  (defmethod jingle:handle-error ((error api-error))
    "Handles the error and sets up the HTTP error response to be sent to clients"
    (with-accessors ((code jingle:http-error-code)
                     (body jingle:http-error-body)) error
      (jingle:set-response-status code)
      (jingle:set-response-header :content-type "application/json")
      (jonathan:to-json (list :|error| body))))

Next, we will implement some helper functions that signal common client-error HTTP responses.

  (defun throw-not-found-error (message)
    "Throws a 404 (Not Found) HTTP response"
    (error 'api-error :code :not-found :body message))

  (defun throw-bad-request-error (message)
    "Throws a 400 (Bad Request) HTTP response"
    (error 'api-error :code :bad-request :body message))

Having our conditions and error-related functions we will also define another helper function, which will be responsible for parsing HTTP query parameters as integers, which we will use in our handlers.

  (defun get-int-param (params name &optional default)
    "Gets the NAME parameter from PARAMS and parses it as an integer.
  In case of invalid input it will signal a 400 (Bad Request) error"
    (let ((raw (jingle:get-request-param params name default)))
      (typecase raw
        (number raw)
        (null (throw-bad-request-error (format nil "missing value for `~A` param" name)))
        (string (let ((parsed (parse-integer raw :junk-allowed t)))
                  (unless parsed
                    (throw-bad-request-error (format nil "invalid value for `~A` param" name)))
                  parsed))
        (t (throw-bad-request-error (format nil "unsupported value for `~A` param" name))))))

We will be building on top of the products API, which was shown in a previous section. The *PRODUCTS* var will be our "database" in this simple API.

  (defparameter *products*
    '((:|id| 1 :|name| "foo")
      (:|id| 2 :|name| "bar")
      (:|id| 3 :|name| "baz")
      (:|id| 4 :|name| "qux")
      (:|id| 5 :|name| "foo v2")
      (:|id| 6 :|name| "bar v3")
      (:|id| 7 :|name| "baz v4")
      (:|id| 8 :|name| "qux v5"))
    "The list of our supported products")

  (defun find-product-by-name (name)
    "Finds a product by name"
    (find name
          *products*
          :key (lambda (item) (getf item :|name|))
          :test #'string=))

  (defun take (items from to)
    "A helper function to return the ITEMS between FROM and TO range"
    (let* ((len (length items))
           (to (if (>= to len) len to)))
      (if (>= from len)
          nil
          (subseq items from to))))

And these are the actual HTTP handlers, which will accept and handle client requests.

  (defun get-product-handler (params)
    "Handles requests for the /api/v1/product/:name endpoint"
    (jingle:with-json-response
      (let* ((name (jingle:get-request-param params :name))
             (product (find-product-by-name name)))
        (unless product
          (throw-not-found-error "product not found"))
        product)))

  (defun get-products-page-handler (params)
    "Handles requests for the /api/v1/product endpoint"
    (jingle:with-json-response
      (let ((from (get-int-param params "from" 0))
            (to (get-int-param params "to" 2)))
        (when (or (minusp from) (minusp to))
          (throw-bad-request-error "`from` and `to` must be positive"))
        (take *products* from to))))

Finally, we will create our JINGLE:APP, register our handlers and start serving HTTP requests.

  CL-USER> (defparameter *app* (jingle:make-app))
  CL-USER> (setf (jingle:route *app* "/api/v1/product") #'get-products-page-handler)
  CL-USER> (setf (jingle:route *app* "/api/v1/product/:name") #'get-product-handler)
  CL-USER> (jingle:start *app*)

Time to test things out.

  # Getting a page of products using default `from` and `to` params
  $ curl -s --get 'http://localhost:5000/api/v1/product' | jq '.'
  [
    {
      "id": 1,
      "name": "foo"
    },
    {
      "id": 2,
      "name": "bar"
    }
  ]

  # Getting next page of products
  $ curl -s --get 'http://localhost:5000/api/v1/product?from=2&to=4' | jq '.'
  [
    {
      "id": 3,
      "name": "baz"
    },
    {
      "id": 4,
      "name": "qux"
    }
  ]

  # Passing invalid query params
  $ curl -s --get 'http://localhost:5000/api/v1/product?from=bad-value' | jq '.'
  {
    "error": "invalid value for `from` param"
  }

  # Passing negative values
  $ curl -s --get 'http://localhost:5000/api/v1/product?to=-42' | jq '.'
  {
    "error": "`from` and `to` must be positive"
  }

  # Getting a product by name
  $ curl -s --get 'http://localhost:5000/api/v1/product/foo' | jq '.'
  {
    "id": 1,
    "name": "foo"
  }

  # Getting a non-existing product
  $ curl -s --get 'http://localhost:5000/api/v1/product/unknown' | jq '.'
  {
    "error": "product not found"
  }

Great, things work as expected and our API clients will receive consistent error responses with the proper HTTP status codes set.

5.12Reverse URLs

When you register routes in your app with names, you can then refer to these routes by their names. This is useful in situations where you need to get the URL for a particular route.

In order to get the URL for a route with a particular name use the JINGLE:URL-FOR generic function.

Consider the HTTP handlers we have shown in the previous section of this document.

  (defun get-product-handler (params)
    "Handles requests for the /api/v1/product/:name endpoint"
    (jingle:with-json-response
      (let* ((name (jingle:get-request-param params :name))
             (product (find-product-by-name name)))
        (unless product
          (throw-not-found-error "product not found"))
        product)))

  (defun get-products-page-handler (params)
    "Handles requests for the /api/v1/product endpoint"
    (jingle:with-json-response
      (let ((from (get-int-param params "from" 0))
            (to (get-int-param params "to" 2)))
        (when (or (minusp from) (minusp to))
          (throw-bad-request-error "`from` and `to` must be positive"))
        (take *products* from to))))

We can register these handlers and associate them with a name, which we can later refer to.

  CL-USER> (defparameter *app* (jingle:make-app))
  CL-USER> (setf (jingle:route *app* "/api/v1/product" :method :get :identifier "get-products-page")
                 #'get-products-page-handler)
  CL-USER> (setf (jingle:route *app* "/api/v1/product/:name" :method :get :identifier "get-product-by-name")
                 #'get-product-handler)

Now, we can get the actual URLs for our HTTP handlers by using their names.

  CL-USER> (jingle:url-for *app* "get-product-by-name" :name "foo")
  #<QURI.URI:URI /api/v1/product/foo>
  CL-USER> (jingle:url-for *app* "get-products-page" :|from| 0 :|to| 100)
  #<QURI.URI:URI /api/v1/product?from=0&to=100>

Resolving URLs using JINGLE:URL-FOR is also useful when you are creating test cases for your HTTP handlers. Within your test cases instead of manually constructing the URLs to the respective HTTP handlers you may refer to them by using their names.

Make sure to also check the JINGLE.DEMO system, which uses a handler registry, which is used for registering the HTTP handlers for a JINGLE:APP.

Once you resolve the URL for a particular handler you can construct the final URL, which will contain scheme, hostname, etc.

  CL-USER> (jingle:url-for *app* "get-product-by-name" :name "foo")
  #<QURI.URI:URI /api/v1/product/foo>
  CL-USER> (quri:merge-uris * (quri:make-uri :host "example.org" :scheme "https"))
  #<QURI.URI.HTTP:URI-HTTPS https://example.org/api/v1/product/foo>

5.13Testing HTTP Handlers

The JINGLE:TEST-APP is a test app meant to be used for test cases. The difference between JINGLE:TEST-APP and JINGLE:APP is that the test app always binds on 127.0.0.1 and listens on a random port within a given range.

Also, when using JINGLE:URL-FOR generic function with a JINGLE:TEST-APP the result is a full URL, which contains the scheme, the hostname and the port of the running test HTTP server.

Make sure to check the JINGLE.DEMO.TEST system for some examples, which provides the test suite for the demo application.

6Contributing

jingle is hosted on Github. Please contribute by reporting issues, suggesting features or by sending patches using pull requests.

7License

This project is Open Source and licensed under the BSD License.

8Authors

  • Marin Atanasov Nikolov <dnaeon@gmail.com>

Dependencies (15)

  • babel
  • clack
  • cl-ascii-table
  • clingon
  • cl-reexport
  • cl-str
  • dexador
  • find-port
  • jonathan
  • lack
  • local-time
  • myway
  • ningle
  • quri
  • rove

Dependents (0)

    • GitHub
    • Quicklisp