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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28ba76ce129635022bb442e61f18dac9c6d29d7552b7b2e006ec16b8c5ef975a
4
- data.tar.gz: 944d5bfee24014c8aab5bffede13f73d6316023dcda9df7cbaf7e6fb442f8d9d
3
+ metadata.gz: '09f3db1a1f234effca0f069532c406269afd5d97fd94f2bd84e0897794111c94'
4
+ data.tar.gz: 9b58b1c27aa1be97ffbcda9f58d7d10773c34ec17ed2f8266f7b4515a1916866
5
5
  SHA512:
6
- metadata.gz: 658829fb38833abdd60bbb175f552faddfa26692598626a25a546845bcbe4f1b6478f268b2917c6ef08b5ebf86e93c3bace063ba0594e2a774dca3810d616ce4
7
- data.tar.gz: 9089b2a6067e148a8893f8e12e07e9e7fa1e3625b6a8940d3bf4fbae3aa81d3b10ab538250c8a15bdca084d0a603480554bb1663b73deccfc68c6deaba560205
6
+ metadata.gz: 4b0adee39683e6aa6d8fde02c7caeb448924090c85db779cd210ddc01a6b1ded6944604e16b17a9e18cf8833a8565c6473d3761c0015ab13af56570f0b769850
7
+ data.tar.gz: 2733bfcbb217c88fb2c49f577eb764952551c2c0f6f0be6435505eb55334276367d3ef854bb3aed054f33c192c4c7b5ee02a2ca90d0abf2249b991b1d9a531fd
data/Cargo.lock CHANGED
@@ -1644,7 +1644,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
1644
1644
 
1645
1645
  [[package]]
1646
1646
  name = "itsi-server"
