Revise
-compatible hot-reloading middleware for web development in Julia
Development utilities for improving your workflow when building web
applications in pure-Julia. This package integrates with Revise
and HTTP
to
allow for automatic hot-reloading pages with "in-place" DOM-swapping rather
than full-page refreshes.
To scaffold a new web app project using this package and a collection of other useful packages you can run the following copy-paste code in the Julia REPL. It will launch an interactive prompt to ask you for some details about your project and then create all the starter files for you.
julia> import Pkg
Pkg.activate(; temp = true)
Pkg.add(["ReloadableMiddleware", "PkgTemplates"])
using ReloadableMiddleware
ReloadableMiddleware.Templates.create()
This will avoid polluting your global environment with packages and will create a new project in the current directory.
A HotReloader
can be added to the top of a HTTP
router stack to enable
hot-reloading of page content.
using HTTP
using ReloadableMiddleware
hot = HotReloader()
router = HTTP.Router()
# Add some routes to the `router`...
HTTP.serve!(router |> hot.middleware, HTTP.Sockets.localhost, 8080)
# Later on:
hot.refresh() # Refresh the page.
Calling hot.refresh()
will cause any clients connected to the server to
reload their page. The reload is done "in-place" by swapping out the DOM
content with the new content for a page rather than doing a full page refresh.
This does not perform any file-watching or other automatic reloading. It is
intended to be used in conjunction with separate file-watching utilities such
as the FileWatching
module.
This type can be used to automatically create an HTTP.Router
based on the functions
defined in a given module, or modules. The router automatically updates itself when
changes are detected by Revise
if a HotReloader
is present in the router stack.
You can define functions within a module that should act as endpoints for the
router with the @route
and @req
macros. Some examples follow which describe
the general form of the @req
macro and what it supports. @route
simply marks the
function as a route handler.
module Routes
@route function index(req::@req GET "/")
# Just a plain GET / request.
end
@route function search(req::@req GET "/search" query = {q})
# GET /search?q=... where the `q` parameter is left as a `String` value.
end
@route function table(req::@req GET "/table" query = {page::Int})
# GET /table?page=... where the `page` parameter is parsed as an `Int` for pagination.
# When `page` is not parsable to an `Int` then we don't call this function, and an error
# is returned to the client. So we can guarantee that `page` exists and is an `Int` here.
end
@route function user_account(req::@req GET "/user/$(id::Base.UUID)")
# GET /user/{id} where the `id` parameter is parsed as a `UUID`. When not parsable to a
# `UUID` then we don't call this function, and an error is returned to the client.
end
@route function blog_post(req::@req GET "/blog/$(title)")
# GET /blog/{title} where the `title` parameter is parsed as a `String`.
end
# Use `(` and `)` when you need multi-line syntax.
@route function edit_post(
req::@req(
POST,
"/blog/$(title)",
query = {
author,
content = "blank content",
date::Dates.Date
},
)
)
# POST /blog/{title} where the `title` parameter is parsed as a `String`. Form data is
# pass via URL query parameters. If it fails to parse then we don't call this function, and
# an error is returned to the client. `content` is optional and defaults to `"blank content"`
# if not provided.
end
@route function edit_post_form_data(
req::@req(
POST,
"/blog/$(title)",
form = {
author,
content = "blank content",
date::Dates.Date
}
)
)
# POST /blog/{title} where the `title` parameter is parsed as a `String`. Data form data is
# parsed from the request body. If it fails to parse then we don't call this function, and
# an error is returned to the client. `content` is optional and defaults to `"blank content"`.
end
end
To turn the above Router
module into a router we can do the following:
router = MoudleRouter(Routes)
HTTP.serve(router, HTTP.Sockets.localhost, 8080)
Note that the ModuleRouter
automatically integrates with the HotReloader
middleware if it is included in the router stack. This means that you can make
changes to the Routes
module and have them automatically reflected in the
running server as soon as changes are made and you save the file.
During development it can be useful to have a way to explore the API that your
server exposes. To this end, the ModuleRouter
automatically adds a /api
route to the router which can be used to explore the API via a web interface.
In production (when Revise
is loaded) this route does not exist. If the
default /api
route conflicts with your application then you can change the
route by passing the api_route
keyword argument to the ModuleRouter
constructor.
Each endpoint that your application exposes is displayed as a separate page
which includes all param
, query
, and form
fields that are defined for
that endpoint. In addition, any docstrings attached to endpoints are included
in the page for easy reference.
When the HotReloader
middleware is present in the router stack then the /api
route will automatically update itself when changes are made to any routes, such
as docstring changes, or changes to the @req
macros that define the routes.
@req(method, path, [query], [form])
where method
can be any of
GET
POST
PUT
DELETE
PATCH
"*"
(due to Julia's macro syntax this must be a string literal)
path
is either a plain String
literal representing the path, or a string
literal with interpolated values. Interpolated values are parsed as follows:
$(x)
is parsed as aString
value for the field namedx
.$(x::T)
is parsed as a value of typeT
whereT
is any type that can be parsed from aString
viaBase.parse
for the field namedx
.
These values are then available as fields in the params
field of the Req
object
passed to the function. For example, the following function:
@route function get_post(req::@req GET "/blog/$(id::Base.UUID)")
req.params.id # This is a `Base.UUID` value.
end
The optional query
and form
fields are used to parse query parameters and
form data respectively. These fields are parsed as follows:
query = {x}
orform = {x}
is parsed as a query parameter namedx
which is parsed as aString
value.query = {x::T}
orform = {x::T}
is parsed as a query parameter namedx
which is parsed as a value of typeT
whereT
is any type that can be parsed from aString
viaBase.parse
, the same as forparams
above. If the parameter needs to contain multiple values, e.g.a=1&a=2
thenT
can be aVector
of the type of the individual values in which case the value ofa
will be["1", "2"]
whenVector{String}
is specified.- any number of fields can be specified in the
{...}
syntax, separated by commas. - default values can be specified by using
x = default
instead ofx
in the above syntax. Orx::T = default
for a typed default value. These values are created on-demand when the parameter is not present in the request and are not created until needed.
form
data by default assumes that the request body syntax is
application/x-www-form-urlencoded
and that that the Content-Type
header is
set to application/x-www-form-urlencoded
. Parsing of this text is done using
URIs.parseparams(s::AbstractString)
.
JSON data parsing can be enabled by setting the Content-Type
header to
application/json
in the request. This will cause the form
fields to be
parsed as JSON instead of as form data. The {...}
syntax is still used, so
each field in the NamedTuple
to is parsed is a JSON-parsed value. JSON3.jl
is used for the parsing, and as such we support using custom Julia types for
the resulting values so long as suitable StructType
definitions are provided.
Please see the JSON3.jl
documentation for more information on this topic.
struct CustomType
x::Int
y::Float64
end
@route function json_endpoint(req::@req POST "/json" form = {x::CustomType})
# `x` is parsed as a `CustomType` value.
end
The HTTP request would then need to be sent as follows:
POST /json HTTP/1.1
Content-Type: application/json
{
"x": {
"x": 1,
"y": 2.0
}
}
Multi-part form data can be parsed by setting the Content-Type
header to
multipart/form-data
in the request. This will cause the form
fields to be
parsed as multi-part form data instead of url encoded, or JSON data. The {...}
syntax is still used, and each field in the NamedTuple
is a single value from
the submitted form data that is deserialized into the declared type separately.
struct File
name::String
data::Vector{UInt8}
end
function File(multipart::HTTP.Multipart)
name = multipart.filename
data = take!(multipart.data)
File(name, data)
end
@route function multi_part_endpoint(::@req POST "/upload_file" form = {file::File})
# `file` is parsed as a `File` value.
end
Deserialization into the declared field type is handled by defining a
constructor for the type that takes a HTTP.Multipart
object. The
HTTP.Multipart
object contains the filename and data for the file along with
other data. Consult the HTTP.jl
documentation for more information on the
HTTP.Multipart
type.