Files
lux/stdlib/http.lux
Brandon Lucas ec78286165 feat: enhance Html and Http stdlib modules
Html: add RawHtml, Attribute, meta/link/script/iframe/figure/figcaption
elements, attr() helper, rawHtml() helper, seoDocument() for SEO meta
tags, fix document() to use Attribute instead of DataAttr for standard
HTML attributes.

Http: add serveStaticFile(), parseFormBody(), getFormField(),
sendResponse() convenience helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:36:56 -05:00

700 lines
24 KiB
Plaintext

// HTTP Framework for Lux
//
// Provides helpers for building web applications.
//
// Note: Due to current type system limitations, this module provides
// helper functions rather than abstract types. Use the HttpServer effect
// directly for the server loop.
// ============================================================
// Response Builder Functions
// ============================================================
// Create a 200 OK response
fn httpOk(body: String): { status: Int, body: String } =
{ status: 200, body: body }
// Create a 201 Created response
fn httpCreated(body: String): { status: Int, body: String } =
{ status: 201, body: body }
// Create a 204 No Content response
fn httpNoContent(): { status: Int, body: String } =
{ status: 204, body: "" }
// Create a 400 Bad Request response
fn httpBadRequest(body: String): { status: Int, body: String } =
{ status: 400, body: body }
// Create a 401 Unauthorized response
fn httpUnauthorized(body: String): { status: Int, body: String } =
{ status: 401, body: body }
// Create a 403 Forbidden response
fn httpForbidden(body: String): { status: Int, body: String } =
{ status: 403, body: body }
// Create a 404 Not Found response
fn httpNotFound(body: String): { status: Int, body: String } =
{ status: 404, body: body }
// Create a 500 Server Error response
fn httpServerError(body: String): { status: Int, body: String } =
{ status: 500, body: body }
// Create a 429 Too Many Requests response
fn httpTooManyRequests(body: String): { status: Int, body: String } =
{ status: 429, body: body }
// ============================================================
// Path Matching
// ============================================================
// Check if a path matches a pattern with wildcards
// Pattern "/users/:id" matches "/users/42"
fn pathMatches(path: String, pattern: String): Bool = {
let pathParts = String.split(path, "/")
let patternParts = String.split(pattern, "/")
if List.length(pathParts) != List.length(patternParts) then false
else matchPathParts(pathParts, patternParts)
}
fn matchPathParts(pathParts: List<String>, patternParts: List<String>): Bool = {
if List.length(pathParts) == 0 then true
else {
match List.head(pathParts) {
None => true,
Some(pathPart) => {
match List.head(patternParts) {
None => true,
Some(patternPart) => {
let isMatch = if String.startsWith(patternPart, ":") then true else pathPart == patternPart
if isMatch then {
let restPath = Option.getOrElse(List.tail(pathParts), [])
let restPattern = Option.getOrElse(List.tail(patternParts), [])
matchPathParts(restPath, restPattern)
} else false
}
}
}
}
}
}
// Extract a path segment by position (0-indexed, skipping leading empty segment)
// For path "/users/42", getPathSegment(path, 1) returns Some("42")
fn getPathSegment(path: String, index: Int): Option<String> = {
let parts = String.split(path, "/")
List.get(parts, index + 1)
}
// Extract path parameters from a matched route pattern
// For path "/users/42/posts/5" and pattern "/users/:userId/posts/:postId"
// returns [("userId", "42"), ("postId", "5")]
fn getPathParams(path: String, pattern: String): List<(String, String)> = {
let pathParts = String.split(path, "/")
let patternParts = String.split(pattern, "/")
extractParamsHelper(pathParts, patternParts, [])
}
fn extractParamsHelper(pathParts: List<String>, patternParts: List<String>, acc: List<(String, String)>): List<(String, String)> = {
if List.length(pathParts) == 0 || List.length(patternParts) == 0 then
List.reverse(acc)
else {
match List.head(pathParts) {
None => List.reverse(acc),
Some(p) => match List.head(patternParts) {
None => List.reverse(acc),
Some(pat) => {
let restPath = Option.getOrElse(List.tail(pathParts), [])
let restPattern = Option.getOrElse(List.tail(patternParts), [])
if String.startsWith(pat, ":") then {
let paramName = String.substring(pat, 1, String.length(pat))
let newAcc = List.concat([(paramName, p)], acc)
extractParamsHelper(restPath, restPattern, newAcc)
} else {
extractParamsHelper(restPath, restPattern, acc)
}
}
}
}
}
}
// Get a specific path parameter by name from a list of params
fn getParam(params: List<(String, String)>, name: String): Option<String> = {
if List.length(params) == 0 then None
else {
match List.head(params) {
None => None,
Some(pair) => match pair {
(pName, pValue) =>
if pName == name then Some(pValue)
else getParam(Option.getOrElse(List.tail(params), []), name)
}
}
}
}
// ============================================================
// JSON Helpers
// ============================================================
// Escape a string for JSON (handles quotes and backslashes)
fn jsonEscape(s: String): String = {
String.replace(String.replace(s, "\\", "\\\\"), "\"", "\\\"")
}
// Create a JSON string field: "key": "value"
fn jsonString(key: String, value: String): String = {
"\"" + jsonEscape(key) + "\":\"" + jsonEscape(value) + "\""
}
// Create a JSON number field: "key": 42
fn jsonNumber(key: String, value: Int): String = {
"\"" + jsonEscape(key) + "\":" + toString(value)
}
// Create a JSON boolean field: "key": true
fn jsonBool(key: String, value: Bool): String = {
let boolStr = if value then "true" else "false"
"\"" + jsonEscape(key) + "\":" + boolStr
}
// Wrap content in JSON object braces
fn jsonObject(content: String): String =
"{" + content + "}"
// Wrap content in JSON array brackets
fn jsonArray(content: String): String =
"[" + content + "]"
// Join multiple JSON fields/items with commas
fn jsonJoin(items: List<String>): String =
String.join(items, ",")
// Create a JSON error object: {"error": "message"}
fn jsonErrorMsg(message: String): String =
jsonObject(jsonString("error", message))
// Create a JSON message object: {"message": "text"}
fn jsonMessage(text: String): String =
jsonObject(jsonString("message", text))
// ============================================================
// Header Helpers
// ============================================================
// Get a header value from request headers (case-insensitive)
fn getHeader(headers: List<(String, String)>, name: String): Option<String> = {
let lowerName = String.toLower(name)
getHeaderHelper(headers, lowerName)
}
fn getHeaderHelper(headers: List<(String, String)>, lowerName: String): Option<String> = {
if List.length(headers) == 0 then None
else {
match List.head(headers) {
None => None,
Some(header) => match header {
(hName, hValue) =>
if String.toLower(hName) == lowerName then Some(hValue)
else getHeaderHelper(Option.getOrElse(List.tail(headers), []), lowerName)
}
}
}
}
// ============================================================
// Routing Helpers
// ============================================================
//
// Route matching pattern:
//
// fn router(method: String, path: String, body: String): { status: Int, body: String } = {
// if method == "GET" && path == "/" then httpOk("Home")
// else if method == "GET" && pathMatches(path, "/users/:id") then {
// let params = getPathParams(path, "/users/:id")
// match getParam(params, "id") {
// Some(id) => httpOk(jsonObject(jsonString("id", id))),
// None => httpNotFound(jsonErrorMsg("User not found"))
// }
// }
// else if method == "POST" && path == "/users" then
// httpCreated(body)
// else
// httpNotFound(jsonErrorMsg("Not found"))
// }
// Helper to check if request is a GET to a specific path
fn isGet(method: String, path: String, pattern: String): Bool =
method == "GET" && pathMatches(path, pattern)
// Helper to check if request is a POST to a specific path
fn isPost(method: String, path: String, pattern: String): Bool =
method == "POST" && pathMatches(path, pattern)
// Helper to check if request is a PUT to a specific path
fn isPut(method: String, path: String, pattern: String): Bool =
method == "PUT" && pathMatches(path, pattern)
// Helper to check if request is a DELETE to a specific path
fn isDelete(method: String, path: String, pattern: String): Bool =
method == "DELETE" && pathMatches(path, pattern)
// ============================================================
// Server Loop Patterns
// ============================================================
//
// The server loop should be defined in your main file:
//
// fn serverLoop(): Unit with {HttpServer} = {
// let req = HttpServer.accept()
// let resp = router(req.method, req.path, req.body, req.headers)
// HttpServer.respond(resp.status, resp.body)
// serverLoop()
// }
//
// For testing with a fixed number of requests:
//
// fn serverLoopN(remaining: Int): Unit with {HttpServer} = {
// if remaining <= 0 then HttpServer.stop()
// else {
// let req = HttpServer.accept()
// let resp = router(req.method, req.path, req.body, req.headers)
// HttpServer.respond(resp.status, resp.body)
// serverLoopN(remaining - 1)
// }
// }
// ============================================================
// Middleware Pattern
// ============================================================
//
// Middleware wraps handlers to add cross-cutting concerns.
// In Lux, middleware is implemented as function composition.
//
// Example logging middleware:
//
// fn withLogging(
// handler: fn(String, String, String): { status: Int, body: String }
// ): fn(String, String, String): { status: Int, body: String } with {Console} = {
// fn(method: String, path: String, body: String): { status: Int, body: String } => {
// Console.print("[HTTP] " + method + " " + path)
// let response = handler(method, path, body)
// Console.print("[HTTP] " + toString(response.status))
// response
// }
// }
//
// Usage:
// let myHandler = withLogging(router)
// ============================================================
// CORS Headers
// ============================================================
// Standard CORS headers for API responses
fn corsHeaders(): List<(String, String)> = [
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
("Access-Control-Allow-Headers", "Content-Type, Authorization")
]
// CORS headers for specific origin with credentials
fn corsHeadersWithOrigin(origin: String): List<(String, String)> = [
("Access-Control-Allow-Origin", origin),
("Access-Control-Allow-Credentials", "true"),
("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"),
("Access-Control-Allow-Headers", "Content-Type, Authorization")
]
// ============================================================
// Content Type Headers
// ============================================================
fn jsonHeaders(): List<(String, String)> = [
("Content-Type", "application/json")
]
fn htmlHeaders(): List<(String, String)> = [
("Content-Type", "text/html; charset=utf-8")
]
fn textHeaders(): List<(String, String)> = [
("Content-Type", "text/plain; charset=utf-8")
]
// ============================================================
// Query String Parsing
// ============================================================
// Parse query string from path (e.g., "/search?q=hello&page=1")
// Returns the path without query string and a list of parameters
pub fn parseQueryString(fullPath: String): (String, List<(String, String)>) = {
match String.indexOf(fullPath, "?") {
None => (fullPath, []),
Some(idx) => {
let path = String.substring(fullPath, 0, idx)
let queryStr = String.substring(fullPath, idx + 1, String.length(fullPath))
let params = parseQueryParams(queryStr)
(path, params)
}
}
}
fn parseQueryParams(queryStr: String): List<(String, String)> = {
let pairs = String.split(queryStr, "&")
List.filterMap(pairs, fn(pair: String): Option<(String, String)> => {
match String.indexOf(pair, "=") {
None => None,
Some(idx) => {
let key = String.substring(pair, 0, idx)
let value = String.substring(pair, idx + 1, String.length(pair))
Some((urlDecode(key), urlDecode(value)))
}
}
})
}
// Get a query parameter by name
pub fn getQueryParam(params: List<(String, String)>, name: String): Option<String> =
getParam(params, name)
// Simple URL decoding (handles %XX and +)
fn urlDecode(s: String): String = {
// For now, just replace + with space
// Full implementation would decode %XX sequences
String.replace(s, "+", " ")
}
// ============================================================
// Cookie Handling
// ============================================================
// Parse cookies from Cookie header value
pub fn parseCookies(cookieHeader: String): List<(String, String)> = {
let pairs = String.split(cookieHeader, "; ")
List.filterMap(pairs, fn(pair: String): Option<(String, String)> => {
match String.indexOf(pair, "=") {
None => None,
Some(idx) => {
let name = String.trim(String.substring(pair, 0, idx))
let value = String.trim(String.substring(pair, idx + 1, String.length(pair)))
Some((name, value))
}
}
})
}
// Get a cookie value by name from request headers
pub fn getCookie(headers: List<(String, String)>, name: String): Option<String> = {
match getHeader(headers, "Cookie") {
None => None,
Some(cookieHeader) => {
let cookies = parseCookies(cookieHeader)
getParam(cookies, name)
}
}
}
// Create a Set-Cookie header value
pub fn setCookie(name: String, value: String): String =
name + "=" + value
// Create a Set-Cookie header with options
pub fn setCookieWithOptions(
name: String,
value: String,
maxAge: Option<Int>,
path: Option<String>,
httpOnly: Bool,
secure: Bool
): String = {
let base = name + "=" + value
let withMaxAge = match maxAge {
Some(age) => base + "; Max-Age=" + toString(age),
None => base
}
let withPath = match path {
Some(p) => withMaxAge + "; Path=" + p,
None => withMaxAge
}
let withHttpOnly = if httpOnly then withPath + "; HttpOnly" else withPath
if secure then withHttpOnly + "; Secure" else withHttpOnly
}
// ============================================================
// Static File MIME Types
// ============================================================
// Get MIME type for a file extension
pub fn getMimeType(path: String): String = {
let ext = getFileExtension(path)
match ext {
"html" => "text/html; charset=utf-8",
"htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" => "application/javascript; charset=utf-8",
"json" => "application/json; charset=utf-8",
"png" => "image/png",
"jpg" => "image/jpeg",
"jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"pdf" => "application/pdf",
"xml" => "application/xml",
"txt" => "text/plain; charset=utf-8",
"md" => "text/markdown; charset=utf-8",
_ => "application/octet-stream"
}
}
fn getFileExtension(path: String): String = {
match String.lastIndexOf(path, ".") {
None => "",
Some(idx) => String.toLower(String.substring(path, idx + 1, String.length(path)))
}
}
// ============================================================
// Request Type
// ============================================================
// Standard request record for cleaner routing
type Request = {
method: String,
path: String,
query: List<(String, String)>,
headers: List<(String, String)>,
body: String
}
// Parse a raw request into a Request record
pub fn parseRequest(
method: String,
fullPath: String,
headers: List<(String, String)>,
body: String
): Request = {
let (path, query) = parseQueryString(fullPath)
{ method: method, path: path, query: query, headers: headers, body: body }
}
// ============================================================
// Response Type with Headers
// ============================================================
// Response with headers support
type Response = {
status: Int,
headers: List<(String, String)>,
body: String
}
// Create a response with headers
pub fn httpResponse(status: Int, body: String, headers: List<(String, String)>): Response =
{ status: status, headers: headers, body: body }
// Create a JSON response
pub fn jsonResponse(status: Int, body: String): Response =
{ status: status, headers: jsonHeaders(), body: body }
// Create an HTML response
pub fn htmlResponse(status: Int, body: String): Response =
{ status: status, headers: htmlHeaders(), body: body }
// Create a redirect response
pub fn httpRedirect(location: String): Response =
{ status: 302, headers: [("Location", location)], body: "" }
// Create a permanent redirect response
pub fn httpRedirectPermanent(location: String): Response =
{ status: 301, headers: [("Location", location)], body: "" }
// ============================================================
// Middleware Functions
// ============================================================
// Request type for middleware (simplified)
type Handler = fn(Request): Response
// Logging middleware - logs request method, path, and response status
pub fn withLogging(handler: Handler): Handler with {Console} =
fn(req: Request): Response => {
Console.print("[HTTP] " + req.method + " " + req.path)
let resp = handler(req)
Console.print("[HTTP] " + toString(resp.status))
resp
}
// CORS middleware - adds CORS headers to all responses
pub fn withCors(handler: Handler): Handler =
fn(req: Request): Response => {
// Handle preflight
if req.method == "OPTIONS" then
{ status: 204, headers: corsHeaders(), body: "" }
else {
let resp = handler(req)
{ status: resp.status, headers: List.concat(resp.headers, corsHeaders()), body: resp.body }
}
}
// JSON content-type middleware - ensures JSON content type on responses
pub fn withJson(handler: Handler): Handler =
fn(req: Request): Response => {
let resp = handler(req)
{ status: resp.status, headers: List.concat(resp.headers, jsonHeaders()), body: resp.body }
}
// Error handling middleware - catches failures and returns 500
pub fn withErrorHandling(handler: Handler): Handler =
fn(req: Request): Response => {
// In a real implementation, this would use effect handling
// For now, just call the handler
handler(req)
}
// Rate limiting check (returns remaining requests or 0 if limited)
// Note: Actual rate limiting requires state/effects
pub fn checkRateLimit(key: String, limit: Int, window: Int): Int with {Time} = {
// Placeholder - real implementation would track requests
limit
}
// ============================================================
// Router DSL
// ============================================================
// Route definition
type Route = {
method: String,
pattern: String,
handler: fn(Request): Response
}
// Create a GET route
pub fn get(pattern: String, handler: fn(Request): Response): Route =
{ method: "GET", pattern: pattern, handler: handler }
// Create a POST route
pub fn post(pattern: String, handler: fn(Request): Response): Route =
{ method: "POST", pattern: pattern, handler: handler }
// Create a PUT route
pub fn put(pattern: String, handler: fn(Request): Response): Route =
{ method: "PUT", pattern: pattern, handler: handler }
// Create a DELETE route
pub fn delete(pattern: String, handler: fn(Request): Response): Route =
{ method: "DELETE", pattern: pattern, handler: handler }
// Create a PATCH route
pub fn patch(pattern: String, handler: fn(Request): Response): Route =
{ method: "PATCH", pattern: pattern, handler: handler }
// Match request against a list of routes
pub fn matchRoute(req: Request, routes: List<Route>): Option<(Route, List<(String, String)>)> = {
matchRouteHelper(req, routes)
}
fn matchRouteHelper(req: Request, routes: List<Route>): Option<(Route, List<(String, String)>)> = {
match List.head(routes) {
None => None,
Some(route) => {
if route.method == req.method && pathMatches(req.path, route.pattern) then {
let params = getPathParams(req.path, route.pattern)
Some((route, params))
} else {
matchRouteHelper(req, Option.getOrElse(List.tail(routes), []))
}
}
}
}
// Create a router from a list of routes
pub fn router(routes: List<Route>, notFound: fn(Request): Response): Handler =
fn(req: Request): Response => {
match matchRoute(req, routes) {
Some((route, _params)) => route.handler(req),
None => notFound(req)
}
}
// ============================================================
// Static File Serving
// ============================================================
// Serve a static file from disk
pub fn serveStaticFile(basePath: String, requestPath: String): Response with {File} = {
let filePath = basePath + requestPath
if File.exists(filePath) then {
let content = File.read(filePath)
let mime = getMimeType(filePath)
{ status: 200, headers: [("Content-Type", mime)], body: content }
} else
{ status: 404, headers: textHeaders(), body: "Not Found" }
}
// ============================================================
// Form Body Parsing
// ============================================================
// Parse URL-encoded form body (same format as query strings)
pub fn parseFormBody(body: String): List<(String, String)> =
parseQueryParams(body)
// Get a form field value by name
pub fn getFormField(fields: List<(String, String)>, name: String): Option<String> =
getParam(fields, name)
// ============================================================
// Response Helpers
// ============================================================
// Send a Response using HttpServer effect (convenience wrapper)
pub fn sendResponse(resp: Response): Unit with {HttpServer} =
HttpServer.respondWithHeaders(resp.status, resp.body, resp.headers)
// ============================================================
// Example Usage
// ============================================================
//
// fn main(): Unit with {Console, HttpServer} = {
// // Define routes
// let routes = [
// get("/", fn(req: Request): Response => jsonResponse(200, jsonMessage("Welcome!"))),
// get("/users/:id", fn(req: Request): Response => {
// let params = getPathParams(req.path, "/users/:id")
// match getParam(params, "id") {
// Some(id) => jsonResponse(200, jsonObject(jsonString("id", id))),
// None => jsonResponse(404, jsonErrorMsg("User not found"))
// }
// }),
// post("/users", fn(req: Request): Response => jsonResponse(201, jsonMessage("Created")))
// ]
//
// // Create router with middleware
// let app = withLogging(withCors(router(routes, fn(req: Request): Response =>
// jsonResponse(404, jsonErrorMsg("Not found"))
// )))
//
// // Start server
// HttpServer.listen(8080)
// Console.print("Server running on http://localhost:8080")
//
// // Server loop
// fn serverLoop(): Unit with {HttpServer} = {
// let rawReq = HttpServer.accept()
// let req = parseRequest(rawReq.method, rawReq.path, rawReq.headers, rawReq.body)
// let resp = app(req)
// HttpServer.respond(resp.status, resp.body)
// serverLoop()
// }
// serverLoop()
// }