Make request and response implement FromLua directly instead of separate config

This commit is contained in:
Filip Tibell 2025-04-28 19:23:19 +02:00
parent 1915441ee6
commit 9f6a1532a6
No known key found for this signature in database
11 changed files with 252 additions and 349 deletions

View file

@ -3,7 +3,6 @@
local net = require("@lune/net")
local process = require("@lune/process")
local task = require("@lune/task")
local PORT = if process.env.PORT ~= nil and #process.env.PORT > 0
then assert(tonumber(process.env.PORT), "Failed to parse port from env")
@ -11,6 +10,10 @@ local PORT = if process.env.PORT ~= nil and #process.env.PORT > 0
-- Create our responder functions
local function root(_request: net.ServeRequest): string
return `Hello from Lune server!`
end
local function pong(request: net.ServeRequest): string
return `Pong!\n{request.path}\n{request.body}`
end
@ -29,10 +32,12 @@ local function notFound(_request: net.ServeRequest): net.ServeResponse
}
end
-- Run the server on port 8080
-- Run the server on the port forever
local handle = net.serve(PORT, function(request)
if string.sub(request.path, 1, 5) == "/ping" then
net.serve(PORT, function(request)
if request.path == "/" then
return root(request)
elseif string.sub(request.path, 1, 5) == "/ping" then
return pong(request)
elseif string.sub(request.path, 1, 7) == "/teapot" then
return teapot(request)
@ -42,12 +47,4 @@ local handle = net.serve(PORT, function(request)
end)
print(`Listening on port {PORT} 🚀`)
-- Exit our example after a small delay, if you copy this
-- example just remove this part to keep the server running
task.delay(2, function()
print("Shutting down...")
task.wait(1)
handle:stop()
end)
print("Press Ctrl+C to stop")

View file

@ -1,154 +0,0 @@
use std::collections::HashMap;
use bstr::{BString, ByteSlice};
use hyper::{header::USER_AGENT, Method};
use mlua::prelude::*;
use crate::shared::headers::{create_user_agent_header, table_to_hash_map};
#[derive(Debug, Clone)]
pub struct RequestConfigOptions {
pub decompress: bool,
}
impl Default for RequestConfigOptions {
fn default() -> Self {
Self { decompress: true }
}
}
impl FromLua for RequestConfigOptions {
fn from_lua(value: LuaValue, _: &Lua) -> LuaResult<Self> {
if let LuaValue::Nil = value {
// Nil means default options
Ok(Self::default())
} else if let LuaValue::Table(tab) = value {
// Table means custom options
let decompress = match tab.get::<Option<bool>>("decompress") {
Ok(decomp) => Ok(decomp.unwrap_or(true)),
Err(_) => Err(LuaError::RuntimeError(
"Invalid option value for 'decompress' in request config options".to_string(),
)),
}?;
Ok(Self { decompress })
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "RequestConfigOptions".to_string(),
message: Some(format!(
"Invalid request config options - expected table or nil, got {}",
value.type_name()
)),
})
}
}
}
#[derive(Debug, Clone)]
pub struct RequestConfig {
pub url: String,
pub method: Method,
pub query: HashMap<String, Vec<String>>,
pub headers: HashMap<String, Vec<String>>,
pub body: Option<Vec<u8>>,
pub options: RequestConfigOptions,
}
impl FromLua for RequestConfig {
fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
// If we just got a string we assume its a GET request to a given url
if let LuaValue::String(s) = value {
Ok(Self {
url: s.to_string_lossy().to_string(),
method: Method::GET,
query: HashMap::new(),
headers: HashMap::new(),
body: None,
options: RequestConfigOptions::default(),
})
} else if let LuaValue::Table(tab) = value {
// If we got a table we are able to configure the entire request
// Extract url
let url = match tab.get::<LuaString>("url") {
Ok(config_url) => Ok(config_url.to_string_lossy().to_string()),
Err(_) => Err(LuaError::runtime("Missing 'url' in request config")),
}?;
// Extract method
let method = match tab.get::<LuaString>("method") {
Ok(config_method) => config_method.to_string_lossy().trim().to_ascii_uppercase(),
Err(_) => "GET".to_string(),
};
// Extract query
let query = match tab.get::<LuaTable>("query") {
Ok(tab) => table_to_hash_map(tab, "query")?,
Err(_) => HashMap::new(),
};
// Extract headers
let mut headers = match tab.get::<LuaTable>("headers") {
Ok(tab) => table_to_hash_map(tab, "headers")?,
Err(_) => HashMap::new(),
};
// Extract body
let body = match tab.get::<BString>("body") {
Ok(config_body) => Some(config_body.as_bytes().to_owned()),
Err(_) => None,
};
// Convert method string into proper enum
let method = method.trim().to_ascii_uppercase();
let method = match method.as_ref() {
"GET" => Ok(Method::GET),
"POST" => Ok(Method::POST),
"PUT" => Ok(Method::PUT),
"DELETE" => Ok(Method::DELETE),
"HEAD" => Ok(Method::HEAD),
"OPTIONS" => Ok(Method::OPTIONS),
"PATCH" => Ok(Method::PATCH),
_ => Err(LuaError::RuntimeError(format!(
"Invalid request config method '{}'",
&method
))),
}?;
// Parse any extra options given
let options = match tab.get::<LuaValue>("options") {
Ok(opts) => RequestConfigOptions::from_lua(opts, lua)?,
Err(_) => RequestConfigOptions::default(),
};
// Finally, add any default headers, if applicable
add_default_headers(lua, &mut headers)?;
// All good, validated and we got what we need
Ok(Self {
url,
method,
query,
headers,
body,
options,
})
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "RequestConfig".to_string(),
message: Some(format!(
"Invalid request config - expected string or table, got {}",
value.type_name()
)),
})
}
}
}
fn add_default_headers(lua: &Lua, headers: &mut HashMap<String, Vec<String>>) -> LuaResult<()> {
if !headers.contains_key(USER_AGENT.as_str()) {
let ua = create_user_agent_header(lua)?;
headers.insert(USER_AGENT.to_string(), vec![ua]);
}
Ok(())
}

