Skip to content

Commit 79b6908

Browse files
authored
Add support for multiple static files mounts (#794)
1 parent 19fd799 commit 79b6908

11 files changed

Lines changed: 109 additions & 67 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,11 @@ Options:
319319
--env-files FILE Environment file(s) to load (requires
320320
granian[dotenv] extra) [env var:
321321
GRANIAN_ENV_FILES]
322-
--static-path-route TEXT Route for static file serving [env var:
322+
--static-path-route TEXT Route(s) for static file serving [env var:
323323
GRANIAN_STATIC_PATH_ROUTE; default:
324324
(/static)]
325-
--static-path-mount DIRECTORY Path to mount for static file serving [env
326-
var: GRANIAN_STATIC_PATH_MOUNT]
325+
--static-path-mount DIRECTORY Path(s) to mount for static file serving
326+
[env var: GRANIAN_STATIC_PATH_MOUNT]
327327
--static-path-dir-to-file TEXT Serve the specified file as the index for
328328
directory listings [env var:
329329
GRANIAN_STATIC_PATH_DIR_TO_FILE]

granian/cli.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,13 +346,15 @@ def option(*param_decls: str, cls: type[click.Option] | None = None, **attrs: An
346346
)
347347
@option(
348348
'--static-path-route',
349-
default='/static',
350-
help='Route for static file serving',
349+
multiple=True,
350+
show_default='/static',
351+
help='Route(s) for static file serving',
351352
)
352353
@option(
353354
'--static-path-mount',
354355
type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True, path_type=pathlib.Path),
355-
help='Path to mount for static file serving',
356+
multiple=True,
357+
help='Path(s) to mount for static file serving',
356358
)
357359
@option(
358360
'--static-path-dir-to-file',
@@ -485,8 +487,8 @@ def cli(
485487
factory: bool,
486488
working_dir: pathlib.Path | None,
487489
env_files: list[pathlib.Path] | None,
488-
static_path_route: str,
489-
static_path_mount: pathlib.Path | None,
490+
static_path_route: list[str],
491+
static_path_mount: list[pathlib.Path],
490492
static_path_dir_to_file: str | None,
491493
static_path_expires: int,
492494
metrics_enabled: bool,

granian/server/common.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ def __init__(
125125
factory: bool = False,
126126
working_dir: Path | None = None,
127127
env_files: Sequence[Path] | None = None,
128-
static_path_route: str = '/static',
129-
static_path_mount: Path | None = None,
128+
static_path_route: Sequence[str] | None = None,
129+
static_path_mount: Sequence[Path] | None = None,
130130
static_path_dir_to_file: str | None = None,
131131
static_path_expires: int = 86400,
132132
metrics_enabled: bool = False,
@@ -186,16 +186,7 @@ def __init__(
186186
self.factory = factory
187187
self.working_dir = working_dir
188188
self.env_files = env_files or ()
189-
self.static_path = (
190-
(
191-
static_path_route,
192-
str(static_path_mount.resolve()),
193-
static_path_dir_to_file,
194-
(str(static_path_expires) if static_path_expires else None),
195-
)
196-
if static_path_mount
197-
else None
198-
)
189+
self.static_path = None
199190
self.metrics_enabled = metrics_enabled
200191
self.metrics_scrape_interval = metrics_scrape_interval
201192
self.metrics_address = metrics_address
@@ -216,6 +207,13 @@ def __init__(
216207

217208
configure_logging(self.log_level, self.log_config, self.log_enabled)
218209

210+
if static_path_mount:
211+
self._init_static_mounts(
212+
static_path_route or [],
213+
static_path_mount,
214+
static_path_dir_to_file,
215+
(str(static_path_expires) if static_path_expires else None),
216+
)
219217
self.build_ssl_context(
220218
ssl_cert, ssl_key, ssl_key_password, ssl_protocol_min, ssl_ca, ssl_crl or [], ssl_client_verify
221219
)
@@ -235,6 +233,31 @@ def __init__(
235233
self.pid = None
236234
self._env_loader = build_env_loader()
237235

236+
def _init_static_mounts(
237+
self,
238+
routes: Sequence[str],
239+
paths: Sequence[Path],
240+
dir_to_file: str | None,
241+
expires: str | None,
242+
):
243+
if not paths:
244+
return
245+
if len(paths) == 1 and not routes:
246+
self.static_path = (
247+
[('/static', str(paths[0].resolve()))],
248+
dir_to_file,
249+
expires,
250+
)
251+
return
252+
if len(paths) != len(routes):
253+
logger.error('Static path routes and mounts should have the same length')
254+
raise ConfigurationError('static_path')
255+
self.static_path = (
256+
[(routes[idx], str(path.resolve())) for idx, path in enumerate(paths)],
257+
dir_to_file,
258+
expires,
259+
)
260+
238261
def build_ssl_context(
239262
self,
240263
cert: Path | None,

granian/server/embed.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import multiprocessing
33
import sys
44
import time
5-
from collections.abc import Callable
5+
from collections.abc import Callable, Sequence
66
from functools import wraps
77
from pathlib import Path
88
from typing import Any
@@ -124,8 +124,8 @@ def __init__(
124124
ssl_client_verify: bool = False,
125125
url_path_prefix: str | None = None,
126126
factory: bool = False,
127-
static_path_route: str = '/static',
128-
static_path_mount: Path | None = None,
127+
static_path_route: Sequence[str] | None = None,
128+
static_path_mount: Sequence[Path] | None = None,
129129
static_path_dir_to_file: str | None = None,
130130
static_path_expires: int = 86400,
131131
):

src/asgi/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl ASGIWorker {
5656
http1_opts: Option<Py<PyAny>>,
5757
http2_opts: Option<Py<PyAny>>,
5858
websockets_enabled: bool,
59-
static_files: Option<(String, String, Option<String>, Option<String>)>,
59+
static_files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
6060
ssl_enabled: bool,
6161
ssl_cert: Option<String>,
6262
ssl_key: Option<String>,

src/files.rs

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,40 +15,41 @@ use crate::http::{HTTPResponse, HV_SERVER, response_404};
1515
#[inline(always)]
1616
pub(crate) fn match_static_file(
1717
uri_path: &str,
18-
prefix: &str,
19-
mount_point: &str,
18+
mounts: &Vec<(String, String)>,
2019
dir_to_file: Option<&String>,
2120
) -> Option<Result<String>> {
2221
let decoded_uri_path = percent_decode_str(uri_path).decode_utf8_lossy();
23-
if let Some(file_path) = decoded_uri_path.strip_prefix(prefix) {
24-
#[cfg(not(windows))]
25-
let fpath = format!("{mount_point}{file_path}");
26-
#[cfg(windows)]
27-
let fpath = format!("{mount_point}{}", file_path.replace("/", "\\"));
28-
match Path::new(&fpath).canonicalize() {
29-
Ok(mut full_path) => {
30-
if full_path.is_dir() {
31-
match dir_to_file {
32-
Some(rewrite_file) => {
33-
full_path = full_path.join(rewrite_file);
22+
for (prefix, mount_point) in mounts {
23+
if let Some(file_path) = decoded_uri_path.strip_prefix(prefix) {
24+
#[cfg(not(windows))]
25+
let fpath = format!("{mount_point}{file_path}");
26+
#[cfg(windows)]
27+
let fpath = format!("{mount_point}{}", file_path.replace("/", "\\"));
28+
match Path::new(&fpath).canonicalize() {
29+
Ok(mut full_path) => {
30+
if full_path.is_dir() {
31+
match dir_to_file {
32+
Some(rewrite_file) => {
33+
full_path = full_path.join(rewrite_file);
34+
}
35+
None => return Some(Err(anyhow::anyhow!("dir"))),
3436
}
35-
None => return Some(Err(anyhow::anyhow!("dir"))),
3637
}
37-
}
38-
#[cfg(windows)]
39-
let full_path = &full_path.display().to_string()[4..];
40-
if full_path.starts_with(mount_point) {
41-
#[cfg(not(windows))]
42-
return full_path.to_str().map(ToOwned::to_owned).map(Ok);
4338
#[cfg(windows)]
44-
return Some(Ok(full_path.to_owned()));
39+
let full_path = &full_path.display().to_string()[4..];
40+
if full_path.starts_with(mount_point) {
41+
#[cfg(not(windows))]
42+
return full_path.to_str().map(ToOwned::to_owned).map(Ok);
43+
#[cfg(windows)]
44+
return Some(Ok(full_path.to_owned()));
45+
}
46+
return Some(Err(anyhow::anyhow!("outside mount path")));
4547
}
46-
return Some(Err(anyhow::anyhow!("outside mount path")));
47-
}
48-
Err(err) if err.kind() == io::ErrorKind::NotFound => {
49-
return Some(Err(err.into()));
48+
Err(err) if err.kind() == io::ErrorKind::NotFound => {
49+
return Some(Err(err.into()));
50+
}
51+
_ => {}
5052
}
51-
_ => {}
5253
}
5354
}
5455
None

src/rsgi/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl RSGIWorker {
5656
http1_opts: Option<Py<PyAny>>,
5757
http2_opts: Option<Py<PyAny>>,
5858
websockets_enabled: bool,
59-
static_files: Option<(String, String, Option<String>, Option<String>)>,
59+
static_files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
6060
ssl_enabled: bool,
6161
ssl_cert: Option<String>,
6262
ssl_key: Option<String>,

src/workers.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ pub(crate) struct WorkerConfig {
102102
pub http1_opts: HTTP1Config,
103103
pub http2_opts: HTTP2Config,
104104
pub websockets_enabled: bool,
105-
pub static_files: Option<(String, String, Option<String>, Option<String>)>,
105+
pub static_files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
106106
pub tls_opts: Option<WorkerTlsConfig>,
107107
pub metrics: (
108108
Option<std::time::Duration>,
@@ -134,7 +134,7 @@ impl WorkerConfig {
134134
http1_opts: HTTP1Config,
135135
http2_opts: HTTP2Config,
136136
websockets_enabled: bool,
137-
static_files: Option<(String, String, Option<String>, Option<String>)>,
137+
static_files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
138138
ssl_enabled: bool,
139139
ssl_cert: Option<String>,
140140
ssl_key: Option<String>,
@@ -264,8 +264,7 @@ impl<M> WorkerCTXBase<M> {
264264
pub(crate) struct WorkerCTXFiles<M> {
265265
pub callback: crate::callbacks::ArcCBScheduler,
266266
pub metrics: M,
267-
pub static_prefix: String,
268-
pub static_mount: String,
267+
pub static_mounts: Vec<(String, String)>,
269268
pub static_dir_to_file: Option<String>,
270269
pub static_expires: Option<String>,
271270
}
@@ -274,14 +273,13 @@ impl<M> WorkerCTXFiles<M> {
274273
pub fn new(
275274
callback: crate::callbacks::PyCBScheduler,
276275
metrics: M,
277-
files: Option<(String, String, Option<String>, Option<String>)>,
276+
files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
278277
) -> Self {
279-
let (static_prefix, static_mount, static_dir_to_file, static_expires) = files.unwrap();
278+
let (static_mounts, static_dir_to_file, static_expires) = files.unwrap();
280279
Self {
281280
callback: Arc::new(callback),
282281
metrics,
283-
static_prefix,
284-
static_mount,
282+
static_mounts,
285283
static_dir_to_file,
286284
static_expires,
287285
}
@@ -406,8 +404,7 @@ macro_rules! service_impl {
406404
fn call(&self, req: crate::http::HTTPRequest) -> Self::Future {
407405
if let Some(static_match) = crate::files::match_static_file(
408406
req.uri().path(),
409-
&self.ctx.static_prefix,
410-
&self.ctx.static_mount,
407+
&self.ctx.static_mounts,
411408
self.ctx.static_dir_to_file.as_ref(),
412409
) {
413410
if static_match.is_err() {
@@ -484,8 +481,7 @@ macro_rules! service_impl {
484481

485482
if let Some(static_match) = crate::files::match_static_file(
486483
req.uri().path(),
487-
&self.ctx.static_prefix,
488-
&self.ctx.static_mount,
484+
&self.ctx.static_mounts,
489485
self.ctx.static_dir_to_file.as_ref(),
490486
) {
491487
self.ctx

src/wsgi/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ impl WSGIWorker {
5959
http_mode: &str,
6060
http1_opts: Option<Py<PyAny>>,
6161
http2_opts: Option<Py<PyAny>>,
62-
static_files: Option<(String, String, Option<String>, Option<String>)>,
62+
static_files: Option<(Vec<(String, String)>, Option<String>, Option<String>)>,
6363
ssl_enabled: bool,
6464
ssl_cert: Option<String>,
6565
ssl_key: Option<String>,

tests/conftest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ async def _server(
5050
kwargs['ssl_protocol_min'] = tls_proto
5151

5252
if static_mount:
53-
kwargs['static_path_mount'] = Path.cwd() / 'tests' / 'fixtures' / 'static'
53+
if static_mount is True:
54+
kwargs['static_path_mount'] = [Path.cwd() / 'tests' / 'fixtures' / 'static']
55+
else:
56+
kwargs['static_path_route'] = [v[0] for v in static_mount]
57+
kwargs['static_path_mount'] = [(Path.cwd() / 'tests' / 'fixtures' / v[1]) for v in static_mount]
5458
if static_rewrite:
5559
kwargs['static_path_dir_to_file'] = 'index.txt'
5660

@@ -124,5 +128,5 @@ def server_tls(server_port, request, tls=True, **extras):
124128

125129

126130
@pytest.fixture(scope='function')
127-
def server_static_files(server_port, request, **extras):
128-
return partial(_server, request.param, server_port, static_mount=True, **extras)
131+
def server_static_files(server_port, request, static_mount=True, **extras):
132+
return partial(_server, request.param, server_port, static_mount=static_mount, **extras)

0 commit comments

Comments
 (0)