feat: add HTTP framework with routing and JSON helpers
- Add stdlib/http.lux with:
- Response builders (httpOk, httpNotFound, etc.)
- Path pattern matching with parameter extraction
- JSON construction helpers (jsonStr, jsonNum, jsonObj, etc.)
- Add examples/http_api.lux demonstrating a complete REST API
- Add examples/http_router.lux showing the routing pattern
- Update stdlib/lib.lux to include http module
The framework provides functional building blocks for web apps:
- Route matching: pathMatches("/users/:id", path)
- Path params: getPathSegment(path, 1)
- Response building: httpOk(jsonObj(...))
Note: Due to current type system limitations with type aliases
and function types, the framework uses inline types rather
than abstract Request/Response types.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
177
examples/http_api.lux
Normal file
177
examples/http_api.lux
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// HTTP API Example
|
||||||
|
//
|
||||||
|
// A complete REST API demonstrating:
|
||||||
|
// - Route matching with path parameters
|
||||||
|
// - Response builders
|
||||||
|
// - JSON construction
|
||||||
|
//
|
||||||
|
// Run with: lux examples/http_api.lux
|
||||||
|
// Test with:
|
||||||
|
// curl http://localhost:8080/
|
||||||
|
// curl http://localhost:8080/users
|
||||||
|
// curl http://localhost:8080/users/42
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Response Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn httpOk(body: String): { status: Int, body: String } =
|
||||||
|
{ status: 200, body: body }
|
||||||
|
|
||||||
|
fn httpCreated(body: String): { status: Int, body: String } =
|
||||||
|
{ status: 201, body: body }
|
||||||
|
|
||||||
|
fn httpNotFound(body: String): { status: Int, body: String } =
|
||||||
|
{ status: 404, body: body }
|
||||||
|
|
||||||
|
fn httpBadRequest(body: String): { status: Int, body: String } =
|
||||||
|
{ status: 400, body: body }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// JSON Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn jsonEscape(s: String): String =
|
||||||
|
String.replace(String.replace(s, "\\", "\\\\"), "\"", "\\\"")
|
||||||
|
|
||||||
|
fn jsonStr(key: String, value: String): String =
|
||||||
|
"\"" + jsonEscape(key) + "\":\"" + jsonEscape(value) + "\""
|
||||||
|
|
||||||
|
fn jsonNum(key: String, value: Int): String =
|
||||||
|
"\"" + jsonEscape(key) + "\":" + toString(value)
|
||||||
|
|
||||||
|
fn jsonObj(content: String): String =
|
||||||
|
"{" + content + "}"
|
||||||
|
|
||||||
|
fn jsonArr(content: String): String =
|
||||||
|
"[" + content + "]"
|
||||||
|
|
||||||
|
fn jsonError(message: String): String =
|
||||||
|
jsonObj(jsonStr("error", message))
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Path Matching
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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 matchParts(pathParts, patternParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matchParts(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), [])
|
||||||
|
matchParts(restPath, restPattern)
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getPathSegment(path: String, index: Int): Option<String> = {
|
||||||
|
let parts = String.split(path, "/")
|
||||||
|
List.get(parts, index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn indexHandler(): { status: Int, body: String } =
|
||||||
|
httpOk(jsonObj(jsonStr("message", "Welcome to Lux HTTP API")))
|
||||||
|
|
||||||
|
fn healthHandler(): { status: Int, body: String } =
|
||||||
|
httpOk(jsonObj(jsonStr("status", "healthy")))
|
||||||
|
|
||||||
|
fn listUsersHandler(): { status: Int, body: String } = {
|
||||||
|
let user1 = jsonObj(jsonNum("id", 1) + "," + jsonStr("name", "Alice"))
|
||||||
|
let user2 = jsonObj(jsonNum("id", 2) + "," + jsonStr("name", "Bob"))
|
||||||
|
httpOk(jsonArr(user1 + "," + user2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getUserHandler(path: String): { status: Int, body: String } = {
|
||||||
|
match getPathSegment(path, 1) {
|
||||||
|
Some(id) => {
|
||||||
|
let body = jsonObj(jsonStr("id", id) + "," + jsonStr("name", "User " + id))
|
||||||
|
httpOk(body)
|
||||||
|
},
|
||||||
|
None => httpNotFound(jsonError("User not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn createUserHandler(body: String): { status: Int, body: String } = {
|
||||||
|
let newUser = jsonObj(jsonNum("id", 3) + "," + jsonStr("name", "New User"))
|
||||||
|
httpCreated(newUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Router
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn router(method: String, path: String, body: String): { status: Int, body: String } = {
|
||||||
|
if method == "GET" && path == "/" then indexHandler()
|
||||||
|
else if method == "GET" && path == "/health" then healthHandler()
|
||||||
|
else if method == "GET" && path == "/users" then listUsersHandler()
|
||||||
|
else if method == "GET" && pathMatches(path, "/users/:id") then getUserHandler(path)
|
||||||
|
else if method == "POST" && path == "/users" then createUserHandler(body)
|
||||||
|
else httpNotFound(jsonError("Not found: " + path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn serveLoop(remaining: Int): Unit with {Console, HttpServer} = {
|
||||||
|
if remaining <= 0 then {
|
||||||
|
Console.print("Max requests reached, stopping server.")
|
||||||
|
HttpServer.stop()
|
||||||
|
} else {
|
||||||
|
let req = HttpServer.accept()
|
||||||
|
Console.print(req.method + " " + req.path)
|
||||||
|
let resp = router(req.method, req.path, req.body)
|
||||||
|
HttpServer.respond(resp.status, resp.body)
|
||||||
|
serveLoop(remaining - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main(): Unit with {Console, HttpServer} = {
|
||||||
|
let port = 8080
|
||||||
|
let maxRequests = 10
|
||||||
|
Console.print("========================================")
|
||||||
|
Console.print(" Lux HTTP API Demo")
|
||||||
|
Console.print("========================================")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Endpoints:")
|
||||||
|
Console.print(" GET / - API info")
|
||||||
|
Console.print(" GET /health - Health check")
|
||||||
|
Console.print(" GET /users - List users")
|
||||||
|
Console.print(" GET /users/:id - Get user by ID")
|
||||||
|
Console.print(" POST /users - Create user")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Try:")
|
||||||
|
Console.print(" curl http://localhost:8080/")
|
||||||
|
Console.print(" curl http://localhost:8080/users")
|
||||||
|
Console.print(" curl http://localhost:8080/users/42")
|
||||||
|
Console.print(" curl -X POST http://localhost:8080/users")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Starting server on port " + toString(port) + "...")
|
||||||
|
HttpServer.listen(port)
|
||||||
|
Console.print("Server listening!")
|
||||||
|
serveLoop(maxRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = run main() with {}
|
||||||
98
examples/http_router.lux
Normal file
98
examples/http_router.lux
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// HTTP Router Example
|
||||||
|
//
|
||||||
|
// Demonstrates the HTTP helper library with:
|
||||||
|
// - Path pattern matching
|
||||||
|
// - Response builders
|
||||||
|
// - JSON helpers
|
||||||
|
//
|
||||||
|
// Run with: lux examples/http_router.lux
|
||||||
|
// Test with:
|
||||||
|
// curl http://localhost:8080/
|
||||||
|
// curl http://localhost:8080/users
|
||||||
|
// curl http://localhost:8080/users/42
|
||||||
|
|
||||||
|
import stdlib/http
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Route Handlers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn indexHandler(): { status: Int, body: String } =
|
||||||
|
httpOk("Welcome to Lux HTTP Framework!")
|
||||||
|
|
||||||
|
fn listUsersHandler(): { status: Int, body: String } = {
|
||||||
|
let user1 = jsonObject(jsonJoin([jsonNumber("id", 1), jsonString("name", "Alice")]))
|
||||||
|
let user2 = jsonObject(jsonJoin([jsonNumber("id", 2), jsonString("name", "Bob")]))
|
||||||
|
let users = jsonArray(jsonJoin([user1, user2]))
|
||||||
|
httpOk(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getUserHandler(path: String): { status: Int, body: String } = {
|
||||||
|
match getPathSegment(path, 1) {
|
||||||
|
Some(id) => {
|
||||||
|
let body = jsonObject(jsonJoin([jsonString("id", id), jsonString("name", "User " + id)]))
|
||||||
|
httpOk(body)
|
||||||
|
},
|
||||||
|
None => httpNotFound(jsonErrorMsg("User ID required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn healthHandler(): { status: Int, body: String } =
|
||||||
|
httpOk(jsonObject(jsonString("status", "healthy")))
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Router
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn router(method: String, path: String, body: String): { status: Int, body: String } = {
|
||||||
|
if method == "GET" && path == "/" then indexHandler()
|
||||||
|
else if method == "GET" && path == "/health" then healthHandler()
|
||||||
|
else if method == "GET" && path == "/users" then listUsersHandler()
|
||||||
|
else if method == "GET" && pathMatches(path, "/users/:id") then getUserHandler(path)
|
||||||
|
else httpNotFound(jsonErrorMsg("Not found: " + path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Server
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn serveLoop(remaining: Int): Unit with {Console, HttpServer} = {
|
||||||
|
if remaining <= 0 then {
|
||||||
|
Console.print("Max requests reached, stopping server.")
|
||||||
|
HttpServer.stop()
|
||||||
|
} else {
|
||||||
|
let req = HttpServer.accept()
|
||||||
|
Console.print(req.method + " " + req.path)
|
||||||
|
let resp = router(req.method, req.path, req.body)
|
||||||
|
HttpServer.respond(resp.status, resp.body)
|
||||||
|
serveLoop(remaining - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main(): Unit with {Console, HttpServer} = {
|
||||||
|
let port = 8080
|
||||||
|
let maxRequests = 10
|
||||||
|
Console.print("========================================")
|
||||||
|
Console.print(" Lux HTTP Router Demo")
|
||||||
|
Console.print("========================================")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Endpoints:")
|
||||||
|
Console.print(" GET / - Welcome message")
|
||||||
|
Console.print(" GET /health - Health check")
|
||||||
|
Console.print(" GET /users - List all users")
|
||||||
|
Console.print(" GET /users/:id - Get user by ID")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Try:")
|
||||||
|
Console.print(" curl http://localhost:8080/")
|
||||||
|
Console.print(" curl http://localhost:8080/users")
|
||||||
|
Console.print(" curl http://localhost:8080/users/42")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Starting server on port " + toString(port) + "...")
|
||||||
|
Console.print("Will handle " + toString(maxRequests) + " requests then stop.")
|
||||||
|
Console.print("")
|
||||||
|
HttpServer.listen(port)
|
||||||
|
Console.print("Server listening!")
|
||||||
|
serveLoop(maxRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = run main() with {}
|
||||||
161
stdlib/http.lux
Normal file
161
stdlib/http.lux
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// 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 }
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Usage Example (copy into your file)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// fn router(method: String, path: String, body: String): { status: Int, body: String } = {
|
||||||
|
// if method == "GET" && path == "/" then httpOk("Welcome!")
|
||||||
|
// else if method == "GET" && pathMatches(path, "/users/:id") then {
|
||||||
|
// match getPathSegment(path, 1) {
|
||||||
|
// Some(id) => httpOk(jsonObject(jsonString("id", id))),
|
||||||
|
// None => httpNotFound(jsonErrorMsg("User not found"))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// else httpNotFound(jsonErrorMsg("Not found"))
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// fn main(): Unit with {Console, HttpServer} = {
|
||||||
|
// HttpServer.listen(8080)
|
||||||
|
// Console.print("Server running on port 8080")
|
||||||
|
// serveLoop(5) // Handle 5 requests
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// fn serveLoop(remaining: Int): Unit with {Console, HttpServer} = {
|
||||||
|
// if remaining <= 0 then HttpServer.stop()
|
||||||
|
// else {
|
||||||
|
// let req = HttpServer.accept()
|
||||||
|
// let resp = router(req.method, req.path, req.body)
|
||||||
|
// HttpServer.respond(resp.status, resp.body)
|
||||||
|
// serveLoop(remaining - 1)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// This module re-exports the core standard library modules.
|
// This module re-exports the core standard library modules.
|
||||||
// Import with: import stdlib
|
// Import with: import stdlib
|
||||||
|
|
||||||
// Re-export Html module
|
// Re-export core modules
|
||||||
pub import html
|
pub import html
|
||||||
pub import browser
|
pub import browser
|
||||||
|
pub import http
|
||||||
|
|||||||
Reference in New Issue
Block a user