View file

@ -18,7 +18,6 @@ use crate::{
},
};
pub mod config;
pub mod http_stream;
pub mod rustls;
pub mod ws_stream;

View file

@ -1,5 +1,6 @@
#![allow(clippy::cargo_common_metadata)]
use hyper::header::{HeaderValue, USER_AGENT};
use lune_utils::TableBuilder;
use mlua::prelude::*;
@ -9,9 +10,12 @@ pub(crate) mod shared;
pub(crate) mod url;
use self::{
client::{config::RequestConfig, ws_stream::WsStream},
client::ws_stream::WsStream,
server::{config::ServeConfig, handle::ServeHandle},
shared::{request::Request, response::Response, websocket::Websocket},
shared::{
headers::create_user_agent_header, request::Request, response::Response,
websocket::Websocket,
},
};
const TYPEDEFS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/types.d.luau"));
@ -41,8 +45,13 @@ pub fn module(lua: Lua) -> LuaResult<LuaTable> {
.build_readonly()
}
async fn net_request(lua: Lua, config: RequestConfig) -> LuaResult<Response> {
self::client::send_request(Request::try_from(config)?, lua).await
async fn net_request(lua: Lua, mut req: Request) -> LuaResult<Response> {
if !req.headers().contains_key(USER_AGENT.as_str()) {
let ua = create_user_agent_header(&lua)?;
let ua = HeaderValue::from_str(&ua).into_lua_err()?;
req.inner.headers_mut().insert(USER_AGENT, ua);
}
self::client::send_request(req, lua).await
}
async fn net_socket(_: Lua, url: String) -> LuaResult<Websocket<WsStream>> {

View file

@ -1,14 +1,7 @@
use std::{
collections::HashMap,
net::{IpAddr, Ipv4Addr},
};
use std::net::{IpAddr, Ipv4Addr};
use bstr::{BString, ByteSlice};
use hyper::{header::CONTENT_TYPE, StatusCode};
use mlua::prelude::*;
use crate::shared::headers::table_to_hash_map;
const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
const WEB_SOCKET_UPDGRADE_REQUEST_HANDLER: &str = r#"
@ -21,64 +14,6 @@ return {
}
"#;
#[derive(Debug, Clone)]
pub struct ResponseConfig {
pub status: StatusCode,
pub headers: HashMap<String, Vec<String>>,
pub body: Option<Vec<u8>>,
}
impl FromLua for ResponseConfig {
fn from_lua(value: LuaValue, _: &Lua) -> LuaResult<Self> {
// If we just got a string we assume its a plaintext 200 response
if let LuaValue::String(s) = value {
Ok(Self {
status: StatusCode::OK,
headers: HashMap::from([(
CONTENT_TYPE.to_string(),
vec!["text/plain".to_string()],
)]),
body: Some(s.as_bytes().to_owned()),
})
} else if let LuaValue::Table(tab) = value {
// If we got a table we are able to configure the entire response
// Extract url
let status = match tab.get::<u16>("status") {
Ok(status) => Ok(StatusCode::from_u16(status).into_lua_err()?),
Err(_) => Err(LuaError::runtime("Missing 'status' in response config")),
}?;
// Extract headers
let headers = match tab.get::<LuaTable>("headers") {
Ok(tab) => table_to_hash_map(tab, "headers")?,
Err(_) => HashMap::new(),
};
// Extract body
let body = match tab.get::<BString>("body") {
Ok(config_body) => Some(config_body.as_bytes().to_owned()),
Err(_) => None,
};
// All good, validated and we got what we need
Ok(Self {
status,
headers,
body,
})
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "ResponseConfig".to_string(),
message: Some(format!(
"Invalid response config - expected string or table, got {}",
value.type_name()
)),
})
}
}
}
#[derive(Debug, Clone)]
pub struct ServeConfig {
pub address: IpAddr,

View file

@ -13,7 +13,7 @@ use mlua_luau_scheduler::{LuaSchedulerExt, LuaSpawnExt};
use crate::{
server::{
config::{ResponseConfig, ServeConfig},
config::ServeConfig,
upgrade::{is_upgrade_request, make_upgrade_response},
},
shared::{hyper::HyperIo, request::Request, response::Response, websocket::Websocket},
@ -96,8 +96,7 @@ async fn handle_request(
.get_thread_result(thread_id)
.expect("Missing handler thread result")?;
let config = ResponseConfig::from_lua_multi(thread_res, &lua)?;
let response = Response::try_from(config)?;
let response = Response::from_lua_multi(thread_res, &lua)?;
Ok(response.into_full())
}

View file

@ -86,33 +86,3 @@ pub fn hash_map_to_table(
builder.build_readonly()
}
pub fn table_to_hash_map(
tab: LuaTable,
tab_origin_key: &'static str,
) -> LuaResult<HashMap<String, Vec<String>>> {
let mut map = HashMap::new();
for pair in tab.pairs::<String, LuaValue>() {
let (key, value) = pair?;
match value {
LuaValue::String(s) => {
map.insert(key, vec![s.to_str()?.to_owned()]);
}
LuaValue::Table(t) => {
let mut values = Vec::new();
for value in t.sequence_values::<LuaString>() {
values.push(value?.to_str()?.to_owned());
}
map.insert(key, values);
}
_ => {
return Err(LuaError::runtime(format!(
"Value for '{tab_origin_key}' must be a string or array of strings",
)))
}
}
}
Ok(map)
}

View file

@ -0,0 +1,57 @@
use hyper::{
body::Bytes,
header::{HeaderName, HeaderValue},
HeaderMap, Method,
};
use mlua::prelude::*;
pub fn lua_value_to_bytes(value: &LuaValue) -> LuaResult<Bytes> {
match value {
LuaValue::Nil => Ok(Bytes::new()),
LuaValue::Buffer(buf) => Ok(Bytes::from(buf.to_vec())),
LuaValue::String(str) => Ok(Bytes::copy_from_slice(&str.as_bytes())),
v => Err(LuaError::FromLuaConversionError {
from: v.type_name(),
to: "Bytes".to_string(),
message: Some(format!(
"Invalid body - expected string or buffer, got {}",
v.type_name()
)),
}),
}
}
pub fn lua_value_to_method(value: &LuaValue) -> LuaResult<Method> {
match value {
LuaValue::Nil => Ok(Method::GET),
LuaValue::String(str) => {
let bytes = str.as_bytes().trim_ascii().to_ascii_uppercase();
Method::from_bytes(&bytes).into_lua_err()
}
LuaValue::Buffer(buf) => {
let bytes = buf.to_vec().trim_ascii().to_ascii_uppercase();
Method::from_bytes(&bytes).into_lua_err()
}
v => Err(LuaError::FromLuaConversionError {
from: v.type_name(),
to: "Method".to_string(),
message: Some(format!(
"Invalid method - expected string or buffer, got {}",
v.type_name()
)),
}),
}
}
pub fn lua_table_to_header_map(table: &LuaTable) -> LuaResult<HeaderMap> {
let mut headers = HeaderMap::new();
for pair in table.pairs::<LuaString, LuaString>() {
let (key, val) = pair?;
let key = HeaderName::from_bytes(&key.as_bytes()).into_lua_err()?;
let val = HeaderValue::from_bytes(&val.as_bytes()).into_lua_err()?;
headers.insert(key, val);
}
Ok(headers)
}

View file

@ -2,6 +2,7 @@ pub mod futures;
pub mod headers;
pub mod hyper;
pub mod incoming;
pub mod lua;
pub mod request;
pub mod response;
pub mod websocket;

View file

@ -5,20 +5,56 @@ use url::Url;
use hyper::{
body::{Bytes, Incoming},
header::{HeaderName, HeaderValue},
HeaderMap, Method, Request as HyperRequest,
};
use mlua::prelude::*;
use crate::{
client::config::RequestConfig,
shared::{
headers::{hash_map_to_table, header_map_to_table},
incoming::handle_incoming_body,
},
use crate::shared::{
headers::{hash_map_to_table, header_map_to_table},
incoming::handle_incoming_body,
lua::{lua_table_to_header_map, lua_value_to_bytes, lua_value_to_method},
};
#[derive(Debug, Clone)]
pub struct RequestOptions {
pub decompress: bool,
}
impl Default for RequestOptions {
fn default() -> Self {
Self { decompress: true }
}
}
impl FromLua for RequestOptions {
fn from_lua(value: LuaValue, _: &Lua) -> LuaResult<Self> {
if let LuaValue::Nil = value {
// Nil means default options
Ok(Self::default())
} else if let LuaValue::Table(tab) = value {
// Table means custom options
let decompress = match tab.get::<Option<bool>>("decompress") {
Ok(decomp) => Ok(decomp.unwrap_or(true)),
Err(_) => Err(LuaError::RuntimeError(
"Invalid option value for 'decompress' in request options".to_string(),
)),
}?;
Ok(Self { decompress })
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "RequestOptions".to_string(),
message: Some(format!(
"Invalid request options - expected table or nil, got {}",
value.type_name()
)),
})
}
}
}
#[derive(Debug, Clone)]
pub struct Request {
// NOTE: We use Bytes instead of Full<Bytes> to avoid
@ -135,52 +171,83 @@ impl Request {
}
}
impl TryFrom<RequestConfig> for Request {
type Error = LuaError;
fn try_from(config: RequestConfig) -> Result<Self, Self::Error> {
// 1. Parse the URL and make sure it is valid
let mut url = Url::parse(&config.url).into_lua_err()?;
impl FromLua for Request {
fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
if let LuaValue::String(s) = value {
// If we just got a string we assume
// its a GET request to a given url
let uri = s.to_str()?;
let uri = uri.parse().into_lua_err()?;
// 2. Append any query pairs passed as a table
{
let mut query = url.query_pairs_mut();
for (key, values) in config.query {
for value in values {
let mut request = HyperRequest::new(Bytes::new());
*request.uri_mut() = uri;
Ok(Self {
inner: request,
address: None,
redirects: None,
decompress: RequestOptions::default().decompress,
})
} else if let LuaValue::Table(tab) = value {
// If we got a table we are able to configure the
// entire request, maybe with extra options too
let options = match tab.get::<LuaValue>("options") {
Ok(opts) => RequestOptions::from_lua(opts, lua)?,
Err(_) => RequestOptions::default(),
};
// Extract url (required) + optional structured query params
let url = tab.get::<LuaString>("url")?;
let mut url = url.to_str()?.parse::<Url>().into_lua_err()?;
if let Some(t) = tab.get::<Option<LuaTable>>("query")? {
let mut query = url.query_pairs_mut();
for pair in t.pairs::<LuaString, LuaString>() {
let (key, value) = pair?;
let key = key.to_str()?;
let value = value.to_str()?;
query.append_pair(&key, &value);
}
}
// Extract method
let method = tab.get::<LuaValue>("method")?;
let method = lua_value_to_method(&method)?;
// Extract headers
let headers = tab.get::<Option<LuaTable>>("headers")?;
let headers = headers
.map(|t| lua_table_to_header_map(&t))
.transpose()?
.unwrap_or_default();
// Extract body
let body = tab.get::<LuaValue>("body")?;
let body = lua_value_to_bytes(&body)?;
// Build the full request
let mut request = HyperRequest::new(body);
request.headers_mut().extend(headers);
*request.uri_mut() = url.to_string().parse().unwrap();
*request.method_mut() = method;
// All good, validated and we got what we need
Ok(Self {
inner: request,
address: None,
redirects: None,
decompress: options.decompress,
})
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "Request".to_string(),
message: Some(format!(
"Invalid request - expected string or table, got {}",
value.type_name()
)),
})
}
// 3. Create the inner request builder
let mut builder = HyperRequest::builder()
.method(config.method)
.uri(url.as_str());
// 4. Append any headers passed as a table - builder
// headers may be None if builder is already invalid
if let Some(headers) = builder.headers_mut() {
for (key, values) in config.headers {
let key = HeaderName::from_bytes(key.as_bytes()).into_lua_err()?;
for value in values {
let value = HeaderValue::from_str(&value).into_lua_err()?;
headers.insert(key.clone(), value);
}
}
}
// 5. Convert request body bytes to the proper Body
// type that Hyper expects, if we got any bytes
let body = config.body.map(Bytes::from).unwrap_or_default();
// 6. Finally, attach the body, verifying that the request is valid
let inner = builder.body(body).into_lua_err()?;
Ok(Self {
inner,
address: None,
redirects: None,
decompress: config.options.decompress,
})
}
}

View file

@ -2,15 +2,16 @@ use http_body_util::Full;
use hyper::{
body::{Bytes, Incoming},
header::{HeaderName, HeaderValue},
HeaderMap, Response as HyperResponse,
header::{HeaderValue, CONTENT_TYPE},
HeaderMap, Response as HyperResponse, StatusCode,
};
use mlua::prelude::*;
use crate::{
server::config::ResponseConfig,
shared::{headers::header_map_to_table, incoming::handle_incoming_body},
use crate::shared::{
headers::header_map_to_table,
incoming::handle_incoming_body,
lua::{lua_table_to_header_map, lua_value_to_bytes},
};
#[derive(Debug, Clone)]
@ -104,33 +105,55 @@ impl Response {
}
}
impl TryFrom<ResponseConfig> for Response {
type Error = LuaError;
fn try_from(config: ResponseConfig) -> Result<Self, Self::Error> {
// 1. Create the inner response builder
let mut builder = HyperResponse::builder().status(config.status);
impl FromLua for Response {
fn from_lua(value: LuaValue, _: &Lua) -> LuaResult<Self> {
if let Ok(body) = lua_value_to_bytes(&value) {
// String or buffer is always a 200 text/plain response
let mut response = HyperResponse::new(body);
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
Ok(Self {
inner: response,
decompressed: false,
})
} else if let LuaValue::Table(tab) = value {
// Extract status (required)
let status = tab.get::<u16>("status")?;
let status = StatusCode::from_u16(status).into_lua_err()?;
// 2. Append any headers passed as a table - builder
// headers may be None if builder is already invalid
if let Some(headers) = builder.headers_mut() {
for (key, values) in config.headers {
let key = HeaderName::from_bytes(key.as_bytes()).into_lua_err()?;
for value in values {
let value = HeaderValue::from_str(&value).into_lua_err()?;
headers.insert(key.clone(), value);
}
}
// Extract headers
let headers = tab.get::<Option<LuaTable>>("headers")?;
let headers = headers
.map(|t| lua_table_to_header_map(&t))
.transpose()?
.unwrap_or_default();
// Extract body
let body = tab.get::<LuaValue>("body")?;
let body = lua_value_to_bytes(&body)?;
// Build the full response
let mut response = HyperResponse::new(body);
response.headers_mut().extend(headers);
*response.status_mut() = status;
// All good, validated and we got what we need
Ok(Self {
inner: response,
decompressed: false,
})
} else {
// Anything else is invalid
Err(LuaError::FromLuaConversionError {
from: value.type_name(),
to: "Response".to_string(),
message: Some(format!(
"Invalid response - expected table/string/buffer, got {}",
value.type_name()
)),
})
}
// 3. Convert response body bytes to the proper Body
// type that Hyper expects, if we got any bytes
let body = config.body.map(Bytes::from).unwrap_or_default();
// 4. Finally, attach the body, verifying that the response is valid
Ok(Self {
inner: builder.body(body).into_lua_err()?,
decompressed: false,
})
}
}