diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md index 6ef408b..652384b 100644 --- a/.agents/skills/fusion-bindings-parity/SKILL.md +++ b/.agents/skills/fusion-bindings-parity/SKILL.md @@ -44,6 +44,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas | Convention HTTP | `def get(self)` | `get()` method | `Get()` method | | Custom HTTP | `@http_get("path/[action]")` | `httpGet('path/[action]')(proto.method)` | `[HttpGet("path/[action]")]` | | Middleware | `middleware.py` factories | factories in `index.js` | `Middleware.cs` | +| Static files | `static_files()` | `staticFiles()` | `Middleware.StaticFiles()` | | Permissions | `permissions=` / `require_permissions` | `permissions` / `requirePermissions` | `PermissionTypes` / `RequirePermissions` | | OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` | | Version navbar | per-version OpenAPI routes | same | same | diff --git a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs index 61644ad..7eb45f3 100644 --- a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs +++ b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs @@ -8,4 +8,10 @@ public static class BuiltinMiddleware public static FusionMiddleware Cors() => Middleware.Cors(); public static FusionMiddleware CacheHeaders() => Middleware.CacheHeaders(); public static FusionMiddleware RequestId() => Middleware.RequestId(); + public static FusionMiddleware StaticFiles( + string root = "static", + string prefix = "/static", + int? maxAge = 3600, + bool? fallthrough = null) => + Middleware.StaticFiles(root, prefix, maxAge, fallthrough); } diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index 08379c7..e701c07 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -113,6 +113,7 @@ public void Mount() } } + Middleware.MountStaticFiles(this, _middleware); SwaggerDocs.Mount(this, SettingsStore.Current); } diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 4a0001a..6978304 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -325,6 +325,157 @@ Dictionary CorsHeaders(string? origin) }; } + static readonly Dictionary StaticMimeTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".css"] = "text/css; charset=utf-8", + [".gif"] = "image/gif", + [".htm"] = "text/html; charset=utf-8", + [".html"] = "text/html; charset=utf-8", + [".ico"] = "image/x-icon", + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".js"] = "text/javascript; charset=utf-8", + [".json"] = "application/json", + [".map"] = "application/json", + [".png"] = "image/png", + [".svg"] = "image/svg+xml", + [".txt"] = "text/plain; charset=utf-8", + [".webp"] = "image/webp", + [".woff"] = "font/woff", + [".woff2"] = "font/woff2", + }; + + /// Map a file extension to Content-Type (octet-stream fallback). + static string GuessStaticContentType(string path) + { + var ext = Path.GetExtension(path); + return StaticMimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + } + + /// + /// Serve files from for URLs under (WhiteNoise-style). + /// is the folder on disk; is the URL prefix + /// (e.g. root=static, prefix=/static → static/logo.png at /static/logo.png). + /// Files are mounted as GET/HEAD routes on . + /// + public static FusionMiddleware StaticFiles( + string root = "static", + string prefix = "/static", + int? maxAge = 3600, + bool? fallthrough = null) + { + var state = new StaticFilesState + { + Root = Path.GetFullPath(root), + Prefix = NormalizeStaticPrefix(prefix), + MaxAge = maxAge, + Fallthrough = fallthrough ?? NormalizeStaticPrefix(prefix) == "/", + }; + + FusionMiddleware middleware = (request, callNext) => ServeStaticOrNext(state, request, callNext); + StaticFilesStates.Add(middleware, state); + return middleware; + } + + static readonly System.Runtime.CompilerServices.ConditionalWeakTable StaticFilesStates = new(); + + sealed class StaticFilesState + { + public required string Root { get; init; } + public required string Prefix { get; init; } + public int? MaxAge { get; init; } + public bool Fallthrough { get; init; } + } + + static string NormalizeStaticPrefix(string? prefix) + { + var trimmed = (prefix ?? "/static").Trim().Trim('/'); + return string.IsNullOrEmpty(trimmed) ? "/" : "/" + trimmed; + } + + static object ServeStaticOrNext(StaticFilesState state, FusionRequest request, Func callNext) + { + var method = (request.Method ?? "GET").ToUpperInvariant(); + if (method is not ("GET" or "HEAD")) + return callNext(request)!; + + var reqPath = string.IsNullOrEmpty(request.Path) ? "/" : request.Path; + string relative; + if (state.Prefix == "/") + { + relative = reqPath.TrimStart('/'); + if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) + return callNext(request)!; + } + else + { + if (!(reqPath == state.Prefix || reqPath.StartsWith(state.Prefix + "/", StringComparison.Ordinal))) + return callNext(request)!; + relative = reqPath[state.Prefix.Length..].TrimStart('/'); + if (string.IsNullOrEmpty(relative)) + return callNext(request)!; + } + + var candidate = Path.GetFullPath(Path.Combine(state.Root, relative)); + var rootPrefix = state.Root.EndsWith(Path.DirectorySeparatorChar) + ? state.Root + : state.Root + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, state.Root, StringComparison.OrdinalIgnoreCase)) + { + return Error(403, "Forbidden"); + } + + if (!File.Exists(candidate)) + { + if (state.Fallthrough) return callNext(request)!; + return Error(404, "Not found"); + } + + return StaticFileResponse(candidate, method, state.MaxAge); + } + + static Dictionary StaticFileResponse(string path, string method, int? maxAge) + { + var info = new FileInfo(path); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["content-type"] = GuessStaticContentType(path), + ["content-length"] = info.Length.ToString(), + }; + if (maxAge is int age) + headers["cache-control"] = $"public, max-age={age}"; + + object body = method == "HEAD" ? Array.Empty() : File.ReadAllBytes(path); + return new Dictionary + { + ["status"] = 200, + ["body"] = body, + ["headers"] = headers, + }; + } + + /// Register GET/HEAD routes for each middleware on the app. + internal static void MountStaticFiles(FusionApp app, IEnumerable middlewares) + { + foreach (var mw in middlewares) + { + if (!StaticFilesStates.TryGetValue(mw, out var state)) + continue; + if (!Directory.Exists(state.Root)) + continue; + + foreach (var filePath in Directory.EnumerateFiles(state.Root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(state.Root, filePath).Replace('\\', '/'); + var url = state.Prefix == "/" ? "/" + rel : state.Prefix + "/" + rel; + var captured = filePath; + app.AddRawRoute("GET", url, () => StaticFileResponse(captured, "GET", state.MaxAge)); + app.AddRawRoute("HEAD", url, () => StaticFileResponse(captured, "HEAD", state.MaxAge)); + } + } + } + /// Optional identity middleware — not enabled by default. Add via app.Use(Middleware.FrameworkHeaders()). public static FusionMiddleware FrameworkHeaders() { diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index fae1fc4..76b8ac1 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -117,6 +117,24 @@ MIDDLEWARE.Add(Middleware.BearerJwt()); Route.Register(typeof(AdminModule), "/api/admin", permissions: new[] { AdminChecks.IsAdmin }); ``` +## Static files + +Serve CSS/images without custom routes (WhiteNoise-style). + +- **root**: folder on disk (e.g. `static`) +- **prefix**: URL prefix (e.g. `/static`) + +So `static/logo.png` is available at `/static/logo.png`: + +```csharp +app.Use(Middleware.StaticFiles(root: "static", prefix: "/static")); +// +``` + +Use `prefix: "/"` when files should be served at the site root +(`templates/home/a.png` → `/a.png`). Files are mounted as real routes on +`FusionApp.Mount()` / `Listen()`. + ## License BSD 3-Clause diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 4dddf75..7d200d1 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -201,6 +201,14 @@ export function requestId(options?: { incoming?: boolean }): FusionMiddleware +/** Serve files from `root` under URL `prefix` (WhiteNoise-style). */ +export function staticFiles(options?: { + root?: string + prefix?: string + maxAge?: number | null + fallthrough?: boolean +}): FusionMiddleware + export type FusionResponse = | string | { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 018f25a..ec2f5f3 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -268,6 +268,122 @@ function cors(options = {}) { } } +const STATIC_MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.htm': 'text/html; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.map': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +} + +/** Guess Content-Type from a file path extension. */ +function guessStaticContentType(filePath) { + const ext = path.extname(String(filePath)).toLowerCase() + return STATIC_MIME_TYPES[ext] || 'application/octet-stream' +} + +/** + * Serve files from `root` for URLs under `prefix` (WhiteNoise-style). + * + * - root: folder on disk (e.g. 'static') + * - prefix: URL prefix (e.g. '/static' → static/logo.png at /static/logo.png) + * + * Files are also mounted as real GET/HEAD routes on FusionApp.mount()/listen(). + */ +function staticFiles(options = {}) { + const rootDir = path.resolve(String(options.root ?? 'static')) + const rawPrefix = String(options.prefix ?? '/static').trim() + const normalized = rawPrefix.replace(/\/+$/, '') === '' ? '/' : `/${rawPrefix.replace(/^\/+|\/+$/g, '')}` + const maxAge = options.maxAge === undefined ? 3600 : options.maxAge + const allowFallthrough = + options.fallthrough === undefined ? normalized === '/' : !!options.fallthrough + const cfg = { root: rootDir, prefix: normalized, maxAge, fallthrough: allowFallthrough } + + const middleware = (request, callNext) => serveStaticOrNext(cfg, request, callNext) + middleware.__fusionStatic = cfg + return middleware +} + +/** Build a 200 file response envelope. */ +function staticFileResponse(filePath, method, maxAge) { + const size = fs.statSync(filePath).size + const headers = { + 'content-type': guessStaticContentType(filePath), + 'content-length': String(size), + } + if (maxAge !== null && maxAge !== undefined) { + headers['cache-control'] = `public, max-age=${Number(maxAge)}` + } + const body = String(method).toUpperCase() === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(filePath) + return { status: 200, body, headers } +} + +/** Try to serve a static file; otherwise callNext. */ +function serveStaticOrNext(cfg, request, callNext) { + const method = String(request.method || 'GET').toUpperCase() + if (method !== 'GET' && method !== 'HEAD') return callNext(request) + + const reqPath = String(request.path || '/') + const normalized = cfg.prefix + let relative = '' + if (normalized === '/') { + relative = reqPath.replace(/^\/+/, '') + if (!relative || relative.endsWith('/')) return callNext(request) + } else { + if (!(reqPath === normalized || reqPath.startsWith(`${normalized}/`))) { + return callNext(request) + } + relative = reqPath.slice(normalized.length).replace(/^\/+/, '') + if (!relative) return callNext(request) + } + + const candidate = path.resolve(cfg.root, relative) + const relToRoot = path.relative(cfg.root, candidate) + if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) { + return { status: 403, body: { detail: 'Forbidden' } } + } + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { + if (cfg.fallthrough) return callNext(request) + return { status: 404, body: { detail: 'Not found' } } + } + return staticFileResponse(candidate, method, cfg.maxAge) +} + +/** Register GET/HEAD routes for files under each staticFiles() mount. */ +function mountStaticFiles(engine, middlewares) { + for (const mw of middlewares || []) { + const cfg = mw && mw.__fusionStatic + if (!cfg || !fs.existsSync(cfg.root) || !fs.statSync(cfg.root).isDirectory()) continue + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name) + const st = fs.statSync(full) + if (st.isDirectory()) { + walk(full) + continue + } + if (!st.isFile()) continue + const rel = path.relative(cfg.root, full).split(path.sep).join('/') + const url = cfg.prefix === '/' ? `/${rel}` : `${cfg.prefix}/${rel}` + engine.route('GET', url, () => staticFileResponse(full, 'GET', cfg.maxAge)) + engine.route('HEAD', url, () => staticFileResponse(full, 'HEAD', cfg.maxAge)) + } + } + walk(cfg.root) + } +} + class FusionBaseApi { constructor(request) { this.request = request && typeof request === 'object' ? request : emptyRequest() @@ -1220,6 +1336,8 @@ class FusionApp { } } + mountStaticFiles(this.engine, this._middleware) + const swagger = readSwaggerSettings() if (swagger.enabled) { const prefix = swagger.path @@ -1527,6 +1645,7 @@ module.exports = { cors, cacheHeaders, requestId, + staticFiles, runMiddlewareChain, coerceParam, parsePagination, diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index 62ed66b..0074b93 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -11,6 +11,7 @@ require_permissions, require_roles, security_headers, + static_files, use, ) from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination @@ -30,6 +31,7 @@ "require_permissions", "require_roles", "security_headers", + "static_files", "use", "PaginationParams", "parse_pagination", diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py index 554f064..5a3ba79 100644 --- a/crates/fusion-py/python/fusion_framework/app.py +++ b/crates/fusion-py/python/fusion_framework/app.py @@ -403,6 +403,9 @@ def listen( if not self._mounted: set_active_global(self._middleware) self._engine.mount_routes() + from fusion_framework.middleware import mount_static_files + + mount_static_files(self._engine, self._middleware) swagger = _swagger_settings(self.settings) if swagger.get("enabled"): _mount_swagger(self._engine, swagger) diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index 65222f3..0aebfbd 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -5,6 +5,7 @@ import base64 import inspect import json +from pathlib import Path from typing import Any, Awaitable, Callable, Iterable, Union Middleware = Callable[..., Any] @@ -398,6 +399,148 @@ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> return middleware +_STATIC_MIME_TYPES: dict[str, str] = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".htm": "text/html; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".map": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + + +def _guess_static_content_type(path: Path) -> str: + """Map a file extension to a Content-Type, defaulting to octet-stream.""" + return _STATIC_MIME_TYPES.get(path.suffix.lower(), "application/octet-stream") + + +def static_files( + root: str | Path = "static", + *, + prefix: str = "/static", + max_age: int | None = 3600, + fallthrough: bool | None = None, +) -> Middleware: + """Serve local files under ``root`` for URLs starting with ``prefix`` (WhiteNoise-style). + + - ``root``: folder on disk that holds the files (e.g. ``\"static\"`` or + ``Path(__file__).parent / \"templates\" / \"home\"``). + - ``prefix``: URL path prefix browsers request (e.g. ``\"/static\"`` so + ``static/logo.png`` is served at ``/static/logo.png``). Use ``\"/\"`` to + serve files at the site root (``templates/home/a.png`` → ``/a.png``). + + On ``FusionApp.listen()``, matching files are mounted as real GET/HEAD routes + (middleware alone cannot see unmatched paths). Only ``GET`` / ``HEAD``. + Path traversal is rejected. The returned middleware also short-circuits when + invoked on an already-mounted path. + """ + root_path = Path(root).expanduser() + normalized = "/" + str(prefix).strip("/") if str(prefix).strip("/") else "/" + allow_fallthrough = (normalized == "/") if fallthrough is None else bool(fallthrough) + cfg = { + "root": root_path, + "prefix": normalized, + "max_age": max_age, + "fallthrough": allow_fallthrough, + } + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + return _serve_static_or_next(cfg, request, call_next) + + middleware.__fusion_static__ = cfg # type: ignore[attr-defined] + return middleware + + +def _serve_static_or_next( + cfg: dict[str, Any], + request: RequestDict, + call_next: Callable[[RequestDict], Any], +) -> Any: + """Try to serve a file for this request; otherwise call the next handler.""" + method = str(request.get("method", "GET")).upper() + if method not in ("GET", "HEAD"): + return call_next(request) + + req_path = str(request.get("path") or "/") + normalized = str(cfg["prefix"]) + if normalized == "/": + relative = req_path.lstrip("/") + if not relative or relative.endswith("/"): + return call_next(request) + else: + if not (req_path == normalized or req_path.startswith(normalized + "/")): + return call_next(request) + relative = req_path[len(normalized) :].lstrip("/") + if not relative: + return call_next(request) + + base = Path(cfg["root"]).resolve() + candidate = (base / relative).resolve() + try: + candidate.relative_to(base) + except ValueError: + return {"status": 403, "body": {"detail": "Forbidden"}} + + if not candidate.is_file(): + if cfg["fallthrough"]: + return call_next(request) + return {"status": 404, "body": {"detail": "Not found"}} + + return _static_file_response(candidate, method, cfg.get("max_age")) + + +def _static_file_response(path: Path, method: str, max_age: int | None) -> dict[str, Any]: + """Build a 200 response envelope for a file on disk.""" + size = path.stat().st_size + headers = { + "content-type": _guess_static_content_type(path), + "content-length": str(size), + } + if max_age is not None: + headers["cache-control"] = f"public, max-age={int(max_age)}" + body: Any = b"" if method.upper() == "HEAD" else path.read_bytes() + return {"status": 200, "body": body, "headers": headers} + + +def mount_static_files(engine: Any, middlewares: Iterable[Middleware]) -> None: + """Register GET/HEAD routes for every file under each ``static_files`` mount.""" + for middleware in middlewares: + cfg = getattr(middleware, "__fusion_static__", None) + if not isinstance(cfg, dict): + continue + root = Path(cfg["root"]).expanduser().resolve() + if not root.is_dir(): + continue + prefix = str(cfg["prefix"]) + max_age = cfg.get("max_age") + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel = file_path.relative_to(root).as_posix() + url = f"/{rel}" if prefix == "/" else f"{prefix}/{rel}" + path_for_get = file_path + path_for_head = file_path + + def _get(_req: RequestDict, p: Path = path_for_get, age: int | None = max_age) -> dict[str, Any]: + return _static_file_response(p, "GET", age) + + def _head(_req: RequestDict, p: Path = path_for_head, age: int | None = max_age) -> dict[str, Any]: + return _static_file_response(p, "HEAD", age) + + engine.route("GET", url, _get) + engine.route("HEAD", url, _head) + + def dispatch_route( request: RequestDict, handler: Callable[[RequestDict], Any], diff --git a/examples/static_files.cs b/examples/static_files.cs new file mode 100644 index 0000000..1e911c9 --- /dev/null +++ b/examples/static_files.cs @@ -0,0 +1,25 @@ +// Serve images/CSS with Middleware.StaticFiles (WhiteNoise-style). +// app.Use(Middleware.StaticFiles(root: "static", prefix: "/static")); +// + +using FusionFramework; + +var staticDir = Path.Combine(AppContext.BaseDirectory, "static_files_assets"); +Directory.CreateDirectory(staticDir); +var logo = Path.Combine(staticDir, "logo.png"); +if (!File.Exists(logo)) + File.WriteAllBytes(logo, new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }); + +var settings = new FusionSettings(); +settings.EnsureLoaded(); +var app = new FusionApp(settings); +app.Use(Middleware.StaticFiles(root: staticDir, prefix: "/static")); + +[Route("/api/ping")] +sealed class PingModule : FusionBaseApi +{ + public object Get() => Response(new { ok = true }); +} + +Route.Register(); +app.Listen(); diff --git a/examples/static_files.mjs b/examples/static_files.mjs new file mode 100644 index 0000000..db6c319 --- /dev/null +++ b/examples/static_files.mjs @@ -0,0 +1,46 @@ +/** + * Serve images/CSS with `staticFiles` (WhiteNoise-style). + * + * app.use(staticFiles({ root: 'static', prefix: '/static' })) + * // + */ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { + FusionApp, + FusionBaseApi, + getSettings, + route, + staticFiles, + status, +} from 'fusion-framework' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const STATIC_DIR = path.join(__dirname, 'static_files_assets') + +class Ping extends FusionBaseApi { + get() { + return this.response({ ok: true }, status.HTTP_SUCCESS) + } +} + +route('/api/ping')(Ping) + +async function main() { + fs.mkdirSync(STATIC_DIR, { recursive: true }) + const logo = path.join(STATIC_DIR, 'logo.png') + if (!fs.existsSync(logo)) { + fs.writeFileSync(logo, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + } + + await import('./settings.mjs').catch(() => {}) + const app = new FusionApp(getSettings()) + app.use(staticFiles({ root: STATIC_DIR, prefix: '/static' })) + await app.listen() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/static_files.py b/examples/static_files.py new file mode 100644 index 0000000..f89bcd5 --- /dev/null +++ b/examples/static_files.py @@ -0,0 +1,53 @@ +"""Serve images/CSS from disk with ``static_files`` (WhiteNoise-style). + +``root`` = folder on disk · ``prefix`` = URL prefix the browser requests. + + static_files(root="static", prefix="/static") + # static/logo.png → /static/logo.png + # + +Files are registered as real GET/HEAD routes when the app listens. +""" + +from pathlib import Path + +from fusion_framework import static_files, status +from fusion_framework.api import FusionBaseApi +from fusion_framework.app import FusionApp +from fusion_framework.config import get_settings, load_settings_module +from fusion_framework.route import route +from fusion_framework.template import FusionBaseTemplate + +STATIC_DIR = Path(__file__).resolve().parent / "static_files_assets" + + +@route("/") +class HomePage(FusionBaseTemplate): + template = "home/index.html" + + def context(self): + return {"title": "static files demo"} + + +@route("/api/ping") +class Ping(FusionBaseApi): + def get(self): + return self.response({"ok": True}, status=status.HTTP_SUCCESS) + + +def main() -> None: + # Demo assets live beside this example; in apps use project ``static/``. + STATIC_DIR.mkdir(exist_ok=True) + logo = STATIC_DIR / "logo.png" + if not logo.is_file(): + # Minimal valid-looking PNG header bytes for local demos. + logo.write_bytes(b"\x89PNG\r\n\x1a\n") + + load_settings_module("settings") + app = FusionApp(get_settings()) + app.use(static_files(root=STATIC_DIR, prefix="/static")) + app.listen() + + +if __name__ == "__main__": + main() diff --git a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs index 8002422..97f4d4c 100644 --- a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs +++ b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs @@ -73,4 +73,37 @@ public void Cors_answers_options_with_204() var headers = Assert.IsType>(result["headers"]); Assert.False(string.IsNullOrEmpty(headers["Access-Control-Allow-Origin"])); } + + [Fact] + public void StaticFiles_serves_asset_under_prefix() + { + var dir = Directory.CreateTempSubdirectory("fusion-static-"); + try + { + var file = Path.Combine(dir.FullName, "logo.png"); + File.WriteAllBytes(file, new byte[] { 0x89, 0x50, 0x4e, 0x47 }); + + var request = new FusionRequest + { + Method = "GET", + Path = "/static/logo.png", + Headers = new Dictionary(), + }; + + var result = Middleware.RunChain( + request, + new[] { Middleware.StaticFiles(root: dir.FullName, prefix: "/static", maxAge: 60) }, + Handler) as Dictionary; + + Assert.NotNull(result); + Assert.Equal(200, result["status"]); + Assert.IsType(result["body"]); + var headers = Assert.IsType>(result["headers"]); + Assert.Equal("image/png", headers["content-type"]); + } + finally + { + dir.Delete(recursive: true); + } + } } diff --git a/tests/node/unit/bindings.test.js b/tests/node/unit/bindings.test.js index fbdf3e9..34368b2 100644 --- a/tests/node/unit/bindings.test.js +++ b/tests/node/unit/bindings.test.js @@ -16,6 +16,7 @@ const { cors, requireRoles, frameworkHeaders, + staticFiles, resolveRoutePath, apiResourceName, } = fusion @@ -143,4 +144,25 @@ describe('middleware', () => { const result = await runMiddlewareChain(request, [frameworkHeaders()], handler) assert.ok(result.headers['x-powered-by'] || result.headers['X-Powered-By']) }) + + it('staticFiles serves assets under prefix', async () => { + const fs = require('fs') + const os = require('os') + const path = require('path') + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fusion-static-')) + const file = path.join(dir, 'logo.png') + fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + const request = { method: 'GET', path: '/static/logo.png', headers: {} } + const result = await runMiddlewareChain( + request, + [staticFiles({ root: dir, prefix: '/static', maxAge: 60 })], + handler, + ) + assert.equal(result.status, 200) + assert.ok(Buffer.isBuffer(result.body) || result.body instanceof Uint8Array) + const headers = Object.fromEntries( + Object.entries(result.headers || {}).map(([k, v]) => [k.toLowerCase(), v]), + ) + assert.equal(headers['content-type'], 'image/png') + }) }) diff --git a/tests/python/unit/test_middleware.py b/tests/python/unit/test_middleware.py index 31086df..9baea8a 100644 --- a/tests/python/unit/test_middleware.py +++ b/tests/python/unit/test_middleware.py @@ -12,6 +12,7 @@ request_id, require_roles, set_active_global, + static_files, ) @@ -111,3 +112,31 @@ def test_sync_middleware_propagates_async_handler_coroutine(): assert inspect.isawaitable(result) resolved = asyncio.run(result) assert resolved["body"]["ok"] is True + + +def test_static_files_serves_png(tmp_path): + """static_files returns file bytes under the URL prefix.""" + asset = tmp_path / "logo.png" + asset.write_bytes(b"\x89PNG\r\n\x1a\nfake") + set_active_global([static_files(root=tmp_path, prefix="/static", max_age=60)]) + request = {"path": "/static/logo.png", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 + assert result["body"] == asset.read_bytes() + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert headers["content-type"] == "image/png" + assert "max-age=60" in headers["cache-control"] + + +def test_static_files_missing_under_prefix_is_404(tmp_path): + set_active_global([static_files(root=tmp_path, prefix="/static")]) + request = {"path": "/static/missing.png", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 404 + + +def test_static_files_outside_prefix_falls_through(tmp_path): + set_active_global([static_files(root=tmp_path, prefix="/static")]) + request = {"path": "/api/items", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200