Packages

LiveView-style runtime for Gleam.

Current section

Files

Jump to
lightspeed src lightspeed framework endpoint.gleam
Raw

src/lightspeed/framework/endpoint.gleam

//// Endpoint + middleware pipeline with controller and live-route dispatch.
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import lightspeed/framework/controller
import lightspeed/framework/http
import lightspeed/framework/verified_routes
import lightspeed/transport/contract
import lightspeed/transport/wisp_html
/// Endpoint middleware function.
pub type Middleware =
fn(http.Conn) -> http.Conn
type ControllerHandler =
fn(http.Conn) -> http.Conn
type LiveHandler =
fn(http.Conn) -> String
type Route {
ControllerRoute(method: http.Method, pattern: String, run: ControllerHandler)
LiveRoute(
method: http.Method,
pattern: String,
view_id: String,
render: LiveHandler,
)
}
/// One in-memory static asset served by this endpoint.
pub type StaticAsset {
StaticAsset(path: String, content_type: String, body: String)
}
/// Endpoint definition.
pub opaque type Endpoint {
Endpoint(
websocket_path: String,
auth_hook: contract.AuthHook,
middleware_rev: List(Middleware),
routes_rev: List(Route),
static_assets_rev: List(StaticAsset),
)
}
type Dispatch {
ControllerDispatch(run: ControllerHandler, params: List(http.RouteParam))
LiveDispatch(
view_id: String,
render: LiveHandler,
params: List(http.RouteParam),
)
}
/// Create an endpoint.
pub fn new(auth_hook: contract.AuthHook, websocket_path: String) -> Endpoint {
Endpoint(
websocket_path: websocket_path,
auth_hook: auth_hook,
middleware_rev: [],
routes_rev: [],
static_assets_rev: [],
)
}
/// Register one middleware in endpoint order.
pub fn pipe(endpoint: Endpoint, middleware: Middleware) -> Endpoint {
Endpoint(..endpoint, middleware_rev: [middleware, ..endpoint.middleware_rev])
}
/// Register a GET controller route from a verified route helper.
pub fn get_controller(
endpoint: Endpoint,
route: verified_routes.VerifiedRoute(params),
run: ControllerHandler,
) -> Endpoint {
let pattern = verified_routes.pattern(route)
Endpoint(..endpoint, routes_rev: [
ControllerRoute(method: http.Get, pattern: pattern, run: run),
..endpoint.routes_rev
])
}
/// Register a GET controller route from one router-indexed verified route.
pub fn get_controller_indexed(
endpoint: Endpoint,
route: verified_routes.IndexedRoute(params, verified_routes.GetTag),
run: ControllerHandler,
) -> Endpoint {
get_controller(endpoint, verified_routes.as_verified_route(route), run)
}
/// Register a GET live route from a verified route helper.
pub fn get_live(
endpoint: Endpoint,
route: verified_routes.VerifiedRoute(params),
view_id: String,
render: LiveHandler,
) -> Endpoint {
let pattern = verified_routes.pattern(route)
Endpoint(..endpoint, routes_rev: [
LiveRoute(
method: http.Get,
pattern: pattern,
view_id: view_id,
render: render,
),
..endpoint.routes_rev
])
}
/// Register a GET live route from one router-indexed verified route.
pub fn get_live_indexed(
endpoint: Endpoint,
route: verified_routes.IndexedRoute(params, verified_routes.GetTag),
view_id: String,
render: LiveHandler,
) -> Endpoint {
get_live(endpoint, verified_routes.as_verified_route(route), view_id, render)
}
/// Register one static asset.
pub fn static(
endpoint: Endpoint,
path: String,
content_type: String,
body: String,
) -> Endpoint {
Endpoint(..endpoint, static_assets_rev: [
StaticAsset(
path: normalize_path(path),
content_type: content_type,
body: body,
),
..endpoint.static_assets_rev
])
}
/// Handle one request through middleware and dispatch tables.
pub fn call(endpoint: Endpoint, request: http.Request) -> http.Response {
let conn =
run_middleware(request |> http.from_request, endpoint.middleware_rev)
case http.halted(conn) {
True -> http.to_response(conn)
False ->
case serve_static(endpoint, conn) {
Some(served) -> http.to_response(served)
None ->
case find_dispatch(endpoint, conn) {
Some(ControllerDispatch(run, params)) ->
conn
|> http.with_route_params(params)
|> run
|> http.to_response
Some(LiveDispatch(view_id, render, params)) ->
conn
|> http.with_route_params(params)
|> render_live(endpoint, view_id, render)
|> http.to_response
None -> conn |> controller.not_found |> http.to_response
}
}
}
}
/// Stable endpoint route table labels.
pub fn route_labels(endpoint: Endpoint) -> List(String) {
endpoint.routes_rev
|> list.reverse
|> list.map(route_label)
}
/// Stable static table labels.
pub fn static_labels(endpoint: Endpoint) -> List(String) {
endpoint.static_assets_rev
|> list.reverse
|> list.map(fn(asset) { "static:" <> asset.path })
}
fn run_middleware(conn: http.Conn, reversed: List(Middleware)) -> http.Conn {
case reversed {
[] -> conn
[middleware, ..rest] ->
case http.halted(conn) {
True -> conn
False -> run_middleware(middleware(conn), rest)
}
}
}
fn serve_static(endpoint: Endpoint, conn: http.Conn) -> Option(http.Conn) {
case http.request_method(conn) {
http.Get -> {
let path = normalize_path(http.request_path(conn))
case find_static(endpoint.static_assets_rev, path) {
Some(asset) ->
Some(http.send(conn, 200, asset.content_type, asset.body))
None -> None
}
}
_ -> None
}
}
fn find_static(
static_assets: List(StaticAsset),
path: String,
) -> Option(StaticAsset) {
case static_assets {
[] -> None
[asset, ..rest] ->
case asset.path == path {
True -> Some(asset)
False -> find_static(rest, path)
}
}
}
fn find_dispatch(endpoint: Endpoint, conn: http.Conn) -> Option(Dispatch) {
let method = http.request_method(conn)
let path = normalize_path(http.request_path(conn))
endpoint.routes_rev |> list.reverse |> find_dispatch_loop(method, path)
}
fn find_dispatch_loop(
routes: List(Route),
method: http.Method,
path: String,
) -> Option(Dispatch) {
case routes {
[] -> None
[route, ..rest] ->
case route {
ControllerRoute(route_method, pattern, run) ->
case
method_matches(route_method, method),
match_pattern(pattern, path)
{
True, Some(params) ->
Some(ControllerDispatch(run: run, params: params))
_, _ -> find_dispatch_loop(rest, method, path)
}
LiveRoute(route_method, pattern, view_id, render) ->
case
method_matches(route_method, method),
match_pattern(pattern, path)
{
True, Some(params) ->
Some(LiveDispatch(
view_id: view_id,
render: render,
params: params,
))
_, _ -> find_dispatch_loop(rest, method, path)
}
}
}
}
fn render_live(
conn: http.Conn,
endpoint: Endpoint,
view_id: String,
render: LiveHandler,
) -> http.Conn {
let request =
wisp_html.HtmlRequest(
session_id: http.request_session_id(conn),
route: normalize_path(http.request_path(conn)),
csrf_token: http.request_csrf_token(conn),
origin: http.request_origin(conn),
)
case
wisp_html.render_initial(
request,
render(conn),
endpoint.websocket_path,
endpoint.auth_hook,
)
{
Ok(response) ->
conn
|> http.send(
wisp_html.status(response),
"text/html; charset=utf-8",
wisp_html.body(response),
)
|> http.put_header("x-lightspeed-view-id", view_id)
Error(error) ->
controller.internal_error(conn, contract.error_to_string(error))
}
}
fn route_label(route: Route) -> String {
case route {
ControllerRoute(method, pattern, _) ->
"controller:" <> http.method_label(method) <> ":" <> pattern
LiveRoute(method, pattern, view_id, _) ->
"live:" <> http.method_label(method) <> ":" <> pattern <> ":" <> view_id
}
}
fn method_matches(expected: http.Method, actual: http.Method) -> Bool {
http.method_label(expected) == http.method_label(actual)
}
fn match_pattern(
pattern: String,
path: String,
) -> Option(List(http.RouteParam)) {
let pattern_segments = segments(pattern)
let path_segments = segments(path)
match_segments(pattern_segments, path_segments, [])
}
fn match_segments(
pattern_segments: List(String),
path_segments: List(String),
captures_rev: List(http.RouteParam),
) -> Option(List(http.RouteParam)) {
case pattern_segments, path_segments {
[], [] -> Some(list.reverse(captures_rev))
[], _ -> None
_, [] -> None
[pattern_segment, ..pattern_rest], [path_segment, ..path_rest] ->
case route_param_name(pattern_segment) {
Some(name) ->
match_segments(pattern_rest, path_rest, [
http.RouteParam(name: name, value: path_segment),
..captures_rev
])
None ->
case pattern_segment == path_segment {
True -> match_segments(pattern_rest, path_rest, captures_rev)
False -> None
}
}
}
}
fn route_param_name(segment: String) -> Option(String) {
case string.to_graphemes(segment) {
[":", ..rest] ->
case rest {
[] -> None
_ -> Some(join_graphemes(rest))
}
_ -> None
}
}
fn normalize_path(path: String) -> String {
let no_query = case string.split(path, "?") {
[first, ..] -> first
[] -> path
}
let trimmed = trim_trailing_slashes(no_query)
case trimmed {
"" -> "/"
_ -> trimmed
}
}
fn trim_trailing_slashes(path: String) -> String {
case path {
"/" -> "/"
_ ->
case ends_with_slash(path) {
True -> trim_trailing_slashes(drop_last(path))
False -> path
}
}
}
fn segments(path: String) -> List(String) {
path
|> string.split("/")
|> filter_empty([])
}
fn filter_empty(values: List(String), kept_rev: List(String)) -> List(String) {
case values {
[] -> list.reverse(kept_rev)
[value, ..rest] ->
case value == "" {
True -> filter_empty(rest, kept_rev)
False -> filter_empty(rest, [value, ..kept_rev])
}
}
}
fn ends_with_slash(path: String) -> Bool {
case list.reverse(string.to_graphemes(path)) {
["/", ..] -> True
_ -> False
}
}
fn drop_last(path: String) -> String {
case list.reverse(string.to_graphemes(path)) {
[] -> ""
[_removed, ..rest] -> rest |> list.reverse |> join_graphemes
}
}
fn join_graphemes(graphemes: List(String)) -> String {
case graphemes {
[] -> ""
[grapheme, ..rest] -> grapheme <> join_graphemes(rest)
}
}