// App::static_assets mount + middleware: prefix matching, asset-id
// extraction, content negotiation across encodings/index files, and
// the StaticResolvedResponder that merges asset headers onto the
// response without clobbering middleware-set values.
///|
fn normalize_mount_path(path : String) -> String {
if path == "" {
"/"
} else if path.length() > 1 && path[path.length() - 1:] == "/" {
path[:path.length() - 1].to_owned()
} else {
path
}
}
///|
test "normalize_mount_path normalizes empty, root, and trailing slashes" {
debug_inspect(
normalize_mount_path(""),
content=(
#|"/"
),
)
debug_inspect(
normalize_mount_path("/"),
content=(
#|"/"
),
)
debug_inspect(
normalize_mount_path("/api/"),
content=(
#|"/api"
),
)
debug_inspect(
normalize_mount_path("/api"),
content=(
#|"/api"
),
)
}
///|
fn mounted_asset_id(mount_path : String, request_path : String) -> String {
if mount_path == "/" {
if request_path == "/" {
""
} else {
request_path[1:].to_owned()
}
} else if request_path.length() == mount_path.length() {
""
} else {
request_path[mount_path.length() + 1:].to_owned()
}
}
///|
test "mounted_asset_id extracts asset id relative to mount path" {
debug_inspect(
mounted_asset_id("/", "/"),
content=(
#|""
),
)
debug_inspect(
mounted_asset_id("/", "/hello.txt"),
content=(
#|"hello.txt"
),
)
debug_inspect(
mounted_asset_id("/static", "/static"),
content=(
#|""
),
)
debug_inspect(
mounted_asset_id("/static", "/static/hello.txt"),
content=(
#|"hello.txt"
),
)
debug_inspect(
mounted_asset_id("/static", "/static/sub/file.txt"),
content=(
#|"sub/file.txt"
),
)
}
///|
struct StaticResolvedResponder {
inner : &Responder
asset_headers : Map[String, String]
}
///|
pub impl Responder for StaticResolvedResponder with fn options(self, res) -> Unit {
self.inner.options(res)
if res.status_code == OK {
self.asset_headers.each((key, value) => {
if !@httputil.has_header_case_insensitive(res.headers, key) {
res.headers.set(key, value)
}
})
}
}
///|
pub impl Responder for StaticResolvedResponder with fn output(self, buf) -> Unit {
self.inner.output(buf)
}
///|
pub impl Responder for StaticResolvedResponder with fn output_bytes(self) -> Bytes? {
self.inner.output_bytes()
}
///|
/// Mounts a static asset provider at the given path, serving files with ETag/Last-Modified caching and content negotiation.
pub fn App::static_assets(
self : App,
path : String,
provider : &ServeStaticProvider,
) -> Unit {
let mount_path = normalize_mount_path(self.base_path + path)
self.use_middleware(
(event, next) => {
let request_path = event.req.path()
if !@httputil.path_scope_matches(mount_path, request_path) {
return next()
}
// Method check
if event.req.http_method != Get && event.req.http_method != Head {
if provider.get_fallthrough() {
return next()
}
event.res.headers.set("Allow", "GET, HEAD")
return HttpResponse(status_code=MethodNotAllowed)
}
let original_id = mounted_asset_id(mount_path, request_path)
let if_none_match = event.req.get_header("if-none-match")
let if_modified_since = event.req.get_header("if-modified-since")
// Parse Accept-Encoding
// Headers are Map[StringView, StringView]
let accept_encoding = event.req
.get_header("accept-encoding")
.unwrap_or("")
let encodings = provider.get_encodings()
let matched_encodings = []
if !encodings.is_empty() {
// Token-safe Vary append: don't clobber existing Vary values
// (e.g. "Origin" from CORS middleware).
@httputil.append_token_case_insensitive(
event.res.headers,
"Vary",
"Accept-Encoding",
)
}
if accept_encoding != "" {
// split requires `chars` label
for pair in accept_encoding.split(",") {
match parse_accept_encoding_token(pair) {
Some(encoding) =>
match encodings.get(encoding) {
Some(mapped) => matched_encodings.push(mapped)
None => ()
}
None => () // q=0 — client refuses this encoding
}
}
}
// Search paths
let mut id = original_id
let mut meta : StaticAssetMeta? = None
let index_names = provider.get_index_names()
if index_names.length() == 0 {
ignore(index_names.push("/index.html"))
}
// Search logic: suffix -> encoding
let mut found = false
let suffixes = [""]
suffixes.append(index_names)
let try_encodings = matched_encodings.copy()
try_encodings.push("") // Add empty encoding (identity)
for suffix in suffixes {
if found {
break
}
for encoding in try_encodings {
let try_id = id + suffix + encoding
match provider.get_meta(try_id) {
Some(m) => {
meta = Some(m)
id = try_id
found = true
break
}
None => ()
}
}
}
match meta {
None => {
if provider.get_fallthrough() {
return next()
}
return HttpResponse(status_code=NotFound).body("Not Found")
}
Some(meta) => {
let meta = match meta.etag {
Some(etag) =>
if if_none_match.map(header => if_none_match_matches(header, etag)) ==
Some(true) {
match provider.get_meta(id) {
Some(revalidated_meta) =>
match revalidated_meta.etag {
Some(revalidated_etag) if revalidated_etag == etag =>
return HttpResponse(
status_code=NotModified,
headers=static_not_modified_headers(
event.res.headers,
revalidated_meta,
),
)
_ => revalidated_meta
}
None => meta
}
} else {
meta
}
None => meta
}
let meta = match (if_none_match, if_modified_since, meta.mtime) {
(None, Some(header), Some(mtime)) if if_modified_since_matches(
header, mtime,
) =>
match provider.get_meta(id) {
Some(revalidated_meta) =>
match revalidated_meta.mtime {
Some(revalidated_mtime) if if_modified_since_matches(
header, revalidated_mtime,
) =>
return HttpResponse(
status_code=NotModified,
headers=static_not_modified_headers(
event.res.headers,
revalidated_meta,
),
)
_ => revalidated_meta
}
None => meta
}
_ => meta
}
let asset_headers = static_asset_headers(provider, id, meta)
if event.req.http_method == Head {
let head_headers = @httputil.copy_headers(event.res.headers)
asset_headers.each((key, value) => {
if !@httputil.has_header_case_insensitive(head_headers, key) {
head_headers.set(key, value)
}
})
return HttpResponse(status_code=OK, headers=head_headers)
}
let contents = provider.get_contents(id)
StaticResolvedResponder::{ inner: contents, asset_headers }
}
}
},
base_path=mount_path,
)
}