Current section
Files
Jump to
Current section
Files
src/lightspeed/framework/verified_routes.gleam
//// Verified-route-style compile-time path helpers and router-index verification.
import gleam/list
import gleam/string
/// One verified route helper carrying a type-safe parameter contract.
pub opaque type VerifiedRoute(params) {
VerifiedRoute(
pattern: String,
param_names: List(String),
build: fn(params) -> String,
)
}
/// Route method used by strict route references.
pub type RouteMethod {
MethodGet
MethodPost
MethodPut
MethodPatch
MethodDelete
}
/// Phantom method marker for GET routes.
pub type GetTag {
GetTag
}
/// Phantom method marker for POST routes.
pub type PostTag {
PostTag
}
/// Phantom method marker for PUT routes.
pub type PutTag {
PutTag
}
/// Phantom method marker for PATCH routes.
pub type PatchTag {
PatchTag
}
/// Phantom method marker for DELETE routes.
pub type DeleteTag {
DeleteTag
}
/// One router-indexed route with typed params and typed method marker.
pub opaque type IndexedRoute(params, method) {
IndexedRoute(name: String, method: RouteMethod, route: VerifiedRoute(params))
}
/// Strict route index used for deterministic reference verification fixtures.
pub opaque type RouterIndex {
RouterIndex(routes_rev: List(IndexedMeta))
}
/// Unchecked route reference used by compile fixtures.
pub type RouteReference {
RouteReference(name: String, method: RouteMethod, param_names: List(String))
}
/// Deterministic route compile diagnostic.
pub type RouteDiagnostic {
UnknownRoute(name: String)
MethodMismatch(name: String, expected: RouteMethod, actual: RouteMethod)
ParamMismatch(name: String, expected: List(String), actual: List(String))
}
type IndexedMeta {
IndexedMeta(
name: String,
method: RouteMethod,
pattern: String,
param_names: List(String),
)
}
/// Build a zero-parameter route helper.
pub fn route0(pattern: String) -> VerifiedRoute(Nil) {
VerifiedRoute(pattern: pattern, param_names: [], build: fn(_) { pattern })
}
/// Build a one-parameter route helper.
///
/// Example:
/// `route1("/posts/:id", "id")`
pub fn route1(pattern: String, param: String) -> VerifiedRoute(String) {
let token = ":" <> param
VerifiedRoute(pattern: pattern, param_names: [param], build: fn(value) {
string.replace(pattern, each: token, with: value)
})
}
/// Build a two-parameter route helper.
///
/// Example:
/// `route2("/teams/:team_id/members/:id", "team_id", "id")`
pub fn route2(
pattern: String,
first_param: String,
second_param: String,
) -> VerifiedRoute(#(String, String)) {
let first_token = ":" <> first_param
let second_token = ":" <> second_param
VerifiedRoute(
pattern: pattern,
param_names: [first_param, second_param],
build: fn(values) {
let #(first_value, second_value) = values
let first = string.replace(pattern, each: first_token, with: first_value)
string.replace(first, each: second_token, with: second_value)
},
)
}
/// Render path for one verified route.
pub fn path(route: VerifiedRoute(params), params: params) -> String {
route.build(params)
}
/// Render path with query pairs. Empty query emits no `?`.
pub fn path_with_query(
route: VerifiedRoute(params),
params: params,
query: List(#(String, String)),
) -> String {
let base = path(route, params)
let rendered_query = query_string(query)
case rendered_query {
"" -> base
_ -> base <> "?" <> rendered_query
}
}
/// Route pattern string.
pub fn pattern(route: VerifiedRoute(params)) -> String {
route.pattern
}
/// Route param-name contract for deterministic verification checks.
pub fn param_names(route: VerifiedRoute(params)) -> List(String) {
route.param_names
}
/// Create an empty router index.
pub fn new_index() -> RouterIndex {
RouterIndex(routes_rev: [])
}
/// Register a named GET zero-param route in the router index.
pub fn index_get0(
index: RouterIndex,
name: String,
pattern: String,
) -> #(RouterIndex, IndexedRoute(Nil, GetTag)) {
let route = route0(pattern)
let indexed = IndexedRoute(name: name, method: MethodGet, route: route)
let updated = add_index_entry(index, indexed)
#(updated, indexed)
}
/// Register a named GET one-param route in the router index.
pub fn index_get1(
index: RouterIndex,
name: String,
pattern: String,
param: String,
) -> #(RouterIndex, IndexedRoute(String, GetTag)) {
let route = route1(pattern, param)
let indexed = IndexedRoute(name: name, method: MethodGet, route: route)
let updated = add_index_entry(index, indexed)
#(updated, indexed)
}
/// Register a named GET two-param route in the router index.
pub fn index_get2(
index: RouterIndex,
name: String,
pattern: String,
first_param: String,
second_param: String,
) -> #(RouterIndex, IndexedRoute(#(String, String), GetTag)) {
let route = route2(pattern, first_param, second_param)
let indexed = IndexedRoute(name: name, method: MethodGet, route: route)
let updated = add_index_entry(index, indexed)
#(updated, indexed)
}
/// Register a named POST zero-param route in the router index.
pub fn index_post0(
index: RouterIndex,
name: String,
pattern: String,
) -> #(RouterIndex, IndexedRoute(Nil, PostTag)) {
let route = route0(pattern)
let indexed = IndexedRoute(name: name, method: MethodPost, route: route)
let updated = add_index_entry(index, indexed)
#(updated, indexed)
}
/// Register a named POST one-param route in the router index.
pub fn index_post1(
index: RouterIndex,
name: String,
pattern: String,
param: String,
) -> #(RouterIndex, IndexedRoute(String, PostTag)) {
let route = route1(pattern, param)
let indexed = IndexedRoute(name: name, method: MethodPost, route: route)
let updated = add_index_entry(index, indexed)
#(updated, indexed)
}
/// Access one indexed route as a legacy verified route.
pub fn as_verified_route(
route: IndexedRoute(params, method),
) -> VerifiedRoute(params) {
route.route
}
/// Indexed route label.
pub fn indexed_name(route: IndexedRoute(params, method)) -> String {
route.name
}
/// Indexed route method.
pub fn indexed_method(route: IndexedRoute(params, method)) -> RouteMethod {
route.method
}
/// Indexed route static pattern path.
pub fn static_path(route: IndexedRoute(params, method)) -> String {
route.route.pattern
}
/// Render path for one indexed route.
pub fn path_from_indexed(
route: IndexedRoute(params, method),
params: params,
) -> String {
path(route.route, params)
}
/// Render path with query for one indexed route.
pub fn path_with_query_from_indexed(
route: IndexedRoute(params, method),
params: params,
query: List(#(String, String)),
) -> String {
path_with_query(route.route, params, query)
}
/// Build a zero-parameter unchecked reference.
pub fn reference0(name: String, method: RouteMethod) -> RouteReference {
RouteReference(name: name, method: method, param_names: [])
}
/// Build a one-parameter unchecked reference.
pub fn reference1(
name: String,
method: RouteMethod,
param: String,
) -> RouteReference {
RouteReference(name: name, method: method, param_names: [param])
}
/// Build a two-parameter unchecked reference.
pub fn reference2(
name: String,
method: RouteMethod,
first_param: String,
second_param: String,
) -> RouteReference {
RouteReference(name: name, method: method, param_names: [
first_param,
second_param,
])
}
/// Verify one unchecked reference against the indexed route table.
///
/// This models compile-time route verification fixtures with deterministic
/// diagnostics.
pub fn compile_reference(
index: RouterIndex,
reference: RouteReference,
) -> Result(Nil, RouteDiagnostic) {
case find_route(index.routes_rev, reference.name) {
Error(_) -> Error(UnknownRoute(name: reference.name))
Ok(meta) ->
case meta.method == reference.method {
False ->
Error(MethodMismatch(
name: reference.name,
expected: meta.method,
actual: reference.method,
))
True ->
case meta.param_names == reference.param_names {
True -> Ok(Nil)
False ->
Error(ParamMismatch(
name: reference.name,
expected: meta.param_names,
actual: reference.param_names,
))
}
}
}
}
/// Stable compile diagnostic label for fixture assertions.
pub fn diagnostic_label(diagnostic: RouteDiagnostic) -> String {
case diagnostic {
UnknownRoute(name) -> "unknown_route:" <> name
MethodMismatch(name, expected, actual) ->
"method_mismatch:"
<> name
<> ":expected="
<> method_label(expected)
<> ":actual="
<> method_label(actual)
ParamMismatch(name, expected, actual) ->
"param_mismatch:"
<> name
<> ":expected="
<> join_with(",", expected)
<> ":actual="
<> join_with(",", actual)
}
}
/// Stable method label.
pub fn method_label(method: RouteMethod) -> String {
case method {
MethodGet -> "GET"
MethodPost -> "POST"
MethodPut -> "PUT"
MethodPatch -> "PATCH"
MethodDelete -> "DELETE"
}
}
/// Stable router-index signature for deterministic fixture assertions.
pub fn index_signature(index: RouterIndex) -> String {
let labels =
index.routes_rev
|> list.reverse
|> list.map(fn(meta) {
meta.name
<> "@"
<> method_label(meta.method)
<> "@"
<> meta.pattern
<> "@params="
<> join_with(",", meta.param_names)
})
join_with(";", labels)
}
fn add_index_entry(
index: RouterIndex,
route: IndexedRoute(params, method),
) -> RouterIndex {
let meta =
IndexedMeta(
name: route.name,
method: route.method,
pattern: route.route.pattern,
param_names: route.route.param_names,
)
RouterIndex(routes_rev: [meta, ..index.routes_rev])
}
fn find_route(
routes: List(IndexedMeta),
name: String,
) -> Result(IndexedMeta, Nil) {
case routes {
[] -> Error(Nil)
[route, ..rest] ->
case route.name == name {
True -> Ok(route)
False -> find_route(rest, name)
}
}
}
fn query_string(query: List(#(String, String))) -> String {
case query {
[] -> ""
pairs -> {
let rendered =
list.map(pairs, fn(pair) {
let #(key, value) = pair
escape_query_piece(key) <> "=" <> escape_query_piece(value)
})
join_with("&", rendered)
}
}
}
fn escape_query_piece(value: String) -> String {
let escaped_percent = string.replace(value, each: "%", with: "%25")
let escaped_space = string.replace(escaped_percent, each: " ", with: "%20")
let escaped_plus = string.replace(escaped_space, each: "+", with: "%2B")
let escaped_ampersand = string.replace(escaped_plus, each: "&", with: "%26")
let escaped_equals = string.replace(escaped_ampersand, each: "=", with: "%3D")
let escaped_question = string.replace(escaped_equals, each: "?", with: "%3F")
let escaped_hash = string.replace(escaped_question, each: "#", with: "%23")
string.replace(escaped_hash, each: "/", with: "%2F")
}
fn join_with(separator: String, values: List(String)) -> String {
case values {
[] -> ""
[value] -> value
[value, ..rest] -> value <> separator <> join_with(separator, rest)
}
}