Skip to content

Commit 46f4141

Browse files
committed
Implement Caddy static responses config
1 parent 9b4ca59 commit 46f4141

File tree

8 files changed

+451
-24
lines changed

8 files changed

+451
-24
lines changed

buildpacks/static-web-server/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,75 @@ For example, a request to `example.com/support` will be tried in the document ro
306306
2. the path with HTML extension, `support.html`
307307
3. the path as a directory, `support/`
308308

309+
#### Caddy: Static Responses
310+
311+
*Default: none*
312+
313+
Define preset HTTP responses. Supports the common HTTP redirect use-case, or any custom status, headers, and body response.
314+
315+
```toml
316+
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
317+
# match the Host header indicated in request
318+
host_matcher = "hostname.example.com"
319+
# match the whole request path, supports `*` wildcards
320+
path_matcher = "/resources/*"
321+
# respond with HTTP status (default: 200)
322+
status = 200
323+
# repond with body content (default: none)
324+
body = "I could be anything."
325+
# set one or more response headers (default: none)
326+
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
327+
"X-My-Header" = "Yells at cloud"
328+
```
329+
330+
`host_matcher` and `path_matcher` are both optional. At least one of them should be set for each static response. Static responses are processed in the order defined. When a static response is matched, its response is terminal. No further processing will occur for the request.
331+
332+
[Caddy placeholders](https://caddyserver.com/docs/conventions#placeholders) can be used for per-request dynamic values. The [HTTP placeholders](https://caddyserver.com/docs/json/apps/http/#docs) are useful with this feature:
333+
334+
- `{http.request.host}`
335+
- `{http.request.uri}`
336+
- `{http.request.uri.path}`
337+
- `{http.request.uri.path.dir}`
338+
- `{http.request.uri.path.file}`
339+
- `{http.request.uri.query}`
340+
341+
For example, permanently redirect to a different path with status `301` and a `Location` header, using the requested filename in the new path:
342+
343+
```toml
344+
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
345+
path_matcher = "/blog/*"
346+
status = 301
347+
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
348+
"Location" = "/feed/{http.request.uri.path.file}"
349+
```
350+
351+
Another example, permanently redirect any request for a specific host to a new server, passing through the original URI (path and querystring) and an additional custom header:
352+
353+
```toml
354+
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
355+
host_matcher = "original.example.com"
356+
status = 301
357+
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
358+
"Location" = "https://new.example.com{http.request.uri}"
359+
"X-Redirected-From" = "original.example.com"
360+
```
361+
362+
Multiple static responses may be set using additional TOML tables:
363+
364+
```toml
365+
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
366+
path_matcher = "/news/*"
367+
status = 301
368+
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
369+
"Location" = "/media/{http.request.uri.path.file}"
370+
371+
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
372+
path_matcher = "/support/*"
373+
status = 301
374+
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
375+
"Location" = "/help/{http.request.uri.path.file}"
376+
```
377+
309378
#### Caddy: Basic Authorization
310379

311380
*Default: not enabled*

buildpacks/static-web-server/src/caddy_config.rs

Lines changed: 191 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
11
use crate::heroku_web_server_config::{
2-
ErrorsConfig, Header, HerokuWebServerConfig, DEFAULT_DOC_INDEX, DEFAULT_DOC_ROOT,
2+
ErrorsConfig, HerokuWebServerConfig, PathMatchedHeader, DEFAULT_DOC_INDEX, DEFAULT_DOC_ROOT,
33
};
44
use crate::o11y::*;
55
use indexmap::IndexMap;
6+
use serde::Serialize;
67
use serde_json::json;
78
use std::collections::HashMap;
89

910
/// Transforms the given [`HerokuWebServerConfig`] into an equivalent Caddy JSON configuration.
1011
/// Keeping this as a single function, because many lines are just the JSON itself being assembled.
1112
#[allow(clippy::too_many_lines)]
12-
pub(crate) fn caddy_json_config(config: HerokuWebServerConfig) -> serde_json::Value {
13+
pub(crate) fn caddy_json_config(config: &HerokuWebServerConfig) -> serde_json::Value {
1314
let mut routes = vec![];
1415

1516
// Header routes come first so headers will be added to any response down the chain.
16-
if let Some(headers) = config.headers {
17+
if let Some(ref headers) = config.headers {
1718
tracing::info!({ CONFIG_RESPONSE_HEADERS_ENABLED } = true, "config");
18-
routes.extend(generate_response_headers_routes(&headers));
19+
routes.extend(generate_response_headers_routes(headers));
1920
}
2021

2122
let doc_root = config
2223
.root
24+
.clone()
2325
.map_or(String::from(DEFAULT_DOC_ROOT), |path_buf| {
2426
String::from(path_buf.to_string_lossy())
2527
});
@@ -63,6 +65,8 @@ pub(crate) fn caddy_json_config(config: HerokuWebServerConfig) -> serde_json::Va
6365
}));
6466
}
6567