1647
- version = "0.2.3"
1647
+ version = "0.2.4"
1648
1648
  dependencies = [
1649
1649
  "argon2",
1650
1650
  "async-channel",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "itsi-scheduler"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2021"
5
5
  authors = ["Wouter Coppieters <wc@pico.net.nz>"]
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "itsi-server"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2021"
5
5
  authors = ["Wouter Coppieters <wc@pico.net.nz>"]
6
6
  license = "MIT"
@@ -1,5 +1,6 @@
1
1
  use crate::{
2
- server::http_message_types::HttpResponse, services::itsi_http_service::HttpRequestContext,
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 = match find_first_supported(
211
- &context.supported_encoding_set,
212
- self.algorithms.iter().map(|algo| algo.as_str()),
213
- ) {
214
- Some("gzip") => CompressionAlgorithm::Gzip,
215
- Some("br") => CompressionAlgorithm::Brotli,
216
- Some("deflate") => CompressionAlgorithm::Deflate,
217
- Some("zstd") => CompressionAlgorithm::Zstd,
218
- _ => CompressionAlgorithm::Identity,
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.track_start_time();
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::{debug, info};
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
- &context.supported_encoding_set,
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) => result.push_str(text),
67
- Segment::Placeholder(placeholder) => {
68
- let replacement = match placeholder.as_str() {
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
- .unwrap_or("".to_string()),
116
+ .unwrap_or_default(),
80
117
  "query" => {
81
- let query = req.uri().query().unwrap_or("").to_string();
82
- if query.is_empty() {
83
- query
118
+ let q = req.uri().query().unwrap_or("");
119
+ if q.is_empty() {
120
+ "".to_string()
84
121
  } else {
85
- format!("?{}", query)
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(start_time) = context.start_time() {
95
- start_time.format("%Y-%m-%d:%H:%M:%S:%3f").to_string()
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
- if let Some(header_val) = req.headers().get(other) {
102
- if let Ok(s) = header_val.to_str() {
103
- s.to_string()
104
- } else {
105
- "".to_string()
106
- }
107
- } else if let Some(caps) = &captures {
108
- if let Some(m) = caps.name(other) {
109
- m.as_str().to_string()
110
- } else {
111
- // Fallback: leave the placeholder as is.
112
- format!("{{{}}}", other)
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) => result.push_str(text),
136
- Segment::Placeholder(placeholder) => {
137
- let replacement = match placeholder.as_str() {
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
- if let Some(response_time) = context.get_response_time() {
144
- if let Some(microseconds) = response_time.num_microseconds() {
145
- format!("{:.3}ms", microseconds as f64 / 1000.0)
146
- } else {
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
- "-".to_string()
194
+ let ms = dur.as_secs_f64() * 1_000.0;
195
+ format!("{:.3}ms", ms)
151
196
  }
152
197
  }
153
198
  other => {
154
- // Try pulling from response headers first
155
- if let Some(val) = resp.headers().get(other) {
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::{ConversionExt, HttpResponse, RequestExt, ResponseFormat};
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: u128,
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 start_time: chrono::DateTime<chrono::Utc>,
78
- pub request: Option<Arc<Request<Incoming>>>,
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::<u128>(),
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!("{:016x}", self.inner.request_id & 0xffff_ffff_ffff_ffff)
136
+ format!("{:08x}", self.inner.request_id & 0xffff_ffff)
125
137
  }
126
138
 
127
139
  pub fn request_id(&self) -> String {
128
- format!("{:016x}", self.inner.request_id)
140
+ format!("{:08x}", self.inner.request_id)
129
141
  }
130
142
 
131
- pub fn track_start_time(&self) {
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 start_time(&self) -> Option<chrono::DateTime<Local>> {
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) -> Option<chrono::TimeDelta> {
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
- let supported_encoding_set = req
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
- Box::pin(async move {
233
- if let Some(timeout_duration) = request_timeout {
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
- } else {
253
- service_future.await
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
- Err(self.config.not_found_behavior.clone())
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
- middleware_loaders: [],
55
+ nested_locations: [],
56
56
  middleware_loader: lambda do
57
- @options[:middleware_loaders].each(&:call)
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, :block
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
- location.children << DSL.new(
81
+ child = DSL.new(
76
82
  location,
77
83
  routes: routes,
78
- methods: Array(http_methods) | location.http_methods,
79
- protocols: Array(protocols) | location.protocols,
80
- hosts: Array(hosts) | location.hosts,
81
- ports: Array(ports) | location.ports,
82
- extensions: Array(extensions) | location.extensions,
83
- content_types: Array(content_types) | location.content_types,
84
- accepts: Array(accepts) | location.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
- if location.parent.nil?
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
- proxy \\
8
- to: "${1:http://backend.example.com/api{path}{query}}", \\
9
- backends: [${2:"127.0.0.1:3001", "127.0.0.1:3002"}], \\
10
- backend_priority: ${3|"round_robin","ordered","random"|}, \\
11
- headers: { ${4| "X-Forwarded-For" => { rewrite: "{addr}" },|} }, \\
12
- verify_ssl: ${5|true,false|}, \\
13
- timeout: ${6|30,60|}, \\
14
- tls_sni: ${7|true,false|}, \\
15
- error_response: ${8|"bad_gateway", "service_unavailable", { code: 503\\, default_format: "html"\\, html: { inline: "<h1>Service Unavailable</h1>" } }|}
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(["round_robin", "ordered", "random"]).default("round_robin"),
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 'uri'
35
- @params[:backends]||= URI.extract(@params[:to]).map(&URI.method(:parse)).map{|u| "#{u.scheme}://#{u.host}:#{u.port}" }
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
- rackup_file \\
8
- "config.ru",
9
- nonblocking: ${2|true,false|},
10
- sendfile: ${3|true,false|}
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[:base_path] = "^(?<base_path>#{location.paths_from_parent}).*$"
79
- params = @params
80
- location.location("*", extensions: @params[:allowed_extensions]) do
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.rb|}\" # Include another file to be loaded within the current configuration"
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
@@ -4,7 +4,7 @@ module Itsi
4
4
  module RouteTester
5
5
  require "set"
6
6
  require "strscan"
7
- require "debug"
7
+
8
8
  def format_mw(mw)
9
9
  mw_name, mw_args = mw
10
10
  case mw_name
@@ -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]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Itsi
4
4
  class Server
5
- VERSION = "0.2.3"
5
+ VERSION = "0.2.4"
6
6
  end
7
7
  end
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: itsi-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wouter Coppieters