Far more web for much less code.
Spin is a small, Ring 2.0-compatible, Clojure library that helps you write HTTP endpoints that actually conform to HTTP.
Since the earliest web servers, dynamic behaviour in web servers has been built on the CGI: Common Gateway Interface. CGI itself has been consigned to history but the model of web development that it inspired is still by far the dominant model used by developers of dynamic websites and web APIs.
In CGI, a web-server captures information about a web request (such as the request method, path, remote address, query-string, etc.) and calls a sends it to an external program, script, plugin or in-process function, that generates the response on behalf of the web server.
As web-servers have evolved, virtually every web library and framework has inherited this primeval design: from Java’s 'servlets' and Ruby’s Rack and PHP to Node.js Express, Erlang’s Phoenix, Clojure’s Ring and hundreds more.
The problem at the heart of CGI, and everything based on it, is that it is an interface based on a web request rather than a web resource. It was designed to attach basic scripts to web servers, not to build the web itself.
The web is built on the HTTP protocol, which governs how browsers, web-servers, proxies and other participants communicate. What does HTTP provide? It’s right there in just the second paragraph:
HTTP provides a uniform interface for interacting with a resource
The problem with CGI-based web libraries is that CGI is too low-level. It asks too much of developers who have to recreate this 'resource' abstraction in every handler they write. Of course this results in HTTP services that are half-baked, half-implemented and non-comformant with HTTP. This makes the web poorer, more brittle, less inter-operable, less flexible, more expensive to create and maintain.
This project defines a new resource-oriented interface to replace CGI.
It also provides an adapter that converts the CGI-based request-oriented interface of Java/Clojure web servers to this new resource-oriented interface.
You provide the resource (in the form of data and functions). In exchange, you get a Ring 2.0 handler that fully conforms with HTTP.
Spin’s goal is to assist developers in implementing RFCs 7231-7235, and possibly other RFCs, as faithfully as possible.
Spin is a Clojure project because that language provides a very fast design iteration cycle. But it’s hoped that once Spin’s design stabilises it can be copied by others, just as CGI was.
Spin is still under active development and is ALPHA status, meaning that the API should be considered unstable and likely to change.
It is planned that Spin will be agnostic as to whether you are using Ring’s sync (1-arity) of async (3-arity) forms and will support both.
Spin is based on Clojure maps, with namespaced keywords. Functions take maps and, usually, return other maps.
You create a Ring handler with juxt.spin.alpha/handler
that takes a single
argument, the resource.
For examplel, to return the message "Hello World!" from a GET request:
(require '[juxt.spin.alpha :as spin])
(def hello-resource
{::spin/representation
{::spin/content "Hello World!\n"}})
(def hello
(spin/handler hello-resource))
(hello {:ring.request/method :get})
=>
{:ring.response/status 200
:ring.response/headers
{"content-length" "13"
"date" "Thu, 26 Nov 2020 16:53:14 GMT"}
:ring.response/body "Hello World!\n"}
The resource is a Clojure map.
It can contain any entries you like, to describe what you want.
But the ones with keywords in the juxt.spin.alpha
namespace are reserved. They
are the declarations that Spin uses to process a request properly.
Each resource entry is described below.
A function that takes a context argument and returns it, or (optionally) a modified version of it, if the request is valid.
The resource can be found in the :resource
entry of the context.
If the request is malformed or invalid in some way, the function MUST respond
directly using the function in the :respond!
entry of the context.
When responding, the function SHOULD use the value in the :response
entry of
the context (although it is free to modify it as necessary).
Say you have a resource that requires a query parameter to be present. Requests without this query parameter are considered invalid and should result in a 400 response.
{::spin/representation {}
::spin/validate-request!
(fn [{::spin/keys [request respond! response] :as ctx}]
(if (:ring.request/query request)
ctx
;; No query string, bad request!
(respond!
(assoc
response
:ring.response/status 400
:ring.response/body "Bad request!"))))}
The validate-request!
is also the place to authenticate the request and ensure
it is authorized to interact with the resource.
In this example, we use the validate-request!
function in conjunction with
some custom data in our resource to implement restrictions to a resource.
First, we’ll need to authenticate the request. We’ll assign each request a single role.
Warning
|
We’ll use an extremely silly authentication scheme (called Terrible )
FOR THE PURPOSES OF THIS EXPLANATION ONLY. Don’t copy this!
|
Then we’ll check the role provides the entitlement to access the resource using the method in the requested. We could use any arbitary authorization logic here instead.
The validate-request!
function first determines the role by authenticating the
request. Normally, you wouldn’t automatically trust the user agent like this,
instead, you’d add some way of verifying the trust, e.g. JWT signatures, a
database lookup.
{:roles {:superuser #{:get :head :put}
:manager #{:get :head}} (1)
::spin/representation {::spin/content "Secret stuff!"}
::spin/validate-request!
(fn [{::spin/keys [request respond! response] :as ctx}]
(when-let [role (2)
(case (get-in request
[:ring.request/headers "authorization"])
"Terrible let-me-in;role=superuser"
:superuser
"Terrible let-me-in;role=manager"
:manager
(respond! (3)
(-> response
(assoc :ring.response/status 401)
(assoc-in
[:ring.response/headers "www-authenticate"]
"Terrible"))))]
(if (get-in resource
[:roles role (:ring.request/method request)]) (4)
(assoc ctx :role role) (5)
(respond! (assoc response :ring.response/status 403)) (6)
)))}
-
some custom data in the resource map we’ll use later
-
authenticate the request
-
respond with a 401 if tell the user-agent to send credentials
-
is the method allowed for this role?
-
yes? then the request can proceed, return the ctx (adding the role)
-
no? then the request is forbidden, return a 403
A function that takes a context argument and returns a map corresponding to the selected representation’s metadata.
The representation should be chosen based on the request (the :request
entry
of the context) and the response (the :response
entry of the context). Usually
this means looking up the :ring.response/status
of the response, since the
desirable content type often depends on the status of the response. For
instance, the representation of an error might only be available in English,
regardless of the language preferences of the user agent.
Proactive content negotiation may be employed to determine the representation.
If there are no representations, regardless of their acceptability, you MUST
respond with a 404 response, calling the :respond!
function provided in the
context argument.
Otherwise, if none of the representations are acceptable, you MAY respond with a
406 response, in which you SHOULD add a Vary
header. See
Section 7.1.4 of RFC
7231 for how to construct the Vary
header.
Alternatively, you may wish to return one anyway, since "sending a response that doesn’t conform to the user agent’s preferences" might be "better than sending a 406" (see Section 3.4.1 of RFC 7231).
Finally, if you wish to use Reactive Negotiation, respond with a 300 response with the response payload of your choosing. See Section 3.4.2 of RFC 7231 for further details.
A map that maps method keywords to their implementations.
If this entry is not provided, the resource will have default implementations of GET, HEAD and OPTIONS.
To indicate the methods on a resource, add a ::spin/methods
entry.
{::spin/methods
{:post
(fn [ctx]
;; Insert new record into database
(spin/resource-created! ctx "/new-resource"))}}
The implementations are as follows.
A function that takes the context as an argument.
The function is called on a POST request.
The get
method should respond with a Ring response containing the selected
representation.
A function that takes the context as an argument.
The function is called on a POST request. It is responsible for any data
processing associated with a POST. If a new resource is created, it should
respond with a 201 status and a Location
header containing the URL of the new
resource. A convenience function is available
(juxt.spin.alpha/resource-created!
) which does this.
To respond, it should call the respond!
function provided in the context
argument with the (Ring 2.0) response as an argument.
See Section 4.3.3 of RFC 7231 for further details.
A function that takes the context as an argument.
The function is called on a PUT request.
Generally speaking, the put
function is responsible for replacing the state
of the target resource with the representation enclosed in the request message
payload.
Like the post
method, a PUT should respond with a 201 status is the target
resource doesn’t have a representation until the PUT successfully creates
one. Otherwise, it should respond with a 200 (or 204) to indicate successful
modification of an existing representation.
To respond, it should call the respond!
function provided in the context
argument with the (Ring 2.0) response as an argument.
See Section 4.3.4 of RFC 7231 for further details.
A function that takes the context as an argument.
The function is called on a DELETE request.
To respond, it should call the respond!
function provided in the context
argument with the (Ring 2.0) response as an argument.
See Section 4.3.5 of RFC 7231 for further details.
The select-representation!
function should return
representation metadata. This is a Clojure map which can contain any data, but
entries with keywords in the juxt.spin.alpha
are meaningful to Spin.
A representation can declare a single-arity function which will generate the actual Ring response.
If this is not provided, then Spin will do its best to return a representation defined by the representation metadata in the map.
On each request, a request context is created. This is a map with the following entries:
A map describing a Ring request, see https://github.com/ring-clojure/ring/blob/2.0/SPEC-2.md
A callback function that is used to return a Ring response, which is map. See https://github.com/ring-clojure/ring/blob/2.0/SPEC-2.md for full details.
A callback function that is used to raise any errors. See https://github.com/ring-clojure/ring/blob/2.0/SPEC-2.md for full details.
The target resource, as a map. See Resources.
Keywords are all in the juxt.spin.alpha
namespace, unless otherwise stated.
Keywords that end in a !
indicate functions that can directly produce a Ring
response via the respond!
callback provided in the first parameter of the
function. Sending a response back to the user agent is certainly a side-effect,
so the Clojure convention is adopted of marking functions that potentially cause
side-effects.
JUXT publish another library, yada, which shares similar goals to this project. Spin in a much younger project, and is hoped to be an official successor to yada. They do have simiarities but Spin is smaller, with fewer dependencies, and a significantly different design. In comparison, Spin can be considered less opinionated and more modular than yada. But at the present time, it doesn’t quite have as much funcionality built-in. This may change over time, of course.
Both Spin and yada fully support fulfilling each request in an asynchronous manner, to avoid blocking the request thread. In the case of yada, Manifold is used to provide async chaining of operations.
Spin is built on the asynchronous standard defined in Ring 1.6 which was not yet established when yada was designed. This provides independence from the underlying server and full compatibility with existing Ring middleware. In contrast, yada's use of Manifold fixes it to aleph, a Clojure wrapper on Netty.
However, one sizeable benefit of yada's dependence on Aleph does mean it is easy access to create asynchronous response streams, for instance, to create streams of server-sent events. Work is underway on a comparable set of functionality for Spin based on Vert.x, via our Vext project, although this is some way from feature parity.
In yada, blocking operations can be wrapped in asynchronous chains using
Manifold’s chain
function. In Spin, the respond!
function can be passed
around between threads and invoked in a different thread from the request
thread, which can prevent blocking the request thread during the request
processing. For non-blocking steaming of response payloads (which might be
standardised in a future Ring 2.1), there is some work underway within Vext on
adopting the Java interfaces defined by
Reactive Streams.
yada uses Primatic Schema for validation of its resource maps. Spin uses Clojure’s now built-in spec.
Sometimes you need to take over request processing from a library and send your
own response. In yada,
explicit
responses are provided. In Spin, care has been taken to allow for the calling
of the respond!
callback. This allows implementations direct control of the
response.
yada supports the definition, via Prismatic Schema, of parameters to facilitate the generation of OpenAPI (Swagger) descriptions.
Spin is agnostic to OpenAPI, and does not involve itself in the specification of the types of parameters, request and response bodies. However, it is designed to complement other projects that may seek to add these facilities to Spin. One example is our Apex project, which aims to process parameters according to their definitions in OpenAPI documents. The reconvergence of OpenAPI 3.1.0 with JSON Schema hasn’t escaped our notice, and we hope this will allow direct use of JSON Schema, possibly supported by our jinx library.
yada supports a limited form of content negotiation, but is unable to use the response status code in its determination of available variants. In Spin, the status code is computed earlier, and can be used in content negotiation. This is particularly relevant to OpenAPI, which allows for different status codes their own variants.
Spin aligns directly with OpenAPI’s declaration hierarchy: paths → operations → statuses → content-types. In comparison, in yada, the available content-types for a given resource are usually declared statically, without taking the response status code into consideration. Error representations, in particular, are fixed, whereas in Spin a resource’s variant representations are computed dynamically, and can factor in the response’s status code into the decision.
For proactive (server-driven) content negotiation, Spin is designed to interoperate with external algorithms, in particular, with pick.
The MIT License (MIT)
Copyright © 2020 JUXT LTD.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.