68+
generate_static_response_handlers(config, &mut static_file_handlers);
69+
6670
static_file_handlers.push(json!(
6771
{
6872
"handler": "encode",
@@ -191,10 +195,67 @@ pub(crate) fn caddy_json_config(config: HerokuWebServerConfig) -> serde_json::Va
191195
})
192196
}
193197

194-
fn generate_response_headers_routes(headers: &Vec<Header>) -> Vec<serde_json::Value> {
198+
fn generate_static_response_handlers(
199+
config: &HerokuWebServerConfig,
200+
static_file_handlers: &mut Vec<serde_json::Value>,
201+
) {
202+
if let Some(static_responses) = config
203+
.caddy_server_opts
204+
.as_ref()
205+
.and_then(|v| v.static_responses.clone())
206+
{
207+
tracing::info!(
208+
{ CONFIG_CADDY_SERVER_OPTS_STATIC_RESPONSES } = true,
209+
"config"
210+
);
211+
for static_response in static_responses {
212+
let mut match_array = vec![];
213+
214+
if let Some(host_matcher) = static_response.host_matcher {
215+
let mut host_match = serde_json::Map::new();
216+
host_match.insert("host".to_string(), json!(vec![host_matcher]));
217+
match_array.push(serde_json::Value::Object(host_match));
218+
}
219+
if let Some(path_matcher) = static_response.path_matcher {
220+
let mut path_match = serde_json::Map::new();
221+
path_match.insert("path".to_string(), json!(vec![path_matcher]));
222+
match_array.push(serde_json::Value::Object(path_match));
223+
}
224+
225+
let headers = static_response.headers.map(|headers_vec| {
226+
headers_vec
227+
.into_iter()
228+
.map(|header| (header.key, vec![header.value]))
229+
.collect::<HashMap<_, _>>()
230+
});
231+
232+
let static_response_handler = StaticResponseHandler {
233+
handler: "static_response".to_string(),
234+
status_code: static_response.status.unwrap_or(200),
235+
headers,
236+
body: static_response.body,
237+
};
238+
let static_response_handler_json = serde_json::to_value(static_response_handler)
239+
.expect("StaticResponseHandler should serialize to JSON");
240+
241+
static_file_handlers.push(json!(
242+
{
243+
"handler": "subroute",
244+
"routes": [{
245+
"match": match_array,
246+
"handle": [static_response_handler_json],
247+
"terminal": true
248+
}],
249+
250+
}));
251+
}
252+
}
253+
}
254+
255+
fn generate_response_headers_routes(headers: &Vec<PathMatchedHeader>) -> Vec<serde_json::Value> {
195256
// Group headers with the same matcher while preserving the order of the matchers
196257
// by "when-first-seen".
197-
let mut groups = IndexMap::<String, Vec<&Header>>::new();
258+
let mut groups = IndexMap::<String, Vec<&PathMatchedHeader>>::new();
198259
for header in headers {
199260
if let Some(headers) = groups.get_mut(&header.path_matcher) {
200261
headers.push(header);
@@ -284,27 +345,37 @@ const DEFAULT_404_HTML: &str = r#"
284345
</html>
285346
"#;
286347

348+
#[derive(Serialize)]
349+
struct StaticResponseHandler {
350+
handler: String,
351+
status_code: u16,
352+
headers: Option<HashMap<String, Vec<String>>>,
353+
body: Option<String>,
354+
}
355+
287356
#[cfg(test)]
288357
mod tests {
289358
use super::*;
290-
use crate::heroku_web_server_config::{ErrorConfig, ErrorsConfig};
359+
use crate::heroku_web_server_config::{
360+
CaddyServerOpts, CaddyStaticResponseConfig, ErrorConfig, ErrorsConfig, Header,
361+
};
291362
use std::path::PathBuf;
292363

293364
#[test]
294365
fn generates_matched_response_headers_routes() {
295366
let heroku_config = HerokuWebServerConfig {
296367
headers: Some(vec![
297-
Header {
368+
PathMatchedHeader {
298369
path_matcher: String::from("*"),
299370
key: String::from("X-Foo"),
300371
value: String::from("Bar"),
301372
},
302-
Header {
373+
PathMatchedHeader {
303374
path_matcher: String::from("*.html"),
304375
key: String::from("X-Baz"),
305376
value: String::from("Buz"),
306377
},
307-
Header {
378+
PathMatchedHeader {
308379
path_matcher: String::from("*"),
309380
key: String::from("X-Zuu"),
310381
value: String::from("Zem"),
@@ -327,7 +398,7 @@ mod tests {
327398
#[test]
328399
fn generates_global_response_headers_routes() {
329400
let heroku_config = HerokuWebServerConfig {
330-
headers: Some(vec![Header {
401+
headers: Some(vec![PathMatchedHeader {
331402
path_matcher: String::from("*"),
332403
key: String::from("X-Foo"),
333404
value: String::from("Bar"),
@@ -390,4 +461,113 @@ mod tests {
390461
json!({"handle":[{"handler":"rewrite","uri":"index.html"},{"handler":"file_server","index_names":["index.html"],"pass_thru":false,"root":"tests/fixtures/client_side_routing/public","status_code":"200"}]})
391462
);
392463
}
464+
465+
#[test]
466+
fn generates_static_response_handlers() {
467+
let heroku_config = HerokuWebServerConfig {
468+
caddy_server_opts: Some(CaddyServerOpts {
469+
static_responses: Some(vec![
470+
CaddyStaticResponseConfig {
471+
host_matcher: Some("original.example.com".to_string()),
472+
path_matcher: None,
473+
status: Some(301),
474+
headers: Some(vec![
475+
Header {
476+
key: "Location".to_string(),
477+
value: "https://new.example.com{http.request.uri}".to_string(),
478+
},
479+
Header {
480+
key: "X-Redirected-From".to_string(),
481+
value: "original.example.com".to_string(),
482+
},
483+
]),
484+
body: None,
485+
},
486+
CaddyStaticResponseConfig {
487+
host_matcher: Some("original.example.com".to_string()),
488+
path_matcher: Some("/blog/*".to_string()),
489+
status: Some(301),
490+
headers: Some(vec![Header {
491+
key: "Location".to_string(),
492+
value:
493+
"https://{http.request.host}/new-blog/{http.request.uri.path.file}"
494+
.to_string(),
495+
}]),
496+
body: None,
497+
},
498+
CaddyStaticResponseConfig {
499+
host_matcher: None,
500+
path_matcher: Some("/api/*".to_string()),
501+
status: Some(500),
502+
headers: Some(vec![Header {
503+
key: "Content-Type".to_string(),
504+
value: "application/json".to_string(),
505+
}]),
506+
body: Some(r#"{"error":"Service not available"}"#.to_string()),
507+
},
508+
]),
509+
..CaddyServerOpts::default()
510+
}),
511+
..HerokuWebServerConfig::default()
512+
};
513+
514+
let mut handlers = vec![];
515+
generate_static_response_handlers(&heroku_config, &mut handlers);
516+
517+
assert_eq!(handlers.len(), 3);
518+
519+
assert_eq!(
520+
handlers[0],
521+
json!({
522+
"handler": "subroute",
523+
"routes": [{
524+
"match": [{"host": ["original.example.com"]}],
525+
"handle": [{
526+
"handler": "static_response",
527+
"status_code": 301,
528+
"headers": {
529+
"Location": ["https://new.example.com{http.request.uri}"],
530+
"X-Redirected-From": ["original.example.com"]
531+
},
532+
"body": null
533+
}],
534+
"terminal": true
535+
}]
536+
})
537+
);
538+
539+
assert_eq!(
540+
handlers[1],
541+
json!({
542+
"handler": "subroute",
543+
"routes": [{
544+
"match": [{"host": ["original.example.com"]}, {"path": ["/blog/*"]}],
545+
"handle": [{
546+
"handler": "static_response",
547+
"status_code": 301,
548+
"headers": {"Location": ["https://{http.request.host}/new-blog/{http.request.uri.path.file}"]},
549+
"body": null
550+
}],
551+
"terminal": true
552+
}]
553+
})
554+
);
555+
556+
assert_eq!(
557+
handlers[2],
558+
json!({
559+
"handler": "subroute",
560+
"routes": [{
561+
"match": [{"path": ["/api/*"]}],
562+
"handle": [{
563+
"handler": "static_response",
564+
"status_code": 500,
565+
"headers": {"Content-Type": ["application/json"]},
566+
"body": r#"{"error":"Service not available"}"#
567+
}],
568+
"terminal": true
569+
}]
570+
})
571+
);
572+
}
393573
}

buildpacks/static-web-server/src/config_web_server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ pub(crate) fn config_web_server(
5656
);
5757

5858
// Transform web server config to Caddy native JSON config
59-
let caddy_config_json = serde_json::to_string(&caddy_json_config(heroku_config))
59+
let caddy_config_json = serde_json::to_string(&caddy_json_config(&heroku_config))
6060
.map_err(StaticWebServerBuildpackError::Json)?;
6161

6262
let config_path = configuration_layer.path().join("caddy.json");

0 commit comments

Comments
 (0)