diff --git a/.agents/README.md b/.agents/README.md index b36056f..b7c990d 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -22,6 +22,7 @@ Each skill teaches domain-specific workflows for Fusion (Rust core + Python / No | `fusion-http-routes` | Routes, `http_get` / `[HttpGet]`, `[module]`, `[action]`, Swagger | | `fusion-release` | Version bumps, manifests, publish prep | | `fusion-testing` | Running checks; investigating failed tests | +| `fusion-cache` | Application cache (moka default; Redis later) | ## Always-on rules diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md index 652384b..a6a29e5 100644 --- a/.agents/skills/fusion-bindings-parity/SKILL.md +++ b/.agents/skills/fusion-bindings-parity/SKILL.md @@ -45,6 +45,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas | 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()` | +| Cache | `fusion_framework.cache` (moka) | `cache` export | `Cache` class | | 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/.agents/skills/fusion-cache/SKILL.md b/.agents/skills/fusion-cache/SKILL.md new file mode 100644 index 0000000..c9b5569 --- /dev/null +++ b/.agents/skills/fusion-cache/SKILL.md @@ -0,0 +1,95 @@ +--- +name: fusion-cache +description: >- + Documents Fusion application cache (default moka driver, Redis reserved), + settings under fusion..json, sync/async APIs, TTL rules, and clear + across Python, Node, and C#. Use when adding cache usage or changing drivers. +--- + +# Fusion cache + +Process-wide cache shared by Python / Node / C# via `fusion-core`. + +## Default driver + +**moka** (in-process Rust cache). Settings alias `mako` is accepted and maps to `moka`. + +Redis (`cache.driver = "redis"`) is reserved in settings but **not implemented yet**. + +## Settings (`fusion.dev.json` / stage / prod) + +```json +"cache": { + "driver": "moka", + "max_capacity": 10000, + "default_ttl": null, + "connection_string": null, + "host": "127.0.0.1", + "port": 6379, + "username": null, + "password": null, + "db": 0 +} +``` + +| Key | Purpose | +|-----|---------| +| `driver` | `moka` (default) or future `redis` | +| `max_capacity` | moka max entries | +| `default_ttl` | seconds, or **`null` = no expiry** unless code passes `ttl=` | +| `connection_string` / `host` / `port` / `username` / `password` / `db` | Redis connection (future) | + +### TTL rules + +1. `cache.set("k", value, ttl=200)` → expires in 200 seconds (always wins). +2. `cache.set("k", value)` with `default_ttl: null` → **forever** (until delete/clear). +3. `cache.set("k", value)` with `default_ttl: 3600` → expires in 3600 seconds. + +Same rules apply to `get_or_set` / `delete_or_set` / `exists_or_set` and their async variants. + +## Sync API + +| Python | Node | C# | +|--------|------|-----| +| `cache.set(..., ttl=?)` | `cache.set(..., ttl?)` | `Cache.Set(..., ttlSeconds?)` | +| `cache.get` | `cache.get` | `Cache.Get` | +| `cache.delete` | `cache.delete` | `Cache.Delete` | +| `cache.exists` | `cache.exists` | `Cache.Exists` | +| `cache.get_or_set` | `cache.getOrSet` | `Cache.GetOrSet` | +| `cache.delete_or_set` | `cache.deleteOrSet` | `Cache.DeleteOrSet` | +| `cache.exists_or_set` | `cache.existsOrSet` | `Cache.ExistsOrSet` | +| `cache.clear` | `cache.clear` | `Cache.Clear` | + +## Async API + +| Python | Node | C# | +|--------|------|-----| +| `await cache.aset` | `await cache.aset` | `await Cache.SetAsync` | +| `await cache.aget` | `await cache.aget` | `await Cache.GetAsync` | +| `await cache.adelete` | `await cache.adelete` | `await Cache.DeleteAsync` | +| `await cache.aexists` | `await cache.aexists` | `await Cache.ExistsAsync` | +| `await cache.aget_or_set` | `await cache.agetOrSet` | `await Cache.GetOrSetAsync` | +| `await cache.adelete_or_set` | `await cache.adeleteOrSet` | `await Cache.DeleteOrSetAsync` | +| `await cache.aexists_or_set` | `await cache.aexistsOrSet` | `await Cache.ExistsOrSetAsync` | +| `await cache.aclear` | `await cache.aclear` | `await Cache.ClearAsync` | + +Semantics: + +- **get_or_set** — return cached value, else store default (value or callable) and return it +- **aget_or_set** — same; factory may be **async** +- **delete_or_set** — delete then set; return stored value +- **exists_or_set** — if key exists return `true`; else set and return `false` +- **clear** — drop all keys (keeps the cache instance); **reset** (tests) drops the global instance + +Values must be JSON-compatible. + +## Examples + +`examples/cache.py` / `.mjs` / `.cs` + +## Implementation + +- Core: `crates/fusion-core/src/cache.rs` (moka) +- Python: `fusion_framework.cache` +- Node: `cache` export from `fusion-framework` +- C#: `FusionFramework.Cache` via FFI diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md index ed40d36..d435731 100644 --- a/.agents/skills/fusion-cli/SKILL.md +++ b/.agents/skills/fusion-cli/SKILL.md @@ -122,6 +122,7 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`. - `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`. - Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`). - Opt-in middleware list in `main` (e.g. `request_id`, `cors`, `cache_headers`, `security_headers`, `framework_headers`). Framework does **not** auto-enable middleware; the scaffold opts in. +- Application cache defaults to **moka** (`cache` block in env JSON); see `fusion-cache` skill. ### Default ports diff --git a/Cargo.lock b/Cargo.lock index 69622de..2a9aff7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -49,6 +49,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" @@ -98,6 +104,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "ctor" version = "0.2.9" @@ -146,6 +176,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "moka", "serde_json", "tera", "tokio", @@ -222,6 +253,7 @@ dependencies = [ "futures-core", "futures-task", "pin-project-lite", + "slab", ] [[package]] @@ -232,10 +264,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "h2" version = "0.4.15" @@ -391,6 +434,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" @@ -442,6 +496,23 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "napi" version = "2.16.17" @@ -639,6 +710,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -822,6 +899,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -943,6 +1026,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -973,6 +1067,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/bindings/csharp/FusionFramework/Cache.cs b/bindings/csharp/FusionFramework/Cache.cs new file mode 100644 index 0000000..ed698b5 --- /dev/null +++ b/bindings/csharp/FusionFramework/Cache.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FusionFramework; + +/// Process-wide application cache (default driver: moka). +public static class Cache +{ + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = null, + }; + + /// Apply cache.* from a settings handle. + public static void Configure(FusionSettings settings) + { + if (Native.fusion_cache_configure(settings.Handle) != 0) + throw new InvalidOperationException("cache configure failed"); + } + + /// Ensure a default moka cache is ready. + public static void Ensure() + { + if (Native.fusion_cache_ensure() != 0) + throw new InvalidOperationException("cache ensure failed"); + } + + /// Store a JSON-compatible value. + /// null → use settings default_ttl; if that is also null, no expiry. + public static void Set(string key, object? value, double? ttlSeconds = null) + { + Ensure(); + var json = JsonSerializer.Serialize(value, JsonOptions); + if (Native.fusion_cache_set(key, json, ttlSeconds ?? -1.0) != 0) + throw new InvalidOperationException($"cache set failed for key '{key}'"); + } + + /// Return the cached value, or null if missing/expired. + public static JsonNode? Get(string key) + { + Ensure(); + var ptr = Native.fusion_cache_get(key); + if (ptr == IntPtr.Zero) + return null; + var json = Native.TakeUtf8(ptr); + return string.IsNullOrEmpty(json) ? null : JsonNode.Parse(json); + } + + /// Remove a key; returns whether it existed. + public static bool Delete(string key) + { + Ensure(); + var code = Native.fusion_cache_delete(key); + if (code < 0) + throw new InvalidOperationException($"cache delete failed for key '{key}'"); + return code == 1; + } + + /// True when the key is present and not expired. + public static bool Exists(string key) + { + Ensure(); + var code = Native.fusion_cache_exists(key); + if (code < 0) + throw new InvalidOperationException($"cache exists failed for key '{key}'"); + return code == 1; + } + + /// Return cached value, or store and return it. + public static JsonNode? GetOrSet(string key, object? defaultValue, double? ttlSeconds = null) + { + Ensure(); + var json = JsonSerializer.Serialize(defaultValue, JsonOptions); + var ptr = Native.fusion_cache_get_or_set(key, json, ttlSeconds ?? -1.0); + var raw = Native.TakeUtf8(ptr); + return string.IsNullOrEmpty(raw) ? null : JsonNode.Parse(raw); + } + + /// Delete then set; returns the stored value. + public static JsonNode? DeleteOrSet(string key, object? value, double? ttlSeconds = null) + { + Ensure(); + var json = JsonSerializer.Serialize(value, JsonOptions); + var ptr = Native.fusion_cache_delete_or_set(key, json, ttlSeconds ?? -1.0); + var raw = Native.TakeUtf8(ptr); + return string.IsNullOrEmpty(raw) ? null : JsonNode.Parse(raw); + } + + /// If key exists return true; otherwise set value and return false. + public static bool ExistsOrSet(string key, object? value, double? ttlSeconds = null) + { + Ensure(); + var json = JsonSerializer.Serialize(value, JsonOptions); + var code = Native.fusion_cache_exists_or_set(key, json, ttlSeconds ?? -1.0); + if (code < 0) + throw new InvalidOperationException($"cache exists_or_set failed for key '{key}'"); + return code == 1; + } + + /// Remove every entry from the process-wide cache. + public static void Clear() + { + Ensure(); + if (Native.fusion_cache_clear() != 0) + throw new InvalidOperationException("cache clear failed"); + } + + /// Active driver name (e.g. moka). + public static string Driver() + { + Ensure(); + var ptr = Native.fusion_cache_driver(); + var name = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(name)) + throw new InvalidOperationException("cache driver lookup failed"); + return name; + } + + /// Drop the global cache instance (tests). + public static void Reset() => Native.fusion_cache_reset(); + + /// Async . + public static Task SetAsync(string key, object? value, double? ttlSeconds = null) => + Task.Run(() => Set(key, value, ttlSeconds)); + + /// Async . + public static Task GetAsync(string key) => + Task.Run(() => Get(key)); + + /// Async . + public static Task DeleteAsync(string key) => + Task.Run(() => Delete(key)); + + /// Async . + public static Task ExistsAsync(string key) => + Task.Run(() => Exists(key)); + + /// Async with a sync default value. + public static Task GetOrSetAsync( + string key, + object? defaultValue, + double? ttlSeconds = null) => + Task.Run(() => GetOrSet(key, defaultValue, ttlSeconds)); + + /// Async ; factory may be async. + public static async Task GetOrSetAsync( + string key, + Func> factory, + double? ttlSeconds = null) + { + if (await ExistsAsync(key).ConfigureAwait(false)) + return await GetAsync(key).ConfigureAwait(false); + var value = await factory().ConfigureAwait(false); + await SetAsync(key, value, ttlSeconds).ConfigureAwait(false); + return await GetAsync(key).ConfigureAwait(false); + } + + /// Async . + public static Task DeleteOrSetAsync( + string key, + object? value, + double? ttlSeconds = null) => + Task.Run(() => DeleteOrSet(key, value, ttlSeconds)); + + /// Async . + public static Task ExistsOrSetAsync( + string key, + object? value, + double? ttlSeconds = null) => + Task.Run(() => ExistsOrSet(key, value, ttlSeconds)); + + /// Async . + public static Task ClearAsync() => Task.Run(Clear); +} diff --git a/bindings/csharp/FusionFramework/Native.cs b/bindings/csharp/FusionFramework/Native.cs index 8768268..c783214 100644 --- a/bindings/csharp/FusionFramework/Native.cs +++ b/bindings/csharp/FusionFramework/Native.cs @@ -192,6 +192,54 @@ public static extern IntPtr fusion_render_template( [MarshalAs(UnmanagedType.LPUTF8Str)] string contextJson, [MarshalAs(UnmanagedType.LPUTF8Str)] string? templatesRoot); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_configure(IntPtr settings); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_ensure(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_set( + [MarshalAs(UnmanagedType.LPUTF8Str)] string key, + [MarshalAs(UnmanagedType.LPUTF8Str)] string valueJson, + double ttlSecs); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_get([MarshalAs(UnmanagedType.LPUTF8Str)] string key); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_delete([MarshalAs(UnmanagedType.LPUTF8Str)] string key); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_exists([MarshalAs(UnmanagedType.LPUTF8Str)] string key); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_get_or_set( + [MarshalAs(UnmanagedType.LPUTF8Str)] string key, + [MarshalAs(UnmanagedType.LPUTF8Str)] string defaultJson, + double ttlSecs); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_delete_or_set( + [MarshalAs(UnmanagedType.LPUTF8Str)] string key, + [MarshalAs(UnmanagedType.LPUTF8Str)] string valueJson, + double ttlSecs); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_exists_or_set( + [MarshalAs(UnmanagedType.LPUTF8Str)] string key, + [MarshalAs(UnmanagedType.LPUTF8Str)] string valueJson, + double ttlSecs); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_driver(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_cache_clear(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void fusion_cache_reset(); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate IntPtr FusionHandlerFn( IntPtr userData, diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index 76b8ac1..17ff493 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -135,6 +135,21 @@ 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()`. +## Cache + +Process-wide cache (default driver **moka**). Configure via `cache` in `fusion..json`. + +```csharp +Cache.Set("user:1", new { name = "Ada" }, ttlSeconds: 60); +var value = Cache.Get("user:1"); +Cache.GetOrSet("counter", 1); +Cache.ExistsOrSet("flag", true); +Cache.DeleteOrSet("user:1", new { name = "Bob" }); +Cache.Clear(); +await Cache.SetAsync("user:2", new { name = "Ada" }); +await Cache.ClearAsync(); +``` + ## License BSD 3-Clause diff --git a/crates/fusion-core/Cargo.toml b/crates/fusion-core/Cargo.toml index bbf45d2..9b9ab1d 100644 --- a/crates/fusion-core/Cargo.toml +++ b/crates/fusion-core/Cargo.toml @@ -20,4 +20,5 @@ bytes = { workspace = true } http = { workspace = true } serde_json = { workspace = true } tera = {version = "2", features = ["fast"]} +moka = { version = "0.12", features = ["sync"] } console = "0.16.4" diff --git a/crates/fusion-core/src/cache.rs b/crates/fusion-core/src/cache.rs new file mode 100644 index 0000000..1894ff4 --- /dev/null +++ b/crates/fusion-core/src/cache.rs @@ -0,0 +1,508 @@ +//! Application cache with pluggable drivers. +//! +//! Default driver is **moka** (in-process). Redis is reserved via settings +//! (`cache.driver = "redis"`) but not implemented yet. +//! +//! Settings (under `fusion..json`): +//! ```json +//! "cache": { +//! "driver": "moka", +//! "max_capacity": 10000, +//! "default_ttl": null, +//! "connection_string": null, +//! "host": "127.0.0.1", +//! "port": 6379, +//! "username": null, +//! "password": null, +//! "db": 0 +//! } +//! ``` + +use std::sync::{Arc, OnceLock, RwLock}; +use std::time::{Duration, Instant}; + +use moka::sync::Cache as MokaCache; +use serde_json::Value; + +use crate::settings::Settings; + +/// Canonical default driver name (in-process moka). +pub const DEFAULT_DRIVER: &str = "moka"; + +/// Alias accepted in settings (`mako` → moka). +const DRIVER_ALIASES_MOKA: &[&str] = &["moka", "mako"]; + +#[derive(Debug, Clone)] +struct Entry { + value: Value, + expires_at: Option, +} + +impl Entry { + fn alive(&self) -> bool { + match self.expires_at { + Some(at) => Instant::now() < at, + None => true, + } + } +} + +/// Cache configuration parsed from settings. +#[derive(Debug, Clone)] +pub struct CacheConfig { + pub driver: String, + pub max_capacity: u64, + pub default_ttl: Option, + pub connection_string: Option, + pub host: Option, + pub port: Option, + pub username: Option, + pub password: Option, + pub db: Option, +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + driver: DEFAULT_DRIVER.to_string(), + max_capacity: 10_000, + // null / None = no expiry unless the caller passes an explicit ttl. + default_ttl: None, + connection_string: None, + host: None, + port: None, + username: None, + password: None, + db: None, + } + } +} + +impl CacheConfig { + /// Build config from Fusion settings (`cache.*` keys). + pub fn from_settings(settings: &Settings) -> Self { + let mut cfg = Self::default(); + if let Some(driver) = settings.get_str("cache.driver") { + cfg.driver = normalize_driver(&driver); + } + if let Some(cap) = settings.get_u64("cache.max_capacity") { + cfg.max_capacity = cap.max(1); + } + match settings.get("cache.default_ttl") { + // Explicit null (or missing after Default) → infinite unless set(..., ttl=...). + None | Some(Value::Null) => cfg.default_ttl = None, + Some(Value::Number(n)) => { + cfg.default_ttl = n.as_u64().map(Duration::from_secs); + } + Some(Value::String(s)) if s.eq_ignore_ascii_case("null") || s.is_empty() => { + cfg.default_ttl = None; + } + _ => cfg.default_ttl = None, + } + cfg.connection_string = settings.get_str("cache.connection_string"); + cfg.host = settings.get_str("cache.host"); + cfg.port = settings.get_u64("cache.port").map(|p| p as u16); + cfg.username = settings.get_str("cache.username"); + cfg.password = settings.get_str("cache.password"); + cfg.db = settings.get_u64("cache.db"); + cfg + } +} + +fn normalize_driver(name: &str) -> String { + let lower = name.trim().to_ascii_lowercase(); + if DRIVER_ALIASES_MOKA.contains(&lower.as_str()) { + DEFAULT_DRIVER.to_string() + } else { + lower + } +} + +/// Shared cache handle used by all language bindings. +#[derive(Clone)] +pub struct Cache { + inner: Arc, + default_ttl: Option, + driver: String, +} + +trait CacheBackend: Send + Sync { + fn set(&self, key: &str, entry: Entry); + fn get(&self, key: &str) -> Option; + fn delete(&self, key: &str) -> bool; + fn clear(&self); +} + +struct MokaBackend { + store: MokaCache, +} + +impl MokaBackend { + fn new(max_capacity: u64) -> Self { + Self { + store: MokaCache::builder().max_capacity(max_capacity).build(), + } + } +} + +impl CacheBackend for MokaBackend { + fn set(&self, key: &str, entry: Entry) { + self.store.insert(key.to_string(), entry); + } + + fn get(&self, key: &str) -> Option { + let entry = self.store.get(key)?; + if entry.alive() { + Some(entry) + } else { + self.store.invalidate(key); + None + } + } + + fn delete(&self, key: &str) -> bool { + let existed = self.store.contains_key(key); + self.store.invalidate(key); + existed + } + + fn clear(&self) { + self.store.invalidate_all(); + } +} + +impl Cache { + /// Create a cache for the given config (errors on unknown/unsupported drivers). + pub fn open(config: CacheConfig) -> Result { + let driver = normalize_driver(&config.driver); + let backend: Arc = match driver.as_str() { + "moka" => Arc::new(MokaBackend::new(config.max_capacity)), + "redis" => { + return Err( + "cache driver \"redis\" is not implemented yet; use \"moka\"".into(), + ); + } + other => { + return Err(format!( + "unknown cache driver \"{other}\"; supported: moka (default)" + )); + } + }; + Ok(Self { + inner: backend, + default_ttl: config.default_ttl, + driver, + }) + } + + /// Driver name currently in use (`moka`, …). + pub fn driver(&self) -> &str { + &self.driver + } + + /// Store a JSON value under `key`. + /// + /// `ttl`: + /// - `Some(duration)` — expire after that duration + /// - `None` — use `default_ttl` from settings; if that is also `None`, keep forever + pub fn set(&self, key: &str, value: Value, ttl: Option) { + let ttl = ttl.or(self.default_ttl); + let expires_at = ttl.map(|d| Instant::now() + d); + self.inner.set( + key, + Entry { + value, + expires_at, + }, + ); + } + + /// Fetch a value if present and not expired. + pub fn get(&self, key: &str) -> Option { + self.inner.get(key).map(|e| e.value) + } + + /// Remove a key; returns whether it existed. + pub fn delete(&self, key: &str) -> bool { + self.inner.delete(key) + } + + /// True when the key is present and not expired. + pub fn exists(&self, key: &str) -> bool { + self.inner.get(key).is_some() + } + + /// Return cached value, or store `default` and return it. + pub fn get_or_set(&self, key: &str, default: Value, ttl: Option) -> Value { + if let Some(existing) = self.get(key) { + return existing; + } + self.set(key, default.clone(), ttl); + default + } + + /// Delete then set (force replace); returns the stored value. + pub fn delete_or_set(&self, key: &str, value: Value, ttl: Option) -> Value { + let _ = self.delete(key); + self.set(key, value.clone(), ttl); + value + } + + /// If the key already exists, leave it and return `true`. + /// Otherwise set `value` and return `false`. + pub fn exists_or_set(&self, key: &str, value: Value, ttl: Option) -> bool { + if self.exists(key) { + return true; + } + self.set(key, value, ttl); + false + } + + /// Drop all entries (test helper / admin). + pub fn clear(&self) { + self.inner.clear(); + } +} + +static GLOBAL: OnceLock>> = OnceLock::new(); + +fn global_slot() -> &'static RwLock> { + GLOBAL.get_or_init(|| RwLock::new(None)) +} + +/// Install (or replace) the process-wide cache from settings. +pub fn configure_from_settings(settings: &Settings) -> Result<(), String> { + let cfg = CacheConfig::from_settings(settings); + let cache = Cache::open(cfg)?; + let mut guard = global_slot() + .write() + .map_err(|_| "cache lock poisoned".to_string())?; + *guard = Some(cache); + Ok(()) +} + +/// Install a concrete cache instance as the process-wide default. +pub fn configure(cache: Cache) { + if let Ok(mut guard) = global_slot().write() { + *guard = Some(cache); + } +} + +/// Ensure a global cache exists (default moka if never configured). +pub fn ensure_configured() -> Result<(), String> { + { + let guard = global_slot() + .read() + .map_err(|_| "cache lock poisoned".to_string())?; + if guard.is_some() { + return Ok(()); + } + } + let cache = Cache::open(CacheConfig::default())?; + configure(cache); + Ok(()) +} + +fn with_global(f: impl FnOnce(&Cache) -> R) -> Result { + ensure_configured()?; + let guard = global_slot() + .read() + .map_err(|_| "cache lock poisoned".to_string())?; + let cache = guard + .as_ref() + .ok_or_else(|| "cache is not configured".to_string())?; + Ok(f(cache)) +} + +/// Process-wide `set`. +pub fn set(key: &str, value: Value, ttl: Option) -> Result<(), String> { + with_global(|c| c.set(key, value, ttl)) +} + +/// Process-wide `get`. +pub fn get(key: &str) -> Result, String> { + with_global(|c| c.get(key)) +} + +/// Process-wide `delete`. +pub fn delete(key: &str) -> Result { + with_global(|c| c.delete(key)) +} + +/// Process-wide `exists`. +pub fn exists(key: &str) -> Result { + with_global(|c| c.exists(key)) +} + +/// Process-wide `get_or_set`. +pub fn get_or_set(key: &str, default: Value, ttl: Option) -> Result { + with_global(|c| c.get_or_set(key, default, ttl)) +} + +/// Process-wide `delete_or_set`. +pub fn delete_or_set(key: &str, value: Value, ttl: Option) -> Result { + with_global(|c| c.delete_or_set(key, value, ttl)) +} + +/// Process-wide `exists_or_set`. +pub fn exists_or_set(key: &str, value: Value, ttl: Option) -> Result { + with_global(|c| c.exists_or_set(key, value, ttl)) +} + +/// Remove every entry from the process-wide cache. +pub fn clear() -> Result<(), String> { + with_global(|c| c.clear()) +} + +/// Active driver name (`moka`, …). +pub fn driver() -> Result { + with_global(|c| c.driver().to_string()) +} + +/// Reset global cache (tests). +pub fn reset_global() { + if let Ok(mut guard) = global_slot().write() { + *guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::thread; + + #[test] + fn moka_set_get_delete_exists() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + assert!(!cache.exists("a")); + cache.set("a", json!({"n": 1}), None); + assert!(cache.exists("a")); + assert_eq!(cache.get("a"), Some(json!({"n": 1}))); + assert!(cache.delete("a")); + assert!(!cache.exists("a")); + } + + #[test] + fn get_or_set_and_exists_or_set() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + let v = cache.get_or_set("k", json!("first"), None); + assert_eq!(v, json!("first")); + let v2 = cache.get_or_set("k", json!("second"), None); + assert_eq!(v2, json!("first")); + assert!(cache.exists_or_set("k", json!("third"), None)); + assert!(!cache.exists_or_set("missing", json!(1), None)); + assert_eq!(cache.get("missing"), Some(json!(1))); + } + + #[test] + fn delete_or_set_replaces() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + cache.set("k", json!(1), None); + let out = cache.delete_or_set("k", json!(2), None); + assert_eq!(out, json!(2)); + assert_eq!(cache.get("k"), Some(json!(2))); + } + + #[test] + fn ttl_expires() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + cache.set("t", json!(true), Some(Duration::from_millis(40))); + assert!(cache.exists("t")); + thread::sleep(Duration::from_millis(60)); + assert!(!cache.exists("t")); + } + + #[test] + fn omitted_ttl_is_infinite_when_default_ttl_is_null() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + cache.set("forever", json!(1), None); + thread::sleep(Duration::from_millis(40)); + assert!(cache.exists("forever")); + assert_eq!(cache.get("forever"), Some(json!(1))); + } + + #[test] + fn omitted_ttl_uses_settings_default_ttl() { + let cache = Cache::open(CacheConfig { + default_ttl: Some(Duration::from_millis(40)), + ..CacheConfig::default() + }) + .unwrap(); + cache.set("k", json!(1), None); + assert!(cache.exists("k")); + thread::sleep(Duration::from_millis(60)); + assert!(!cache.exists("k")); + } + + #[test] + fn explicit_ttl_overrides_default_ttl() { + let cache = Cache::open(CacheConfig { + default_ttl: Some(Duration::from_millis(40)), + ..CacheConfig::default() + }) + .unwrap(); + // Explicit long TTL must not expire with the short default. + cache.set("k", json!(1), Some(Duration::from_secs(60))); + thread::sleep(Duration::from_millis(60)); + assert!(cache.exists("k")); + } + + #[test] + fn mako_alias_maps_to_moka() { + let cfg = CacheConfig { + driver: "mako".into(), + default_ttl: None, + ..CacheConfig::default() + }; + let cache = Cache::open(cfg).unwrap(); + assert_eq!(cache.driver(), "moka"); + } + + #[test] + fn clear_removes_all_keys() { + let cache = Cache::open(CacheConfig { + default_ttl: None, + ..CacheConfig::default() + }) + .unwrap(); + cache.set("a", json!(1), None); + cache.set("b", json!(2), None); + cache.clear(); + assert!(!cache.exists("a")); + assert!(!cache.exists("b")); + } + + #[test] + fn redis_not_implemented() { + let result = Cache::open(CacheConfig { + driver: "redis".into(), + ..CacheConfig::default() + }); + let err = match result { + Ok(_) => panic!("expected redis to fail"), + Err(e) => e, + }; + assert!(err.contains("not implemented")); + } +} \ No newline at end of file diff --git a/crates/fusion-core/src/lib.rs b/crates/fusion-core/src/lib.rs index 29775a5..5b5b58e 100644 --- a/crates/fusion-core/src/lib.rs +++ b/crates/fusion-core/src/lib.rs @@ -1,4 +1,5 @@ mod api_context; +pub mod cache; mod coerce; mod dispatch; mod error; @@ -17,6 +18,13 @@ mod status; mod templates; pub use api_context::ApiContext; +pub use cache::{ + Cache, CacheConfig, DEFAULT_DRIVER, clear as cache_clear, configure as configure_cache, + configure_from_settings as configure_cache_from_settings, delete as cache_delete, + delete_or_set as cache_delete_or_set, driver as cache_driver, ensure_configured as ensure_cache, + exists as cache_exists, exists_or_set as cache_exists_or_set, get as cache_get, + get_or_set as cache_get_or_set, reset_global as reset_cache, set as cache_set, +}; pub use coerce::{ParamKind, coerce_param, param_kind_from_name}; pub use dispatch::{BODY_METHODS, ParamSpec, bind_args, build_response, parse_json_object}; pub use error::{Error, Result}; diff --git a/crates/fusion-ffi/src/lib.rs b/crates/fusion-ffi/src/lib.rs index cc878a3..dbe2822 100644 --- a/crates/fusion-ffi/src/lib.rs +++ b/crates/fusion-ffi/src/lib.rs @@ -16,6 +16,7 @@ use fusion_core::{ location, render_template, resolve_route_path, response_from_value, }; use serde_json::{Map, Value}; +use std::time::Duration; /// Opaque application handle. pub struct FusionAppHandle { @@ -560,3 +561,191 @@ pub extern "C" fn fusion_render_template( } } } + +fn parse_json_value(raw: &str) -> Value { + if raw.is_empty() { + Value::Null + } else { + serde_json::from_str(raw).unwrap_or(Value::Null) + } +} + +fn ttl_opt(ttl_secs: f64) -> Option { + // Use -1.0 to mean "no explicit TTL" (fall back to cache default). + if ttl_secs < 0.0 { + None + } else { + Some(Duration::from_secs_f64(ttl_secs)) + } +} + +fn value_to_cstring(v: &Value) -> *mut c_char { + to_cstring(&serde_json::to_string(v).unwrap_or_else(|_| "null".into())) +} + +/// Configure process-wide cache from a settings handle (`cache.*`). +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_configure(settings: *const FusionSettingsHandle) -> c_int { + if settings.is_null() { + return -1; + } + let settings = unsafe { &*settings }; + match fusion_core::cache::configure_from_settings(&settings.settings) { + Ok(()) => 0, + Err(e) => { + eprintln!("fusion_cache_configure: {e}"); + -1 + } + } +} + +/// Ensure a default moka cache exists. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_ensure() -> c_int { + match fusion_core::cache::ensure_configured() { + Ok(()) => 0, + Err(e) => { + eprintln!("fusion_cache_ensure: {e}"); + -1 + } + } +} + +/// Store JSON value. `ttl_secs < 0` uses the configured default TTL. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_set( + key: *const c_char, + value_json: *const c_char, + ttl_secs: f64, +) -> c_int { + let key = cstr_to_str(key); + if key.is_empty() { + return -1; + } + let value = parse_json_value(cstr_to_str(value_json)); + match fusion_core::cache::set(key, value, ttl_opt(ttl_secs)) { + Ok(()) => 0, + Err(e) => { + eprintln!("fusion_cache_set: {e}"); + -1 + } + } +} + +/// Get JSON value or null pointer when missing. Free with fusion_string_free. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_get(key: *const c_char) -> *mut c_char { + let key = cstr_to_str(key); + match fusion_core::cache::get(key) { + Ok(Some(v)) => value_to_cstring(&v), + Ok(None) => ptr::null_mut(), + Err(e) => { + eprintln!("fusion_cache_get: {e}"); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_delete(key: *const c_char) -> c_int { + match fusion_core::cache::delete(cstr_to_str(key)) { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + eprintln!("fusion_cache_delete: {e}"); + -1 + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_exists(key: *const c_char) -> c_int { + match fusion_core::cache::exists(cstr_to_str(key)) { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + eprintln!("fusion_cache_exists: {e}"); + -1 + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_get_or_set( + key: *const c_char, + default_json: *const c_char, + ttl_secs: f64, +) -> *mut c_char { + let key = cstr_to_str(key); + let default = parse_json_value(cstr_to_str(default_json)); + match fusion_core::cache::get_or_set(key, default, ttl_opt(ttl_secs)) { + Ok(v) => value_to_cstring(&v), + Err(e) => { + eprintln!("fusion_cache_get_or_set: {e}"); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_delete_or_set( + key: *const c_char, + value_json: *const c_char, + ttl_secs: f64, +) -> *mut c_char { + let key = cstr_to_str(key); + let value = parse_json_value(cstr_to_str(value_json)); + match fusion_core::cache::delete_or_set(key, value, ttl_opt(ttl_secs)) { + Ok(v) => value_to_cstring(&v), + Err(e) => { + eprintln!("fusion_cache_delete_or_set: {e}"); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_exists_or_set( + key: *const c_char, + value_json: *const c_char, + ttl_secs: f64, +) -> c_int { + let key = cstr_to_str(key); + let value = parse_json_value(cstr_to_str(value_json)); + match fusion_core::cache::exists_or_set(key, value, ttl_opt(ttl_secs)) { + Ok(true) => 1, + Ok(false) => 0, + Err(e) => { + eprintln!("fusion_cache_exists_or_set: {e}"); + -1 + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_driver() -> *mut c_char { + match fusion_core::cache::driver() { + Ok(d) => to_cstring(&d), + Err(e) => { + eprintln!("fusion_cache_driver: {e}"); + ptr::null_mut() + } + } +} + +/// Clear all entries from the process-wide cache. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_clear() -> c_int { + match fusion_core::cache::clear() { + Ok(()) => 0, + Err(e) => { + eprintln!("fusion_cache_clear: {e}"); + -1 + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_reset() { + fusion_core::cache::reset_global(); +} diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 2fa60d3..4dc412b 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -1,219 +1,68 @@ -export class App { - constructor() - route(method: string, path: string, handler: (req: FusionRequest) => FusionResponse | string): void - listen(host: string, port: number): Promise -} - -export class Settings { - constructor() - loadJson(path?: string | null, env?: string | null, extraRoots?: string[]): void - ensureLoaded(extraRoots?: string[]): void - merge(values: Record): void - get(key: string, defaultValue?: unknown): unknown - readonly host: string - readonly port: number - readonly debug: boolean - readonly env: string -} - -export class FusionBaseApi { - request: FusionRequest - constructor(request: FusionRequest) - readonly method: string - readonly path: string - readonly body: string - readonly headers: Record - readonly params: Record - readonly query: Record - readonly state: Record - wantsJson(): boolean - response(body?: unknown, status?: number, headers?: Record): FusionResponse -} - -export class FusionBaseTemplate extends FusionBaseApi { - static template: string - static templateAddress: string - static templatesDir: string - /** Sync object or Promise (async context). */ - context(): Record | Promise> - get(): FusionResponse | Promise - templateName(): string - templatesRoot(): string - render(options?: { - status?: number - headers?: Record - context?: Record - templateName?: string - }): FusionResponse | Promise +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare function getHttpMethods(): Array +export declare function apiResourceNameJs(className: string): string +export declare function resolveRoutePathJs(template: string, className: string): string +export declare function coerceParamJs(raw: string, kind?: string | undefined | null): unknown +export interface HttpStatusCode { + name: string + code: number } - -export function renderTemplate( - templateName: string, - context?: Record, - templatesRoot?: string | null, -): string - -export class HTTPException extends Error { - status: number - detail: unknown - headers: Record - constructor(status: number, detail?: unknown, headers?: Record) - toResponse(): FusionResponse +export declare function getHttpStatusCodes(): Array +export interface HttpHeaderConstant { + name: string + value: string } - -export class FusionApp { - constructor(settings?: Partial) - use(middleware: FusionMiddleware): void - mount(): void - listen( - host?: string | { - host?: string - port?: number - reload?: boolean - watchDirs?: string[] - }, - port?: number, - options?: { reload?: boolean; watchDirs?: string[] }, - ): Promise -} - -export type RouteOptions = { - tags?: string[] - desc?: string - title?: string - version?: string - deprecated?: boolean - middleware?: FusionMiddleware[] - permissions?: Array<(request: FusionRequest) => boolean> -} - -export function router(path: string, options?: RouteOptions): (ApiClass: T) => T -/** Alias of `router`. */ -export function route(path: string, options?: RouteOptions): (ApiClass: T) => T - -export function bearerJwt(options?: { - stateKey?: string - header?: string - verify?: (token: string) => Record | null -}): FusionMiddleware - -export function requirePermissions( - ...checks: Array<(request: FusionRequest) => boolean> -): FusionMiddleware - -export function requireRoles(...roles: string[]): FusionMiddleware -export function requireRoles(options: { - roles: string[] - claim?: string - stateKey?: string -}): FusionMiddleware - -export function runMiddlewareChain( - request: FusionRequest, - middlewares: FusionMiddleware[], - handler: (request: FusionRequest) => unknown | Promise, -): Promise - -export function apiResourceName(cls: { name: string } | string): string -export function resolveRoutePath(path: string, cls: { name: string }): string -export function configure(settings: Record): FusionSettings -export function getSettings(): FusionSettings -export function run( - options?: string | { settingsModule?: string; middleware?: FusionMiddleware[] }, -): Promise -export function coerceParam(raw: string, kind?: string): unknown -export function getHttpMethods(): string[] -export function apiResourceNameJs(className: string): string -export function resolveRoutePathJs(template: string, className: string): string -export function coerceParamJs(raw: string, kind?: string): unknown -export function prefersJsonJs(accept?: string | null, formatQuery?: string | null): boolean - -export const settings: Settings -export const status: Record -export const header: HeaderModule -export const HTTP_METHODS: string[] - -export interface HeaderModule { - [name: string]: string | ((...args: any[]) => Record) - CONTENT_TYPE: string - CONTENT_DISPOSITION: string - LOCATION: string - AUTHORIZATION: string - APPLICATION_JSON: string - APPLICATION_OCTET_STREAM: string - APPLICATION_PDF: string - attachment(filename: string): Record - inline(filename?: string | null): Record - contentType(mediaType: string, charset?: string | null): Record - location(url: string): Record - cacheControl(value: string): Record - download(filename: string, mediaType?: string | null): Record - fingerprint(): Record +export declare function getHttpHeaderConstants(): Array +export declare function headerAttachment(filename: string): Record +export declare function headerInline(filename?: string | undefined | null): Record +export declare function headerContentType(mediaType: string, charset?: string | undefined | null): Record +export declare function headerLocation(url: string): Record +export declare function headerCacheControl(value: string): Record +export declare function headerDownload(filename: string, mediaType?: string | undefined | null): Record +export declare function getFingerprintHeaders(): Record +/** True when the client prefers JSON (`Accept` or `?format=json`). */ +export declare function prefersJsonJs(accept?: string | undefined | null, formatQuery?: string | undefined | null): boolean +/** Render a Tera template file relative to `templates_root` (default `"templates"`). */ +export declare function renderTemplateJs(templateName: string, context: JsJson, templatesRoot?: string | undefined | null): string +export interface PaginationParams { + page: number + pageSize: number + offset: number } - -export interface FusionSettings { - host: string - port: number - debug: boolean - env?: string +export declare function parsePagination(query: object, defaultPageSize?: number | undefined | null, maxPageSize?: number | undefined | null): PaginationParams +export declare function paginatedBody(items: unknown, total: number, params: PaginationParams): unknown +/** Apply `cache.*` from a Settings instance to the process-wide cache. */ +export declare function cacheConfigure(settings: Settings): void +/** Install a driver explicitly (default moka). */ +export declare function cacheConfigureDriver(driver?: string | undefined | null, maxCapacity?: number | undefined | null, defaultTtl?: number | undefined | null): void +export declare function cacheSet(key: string, value: JsJson, ttl?: number | undefined | null): void +export declare function cacheGet(key: string): unknown +export declare function cacheDelete(key: string): boolean +export declare function cacheExists(key: string): boolean +export declare function cacheGetOrSet(key: string, default: JsJson, ttl?: number | undefined | null): unknown +export declare function cacheDeleteOrSet(key: string, value: JsJson, ttl?: number | undefined | null): unknown +export declare function cacheExistsOrSet(key: string, value: JsJson, ttl?: number | undefined | null): boolean +export declare function cacheDriver(): string +export declare function cacheClear(): void +export declare function cacheReset(): void +export declare class Settings { + constructor() + loadJson(path?: string | undefined | null, envName?: string | undefined | null, extraRoots?: Array | undefined | null): void + ensureLoaded(extraRoots?: Array | undefined | null): void + merge(values: unknown): void + get(key: string, default?: unknown | undefined | null): unknown + get host(): string + get port(): number + get debug(): boolean + get reload(): boolean + get env(): string } - -export interface FusionRequest { - method: string - path: string - body: string - headers: Record - params: Record - query: Record - state?: Record +export declare class App { + constructor() + route(method: string, path: string, handler: (...args: any[]) => any): void + listen(host: string, port: number): Promise } - -export type FusionMiddleware = ( - request: FusionRequest, - callNext: (request: FusionRequest) => unknown | Promise, -) => unknown | Promise - -export function frameworkHeaders(): FusionMiddleware - -export function securityHeaders(options?: { - contentTypeOptions?: string - frameOptions?: string - referrerPolicy?: string - permissionsPolicy?: string - coop?: string - corp?: string - csp?: string - hsts?: string -}): FusionMiddleware - -export function cors(options?: { - allowOrigins?: string | string[] - allowMethods?: string[] - allowHeaders?: string[] - exposeHeaders?: string[] - allowCredentials?: boolean - maxAge?: number -}): FusionMiddleware - -export function cacheHeaders(options?: { default?: string; value?: string }): FusionMiddleware - -export function requestId(options?: { - header?: string - 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 - | { - status?: number - body?: unknown - headers?: Record - } diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 0875f4c..d911490 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -1637,6 +1637,107 @@ function paginatedBody(items, total, params) { return native.paginatedBody(items, total, params) } +/** Process-wide application cache (default driver: moka). */ +const cache = { + _ready: false, + _ensure() { + if (this._ready) return + try { + // Use the native Settings singleton (not getSettings()'s plain object). + settings.ensureLoaded([process.cwd()]) + native.cacheConfigure(settings) + this._ready = true + } catch { + native.cacheConfigureDriver('moka', null, null) + this._ready = true + } + }, + configure(settingsInstance) { + const s = settingsInstance || settings + if (s && typeof s.ensureLoaded === 'function') { + s.ensureLoaded([process.cwd()]) + } + native.cacheConfigure(s) + this._ready = true + }, + configureDriver(driver = 'moka', { maxCapacity, defaultTtl } = {}) { + native.cacheConfigureDriver(driver, maxCapacity ?? null, defaultTtl ?? null) + this._ready = true + }, + set(key, value, ttl = null) { + this._ensure() + native.cacheSet(key, value, ttl) + }, + get(key) { + this._ensure() + return native.cacheGet(key) + }, + delete(key) { + this._ensure() + return native.cacheDelete(key) + }, + exists(key) { + this._ensure() + return native.cacheExists(key) + }, + getOrSet(key, defaultValue, ttl = null) { + this._ensure() + if (native.cacheExists(key)) return native.cacheGet(key) + const value = typeof defaultValue === 'function' ? defaultValue() : defaultValue + return native.cacheGetOrSet(key, value, ttl) + }, + deleteOrSet(key, value, ttl = null) { + this._ensure() + return native.cacheDeleteOrSet(key, value, ttl) + }, + existsOrSet(key, value, ttl = null) { + this._ensure() + return native.cacheExistsOrSet(key, value, ttl) + }, + clear() { + this._ensure() + native.cacheClear() + }, + driver() { + this._ensure() + return native.cacheDriver() + }, + reset() { + native.cacheReset() + this._ready = false + }, + + /** Async set (Promise). */ + async aset(key, value, ttl = null) { + this.set(key, value, ttl) + }, + async aget(key) { + return this.get(key) + }, + async adelete(key) { + return this.delete(key) + }, + async aexists(key) { + return this.exists(key) + }, + async agetOrSet(key, defaultValue, ttl = null) { + this._ensure() + if (native.cacheExists(key)) return native.cacheGet(key) + let value = typeof defaultValue === 'function' ? defaultValue() : defaultValue + if (value && typeof value.then === 'function') value = await value + return native.cacheGetOrSet(key, value, ttl) + }, + async adeleteOrSet(key, value, ttl = null) { + return this.deleteOrSet(key, value, ttl) + }, + async aexistsOrSet(key, value, ttl = null) { + return this.existsOrSet(key, value, ttl) + }, + async aclear() { + this.clear() + }, +} + const route = router module.exports = { @@ -1679,6 +1780,7 @@ module.exports = { coerceParam, parsePagination, paginatedBody, + cache, renderTemplate, clearRouteRegistry, openapiSpec, diff --git a/crates/fusion-node/src/lib.rs b/crates/fusion-node/src/lib.rs index 83ab11b..7b296dc 100644 --- a/crates/fusion-node/src/lib.rs +++ b/crates/fusion-node/src/lib.rs @@ -18,7 +18,7 @@ use serde_json::{Map, Number, Value as JsonValue}; pub use settings::Settings; /// JSON extracted on the Node thread so the async result is `Send`. -struct JsJson(JsonValue); +pub struct JsJson(pub JsonValue); impl FromNapiValue for JsJson { unsafe fn from_napi_value(env: sys::napi_env, value: sys::napi_value) -> Result { @@ -440,3 +440,109 @@ pub fn paginated_body( let body = core_paginated_body(items_json, total as u64, &page); json_to_js(&env, &body) } + +fn cache_err(e: String) -> Error { + Error::from_reason(e) +} + +fn ttl_secs(ttl: Option) -> Result> { + match ttl { + None => Ok(None), + Some(s) if s < 0.0 => Err(Error::from_reason("ttl must be >= 0")), + Some(s) => Ok(Some(std::time::Duration::from_secs_f64(s))), + } +} + +/// Apply `cache.*` from a Settings instance to the process-wide cache. +#[napi] +pub fn cache_configure(settings: &Settings) -> Result<()> { + let guard = settings + .inner + .lock() + .map_err(|_| Error::from_reason("settings lock poisoned"))?; + fusion_core::cache::configure_from_settings(&guard).map_err(cache_err) +} + +/// Install a driver explicitly (default moka). +#[napi] +pub fn cache_configure_driver( + driver: Option, + max_capacity: Option, + default_ttl: Option, +) -> Result<()> { + let mut cfg = fusion_core::cache::CacheConfig::default(); + if let Some(d) = driver { + cfg.driver = d; + } + if let Some(cap) = max_capacity { + cfg.max_capacity = u64::from(cap).max(1); + } + if let Some(secs) = default_ttl { + cfg.default_ttl = Some(std::time::Duration::from_secs_f64(secs)); + } + let instance = fusion_core::cache::Cache::open(cfg).map_err(cache_err)?; + fusion_core::cache::configure(instance); + Ok(()) +} + +#[napi] +pub fn cache_set(key: String, value: JsJson, ttl: Option) -> Result<()> { + fusion_core::cache::set(&key, value.0, ttl_secs(ttl)?).map_err(cache_err) +} + +#[napi] +pub fn cache_get(env: Env, key: String) -> Result { + match fusion_core::cache::get(&key).map_err(cache_err)? { + Some(v) => json_to_js(&env, &v), + None => env.get_null().map(|n| n.into_unknown()), + } +} + +#[napi] +pub fn cache_delete(key: String) -> Result { + fusion_core::cache::delete(&key).map_err(cache_err) +} + +#[napi] +pub fn cache_exists(key: String) -> Result { + fusion_core::cache::exists(&key).map_err(cache_err) +} + +#[napi] +pub fn cache_get_or_set(env: Env, key: String, default: JsJson, ttl: Option) -> Result { + let value = + fusion_core::cache::get_or_set(&key, default.0, ttl_secs(ttl)?).map_err(cache_err)?; + json_to_js(&env, &value) +} + +#[napi] +pub fn cache_delete_or_set( + env: Env, + key: String, + value: JsJson, + ttl: Option, +) -> Result { + let stored = + fusion_core::cache::delete_or_set(&key, value.0, ttl_secs(ttl)?).map_err(cache_err)?; + json_to_js(&env, &stored) +} + +#[napi] +pub fn cache_exists_or_set(key: String, value: JsJson, ttl: Option) -> Result { + fusion_core::cache::exists_or_set(&key, value.0, ttl_secs(ttl)?).map_err(cache_err) +} + +#[napi] +pub fn cache_driver() -> Result { + fusion_core::cache::driver().map_err(cache_err) +} + +#[napi] +pub fn cache_clear() -> Result<()> { + fusion_core::cache::clear().map_err(cache_err) +} + +#[napi] +pub fn cache_reset() { + fusion_core::cache::reset_global(); +} diff --git a/crates/fusion-node/src/settings.rs b/crates/fusion-node/src/settings.rs index 542ccf7..5b810e2 100644 --- a/crates/fusion-node/src/settings.rs +++ b/crates/fusion-node/src/settings.rs @@ -10,7 +10,7 @@ use crate::{js_to_json, json_to_js}; #[napi] pub struct Settings { - inner: Mutex, + pub(crate) inner: Mutex, } #[napi] diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index 0074b93..52a1625 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -16,12 +16,14 @@ ) from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination from fusion_framework.template import FusionBaseTemplate, render_template +from fusion_framework import cache from . import header, status __all__ = [ "settings", "status", "header", + "cache", "HTTPException", "bearer_jwt", "cache_headers", diff --git a/crates/fusion-py/python/fusion_framework/cache.py b/crates/fusion-py/python/fusion_framework/cache.py new file mode 100644 index 0000000..ac4d862 --- /dev/null +++ b/crates/fusion-py/python/fusion_framework/cache.py @@ -0,0 +1,258 @@ +"""Process-wide application cache (default driver: moka). + +Configure via ``fusion..json``:: + + "cache": { + "driver": "moka", + "max_capacity": 10000, + "default_ttl": null, + "connection_string": null, + "host": "127.0.0.1", + "port": 6379, + "username": null, + "password": null, + "db": 0 + } + +``default_ttl``: + +- ``null`` (scaffold default) — entries live forever unless you pass ``ttl=`` +- a number (seconds) — used when ``set`` / ``get_or_set`` omit ``ttl`` + +Sync:: + + from fusion_framework import cache + + cache.set("user:1", {"name": "Ada"}) # forever (if default_ttl is null) + cache.set("user:1", {"name": "Ada"}, ttl=60) # expire in 60s + cache.get("user:1") + cache.clear() + +Async:: + + await cache.aset("user:1", {"name": "Ada"}, ttl=60) + await cache.aget("user:1") + await cache.aget_or_set("user:1", fetch_user) + await cache.aclear() +""" + +from __future__ import annotations + +import asyncio +import inspect +from typing import Any, Awaitable, Callable, Optional, Union + +from fusion_framework._fusion import ( + cache_clear as _cache_clear, + cache_configure as _cache_configure, + cache_configure_driver as _cache_configure_driver, + cache_delete as _cache_delete, + cache_delete_or_set as _cache_delete_or_set, + cache_driver as _cache_driver, + cache_exists as _cache_exists, + cache_exists_or_set as _cache_exists_or_set, + cache_get as _cache_get, + cache_get_or_set as _cache_get_or_set, + cache_reset as _cache_reset, + cache_set as _cache_set, +) + +_configured = False + +DefaultFactory = Union[Any, Callable[[], Any], Callable[[], Awaitable[Any]]] + + +def _ensure() -> None: + """Load settings once and apply ``cache.*`` (falls back to default moka).""" + global _configured + if _configured: + return + from fusion_framework.config import get_settings + + settings = get_settings() + _cache_configure(settings) + _configured = True + + +def configure(settings: Any = None) -> None: + """Apply cache settings from a Settings object (or reload from get_settings).""" + global _configured + if settings is None: + from fusion_framework.config import get_settings + + settings = get_settings() + _cache_configure(settings) + _configured = True + + +def configure_driver( + driver: str = "moka", + *, + max_capacity: Optional[int] = None, + default_ttl: Optional[float] = None, +) -> None: + """Install a driver explicitly (tests / advanced use).""" + global _configured + _cache_configure_driver(driver, max_capacity, default_ttl) + _configured = True + + +def set(key: str, value: Any, ttl: Optional[float] = None) -> None: + """Store ``value`` under ``key`` (JSON-compatible). + + ``ttl`` is seconds. If omitted, uses ``cache.default_ttl`` from settings; + when that is ``null``, the entry does not expire. + """ + _ensure() + _cache_set(key, value, ttl) + + +def get(key: str) -> Any: + """Return the cached value, or ``None`` if missing/expired.""" + _ensure() + return _cache_get(key) + + +def delete(key: str) -> bool: + """Remove ``key``; returns whether it existed.""" + _ensure() + return bool(_cache_delete(key)) + + +def exists(key: str) -> bool: + """True when ``key`` is present and not expired.""" + _ensure() + return bool(_cache_exists(key)) + + +def get_or_set( + key: str, + default: Union[Any, Callable[[], Any]], + ttl: Optional[float] = None, +) -> Any: + """Return cached value, or evaluate/store ``default`` and return it.""" + _ensure() + return _cache_get_or_set(key, default, ttl) + + +def delete_or_set(key: str, value: Any, ttl: Optional[float] = None) -> Any: + """Delete ``key`` (if any), then set ``value``; returns the stored value.""" + _ensure() + return _cache_delete_or_set(key, value, ttl) + + +def exists_or_set(key: str, value: Any, ttl: Optional[float] = None) -> bool: + """If ``key`` exists return ``True``; otherwise set ``value`` and return ``False``.""" + _ensure() + return bool(_cache_exists_or_set(key, value, ttl)) + + +def clear() -> None: + """Remove every entry from the process-wide cache.""" + _ensure() + _cache_clear() + + +def driver() -> str: + """Active cache driver name (e.g. ``\"moka\"``).""" + _ensure() + return str(_cache_driver()) + + +def reset() -> None: + """Drop the global cache instance (tests).""" + global _configured + _cache_reset() + _configured = False + + +async def _maybe_await(value: Any) -> Any: + """Await ``value`` when it is awaitable; otherwise return it unchanged.""" + if inspect.isawaitable(value): + return await value + return value + + +async def _run_sync(fn: Callable[[], Any]) -> Any: + """Run a sync cache op off the event loop when a loop is running.""" + try: + asyncio.get_running_loop() + except RuntimeError: + return fn() + return await asyncio.to_thread(fn) + + +async def aset(key: str, value: Any, ttl: Optional[float] = None) -> None: + """Async ``set``.""" + await _run_sync(lambda: set(key, value, ttl)) + + +async def aget(key: str) -> Any: + """Async ``get``.""" + return await _run_sync(lambda: get(key)) + + +async def adelete(key: str) -> bool: + """Async ``delete``.""" + return await _run_sync(lambda: delete(key)) + + +async def aexists(key: str) -> bool: + """Async ``exists``.""" + return await _run_sync(lambda: exists(key)) + + +async def aget_or_set( + key: str, + default: DefaultFactory, + ttl: Optional[float] = None, +) -> Any: + """Async ``get_or_set``; ``default`` may be sync/async callable or a value.""" + _ensure() + if await _run_sync(lambda: exists(key)): + return await _run_sync(lambda: get(key)) + if callable(default): + value = await _maybe_await(default()) + else: + value = default + await _run_sync(lambda: set(key, value, ttl)) + return value + + +async def adelete_or_set(key: str, value: Any, ttl: Optional[float] = None) -> Any: + """Async ``delete_or_set``.""" + return await _run_sync(lambda: delete_or_set(key, value, ttl)) + + +async def aexists_or_set(key: str, value: Any, ttl: Optional[float] = None) -> bool: + """Async ``exists_or_set``.""" + return await _run_sync(lambda: exists_or_set(key, value, ttl)) + + +async def aclear() -> None: + """Async ``clear``.""" + await _run_sync(clear) + + +__all__ = [ + "configure", + "configure_driver", + "set", + "get", + "delete", + "exists", + "get_or_set", + "delete_or_set", + "exists_or_set", + "clear", + "driver", + "reset", + "aset", + "aget", + "adelete", + "aexists", + "aget_or_set", + "adelete_or_set", + "aexists_or_set", + "aclear", +] diff --git a/crates/fusion-py/python/fusion_framework/config.py b/crates/fusion-py/python/fusion_framework/config.py index 42e01bd..68423e1 100644 --- a/crates/fusion-py/python/fusion_framework/config.py +++ b/crates/fusion-py/python/fusion_framework/config.py @@ -58,6 +58,15 @@ def load_settings_module(module: str | ModuleType = "settings") -> Settings: if extras: settings.merge(extras) + # Apply cache.* from the loaded env JSON (default driver: moka). + try: + from fusion_framework.cache import configure as configure_cache + + configure_cache(settings) + except Exception: + # Cache is optional at import time; first use will ensure_configured. + pass + return settings diff --git a/crates/fusion-py/src/cache.rs b/crates/fusion-py/src/cache.rs new file mode 100644 index 0000000..533d41b --- /dev/null +++ b/crates/fusion-py/src/cache.rs @@ -0,0 +1,158 @@ +//! Python bindings for the process-wide Fusion cache (default: moka). + +use std::time::Duration; + +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyAny; + +use fusion_core::cache::{self, CacheConfig}; + +use crate::json::{json_to_py, py_to_json}; +use crate::PySettings; + +fn map_err(e: String) -> PyErr { + PyRuntimeError::new_err(e) +} + +fn ttl_from_secs(ttl: Option) -> PyResult> { + match ttl { + None => Ok(None), + Some(s) if s < 0.0 => Err(PyValueError::new_err("ttl must be >= 0")), + Some(s) => Ok(Some(Duration::from_secs_f64(s))), + } +} + +/// Configure the global cache from a Settings object (`cache.*` keys). +#[pyfunction(name = "cache_configure")] +fn py_cache_configure(settings: &Bound<'_, PySettings>) -> PyResult<()> { + let borrowed = settings.borrow(); + let guard = borrowed + .inner + .lock() + .map_err(|_| PyRuntimeError::new_err("settings lock poisoned"))?; + cache::configure_from_settings(&guard).map_err(map_err) +} +/// Configure the global cache from an explicit driver name (mainly for tests). +#[pyfunction(name = "cache_configure_driver")] +#[pyo3(signature = (driver="moka", max_capacity=None, default_ttl=None))] +fn py_cache_configure_driver( + driver: &str, + max_capacity: Option, + default_ttl: Option, +) -> PyResult<()> { + let mut cfg = CacheConfig::default(); + cfg.driver = driver.to_string(); + if let Some(cap) = max_capacity { + cfg.max_capacity = cap.max(1); + } + if let Some(secs) = default_ttl { + cfg.default_ttl = Some(Duration::from_secs_f64(secs)); + } + let instance = cache::Cache::open(cfg).map_err(map_err)?; + cache::configure(instance); + Ok(()) +} + +#[pyfunction(name = "cache_set")] +#[pyo3(signature = (key, value, ttl=None))] +fn py_cache_set(py: Python<'_>, key: &str, value: Bound<'_, PyAny>, ttl: Option) -> PyResult<()> { + let json = py_to_json(py, &value)?; + cache::set(key, json, ttl_from_secs(ttl)?).map_err(map_err) +} + +#[pyfunction(name = "cache_get")] +fn py_cache_get(py: Python<'_>, key: &str) -> PyResult { + match cache::get(key).map_err(map_err)? { + Some(v) => json_to_py(py, &v), + None => Ok(py.None()), + } +} + +#[pyfunction(name = "cache_delete")] +fn py_cache_delete(key: &str) -> PyResult { + cache::delete(key).map_err(map_err) +} + +#[pyfunction(name = "cache_exists")] +fn py_cache_exists(key: &str) -> PyResult { + cache::exists(key).map_err(map_err) +} + +#[pyfunction(name = "cache_get_or_set")] +#[pyo3(signature = (key, default, ttl=None))] +fn py_cache_get_or_set( + py: Python<'_>, + key: &str, + default: Bound<'_, PyAny>, + ttl: Option, +) -> PyResult { + if cache::exists(key).map_err(map_err)? { + return py_cache_get(py, key); + } + let value = if default.is_callable() { + default.call0()? + } else { + default + }; + let json = py_to_json(py, &value)?; + let stored = cache::get_or_set(key, json, ttl_from_secs(ttl)?).map_err(map_err)?; + json_to_py(py, &stored) +} + +#[pyfunction(name = "cache_delete_or_set")] +#[pyo3(signature = (key, value, ttl=None))] +fn py_cache_delete_or_set( + py: Python<'_>, + key: &str, + value: Bound<'_, PyAny>, + ttl: Option, +) -> PyResult { + let json = py_to_json(py, &value)?; + let stored = cache::delete_or_set(key, json, ttl_from_secs(ttl)?).map_err(map_err)?; + json_to_py(py, &stored) +} + +#[pyfunction(name = "cache_exists_or_set")] +#[pyo3(signature = (key, value, ttl=None))] +fn py_cache_exists_or_set( + py: Python<'_>, + key: &str, + value: Bound<'_, PyAny>, + ttl: Option, +) -> PyResult { + let json = py_to_json(py, &value)?; + cache::exists_or_set(key, json, ttl_from_secs(ttl)?).map_err(map_err) +} + +#[pyfunction(name = "cache_driver")] +fn py_cache_driver() -> PyResult { + cache::driver().map_err(map_err) +} + +#[pyfunction(name = "cache_clear")] +fn py_cache_clear() -> PyResult<()> { + cache::clear().map_err(map_err) +} + +#[pyfunction(name = "cache_reset")] +fn py_cache_reset() { + cache::reset_global(); +} + +/// Register cache helpers on the `_fusion` native module. +pub fn register_cache(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(py_cache_configure, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_configure_driver, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_set, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_get, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_delete, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_exists, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_get_or_set, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_delete_or_set, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_exists_or_set, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_driver, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_clear, m)?)?; + m.add_function(wrap_pyfunction!(py_cache_reset, m)?)?; + Ok(()) +} diff --git a/crates/fusion-py/src/lib.rs b/crates/fusion-py/src/lib.rs index 800239d..7762cfc 100644 --- a/crates/fusion-py/src/lib.rs +++ b/crates/fusion-py/src/lib.rs @@ -14,18 +14,20 @@ use pyo3::types::{PyDict, PyModule, PyType}; use serde_json::Value as JsonValue; mod api_types; +mod cache; mod json; mod pagination; use api_types::{PyFusionBaseApi, clear_registry, mount_routes, register_route}; +use cache::register_cache; use json::{json_to_py, py_to_json}; use pagination::register_pagination; // ─── Settings (core) ───────────────────────────────────────────────────────── #[pyclass(name = "Settings")] -struct PySettings { - inner: Mutex, +pub(crate) struct PySettings { + pub(crate) inner: Mutex, } impl PySettings { @@ -522,6 +524,7 @@ fn _fusion(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(py_route_versions, m)?)?; m.add_function(wrap_pyfunction!(py_has_unversioned_routes, m)?)?; register_pagination(m)?; + register_cache(m)?; m.add("HTTP_METHODS", HTTP_METHODS)?; add_status_module(m)?; add_header_module(m)?; diff --git a/examples/cache.cs b/examples/cache.cs new file mode 100644 index 0000000..d39f24d --- /dev/null +++ b/examples/cache.cs @@ -0,0 +1,26 @@ +// Application cache demo (sync + async, default driver: moka). + +using FusionFramework; + +Cache.Reset(); +Cache.Set("greeting", new { hello = "world" }, ttlSeconds: 30); +Console.WriteLine($"get: {Cache.Get("greeting")}"); +Console.WriteLine($"exists: {Cache.Exists("greeting")}"); +Console.WriteLine($"getOrSet: {Cache.GetOrSet("counter", 1)}"); +Console.WriteLine($"existsOrSet (first): {Cache.ExistsOrSet("flag", true)}"); +Console.WriteLine($"existsOrSet (again): {Cache.ExistsOrSet("flag", false)}"); +Console.WriteLine($"deleteOrSet: {Cache.DeleteOrSet("greeting", new { hello = "fusion" })}"); +Console.WriteLine($"driver: {Cache.Driver()}"); +Cache.Clear(); +Console.WriteLine($"after clear: {Cache.Get("greeting")}"); + +await Cache.SetAsync("async-greeting", new { hello = "async" }, ttlSeconds: 30); +Console.WriteLine($"aget: {await Cache.GetAsync("async-greeting")}"); +Console.WriteLine( + $"agetOrSet: {await Cache.GetOrSetAsync("async-counter", async () => + { + await Task.Yield(); + return 1; + })}"); +await Cache.ClearAsync(); +Console.WriteLine($"after aclear: {await Cache.GetAsync("async-greeting")}"); diff --git a/examples/cache.mjs b/examples/cache.mjs new file mode 100644 index 0000000..7532e23 --- /dev/null +++ b/examples/cache.mjs @@ -0,0 +1,27 @@ +/** + * Application cache demo (sync + async, default driver: moka). + * + * node examples/cache.mjs + */ +import { cache } from 'fusion-framework' + +cache.configureDriver('moka', { defaultTtl: 60 }) +cache.set('greeting', { hello: 'world' }, 30) +console.log('get:', cache.get('greeting')) +console.log('exists:', cache.exists('greeting')) +console.log('getOrSet:', cache.getOrSet('counter', () => 1)) +console.log('existsOrSet (first):', cache.existsOrSet('flag', true)) +console.log('existsOrSet (again):', cache.existsOrSet('flag', false)) +console.log('deleteOrSet:', cache.deleteOrSet('greeting', { hello: 'fusion' })) +console.log('driver:', cache.driver()) +cache.clear() +console.log('after clear:', cache.get('greeting')) + +await cache.aset('async-greeting', { hello: 'async' }, 30) +console.log('aget:', await cache.aget('async-greeting')) +console.log( + 'agetOrSet:', + await cache.agetOrSet('async-counter', async () => 1), +) +await cache.aclear() +console.log('after aclear:', await cache.aget('async-greeting')) diff --git a/examples/cache.py b/examples/cache.py new file mode 100644 index 0000000..879d69a --- /dev/null +++ b/examples/cache.py @@ -0,0 +1,45 @@ +"""Application cache demo (sync + async, default driver: moka). + + python examples/cache.py +""" + +from __future__ import annotations + +import asyncio + +from fusion_framework import cache + + +async def async_demo() -> None: + await cache.aset("async-greeting", {"hello": "async"}, ttl=30) + print("aget:", await cache.aget("async-greeting")) + print( + "aget_or_set:", + await cache.aget_or_set("async-counter", _async_one), + ) + await cache.aclear() + print("after aclear:", await cache.aget("async-greeting")) + + +async def _async_one() -> int: + await asyncio.sleep(0) + return 1 + + +def main() -> None: + cache.configure_driver("moka", default_ttl=60) + cache.set("greeting", {"hello": "world"}, ttl=30) + print("get:", cache.get("greeting")) + print("exists:", cache.exists("greeting")) + print("get_or_set:", cache.get_or_set("counter", lambda: 1)) + print("exists_or_set (first):", cache.exists_or_set("flag", True)) + print("exists_or_set (again):", cache.exists_or_set("flag", False)) + print("delete_or_set:", cache.delete_or_set("greeting", {"hello": "fusion"})) + print("driver:", cache.driver()) + cache.clear() + print("after clear:", cache.get("greeting")) + asyncio.run(async_demo()) + + +if __name__ == "__main__": + main() diff --git a/tests/csharp/FusionFramework.Tests/CacheTests.cs b/tests/csharp/FusionFramework.Tests/CacheTests.cs new file mode 100644 index 0000000..6464310 --- /dev/null +++ b/tests/csharp/FusionFramework.Tests/CacheTests.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; +using FusionFramework; +using Xunit; + +namespace FusionFramework.Tests; + +public class CacheTests +{ + public CacheTests() + { + Cache.Reset(); + } + + [Fact] + public void SetGetDeleteExists() + { + Assert.Null(Cache.Get("k")); + Cache.Set("k", new { n = 1 }); + Assert.True(Cache.Exists("k")); + Assert.Equal(1, Cache.Get("k")?["n"]?.GetValue()); + Assert.True(Cache.Delete("k")); + Assert.False(Cache.Exists("k")); + } + + [Fact] + public void GetOrSetExistsOrSetDeleteOrSet() + { + Assert.Equal(1, Cache.GetOrSet("counter", 1)?.GetValue()); + Assert.Equal(1, Cache.GetOrSet("counter", 99)?.GetValue()); + Assert.False(Cache.ExistsOrSet("flag", true)); + Assert.True(Cache.ExistsOrSet("flag", false)); + Assert.True(Cache.Get("flag")!.GetValue()); + Assert.Equal("next", Cache.DeleteOrSet("flag", "next")?.GetValue()); + Assert.Equal("moka", Cache.Driver()); + } + + [Fact] + public void ClearRemovesAll() + { + Cache.Set("a", 1); + Cache.Set("b", 2); + Cache.Clear(); + Assert.Null(Cache.Get("a")); + Assert.Null(Cache.Get("b")); + } + + [Fact] + public async Task AsyncSetGetClearAndGetOrSetFactory() + { + await Cache.SetAsync("async-k", new { ok = true }); + Assert.True(Cache.Get("async-k")?["ok"]?.GetValue()); + Assert.True(await Cache.ExistsAsync("async-k")); + + var calls = 0; + var first = await Cache.GetOrSetAsync("ax", async () => + { + calls += 1; + await Task.Yield(); + return new { v = calls }; + }); + Assert.Equal(1, first?["v"]?.GetValue()); + var second = await Cache.GetOrSetAsync("ax", async () => + { + calls += 1; + return new { v = calls }; + }); + Assert.Equal(1, second?["v"]?.GetValue()); + Assert.Equal(1, calls); + + await Cache.ClearAsync(); + Assert.Null(await Cache.GetAsync("async-k")); + } +} diff --git a/tests/node/unit/cache.test.js b/tests/node/unit/cache.test.js new file mode 100644 index 0000000..7f0bd41 --- /dev/null +++ b/tests/node/unit/cache.test.js @@ -0,0 +1,76 @@ +const { describe, it, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const { cache } = require('../helpers/load-fusion') + +describe('cache', () => { + beforeEach(() => { + cache.reset() + cache.configureDriver('moka', { defaultTtl: null }) + }) + + it('set/get/delete/exists', () => { + assert.equal(cache.get('k'), null) + cache.set('k', { n: 1 }) + assert.equal(cache.exists('k'), true) + assert.deepEqual(cache.get('k'), { n: 1 }) + assert.equal(cache.delete('k'), true) + assert.equal(cache.exists('k'), false) + }) + + it('getOrSet / existsOrSet / deleteOrSet', () => { + let calls = 0 + assert.deepEqual( + cache.getOrSet('x', () => { + calls += 1 + return { v: calls } + }), + { v: 1 }, + ) + assert.deepEqual( + cache.getOrSet('x', () => { + calls += 1 + return { v: calls } + }), + { v: 1 }, + ) + assert.equal(calls, 1) + assert.equal(cache.existsOrSet('f', true), false) + assert.equal(cache.existsOrSet('f', false), true) + assert.equal(cache.get('f'), true) + assert.equal(cache.deleteOrSet('f', 'next'), 'next') + assert.equal(cache.driver(), 'moka') + }) + + it('clear removes all keys', () => { + cache.set('a', 1) + cache.set('b', 2) + cache.clear() + assert.equal(cache.get('a'), null) + assert.equal(cache.get('b'), null) + }) + + it('async aset/aget/aclear and agetOrSet', async () => { + await cache.aset('async-k', { ok: true }) + assert.deepEqual(await cache.aget('async-k'), { ok: true }) + assert.equal(await cache.aexists('async-k'), true) + let calls = 0 + assert.deepEqual( + await cache.agetOrSet('ax', async () => { + calls += 1 + return { v: calls } + }), + { v: 1 }, + ) + assert.deepEqual( + await cache.agetOrSet('ax', async () => { + calls += 1 + return { v: calls } + }), + { v: 1 }, + ) + assert.equal(calls, 1) + await cache.aclear() + assert.equal(await cache.aget('async-k'), null) + }) +}) diff --git a/tests/python/unit/test_cache.py b/tests/python/unit/test_cache.py new file mode 100644 index 0000000..00a6634 --- /dev/null +++ b/tests/python/unit/test_cache.py @@ -0,0 +1,107 @@ +"""Unit tests for the process-wide Fusion cache (moka).""" + +from __future__ import annotations + +import asyncio + +from fusion_framework import cache + + +def setup_function() -> None: + cache.reset() + cache.configure_driver("moka", default_ttl=None) + + +def test_set_get_delete_exists(): + assert cache.get("k") is None + cache.set("k", {"n": 1}) + assert cache.exists("k") + assert cache.get("k") == {"n": 1} + assert cache.delete("k") is True + assert cache.exists("k") is False + + +def test_get_or_set_callable(): + calls = {"n": 0} + + def factory(): + calls["n"] += 1 + return {"v": calls["n"]} + + assert cache.get_or_set("x", factory) == {"v": 1} + assert cache.get_or_set("x", factory) == {"v": 1} + assert calls["n"] == 1 + + +def test_exists_or_set_and_delete_or_set(): + assert cache.exists_or_set("f", True) is False + assert cache.exists_or_set("f", False) is True + assert cache.get("f") is True + assert cache.delete_or_set("f", "next") == "next" + assert cache.get("f") == "next" + + +def test_clear_removes_all(): + cache.set("a", 1) + cache.set("b", 2) + cache.clear() + assert cache.get("a") is None + assert cache.get("b") is None + + +def test_explicit_ttl_expires(): + cache.set("short", "x", ttl=0.05) + assert cache.exists("short") + import time + + time.sleep(0.08) + assert cache.get("short") is None + + +def test_omitted_ttl_stays_forever_with_null_default(): + cache.set("forever", "x") + import time + + time.sleep(0.05) + assert cache.get("forever") == "x" + + +def test_driver_is_moka(): + assert cache.driver() == "moka" + + +def test_mako_alias(): + cache.reset() + cache.configure_driver("mako", default_ttl=None) + assert cache.driver() == "moka" + + +def test_async_set_get_clear(): + async def body() -> None: + await cache.aset("async-k", {"ok": True}) + assert await cache.aget("async-k") == {"ok": True} + assert await cache.aexists("async-k") is True + await cache.aclear() + assert await cache.aget("async-k") is None + + asyncio.run(body()) + + +def test_async_get_or_set_async_factory(): + async def body() -> None: + calls = {"n": 0} + + async def factory(): + await asyncio.sleep(0) + calls["n"] += 1 + return {"v": calls["n"]} + + assert await cache.aget_or_set("ax", factory) == {"v": 1} + assert await cache.aget_or_set("ax", factory) == {"v": 1} + assert calls["n"] == 1 + assert await cache.aexists_or_set("flag", True) is False + assert await cache.aexists_or_set("flag", False) is True + assert await cache.adelete_or_set("flag", "next") == "next" + assert await cache.adelete("flag") is True + + asyncio.run(body())