itsi-server 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Cargo.lock +1 -1
- data/ext/itsi_scheduler/Cargo.toml +1 -1
- data/ext/itsi_server/Cargo.toml +1 -1
- data/ext/itsi_server/src/server/middleware_stack/middlewares/compression.rs +28 -11
- data/ext/itsi_server/src/server/middleware_stack/middlewares/log_requests.rs +1 -1
- data/ext/itsi_server/src/server/middleware_stack/middlewares/proxy.rs +1 -2
- data/ext/itsi_server/src/server/middleware_stack/middlewares/static_assets.rs +14 -2
- data/ext/itsi_server/src/server/middleware_stack/middlewares/string_rewrite.rs +86 -41
- data/ext/itsi_server/src/services/itsi_http_service.rs +46 -35
- data/ext/itsi_server/src/services/static_file_server.rs +31 -3
- data/lib/itsi/server/config/dsl.rb +2 -2
- data/lib/itsi/server/config/middleware/endpoint/endpoint.rb +0 -4
- data/lib/itsi/server/config/middleware/location.rb +25 -21
- data/lib/itsi/server/config/middleware/proxy.rb +15 -14
- data/lib/itsi/server/config/middleware/rackup_file.rb +7 -10
- data/lib/itsi/server/config/middleware/static_assets.rb +8 -4
- data/lib/itsi/server/config/middleware/string_rewrite.md +14 -0
- data/lib/itsi/server/config/options/include.rb +1 -1
- data/lib/itsi/server/rack_interface.rb +1 -1
- data/lib/itsi/server/route_tester.rb +1 -1
- data/lib/itsi/server/typed_handlers/param_parser.rb +25 -0
- data/lib/itsi/server/version.rb +1 -1
- data/lib/itsi/server.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '09f3db1a1f234effca0f069532c406269afd5d97fd94f2bd84e0897794111c94'
|
4
|
+
data.tar.gz: 9b58b1c27aa1be97ffbcda9f58d7d10773c34ec17ed2f8266f7b4515a1916866
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4b0adee39683e6aa6d8fde02c7caeb448924090c85db779cd210ddc01a6b1ded6944604e16b17a9e18cf8833a8565c6473d3761c0015ab13af56570f0b769850
|
7
|
+
data.tar.gz: 2733bfcbb217c88fb2c49f577eb764952551c2c0f6f0be6435505eb55334276367d3ef854bb3aed054f33c192c4c7b5ee02a2ca90d0abf2249b991b1d9a531fd
|
data/Cargo.lock
CHANGED
data/ext/itsi_server/Cargo.toml
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
use crate::{
|
2
|
-
server::http_message_types::
|
2
|
+
server::http_message_types::{HttpRequest, HttpResponse},
|
3
|
+
services::itsi_http_service::HttpRequestContext,
|
3
4
|
};
|
4
5
|
|
5
6
|
use super::{
|
@@ -13,6 +14,7 @@ use async_compression::{
|
|
13
14
|
};
|
14
15
|
use async_trait::async_trait;
|
15
16
|
use bytes::{Bytes, BytesMut};
|
17
|
+
use either::Either;
|
16
18
|
use futures::TryStreamExt;
|
17
19
|
use http::{
|
18
20
|
header::{GetAll, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
|
@@ -20,6 +22,7 @@ use http::{
|
|
20
22
|
};
|
21
23
|
use http_body_util::{combinators::BoxBody, BodyExt, Full, StreamBody};
|
22
24
|
use hyper::body::{Body, Frame};
|
25
|
+
use magnus::error::Result;
|
23
26
|
use serde::{Deserialize, Serialize};
|
24
27
|
use std::convert::Infallible;
|
25
28
|
use tokio::io::{AsyncRead, AsyncReadExt, BufReader};
|
@@ -151,6 +154,15 @@ fn update_content_encoding(parts: &mut http::response::Parts, new_encoding: Head
|
|
151
154
|
|
152
155
|
#[async_trait]
|
153
156
|
impl MiddlewareLayer for Compression {
|
157
|
+
async fn before(
|
158
|
+
&self,
|
159
|
+
req: HttpRequest,
|
160
|
+
context: &mut HttpRequestContext,
|
161
|
+
) -> Result<Either<HttpRequest, HttpResponse>> {
|
162
|
+
context.set_supported_encoding_set(&req);
|
163
|
+
Ok(Either::Left(req))
|
164
|
+
}
|
165
|
+
|
154
166
|
/// We'll apply compression on the response, where appropriate.
|
155
167
|
/// This is if:
|
156
168
|
/// * The response body is larger than the minimum size.
|
@@ -207,16 +219,21 @@ impl MiddlewareLayer for Compression {
|
|
207
219
|
}
|
208
220
|
}
|
209
221
|
|
210
|
-
let compression_method =
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
222
|
+
let compression_method =
|
223
|
+
if let Some(supported_encoding_set) = context.supported_encoding_set() {
|
224
|
+
match find_first_supported(
|
225
|
+
supported_encoding_set,
|
226
|
+
self.algorithms.iter().map(|algo| algo.as_str()),
|
227
|
+
) {
|
228
|
+
Some("gzip") => CompressionAlgorithm::Gzip,
|
229
|
+
Some("br") => CompressionAlgorithm::Brotli,
|
230
|
+
Some("deflate") => CompressionAlgorithm::Deflate,
|
231
|
+
Some("zstd") => CompressionAlgorithm::Zstd,
|
232
|
+
_ => CompressionAlgorithm::Identity,
|
233
|
+
}
|
234
|
+
} else {
|
235
|
+
CompressionAlgorithm::Identity
|
236
|
+
};
|
220
237
|
|
221
238
|
debug!(
|
222
239
|
target: "middleware::compress",
|
@@ -62,7 +62,7 @@ impl MiddlewareLayer for LogRequests {
|
|
62
62
|
req: HttpRequest,
|
63
63
|
context: &mut HttpRequestContext,
|
64
64
|
) -> Result<Either<HttpRequest, HttpResponse>> {
|
65
|
-
context.
|
65
|
+
context.init_logging_params();
|
66
66
|
if let Some(LogConfig { level, format }) = self.before.as_ref() {
|
67
67
|
level.log(format.rewrite_request(&req, context));
|
68
68
|
}
|
@@ -33,7 +33,7 @@ use reqwest::{
|
|
33
33
|
Body, Client, Url,
|
34
34
|
};
|
35
35
|
use serde::Deserialize;
|
36
|
-
use tracing::
|
36
|
+
use tracing::debug;
|
37
37
|
|
38
38
|
#[derive(Debug, Clone, Deserialize)]
|
39
39
|
pub struct Proxy {
|
@@ -324,7 +324,6 @@ impl MiddlewareLayer for Proxy {
|
|
324
324
|
.unwrap_or("")
|
325
325
|
});
|
326
326
|
|
327
|
-
info!("Extracted host str is {}", host_str);
|
328
327
|
let req_info = RequestInfo {
|
329
328
|
method: req.method().clone(),
|
330
329
|
headers: req_headers.clone(),
|
@@ -12,10 +12,11 @@ use async_trait::async_trait;
|
|
12
12
|
use either::Either;
|
13
13
|
use http::{
|
14
14
|
header::{IF_MODIFIED_SINCE, RANGE},
|
15
|
-
HeaderMap, Method,
|
15
|
+
HeaderMap, HeaderValue, Method,
|
16
16
|
};
|
17
17
|
use itsi_error::ItsiError;
|
18
18
|
use magnus::error::Result;
|
19
|
+
use moka::sync::Cache;
|
19
20
|
use regex::Regex;
|
20
21
|
use serde::Deserialize;
|
21
22
|
use std::{collections::HashMap, path::PathBuf, sync::OnceLock, time::Duration};
|
@@ -75,6 +76,10 @@ impl MiddlewareLayer for StaticAssets {
|
|
75
76
|
recheck_interval: Duration::from_secs(self.file_check_interval),
|
76
77
|
serve_hidden_files: self.serve_hidden_files,
|
77
78
|
allowed_extensions: self.allowed_extensions.clone(),
|
79
|
+
miss_cache: Cache::builder()
|
80
|
+
.max_capacity(self.max_files_in_memory)
|
81
|
+
.time_to_live(Duration::from_secs(self.file_check_interval))
|
82
|
+
.build(),
|
78
83
|
})?)
|
79
84
|
.map_err(ItsiError::new)?;
|
80
85
|
Ok(())
|
@@ -90,6 +95,8 @@ impl MiddlewareLayer for StaticAssets {
|
|
90
95
|
debug!(target: "middleware::static_assets", "Refusing to handle non-GET/HEAD request");
|
91
96
|
return Ok(Either::Left(req));
|
92
97
|
}
|
98
|
+
|
99
|
+
context.set_supported_encoding_set(&req);
|
93
100
|
let abs_path = req.uri().path();
|
94
101
|
let rel_path = if !self.relative_path {
|
95
102
|
abs_path.trim_start_matches("/")
|
@@ -123,6 +130,10 @@ impl MiddlewareLayer for StaticAssets {
|
|
123
130
|
|
124
131
|
// Let the file server handle everything
|
125
132
|
let file_server = self.file_server.get().unwrap();
|
133
|
+
let encodings: &[HeaderValue] = context
|
134
|
+
.supported_encoding_set()
|
135
|
+
.map(Vec::as_slice)
|
136
|
+
.unwrap_or(&[] as &[HeaderValue]);
|
126
137
|
let response = file_server
|
127
138
|
.serve(
|
128
139
|
&req,
|
@@ -131,9 +142,10 @@ impl MiddlewareLayer for StaticAssets {
|
|
131
142
|
serve_range,
|
132
143
|
if_modified_since,
|
133
144
|
is_head_request,
|
134
|
-
|
145
|
+
encodings,
|
135
146
|
)
|
136
147
|
.await;
|
148
|
+
|
137
149
|
if response.is_none() {
|
138
150
|
Ok(Either::Left(req))
|
139
151
|
} else {
|
@@ -50,6 +50,35 @@ pub fn parse_template(template: &str) -> Vec<Segment> {
|
|
50
50
|
}
|
51
51
|
|
52
52
|
impl StringRewrite {
|
53
|
+
/// Apply a single modifier of the form `op:arg` (or for replace `op:from,to`)
|
54
|
+
#[inline]
|
55
|
+
fn apply_modifier(s: &mut String, mod_str: &str) {
|
56
|
+
if let Some((op, arg)) = mod_str.split_once(':') {
|
57
|
+
match op {
|
58
|
+
"strip_prefix" => {
|
59
|
+
if s.starts_with(arg) {
|
60
|
+
let _ = s.drain(..arg.len());
|
61
|
+
}
|
62
|
+
}
|
63
|
+
"strip_suffix" => {
|
64
|
+
if s.ends_with(arg) {
|
65
|
+
let len = s.len();
|
66
|
+
let start = len.saturating_sub(arg.len());
|
67
|
+
let _ = s.drain(start..);
|
68
|
+
}
|
69
|
+
}
|
70
|
+
"replace" => {
|
71
|
+
if let Some((from, to)) = arg.split_once(',') {
|
72
|
+
if s.contains(from) {
|
73
|
+
*s = s.replace(from, to);
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
_ => {}
|
78
|
+
}
|
79
|
+
}
|
80
|
+
}
|
81
|
+
|
53
82
|
pub fn rewrite_request(&self, req: &HttpRequest, context: &HttpRequestContext) -> String {
|
54
83
|
let segments = self
|
55
84
|
.segments
|
@@ -63,9 +92,17 @@ impl StringRewrite {
|
|
63
92
|
|
64
93
|
for segment in segments {
|
65
94
|
match segment {
|
66
|
-
Segment::Literal(text) =>
|
67
|
-
|
68
|
-
|
95
|
+
Segment::Literal(text) => {
|
96
|
+
result.push_str(text);
|
97
|
+
}
|
98
|
+
Segment::Placeholder(raw) => {
|
99
|
+
// split into key and optional modifier
|
100
|
+
let mut parts = raw.split('|');
|
101
|
+
let key = parts.next().unwrap();
|
102
|
+
let modifiers = parts; // zero o
|
103
|
+
|
104
|
+
// 1) lookup the raw replacement
|
105
|
+
let mut replacement = match key {
|
69
106
|
"request_id" => context.short_request_id(),
|
70
107
|
"request_id_full" => context.request_id(),
|
71
108
|
"method" => req.method().as_str().to_string(),
|
@@ -76,13 +113,13 @@ impl StringRewrite {
|
|
76
113
|
.uri()
|
77
114
|
.path_and_query()
|
78
115
|
.map(|pq| pq.to_string())
|
79
|
-
.
|
116
|
+
.unwrap_or_default(),
|
80
117
|
"query" => {
|
81
|
-
let
|
82
|
-
if
|
83
|
-
|
118
|
+
let q = req.uri().query().unwrap_or("");
|
119
|
+
if q.is_empty() {
|
120
|
+
"".to_string()
|
84
121
|
} else {
|
85
|
-
format!("?{}",
|
122
|
+
format!("?{}", q)
|
86
123
|
}
|
87
124
|
}
|
88
125
|
"port" => req
|
@@ -91,31 +128,34 @@ impl StringRewrite {
|
|
91
128
|
.map(|p| p.to_string())
|
92
129
|
.unwrap_or_else(|| "80".to_string()),
|
93
130
|
"start_time" => {
|
94
|
-
if let Some(
|
95
|
-
|
131
|
+
if let Some(ts) = context.start_time() {
|
132
|
+
ts.format("%Y-%m-%d:%H:%M:%S:%3f").to_string()
|
96
133
|
} else {
|
97
134
|
"N/A".to_string()
|
98
135
|
}
|
99
136
|
}
|
100
137
|
other => {
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
}
|
114
|
-
} else {
|
138
|
+
// headers first
|
139
|
+
if let Some(hv) = req.headers().get(other) {
|
140
|
+
hv.to_str().unwrap_or("").to_string()
|
141
|
+
}
|
142
|
+
// then any regex‐capture
|
143
|
+
else if let Some(caps) = &captures {
|
144
|
+
caps.name(other)
|
145
|
+
.map(|m| m.as_str().to_string())
|
146
|
+
.unwrap_or_else(|| format!("{{{}}}", other))
|
147
|
+
}
|
148
|
+
// fallback: leave placeholder intact
|
149
|
+
else {
|
115
150
|
format!("{{{}}}", other)
|
116
151
|
}
|
117
152
|
}
|
118
153
|
};
|
154
|
+
|
155
|
+
for m in modifiers {
|
156
|
+
Self::apply_modifier(&mut replacement, m);
|
157
|
+
}
|
158
|
+
|
119
159
|
result.push_str(&replacement);
|
120
160
|
}
|
121
161
|
}
|
@@ -132,37 +172,42 @@ impl StringRewrite {
|
|
132
172
|
let mut result = String::with_capacity(self.template_string.len());
|
133
173
|
for segment in segments {
|
134
174
|
match segment {
|
135
|
-
Segment::Literal(text) =>
|
136
|
-
|
137
|
-
|
175
|
+
Segment::Literal(text) => {
|
176
|
+
result.push_str(text);
|
177
|
+
}
|
178
|
+
Segment::Placeholder(raw) => {
|
179
|
+
let mut parts = raw.split('|');
|
180
|
+
let key = parts.next().unwrap();
|
181
|
+
let modifiers = parts; // zero o
|
182
|
+
|
183
|
+
let mut replacement = match key {
|
138
184
|
"request_id" => context.short_request_id(),
|
139
185
|
"request_id_full" => context.request_id(),
|
140
186
|
"status" => resp.status().as_str().to_string(),
|
141
187
|
"addr" => context.addr.to_owned(),
|
142
188
|
"response_time" => {
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
}
|
147
|
-
format!("{}ms", response_time.num_milliseconds())
|
148
|
-
}
|
189
|
+
let dur = context.get_response_time();
|
190
|
+
let micros = dur.as_micros();
|
191
|
+
if micros < 1_000 {
|
192
|
+
format!("{}µs", micros)
|
149
193
|
} else {
|
150
|
-
|
194
|
+
let ms = dur.as_secs_f64() * 1_000.0;
|
195
|
+
format!("{:.3}ms", ms)
|
151
196
|
}
|
152
197
|
}
|
153
198
|
other => {
|
154
|
-
|
155
|
-
|
156
|
-
if let Ok(s) = val.to_str() {
|
157
|
-
s.to_string()
|
158
|
-
} else {
|
159
|
-
"".to_string()
|
160
|
-
}
|
199
|
+
if let Some(hv) = resp.headers().get(other) {
|
200
|
+
hv.to_str().unwrap_or("").to_string()
|
161
201
|
} else {
|
162
202
|
format!("{{{}}}", other)
|
163
203
|
}
|
164
204
|
}
|
165
205
|
};
|
206
|
+
|
207
|
+
for m in modifiers {
|
208
|
+
Self::apply_modifier(&mut replacement, m);
|
209
|
+
}
|
210
|
+
|
166
211
|
result.push_str(&replacement);
|
167
212
|
}
|
168
213
|
}
|
@@ -1,14 +1,15 @@
|
|
1
1
|
use crate::default_responses::{NOT_FOUND_RESPONSE, TIMEOUT_RESPONSE};
|
2
2
|
use crate::ruby_types::itsi_server::itsi_server_config::{ItsiServerTokenPreference, ServerParams};
|
3
3
|
use crate::server::binds::listener::ListenerInfo;
|
4
|
-
use crate::server::http_message_types::{
|
4
|
+
use crate::server::http_message_types::{
|
5
|
+
ConversionExt, HttpRequest, HttpResponse, RequestExt, ResponseFormat,
|
6
|
+
};
|
5
7
|
use crate::server::lifecycle_event::LifecycleEvent;
|
6
8
|
use crate::server::middleware_stack::MiddlewareLayer;
|
7
9
|
use crate::server::request_job::RequestJob;
|
8
10
|
use crate::server::serve_strategy::single_mode::RunningPhase;
|
9
11
|
use crate::server::signal::send_lifecycle_event;
|
10
|
-
use chrono;
|
11
|
-
use chrono::Local;
|
12
|
+
use chrono::{self, DateTime, Local};
|
12
13
|
use either::Either;
|
13
14
|
use http::header::ACCEPT_ENCODING;
|
14
15
|
use http::{HeaderValue, Request};
|
@@ -18,6 +19,7 @@ use itsi_error::ItsiError;
|
|
18
19
|
use regex::Regex;
|
19
20
|
use std::sync::atomic::{AtomicBool, Ordering};
|
20
21
|
use std::sync::OnceLock;
|
22
|
+
use std::time::{Duration, Instant};
|
21
23
|
use tracing::error;
|
22
24
|
|
23
25
|
use std::{future::Future, ops::Deref, pin::Pin, sync::Arc};
|
@@ -68,17 +70,16 @@ impl Deref for RequestContextInner {
|
|
68
70
|
}
|
69
71
|
|
70
72
|
pub struct RequestContextInner {
|
71
|
-
pub request_id:
|
73
|
+
pub request_id: u64,
|
72
74
|
pub service: ItsiHttpService,
|
73
75
|
pub accept: ResponseFormat,
|
74
76
|
pub matching_pattern: Option<Arc<Regex>>,
|
75
77
|
pub origin: OnceLock<Option<String>>,
|
76
78
|
pub response_format: OnceLock<ResponseFormat>,
|
77
|
-
pub
|
78
|
-
pub
|
79
|
-
pub request_start_time: OnceLock<chrono::DateTime<Local>>,
|
79
|
+
pub request_start_time: OnceLock<DateTime<Local>>,
|
80
|
+
pub start_instant: Instant,
|
80
81
|
pub if_none_match: OnceLock<Option<String>>,
|
81
|
-
pub supported_encoding_set: Vec<HeaderValue
|
82
|
+
pub supported_encoding_set: OnceLock<Vec<HeaderValue>>,
|
82
83
|
pub is_ruby_request: Arc<AtomicBool>,
|
83
84
|
}
|
84
85
|
|
@@ -87,27 +88,38 @@ impl HttpRequestContext {
|
|
87
88
|
service: ItsiHttpService,
|
88
89
|
matching_pattern: Option<Arc<Regex>>,
|
89
90
|
accept: ResponseFormat,
|
90
|
-
supported_encoding_set: Vec<HeaderValue>,
|
91
91
|
is_ruby_request: Arc<AtomicBool>,
|
92
92
|
) -> Self {
|
93
93
|
HttpRequestContext {
|
94
94
|
inner: Arc::new(RequestContextInner {
|
95
|
-
request_id: rand::random::<
|
95
|
+
request_id: rand::random::<u64>(),
|
96
96
|
service,
|
97
97
|
matching_pattern,
|
98
98
|
accept,
|
99
99
|
origin: OnceLock::new(),
|
100
100
|
response_format: OnceLock::new(),
|
101
|
-
start_time: chrono::Utc::now(),
|
102
|
-
request: None,
|
103
101
|
request_start_time: OnceLock::new(),
|
102
|
+
start_instant: Instant::now(),
|
104
103
|
if_none_match: OnceLock::new(),
|
105
|
-
supported_encoding_set,
|
104
|
+
supported_encoding_set: OnceLock::new(),
|
106
105
|
is_ruby_request,
|
107
106
|
}),
|
108
107
|
}
|
109
108
|
}
|
110
109
|
|
110
|
+
pub fn set_supported_encoding_set(&self, req: &HttpRequest) {
|
111
|
+
let supported_encoding_set = req
|
112
|
+
.headers()
|
113
|
+
.get_all(ACCEPT_ENCODING)
|
114
|
+
.into_iter()
|
115
|
+
.cloned()
|
116
|
+
.collect::<Vec<_>>();
|
117
|
+
self.inner
|
118
|
+
.supported_encoding_set
|
119
|
+
.set(supported_encoding_set)
|
120
|
+
.unwrap();
|
121
|
+
}
|
122
|
+
|
111
123
|
pub fn set_origin(&self, origin: Option<String>) {
|
112
124
|
self.inner.origin.set(origin).unwrap();
|
113
125
|
}
|
@@ -121,28 +133,29 @@ impl HttpRequestContext {
|
|
121
133
|
}
|
122
134
|
|
123
135
|
pub fn short_request_id(&self) -> String {
|
124
|
-
format!("{:
|
136
|
+
format!("{:08x}", self.inner.request_id & 0xffff_ffff)
|
125
137
|
}
|
126
138
|
|
127
139
|
pub fn request_id(&self) -> String {
|
128
|
-
format!("{:
|
140
|
+
format!("{:08x}", self.inner.request_id)
|
129
141
|
}
|
130
142
|
|
131
|
-
pub fn
|
143
|
+
pub fn init_logging_params(&self) {
|
132
144
|
self.inner
|
133
145
|
.request_start_time
|
134
146
|
.get_or_init(chrono::Local::now);
|
135
147
|
}
|
136
148
|
|
137
|
-
pub fn
|
149
|
+
pub fn start_instant(&self) -> Instant {
|
150
|
+
self.inner.start_instant
|
151
|
+
}
|
152
|
+
|
153
|
+
pub fn start_time(&self) -> Option<DateTime<Local>> {
|
138
154
|
self.inner.request_start_time.get().cloned()
|
139
155
|
}
|
140
156
|
|
141
|
-
pub fn get_response_time(&self) ->
|
142
|
-
self.inner
|
143
|
-
.request_start_time
|
144
|
-
.get()
|
145
|
-
.map(|instant| Local::now() - instant)
|
157
|
+
pub fn get_response_time(&self) -> Duration {
|
158
|
+
self.inner.start_instant.elapsed()
|
146
159
|
}
|
147
160
|
|
148
161
|
pub fn set_response_format(&self, format: ResponseFormat) {
|
@@ -152,6 +165,10 @@ impl HttpRequestContext {
|
|
152
165
|
pub fn response_format(&self) -> &ResponseFormat {
|
153
166
|
self.inner.response_format.get().unwrap()
|
154
167
|
}
|
168
|
+
|
169
|
+
pub fn supported_encoding_set(&self) -> Option<&Vec<HeaderValue>> {
|
170
|
+
self.inner.supported_encoding_set.get()
|
171
|
+
}
|
155
172
|
}
|
156
173
|
|
157
174
|
const SERVER_TOKEN_VERSION: HeaderValue =
|
@@ -170,12 +187,7 @@ impl Service<Request<Incoming>> for ItsiHttpService {
|
|
170
187
|
let accept: ResponseFormat = req.accept().into();
|
171
188
|
let accept_clone = accept.clone();
|
172
189
|
let is_single_mode = self.server_params.workers == 1;
|
173
|
-
|
174
|
-
.headers()
|
175
|
-
.get_all(ACCEPT_ENCODING)
|
176
|
-
.into_iter()
|
177
|
-
.cloned()
|
178
|
-
.collect::<Vec<_>>();
|
190
|
+
|
179
191
|
let request_timeout = self.server_params.request_timeout;
|
180
192
|
let is_ruby_request = Arc::new(AtomicBool::new(false));
|
181
193
|
let irr_clone = is_ruby_request.clone();
|
@@ -187,7 +199,6 @@ impl Service<Request<Incoming>> for ItsiHttpService {
|
|
187
199
|
self_clone,
|
188
200
|
matching_pattern,
|
189
201
|
accept_clone.clone(),
|
190
|
-
supported_encoding_set,
|
191
202
|
irr_clone,
|
192
203
|
);
|
193
204
|
let mut depth = 0;
|
@@ -229,8 +240,8 @@ impl Service<Request<Incoming>> for ItsiHttpService {
|
|
229
240
|
Ok(resp)
|
230
241
|
};
|
231
242
|
|
232
|
-
|
233
|
-
|
243
|
+
if let Some(timeout_duration) = request_timeout {
|
244
|
+
Box::pin(async move {
|
234
245
|
match timeout(timeout_duration, service_future).await {
|
235
246
|
Ok(result) => result,
|
236
247
|
Err(_) => {
|
@@ -249,9 +260,9 @@ impl Service<Request<Incoming>> for ItsiHttpService {
|
|
249
260
|
Ok(TIMEOUT_RESPONSE.to_http_response(accept).await)
|
250
261
|
}
|
251
262
|
}
|
252
|
-
}
|
253
|
-
|
254
|
-
|
255
|
-
}
|
263
|
+
})
|
264
|
+
} else {
|
265
|
+
Box::pin(service_future)
|
266
|
+
}
|
256
267
|
}
|
257
268
|
}
|
@@ -51,6 +51,10 @@ pub static ROOT_STATIC_FILE_SERVER: LazyLock<StaticFileServer> = LazyLock::new(|
|
|
51
51
|
not_found_behavior: NotFoundBehavior::Error(ErrorResponse::not_found()),
|
52
52
|
serve_hidden_files: false,
|
53
53
|
allowed_extensions: vec!["html".to_string(), "css".to_string(), "js".to_string()],
|
54
|
+
miss_cache: Cache::builder()
|
55
|
+
.max_capacity(1000)
|
56
|
+
.time_to_live(Duration::from_secs(1))
|
57
|
+
.build(),
|
54
58
|
})
|
55
59
|
.unwrap()
|
56
60
|
});
|
@@ -85,6 +89,7 @@ pub struct StaticFileServerConfig {
|
|
85
89
|
pub headers: Option<HashMap<String, String>>,
|
86
90
|
pub serve_hidden_files: bool,
|
87
91
|
pub allowed_extensions: Vec<String>,
|
92
|
+
pub miss_cache: Cache<String, NotFoundBehavior>,
|
88
93
|
}
|
89
94
|
|
90
95
|
#[derive(Debug, Clone)]
|
@@ -389,6 +394,29 @@ impl StaticFileServer {
|
|
389
394
|
abs_path: &str,
|
390
395
|
accept: ResponseFormat,
|
391
396
|
) -> std::result::Result<ResolvedAsset, NotFoundBehavior> {
|
397
|
+
let ext_opt = Path::new(key)
|
398
|
+
.extension()
|
399
|
+
.and_then(|e| e.to_str())
|
400
|
+
.map(|s| s.to_lowercase());
|
401
|
+
|
402
|
+
// If the allowed list is non-empty, enforce membership
|
403
|
+
if !self.allowed_extensions.is_empty() {
|
404
|
+
match ext_opt {
|
405
|
+
Some(ref ext)
|
406
|
+
if self
|
407
|
+
.allowed_extensions
|
408
|
+
.iter()
|
409
|
+
.any(|ae| ae.eq_ignore_ascii_case(ext)) => {}
|
410
|
+
None if self.config.try_html_extension => {}
|
411
|
+
_ => {
|
412
|
+
return Err(self.config.not_found_behavior.clone());
|
413
|
+
}
|
414
|
+
}
|
415
|
+
}
|
416
|
+
|
417
|
+
if let Some(cached_nf) = self.miss_cache.get(key) {
|
418
|
+
return Err(cached_nf.clone());
|
419
|
+
}
|
392
420
|
// First check if we have a cached mapping for this key
|
393
421
|
if let Some(path) = self.key_to_path.lock().await.get(key) {
|
394
422
|
// Check if the cached entry is still valid
|
@@ -449,7 +477,6 @@ impl StaticFileServer {
|
|
449
477
|
|
450
478
|
let mut full_path = self.config.root_dir.clone();
|
451
479
|
full_path.push(normalized_path);
|
452
|
-
debug!("Resolving path {:?}", full_path);
|
453
480
|
// Check if path exists and is a file
|
454
481
|
match tokio::fs::metadata(&full_path).await {
|
455
482
|
Ok(metadata) => {
|
@@ -561,7 +588,6 @@ impl StaticFileServer {
|
|
561
588
|
}
|
562
589
|
Err(_) => {
|
563
590
|
// Path doesn't exist, try with .html extension if configured
|
564
|
-
debug!("Path doesn't exist");
|
565
591
|
if self.config.try_html_extension {
|
566
592
|
let mut html_path = full_path.clone();
|
567
593
|
html_path.set_extension("html");
|
@@ -592,7 +618,9 @@ impl StaticFileServer {
|
|
592
618
|
}
|
593
619
|
|
594
620
|
// If we get here, we couldn't resolve the key to a file
|
595
|
-
|
621
|
+
let nf = self.config.not_found_behavior.clone();
|
622
|
+
self.miss_cache.insert(key.to_string(), nf.clone());
|
623
|
+
Err(nf)
|
596
624
|
}
|
597
625
|
|
598
626
|
async fn stream_file_range(
|
@@ -52,9 +52,9 @@ module Itsi
|
|
52
52
|
@accepts = accepts.map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
53
53
|
|
54
54
|
@options = {
|
55
|
-
|
55
|
+
nested_locations: [],
|
56
56
|
middleware_loader: lambda do
|
57
|
-
@options[:
|
57
|
+
@options[:nested_locations].each(&:call)
|
58
58
|
@middleware[:app] ||= {}
|
59
59
|
@middleware[:app][:app_proc] = @middleware[:app]&.[](:preloader)&.call || DEFAULT_APP[]
|
60
60
|
[flatten_routes, Config.errors_to_error_lines(errors)]
|
@@ -84,10 +84,6 @@ module Itsi
|
|
84
84
|
end
|
85
85
|
else
|
86
86
|
@params[:paths] << "" if @params[:paths].empty?
|
87
|
-
@params[:paths] = @params[:paths].flat_map do |p|
|
88
|
-
stripped_trailing = p[/(.*)\/?$/, 1]
|
89
|
-
[stripped_trailing, stripped_trailing + "/"]
|
90
|
-
end.uniq
|
91
87
|
location.location(*@params[:paths], methods: @params[:http_methods]) do
|
92
88
|
@middleware[:app] = app
|
93
89
|
end
|
@@ -27,7 +27,7 @@ module Itsi
|
|
27
27
|
end
|
28
28
|
|
29
29
|
attr_accessor :location, :routes, :block, :protocols, :hosts, :ports,
|
30
|
-
:extensions, :content_types, :accepts
|
30
|
+
:extensions, :content_types, :accepts
|
31
31
|
|
32
32
|
def initialize(location,
|
33
33
|
*routes,
|
@@ -56,13 +56,13 @@ module Itsi
|
|
56
56
|
block: block
|
57
57
|
}).to_h
|
58
58
|
@routes = params[:routes].empty? ? ["*"] : params[:routes]
|
59
|
-
@methods = params[:methods]
|
60
|
-
@protocols = params[:protocols] | params[:schemes]
|
61
|
-
@hosts = params[:hosts]
|
62
|
-
@ports = params[:ports]
|
63
|
-
@extensions = params[:extensions]
|
64
|
-
@content_types = params[:content_types]
|
65
|
-
@accepts = params[:accepts]
|
59
|
+
@methods = params[:methods].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
60
|
+
@protocols = (params[:protocols] | params[:schemes]).map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
61
|
+
@hosts = params[:hosts].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
62
|
+
@ports = params[:ports].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
63
|
+
@extensions = params[:extensions].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
64
|
+
@content_types = params[:content_types].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
65
|
+
@accepts = params[:accepts].map { |s| s.is_a?(Regexp) ? s : s.to_s }
|
66
66
|
@block = block
|
67
67
|
end
|
68
68
|
|
@@ -70,27 +70,31 @@ module Itsi
|
|
70
70
|
@methods
|
71
71
|
end
|
72
72
|
|
73
|
+
def intersect(a, b)
|
74
|
+
return b if a.empty?
|
75
|
+
return a if b.empty?
|
76
|
+
a & b
|
77
|
+
end
|
78
|
+
|
73
79
|
def build!
|
74
80
|
build_child = lambda {
|
75
|
-
|
81
|
+
child = DSL.new(
|
76
82
|
location,
|
77
83
|
routes: routes,
|
78
|
-
methods:
|
79
|
-
protocols:
|
80
|
-
hosts:
|
81
|
-
ports:
|
82
|
-
extensions:
|
83
|
-
content_types:
|
84
|
-
accepts:
|
84
|
+
methods: intersect(http_methods, location.http_methods),
|
85
|
+
protocols: intersect(protocols, location.protocols),
|
86
|
+
hosts: intersect(hosts, location.hosts),
|
87
|
+
ports: intersect(ports, location.ports),
|
88
|
+
extensions: intersect(extensions, location.extensions),
|
89
|
+
content_types: intersect(content_types, location.content_types),
|
90
|
+
accepts: intersect(accepts, location.accepts),
|
85
91
|
controller: location.controller,
|
86
92
|
&block
|
87
93
|
)
|
94
|
+
child.options[:nested_locations].each(&:call)
|
95
|
+
location.children << child
|
88
96
|
}
|
89
|
-
|
90
|
-
location.options[:middleware_loaders] << build_child
|
91
|
-
else
|
92
|
-
build_child[]
|
93
|
-
end
|
97
|
+
location.options[:nested_locations] << build_child
|
94
98
|
end
|
95
99
|
|
96
100
|
end
|
@@ -2,17 +2,16 @@ module Itsi
|
|
2
2
|
class Server
|
3
3
|
module Config
|
4
4
|
class Proxy < Middleware
|
5
|
-
|
6
5
|
insert_text <<~SNIPPET
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
6
|
+
proxy \\
|
7
|
+
to: "${1:http://backend.example.com{path_and_query}",
|
8
|
+
backends: [${2:"127.0.0.1:3001", "127.0.0.1:3002"}],
|
9
|
+
backend_priority: ${3|"round_robin","ordered","random"|},
|
10
|
+
headers: { ${4| "X-Forwarded-For" => { rewrite: "{addr}" },|} },
|
11
|
+
verify_ssl: ${5|true,false|},
|
12
|
+
timeout: ${6|30,60|},
|
13
|
+
tls_sni: ${7|true,false|},
|
14
|
+
error_response: ${8|"bad_gateway", "service_unavailable", { code: 503\\, default_format: "html"\\, html: { inline: "<h1>Service Unavailable</h1>" } }|}
|
16
15
|
SNIPPET
|
17
16
|
|
18
17
|
detail "Forwards incoming requests to a backend server using dynamic URL rewriting. Supports various backend selection strategies and header overriding."
|
@@ -21,18 +20,20 @@ module Itsi
|
|
21
20
|
{
|
22
21
|
to: Type(String) & Required(),
|
23
22
|
backends: Array(Type(String)),
|
24
|
-
backend_priority: Enum([
|
23
|
+
backend_priority: Enum(%w[round_robin ordered random]).default("round_robin"),
|
25
24
|
headers: Hash(Type(String), Type(String)).default({}),
|
26
25
|
verify_ssl: Bool().default(true),
|
27
26
|
tls_sni: Bool().default(true),
|
28
27
|
timeout: Type(Integer).default(30),
|
29
|
-
error_response: Type(ErrorResponseDef).default("bad_gateway")
|
28
|
+
error_response: Type(ErrorResponseDef).default("bad_gateway")
|
30
29
|
}
|
31
30
|
end
|
32
31
|
|
33
32
|
def build!
|
34
|
-
require
|
35
|
-
@params[:backends]||=
|
33
|
+
require "uri"
|
34
|
+
@params[:backends] ||= URI.extract(@params[:to]).map(&URI.method(:parse)).map do |u|
|
35
|
+
"#{u.scheme}://#{u.host}:#{u.port}"
|
36
|
+
end
|
36
37
|
super
|
37
38
|
end
|
38
39
|
end
|
@@ -2,12 +2,11 @@ module Itsi
|
|
2
2
|
class Server
|
3
3
|
module Config
|
4
4
|
class RackupFile < Middleware
|
5
|
-
|
6
5
|
insert_text <<~SNIPPET
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
6
|
+
rackup_file \\
|
7
|
+
"config.ru",
|
8
|
+
nonblocking: ${2|true,false|},
|
9
|
+
sendfile: ${3|true,false|}
|
11
10
|
|
12
11
|
SNIPPET
|
13
12
|
|
@@ -23,20 +22,18 @@ module Itsi
|
|
23
22
|
def initialize(location, app, **params)
|
24
23
|
super(location, params)
|
25
24
|
raise "Rackup file must be a string" unless app.is_a?(String)
|
25
|
+
|
26
26
|
@app = Itsi::Server::RackInterface.for(app)
|
27
27
|
end
|
28
28
|
|
29
29
|
def build!
|
30
30
|
app_args = {
|
31
|
-
preloader: -> { @app},
|
31
|
+
preloader: -> { @app },
|
32
32
|
sendfile: @params[:sendfile],
|
33
33
|
nonblocking: @params[:nonblocking],
|
34
|
-
base_path: "^(?<base_path>#{location.paths_from_parent.gsub(/\.\*\)$/,
|
34
|
+
base_path: "^(?<base_path>#{location.paths_from_parent.gsub(/\.\*\)$/, ")")}).*$"
|
35
35
|
}
|
36
36
|
location.middleware[:app] = app_args
|
37
|
-
location.location("*") do
|
38
|
-
@middleware[:app] = app_args
|
39
|
-
end
|
40
37
|
end
|
41
38
|
end
|
42
39
|
end
|
@@ -75,11 +75,15 @@ module Itsi
|
|
75
75
|
@params[:allowed_extensions] << ""
|
76
76
|
end
|
77
77
|
|
78
|
-
@params[:
|
79
|
-
|
80
|
-
|
81
|
-
@middleware[:static_assets] = params
|
78
|
+
if @params[:allowed_extensions].any? && @params[:auto_index]
|
79
|
+
@params[:allowed_extensions] |= ["html"]
|
80
|
+
@params[:allowed_extensions] |= [""]
|
82
81
|
end
|
82
|
+
|
83
|
+
@params[:base_path] = "^(?<base_path>#{location.paths_from_parent.gsub(/\.\*\)$/,")")}).*$"
|
84
|
+
params = @params
|
85
|
+
|
86
|
+
location.middleware[:static_assets] = params
|
83
87
|
end
|
84
88
|
end
|
85
89
|
end
|
@@ -13,6 +13,20 @@ The String Rewrite mechanism is used when configuring Itsi for
|
|
13
13
|
|
14
14
|
It allows you to create dynamic strings from a template by combining literal text with placeholders. Placeholders (denoted using curly braces: `{}`) are replaced at runtime with data derived from the HTTP request, response, or context.
|
15
15
|
|
16
|
+
Modifiers can be appended after a pipe | to transform the substituted value.
|
17
|
+
|
18
|
+
## Modifiers
|
19
|
+
|
20
|
+
After a placeholder name, add |<modifier>:<arg> (or for replace, |replace:<from>,<to>). Available modifiers:
|
21
|
+
|
22
|
+
`strip_prefix:<text>` If the substituted value starts with <text>, remove that prefix.
|
23
|
+
|
24
|
+
`strip_suffix:<text>` If the substituted value ends with <text>, remove that suffix.
|
25
|
+
|
26
|
+
`replace:<from>,<to>` Replace all occurrences of <from> in the substituted value with <to>.
|
27
|
+
|
28
|
+
Modifiers are applied in the order they appear. You can chain multiple modifiers by repeating the |<modifier>:<arg> syntax (e.g. `{path|strip_prefix:/rails|replace:old,new}`).
|
29
|
+
|
16
30
|
### Rewriting a Request
|
17
31
|
|
18
32
|
The following placeholders are supported:
|
@@ -3,7 +3,7 @@ module Itsi
|
|
3
3
|
module Config
|
4
4
|
class Include < Option
|
5
5
|
|
6
|
-
insert_text "include \"${1|other_file
|
6
|
+
insert_text "include \"${1|other_file|}\" # Include another file to be loaded within the current configuration"
|
7
7
|
|
8
8
|
detail "Include another file to be loaded within the current configuration"
|
9
9
|
|
@@ -7,7 +7,7 @@ module Itsi
|
|
7
7
|
if app.is_a?(String)
|
8
8
|
dir = File.expand_path(File.dirname(app))
|
9
9
|
Dir.chdir(dir) do
|
10
|
-
loaded_app = ::Rack::Builder.parse_file(app)
|
10
|
+
loaded_app = ::Rack::Builder.parse_file(File.basename(app))
|
11
11
|
app = loaded_app.is_a?(Array) ? loaded_app.first : loaded_app
|
12
12
|
end
|
13
13
|
end
|
@@ -143,6 +143,31 @@ module Itsi
|
|
143
143
|
# Fixed keys are converted to symbols, and regex-matched keys remain as strings.
|
144
144
|
# The current location in the params is tracked as an array of path segments.
|
145
145
|
def apply_schema!(params, schema, path = [])
|
146
|
+
# Support top-level array schema: homogeneous arrays.
|
147
|
+
if schema.is_a?(Array)
|
148
|
+
# Only allow homogeneous array types
|
149
|
+
unless schema.size == 1
|
150
|
+
raise ValidationError.new(["Schema Array must contain exactly one type. Got #{schema.size}"])
|
151
|
+
end
|
152
|
+
expected_type = schema.first
|
153
|
+
# Expect params to be an Array
|
154
|
+
unless params.is_a?(Array)
|
155
|
+
raise ValidationError.new(["Expected Array at #{format_path(path)}, got #{params.class}"])
|
156
|
+
end
|
157
|
+
errors = []
|
158
|
+
params.each_with_index do |_, idx|
|
159
|
+
err = cast_value!(params, idx, expected_type, path + [idx])
|
160
|
+
errors << err if err
|
161
|
+
end
|
162
|
+
raise ValidationError.new(errors) unless errors.empty?
|
163
|
+
return params
|
164
|
+
end
|
165
|
+
|
166
|
+
# Ensure schema is a Hash
|
167
|
+
unless schema.is_a?(Hash)
|
168
|
+
raise ValidationError.new(["Unsupported schema type: #{schema.class} at #{format_path(path)}"])
|
169
|
+
end
|
170
|
+
|
146
171
|
errors = []
|
147
172
|
processed = processed_schema(schema)
|
148
173
|
fixed_schema = processed[0]
|
data/lib/itsi/server/version.rb
CHANGED
data/lib/itsi/server.rb
CHANGED
@@ -262,7 +262,7 @@ module Itsi
|
|
262
262
|
end
|
263
263
|
|
264
264
|
def routes(cli_params = {})
|
265
|
-
load_route_middleware_stack(cli_params).each do |stack|
|
265
|
+
load_route_middleware_stack(cli_params).first.each do |stack|
|
266
266
|
routes = explode_route_pattern(stack["route"].source)
|
267
267
|
routes.each do |route|
|
268
268
|
print_route(route, stack)
|