diff --git a/.agents/README.md b/.agents/README.md index 80b4d28..4ab989a 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -6,18 +6,30 @@ Project-local skills for Cursor agents working on this repository. ``` .agents/skills//SKILL.md +.cursor/rules/*.mdc # always-on / scoped project rules ``` -Each skill teaches the agent domain-specific workflows for Fusion (Rust core + Python / Node / C# bindings). +Each skill teaches domain-specific workflows for Fusion (Rust core + Python / Node / C# bindings) and the companion `fusion` CLI. ## Available skills | Skill | Use when | |-------|----------| -| `fusion-architecture` | Understanding repo layout, binding layers, where logic belongs | -| `fusion-bindings-parity` | Changing behavior that must stay aligned across Python, Node, C# | -| `fusion-http-routes` | Routes, `@http_get` / `[HttpGet]`, `[module]`, `[action]`, Swagger | +| `fusion-architecture` | Repo layout, binding layers, where logic belongs | +| `fusion-bindings-parity` | Feature must land in Python **and** Node **and** C# | +| `fusion-coding-standards` | Comments, tests preference, git staging, skill/doc hygiene | +| `fusion-cli` | `fusion init` / commands / scaffold tree / CLI ↔ framework | +| `fusion-http-routes` | Routes, `http_get` / `[HttpGet]`, `[module]`, `[action]`, Swagger | | `fusion-release` | Version bumps, manifests, publish prep | -| `fusion-testing` | Running checks and binding-specific tests | +| `fusion-testing` | Running checks; investigating failed tests | +| `fusion-cache` | Application cache (moka default; Redis later) | +| `fusion-background-tasks` | Tokio spawn / cancel / status / snapshot | +| `fusion-template-forms` | Template `form` / `ok` / `fail` + SPA `data-fusion-form` | -Skills are loaded when the task matches the skill description (or when you name the skill explicitly). +## Always-on rules + +`.cursor/rules/fusion-engineering.mdc` applies every session: parity across bindings, **examples in all three languages for new features**, function comments, prefer tests, never `git add .`, investigate failures, update skills when needed. + +## Hygiene + +When you add a new concept agents must remember, either extend an existing skill or add `.agents/skills//SKILL.md` and a row in this table. diff --git a/.agents/skills/fusion-architecture/SKILL.md b/.agents/skills/fusion-architecture/SKILL.md index 4f85506..8efcc50 100644 --- a/.agents/skills/fusion-architecture/SKILL.md +++ b/.agents/skills/fusion-architecture/SKILL.md @@ -1,9 +1,10 @@ --- name: fusion-architecture description: >- - Explains Fusion Framework repository layout, crate boundaries, and where - new logic belongs (fusion-core vs Python/Node/C# bindings). Use when - navigating the codebase, adding features, or deciding which layer to change. + Explains Fusion Framework repository layout, crate boundaries, where new + logic belongs (fusion-core vs Python/Node/C# bindings), tests layout, and + relationship to the fusion CLI. Use when navigating the codebase, adding + features, or deciding which layer to change. --- # Fusion Architecture @@ -12,18 +13,24 @@ description: >- | Path | Role | |------|------| -| `crates/fusion-core/` | Shared Rust: naming, route tokens, HTTP conventions | +| `crates/fusion-core/` | Shared Rust: naming, route tokens, HTTP conventions, settings helpers | | `crates/fusion-py/` | Python binding (PyO3) + `python/fusion_framework/` package | | `crates/fusion-node/` | Node binding (`index.js`, N-API) | -| `bindings/csharp/FusionFramework/` | C# binding (source of truth for NuGet layout) | +| `crates/fusion-ffi/` | C ABI for the C# binding | +| `bindings/csharp/FusionFramework/` | C# binding (NuGet layout source of truth) | +| `tests/` | Executable tests (Python / Node / C#) — not inside installable packages | | `examples/` | Runnable samples per binding | -| `scripts/` | Release tooling (`set-version.sh`) | +| `scripts/` | Dev install, version bumps (`set-version.sh`) | +| `.agents/skills/` | Agent skills for this repo | + +**Related external repo:** [fusion-tool](https://github.com/cipherunits/fusion-tool) — `fusion` CLI that scaffolds apps. See `fusion-cli` skill. ## Layering rules 1. **Put shared semantics in `fusion-core`** — route token resolution (`[module]`, `[action]`), path joining, handler naming. Bindings should call Rust helpers via FFI where possible. 2. **Bindings mirror behavior** — Python decorators, Node functions, C# attributes must produce the same mount paths and OpenAPI shapes. 3. **Do not duplicate business logic in three languages** — only binding-specific glue (decorators, reflection, module registration). +4. **Tests live under `tests/`** — do not add `test_*.py` inside `fusion_framework/` package sources. ## Key entry points @@ -33,6 +40,9 @@ description: >- ## When adding a feature -1. Identify if it is cross-binding (yes → start in `fusion-core`). -2. Implement mount + OpenAPI in all three bindings in one PR when possible. -3. Add or extend an example under `examples/`. +1. Identify if it is cross-binding (yes → start in `fusion-core` when semantics are shared). +2. Implement in **Python, Node, and C#** in one change set (see `fusion-bindings-parity`). +3. Add tests under `tests/` (preferred) and/or Rust unit tests. +4. Add **usage examples in all three languages** under `examples/` (`.py` / `.mjs` / `.cs`) so the API shape is visible. +5. If scaffolds or env JSON contracts change, update the `fusion-cli` skill and consider fusion-tool templates. +6. Comment new functions; document dense logic (see `fusion-coding-standards`). diff --git a/.agents/skills/fusion-background-tasks/SKILL.md b/.agents/skills/fusion-background-tasks/SKILL.md new file mode 100644 index 0000000..07945f8 --- /dev/null +++ b/.agents/skills/fusion-background-tasks/SKILL.md @@ -0,0 +1,63 @@ +--- +name: fusion-background-tasks +description: >- + Documents Fusion process-wide Tokio background tasks (spawn, spawn_after, + cancel, status, snapshot) across Python, Node, and C#. Use when scheduling + fire-and-forget or delayed work off the request path, or when inspecting tasks + via the Fusion monitor panel. +--- + +# Fusion background tasks + +In-process jobs on a dedicated **Tokio** multi-thread runtime in `fusion-core`. +Not a durable queue (no Redis/persistence). + +## API + +| Python | Node | C# | +|--------|------|-----| +| `tasks.spawn(fn)` | `tasks.spawn(fn)` | `BackgroundTasks.Spawn(action)` | +| `tasks.spawn_after(ms, fn)` | `tasks.spawnAfter(ms, fn)` | `BackgroundTasks.SpawnAfter(ms, action)` | +| `tasks.cancel(id)` | `tasks.cancel(id)` | `BackgroundTasks.Cancel(id)` | +| `tasks.status(id)` | `tasks.status(id)` | `BackgroundTasks.Status(id)` | +| `tasks.snapshot()` | `tasks.snapshot()` | `BackgroundTasks.Snapshot()` | +| `tasks.reset()` | `tasks.reset()` | `BackgroundTasks.Reset()` | + +Status values: `pending` | `running` | `done` | `cancelled` | `failed`. + +`snapshot()` returns `{ task_count, active_count, tasks: [{ id, status, delay_ms, created_at_ms }] }`. +Terminal tasks are pruned (keep last 100) so the registry cannot grow forever. + +### Pass a callable + +```python +# Correct — defer the call: +tasks.spawn(lambda: test_task(name)) + +# Wrong — calls test_task immediately and passes None: +# tasks.spawn(test_task(name)) # TypeError: callback must be callable +``` + +## Fusion monitor + +When `monitor.enabled` is true, the Fusion monitor HTML and `{path}/json` embed the +task list under `tasks` / a **Background tasks** card. Settings live under top-level +`monitor.*` (not under `cache.monitor`). + +## Notes + +- Callbacks may run on Tokio worker threads (Python holds the GIL only for the call). +- Tasks are process-wide and outlive HTTP requests. +- Cancel before run aborts the delay; host userdata is freed (C# GCHandle). + +## Examples + +`examples/background_tasks.py` / `.mjs` / `.cs` +`examples/monitor.*` (spawns sample tasks for the panel) + +## Implementation + +- Core: `crates/fusion-core/src/tasks.rs` +- Python: `fusion_framework.tasks` +- Node: `tasks` export +- C#: `BackgroundTasks` + FFI diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md index b050822..1416d40 100644 --- a/.agents/skills/fusion-bindings-parity/SKILL.md +++ b/.agents/skills/fusion-bindings-parity/SKILL.md @@ -2,20 +2,39 @@ name: fusion-bindings-parity description: >- Keeps Python, Node, and C# Fusion bindings aligned when changing APIs, routes, - Swagger, or middleware. Use when editing more than one binding or adding - cross-language behavior. + Swagger, middleware, or permissions. Use when editing more than one binding + or when the user asks to add a feature (always implement all three languages + unless they limit scope). --- # Bindings Parity +## Hard rule + +If the user says “add X” (permissions, middleware, route option, Swagger behavior, settings key, etc.) and X is framework surface area, implement it for: + +1. **Python** +2. **Node** +3. **C#** + +in the **same** change set unless they explicitly say “only Python” (or only one binding). + +Do not leave one language behind “for later” without saying so and getting confirmation. + ## Checklist (every cross-binding change) - [ ] `fusion-core` updated if semantics are shared -- [ ] Python: `fusion_framework/` + `crates/fusion-py/src/api_types.rs` -- [ ] Node: `crates/fusion-node/index.js` +- [ ] Python: `fusion_framework/` + `crates/fusion-py/src/api_types.rs` as needed +- [ ] Node: `crates/fusion-node/index.js` (+ `index.d.ts` if public types change) - [ ] C#: `bindings/csharp/FusionFramework/*.cs` -- [ ] Example snippet in `examples/` (at least one runnable file + others documented) +- [ ] Tests under `tests/python/`, `tests/node/`, and/or `tests/csharp/` when behavior is testable +- [ ] **Examples in all three languages** under `examples/` (`.py`, `.mjs`, `.cs`) showing how to use the new API - [ ] README in C# binding updated if public API changed +- [ ] Skills/docs updated if agents need new knowledge (`fusion-cli`, `fusion-http-routes`, …) + +## Examples rule + +New public surface → show usage in **Python + Node + C#**. Prefer the same basename for the trio (see `custom_http_routes.*`, `pagination.*`). Examples should be short and runnable enough to see the API shape, not full apps. ## Parity matrix @@ -24,8 +43,16 @@ description: >- | Module route | `@route("/api/[module]")` | `route('/api/[module]')(Cls)` | `[Route("/api/[module]")]` | | 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()` | +| Cache | `fusion_framework.cache` (moka) | `cache` export | `Cache` | +| Fusion monitor | `monitor.mount_monitor` | `mountMonitor` | `FusionMonitor` | +| Background tasks | `fusion_framework.tasks` (+ snapshot; in monitor) | `tasks` export | `BackgroundTasks` | +| 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 | +| Template routes | omit from OpenAPI | omit | omit | +| Template forms | `form` / `ok` / `fail` + `data-fusion-form` | same | `Form` / `Ok` / `Fail` | ## Verification commands @@ -34,10 +61,13 @@ cargo test -p fusion-core naming cargo check -p fusion-py node --check crates/fusion-node/index.js dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj -python -m pytest crates/fusion-py/python/fusion_framework/test_http_route.py -q +./tests/scripts/run-python.sh -q +./tests/scripts/run-node.sh +./tests/scripts/run-csharp.sh -q ``` ## Style - Match existing naming in each language (snake_case Python, camelCase Node helpers, PascalCase C#). - Prefer minimal diffs; do not refactor unrelated binding code. +- Comment new exported helpers (see `fusion-coding-standards`). diff --git a/.agents/skills/fusion-cache/SKILL.md b/.agents/skills/fusion-cache/SKILL.md new file mode 100644 index 0000000..4140165 --- /dev/null +++ b/.agents/skills/fusion-cache/SKILL.md @@ -0,0 +1,118 @@ +--- +name: fusion-cache +description: >- + Documents Fusion application cache (default moka driver, Redis reserved), + settings under fusion..json, sync/async APIs, TTL rules, the optional + Fusion monitor panel (cache + tasks), 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, + "max_events": 50, + "connection_string": null, + "host": "127.0.0.1", + "port": 6379, + "username": null, + "password": null, + "db": 0 +}, +"monitor": { + "enabled": true, + "path": "/__fusion/monitor" +} +``` + +| Key | Purpose | +|-----|---------| +| `driver` | `moka` (default) or future `redis` | +| `max_capacity` | moka max entries | +| `default_ttl` | seconds, or **`null` = no expiry** unless code passes `ttl=` | +| `max_events` | Ring-buffer size for recent set/delete/clear events (legacy: `cache.monitor.max_events`) | +| `connection_string` / `host` / `port` / `username` / `password` / `db` | Redis connection (future) | +| `monitor.enabled` | Mount HTML + JSON monitor routes (dev scaffold: `true`; stage/prod: `false`) | +| `monitor.path` | UI path; JSON at `{path}/json` (default `/__fusion/monitor`) | + +When `monitor.enabled` is **false**, bindings must **not** register the monitor endpoints (security: disable routes, not only UI). Legacy `cache.monitor.enabled` / `cache.monitor.path` still work. + +The HTML panel and `{path}/json` include **cache entries**, **recent cache events**, and **background tasks**. + +### 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` | +| `cache.snapshot` | `cache.snapshot` | `Cache.Snapshot` | +| `cache.panel_context` | `cache.panelContext` | `Cache.PanelContext` | + +## 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` | + +Notes: + +- **clear** — drop all keys (keeps the cache instance); **reset** (tests) drops the global instance +- **snapshot** — entries + recent events + embedded `tasks` object (monitor JSON) +- **panel_context** — template vars for `fusion/monitor.html` (including task table) + +Values must be JSON-compatible. + +## Fusion monitor panel + +Built-in HTML panel auto-mounted on `listen` / `Mount` when `monitor.enabled` is true: + +- `GET {path}` — HTML (auto-refresh every 5s): cache entries, recent events, background tasks +- `GET {path}/json` — raw snapshot (includes top-level `tasks`) + +Scaffold (fusion-tool): **dev** `enabled: true`; **stage/prod** `enabled: false`. + +## Examples + +`examples/cache.py` / `.mjs` / `.cs` +`examples/monitor.py` / `.mjs` / `.cs` + +## Implementation + +- Core: `crates/fusion-core/src/cache.rs` + `monitor.rs` + `assets/templates/fusion/monitor.html` +- Python: `fusion_framework.cache` + `monitor.mount_monitor` +- Node: `cache` export + `mountMonitor` in `FusionApp.mount` +- C#: `Cache` + `FusionMonitor.Mount` via FFI diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md new file mode 100644 index 0000000..01a4024 --- /dev/null +++ b/.agents/skills/fusion-cli/SKILL.md @@ -0,0 +1,171 @@ +--- +name: fusion-cli +description: >- + Documents the Fusion Tool CLI (fusion init, command, module, add, update), + the scaffolded project tree, and how the CLI relates to this framework repo. + Use when explaining project layout, scaffolding, env JSON, or when framework + API changes must stay compatible with fusion-tool generators. +--- + +# Fusion CLI (fusion-tool) + +Apps are usually created with **Fusion Tool** (`fusion`), a separate repo: +https://github.com/cipherunits/fusion-tool + +This skill describes the CLI from the **framework** side so agents know what +generated projects look like and what must stay compatible. + +## Install & entry + +```bash +fusion --help +fusion --version +``` + +Binary name: `fusion`. Source of truth for generators: `fusion-tool` (`src/command/`, `src/setting/structure.rs`, `src/setting/environment.rs`). + +## Commands overview + +| Command | Purpose | +|---------|---------| +| `fusion init` | Scaffold a new Fusion app (Python / TypeScript / ASP.NET Core) | +| `fusion command ` | Run a named command from `fusion..json` | +| `fusion load-env` | Load `fusion..json` into the process environment | +| `fusion module init` | Scaffold a **publishable library package** (not an app route module) | +| `fusion add --github OWNER/REPO` | Vendor a module into the current app | +| `fusion update` | Self-update the CLI binary | + +### `fusion init` + +```bash +fusion init +fusion init my-app +fusion init --lang python --name myproject --description "…" +``` + +| Flag / arg | Values | +|------------|--------| +| `[DIRECTORY]` | Target dir (created if missing; default = cwd) | +| `--lang` | `python`, `typescript`, `asp-core` | +| `--name` | Project name | +| `--description` | Short description | + +Writes `fusion-framework.toml`, `fusion.{dev,stage,prod}.json`, `.gitignore`, language entrypoint, sample route module, templates, and dependency pins to the framework version. + +### `fusion command` + +Commands live under the `commands` object in `fusion..json`. + +```bash +fusion command run # default env: FUSION_ENV or `dev` +fusion command run --stage +fusion command run:stage # same +fusion command run --prod +fusion command run --env test +fusion command --stage # list commands for that env +``` + +Runs via the shell from the project root with `FUSION_ENV` set so `core/settings` loads the matching file. + +### Modules vs route modules + +| Term | Meaning | +|------|---------| +| Route module | App code: `FusionBaseApi` / template under `src/modules/…` | +| Library module | Separate package from `fusion module init` (`fusion.module.toml`), installed with `fusion add` | + +Do not confuse the two when naming APIs or writing docs. + +### `fusion module init` / `fusion add` + +```bash +fusion module init --lang python --name example --description "…" +fusion add --github OWNER/MODULE_NAME +fusion add --github OWNER/MODULE_NAME@v1.0.0 +``` + +Vendors under `.fusion/modules//` and records `[[modules]]` in `fusion-framework.toml`. + +## Scaffolded app layout (`fusion init`) + +Python shown; TypeScript/C# use the same tree with language extensions. + +```text +/ +├── core/ +│ └── settings.py # Overlay (RELOAD, TEMPLATES_DIR, …) +├── src/ +│ └── modules/ +│ └── products/ +│ └── products.py # HomePage (template) + ProductModule (API) +├── templates/ +│ └── home/ +│ ├── index.html +│ └── style.css +├── main.py # Register middleware + FusionApp.listen() +├── requirements.txt # Python pin (or package.json / *.csproj) +├── pyproject.toml # Python only +├── fusion-framework.toml # Project metadata + tool/framework versions +├── fusion.dev.json # env=dev, port 8080, swagger on, reload +├── fusion.stage.json # port 8081 +├── fusion.prod.json # port 9090 +└── .gitignore +``` + +TypeScript: `main.ts`, `core/settings.ts`, `package.json`, `tsconfig.json`. +C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`. + +### What the starter demonstrates + +- `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger). +- HTML forms: `form` / `ok` / `fail` + optional `data-fusion-form` (see skill `fusion-template-forms`). +- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (optional `page_size={10}` for client-side row pagination; styles: `{% include "fusion/components.css" %}`). +- `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. +- Cache monitor (`monitor.enabled`) is on in **dev** and off in **stage/prod**; when off, no monitor HTTP endpoints are registered. + +### Default ports + +| Env | Port | +|-----|------| +| dev | 8080 | +| stage | 8081 | +| prod | 9090 | + +### Environment JSON shape + +```json +{ + "env": "dev", + "config": { + "host": "127.0.0.1", + "port": 8080, + "debug": true, + "fingerprint": { "enabled": false }, + "swagger": { "enabled": true, "path": "/swagger" } + }, + "commands": { + "run": "python main.py" + } +} +``` + +`FUSION_ENV` selects `fusion..json` (default `dev`). Unresolved `HOST` placeholders must not crash listen — framework resolves safe defaults. + +## Compatibility duties (framework ↔ CLI) + +When changing public Fusion APIs used by scaffolds: + +1. Prefer keeping generated starter patterns working (or update **fusion-tool** templates in a follow-up / paired PR). +2. Do not invent decorators/config keys that only exist in one binding. +3. After middleware / route / settings changes, check whether `fusion-tool` `structure.rs` / `environment.rs` comments or defaults need updates. +4. Version pin in the CLI (`FUSION_FRAMEWORK_VERSION`) is separate from this repo’s version bump (`./scripts/set-version.sh`). + +## Related + +- Framework layout: `fusion-architecture` +- Binding alignment: `fusion-bindings-parity` +- Routes: `fusion-http-routes` +- CLI repo skills (if editing the tool itself): fusion-tool `.agents/skills/fusion-cli*` diff --git a/.agents/skills/fusion-coding-standards/SKILL.md b/.agents/skills/fusion-coding-standards/SKILL.md new file mode 100644 index 0000000..321c0ae --- /dev/null +++ b/.agents/skills/fusion-coding-standards/SKILL.md @@ -0,0 +1,82 @@ +--- +name: fusion-coding-standards +description: >- + Coding standards for Fusion Framework: function comments, clarifying + comments for complex code, preferred tests, failure investigation, and when + to update skills/docs. Use when writing or reviewing code in this repo. +--- + +# Coding standards + +## Comments + +### Functions / methods + +Every **new** public or non-trivial private function, method, or exported helper must have a one-line (or short) comment/docstring that states **what it does**. + +| Language | Prefer | +|----------|--------| +| Python | Docstring or `#` above `def` | +| JavaScript | `/** … */` or `//` above the function | +| C# | `/// ` or `//` above the member | +| Rust | `///` for public items; `//` for local helpers when non-obvious | + +Do not restate the name alone (`// get user` on `get_user`). Say the behavior or contract. + +### Dense / hard sections + +When code becomes branching-heavy, protocol-sensitive, or easy to break (OpenAPI fill, middleware chain, route slot mounting, FFI): + +- Add short **why** comments at the tricky points. +- Prefer extracting a named helper with a docstring over a wall of uncommented logic. + +## Tests (prefer writing them) + +When you add behavior: + +1. Prefer a test under `tests/python/`, `tests/node/`, `tests/csharp/`, or Rust `#[cfg(test)]`. +2. Mirror coverage across bindings when the feature is cross-binding (see `fusion-bindings-parity`). +3. Run the relevant script from `fusion-testing` before claiming done. + +### When a test fails + +1. Read the failure output (assertion, path, expected vs actual). +2. Trace to implementation (wrong name stripping, async not awaited, header case, version prefix, etc.). +3. Tell the user **what broke and why** in plain language. +4. Fix the code or the incorrect expectation — never silently weaken assertions without saying so. + +## Git + +- Stage **individual files only**. Never `git add .` / `git add -A`. +- Only commit when the user asks. + +## Docs & skills hygiene + +After introducing something agents or developers must know later: + +| Change type | Update | +|-------------|--------| +| New public API / middleware / route option | Binding parity + **examples in all three languages** (`examples/.py`, `.mjs`, `.cs`) + relevant skill | +| New test layout or runner | `tests/README.md` + `fusion-testing` | +| CLI-facing scaffold contract | `fusion-cli` skill; coordinate with fusion-tool if generators break | +| Entirely new workflow | New `.agents/skills//SKILL.md` + row in `.agents/README.md` | + +### Examples (required for new features) + +Always add side-by-side usage demos so humans/agents can see the API shape: + +```text +examples/.py +examples/.mjs +examples/.cs +``` + +Follow existing trios (`custom_http_routes.*`, `pagination.*`). Do not leave a language without an example when the feature exists in that binding. + +Keep skills concise; link to code paths instead of pasting large dumps. + +## Style reminders + +- Match existing naming (snake_case Python, camelCase Node, PascalCase C#). +- Minimal diffs; no drive-by refactors. +- Shared logic → `fusion-core` when possible. diff --git a/.agents/skills/fusion-http-routes/SKILL.md b/.agents/skills/fusion-http-routes/SKILL.md index 459cb06..b68186b 100644 --- a/.agents/skills/fusion-http-routes/SKILL.md +++ b/.agents/skills/fusion-http-routes/SKILL.md @@ -63,4 +63,8 @@ public class UserModule : FusionBaseApi { ## Tests - Rust: `cargo test -p fusion-core naming` -- Python: `test_http_route.py` +- Python: `pytest tests/python` +- Node: `./tests/scripts/run-node.sh` +- C#: `./tests/scripts/run-csharp.sh` + +Cross-binding route/Swagger changes must update **all three** languages (`fusion-bindings-parity`). diff --git a/.agents/skills/fusion-release/SKILL.md b/.agents/skills/fusion-release/SKILL.md index e3d92c7..f32d243 100644 --- a/.agents/skills/fusion-release/SKILL.md +++ b/.agents/skills/fusion-release/SKILL.md @@ -34,3 +34,5 @@ Updates (when present): - Keep all bindings on the **same version** for a release. - Do not commit `bin/`, `obj/`, or `.pdb` artifacts from local `dotnet build`. - Changelog/README updates only when the user requests documentation. +- Stage release files **individually** (never `git add .`). +- After a release that changes public APIs used by scaffolds, note whether fusion-tool’s `FUSION_FRAMEWORK_VERSION` / templates need a bump (see `fusion-cli`). diff --git a/.agents/skills/fusion-template-forms/SKILL.md b/.agents/skills/fusion-template-forms/SKILL.md new file mode 100644 index 0000000..0fc6d78 --- /dev/null +++ b/.agents/skills/fusion-template-forms/SKILL.md @@ -0,0 +1,54 @@ +--- +name: fusion-template-forms +description: >- + Documents FusionBaseTemplate form helpers (form, ok, fail), context vs get vs + post, and SPA-friendly data-fusion-form + fusion/form.js. Use when building + HTML forms that POST to the same page. +--- + +# Template forms (SPA-friendly) + +## Mental model + +| Method | Role | +|--------|------| +| `context()` | Template **data** (title, fields). Not an HTTP verb. | +| `get()` | **HTTP GET** — renders `context()` as HTML (or JSON if `Accept: application/json`). Rarely override. | +| `post()` | **HTTP POST** — read `form`, validate with normal `if`, return `ok` / `fail`. | + +## API + +| Python | Node | C# | +|--------|------|-----| +| `self.form` | `this.form` | `Form` | +| `self.fail(errors, message=..., **fields)` | `this.fail(errors, { message, ...fields })` | `Fail(errors, message, fields)` | +| `self.ok(message=..., **fields)` | `this.ok({ message, ...fields })` | `Ok(message, fields)` | + +- JSON clients (`Accept: application/json` or SPA fetch) get `{ ok, message, errors, fields }`. +- HTML clients re-render the **same** template with errors/fields (no separate “success page” required). + +## SPA markup + +```html +
+
+ + + +
+ +``` + +Built-in script lives at `fusion/form.js` (embedded in fusion-core). Without JS, classic POST still works via `ok`/`fail` HTML path. + +## Example + +```python +def post(self): + form = self.form + if not form.get("phone"): + return self.fail({"phone": "required"}, **form) + return self.ok(message="Saved.", **form) +``` + +See `examples/template_form.{py,mjs,cs}`. diff --git a/.agents/skills/fusion-testing/SKILL.md b/.agents/skills/fusion-testing/SKILL.md index 8d8ad4c..5a8a07e 100644 --- a/.agents/skills/fusion-testing/SKILL.md +++ b/.agents/skills/fusion-testing/SKILL.md @@ -2,20 +2,63 @@ name: fusion-testing description: >- Runs Fusion Framework verification across Rust, Python, Node, and C#. Use - after code changes, before commits, or when CI-like validation is needed - locally. + after code changes, before commits, when CI-like validation is needed, or + when tests fail and need root-cause investigation. --- # Testing & Verification +## Prefer writing tests + +New behavior should land with tests when practical: + +| Layer | Where | +|-------|--------| +| Rust shared logic | `#[cfg(test)]` in `crates/fusion-core` (and related crates) | +| Python | `tests/python/unit/` (pytest) | +| Node | `tests/node/unit/*.test.js` (`node --test`) | +| C# | `tests/csharp/FusionFramework.Tests/` (xUnit) | + +Do **not** put `test_*.py` under `crates/fusion-py/python/fusion_framework/`. + +## Full suite (recommended) + +```bash +./tests/scripts/run-all.sh +``` + ## Quick smoke (after route/API changes) ```bash cargo test -p fusion-core naming cargo check -p fusion-py node --check crates/fusion-node/index.js -dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj --no-restore 2>/dev/null || dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj -python -m pytest crates/fusion-py/python/fusion_framework/test_http_route.py -q +dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj +./tests/scripts/run-python.sh -q +``` + +## Python (pytest) + +```bash +./scripts/dev-install-python.sh --venv .venv +source .venv/bin/activate +pytest +./tests/scripts/run-python.sh +pytest tests/python/unit/test_http_route.py -q +``` + +## Node + +```bash +cd crates/fusion-node && npm install && npm run build:debug +./tests/scripts/run-node.sh +``` + +## C# + +```bash +./tests/scripts/run-csharp.sh +# builds fusion-ffi then: dotnet test … -c Release ``` ## Full Rust workspace @@ -29,18 +72,27 @@ cargo test --workspace | Binding | Command | |---------|---------| -| Python package | `cd crates/fusion-py && maturin develop` (if building extension) | -| Node | `node --check crates/fusion-node/index.js` | -| C# | `dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj` | +| Python | `pytest tests/python` (after `dev-install-python.sh`) | +| Node | `./tests/scripts/run-node.sh` | +| C# | `./tests/scripts/run-csharp.sh` | + +## Layout + +See `tests/README.md` for folder structure and conventions. + +## When tests fail (required) + +1. **Investigate** — read assertion, stack, expected vs actual. +2. **Explain** — tell the user the root cause (e.g. class name → `[module]` stripping, async middleware not awaited, CORS header casing, version prefix missing). +3. **Fix** — correct implementation or wrong expectation; do not hide failures. +4. Order of suspicion for route/OpenAPI issues: + - `fusion-core` naming + - Python registry (`api_types.rs`) as reference + - Node / C# mount + OpenAPI fill ## What to exclude from git - `bindings/csharp/**/bin/`, `obj/` -- `target/`, `node_modules/`, `__pycache__/` +- `target/`, `node_modules/`, `__pycache__/`, `.pytest_cache/` - Local `.pdb` changes from debug builds - -## When tests fail - -1. Fix `fusion-core` first if naming/route tests fail. -2. Then fix Python registry (`api_types.rs`) — often the reference implementation. -3. Align Node and C# mount/OpenAPI with Python behavior. +- Scratch `.nuget/` / `.tmp/` caches if created locally diff --git a/.cursor/rules/fusion-engineering.mdc b/.cursor/rules/fusion-engineering.mdc new file mode 100644 index 0000000..2f4c2c8 --- /dev/null +++ b/.cursor/rules/fusion-engineering.mdc @@ -0,0 +1,53 @@ +--- +description: Core Fusion Framework engineering rules — parity, comments, tests, git, docs +alwaysApply: true +--- + +# Fusion engineering (always) + +## Cross-binding parity + +When the user asks to add or change a framework feature (routes, middleware, permissions, Swagger, settings, etc.), implement it in **all three** bindings in the same change set unless they explicitly limit scope: + +- Python (`crates/fusion-py/`) +- Node (`crates/fusion-node/`) +- C# (`bindings/csharp/FusionFramework/`) + +Shared semantics belong in `crates/fusion-core/` first. Follow the `fusion-bindings-parity` skill. + +## Examples (every new public feature) + +Whenever a **new** user-facing feature or API is added (middleware, route options, permissions, pagination helpers, settings keys, etc.), add **usage examples in all three languages** under `examples/` so the shape is visible side by side: + +- Python: `examples/.py` +- Node: `examples/.mjs` +- C#: `examples/.cs` (or a small folder if a project is required) + +Match existing naming (`custom_http_routes.py` / `.mjs` / `.cs`, `pagination.py` / `.mjs` / `.cs`). Extend an existing trio when the feature fits; otherwise create a new trio. Do not ship Python-only demos for cross-binding APIs. + +## Comments + +- Every **new or meaningfully changed** function/method must have a short comment or docstring stating what it does. +- When logic is dense, non-obvious, or branched, add inline comments that explain **why**, not just what the syntax does. + +## Tests + +- Prefer adding or updating tests for new behavior under `tests/` (Python / Node / C#) or `#[cfg(test)]` in Rust. +- If a test fails: **investigate**, report the root cause clearly, then fix. Do not ignore or skip without explaining why. + +## Git staging + +- **Never** run `git add .` or `git add -A` / `git add --all`. +- Stage files **one path at a time** (`git add path/to/file`). + +## Documentation & skills + +When you add a user-facing or agent-facing concept (new CLI-related behavior, new API surface, new test layout, new workflow): + +- Update the relevant skill under `.agents/skills/` and/or `.agents/README.md`. +- Add a new skill if no existing one covers it. +- Keep framework docs/examples aligned when public API changes. + +## Related skills + +Read when relevant: `fusion-architecture`, `fusion-bindings-parity`, `fusion-http-routes`, `fusion-testing`, `fusion-cli`, `fusion-coding-standards`, `fusion-release`. diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md index 06f4a79..87aa99d 100644 --- a/.github/PUBLISHING.md +++ b/.github/PUBLISHING.md @@ -1,16 +1,26 @@ # Secrets & publishing -Release by pushing a version tag: +## Branch flow + +- **`dev`** — day-to-day development; the `CI` workflow (lint + tests) runs on push and PRs. +- **`main`** — release branch; merge `dev` → `main` after bumping versions with `scripts/set-version.sh`. +- **`publish.yml`** — runs on push to `main` and on `v*` tags; publishes PyPI, npm, and NuGet packages. + +## Release + +Bump versions, merge to `main`, or push a version tag: ```bash -git tag v0.1.0 -git push origin v0.1.0 +./scripts/set-version.sh 1.2.7 +git tag v1.2.7 +git push origin main --tags ``` -That triggers: +That triggers `publish.yml`, which calls: - `publish-pypi.yml` → PyPI package `fusion-framework` - `publish-npm.yml` → npm package `fusion-framework` +- `publish-nuget.yml` → NuGet package `Fusion-Framework` ## Required GitHub configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f842774..c3aab36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ +# CI runs on `dev` only. Feature branches open PRs against `dev`; releases merge `dev` → `main` +# (see publish.yml). `main` does not run this workflow. + name: CI on: push: - branches: [master, main] + branches: [dev] pull_request: + branches: [dev] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -13,8 +17,56 @@ env: CARGO_TERM_COLOR: always jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: rustfmt + run: cargo fmt --all -- --check + + - name: clippy + # fusion-ffi uses raw FFI pointers; tighten to `-D warnings` once FFI lints are addressed. + run: cargo clippy --workspace --all-targets + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: python -m pip install ruff + + - name: ruff check + run: ruff check crates/fusion-py/python tests/python + + - name: ruff format + run: ruff format --check crates/fusion-py/python tests/python + + # Node binding is a thin CommonJS wrapper over N-API; no eslint config in this repo. + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: node syntax check + run: node --check crates/fusion-node/index.js + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: dotnet format + run: dotnet format --verify-no-changes bindings/csharp/FusionFramework/FusionFramework.csproj + rust: name: Rust workspace + needs: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -27,6 +79,7 @@ jobs: python: name: Python wheel (${{ matrix.os }}) + needs: lint strategy: fail-fast: false matrix: @@ -47,10 +100,13 @@ jobs: shell: bash run: | pip install dist/*.whl + pip install pytest python -c "from fusion_framework.api import FusionBaseApi; from fusion_framework.route import router; from fusion_framework import settings; print('ok')" + pytest tests/python -q node: name: Node addon (${{ matrix.settings.target }}) + needs: lint strategy: fail-fast: false matrix: @@ -76,14 +132,16 @@ jobs: - name: Install deps run: npm install - name: Build native addon - run: npx napi build --platform --release --target ${{ matrix.settings.target }} + run: npx napi build --platform --release --js false --target ${{ matrix.settings.target }} - name: Smoke require shell: bash run: | node -e "const f=require('./index.js'); if(!f.FusionApp||!f.status) throw new Error('smoke failed'); console.log('ok', f.status.HTTP_SUCCESS)" + node --test ../../tests/node/unit/*.test.js csharp: name: C# package + needs: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -95,3 +153,5 @@ jobs: run: cargo build -p fusion-ffi --release - name: Build FusionFramework run: dotnet build -c Release bindings/csharp/FusionFramework/FusionFramework.csproj + - name: Test C# bindings + run: dotnet test tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj -c Release diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 85ea2a0..bc94d5f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,9 +1,7 @@ name: Publish npm on: - push: - tags: - - "v*" + workflow_call: workflow_dispatch: # Trusted Publishing via OIDC — no NPM_TOKEN secret needed. @@ -25,18 +23,18 @@ jobs: settings: - host: ubuntu-latest target: x86_64-unknown-linux-gnu - build: npx napi build --platform --release --target x86_64-unknown-linux-gnu + build: npx napi build --platform --release --js false --target x86_64-unknown-linux-gnu # Native arm runner — avoids --use-napi-cross (napi-cli v3 only; we pin v2). - host: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - build: npx napi build --platform --release --target aarch64-unknown-linux-gnu + build: npx napi build --platform --release --js false --target aarch64-unknown-linux-gnu - host: windows-latest target: x86_64-pc-windows-msvc - build: npx napi build --platform --release --target x86_64-pc-windows-msvc + build: npx napi build --platform --release --js false --target x86_64-pc-windows-msvc # macos-latest = Apple Silicon (arm64). Prefer over macos-13 (Intel), which is slower / longer queues. - host: macos-latest target: aarch64-apple-darwin - build: npx napi build --platform --release --target aarch64-apple-darwin + build: npx napi build --platform --release --js false --target aarch64-apple-darwin steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 9a3f026..4415d80 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -1,9 +1,7 @@ name: Publish NuGet on: - push: - tags: - - "v*" + workflow_call: workflow_dispatch: # Trusted Publishing via OIDC — no NUGET_API_KEY secret needed. diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index d4e1bb6..38e6395 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,9 +1,7 @@ name: Publish PyPI on: - push: - tags: - - "v*" + workflow_call: workflow_dispatch: # Trusted Publishing via OIDC — no PYPI_API_TOKEN secret needed. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5e1ee7c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +# Release flow: merge `dev` → `main` after bumping versions with scripts/set-version.sh. +# This workflow publishes PyPI, npm, and NuGet packages when code lands on `main` or on `v*` tags. +# Tag pushes use the tag for package versions; `main` pushes use manifest versions as-is. +# CI (lint + tests) runs on `dev` only — see ci.yml. + +name: Publish + +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +concurrency: + group: publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + pypi: + uses: ./.github/workflows/publish-pypi.yml + secrets: inherit + + npm: + uses: ./.github/workflows/publish-npm.yml + secrets: inherit + + nuget: + uses: ./.github/workflows/publish-nuget.yml + secrets: inherit + + # TODO: enable when crates.io publishing and API token / trusted publishing are configured. + crates-io: + if: false + runs-on: ubuntu-latest + steps: + - run: echo "TODO cargo publish workspace crates (fusion-core is not published yet)" diff --git a/.gitignore b/.gitignore index 8417651..274df90 100644 --- a/.gitignore +++ b/.gitignore @@ -5,39 +5,82 @@ target/ ## Python .venv/ venv/ +.env/ +.env.* +!.env.example .pydeps/ __pycache__/ *.py[cod] +*$py.class .pytest_cache/ .mypy_cache/ - -## Wheels / build artifacts +.ruff_cache/ +.coverage +htmlcov/ +*.egg-info/ +.eggs/ dist/ +build/ .wheel_tmp/ -## Node +## Node.js node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +.pnpm-store/ -## IDE / OS -.idea/ -.vscode/ -.DS_Store - -## Maturin / PyO3 generated native libs (built output) -**/_fusion*.so +## Native addons (built locally; CI publishes platform binaries) **/*.node +**/_fusion*.so **/*.so **/*.dylib **/*.dll -## JavaScript / TypeScript build output (if any) -build/ -coverage/ -.nyc_output/ +## C# / .NET +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.user +*.userosscache +*.suo +*.sln.docstates +*.rsuser +*.DotSettings.user +.vs/ +*.nupkg +*.snupkg +project.lock.json +*.pfx +*.publishsettings -## NuGet +## NuGet output (release packages) nupkg/ bindings/csharp/**/runtimes/ + +## JavaScript / TypeScript tooling +coverage/ +.nyc_output/ + +## IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +Desktop.ini + +## Local secrets / overrides (keep examples/*.json samples in repo) +fusion.local.json +*.local.json + + +.tmp +.nuget +.sentry-native \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 9bafb2f..fbd8e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -35,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" @@ -84,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" @@ -124,7 +168,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "fusion-core" -version = "1.2.6" +version = "2.0.0" dependencies = [ "bytes", "console", @@ -132,13 +176,15 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "moka", "serde_json", + "tera", "tokio", ] [[package]] name = "fusion-ffi" -version = "1.2.6" +version = "2.0.0" dependencies = [ "bytes", "fusion-core", @@ -148,7 +194,7 @@ dependencies = [ [[package]] name = "fusion-node" -version = "1.2.6" +version = "2.0.0" dependencies = [ "fusion-core", "napi", @@ -161,7 +207,7 @@ dependencies = [ [[package]] name = "fusion-py" -version = "1.2.6" +version = "2.0.0" dependencies = [ "bytes", "fusion-core", @@ -207,6 +253,30 @@ dependencies = [ "futures-core", "futures-task", "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "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]] @@ -364,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" @@ -415,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" @@ -529,6 +627,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pyo3" version = "0.25.1" @@ -600,6 +704,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +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" @@ -783,12 +899,30 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tera" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52fca06a22977165c6821c26e2bf0387cd7e0822107a6854d5fbb787307c4194" +dependencies = [ + "ahash", + "itoa", + "pulldown-cmark-escape", + "serde", +] + [[package]] name = "tokio" version = "1.53.1" @@ -892,6 +1026,23 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -907,6 +1058,60 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +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" @@ -951,6 +1156,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 45e4f4c..578a400 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "1.2.6" +version = "2.0.0" edition = "2024" [workspace.dependencies] diff --git a/bindings/csharp/FusionFramework/BackgroundTasks.cs b/bindings/csharp/FusionFramework/BackgroundTasks.cs new file mode 100644 index 0000000..cde115c --- /dev/null +++ b/bindings/csharp/FusionFramework/BackgroundTasks.cs @@ -0,0 +1,87 @@ +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; + +namespace FusionFramework; + +/// Process-wide Tokio background tasks (Python/Node parity). +public static class BackgroundTasks +{ + // Must match Native.FusionTask* delegates for P/Invoke marshalling. + static readonly Native.FusionTaskCallback InvokeAction = static userData => + { + var handle = GCHandle.FromIntPtr(userData); + if (handle.Target is Action action) + action(); + }; + + static readonly Native.FusionTaskDataFree FreeHandle = static userData => + { + var handle = GCHandle.FromIntPtr(userData); + if (handle.IsAllocated) + handle.Free(); + }; + + /// Run on the Tokio background runtime. + public static string Spawn(Action action) + { + ArgumentNullException.ThrowIfNull(action); + var gch = GCHandle.Alloc(action); + var ptr = Native.fusion_task_spawn( + InvokeAction, + GCHandle.ToIntPtr(gch), + FreeHandle); + var id = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(id)) + throw new InvalidOperationException("task spawn failed"); + return id; + } + + /// Run after milliseconds. + public static string SpawnAfter(ulong delayMs, Action action) + { + ArgumentNullException.ThrowIfNull(action); + var gch = GCHandle.Alloc(action); + var ptr = Native.fusion_task_spawn_after( + delayMs, + InvokeAction, + GCHandle.ToIntPtr(gch), + FreeHandle); + var id = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(id)) + throw new InvalidOperationException("task spawn_after failed"); + return id; + } + + /// Cancel a pending/running task. + public static bool Cancel(string taskId) + { + var code = Native.fusion_task_cancel(taskId); + if (code < 0) + throw new ArgumentException("invalid task id", nameof(taskId)); + return code == 1; + } + + /// Status string, or null if unknown. + public static string? Status(string taskId) + { + var ptr = Native.fusion_task_status(taskId); + if (ptr == IntPtr.Zero) + return null; + return Native.TakeUtf8(ptr); + } + + /// JSON snapshot of tracked tasks (also under ). + public static JsonNode Snapshot() + { + var ptr = Native.fusion_task_snapshot(); + if (ptr == IntPtr.Zero) + throw new InvalidOperationException("task snapshot failed"); + var json = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(json)) + throw new InvalidOperationException("empty task snapshot"); + return JsonNode.Parse(json) ?? new JsonObject(); + } + + /// Abort and clear all tracked tasks (tests). + public static void Reset() => Native.fusion_task_reset(); +} diff --git a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs new file mode 100644 index 0000000..7eb45f3 --- /dev/null +++ b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs @@ -0,0 +1,17 @@ +namespace FusionFramework; + +/// Convenience aliases for built-in middleware factories used in scaffolded apps. +public static class BuiltinMiddleware +{ + public static FusionMiddleware FrameworkHeaders() => Middleware.FrameworkHeaders(); + public static FusionMiddleware SecurityHeaders() => Middleware.SecurityHeaders(); + 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/Cache.cs b/bindings/csharp/FusionFramework/Cache.cs new file mode 100644 index 0000000..03a7616 --- /dev/null +++ b/bindings/csharp/FusionFramework/Cache.cs @@ -0,0 +1,196 @@ +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(); + + /// JSON snapshot of live entries and recent mutations (monitor). + public static JsonNode Snapshot() + { + Ensure(); + var ptr = Native.fusion_cache_snapshot(); + var json = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(json)) + throw new InvalidOperationException("cache snapshot failed"); + return JsonNode.Parse(json) ?? new JsonObject(); + } + + /// Template context for the built-in fusion/cache_monitor.html panel. + public static JsonNode PanelContext() + { + Ensure(); + var ptr = Native.fusion_cache_panel_context(); + var json = Native.TakeUtf8(ptr); + if (string.IsNullOrEmpty(json)) + throw new InvalidOperationException("cache panel_context failed"); + return JsonNode.Parse(json) ?? new JsonObject(); + } + + /// 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/CacheMonitor.cs b/bindings/csharp/FusionFramework/CacheMonitor.cs new file mode 100644 index 0000000..0861b07 --- /dev/null +++ b/bindings/csharp/FusionFramework/CacheMonitor.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FusionFramework; + +/// +/// Built-in Fusion monitor (cache + background tasks HTML + JSON). +/// Mounted when monitor.enabled is true (legacy cache.monitor.enabled still works). +/// +public static class FusionMonitor +{ + /// Register HTML and /json routes when enabled in settings. + public static bool Mount(FusionApp app, FusionSettings settings) + { + if (!Enabled(settings)) + return false; + + Cache.Configure(settings); + var path = ResolvePath(settings); + + app.AddRawRoute("GET", path, () => new MonitorPanel().Get()); + if (path != "/") + app.AddRawRoute("GET", $"{path}/", () => new MonitorPanel().Get()); + app.AddRawRoute("GET", $"{path}/json", () => Cache.Snapshot()); + return true; + } + + /// Prefer monitor.enabled, else legacy cache.monitor.enabled. + static bool Enabled(FusionSettings settings) + { + var top = settings.Get("monitor.enabled", null); + if (top is not null && !IsJsonNull(top)) + return Truthy(AsNode(top), false); + return Truthy(AsNode(settings.Get("cache.monitor.enabled", false)), false); + } + + /// Prefer monitor.path, else legacy cache.monitor.path. + static string ResolvePath(FusionSettings settings) + { + var top = AsString(settings.Get("monitor.path", null)); + if (!string.IsNullOrWhiteSpace(top)) + return NormalizePath(top); + return NormalizePath(AsString(settings.Get("cache.monitor.path", "/__fusion/monitor"))); + } + + static bool IsJsonNull(object? value) => + value is null + || (value is JsonNode n && n.GetValueKind() == JsonValueKind.Null); + + static JsonNode? AsNode(object? value) => + value switch + { + null => null, + JsonNode n => n, + bool b => JsonValue.Create(b), + string s => JsonValue.Create(s), + _ => JsonValue.Create(value.ToString()), + }; + + static string NormalizePath(string? raw) + { + var path = string.IsNullOrWhiteSpace(raw) ? "/__fusion/monitor" : raw.Trim(); + if (!path.StartsWith('/')) path = "/" + path; + path = path.TrimEnd('/'); + return string.IsNullOrEmpty(path) ? "/__fusion/monitor" : path; + } + + static bool Truthy(JsonNode? value, bool defaultValue) + { + if (value is null || value.GetValueKind() == JsonValueKind.Null) + return defaultValue; + if (value.GetValueKind() == JsonValueKind.False) return false; + if (value.GetValueKind() == JsonValueKind.True) return true; + if (value is JsonValue v) + { + if (v.TryGetValue(out var b)) return b; + if (v.TryGetValue(out var n)) return n != 0; + if (v.TryGetValue(out var s)) + { + return s.ToLowerInvariant() switch + { + "false" or "0" or "off" or "no" => false, + "true" or "1" or "on" or "yes" => true, + _ => !string.IsNullOrWhiteSpace(s), + }; + } + } + return true; + } + + static string? AsString(object? value) + { + if (value is null) return null; + if (value is string s) return s; + if (value is JsonValue jv && jv.TryGetValue(out var str)) return str; + if (value is JsonNode node) + return node.GetValueKind() == JsonValueKind.String ? node.GetValue() : node.ToJsonString(); + return value.ToString(); + } +} + +/// Backward-compatible alias for . +public static class CacheMonitor +{ + /// + public static bool Mount(FusionApp app, FusionSettings settings) => + FusionMonitor.Mount(app, settings); +} + +/// Default Fusion monitor page (cache + tasks). +public sealed class MonitorPanel : FusionBaseTemplate +{ + /// + public override string TemplateName() => "fusion/monitor.html"; + + /// + public override Dictionary Context() + { + var node = Cache.PanelContext(); + var dict = new Dictionary(StringComparer.Ordinal); + if (node is JsonObject obj) + { + foreach (var kv in obj) + dict[kv.Key] = kv.Value?.DeepClone(); + } + return dict; + } +} + +/// Backward-compatible alias for . +public sealed class CacheMonitorPanel : FusionBaseTemplate +{ + /// + public override string TemplateName() => "fusion/monitor.html"; + + /// + public override Dictionary Context() => new MonitorPanel().Context(); +} diff --git a/bindings/csharp/FusionFramework/ContentNegotiation.cs b/bindings/csharp/FusionFramework/ContentNegotiation.cs new file mode 100644 index 0000000..e2d3756 --- /dev/null +++ b/bindings/csharp/FusionFramework/ContentNegotiation.cs @@ -0,0 +1,47 @@ +namespace FusionFramework; + +/// Shared content negotiation helpers (fusion-core parity). +internal static class ContentNegotiation +{ + public static bool PrefersJson(string? accept, string? formatQuery) + { + if (string.Equals(formatQuery, "json", StringComparison.OrdinalIgnoreCase)) + return true; + + accept = accept?.Trim(); + if (string.IsNullOrEmpty(accept)) + return false; + + var bestJson = -1.0f; + var bestHtml = -1.0f; + + foreach (var part in accept.Split(',')) + { + var tokens = part.Trim().Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var media = tokens.Length > 0 ? tokens[0].ToLowerInvariant() : ""; + var q = 1.0f; + for (var i = 1; i < tokens.Length; i++) + { + if (tokens[i].StartsWith("q=", StringComparison.OrdinalIgnoreCase) + && float.TryParse(tokens[i][2..], out var parsed)) + { + q = parsed; + } + } + + switch (media) + { + case "application/json": + case "text/json": + bestJson = Math.Max(bestJson, q); + break; + case "text/html": + case "application/xhtml+xml": + bestHtml = Math.Max(bestHtml, q); + break; + } + } + + return bestJson > 0 && bestJson >= bestHtml; + } +} diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index 689d747..b47a63b 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -20,8 +20,6 @@ public FusionApp(FusionSettings? settings = null) settings ??= SettingsStore.Current; Native.fusion_app_set_settings(_app, settings.Handle); - // Default: advertise Fusion to clients / Wappalyzer-style detectors. - _middleware.Add(Middleware.FrameworkHeaders()); } public FusionApp Use(FusionMiddleware middleware) @@ -115,7 +113,9 @@ public void Mount() } } + Middleware.MountStaticFiles(this, _middleware); SwaggerDocs.Mount(this, SettingsStore.Current); + FusionMonitor.Mount(this, SettingsStore.Current); } internal void AddRawRoute(string method, string path, Func handler) @@ -142,12 +142,26 @@ internal void AddRawRoute(string method, string path, Func handler) throw new InvalidOperationException($"Failed to register {method} {path}"); } - public void Listen(string? host = null, ushort port = 0) + public void Listen(string? host = null, ushort port = 0, bool? reload = null, IEnumerable? watchDirs = null) { - Mount(); var settings = SettingsStore.Current; + var settingsReload = Truthy(settings.Get("reload", false)); + var shouldReload = Reloader.Resolve(reload, settingsReload); + + if (shouldReload && !Reloader.IsChild) + { + Reloader.RunWithReloader(watchDirs); + return; + } + + Mount(); host ??= settings.Host; if (port == 0) port = settings.Port; + if (settings.Debug || shouldReload) + { + var mode = shouldReload ? " (reload)" : ""; + Console.WriteLine($"fusion listening on http://{host}:{port}{mode}"); + } var code = Native.fusion_app_listen(_app, host, port); // listen consumes the native app @@ -156,6 +170,18 @@ public void Listen(string? host = null, ushort port = 0) throw new InvalidOperationException("fusion_app_listen failed"); } + static bool Truthy(System.Text.Json.Nodes.JsonNode? node, bool fallback = false) + { + if (node is null) return fallback; + if (node is System.Text.Json.Nodes.JsonValue v) + { + if (v.TryGetValue(out var b)) return b; + if (v.TryGetValue(out var s)) + return s is "1" or "true" or "True" or "yes" or "on"; + } + return fallback; + } + public void Dispose() { if (_disposed) return; diff --git a/bindings/csharp/FusionFramework/FusionBaseApi.cs b/bindings/csharp/FusionFramework/FusionBaseApi.cs index 388890c..c2f25fc 100644 --- a/bindings/csharp/FusionFramework/FusionBaseApi.cs +++ b/bindings/csharp/FusionFramework/FusionBaseApi.cs @@ -27,6 +27,22 @@ public abstract class FusionBaseApi public IReadOnlyDictionary Query => Request.Query; public IDictionary State => Request.State; + /// True when the client prefers JSON (Accept header or ?format=json). + public bool WantsJson() + { + string? accept = null; + foreach (var kv in Request.Headers) + { + if (string.Equals(kv.Key, "Accept", StringComparison.OrdinalIgnoreCase)) + { + accept = kv.Value; + break; + } + } + Request.Query.TryGetValue("format", out var format); + return ContentNegotiation.PrefersJson(accept, format); + } + public object Response(object? body = null, int status = 200, IDictionary? headers = null) { var envelope = new Dictionary diff --git a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs new file mode 100644 index 0000000..3f1145a --- /dev/null +++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs @@ -0,0 +1,273 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FusionFramework; + +/// HTML handlers using Tera templates (Python/Node parity). +public abstract class FusionBaseTemplate : FusionBaseApi +{ + public static string Template { get; set; } = ""; + public static string TemplateAddress { get; set; } = ""; + public static string TemplatesDir { get; set; } = ""; + + /// + /// Template variables (not an HTTP verb). Override in subclasses. + /// renders this as HTML; POST handlers use / / . + /// + public virtual Dictionary Context() => new(); + + /// Async template variables; default wraps . + public virtual Task> ContextAsync() => + Task.FromResult(Context()); + + /// Parsed POST body (urlencoded or JSON) as flat string fields. + public Dictionary Form => ParseFormBody(Body, ContentType()); + + /// Parse urlencoded or JSON body into flat string fields. + public static Dictionary ParseFormBody(string? body, string? contentType) + { + var raw = body ?? ""; + var ct = (contentType ?? "").ToLowerInvariant(); + var outDict = new Dictionary(StringComparer.Ordinal); + if (ct.Contains("application/json", StringComparison.Ordinal) + || (raw.TrimStart().StartsWith('{') && !ct.Contains("urlencoded", StringComparison.Ordinal))) + { + try + { + var node = string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw); + if (node is JsonObject obj) + { + foreach (var kv in obj) + outDict[kv.Key] = kv.Value is null || kv.Value.GetValueKind() == JsonValueKind.Null + ? "" + : kv.Value.ToString() ?? ""; + } + } + catch (JsonException) + { + // ignore invalid JSON + } + return outDict; + } + + if (string.IsNullOrEmpty(raw)) + return outDict; + + foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var parts = pair.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0].Replace('+', ' ')); + var value = parts.Length > 1 + ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) + : ""; + outDict[key] = value; + } + return outDict; + } + + string? ContentType() + { + foreach (var kv in Request.Headers) + { + if (string.Equals(kv.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)) + return kv.Value; + } + return null; + } + + /// + /// Default GET — HTML or JSON context. Uses so + /// subclasses can override that for DB/API-backed pages (Python async context parity). + /// + public virtual object Get() + { + var task = ContextAsync(); + if (task.IsCompletedSuccessfully) + return FinishGet(task.Result); + return FinishGetAsync(task); + } + + async Task FinishGetAsync(Task> task) + { + var ctx = await task.ConfigureAwait(false); + return FinishGet(ctx); + } + + object FinishGet(Dictionary ctx) + { + if (WantsJson()) + return ctx; + return HtmlResponse(ctx); + } + + /// Validation failure — JSON for SPA fetch, else same template with errors. + public object Fail( + IDictionary? errors = null, + string? message = null, + IDictionary? fields = null) + { + var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var flat = fields?.ToDictionary( + kv => kv.Key, + kv => kv.Value ?? "", + StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var msg = message ?? "Validation failed"; + if (WantsJson()) + { + return Response(new Dictionary + { + ["ok"] = false, + ["message"] = msg, + ["errors"] = err, + ["fields"] = flat, + }, 400); + } + return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400); + } + + /// Success — JSON for SPA fetch, else same template with ok=true. + public object Ok(string? message = null, IDictionary? fields = null) + { + var flat = fields?.ToDictionary( + kv => kv.Key, + kv => kv.Value ?? "", + StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var msg = message ?? "OK"; + if (WantsJson()) + { + return Response(new Dictionary + { + ["ok"] = true, + ["message"] = msg, + ["errors"] = new Dictionary(), + ["fields"] = flat, + }, 200); + } + return FormHtmlResult( + ok: true, + message: msg, + errors: new Dictionary(), + fields: flat, + status: 200); + } + + object FormHtmlResult( + bool ok, + string message, + IDictionary errors, + IDictionary fields, + int status) + { + var task = ContextAsync(); + if (!task.IsCompletedSuccessfully) + return FormHtmlResultAsync(task, ok, message, errors, fields, status); + return FinishFormHtml(task.Result, ok, message, errors, fields, status); + } + + async Task FormHtmlResultAsync( + Task> task, + bool ok, + string message, + IDictionary errors, + IDictionary fields, + int status) + { + var ctx = await task.ConfigureAwait(false); + return FinishFormHtml(ctx, ok, message, errors, fields, status); + } + + object FinishFormHtml( + Dictionary ctx, + bool ok, + string message, + IDictionary errors, + IDictionary fields, + int status) + { + var data = new Dictionary(ctx, StringComparer.Ordinal); + foreach (var kv in fields) + data[kv.Key] = JsonValue.Create(kv.Value); + data["ok"] = JsonValue.Create(ok); + data["message"] = JsonValue.Create(message); + data["errors"] = JsonSerializer.SerializeToNode(errors); + data["fields"] = JsonSerializer.SerializeToNode(fields); + return HtmlResponse(data, status); + } + + public virtual string TemplateName() + { + var name = !string.IsNullOrEmpty(Template) ? Template : TemplateAddress; + if (string.IsNullOrEmpty(name)) + throw new InvalidOperationException($"{GetType().Name} must set Template or TemplateAddress"); + return name; + } + + public virtual string TemplatesRoot() + { + if (!string.IsNullOrEmpty(TemplatesDir)) + return TemplatesDir; + var fromSettings = SettingsStore.Current.Get("templates.dir", "templates"); + return fromSettings is JsonValue v && v.TryGetValue(out var s) ? s : "templates"; + } + + public virtual object Render( + int status = 200, + IDictionary? headers = null, + IDictionary? context = null, + string? templateName = null) + { + var task = ContextAsync(); + if (!task.IsCompletedSuccessfully) + return RenderAsync(task, status, headers, context, templateName); + + var ctx = new Dictionary(task.Result, StringComparer.Ordinal); + if (context != null) + { + foreach (var kv in context) + ctx[kv.Key] = kv.Value; + } + return HtmlResponse(ctx, status, headers, templateName); + } + + async Task RenderAsync( + Task> task, + int status, + IDictionary? headers, + IDictionary? context, + string? templateName) + { + var ctx = new Dictionary(await task.ConfigureAwait(false), StringComparer.Ordinal); + if (context != null) + { + foreach (var kv in context) + ctx[kv.Key] = kv.Value; + } + return HtmlResponse(ctx, status, headers, templateName); + } + + /// Render an already-resolved context dictionary to an HTML envelope. + protected object HtmlResponse( + IDictionary ctx, + int status = 200, + IDictionary? headers = null, + string? templateName = null) + { + var html = Templates.Render( + templateName ?? TemplateName(), + ctx, + TemplatesRoot()); + var hdrs = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["content-type"] = "text/html; charset=utf-8", + }; + if (headers != null) + { + foreach (var kv in headers) + hdrs[kv.Key] = kv.Value; + } + return Response(html, status, hdrs); + } +} diff --git a/bindings/csharp/FusionFramework/FusionFramework.csproj b/bindings/csharp/FusionFramework/FusionFramework.csproj index a23949d..5b84461 100644 --- a/bindings/csharp/FusionFramework/FusionFramework.csproj +++ b/bindings/csharp/FusionFramework/FusionFramework.csproj @@ -7,7 +7,7 @@ FusionFramework FusionFramework Fusion-Framework - 1.2.6 + 2.0.0 CipherUnits CipherUnits Fusion Framework managed bindings (C#) over fusion-ffi / fusion-core @@ -22,8 +22,12 @@ false + + + + diff --git a/bindings/csharp/FusionFramework/Header.cs b/bindings/csharp/FusionFramework/Header.cs index bf98a13..ac0f64e 100644 --- a/bindings/csharp/FusionFramework/Header.cs +++ b/bindings/csharp/FusionFramework/Header.cs @@ -90,7 +90,7 @@ public static Dictionary Fingerprint() { ["X-Powered-By"] = "Fusion Framework", ["X-Framework"] = "Fusion", - ["X-Fusion-Version"] = "1.2.6", + ["X-Fusion-Version"] = "2.0.0", }; } } diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 36fd0c6..6978304 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -4,6 +4,9 @@ namespace FusionFramework; +/// Custom route permission check — return false to deny with HTTP 403. +public delegate bool FusionPermission(FusionRequest request); + public delegate object? FusionMiddleware(FusionRequest request, Func callNext); public static class Middleware @@ -53,32 +56,32 @@ public static void SetActiveGlobal(IEnumerable middlewares) case null: return null; case Task task: - { - task.ConfigureAwait(false).GetAwaiter().GetResult(); - var t = task.GetType(); - if (t.IsGenericType) - { - result = t.GetProperty("Result")?.GetValue(task); - continue; - } - return null; - } - default: - { - var type = result.GetType(); - if (type == typeof(ValueTask)) { - ((ValueTask)result).ConfigureAwait(false).GetAwaiter().GetResult(); + task.ConfigureAwait(false).GetAwaiter().GetResult(); + var t = task.GetType(); + if (t.IsGenericType) + { + result = t.GetProperty("Result")?.GetValue(task); + continue; + } return null; } - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + default: { - // ValueTask → Task then unwrap on next iteration. - result = type.GetMethod("AsTask")!.Invoke(result, null); - continue; + var type = result.GetType(); + if (type == typeof(ValueTask)) + { + ((ValueTask)result).ConfigureAwait(false).GetAwaiter().GetResult(); + return null; + } + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + // ValueTask → Task then unwrap on next iteration. + result = type.GetMethod("AsTask")!.Invoke(result, null); + continue; + } + return result; } - return result; - } } } @@ -186,7 +189,294 @@ public static FusionMiddleware RequireRoles( public static FusionMiddleware RequireRoles(params string[] roles) => RequireRoles((IEnumerable)roles); - /// Default identity middleware — advertises Fusion on every response. + /// Route middleware: run custom permission checks; any failure → 403. + public static FusionMiddleware RequirePermissions(params FusionPermission[] checks) => + RequirePermissions((IEnumerable)checks); + + public static FusionMiddleware RequirePermissions(IEnumerable checks) + { + var list = checks.ToList(); + return (request, callNext) => + { + foreach (var check in list) + { + if (!check(request)) + return Error(403, "Forbidden"); + } + return callNext(request); + }; + } + + /// Common security response headers. + public static FusionMiddleware SecurityHeaders( + string contentTypeOptions = "nosniff", + string frameOptions = "DENY", + string referrerPolicy = "strict-origin-when-cross-origin", + string permissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()", + string coop = "same-origin", + string corp = "same-origin", + string? csp = null, + string? hsts = null) + { + var extra = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-Content-Type-Options"] = contentTypeOptions, + ["X-Frame-Options"] = frameOptions, + ["Referrer-Policy"] = referrerPolicy, + ["Permissions-Policy"] = permissionsPolicy, + ["Cross-Origin-Opener-Policy"] = coop, + ["Cross-Origin-Resource-Policy"] = corp, + }; + if (!string.IsNullOrEmpty(csp)) extra["Content-Security-Policy"] = csp!; + if (!string.IsNullOrEmpty(hsts)) extra["Strict-Transport-Security"] = hsts!; + + return (request, callNext) => + { + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, extra); + }; + } + + /// Set Cache-Control on responses. + public static FusionMiddleware CacheHeaders(string defaultValue = "no-store") => + (request, callNext) => + { + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, new Dictionary + { + ["Cache-Control"] = defaultValue, + }); + }; + + /// Echo or generate X-Request-Id on each request. + public static FusionMiddleware RequestId(string header = "X-Request-Id", bool incoming = true) => + (request, callNext) => + { + string? rid = incoming ? GetHeader(request, header) : null; + if (string.IsNullOrEmpty(rid)) + rid = Guid.NewGuid().ToString(); + EnsureState(request)["request_id"] = rid; + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, new Dictionary { [header] = rid }); + }; + + /// CORS middleware; answers OPTIONS preflight with 204. + public static FusionMiddleware Cors( + IEnumerable? allowOrigins = null, + IEnumerable? allowMethods = null, + IEnumerable? allowHeaders = null, + IEnumerable? exposeHeaders = null, + bool allowCredentials = false, + int maxAge = 600) + { + var origins = (allowOrigins ?? new[] { "*" }).Select(o => o.ToString()).ToList(); + var methods = (allowMethods ?? new[] + { + "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", + }).Select(m => m.ToUpperInvariant()).ToList(); + var headers = (allowHeaders ?? new[] + { + "Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id", + }).ToList(); + var expose = (exposeHeaders ?? new[] { "X-Request-Id" }).ToList(); + var allowAll = origins.Contains("*"); + + Dictionary CorsHeaders(string? origin) + { + var chosen = "*"; + if (!allowAll) + { + if (!string.IsNullOrEmpty(origin) && origins.Contains(origin)) + chosen = origin; + else if (origins.Count > 0) + chosen = origins[0]; + } + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Access-Control-Allow-Origin"] = chosen, + ["Access-Control-Allow-Methods"] = string.Join(", ", methods), + ["Access-Control-Allow-Headers"] = string.Join(", ", headers), + ["Access-Control-Expose-Headers"] = string.Join(", ", expose), + ["Access-Control-Max-Age"] = maxAge.ToString(), + ["Vary"] = "Origin", + }; + if (allowCredentials && chosen != "*") + map["Access-Control-Allow-Credentials"] = "true"; + return map; + } + + return (request, callNext) => + { + var origin = GetHeader(request, "Origin"); + var extra = CorsHeaders(origin); + if (string.Equals(request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary + { + ["status"] = 204, + ["body"] = "", + ["headers"] = extra, + }; + } + + var result = ResolveAwaitable(callNext(request)); + return MergeResponseHeaders(result, extra); + }; + } + + 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() { var extra = Header.Fingerprint(); @@ -228,6 +518,18 @@ static object Error(int status, string detail) => ["body"] = new Dictionary { ["detail"] = detail }, }; + static string? GetHeader(FusionRequest request, string name) + { + if (request.Headers.TryGetValue(name, out var direct) && !string.IsNullOrEmpty(direct)) + return direct; + foreach (var kv in request.Headers) + { + if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)) + return kv.Value; + } + return null; + } + static byte[] Base64UrlDecode(string input) { var s = input.Replace('-', '+').Replace('_', '/'); diff --git a/bindings/csharp/FusionFramework/Native.cs b/bindings/csharp/FusionFramework/Native.cs index 88123ee..c18323e 100644 --- a/bindings/csharp/FusionFramework/Native.cs +++ b/bindings/csharp/FusionFramework/Native.cs @@ -186,6 +186,97 @@ public static extern IntPtr fusion_header_download( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr fusion_fingerprint_headers(); + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_render_template( + [MarshalAs(UnmanagedType.LPUTF8Str)] string templateName, + [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(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_snapshot(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_cache_panel_context(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void FusionTaskCallback(IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void FusionTaskDataFree(IntPtr userData); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_task_spawn( + FusionTaskCallback callback, + IntPtr userData, + FusionTaskDataFree freeData); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_task_spawn_after( + ulong delayMs, + FusionTaskCallback callback, + IntPtr userData, + FusionTaskDataFree freeData); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int fusion_task_cancel([MarshalAs(UnmanagedType.LPUTF8Str)] string id); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_task_status([MarshalAs(UnmanagedType.LPUTF8Str)] string id); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void fusion_task_reset(); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr fusion_task_snapshot(); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate IntPtr FusionHandlerFn( IntPtr userData, diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index f9e965e..a941401 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -54,6 +54,22 @@ foreach (var mw in MIDDLEWARE) app.Use(mw); app.Listen(); ``` +### Auto-reload (development) + +```csharp +// Restart the process when source files change +app.Listen(reload: true); + +// Never reload (default) — same as omit / settings reload: false +app.Listen(reload: false); +``` + +Or in `fusion.dev.json`: + +```json +{ "reload": true } +``` + ## Custom HTTP routes Use method-level attributes alongside convention `get`/`post`/… handlers: @@ -98,7 +114,50 @@ Parameters appear in Swagger OpenAPI docs. ```csharp MIDDLEWARE.Add(Middleware.BearerJwt()); -Route.Register(typeof(AdminModule), "/api/admin", roles: new[] { "admin" }); +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()`. + +## 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(); + +## Background tasks + +```csharp +var id = BackgroundTasks.Spawn(() => Console.WriteLine("done")); +BackgroundTasks.SpawnAfter(1000, () => Console.WriteLine("later")); +BackgroundTasks.Cancel(id); +BackgroundTasks.Status(id); // pending|running|done|cancelled|failed +BackgroundTasks.Snapshot(); // also under Cache.Snapshot()["tasks"] +``` ``` ## License diff --git a/bindings/csharp/FusionFramework/Reloader.cs b/bindings/csharp/FusionFramework/Reloader.cs new file mode 100644 index 0000000..722d75a --- /dev/null +++ b/bindings/csharp/FusionFramework/Reloader.cs @@ -0,0 +1,169 @@ +using System.Diagnostics; + +namespace FusionFramework; + +/// +/// Process-based auto-reload for development. +/// Parent watches files and restarts a child that runs the real server. +/// +public static class Reloader +{ + public const string ChildEnvVar = "FUSION_RELOAD_CHILD"; + + static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", ".hg", "node_modules", "target", ".venv", "venv", + "__pycache__", "bin", "obj", "dist", "build", ".idea", ".vs", + }; + + static readonly HashSet Extensions = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".json", ".html", ".tera", ".js", ".mjs", ".py", + }; + + public static bool IsChild => + string.Equals(Environment.GetEnvironmentVariable(ChildEnvVar), "1", StringComparison.Ordinal); + + public static bool Resolve(bool? reload, bool settingsReload) => + reload ?? settingsReload; + + public static void RunWithReloader(IEnumerable? watchDirs = null) + { + if (IsChild) + throw new InvalidOperationException("RunWithReloader must not run inside the child process"); + + var roots = (watchDirs ?? new[] { Directory.GetCurrentDirectory() }) + .Select(Path.GetFullPath) + .Distinct(StringComparer.Ordinal) + .ToList(); + + Console.WriteLine($"fusion: reload enabled (watching {string.Join(", ", roots)})"); + + Process? child = null; + var mtimes = Snapshot(Collect(roots)); + + void StopChild() + { + if (child is null || child.HasExited) + { + child = null; + return; + } + try + { + child.Kill(entireProcessTree: true); + child.WaitForExit(5000); + } + catch + { + /* best effort */ + } + child = null; + } + + Process StartChild() + { + var fileName = Environment.ProcessPath + ?? throw new InvalidOperationException("Environment.ProcessPath is unavailable"); + var start = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + }; + // Skip argv[0] (executable path); forward the rest. + foreach (var arg in Environment.GetCommandLineArgs().Skip(1)) + start.ArgumentList.Add(arg); + start.Environment[ChildEnvVar] = "1"; + return Process.Start(start) + ?? throw new InvalidOperationException("failed to start reload child"); + } + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + StopChild(); + Environment.Exit(0); + }; + + child = StartChild(); + while (true) + { + Thread.Sleep(500); + if (child.HasExited) + { + Console.WriteLine($"fusion: child exited ({child.ExitCode}); restarting…"); + Thread.Sleep(300); + child = StartChild(); + mtimes = Snapshot(Collect(roots)); + continue; + } + + var files = Collect(roots); + var next = Snapshot(files); + string? changed = null; + foreach (var (file, mtime) in next) + { + if (!mtimes.TryGetValue(file, out var prev) || mtime > prev) + { + changed = file; + break; + } + } + if (changed is null) + { + foreach (var file in mtimes.Keys) + { + if (!next.ContainsKey(file)) + { + changed = file; + break; + } + } + } + if (changed is null) continue; + + var label = changed; + try { label = Path.GetRelativePath(Directory.GetCurrentDirectory(), changed); } catch { /* keep */ } + Console.WriteLine($"fusion: change detected ({label}); reloading…"); + StopChild(); + child = StartChild(); + mtimes = Snapshot(Collect(roots)); + } + } + + static List Collect(IEnumerable roots) + { + var files = new List(); + foreach (var root in roots) + { + if (File.Exists(root)) + { + if (Extensions.Contains(Path.GetExtension(root))) + files.Add(root); + continue; + } + if (!Directory.Exists(root)) continue; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(root, file); + if (rel.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Any(p => SkipDirs.Contains(p))) + continue; + if (Extensions.Contains(Path.GetExtension(file))) + files.Add(file); + } + } + return files; + } + + static Dictionary Snapshot(IEnumerable files) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var file in files) + { + try { map[file] = File.GetLastWriteTimeUtc(file).Ticks; } + catch { /* ignore */ } + } + return map; + } +} diff --git a/bindings/csharp/FusionFramework/Route.cs b/bindings/csharp/FusionFramework/Route.cs index d372c1b..82ebd62 100644 --- a/bindings/csharp/FusionFramework/Route.cs +++ b/bindings/csharp/FusionFramework/Route.cs @@ -14,9 +14,7 @@ public sealed class RouteAttribute : Attribute public string? Title { get; set; } public string? Version { get; set; } public bool Deprecated { get; set; } - public string[]? Roles { get; set; } - public string RoleClaim { get; set; } = "roles"; - public string RoleStateKey { get; set; } = "jwt"; + public Type[]? PermissionTypes { get; set; } public RouteAttribute(string path) => Path = path; } @@ -44,6 +42,7 @@ internal sealed class RouteEntry public string? Desc { get; init; } public string? Title { get; init; } public bool Deprecated { get; init; } + public bool RequiresPermissions { get; init; } public List Slots { get; init; } = new(); } @@ -149,10 +148,8 @@ public static Type Register( Type apiClass, string path, IEnumerable? middleware = null, - IEnumerable? roles = null, + IEnumerable? permissions = null, string? version = null, - string roleClaim = "roles", - string roleStateKey = "jwt", IEnumerable? tags = null, string? desc = null, string? title = null, @@ -167,12 +164,10 @@ public static Type Register( resolved = $"{v}/{resolved.TrimStart('/')}"; var chain = middleware?.ToList() ?? new List(); - if (roles != null) - { - var roleList = roles.ToList(); - if (roleList.Count > 0) - chain.Add(Middleware.RequireRoles(roleList, roleClaim, roleStateKey)); - } + var permissionChecks = ResolvePermissions(permissions).ToList(); + var requiresPermissions = permissionChecks.Count > 0; + if (requiresPermissions) + chain.Add(Middleware.RequirePermissions(permissionChecks)); var classBasePath = resolved.StartsWith('/') ? resolved : "/" + resolved; var classTags = tags?.ToArray() ?? Array.Empty(); @@ -190,12 +185,41 @@ public static Type Register( Desc = desc, Title = title, Deprecated = deprecated, + RequiresPermissions = requiresPermissions, Slots = BuildSlots(apiClass, classBasePath, classTags, desc, title, deprecated), }); } return apiClass; } + static IEnumerable ResolvePermissions(IEnumerable? permissions) + { + if (permissions is null) yield break; + foreach (var check in permissions) + yield return check; + } + + static IEnumerable ResolvePermissionTypes(Type[]? types) + { + if (types is null || types.Length == 0) yield break; + foreach (var type in types) + { + var method = type.GetMethod( + "Check", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(FusionRequest) }, + modifiers: null); + if (method is null) + { + throw new InvalidOperationException( + $"{type.Name} must define public static bool Check(FusionRequest request)"); + } + yield return request => (bool)(method.Invoke(null, new object[] { request }) + ?? throw new InvalidOperationException($"{type.Name}.Check returned null")); + } + } + public static Type Register() where T : FusionBaseApi { var attr = typeof(T).GetCustomAttribute() @@ -203,10 +227,8 @@ public static Type Register() where T : FusionBaseApi return Register( typeof(T), attr.Path, - roles: attr.Roles, + permissions: ResolvePermissionTypes(attr.PermissionTypes), version: attr.Version, - roleClaim: attr.RoleClaim, - roleStateKey: attr.RoleStateKey, tags: attr.Tags, desc: attr.Desc, title: attr.Title, @@ -226,10 +248,8 @@ public static void RegisterAll(Assembly assembly) Register( type, attr.Path, - roles: attr.Roles, + permissions: ResolvePermissionTypes(attr.PermissionTypes), version: attr.Version, - roleClaim: attr.RoleClaim, - roleStateKey: attr.RoleStateKey, tags: attr.Tags, desc: attr.Desc, title: attr.Title, diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs index ede2688..394daea 100644 --- a/bindings/csharp/FusionFramework/Swagger.cs +++ b/bindings/csharp/FusionFramework/Swagger.cs @@ -18,6 +18,7 @@ public static void Mount(FusionApp app, FusionSettings settings) var labels = ApplyVersionNavbar(swagger); var combined = BuildOpenApi(swagger); + MountAssets(app, prefix); app.AddRawRoute("GET", $"{prefix}/openapi.json", () => combined); app.AddRawRoute("GET", prefix, () => Html(UiHtml(swagger, $"{prefix}/openapi.json"))); if (prefix != "/") @@ -110,7 +111,7 @@ sealed class SwaggerConfig ["showCommonExtensions"] = false, ["syntaxHighlight"] = new JsonObject { ["activated"] = true, ["theme"] = "agate" }, ["withCredentials"] = false, - ["validatorUrl"] = "https://validator.swagger.io/validator", + ["validatorUrl"] = null, }; var uiOverlay = AsObject(settings.Get("swagger.ui", new { })); if (uiOverlay is not null) @@ -214,15 +215,58 @@ static JsonObject BuildOpenApi(SwaggerConfig swagger, string? version = null) if (swagger.AuthGlobal.Count > 0) spec["security"] = swagger.AuthGlobal.DeepClone(); - FillPaths((JsonObject)spec["paths"]!, version); + var anyPermissions = FillPaths((JsonObject)spec["paths"]!, version); + if (anyPermissions) + { + if (spec["components"] is not JsonObject components) + { + components = new JsonObject(); + spec["components"] = components; + } + if (components["securitySchemes"] is not JsonObject schemes) + { + schemes = new JsonObject(); + components["securitySchemes"] = schemes; + } + schemes["FusionPermissions"] = new JsonObject + { + ["type"] = "apiKey", + ["in"] = "header", + ["name"] = "Authorization", + ["description"] = "Route requires custom permission checks to pass", + }; + } return spec; } - static void FillPaths(JsonObject paths, string? versionFilter) + /// Build a minimal OpenAPI document for unit tests without a live Swagger config. + internal static JsonObject CreateTestSpec(string? version = null) { + var swagger = new SwaggerConfig + { + Path = "/swagger", + PageTitle = "Fusion API Docs", + Info = new JsonObject + { + ["title"] = "fusion-framework", + ["version"] = "1.0.0", + }, + Ui = new JsonObject(), + }; + return BuildOpenApi(swagger, version); + } + + /// Fill OpenAPI path operations; returns true if any route requires permissions. + static bool FillPaths(JsonObject paths, string? versionFilter) + { + const string permissionsScheme = "FusionPermissions"; + var anyPermissions = false; + foreach (var entry in Route.Snapshot()) { if (!MatchesVersion(entry.Version, versionFilter)) continue; + if (typeof(FusionBaseTemplate).IsAssignableFrom(entry.ApiClass)) continue; + if (entry.RequiresPermissions) anyPermissions = true; foreach (var slot in entry.Slots) { @@ -336,9 +380,26 @@ static void FillPaths(JsonObject paths, string? versionFilter) }; } + if (entry.RequiresPermissions) + { + operation["security"] = new JsonArray + { + new JsonObject { [permissionsScheme] = new JsonArray() }, + }; + if (operation["responses"] is JsonObject responses) + { + responses["403"] = new JsonObject + { + ["description"] = "Forbidden — permission check failed", + }; + } + } + methods[methodLower] = operation; } } + + return anyPermissions; } static bool MatchesVersion(string? routeVersion, string? filter) @@ -391,18 +452,22 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa : "null"; var title = JsonSerializer.Serialize(swagger.PageTitle).Trim('"'); var navbarEnabled = swagger.NavbarEnabled; + var versionUrls = swagger.Urls?.Count > 0; + var needsStandalone = navbarEnabled || versionUrls; var hideUrlCss = navbarEnabled && !swagger.ShowUrlInput ? """ """ : ""; - var standalone = navbarEnabled - ? """""" + var standalone = needsStandalone && File.Exists(Path.Combine(AssetsDirectory(), "swagger-ui-standalone-preset.js")) + ? $"""""" : ""; - var navbarJs = navbarEnabled ? "true" : "false"; + var navbarJs = needsStandalone ? "true" : "false"; return $$""" @@ -411,12 +476,12 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa {{title}} - + {{hideUrlCss}}
- + {{standalone}} + * + * Posts with Accept: application/json and paints ok/errors without leaving the page. + * Without JS, the browser does a normal HTML POST (server still uses ok/fail). + */ + +(function () { + function clearErrors(form) { + form.querySelectorAll(".fusion-field-error").forEach(function (el) { + el.textContent = ""; + }); + var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status"); + if (status) { + status.textContent = ""; + status.classList.remove("fusion-form-ok", "fusion-form-fail"); + } + } + + function paintErrors(form, errors) { + if (!errors || typeof errors !== "object") return; + Object.keys(errors).forEach(function (key) { + var el = + form.querySelector('.fusion-field-error[data-field="' + key + '"]') || + document.querySelector('.fusion-field-error[data-field="' + key + '"]'); + if (el) el.textContent = String(errors[key] || ""); + }); + } + + function paintStatus(form, message, ok) { + var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status"); + if (!status) return; + status.textContent = message || (ok ? "OK" : "Error"); + status.classList.toggle("fusion-form-ok", !!ok); + status.classList.toggle("fusion-form-fail", !ok); + } + + function fillFields(form, fields) { + if (!fields || typeof fields !== "object") return; + Object.keys(fields).forEach(function (key) { + var input = form.querySelector('[name="' + key + '"]'); + if (!input || input.type === "password") return; + input.value = fields[key] == null ? "" : String(fields[key]); + }); + } + + document.addEventListener( + "submit", + function (event) { + var form = event.target; + if (!form || !form.getAttribute || !form.hasAttribute("data-fusion-form")) return; + event.preventDefault(); + clearErrors(form); + + var action = form.getAttribute("action") || window.location.pathname; + var method = (form.getAttribute("method") || "post").toUpperCase(); + var body = new URLSearchParams(new FormData(form)); + + fetch(action, { + method: method, + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body: body.toString(), + credentials: "same-origin", + }) + .then(function (res) { + return res.json().then(function (data) { + return { res: res, data: data }; + }); + }) + .then(function (payload) { + var data = payload.data || {}; + fillFields(form, data.fields); + if (data.ok) { + paintStatus(form, data.message || "Saved", true); + form.dispatchEvent(new CustomEvent("fusion:form-ok", { detail: data })); + return; + } + paintErrors(form, data.errors); + paintStatus(form, data.message || "Validation failed", false); + form.dispatchEvent(new CustomEvent("fusion:form-fail", { detail: data })); + }) + .catch(function () { + paintStatus(form, "Request failed", false); + }); + }, + true + ); +})(); diff --git a/crates/fusion-core/assets/templates/fusion/global.css b/crates/fusion-core/assets/templates/fusion/global.css new file mode 100644 index 0000000..123799a --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/global.css @@ -0,0 +1,121 @@ +:root { + color-scheme: light; + + /* Radius */ + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 0.125rem); + --radius-lg: calc(var(--radius) + 0.125rem); + + /* Spacing scale */ + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --spacing-10: 2.5rem; + --spacing-12: 3rem; + + /* Typography */ + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, "Helvetica Neue", Arial, sans-serif; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + + /* Elevation */ + --shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 oklch(0 0 0 / 0.1), 0 1px 2px -1px oklch(0 0 0 / 0.1); + --shadow: 0 4px 6px -1px oklch(0 0 0 / 0.1), 0 2px 4px -2px oklch(0 0 0 / 0.1); + + /* Focus / control chrome */ + --ring-offset: var(--background); + --ring-width: 3px; + --border-width: 1px; + --control-height: 2.25rem; + --opacity-disabled: 0.5; + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Colors */ + --background: oklch(0.955 0 0); + --foreground: oklch(0.18 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.18 0 0); + --primary: oklch(0.2 0 0); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.93 0 0); + --secondary-foreground: oklch(0.22 0 0); + --muted: oklch(0.93 0 0); + --muted-foreground: oklch(0.4 0 0); + --accent: oklch(0.925 0 0); + --accent-foreground: oklch(0.18 0 0); + --destructive: oklch(0.5 0.2 25); + --destructive-foreground: oklch(0.99 0 0); + --border: oklch(0.82 0 0); + --input: oklch(0.82 0 0); + --ring: oklch(0.45 0 0); + --chart-1: oklch(0.4 0 0); + --chart-2: oklch(0.55 0 0); + --chart-3: oklch(0.7 0 0); + --chart-4: oklch(0.3 0 0); + --chart-5: oklch(0.6 0 0); + --sidebar: oklch(1 0 0); + --sidebar-foreground: oklch(0.18 0 0); + --sidebar-primary: oklch(0.2 0 0); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.925 0 0); + --sidebar-accent-foreground: oklch(0.18 0 0); + --sidebar-border: oklch(0.82 0 0); + --sidebar-ring: oklch(0.45 0 0); +} + +.dark { + color-scheme: dark; + + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --ring-offset: var(--background); + --shadow-xs: 0 1px 2px 0 oklch(0 0 0 / 0.3); + --shadow-sm: 0 1px 3px 0 oklch(0 0 0 / 0.35), 0 1px 2px -1px oklch(0 0 0 / 0.35); + --shadow: 0 4px 6px -1px oklch(0 0 0 / 0.4), 0 2px 4px -2px oklch(0 0 0 / 0.4); +} diff --git a/crates/fusion-core/assets/templates/fusion/index.css b/crates/fusion-core/assets/templates/fusion/index.css new file mode 100644 index 0000000..5efb64d --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/index.css @@ -0,0 +1,237 @@ +/* Gallery page chrome — theme tokens from global.css only. */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Scroll works; scrollbar is visually hidden. */ +html { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* legacy Edge */ +} + +html::-webkit-scrollbar { + width: 0; + height: 0; + display: none; /* Chrome, Safari, Opera */ +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-normal); + color: var(--foreground); + background-color: var(--background); + transition: background-color var(--transition-fast), color var(--transition-fast); + scrollbar-width: none; + -ms-overflow-style: none; +} + +body::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} + +.gallery { + max-width: 72rem; + margin: 0 auto; + padding: var(--spacing-8) var(--spacing-6) var(--spacing-12); + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.gallery__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-4); +} + +.gallery__intro { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + min-width: min(100%, 20rem); + flex: 1; +} + +.gallery__theme-toggle { + display: inline-flex; + align-items: center; + gap: var(--spacing-2); + height: var(--control-height); + padding: 0 var(--spacing-3); + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + line-height: var(--leading-tight); + color: var(--foreground); + background-color: var(--card); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-xs); + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.gallery__theme-toggle:hover { + border-color: var(--ring); + background-color: var(--accent); + color: var(--accent-foreground); +} + +.gallery__theme-toggle:focus-visible { + outline: none; + border-color: var(--ring); + box-shadow: + var(--shadow-xs), + 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} + +.gallery__theme-toggle-icon { + width: var(--spacing-4); + height: var(--spacing-4); + flex-shrink: 0; +} + +.gallery__theme-toggle-icon--sun { + display: none; +} + +.dark .gallery__theme-toggle-icon--moon { + display: none; +} + +.dark .gallery__theme-toggle-icon--sun { + display: block; +} + +.gallery__eyebrow { + margin: 0; + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +.gallery__title { + margin: 0; + font-size: calc(var(--text-lg) + var(--spacing-2)); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-tight); + color: var(--foreground); +} + +.gallery__lede { + margin: 0; + max-width: 40rem; + color: var(--muted-foreground); + font-size: var(--text-sm); +} + +.gallery-section { + display: flex; + flex-direction: column; + gap: var(--spacing-5); +} + +.gallery-section__head { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + padding-bottom: var(--spacing-3); + border-bottom: var(--border-width) solid var(--border); +} + +.gallery-section__title { + margin: 0; + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-tight); +} + +.gallery-section__desc { + margin: 0; + font-size: var(--text-sm); + color: var(--muted-foreground); +} + +.gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--spacing-6); + align-items: start; +} + +.gallery-grid--controls { + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); +} + +.demo { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + padding: var(--spacing-5); + background-color: var(--card); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-xs); +} + +.demo__label { + margin: 0; + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.demo__preview { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.demo__code { + margin: 0; + padding: var(--spacing-3) var(--spacing-4); + overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--text-xs); + line-height: var(--leading-relaxed); + color: var(--foreground); + background-color: var(--muted); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + white-space: pre; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.demo__code::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} + +.usage-block { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.usage-block .demo__code { + white-space: pre-wrap; +} diff --git a/crates/fusion-core/assets/templates/fusion/index.html b/crates/fusion-core/assets/templates/fusion/index.html new file mode 100644 index 0000000..01178ab --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/index.html @@ -0,0 +1,490 @@ + + + + + + + Fusion UI — Input, Text, Card, Dropdown + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + diff --git a/crates/fusion-core/assets/templates/fusion/macros.html b/crates/fusion-core/assets/templates/fusion/macros.html new file mode 100644 index 0000000..bba0154 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/macros.html @@ -0,0 +1,110 @@ +{# Fusion built-in UI components (Tera 2). + Use: {{}} + Docs: include styles via {% include "fusion/components.css" %} or extend fusion/base.html +#} + +{% component fusion.button(label: string, href: string = "", variant: string = "primary", type: string = "button") %} +{%- if href -%} +{{ label }} +{%- else -%} + +{%- endif -%} +{% endcomponent fusion.button %} + +{% component fusion.link(label: string, href: string = "#", variant: string = "default") %} +{{ label }} +{% endcomponent fusion.link %} + +{% component fusion.card(title: string = "", content: string = "") %} +
+ {% if title %}
{{ title }}
{% endif %} +
{% if content %}{{ content | safe }}{% else %}{{ body | safe }}{% endif %}
+
+{% endcomponent fusion.card %} + +{% component fusion.alert(message: string, variant: string = "info") %} + +{% endcomponent fusion.alert %} + +{# Status/pill badge. Set dot={true} for the success-style indicator used on the welcome page. #} +{% component fusion.badge(label: string, variant: string = "default", dot: bool = false) %} + + {%- if dot -%}{%- endif -%} + {{ label }} + +{% endcomponent fusion.badge %} + +{# Data table. Pass headers/rows as arrays, and/or nest custom markup as the body. + Set page_size={10} to paginate the rows array client-side (0 = off). #} +{% component fusion.table(headers: array = [], rows: array = [], caption: string = "", page_size: number = 0) %} +
0 %} data-page-size="{{ page_size }}"{% endif %}> + + {% if caption %}{% endif %} + {% if headers %} + + + {% for h in headers %}{% endfor %} + + + {% endif %} + + {% for row in rows %} + 0 %} data-fusion-row{% endif %}> + {% for cell in row %}{% endfor %} + + {% endfor %} + {{ body | safe }} + +
{{ caption }}
{{ h }}
{{ cell }}
+ {% if page_size > 0 %} + + {% endif %} +
+{% if page_size > 0 %} + +{% endif %} +{% endcomponent fusion.table %} diff --git a/crates/fusion-core/assets/templates/fusion/monitor.html b/crates/fusion-core/assets/templates/fusion/monitor.html new file mode 100644 index 0000000..f313215 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/monitor.html @@ -0,0 +1,85 @@ +{% extends "fusion/base.html" %} +{% block title %}{{ title | default(value="Fusion Monitor") }}{% endblock %} +{% block head %} + + +{% endblock %} +{% block content %} +
+
+

{{ title | default(value="Fusion Monitor") }}

+
+ {{}} + {{}} + {{}} + {{}} +
+
+ + {% %} + {% if empty_entries %} +

No keys in the process-wide cache.

+ {% else %} + {{}} + {% endif %} + {%
%} + +
+ + {% %} + {% if empty_events %} +

No set / delete / clear events yet.

+ {% else %} + {{}} + {% endif %} + {%
%} + +
+ + {% %} + {% if empty_tasks %} +

No process-wide Tokio background tasks tracked yet.

+ {% else %} + {{}} + {% endif %} + {%
%} + +
+ {{}} + {{}} +
+
+{% endblock %} diff --git a/crates/fusion-core/src/api_context.rs b/crates/fusion-core/src/api_context.rs index f35561a..6e5378e 100644 --- a/crates/fusion-core/src/api_context.rs +++ b/crates/fusion-core/src/api_context.rs @@ -32,11 +32,7 @@ impl ApiContext { } } - pub fn response( - body: Value, - status: u16, - headers: HashMap, - ) -> Value { + pub fn response(body: Value, status: u16, headers: HashMap) -> Value { build_response(body, status, headers) } } diff --git a/crates/fusion-core/src/cache.rs b/crates/fusion-core/src/cache.rs new file mode 100644 index 0000000..0bc321a --- /dev/null +++ b/crates/fusion-core/src/cache.rs @@ -0,0 +1,782 @@ +//! 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, +//! "max_events": 50 +//! }, +//! "monitor": { +//! "enabled": true, +//! "path": "/__fusion/monitor" +//! } +//! ``` +//! +//! The HTML/JSON panel is gated by top-level ``monitor.enabled`` (see +//! [`crate::monitor`]). Legacy ``cache.monitor.*`` is still accepted. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use moka::sync::Cache as MokaCache; +use serde_json::{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, + /// Ring-buffer size for recent set/delete/clear events. + pub max_events: usize, +} + +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, + max_events: 50, + } + } +} + +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"); + // Prefer cache.max_events; legacy cache.monitor.max_events still works. + if let Some(n) = settings + .get_u64("cache.max_events") + .or_else(|| settings.get_u64("cache.monitor.max_events")) + { + cfg.max_events = (n as usize).clamp(1, 10_000); + } + cfg + } +} + +/// Deprecated alias: prefer [`crate::monitor::MonitorConfig`]. +#[deprecated(note = "use fusion_core::monitor::MonitorConfig")] +pub type MonitorConfig = crate::monitor::MonitorConfig; + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[derive(Debug, Clone)] +struct CacheEvent { + op: String, + key: Option, + at_ms: u64, +} + +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, + events: Arc>>, + max_events: usize, +} + +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); + fn entries(&self) -> Vec<(String, Entry)>; +} + +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(); + } + + fn entries(&self) -> Vec<(String, Entry)> { + let mut out = Vec::new(); + for (key, entry) in self.store.iter() { + if entry.alive() { + out.push((key.as_ref().clone(), entry)); + } else { + self.store.invalidate(key.as_ref()); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } +} + +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, + events: Arc::new(Mutex::new(VecDeque::new())), + max_events: config.max_events.max(1), + }) + } + + fn record(&self, op: &str, key: Option<&str>) { + let Ok(mut guard) = self.events.lock() else { + return; + }; + guard.push_front(CacheEvent { + op: op.to_string(), + key: key.map(str::to_string), + at_ms: now_unix_ms(), + }); + while guard.len() > self.max_events { + guard.pop_back(); + } + } + + /// 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, + }, + ); + self.record("set", Some(key)); + } + + /// 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 { + let existed = self.inner.delete(key); + if existed { + self.record("delete", Some(key)); + } + existed + } + + /// 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(); + self.record("clear", None); + } + + /// Whether the Fusion monitor is enabled (from top-level ``monitor`` settings). + pub fn monitor_enabled(&self) -> bool { + let _ = self; + crate::monitor::enabled() + } + + /// Monitor UI path (from top-level ``monitor`` settings). + pub fn monitor_path(&self) -> String { + let _ = self; + crate::monitor::path() + } + + /// JSON snapshot for the monitor panel (entries + recent events + tasks). + pub fn snapshot(&self) -> Value { + let entries: Vec = self + .inner + .entries() + .into_iter() + .map(|(key, entry)| { + let ttl_remaining_secs = entry.expires_at.map(|at| { + at.saturating_duration_since(Instant::now()).as_secs() + }); + json!({ + "key": key, + "value": entry.value, + "ttl_remaining_secs": ttl_remaining_secs, + }) + }) + .collect(); + let events: Vec = self + .events + .lock() + .map(|g| { + g.iter() + .map(|e| { + json!({ + "op": e.op, + "key": e.key, + "at_ms": e.at_ms, + }) + }) + .collect() + }) + .unwrap_or_default(); + let mon = crate::monitor::current(); + let tasks = crate::tasks::snapshot(); + json!({ + "driver": self.driver, + "entry_count": entries.len(), + "event_count": events.len(), + "entries": entries, + "events": events, + "tasks": tasks, + "monitor": { + "enabled": mon.enabled, + "path": mon.path, + } + }) + } + + /// Template context for the Fusion monitor panel (cache + background tasks). + pub fn panel_context(&self) -> Value { + let snap = self.snapshot(); + let entries = snap["entries"].as_array().cloned().unwrap_or_default(); + let events = snap["events"].as_array().cloned().unwrap_or_default(); + let tasks = snap["tasks"]["tasks"].as_array().cloned().unwrap_or_default(); + let entry_rows: Vec = entries + .iter() + .map(|e| { + let key = e["key"].as_str().unwrap_or("").to_string(); + let value = display_cache_value(&e["value"]); + let ttl = match e.get("ttl_remaining_secs") { + Some(Value::Null) | None => "∞".to_string(), + Some(v) => v.to_string(), + }; + json!([key, value, ttl]) + }) + .collect(); + let event_rows: Vec = events + .iter() + .map(|e| { + let op = e["op"].as_str().unwrap_or("").to_string(); + let key = e + .get("key") + .and_then(|k| k.as_str()) + .unwrap_or("—") + .to_string(); + let at = e + .get("at_ms") + .map(|v| v.to_string()) + .unwrap_or_else(|| "—".into()); + json!([op, key, at]) + }) + .collect(); + let task_rows: Vec = tasks + .iter() + .map(|t| { + let id = t["id"].as_str().unwrap_or("").to_string(); + let status = t["status"].as_str().unwrap_or("").to_string(); + let delay = match t.get("delay_ms") { + Some(Value::Null) | None => "—".to_string(), + Some(v) => v.to_string(), + }; + let created = t + .get("created_at_ms") + .map(|v| v.to_string()) + .unwrap_or_else(|| "—".into()); + json!([id, status, delay, created]) + }) + .collect(); + let path = crate::monitor::normalize_path(&self.monitor_path()); + let entry_count = entries.len(); + let event_count = events.len(); + let task_count = snap["tasks"]["task_count"].as_u64().unwrap_or(0) as usize; + let active_count = snap["tasks"]["active_count"].as_u64().unwrap_or(0) as usize; + json!({ + "title": "Fusion Monitor", + "driver": self.driver, + "driver_label": self.driver, + "entry_count": entry_count, + "event_count": event_count, + "task_count": task_count, + "active_task_count": active_count, + "entry_badge": format!("{entry_count} keys"), + "event_badge": format!("{event_count} events"), + "task_badge": format!("{active_count}/{task_count} tasks"), + "empty_entries": entry_count == 0, + "empty_events": event_count == 0, + "empty_tasks": task_count == 0, + "entry_headers": ["Key", "Value", "TTL (s)"], + "entry_rows": entry_rows, + "event_headers": ["Op", "Key", "Time (ms)"], + "event_rows": event_rows, + "task_headers": ["Id", "Status", "Delay (ms)", "Created (ms)"], + "task_rows": task_rows, + "path": path, + "json_path": format!("{path}/json"), + }) + } +} + +fn display_cache_value(value: &Value) -> String { + let raw = serde_json::to_string(value).unwrap_or_else(|_| "null".into()); + if raw.chars().count() > 160 { + let truncated: String = raw.chars().take(157).collect(); + format!("{truncated}...") + } else { + raw + } +} + +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> { + crate::monitor::configure_from_settings(settings); + 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()) +} + +/// Process-wide monitor snapshot (entries + events). +pub fn snapshot() -> Result { + with_global(|c| c.snapshot()) +} + +/// Template context for the built-in monitor HTML panel. +pub fn panel_context() -> Result { + with_global(|c| c.panel_context()) +} + +/// Whether the global cache wants the monitor mounted. +pub fn monitor_path() -> Result { + Ok(crate::monitor::path()) +} + +/// Whether the Fusion monitor should be mounted. +pub fn monitor_enabled() -> Result { + Ok(crate::monitor::enabled()) +} + +/// 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 snapshot_lists_entries_and_events() { + crate::monitor::configure(crate::monitor::MonitorConfig { + enabled: true, + path: "/__fusion/monitor".into(), + }); + let cache = Cache::open(CacheConfig { + default_ttl: None, + max_events: 10, + ..CacheConfig::default() + }) + .unwrap(); + cache.set("a", json!(1), None); + cache.set("b", json!({"x": true}), None); + let _ = cache.delete("a"); + let snap = cache.snapshot(); + assert_eq!(snap["driver"], "moka"); + assert_eq!(snap["entry_count"], 1); + assert_eq!(snap["monitor"]["enabled"], true); + assert_eq!(snap["monitor"]["path"], "/__fusion/monitor"); + let entries = snap["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["key"], "b"); + let events = snap["events"].as_array().unwrap(); + assert!(events.len() >= 2); + assert_eq!(events[0]["op"], "delete"); + assert!(snap["tasks"].is_object()); + assert!(snap["tasks"]["tasks"].is_array()); + let ctx = cache.panel_context(); + assert_eq!(ctx["title"], "Fusion Monitor"); + assert_eq!(ctx["task_headers"][0], "Id"); + assert!(ctx["task_badge"].as_str().unwrap_or("").contains("tasks")); + } + + #[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/dispatch.rs b/crates/fusion-core/src/dispatch.rs index f476005..20b4232 100644 --- a/crates/fusion-core/src/dispatch.rs +++ b/crates/fusion-core/src/dispatch.rs @@ -102,10 +102,7 @@ pub fn coerce_value(raw: &Value, kind: ParamKind) -> Result { } /// Bind handler arguments: path first, then query (read) or JSON body (write). -pub fn bind_args( - specs: &[ParamSpec], - req: &Request, -) -> Result, HttpError> { +pub fn bind_args(specs: &[ParamSpec], req: &Request) -> Result, HttpError> { let http_method = req.method.to_ascii_uppercase(); let body_fields = if BODY_METHODS.contains(&http_method.as_str()) { parse_json_object(&req.body_str()) @@ -156,11 +153,7 @@ pub fn bind_args( } /// Build a framework response envelope from parts. -pub fn build_response( - body: Value, - status: u16, - headers: HashMap, -) -> Value { +pub fn build_response(body: Value, status: u16, headers: HashMap) -> Value { let mut hdrs = headers; if !body.is_string() && !body.is_null() { hdrs.entry("content-type".into()) @@ -226,10 +219,7 @@ mod tests { }]; let request = req("GET", &[], &[("q", "hello")], ""); let args = bind_args(&specs, &request).unwrap(); - assert_eq!( - args.get("q"), - Some(&Value::String("hello".into())) - ); + assert_eq!(args.get("q"), Some(&Value::String("hello".into()))); } #[test] @@ -242,10 +232,7 @@ mod tests { }]; let request = req("POST", &[], &[], r#"{"name":"alice"}"#); let args = bind_args(&specs, &request).unwrap(); - assert_eq!( - args.get("name"), - Some(&Value::String("alice".into())) - ); + assert_eq!(args.get("name"), Some(&Value::String("alice".into()))); } #[test] diff --git a/crates/fusion-core/src/headers.rs b/crates/fusion-core/src/headers.rs index 82b4494..f27db5e 100644 --- a/crates/fusion-core/src/headers.rs +++ b/crates/fusion-core/src/headers.rs @@ -92,9 +92,7 @@ pub fn fingerprint_header_pairs() -> [(&'static str, String); 3] { /// Insert fingerprint headers if the response does not already set them. pub fn apply_fingerprint_headers(headers: &mut Vec<(String, String)>) { for (name, value) in fingerprint_header_pairs() { - let exists = headers - .iter() - .any(|(n, _)| n.eq_ignore_ascii_case(name)); + let exists = headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)); if !exists { headers.push((name.to_string(), value)); } @@ -184,11 +182,23 @@ pub const HTTP_HEADER_CONSTANTS: &[(&str, &str)] = &[ ("ACCESS_CONTROL_ALLOW_ORIGIN", ACCESS_CONTROL_ALLOW_ORIGIN), ("ACCESS_CONTROL_ALLOW_METHODS", ACCESS_CONTROL_ALLOW_METHODS), ("ACCESS_CONTROL_ALLOW_HEADERS", ACCESS_CONTROL_ALLOW_HEADERS), - ("ACCESS_CONTROL_ALLOW_CREDENTIALS", ACCESS_CONTROL_ALLOW_CREDENTIALS), - ("ACCESS_CONTROL_EXPOSE_HEADERS", ACCESS_CONTROL_EXPOSE_HEADERS), + ( + "ACCESS_CONTROL_ALLOW_CREDENTIALS", + ACCESS_CONTROL_ALLOW_CREDENTIALS, + ), + ( + "ACCESS_CONTROL_EXPOSE_HEADERS", + ACCESS_CONTROL_EXPOSE_HEADERS, + ), ("ACCESS_CONTROL_MAX_AGE", ACCESS_CONTROL_MAX_AGE), - ("ACCESS_CONTROL_REQUEST_METHOD", ACCESS_CONTROL_REQUEST_METHOD), - ("ACCESS_CONTROL_REQUEST_HEADERS", ACCESS_CONTROL_REQUEST_HEADERS), + ( + "ACCESS_CONTROL_REQUEST_METHOD", + ACCESS_CONTROL_REQUEST_METHOD, + ), + ( + "ACCESS_CONTROL_REQUEST_HEADERS", + ACCESS_CONTROL_REQUEST_HEADERS, + ), ("X_POWERED_BY", X_POWERED_BY), ("X_FRAMEWORK", X_FRAMEWORK), ("X_FUSION_VERSION", X_FUSION_VERSION), @@ -300,7 +310,10 @@ pub fn headers_map(pairs: &[(&str, String)]) -> BTreeMap { /// `{ Content-Disposition: attachment; filename="..." }` pub fn attachment(filename: &str) -> BTreeMap { - headers_map(&[(CONTENT_DISPOSITION, content_disposition_attachment(filename))]) + headers_map(&[( + CONTENT_DISPOSITION, + content_disposition_attachment(filename), + )]) } /// `{ Content-Disposition: inline; filename="..." }` (filename optional). @@ -331,6 +344,43 @@ pub fn download(filename: &str, media_type: Option<&str>) -> BTreeMap, format_query: Option<&str>) -> bool { + if matches!(format_query, Some(f) if f.eq_ignore_ascii_case("json")) { + return true; + } + let accept = accept.unwrap_or("").trim(); + if accept.is_empty() { + return false; + } + + let mut best_json = -1.0f32; + let mut best_html = -1.0f32; + + for part in accept.split(',') { + let mut tokens = part.trim().split(';').map(str::trim); + let media = tokens.next().unwrap_or("").to_ascii_lowercase(); + let mut q = 1.0f32; + for token in tokens { + if let Some(val) = token.strip_prefix("q=") { + if let Ok(parsed) = val.parse::() { + q = parsed; + } + } + } + match media.as_str() { + "application/json" | "text/json" => best_json = best_json.max(q), + "text/html" | "application/xhtml+xml" => best_html = best_html.max(q), + _ => {} + } + } + + best_json > 0.0 && best_json >= best_html +} + #[cfg(test)] mod tests { use super::*; @@ -356,4 +406,23 @@ mod tests { assert_eq!(h.get(CONTENT_TYPE).map(String::as_str), Some(TEXT_CSV)); assert!(h.get(CONTENT_DISPOSITION).unwrap().contains("attachment")); } + + #[test] + fn prefers_json_from_query() { + assert!(prefers_json(None, Some("json"))); + assert!(!prefers_json(None, Some("html"))); + } + + #[test] + fn prefers_json_from_accept() { + assert!(prefers_json(Some("application/json"), None)); + assert!(!prefers_json( + Some("text/html,application/xhtml+xml,application/xml;q=0.9"), + None + )); + assert!(prefers_json( + Some("text/html;q=0.5,application/json;q=0.9"), + None + )); + } } diff --git a/crates/fusion-core/src/lib.rs b/crates/fusion-core/src/lib.rs index 7102b9a..b8a37e1 100644 --- a/crates/fusion-core/src/lib.rs +++ b/crates/fusion-core/src/lib.rs @@ -1,10 +1,12 @@ mod api_context; +pub mod cache; mod coerce; mod dispatch; mod error; mod handler; mod headers; mod http_error; +pub mod monitor; mod naming; mod pagination; mod request; @@ -14,34 +16,47 @@ mod serialize; mod server; mod settings; mod status; +mod tasks; +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::{ParamSpec, bind_args, build_response, parse_json_object, BODY_METHODS}; +pub use dispatch::{BODY_METHODS, ParamSpec, bind_args, build_response, parse_json_object}; pub use error::{Error, Result}; pub use handler::{Handler, HandlerFuture, SyncHandler}; pub use headers::{ - apply_fingerprint_headers, attachment, cache_control, content_disposition_attachment, - content_disposition_inline, content_type, content_type_value, download, fingerprint_headers, - framework_version, inline, location, FRAMEWORK_ID, FRAMEWORK_POWERED_BY, HTTP_HEADER_CONSTANTS, - X_FRAMEWORK, X_FUSION_VERSION, X_POWERED_BY, + FRAMEWORK_ID, FRAMEWORK_POWERED_BY, HTTP_HEADER_CONSTANTS, X_FRAMEWORK, X_FUSION_VERSION, + X_POWERED_BY, apply_fingerprint_headers, attachment, cache_control, + content_disposition_attachment, content_disposition_inline, content_type, content_type_value, + download, fingerprint_headers, framework_version, inline, location, prefers_json, }; pub use http_error::HttpError; pub use naming::{ HTTP_METHODS, api_action_name, api_resource_name, join_route_paths, resolve_handler_route, resolve_method_route_path, resolve_route_path, }; -pub use pagination::{ - PageConfig, PageParams, paginate_slice, paginated_body, parse_page_params, -}; -pub use request::{parse_query, Request}; +pub use pagination::{PageConfig, PageParams, paginate_slice, paginated_body, parse_page_params}; +pub use request::{Request, parse_query}; pub use response::Response; pub use router::Router; pub use serialize::{is_response_envelope, response_from_value}; pub use settings::Settings; +pub use monitor::{MonitorConfig, DEFAULT_PATH as MONITOR_DEFAULT_PATH}; pub use status::{ HTTP_STATUS_CODES, is_client_error, is_informational, is_redirect, is_server_error, is_success, }; +pub use tasks::{ + TaskStatus, cancel as task_cancel, reset_for_tests as reset_tasks, snapshot as task_snapshot, + spawn_after_ms, spawn_after_ms_future, spawn_fn, spawn_future, status as task_status, +}; +pub use templates::{builtin_components, clear_template_cache, render_template}; use std::net::SocketAddr; @@ -91,7 +106,7 @@ impl App { let fingerprint = self .settings .get_bool("fingerprint.enabled") - .unwrap_or(true); + .unwrap_or(false); if fingerprint { server::listen(self.router, addr).await } else { diff --git a/crates/fusion-core/src/main.rs b/crates/fusion-core/src/main.rs index e887a61..c2c889e 100644 --- a/crates/fusion-core/src/main.rs +++ b/crates/fusion-core/src/main.rs @@ -3,7 +3,11 @@ use fusion_core::{App, Request, Response, SyncHandler}; #[tokio::main] async fn main() { let mut app = App::new(); - app.route("GET", "/", SyncHandler(|_req: Request| Response::text(200, "ok"))); + app.route( + "GET", + "/", + SyncHandler(|_req: Request| Response::text(200, "ok")), + ); app.route( "GET", "/api/[module]/{id}", diff --git a/crates/fusion-core/src/monitor.rs b/crates/fusion-core/src/monitor.rs new file mode 100644 index 0000000..ad93ba5 --- /dev/null +++ b/crates/fusion-core/src/monitor.rs @@ -0,0 +1,155 @@ +//! Process-wide Fusion monitor panel settings (cache + background tasks UI). +//! +//! Top-level settings (preferred): +//! ```json +//! "monitor": { +//! "enabled": true, +//! "path": "/__fusion/monitor" +//! } +//! ``` +//! +//! Legacy fallback: ``cache.monitor.enabled`` / ``cache.monitor.path``. + +use std::sync::{OnceLock, RwLock}; + +use crate::settings::Settings; + +/// Default URL for the built-in HTML + JSON monitor. +pub const DEFAULT_PATH: &str = "/__fusion/monitor"; + +/// Gate + path for the Fusion monitor panel (independent of ``cache.*``). +#[derive(Debug, Clone)] +pub struct MonitorConfig { + pub enabled: bool, + pub path: String, +} + +impl Default for MonitorConfig { + fn default() -> Self { + Self { + enabled: false, + path: DEFAULT_PATH.to_string(), + } + } +} + +impl MonitorConfig { + /// Read ``monitor.*``, falling back to legacy ``cache.monitor.*``. + pub fn from_settings(settings: &Settings) -> Self { + let mut cfg = Self::default(); + cfg.enabled = settings + .get_bool("monitor.enabled") + .or_else(|| settings.get_bool("cache.monitor.enabled")) + .unwrap_or(false); + let path = settings + .get_str("monitor.path") + .or_else(|| settings.get_str("cache.monitor.path")); + if let Some(path) = path { + let trimmed = path.trim(); + if !trimmed.is_empty() { + cfg.path = normalize_path(trimmed); + } + } + cfg + } +} + +/// Normalize monitor URL path (leading slash, no trailing slash). +pub fn normalize_path(raw: &str) -> String { + let trimmed = raw.trim(); + let with_slash = if trimmed.is_empty() { + DEFAULT_PATH.to_string() + } else if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + }; + let stripped = with_slash.trim_end_matches('/'); + if stripped.is_empty() { + DEFAULT_PATH.to_string() + } else { + stripped.to_string() + } +} + +static GLOBAL: OnceLock> = OnceLock::new(); + +fn slot() -> &'static RwLock { + GLOBAL.get_or_init(|| RwLock::new(MonitorConfig::default())) +} + +/// Install monitor settings from Fusion env JSON. +pub fn configure_from_settings(settings: &Settings) { + configure(MonitorConfig::from_settings(settings)); +} + +/// Replace the process-wide monitor config. +pub fn configure(cfg: MonitorConfig) { + if let Ok(mut guard) = slot().write() { + *guard = cfg; + } +} + +/// Current monitor config (defaults if never configured). +pub fn current() -> MonitorConfig { + slot() + .read() + .map(|g| g.clone()) + .unwrap_or_default() +} + +/// Whether the monitor should be mounted. +pub fn enabled() -> bool { + current().enabled +} + +/// Monitor UI path (JSON at ``{path}/json``). +pub fn path() -> String { + current().path +} + +/// Reset monitor config (tests). +pub fn reset() { + if let Ok(mut guard) = slot().write() { + *guard = MonitorConfig::default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prefers_top_level_monitor_settings() { + let mut s = Settings::new(); + s.merge_map( + json!({ + "monitor": { "enabled": true, "path": "/ops" }, + "cache": { "monitor": { "enabled": false, "path": "/old" } } + }) + .as_object() + .unwrap() + .clone(), + ); + let cfg = MonitorConfig::from_settings(&s); + assert!(cfg.enabled); + assert_eq!(cfg.path, "/ops"); + } + + #[test] + fn falls_back_to_legacy_cache_monitor() { + let mut s = Settings::new(); + s.merge_map( + json!({ + "cache": { "monitor": { "enabled": true, "path": "/__fusion/cache" } } + }) + .as_object() + .unwrap() + .clone(), + ); + let cfg = MonitorConfig::from_settings(&s); + assert!(cfg.enabled); + assert_eq!(cfg.path, "/__fusion/cache"); + } +} diff --git a/crates/fusion-core/src/naming.rs b/crates/fusion-core/src/naming.rs index 054026b..0700ef8 100644 --- a/crates/fusion-core/src/naming.rs +++ b/crates/fusion-core/src/naming.rs @@ -6,17 +6,9 @@ pub const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "he /// `MyFirstModule` → `myfirst`; names without a `Module`/`MODULE` suffix are lowercased as-is. pub fn api_resource_name(class_name: &str) -> String { let stem = if let Some(stem) = class_name.strip_suffix("Module") { - if !stem.is_empty() { - stem - } else { - class_name - } + if !stem.is_empty() { stem } else { class_name } } else if let Some(stem) = class_name.strip_suffix("MODULE") { - if !stem.is_empty() { - stem - } else { - class_name - } + if !stem.is_empty() { stem } else { class_name } } else { class_name }; @@ -26,17 +18,9 @@ pub fn api_resource_name(class_name: &str) -> String { /// `UserAction` → `user`; strips trailing `Action` / `ACTION` then lowercases. pub fn api_action_name(method_name: &str) -> String { let stem = if let Some(stem) = method_name.strip_suffix("Action") { - if !stem.is_empty() { - stem - } else { - method_name - } + if !stem.is_empty() { stem } else { method_name } } else if let Some(stem) = method_name.strip_suffix("ACTION") { - if !stem.is_empty() { - stem - } else { - method_name - } + if !stem.is_empty() { stem } else { method_name } } else { method_name }; @@ -61,7 +45,11 @@ pub fn join_route_paths(base: &str, segment: &str) -> String { let base = base.trim_end_matches('/'); let segment = segment.trim_matches('/'); if segment.is_empty() { - return if base.is_empty() { "/".into() } else { base.to_string() }; + return if base.is_empty() { + "/".into() + } else { + base.to_string() + }; } if base.is_empty() { return format!("/{segment}"); diff --git a/crates/fusion-core/src/pagination.rs b/crates/fusion-core/src/pagination.rs index e1fb861..8c27ea9 100644 --- a/crates/fusion-core/src/pagination.rs +++ b/crates/fusion-core/src/pagination.rs @@ -126,10 +126,7 @@ pub fn paginate_slice(items: &[T], params: &PageParams) -> Vec { return Vec::new(); } let start = params.offset as usize; - let end = params - .offset - .saturating_add(params.page_size) - .min(len) as usize; + let end = params.offset.saturating_add(params.page_size).min(len) as usize; items[start..end].to_vec() } @@ -159,9 +156,11 @@ mod tests { #[test] fn parses_page_and_page_size() { - let params = - parse_page_params(&q(&[("page", "3"), ("page_size", "10")]), &PageConfig::default()) - .unwrap(); + let params = parse_page_params( + &q(&[("page", "3"), ("page_size", "10")]), + &PageConfig::default(), + ) + .unwrap(); assert_eq!( params, PageParams { diff --git a/crates/fusion-core/src/serialize.rs b/crates/fusion-core/src/serialize.rs index 01d58d6..c888207 100644 --- a/crates/fusion-core/src/serialize.rs +++ b/crates/fusion-core/src/serialize.rs @@ -147,11 +147,7 @@ mod tests { let res = response_from_value(json!({"status": 204})); assert_eq!(res.status, 204); assert!(res.body.is_empty()); - assert!( - !res.headers - .iter() - .any(|(k, _)| k == "content-type") - ); + assert!(!res.headers.iter().any(|(k, _)| k == "content-type")); } #[test] diff --git a/crates/fusion-core/src/server.rs b/crates/fusion-core/src/server.rs index 1655ba8..ced7f0c 100644 --- a/crates/fusion-core/src/server.rs +++ b/crates/fusion-core/src/server.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Instant; use bytes::Bytes; -use console::{style, Term}; +use console::{Term, style}; use http_body_util::{BodyExt, Full}; use hyper::body::Incoming; use hyper::server::conn::http1; @@ -17,20 +17,20 @@ use tokio::signal; use crate::error::{Error, Result}; use crate::headers::apply_fingerprint_headers; -use crate::request::{parse_query, Request}; +use crate::request::{Request, parse_query}; use crate::response::Response; use crate::router::Router; /// Options for [`listen_with`]. #[derive(Debug, Clone)] pub struct ListenOptions { - /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: true. + /// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: false. pub fingerprint: bool, } impl Default for ListenOptions { fn default() -> Self { - Self { fingerprint: true } + Self { fingerprint: false } } } @@ -175,8 +175,7 @@ async fn handle_request( Err(_) => Bytes::new(), }; - let request = - Request::new(method.clone(), path.clone(), headers, body_bytes).with_query(query); + let request = Request::new(method.clone(), path.clone(), headers, body_bytes).with_query(query); let mut response = router.dispatch(request).await; if options.fingerprint { @@ -197,14 +196,12 @@ fn to_hyper_response(response: Response) -> HyperResponse> { for (name, value) in &response.headers { builder = builder.header(name.as_str(), value.as_str()); } - builder - .body(Full::new(response.body)) - .unwrap_or_else(|_| { - HyperResponse::builder() - .status(500) - .body(Full::new(Bytes::from_static(b"Internal Server Error"))) - .expect("fallback response") - }) + builder.body(Full::new(response.body)).unwrap_or_else(|_| { + HyperResponse::builder() + .status(500) + .body(Full::new(Bytes::from_static(b"Internal Server Error"))) + .expect("fallback response") + }) } pub fn parse_addr(host: &str, port: u16) -> Result { diff --git a/crates/fusion-core/src/settings.rs b/crates/fusion-core/src/settings.rs index 38208b5..a42e2c9 100644 --- a/crates/fusion-core/src/settings.rs +++ b/crates/fusion-core/src/settings.rs @@ -22,9 +22,13 @@ fn resolve_value(value: &Value) -> Value { Value::String(s) if s.len() > 1 && s.chars().all(|c| c.is_ascii_uppercase() || c == '_') - && s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') => + && s.chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') => { - env::var(s).map(Value::String).unwrap_or_else(|_| value.clone()) + env::var(s) + .map(Value::String) + .unwrap_or_else(|_| value.clone()) } Value::Array(items) => Value::Array(items.iter().map(resolve_value).collect()), Value::Object(map) => { @@ -93,6 +97,21 @@ impl Settings { self.get_bool("debug").unwrap_or(false) } + /// When true, host bindings should restart the process on source changes. + /// Default is ``false`` (no reload). Override with ``listen(reload=...)``. + pub fn reload(&self) -> bool { + self.get_bool("reload") + .or_else(|| self.get_bool("reload.enabled")) + .unwrap_or(false) + } + + /// Directory for Tera templates (``templates.dir`` in settings). + pub fn templates_dir(&self) -> String { + self.get_str("templates.dir") + .or_else(|| self.get_str("templates_dir")) + .unwrap_or_else(|| "templates".into()) + } + /// Load ``fusion..json`` (auto-discover) or an explicit path. /// /// `extra_roots` are searched after the process cwd (e.g. `__main__` dir). @@ -105,14 +124,17 @@ impl Settings { let path = match path { Some(p) => { if !p.is_file() { - return Err(Error::Other(format!("settings json not found: {}", p.display()))); + return Err(Error::Other(format!( + "settings json not found: {}", + p.display() + ))); } p.to_path_buf() } None => { - let env_name = env_name - .map(str::to_string) - .unwrap_or_else(|| env::var("FUSION_ENV").unwrap_or_else(|_| self.env_name.clone())); + let env_name = env_name.map(str::to_string).unwrap_or_else(|| { + env::var("FUSION_ENV").unwrap_or_else(|_| self.env_name.clone()) + }); self.env_name = env_name.clone(); match find_json_file(&env_name, extra_roots) { Some(p) => p, @@ -157,7 +179,8 @@ impl Settings { .or_insert_with(|| Value::Object(commands.clone())); } - self.loaded_from.push(path.canonicalize().unwrap_or(path).display().to_string()); + self.loaded_from + .push(path.canonicalize().unwrap_or(path).display().to_string()); self.auto_loaded = true; Ok(self) } @@ -346,9 +369,7 @@ mod tests { .unwrap(); let mut settings = Settings::new(); - settings - .load_json(Some(&path), None, &[]) - .unwrap(); + settings.load_json(Some(&path), None, &[]).unwrap(); assert_eq!(settings.host(), "0.0.0.0"); assert_eq!(settings.port(), 8088); assert!(settings.debug()); @@ -365,6 +386,19 @@ mod tests { assert_eq!(settings.get_str("secret_key").as_deref(), Some("abc")); } + #[test] + fn reload_defaults_false() { + let settings = Settings::new(); + assert!(!settings.reload()); + let mut settings = Settings::new(); + settings.merge_map({ + let mut m = Map::new(); + m.insert("reload".into(), json!(true)); + m + }); + assert!(settings.reload()); + } + fn tempfile_dir() -> PathBuf { let dir = env::temp_dir().join(format!("fusion-settings-{}", std::process::id())); let _ = fs::create_dir_all(&dir); diff --git a/crates/fusion-core/src/status.rs b/crates/fusion-core/src/status.rs index ce8a57a..b35ab08 100644 --- a/crates/fusion-core/src/status.rs +++ b/crates/fusion-core/src/status.rs @@ -85,7 +85,10 @@ pub const HTTP_STATUS_CODES: &[(&str, u16)] = &[ ("HTTP_SUCCESS", HTTP_SUCCESS), ("HTTP_201_CREATED", HTTP_201_CREATED), ("HTTP_202_ACCEPTED", HTTP_202_ACCEPTED), - ("HTTP_203_NON_AUTHORITATIVE_INFORMATION", HTTP_203_NON_AUTHORITATIVE_INFORMATION), + ( + "HTTP_203_NON_AUTHORITATIVE_INFORMATION", + HTTP_203_NON_AUTHORITATIVE_INFORMATION, + ), ("HTTP_204_NO_CONTENT", HTTP_204_NO_CONTENT), ("HTTP_205_RESET_CONTENT", HTTP_205_RESET_CONTENT), ("HTTP_206_PARTIAL_CONTENT", HTTP_206_PARTIAL_CONTENT), @@ -108,40 +111,85 @@ pub const HTTP_STATUS_CODES: &[(&str, u16)] = &[ ("HTTP_404_NOT_FOUND", HTTP_404_NOT_FOUND), ("HTTP_405_METHOD_NOT_ALLOWED", HTTP_405_METHOD_NOT_ALLOWED), ("HTTP_406_NOT_ACCEPTABLE", HTTP_406_NOT_ACCEPTABLE), - ("HTTP_407_PROXY_AUTHENTICATION_REQUIRED", HTTP_407_PROXY_AUTHENTICATION_REQUIRED), + ( + "HTTP_407_PROXY_AUTHENTICATION_REQUIRED", + HTTP_407_PROXY_AUTHENTICATION_REQUIRED, + ), ("HTTP_408_REQUEST_TIMEOUT", HTTP_408_REQUEST_TIMEOUT), ("HTTP_409_CONFLICT", HTTP_409_CONFLICT), ("HTTP_410_GONE", HTTP_410_GONE), ("HTTP_411_LENGTH_REQUIRED", HTTP_411_LENGTH_REQUIRED), ("HTTP_412_PRECONDITION_FAILED", HTTP_412_PRECONDITION_FAILED), - ("HTTP_413_REQUEST_ENTITY_TOO_LARGE", HTTP_413_REQUEST_ENTITY_TOO_LARGE), - ("HTTP_414_REQUEST_URI_TOO_LONG", HTTP_414_REQUEST_URI_TOO_LONG), - ("HTTP_415_UNSUPPORTED_MEDIA_TYPE", HTTP_415_UNSUPPORTED_MEDIA_TYPE), - ("HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE", HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE), + ( + "HTTP_413_REQUEST_ENTITY_TOO_LARGE", + HTTP_413_REQUEST_ENTITY_TOO_LARGE, + ), + ( + "HTTP_414_REQUEST_URI_TOO_LONG", + HTTP_414_REQUEST_URI_TOO_LONG, + ), + ( + "HTTP_415_UNSUPPORTED_MEDIA_TYPE", + HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ), + ( + "HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE", + HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE, + ), ("HTTP_417_EXPECTATION_FAILED", HTTP_417_EXPECTATION_FAILED), ("HTTP_418_IM_A_TEAPOT", HTTP_418_IM_A_TEAPOT), ("HTTP_421_MISDIRECTED_REQUEST", HTTP_421_MISDIRECTED_REQUEST), - ("HTTP_422_UNPROCESSABLE_ENTITY", HTTP_422_UNPROCESSABLE_ENTITY), + ( + "HTTP_422_UNPROCESSABLE_ENTITY", + HTTP_422_UNPROCESSABLE_ENTITY, + ), ("HTTP_423_LOCKED", HTTP_423_LOCKED), ("HTTP_424_FAILED_DEPENDENCY", HTTP_424_FAILED_DEPENDENCY), ("HTTP_425_TOO_EARLY", HTTP_425_TOO_EARLY), ("HTTP_426_UPGRADE_REQUIRED", HTTP_426_UPGRADE_REQUIRED), - ("HTTP_428_PRECONDITION_REQUIRED", HTTP_428_PRECONDITION_REQUIRED), + ( + "HTTP_428_PRECONDITION_REQUIRED", + HTTP_428_PRECONDITION_REQUIRED, + ), ("HTTP_429_TOO_MANY_REQUESTS", HTTP_429_TOO_MANY_REQUESTS), - ("HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE", HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE), - ("HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS", HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS), - ("HTTP_500_INTERNAL_SERVER_ERROR", HTTP_500_INTERNAL_SERVER_ERROR), + ( + "HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE", + HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE, + ), + ( + "HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS", + HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS, + ), + ( + "HTTP_500_INTERNAL_SERVER_ERROR", + HTTP_500_INTERNAL_SERVER_ERROR, + ), ("HTTP_501_NOT_IMPLEMENTED", HTTP_501_NOT_IMPLEMENTED), ("HTTP_502_BAD_GATEWAY", HTTP_502_BAD_GATEWAY), ("HTTP_503_SERVICE_UNAVAILABLE", HTTP_503_SERVICE_UNAVAILABLE), ("HTTP_504_GATEWAY_TIMEOUT", HTTP_504_GATEWAY_TIMEOUT), - ("HTTP_505_HTTP_VERSION_NOT_SUPPORTED", HTTP_505_HTTP_VERSION_NOT_SUPPORTED), - ("HTTP_506_VARIANT_ALSO_NEGOTIATES", HTTP_506_VARIANT_ALSO_NEGOTIATES), - ("HTTP_507_INSUFFICIENT_STORAGE", HTTP_507_INSUFFICIENT_STORAGE), + ( + "HTTP_505_HTTP_VERSION_NOT_SUPPORTED", + HTTP_505_HTTP_VERSION_NOT_SUPPORTED, + ), + ( + "HTTP_506_VARIANT_ALSO_NEGOTIATES", + HTTP_506_VARIANT_ALSO_NEGOTIATES, + ), + ( + "HTTP_507_INSUFFICIENT_STORAGE", + HTTP_507_INSUFFICIENT_STORAGE, + ), ("HTTP_508_LOOP_DETECTED", HTTP_508_LOOP_DETECTED), - ("HTTP_509_BANDWIDTH_LIMIT_EXCEEDED", HTTP_509_BANDWIDTH_LIMIT_EXCEEDED), + ( + "HTTP_509_BANDWIDTH_LIMIT_EXCEEDED", + HTTP_509_BANDWIDTH_LIMIT_EXCEEDED, + ), ("HTTP_510_NOT_EXTENDED", HTTP_510_NOT_EXTENDED), - ("HTTP_511_NETWORK_AUTHENTICATION_REQUIRED", HTTP_511_NETWORK_AUTHENTICATION_REQUIRED), + ( + "HTTP_511_NETWORK_AUTHENTICATION_REQUIRED", + HTTP_511_NETWORK_AUTHENTICATION_REQUIRED, + ), ]; pub fn is_informational(code: u16) -> bool { diff --git a/crates/fusion-core/src/tasks.rs b/crates/fusion-core/src/tasks.rs new file mode 100644 index 0000000..720ea87 --- /dev/null +++ b/crates/fusion-core/src/tasks.rs @@ -0,0 +1,464 @@ +//! Process-wide Tokio background tasks. +//! +//! Bindings spawn host-language callbacks as sync `FnOnce` jobs or async futures +//! on a dedicated multi-thread runtime (independent of HTTP `listen`). + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +/// How many terminal (done/cancelled/failed) tasks to retain for the monitor. +const MAX_TERMINAL_RETAINED: usize = 100; + +/// Lifecycle of a background task. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskStatus { + Pending, + Running, + Done, + Cancelled, + Failed, +} + +impl TaskStatus { + fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Running, + 2 => Self::Done, + 3 => Self::Cancelled, + 4 => Self::Failed, + _ => Self::Pending, + } + } + + fn as_u8(self) -> u8 { + match self { + Self::Pending => 0, + Self::Running => 1, + Self::Done => 2, + Self::Cancelled => 3, + Self::Failed => 4, + } + } + + /// Stable string for language bindings. + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Running => "running", + Self::Done => "done", + Self::Cancelled => "cancelled", + Self::Failed => "failed", + } + } + + /// Whether the task is still scheduled or executing. + fn is_active(self) -> bool { + matches!(self, Self::Pending | Self::Running) + } + + /// Parse a status name (case-insensitive). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "pending" => Some(Self::Pending), + "running" => Some(Self::Running), + "done" => Some(Self::Done), + "cancelled" | "canceled" => Some(Self::Cancelled), + "failed" => Some(Self::Failed), + _ => None, + } + } +} + +struct TaskEntry { + status: Arc, + handle: JoinHandle<()>, + created_at_ms: u64, + delay_ms: Option, +} + +struct TaskRegistry { + next_id: AtomicU64, + entries: Mutex>, +} + +impl TaskRegistry { + fn new() -> Self { + Self { + next_id: AtomicU64::new(1), + entries: Mutex::new(HashMap::new()), + } + } + + fn alloc_id(&self) -> String { + let n = self.next_id.fetch_add(1, Ordering::Relaxed); + format!("task-{n}") + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn runtime() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("fusion-tasks") + .build() + .expect("fusion background task runtime") + }) +} + +fn registry() -> &'static TaskRegistry { + static REG: OnceLock = OnceLock::new(); + REG.get_or_init(TaskRegistry::new) +} + +/// Drop oldest terminal tasks so the registry cannot grow without bound. +fn prune_terminal(entries: &mut HashMap) { + let mut terminal: Vec<(String, u64)> = entries + .iter() + .filter_map(|(id, e)| { + let st = TaskStatus::from_u8(e.status.load(Ordering::SeqCst)); + if st.is_active() { + None + } else { + Some((id.clone(), e.created_at_ms)) + } + }) + .collect(); + if terminal.len() <= MAX_TERMINAL_RETAINED { + return; + } + terminal.sort_by_key(|(_, created)| *created); + let remove_n = terminal.len() - MAX_TERMINAL_RETAINED; + for (id, _) in terminal.into_iter().take(remove_n) { + entries.remove(&id); + } +} + +/// Spawn a job immediately on the background Tokio runtime. Returns task id. +pub fn spawn_fn(job: impl FnOnce() + Send + 'static) -> String { + spawn_inner(None, Box::new(job)) +} + +/// Spawn a job after `delay_ms` milliseconds. Returns task id. +pub fn spawn_after_ms(delay_ms: u64, job: impl FnOnce() + Send + 'static) -> String { + spawn_inner(Some(Duration::from_millis(delay_ms)), Box::new(job)) +} + +/// Spawn an async job on the background Tokio runtime (for bindings that await host callbacks). +pub fn spawn_future(fut: Fut) -> String +where + Fut: std::future::Future + Send + 'static, +{ + spawn_inner_future(None, fut) +} + +/// Spawn an async job after `delay_ms` milliseconds. +pub fn spawn_after_ms_future(delay_ms: u64, fut: Fut) -> String +where + Fut: std::future::Future + Send + 'static, +{ + spawn_inner_future(Some(Duration::from_millis(delay_ms)), fut) +} + +fn spawn_inner(delay: Option, job: Box) -> String { + let reg = registry(); + let id = reg.alloc_id(); + let status = Arc::new(AtomicU8::new(TaskStatus::Pending.as_u8())); + let status_run = Arc::clone(&status); + let created_at_ms = now_ms(); + let delay_ms = delay.map(|d| d.as_millis() as u64); + + let Ok(mut guard) = reg.entries.lock() else { + let _ = runtime().spawn(async move { + if let Some(d) = delay { + tokio::time::sleep(d).await; + } + let _ = job(); + }); + return id; + }; + + let handle = runtime().spawn(async move { + if let Some(d) = delay { + tokio::time::sleep(d).await; + } + if TaskStatus::from_u8(status_run.load(Ordering::SeqCst)) == TaskStatus::Cancelled { + return; + } + status_run.store(TaskStatus::Running.as_u8(), Ordering::SeqCst); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)); + let final_status = match result { + Ok(()) => TaskStatus::Done, + Err(_) => TaskStatus::Failed, + }; + // Do not overwrite Cancelled if cancel raced mid-job. + let _ = status_run.compare_exchange( + TaskStatus::Running.as_u8(), + final_status.as_u8(), + Ordering::SeqCst, + Ordering::SeqCst, + ); + }); + + guard.insert( + id.clone(), + TaskEntry { + status, + handle, + created_at_ms, + delay_ms, + }, + ); + prune_terminal(&mut guard); + id +} + +fn spawn_inner_future(delay: Option, fut: Fut) -> String +where + Fut: std::future::Future + Send + 'static, +{ + let reg = registry(); + let id = reg.alloc_id(); + let status = Arc::new(AtomicU8::new(TaskStatus::Pending.as_u8())); + let status_run = Arc::clone(&status); + let created_at_ms = now_ms(); + let delay_ms = delay.map(|d| d.as_millis() as u64); + + let Ok(mut guard) = reg.entries.lock() else { + let _ = runtime().spawn(async move { + if let Some(d) = delay { + tokio::time::sleep(d).await; + } + fut.await; + }); + return id; + }; + + let handle = runtime().spawn(async move { + if let Some(d) = delay { + tokio::time::sleep(d).await; + } + if TaskStatus::from_u8(status_run.load(Ordering::SeqCst)) == TaskStatus::Cancelled { + return; + } + status_run.store(TaskStatus::Running.as_u8(), Ordering::SeqCst); + // Host async callbacks (e.g. Node TSFN) rarely panic; treat completion as Done. + fut.await; + let _ = status_run.compare_exchange( + TaskStatus::Running.as_u8(), + TaskStatus::Done.as_u8(), + Ordering::SeqCst, + Ordering::SeqCst, + ); + }); + + guard.insert( + id.clone(), + TaskEntry { + status, + handle, + created_at_ms, + delay_ms, + }, + ); + prune_terminal(&mut guard); + id +} + +/// Cancel a pending/running task. Returns whether the id was known. +pub fn cancel(id: &str) -> bool { + let reg = registry(); + let Ok(mut guard) = reg.entries.lock() else { + return false; + }; + let Some(entry) = guard.get_mut(id) else { + return false; + }; + let current = TaskStatus::from_u8(entry.status.load(Ordering::SeqCst)); + if matches!( + current, + TaskStatus::Done | TaskStatus::Cancelled | TaskStatus::Failed + ) { + return true; + } + entry + .status + .store(TaskStatus::Cancelled.as_u8(), Ordering::SeqCst); + entry.handle.abort(); + prune_terminal(&mut guard); + true +} + +/// Current status for a task id, if known. +pub fn status(id: &str) -> Option { + let reg = registry(); + let guard = reg.entries.lock().ok()?; + guard + .get(id) + .map(|e| TaskStatus::from_u8(e.status.load(Ordering::SeqCst))) +} + +/// JSON snapshot of tracked tasks (for the cache monitor panel and bindings). +pub fn snapshot() -> Value { + let reg = registry(); + let Ok(mut guard) = reg.entries.lock() else { + return json!({ + "task_count": 0, + "active_count": 0, + "tasks": [], + }); + }; + prune_terminal(&mut guard); + + let mut tasks: Vec = guard + .iter() + .map(|(id, e)| { + let st = TaskStatus::from_u8(e.status.load(Ordering::SeqCst)); + json!({ + "id": id, + "status": st.as_str(), + "delay_ms": e.delay_ms, + "created_at_ms": e.created_at_ms, + }) + }) + .collect(); + // Newest first for the monitor table. + tasks.sort_by(|a, b| { + let am = a["created_at_ms"].as_u64().unwrap_or(0); + let bm = b["created_at_ms"].as_u64().unwrap_or(0); + bm.cmp(&am) + }); + + let active_count = tasks + .iter() + .filter(|t| { + matches!( + t["status"].as_str(), + Some("pending") | Some("running") + ) + }) + .count(); + + json!({ + "task_count": tasks.len(), + "active_count": active_count, + "tasks": tasks, + }) +} + +/// Drop all tracked tasks (tests). Running jobs are aborted. +pub fn reset_for_tests() { + let reg = registry(); + if let Ok(mut guard) = reg.entries.lock() { + for (_, entry) in guard.drain() { + entry.handle.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + + fn test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn spawn_runs_to_done() { + let _guard = test_lock(); + reset_for_tests(); + let flag = Arc::new(AtomicBool::new(false)); + let f = Arc::clone(&flag); + let id = spawn_fn(move || { + f.store(true, Ordering::SeqCst); + }); + for _ in 0..100 { + if status(&id) == Some(TaskStatus::Done) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert!(flag.load(Ordering::SeqCst), "job did not run"); + assert_eq!(status(&id), Some(TaskStatus::Done)); + } + + #[test] + fn spawn_after_delays() { + let _guard = test_lock(); + reset_for_tests(); + let flag = Arc::new(AtomicBool::new(false)); + let f = Arc::clone(&flag); + let id = spawn_after_ms(80, move || { + f.store(true, Ordering::SeqCst); + }); + thread::sleep(Duration::from_millis(20)); + assert!(!flag.load(Ordering::SeqCst)); + assert!(matches!( + status(&id), + Some(TaskStatus::Pending) | Some(TaskStatus::Running) + )); + for _ in 0..100 { + if flag.load(Ordering::SeqCst) && status(&id) == Some(TaskStatus::Done) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + assert!(flag.load(Ordering::SeqCst), "delayed job did not run"); + assert_eq!(status(&id), Some(TaskStatus::Done)); + } + + #[test] + fn cancel_before_run() { + let _guard = test_lock(); + reset_for_tests(); + let flag = Arc::new(AtomicBool::new(false)); + let f = Arc::clone(&flag); + let id = spawn_after_ms(500, move || { + f.store(true, Ordering::SeqCst); + }); + assert!(cancel(&id)); + thread::sleep(Duration::from_millis(100)); + assert!(!flag.load(Ordering::SeqCst)); + assert_eq!(status(&id), Some(TaskStatus::Cancelled)); + } + + #[test] + fn snapshot_lists_spawned_and_cancelled() { + let _guard = test_lock(); + reset_for_tests(); + let id = spawn_after_ms(5_000, || {}); + let snap = snapshot(); + assert_eq!(snap["task_count"].as_u64(), Some(1)); + assert_eq!(snap["active_count"].as_u64(), Some(1)); + let tasks = snap["tasks"].as_array().expect("tasks array"); + assert_eq!(tasks[0]["id"].as_str(), Some(id.as_str())); + assert_eq!(tasks[0]["status"].as_str(), Some("pending")); + assert_eq!(tasks[0]["delay_ms"].as_u64(), Some(5_000)); + assert!(tasks[0]["created_at_ms"].as_u64().unwrap_or(0) > 0); + + assert!(cancel(&id)); + let snap2 = snapshot(); + assert_eq!(snap2["tasks"][0]["status"].as_str(), Some("cancelled")); + assert_eq!(snap2["active_count"].as_u64(), Some(0)); + } +} diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs new file mode 100644 index 0000000..bcde81f --- /dev/null +++ b/crates/fusion-core/src/templates.rs @@ -0,0 +1,327 @@ +//! Tera template rendering with built-in Fusion UI component macros. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Mutex; + +use serde_json::Value; +use tera::{Context, Tera}; + +const BUILTIN_MACROS: &str = include_str!("../assets/templates/fusion/macros.html"); +const BUILTIN_BASE: &str = include_str!("../assets/templates/fusion/base.html"); +const BUILTIN_COMPONENTS_CSS: &str = + include_str!("../assets/templates/fusion/components.css"); +const BUILTIN_MONITOR: &str = include_str!("../assets/templates/fusion/monitor.html"); +const BUILTIN_FORM_JS: &str = include_str!("../assets/templates/fusion/form.js"); + +static ENGINE_CACHE: Mutex> = Mutex::new(None); + +struct EngineCache { + key: String, + tera: Tera, +} + +/// Render a template file (path relative to the templates root) with a JSON context. +pub fn render_template( + template_name: &str, + context: &Value, + templates_root: &Path, +) -> Result { + let tera = engine_for_root(templates_root)?; + let ctx = + Context::from_serialize(context).map_err(|e| format!("invalid template context: {e}"))?; + tera.render(template_name, &ctx) + .map_err(|e| format!("template render failed: {e}")) +} + +fn engine_for_root(root: &Path) -> Result { + let key = root + .canonicalize() + .unwrap_or_else(|_| root.to_path_buf()) + .to_string_lossy() + .to_string(); + + let mut guard = ENGINE_CACHE + .lock() + .map_err(|_| "template engine lock poisoned".to_string())?; + if let Some(cache) = guard.as_ref() { + if cache.key == key { + return Ok(cache.tera.clone()); + } + } + + let tera = build_engine(root)?; + *guard = Some(EngineCache { + key, + tera: tera.clone(), + }); + Ok(tera) +} + +fn build_engine(root: &Path) -> Result { + let mut raw: Vec<(String, String)> = vec![ + ("fusion/macros.html".to_string(), BUILTIN_MACROS.to_string()), + ("fusion/base.html".to_string(), BUILTIN_BASE.to_string()), + ( + "fusion/components.css".to_string(), + BUILTIN_COMPONENTS_CSS.to_string(), + ), + ( + "fusion/monitor.html".to_string(), + BUILTIN_MONITOR.to_string(), + ), + // Legacy alias for older projects. + ( + "fusion/cache_monitor.html".to_string(), + BUILTIN_MONITOR.to_string(), + ), + ( + "fusion/form.js".to_string(), + BUILTIN_FORM_JS.to_string(), + ), + ]; + + if root.is_dir() { + collect_templates(root, root, &mut raw)?; + } + + let pairs: Vec<(&str, &str)> = raw.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + + let mut tera = Tera::default(); + tera.add_raw_templates(pairs) + .map_err(|e| format!("failed to load templates: {e}"))?; + Ok(tera) +} + +fn collect_templates( + root: &Path, + current: &Path, + out: &mut Vec<(String, String)>, +) -> Result<(), String> { + for entry in std::fs::read_dir(current).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + if path.is_dir() { + collect_templates(root, &path, out)?; + continue; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "html" && ext != "tera" && ext != "css" { + continue; + } + let rel = path + .strip_prefix(root) + .map_err(|e| e.to_string())? + .to_string_lossy() + .replace('\\', "/"); + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + out.push((rel, content)); + } + Ok(()) +} + +/// Clear cached Tera engine (useful in tests or hot-reload). +pub fn clear_template_cache() { + if let Ok(mut guard) = ENGINE_CACHE.lock() { + *guard = None; + } +} + +/// List built-in component names exposed to templates. +pub fn builtin_components() -> HashMap<&'static str, &'static str> { + HashMap::from([ + ( + "button", + "{{}}", + ), + ("link", "{{}}"), + ("card", "{{}}"), + ( + "alert", + "{{}}", + ), + ( + "badge", + "{{}}", + ), + ( + "table", + "{{}}", + ), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn renders_builtin_macro() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains("fusion-btn")); + assert!(html.contains("href=\"/\"")); + assert!(html.contains("Go")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_badge_with_dot() { + clear_template_cache(); + let tpl = + r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_badge_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains("fusion-badge--success")); + assert!(html.contains("fusion-badge__dot")); + assert!(html.contains("Installation successful")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_table_from_arrays() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_table_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template( + "test.html", + &json!({ + "headers": ["Name", "Status"], + "rows": [["Widget", "ok"], ["Gadget", "draft"]], + }), + &dir, + ) + .unwrap(); + assert!(html.contains("fusion-table")); + assert!(html.contains("Name")); + assert!(html.contains("Widget")); + assert!(html.contains("Products")); + assert!(!html.contains("fusion-table-pager")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_table_with_page_size_pager() { + clear_template_cache(); + let tpl = + r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_table_page_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template( + "test.html", + &json!({ + "headers": ["Name"], + "rows": [["a"], ["b"], ["c"]], + }), + &dir, + ) + .unwrap(); + assert!(html.contains("data-page-size=\"2\"")); + assert!(html.contains("data-fusion-row")); + assert!(html.contains("fusion-table-pager")); + assert!(html.contains("data-fusion-prev")); + assert!(html.contains("data-fusion-next")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn includes_css_partial() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_css_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("home")).unwrap(); + std::fs::write(dir.join("home/style.css"), "body { color: red; }").unwrap(); + std::fs::write( + dir.join("home/index.html"), + r#""#, + ) + .unwrap(); + let html = render_template("home/index.html", &json!({}), &dir).unwrap(); + assert!(html.contains("color: red")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_card_with_body_slot() { + clear_template_cache(); + let tpl = r#"{% %}
hello
{%
%}"#; + let dir = std::env::temp_dir().join("fusion_tpl_card_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains("fusion-card")); + assert!(html.contains("Get started")); + assert!(html.contains("hello")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_builtin_monitor() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_monitor_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let html = render_template( + "fusion/monitor.html", + &json!({ + "title": "Fusion Monitor", + "driver_label": "moka", + "entry_badge": "1 keys", + "event_badge": "2 events", + "task_badge": "0/1 tasks", + "empty_entries": false, + "empty_events": false, + "empty_tasks": false, + "entry_headers": ["Key", "Value", "TTL (s)"], + "entry_rows": [["demo", "{\"ok\":true}", "∞"]], + "event_headers": ["Op", "Key", "Time (ms)"], + "event_rows": [["set", "demo", "1"]], + "task_headers": ["Id", "Status", "Delay (ms)", "Created (ms)"], + "task_rows": [["task-1", "done", "—", "1"]], + "path": "/__fusion/monitor", + "json_path": "/__fusion/monitor/json", + }), + &dir, + ) + .unwrap(); + assert!(html.contains("Fusion Monitor")); + assert!(html.contains("Background tasks")); + assert!(html.contains("fusion-table")); + assert!(html.contains("demo")); + assert!(html.contains("task-1")); + assert!(html.contains("data-page-size=\"10\"")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn includes_builtin_components_css() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_components_css_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("test.html"), + r#""#, + ) + .unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains(".fusion-btn")); + assert!(html.contains(".fusion-table")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/fusion-ffi/src/lib.rs b/crates/fusion-ffi/src/lib.rs index 64b748e..0d62987 100644 --- a/crates/fusion-ffi/src/lib.rs +++ b/crates/fusion-ffi/src/lib.rs @@ -11,11 +11,12 @@ use std::path::PathBuf; use std::ptr; use fusion_core::{ - api_resource_name, resolve_route_path, response_from_value, App, Handler, Request, Response, - Settings, HTTP_HEADER_CONSTANTS, HTTP_METHODS, HTTP_STATUS_CODES, attachment, cache_control, - content_type, download, inline, location, + App, HTTP_HEADER_CONSTANTS, HTTP_METHODS, HTTP_STATUS_CODES, Handler, Request, Response, + Settings, api_resource_name, attachment, cache_control, content_type, download, inline, + location, render_template, resolve_route_path, response_from_value, }; use serde_json::{Map, Value}; +use std::time::Duration; /// Opaque application handle. pub struct FusionAppHandle { @@ -48,7 +49,9 @@ fn cstr_to_str<'a>(p: *const c_char) -> &'a str { } fn to_cstring(s: &str) -> *mut c_char { - CString::new(s.replace('\0', "")).map(CString::into_raw).unwrap_or(ptr::null_mut()) + CString::new(s.replace('\0', "")) + .map(CString::into_raw) + .unwrap_or(ptr::null_mut()) } fn parse_json_object(raw: &str) -> Map { @@ -324,11 +327,7 @@ pub extern "C" fn fusion_settings_load_json( }; let env_opt = { let e = cstr_to_str(env); - if e.is_empty() { - None - } else { - Some(e) - } + if e.is_empty() { None } else { Some(e) } }; let roots = parse_path_list(cstr_to_str(extra_roots_json)); match handle @@ -529,3 +528,353 @@ pub extern "C" fn fusion_header_download( pub extern "C" fn fusion_fingerprint_headers() -> *mut c_char { headers_map_json(&fusion_core::fingerprint_headers()) } + +/// Render a Tera template. `context_json` is a JSON object; `templates_root` may be empty for default `templates`. +/// Returns HTML or null on error (check with fusion_last_error). Free with fusion_string_free. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_render_template( + template_name: *const c_char, + context_json: *const c_char, + templates_root: *const c_char, +) -> *mut c_char { + let name = cstr_to_str(template_name); + if name.is_empty() { + return ptr::null_mut(); + } + let ctx_raw = cstr_to_str(context_json); + let context = if ctx_raw.is_empty() { + Value::Object(Map::new()) + } else { + serde_json::from_str(ctx_raw).unwrap_or(Value::Object(Map::new())) + }; + let root_raw = cstr_to_str(templates_root); + let root = if root_raw.is_empty() { + PathBuf::from("templates") + } else { + PathBuf::from(root_raw) + }; + match render_template(name, &context, &root) { + Ok(html) => to_cstring(&html), + Err(e) => { + eprintln!("fusion_render_template: {e}"); + ptr::null_mut() + } + } +} + +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(); +} + +/// JSON snapshot of cache entries + recent events. Free with fusion_string_free. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_snapshot() -> *mut c_char { + match fusion_core::cache::snapshot() { + Ok(v) => value_to_cstring(&v), + Err(e) => { + eprintln!("fusion_cache_snapshot: {e}"); + ptr::null_mut() + } + } +} + +/// JSON template context for the built-in monitor panel. Free with fusion_string_free. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_cache_panel_context() -> *mut c_char { + match fusion_core::cache::panel_context() { + Ok(v) => value_to_cstring(&v), + Err(e) => { + eprintln!("fusion_cache_panel_context: {e}"); + ptr::null_mut() + } + } +} + +/// C callback invoked from a Tokio worker to run a host-language job. +pub type FusionTaskCallback = Option; +/// Optional freer for `user_data` after the job finishes or is dropped (cancel). +pub type FusionTaskDataFree = Option; + +struct FfiTaskJob { + callback: extern "C" fn(*mut c_void), + data: usize, + free_data: FusionTaskDataFree, +} + +impl FfiTaskJob { + /// Run the host callback once. + fn run(self) { + (self.callback)(self.data as *mut c_void); + } +} + +impl Drop for FfiTaskJob { + fn drop(&mut self) { + if let Some(free) = self.free_data.take() { + free(self.data as *mut c_void); + } + } +} + +/// Spawn a background task. Returns task id (free with fusion_string_free), or null on error. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_spawn( + callback: FusionTaskCallback, + user_data: *mut c_void, + free_data: FusionTaskDataFree, +) -> *mut c_char { + let Some(cb) = callback else { + return ptr::null_mut(); + }; + let job = FfiTaskJob { + callback: cb, + data: user_data as usize, + free_data, + }; + let id = fusion_core::spawn_fn(move || { + job.run(); + }); + to_cstring(&id) +} + +/// Spawn after `delay_ms` milliseconds. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_spawn_after( + delay_ms: u64, + callback: FusionTaskCallback, + user_data: *mut c_void, + free_data: FusionTaskDataFree, +) -> *mut c_char { + let Some(cb) = callback else { + return ptr::null_mut(); + }; + let job = FfiTaskJob { + callback: cb, + data: user_data as usize, + free_data, + }; + let id = fusion_core::spawn_after_ms(delay_ms, move || { + job.run(); + }); + to_cstring(&id) +} + +/// Cancel a task by id. 1 = known, 0 = unknown, -1 = bad id pointer. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_cancel(id: *const c_char) -> c_int { + let id = cstr_to_str(id); + if id.is_empty() { + return -1; + } + if fusion_core::task_cancel(id) { + 1 + } else { + 0 + } +} + +/// Status string for a task id (free with fusion_string_free), or null if unknown. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_status(id: *const c_char) -> *mut c_char { + let id = cstr_to_str(id); + match fusion_core::task_status(id) { + Some(s) => to_cstring(s.as_str()), + None => ptr::null_mut(), + } +} + +/// Abort and clear all tracked tasks (tests). +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_reset() { + fusion_core::reset_tasks(); +} + +/// JSON snapshot of tracked tasks. Free with fusion_string_free. +#[unsafe(no_mangle)] +pub extern "C" fn fusion_task_snapshot() -> *mut c_char { + value_to_cstring(&fusion_core::task_snapshot()) +} diff --git a/crates/fusion-node/README.md b/crates/fusion-node/README.md index e09c342..f1ca53b 100644 --- a/crates/fusion-node/README.md +++ b/crates/fusion-node/README.md @@ -16,6 +16,8 @@ From this repo: cd crates/fusion-node && npm install && npm run build:debug ``` +**Important:** `napi build` must use `--js false` so it does not overwrite `index.js` (the FusionApp / middleware layer). The npm scripts already pass this flag. + Scaffold with [Fusion Tool](https://github.com/cipherunits/fusion-tool): ```bash @@ -35,7 +37,7 @@ export const ItemModule = route('/api/[module]/{id}')( }, ) -const MIDDLEWARE = [] // your middleware; Fusion already adds frameworkHeaders() by default +const MIDDLEWARE = [] // add middleware explicitly, e.g. frameworkHeaders() settings.ensureLoaded() const app = new FusionApp(getSettings()) diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index f69d7da..06e8382 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -1,148 +1,87 @@ -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 - response(body?: unknown, status?: number, headers?: Record): FusionResponse +/* 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 class HTTPException extends Error { - status: number - detail: unknown - headers: Record - constructor(status: number, detail?: unknown, headers?: Record) - toResponse(): FusionResponse -} - -export class FusionApp { - constructor(settings?: Partial) - use(middleware: FusionMiddleware): void - mount(): void - listen(host?: string, port?: number): Promise +export declare function getHttpStatusCodes(): Array +export interface HttpHeaderConstant { + name: string + value: string } - -export type RouteOptions = { - tags?: string[] - desc?: string - title?: string - version?: string - deprecated?: boolean - middleware?: FusionMiddleware[] - roles?: string[] - roleClaim?: string - roleStateKey?: string +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 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 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 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 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 function cacheSnapshot(): unknown +export declare function cachePanelContext(): unknown +/** + * Spawn a JS function on the Tokio background runtime. Returns task id. + * + * Uses `call_async` so status becomes `done` only after the JS callback runs + * (sync `Blocking` can return before the Node event loop drains the TSFN). + */ +export declare function taskSpawn(callback: (...args: any[]) => any): string +/** Spawn a JS function after `delay_ms` milliseconds. */ +export declare function taskSpawnAfter(delayMs: number, callback: (...args: any[]) => any): string +/** Cancel a background task by id. */ +export declare function taskCancel(id: string): boolean +/** Status string for a task id, or null if unknown. */ +export declare function taskStatus(id: string): string | null +/** Reset the task registry (tests). */ +export declare function taskReset(): void +/** JSON snapshot of tracked background tasks. */ +export declare function taskSnapshot(): unknown +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 type FusionResponse = - | string - | { - status?: number - body?: unknown - headers?: Record - } diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 61c416e..a639f84 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -1,5 +1,6 @@ const path = require('path') const fs = require('fs') +const { spawn } = require('child_process') const { platform, arch } = process function napiTriple() { @@ -134,7 +135,7 @@ header.fingerprint = () => : { 'X-Powered-By': 'Fusion Framework', 'X-Framework': 'Fusion', - ['X-Fusion-Version']: '1.2.6', + ['X-Fusion-Version']: '2.0.0', } function isThenable(value) { @@ -168,6 +169,221 @@ function frameworkHeaders() { } } +function getHeader(request, name) { + const headers = request.headers || {} + const target = String(name).toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (String(key).toLowerCase() === target) return String(value) + } + return null +} + +function headerMiddleware(extra) { + return async (request, callNext) => { + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, extra) + } +} + +function securityHeaders(options = {}) { + const extra = { + 'X-Content-Type-Options': options.contentTypeOptions ?? 'nosniff', + 'X-Frame-Options': options.frameOptions ?? 'DENY', + 'Referrer-Policy': options.referrerPolicy ?? 'strict-origin-when-cross-origin', + 'Permissions-Policy': + options.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=(), payment=()', + 'Cross-Origin-Opener-Policy': options.coop ?? 'same-origin', + 'Cross-Origin-Resource-Policy': options.corp ?? 'same-origin', + } + if (options.csp) extra['Content-Security-Policy'] = String(options.csp) + if (options.hsts) extra['Strict-Transport-Security'] = String(options.hsts) + return headerMiddleware(extra) +} + +function cacheHeaders(options = {}) { + return headerMiddleware({ + 'Cache-Control': options.default ?? options.value ?? 'no-store', + }) +} + +function requestId(options = {}) { + const headerName = options.header ?? 'X-Request-Id' + const incoming = options.incoming !== false + return async (request, callNext) => { + const state = ensureState(request) + let rid = incoming ? getHeader(request, headerName) : null + if (!rid) { + rid = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` + } + state.request_id = rid + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, { [headerName]: rid }) + } +} + +function cors(options = {}) { + const origins = Array.isArray(options.allowOrigins) + ? options.allowOrigins.map(String) + : [String(options.allowOrigins ?? '*')] + const methods = ( + options.allowMethods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] + ).map((m) => String(m).toUpperCase()) + const allowHeaders = ( + options.allowHeaders ?? ['Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Request-Id'] + ).map(String) + const exposeHeaders = (options.exposeHeaders ?? ['X-Request-Id']).map(String) + const allowCredentials = !!options.allowCredentials + const maxAge = Number(options.maxAge ?? 600) + const allowAll = origins.includes('*') + + function corsHeaders(origin) { + let chosen = '*' + if (!allowAll) { + if (origin && origins.includes(origin)) chosen = origin + else if (origins.length) chosen = origins[0] + } + const out = { + 'Access-Control-Allow-Origin': chosen, + 'Access-Control-Allow-Methods': methods.join(', '), + 'Access-Control-Allow-Headers': allowHeaders.join(', '), + 'Access-Control-Expose-Headers': exposeHeaders.join(', '), + 'Access-Control-Max-Age': String(maxAge), + Vary: 'Origin', + } + if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true' + return out + } + + return async (request, callNext) => { + const origin = getHeader(request, 'Origin') + const extra = corsHeaders(origin) + if (String(request.method || 'GET').toUpperCase() === 'OPTIONS') { + return { status: 204, body: '', headers: extra } + } + const result = await awaitMaybe(callNext(request)) + return mergeResponseHeaders(result, extra) + } +} + +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() @@ -228,6 +444,213 @@ class FusionBaseApi { const body = paginatedBody(items, total, p) return this.response(body, status, headers || {}) } + + wantsJson() { + let accept = null + for (const [key, value] of Object.entries(this.headers || {})) { + if (key.toLowerCase() === 'accept') { + accept = String(value) + break + } + } + const format = this.query?.format != null ? String(this.query.format) : null + return typeof native.prefersJsonJs === 'function' + ? native.prefersJsonJs(accept, format) + : prefersJsonFallback(accept, format) + } +} + +function prefersJsonFallback(accept, formatQuery) { + if (formatQuery && String(formatQuery).toLowerCase() === 'json') return true + const value = String(accept || '').trim().toLowerCase() + if (!value) return false + let bestJson = -1 + let bestHtml = -1 + for (const part of value.split(',')) { + const tokens = part.trim().split(';').map((t) => t.trim()) + const media = tokens[0] || '' + let q = 1 + for (const token of tokens.slice(1)) { + if (token.startsWith('q=')) { + const parsed = Number.parseFloat(token.slice(2)) + if (!Number.isNaN(parsed)) q = parsed + } + } + if (media === 'application/json' || media === 'text/json') bestJson = Math.max(bestJson, q) + else if (media === 'text/html' || media === 'application/xhtml+xml') bestHtml = Math.max(bestHtml, q) + } + return bestJson > 0 && bestJson >= bestHtml +} + +function parseFormBody(body, contentType) { + const raw = body == null ? '' : String(body) + const ct = String(contentType || '').toLowerCase() + if (ct.includes('application/json') || (raw.trim().startsWith('{') && !ct.includes('urlencoded'))) { + try { + const data = raw.trim() ? JSON.parse(raw) : {} + if (data && typeof data === 'object' && !Array.isArray(data)) { + const out = {} + for (const [k, v] of Object.entries(data)) out[k] = v == null ? '' : String(v) + return out + } + } catch { + return {} + } + return {} + } + const params = new URLSearchParams(raw) + const out = {} + for (const key of params.keys()) { + out[key] = params.get(key) ?? '' + } + return out +} + +class FusionBaseTemplate extends FusionBaseApi { + static __fusion_template__ = true + static template = '' + static templateAddress = '' + static templatesDir = '' + + /** + * Template variables (not an HTTP verb). May return a Promise. + * get() renders this as HTML; post() should use form / ok / fail. + */ + context() { + return {} + } + + /** Parsed POST body (urlencoded or JSON) as flat string fields. */ + get form() { + let contentType = null + for (const [key, value] of Object.entries(this.headers || {})) { + if (String(key).toLowerCase() === 'content-type') { + contentType = String(value) + break + } + } + return parseFormBody(this.body, contentType) + } + + get() { + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._getAsync(raw) + } + return this._finishGet(raw) + } + + async _getAsync(raw) { + const ctx = await raw + return this._finishGet(ctx) + } + + _finishGet(ctx) { + const data = { ...(ctx || {}) } + if (this.wantsJson()) return data + return this._htmlResponse(data) + } + + /** + * Validation failure — JSON for SPA fetch, else same template with errors. + * fail({ phone: 'required' }, { message: 'خطا', ...formFields }) + */ + fail(errors = {}, extras = {}) { + const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) } + const message = bag.message != null ? String(bag.message) : 'Validation failed' + delete bag.message + const err = {} + for (const [k, v] of Object.entries(errors || {})) err[k] = String(v) + const flat = {} + for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v) + + if (this.wantsJson()) { + return this.response({ ok: false, message, errors: err, fields: flat }, 400) + } + return this._formHtmlResult({ ok: false, message, errors: err, fields: flat, status: 400 }) + } + + /** Success — JSON for SPA fetch, else same template with ok=true. */ + ok(extras = {}) { + const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) } + const message = bag.message != null ? String(bag.message) : 'OK' + delete bag.message + const flat = {} + for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v) + + if (this.wantsJson()) { + return this.response({ ok: true, message, errors: {}, fields: flat }, 200) + } + return this._formHtmlResult({ ok: true, message, errors: {}, fields: flat, status: 200 }) + } + + _formHtmlResult({ ok, message, errors, fields, status }) { + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._formHtmlResultAsync(raw, { ok, message, errors, fields, status }) + } + return this._finishFormHtml(raw, { ok, message, errors, fields, status }) + } + + async _formHtmlResultAsync(raw, opts) { + const ctx = await raw + return this._finishFormHtml(ctx, opts) + } + + _finishFormHtml(ctx, { ok, message, errors, fields, status }) { + const data = { ...(ctx || {}), ...fields, ok, message, errors: { ...errors }, fields: { ...fields } } + return this._htmlResponse(data, { status }) + } + + templateName() { + const name = this.constructor.template || this.constructor.templateAddress + if (!name) { + throw new Error(`${this.constructor.name} must set static template or templateAddress`) + } + return name + } + + templatesRoot() { + if (this.constructor.templatesDir) return this.constructor.templatesDir + return String(settings.get('templates.dir', 'templates')) + } + + render({ + status = 200, + headers = {}, + context = null, + templateName = null, + } = {}) { + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._renderAsync(raw, { status, headers, context, templateName }) + } + const ctx = { ...(raw || {}), ...(context || {}) } + return this._htmlResponse(ctx, { status, headers, templateName }) + } + + async _renderAsync(raw, { status = 200, headers = {}, context = null, templateName = null } = {}) { + const base = await raw + const ctx = { ...(base || {}), ...(context || {}) } + return this._htmlResponse(ctx, { status, headers, templateName }) + } + + _htmlResponse(ctx, { status = 200, headers = {}, templateName = null } = {}) { + const html = renderTemplate( + templateName || this.templateName(), + ctx, + this.templatesRoot(), + ) + return this.response(html, status, { + 'content-type': 'text/html; charset=utf-8', + ...headers, + }) + } +} + +function renderTemplate(templateName, context = {}, templatesRoot = null) { + const root = templatesRoot ?? String(settings.get('templates.dir', 'templates')) + return native.renderTemplateJs(templateName, context || {}, root) } function apiResourceName(cls) { @@ -421,6 +844,17 @@ async function runMiddlewareChain(request, middlewares, handler) { return dispatch(0, request) } +function requirePermissions(...checks) { + return (request, callNext) => { + for (const check of checks) { + if (!check(request)) { + return { status: 403, body: { detail: 'Forbidden' } } + } + } + return callNext(request) + } +} + function requireRoles(...rolesOrOptions) { let roles = rolesOrOptions let claim = 'roles' @@ -504,14 +938,9 @@ function router(routePath, options = {}) { ApiClass.__fusion_path_template__ = routePath const routeMiddleware = Array.isArray(options.middleware) ? [...options.middleware] : [] - if (Array.isArray(options.roles) && options.roles.length) { - routeMiddleware.push( - requireRoles({ - roles: options.roles, - claim: options.roleClaim || 'roles', - stateKey: options.roleStateKey || 'jwt', - }), - ) + const permissionChecks = Array.isArray(options.permissions) ? options.permissions : [] + if (permissionChecks.length) { + routeMiddleware.push(requirePermissions(...permissionChecks)) } const classSwagger = { @@ -528,6 +957,7 @@ function router(routePath, options = {}) { middleware: routeMiddleware, swagger: classSwagger, version_prefix: v, + requiresPermissions: permissionChecks.length > 0, slots: collectRouteSlots(ApiClass, resolved, classSwagger), }) return ApiClass @@ -653,7 +1083,7 @@ function readSwaggerSettings() { showCommonExtensions: false, syntaxHighlight: { activated: true, theme: 'agate' }, withCredentials: false, - validatorUrl: 'https://validator.swagger.io/validator', + validatorUrl: null, ...asObject(settings.get('swagger.ui', {})), } if (Object.prototype.hasOwnProperty.call(authRaw, 'persistAuthorization')) { @@ -682,6 +1112,102 @@ function readSwaggerSettings() { const UNVERSIONED_SWAGGER_NAME = 'default' +const SWAGGER_ASSETS_DIR = path.join(__dirname, 'static', 'swagger-ui') +const SWAGGER_ASSET_TYPES = { + 'swagger-ui-bundle.js': 'application/javascript; charset=utf-8', + 'swagger-ui-standalone-preset.js': 'application/javascript; charset=utf-8', + 'swagger-ui.css': 'text/css; charset=utf-8', +} + +function loadSwaggerAssets() { + const out = {} + for (const [name, contentType] of Object.entries(SWAGGER_ASSET_TYPES)) { + const filePath = path.join(SWAGGER_ASSETS_DIR, name) + if (!fs.existsSync(filePath)) continue + out[name] = { contentType, body: fs.readFileSync(filePath, 'utf8') } + } + return out +} + +const SWAGGER_ASSETS = loadSwaggerAssets() + +function swaggerAssetUrl(prefix, name) { + return `${prefix}/assets/${name}` +} + + +/** Normalize monitor.path from settings (default /__fusion/monitor). */ +function normalizeMonitorPath(raw) { + let path = String(raw == null || raw === '' ? '/__fusion/monitor' : raw).trim() || '/__fusion/monitor' + if (!path.startsWith('/')) path = `/${path}` + return path.replace(/\/+$/, '') || '/__fusion/monitor' +} + +function resolveMonitorEnabled(settingsInstance) { + const s = settingsInstance || settings + const top = s.get('monitor.enabled', null) + if (top !== null && top !== undefined) { + return truthyEnabled(top, false) + } + return truthyEnabled(s.get('cache.monitor.enabled', false), false) +} + +function resolveMonitorPath(settingsInstance) { + const s = settingsInstance || settings + const top = s.get('monitor.path', null) + if (top !== null && top !== undefined && String(top).trim() !== '') { + return normalizeMonitorPath(top) + } + return normalizeMonitorPath(s.get('cache.monitor.path', '/__fusion/monitor')) +} + +/** + * Built-in Fusion monitor (cache + background tasks). + * When monitor.enabled is false, no routes are registered. + */ +function mountMonitor(engine, settingsInstance) { + const s = settingsInstance || settings + if (!resolveMonitorEnabled(s)) { + return false + } + cache.configure(s) + const path = resolveMonitorPath(s) + + class MonitorPanel extends FusionBaseTemplate { + static template = 'fusion/monitor.html' + context() { + return cache.panelContext() + } + } + + const htmlHandler = (errOrRequest, maybeRequest) => { + const request = nativeRequestArg(errOrRequest, maybeRequest) + return new MonitorPanel(request || emptyRequest()).get() + } + const jsonHandler = () => cache.snapshot() + + engine.route('GET', path, htmlHandler) + if (path !== '/') { + engine.route('GET', `${path}/`, htmlHandler) + } + engine.route('GET', `${path}/json`, jsonHandler) + return true +} + +/** @deprecated Use mountMonitor */ +const mountCacheMonitor = mountMonitor + +function mountSwaggerAssets(engine, prefix) { + const assetsPrefix = `${prefix}/assets` + for (const [name, { contentType, body }] of Object.entries(SWAGGER_ASSETS)) { + engine.route('GET', `${assetsPrefix}/${name}`, () => ({ + status: 200, + body, + headers: { 'content-type': contentType }, + })) + } +} + function normalizeVersionLabel(value) { return String(value || '') .trim() @@ -702,6 +1228,40 @@ function collectRouteVersions() { return { versions, hasUnversioned } } +function clearRouteRegistry() { + registry.length = 0 +} + +function testSwaggerConfig() { + return { + path: '/swagger', + info: { title: 'fusion-framework', version: '1.0.0' }, + servers: [], + auth: { schemes: {}, global: [], oauth: {} }, + navbar: { + enabled: true, + showUrlInput: false, + showUrlInputSet: true, + urlsSet: false, + urls: [], + }, + ui: {}, + pageTitle: 'Fusion API Docs', + } +} + +function openapiSpec(version = null) { + return buildOpenApi(testSwaggerConfig(), version) +} + +function routeVersions() { + return collectRouteVersions().versions +} + +function hasUnversionedRoutes() { + return collectRouteVersions().hasUnversioned +} + function swaggerVersionUrls(prefix) { const { versions, hasUnversioned } = collectRouteVersions() const urls = versions.map((label) => ({ @@ -748,6 +1308,17 @@ function applySwaggerOpenApi(openapi, swagger) { return openapi } +const OPENAPI_PERMISSIONS_SCHEME = 'FusionPermissions' + +function isTemplateClass(ApiClass) { + let current = ApiClass + while (current && current !== Function.prototype) { + if (current === FusionBaseTemplate || current.__fusion_template__) return true + current = Object.getPrototypeOf(current) + } + return false +} + function fillOpenApiPaths(openapi, versionFilter = null) { const parsePathParams = (pattern) => { return String(pattern) @@ -756,9 +1327,13 @@ function fillOpenApiPaths(openapi, versionFilter = null) { .map((seg) => seg.slice(1, -1)) } + let anyPermissions = false + for (const item of registry) { if (!routeMatchesVersion(item, versionFilter)) continue - const { ApiClass, swagger: routeSwagger } = item + const { ApiClass, swagger: routeSwagger, requiresPermissions } = item + if (isTemplateClass(ApiClass)) continue + if (requiresPermissions) anyPermissions = true const slots = item.slots || [] for (const slot of slots) { @@ -784,10 +1359,27 @@ function fillOpenApiPaths(openapi, versionFilter = null) { deprecated: !!routeSwaggerEntry?.deprecated, operationId: `${ApiClass.name}_${slot.handlerMethod}`, parameters: params, - responses: { 200: { description: 'OK' } }, + responses: { + 200: { description: 'OK' }, + ...(requiresPermissions ? { 403: { description: 'Forbidden — permission check failed' } } : {}), + }, + ...(requiresPermissions ? { security: [{ [OPENAPI_PERMISSIONS_SCHEME]: [] }] } : {}), } } } + + if (anyPermissions) { + openapi.components = asObject(openapi.components) + openapi.components.securitySchemes = { + ...asObject(openapi.components.securitySchemes), + [OPENAPI_PERMISSIONS_SCHEME]: { + type: 'apiKey', + in: 'header', + name: 'Authorization', + description: 'Route requires custom permission checks to pass', + }, + } + } return openapi } @@ -835,6 +1427,8 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) { const navbarEnabled = !!swagger.navbar?.enabled const showUrlInput = swagger.navbar?.showUrlInput !== false + const versionUrls = swagger.navbar?.urls?.length > 0 + const needsStandalone = navbarEnabled || versionUrls const hideUrlCss = navbarEnabled && !showUrlInput ? `` : '' - const standaloneScript = navbarEnabled - ? `` - : '' + const standaloneScript = + needsStandalone && SWAGGER_ASSETS['swagger-ui-standalone-preset.js'] + ? `` + : '' return ` @@ -854,19 +1449,19 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) { ${title} - + ${hideUrlCss}
- + ${standaloneScript} ' + f'' ) bootstrap = f""" @@ -241,7 +281,7 @@ def _swagger_ui_html(swagger: dict[str, Any], openapi_url: str, primary_name: st var opts = {ui_json}; opts.presets = [SwaggerUIBundle.presets.apis]; opts.plugins = [SwaggerUIBundle.plugins.DownloadUrl]; - if ({str(navbar_enabled).lower()} && typeof SwaggerUIStandalonePreset !== 'undefined') {{ + if ({str(needs_standalone).lower()} && typeof SwaggerUIStandalonePreset !== 'undefined') {{ opts.presets.push(SwaggerUIStandalonePreset); opts.layout = 'StandaloneLayout'; }} else {{ @@ -262,12 +302,12 @@ def _swagger_ui_html(swagger: dict[str, Any], openapi_url: str, primary_name: st {title} - + {hide_url_css}
- + {standalone_script} + + +`, + ) + +settings.merge({ templates: { dir: tplRoot } }) + +class RegisterPage extends FusionBaseTemplate { + static template = 'register.html' + + context() { + return { + title: 'Register', + message: 'Fill the form.', + ok: false, + errors: {}, + name: '', + phone: '', + } + } + + post() { + const form = this.form + const errors = {} + if (!form.phone) errors.phone = 'phone is required' + if (!form.name) errors.name = 'name is required' + const safe = { name: form.name || '', phone: form.phone || '' } + if (Object.keys(errors).length) { + return this.fail(errors, { message: 'Fix the errors.', ...safe }) + } + console.log('submitted', safe) + return this.ok({ message: 'Saved.', ...safe }) + } +} + +route('/register')(RegisterPage) + +const app = new FusionApp(settings) +await app.listen() diff --git a/examples/template_form.py b/examples/template_form.py new file mode 100644 index 0000000..3fd5b8d --- /dev/null +++ b/examples/template_form.py @@ -0,0 +1,83 @@ +"""SPA-friendly template form (form / ok / fail). + + python examples/template_form.py + +Open http://127.0.0.1:8080/register +""" + +from __future__ import annotations + +from pathlib import Path + +from fusion_framework import settings +from fusion_framework.app import FusionApp +from fusion_framework.route import route +from fusion_framework.template import FusionBaseTemplate + +DEMO = Path(__file__).resolve().parent +settings.configure(templates={"dir": str(DEMO / "templates_form")}) + + +@route("/register") +class RegisterPage(FusionBaseTemplate): + template = "register.html" + + def context(self): + return { + "title": "Register", + "message": "Fill the form.", + "ok": False, + "errors": {}, + "name": "", + "phone": "", + } + + def post(self): + form = self.form + errors = {} + if not form.get("phone"): + errors["phone"] = "phone is required" + if not form.get("name"): + errors["name"] = "name is required" + safe = {"name": form.get("name", ""), "phone": form.get("phone", "")} + if errors: + return self.fail(errors, message="Fix the errors.", **safe) + print("submitted", safe) + return self.ok(message="Saved.", **safe) + + +def main() -> None: + root = DEMO / "templates_form" + root.mkdir(exist_ok=True) + (root / "register.html").write_text( + """ + +{{ title }} + + + +

{{ title }}

+

{{ message }}

+
+
+ + + +
+ + + +""", + encoding="utf-8", + ) + FusionApp(settings).listen() + + +if __name__ == "__main__": + main() diff --git a/examples/templates/home/index.html b/examples/templates/home/index.html new file mode 100644 index 0000000..600e7ae --- /dev/null +++ b/examples/templates/home/index.html @@ -0,0 +1,46 @@ + + + + + + {{ title }} + + + +
+ {{}} +

{{ title }}

+

{{ message }}

+ + {{}} +
+ + diff --git a/examples/ui_components.cs b/examples/ui_components.cs new file mode 100644 index 0000000..5500b26 --- /dev/null +++ b/examples/ui_components.cs @@ -0,0 +1,20 @@ +// Render Fusion Tera UI components (button, badge, table + page_size). +// Run from repo root after building the C# binding. + +using System.Text.Json.Nodes; +using FusionFramework; + +var root = Path.Combine(Directory.GetCurrentDirectory(), "examples", "ui_components_assets"); + +var ctx = new Dictionary +{ + ["headers"] = new JsonArray("Route", "Method"), + ["rows"] = new JsonArray( + new JsonArray("/v1/api/product", "GET"), + new JsonArray("/swagger", "GET"), + new JsonArray("/__fusion/cache", "GET"), + new JsonArray("/health", "GET")), +}; + +var html = Templates.Render("page.html", ctx, root); +Console.WriteLine(html); diff --git a/examples/ui_components.mjs b/examples/ui_components.mjs new file mode 100644 index 0000000..b992bd8 --- /dev/null +++ b/examples/ui_components.mjs @@ -0,0 +1,26 @@ +/** + * Render Fusion Tera UI components (button, badge, table + page_size). + * + * node examples/ui_components.mjs + */ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renderTemplate } from 'fusion-framework' + +const root = join(dirname(fileURLToPath(import.meta.url)), 'ui_components_assets') + +const html = renderTemplate( + 'page.html', + { + headers: ['Route', 'Method'], + rows: [ + ['/v1/api/product', 'GET'], + ['/swagger', 'GET'], + ['/__fusion/cache', 'GET'], + ['/health', 'GET'], + ], + }, + root, +) + +console.log(html) diff --git a/examples/ui_components.py b/examples/ui_components.py new file mode 100644 index 0000000..25c1173 --- /dev/null +++ b/examples/ui_components.py @@ -0,0 +1,33 @@ +"""Render Fusion Tera UI components (button, badge, table + page_size). + + python examples/ui_components.py +""" + +from __future__ import annotations + +from pathlib import Path + +from fusion_framework.template import render_template + +ROOT = Path(__file__).resolve().parent / "ui_components_assets" + + +def main() -> None: + html = render_template( + "page.html", + { + "headers": ["Route", "Method"], + "rows": [ + ["/v1/api/product", "GET"], + ["/swagger", "GET"], + ["/__fusion/cache", "GET"], + ["/health", "GET"], + ], + }, + templates_root=ROOT, + ) + print(html) + + +if __name__ == "__main__": + main() diff --git a/examples/ui_components_assets/page.html b/examples/ui_components_assets/page.html new file mode 100644 index 0000000..3f7ac6a --- /dev/null +++ b/examples/ui_components_assets/page.html @@ -0,0 +1,16 @@ + + + + + UI components + + + + {{}} +

+ {{}} + {{}} +

+ {{}} + + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d12fb50 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests/python +pythonpath = crates/fusion-py/python +addopts = -ra --strict-markers +markers = + integration: tests that require a running HTTP server + slow: long-running tests (opt-in) diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d62c3e5 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,228 @@ +# Local development install scripts + +Shell helpers for building and linking **Fusion Framework** bindings directly from this repository. Use them when you are changing Rust core code or binding glue and want to run examples or tests against your working tree instead of published packages. + +Published installs remain the default for application projects: + +| Binding | Published install | +|---------|-------------------| +| Python | `pip install fusion-framework` | +| Node | `npm install fusion-framework` | +| C# | `dotnet add package Fusion-Framework` | + +## Overview + +| Script | What it does | +|--------|----------------| +| `dev-install-python.sh` | Editable Python install via `maturin develop` | +| `dev-install-node.sh` | Builds the N-API addon and optionally `npm link`s it | +| `dev-install-csharp.sh` | Builds `fusion-ffi` and the `FusionFramework` project | +| `dev-install-all.sh` | Runs the three scripts above in order | + +All scripts are intended to be run **from the repository root**: + +```bash +./scripts/dev-install-python.sh +./scripts/dev-install-node.sh +./scripts/dev-install-csharp.sh +# or +./scripts/dev-install-all.sh +``` + +Shared helpers live in `_dev-common.sh` (sourced by the install scripts; do not run it directly). + +## Prerequisites + +Install these once on your machine before running the scripts. + +| Tool | Required for | Notes | +|------|--------------|-------| +| **Rust** (`cargo`, stable toolchain) | All bindings | Builds `fusion-core`, `fusion-py`, `fusion-node`, and `fusion-ffi` | +| **Python 3.9+** with `pip` | Python | A virtualenv is recommended (`.venv` in the repo root) | +| **maturin** | Python | Installed automatically by `dev-install-python.sh` if missing | +| **Node.js 18+** and **npm** | Node | See `crates/fusion-node/package.json` `engines` | +| **.NET SDK 10.0** | C# | Project targets `net10.0`; install the matching SDK | + +Platform build tools (a C/C++ linker, `python3-dev` headers on Linux, etc.) are required for Rust/PyO3/N-API builds—the same toolchain you would use for `cargo build` in this workspace. + +## Per-script usage + +### Python — `dev-install-python.sh` + +Builds the PyO3 extension and installs the `fusion_framework` package in **editable** mode into the active environment. + +```bash +# Use the current shell's Python (or repo .venv if present) +./scripts/dev-install-python.sh + +# Create .venv and install into it +./scripts/dev-install-python.sh --create-venv + +# Use a specific virtualenv +./scripts/dev-install-python.sh --venv .venv +``` + +**Options** + +| Flag | Description | +|------|-------------| +| `--create-venv` | Create `.venv` at the repo root if it does not exist | +| `--venv PATH` | Use `PATH/bin/python` and `PATH/bin/maturin` | +| `-h`, `--help` | Show usage | + +After install, activate the venv if you use one: + +```bash +source .venv/bin/activate # bash/zsh +``` + +### Node — `dev-install-node.sh` + +Installs npm devDependencies, compiles the native `.node` addon with `@napi-rs/cli`, runs a local smoke test, and by default runs **`npm link`** so other projects can `require('fusion-framework')` from this build. + +```bash +./scripts/dev-install-node.sh +``` + +**Options** + +| Flag | Description | +|------|-------------| +| `--no-link` | Build only; skip `npm link` | +| `--release` | Release build (default is debug / `build:debug`) | +| `-h`, `--help` | Show usage | + +To consume the linked package from another project: + +```bash +npm link fusion-framework +``` + +To require the addon without linking, use the path `crates/fusion-node/index.js` from the repo root. + +### C# — `dev-install-csharp.sh` + +Builds the native `fusion-ffi` library and compiles `bindings/csharp/FusionFramework`. Prints the path to the shared library for `FUSION_FFI_PATH` when auto-discovery is not enough. + +```bash +./scripts/dev-install-csharp.sh +``` + +**Options** + +| Flag | Description | +|------|-------------| +| `--release` | Release build for both Cargo and `dotnet build` | +| `--pack` | Also run `dotnet pack` and write packages to `dist/` | +| `-h`, `--help` | Show usage | + +Reference the project from an app (as in `examples/csharp_hello`): + +```xml + +``` + +If the native library is not found at runtime, export: + +```bash +# Linux +export FUSION_FFI_PATH="$PWD/target/debug/libfusion_ffi.so" + +# macOS +export FUSION_FFI_PATH="$PWD/target/debug/libfusion_ffi.dylib" + +# Windows (PowerShell) +$env:FUSION_FFI_PATH = "$PWD\target\debug\fusion_ffi.dll" +``` + +### All bindings — `dev-install-all.sh` + +Runs Python, then Node, then C# installs in sequence. Useful after a fresh clone or a large `fusion-core` change. + +```bash +./scripts/dev-install-all.sh +./scripts/dev-install-all.sh --create-venv # also create .venv for Python +``` + +Individual script flags (for example `--release` or `--no-link`) are not forwarded; run the per-binding scripts directly when you need those options. + +## Verification + +Each script runs a small smoke check before it finishes. After install, you can verify manually: + +**Python** + +```bash +python -c "from fusion_framework import settings; print('python ok')" +python examples/template_demo.py +python -m pytest tests/python -q +``` + +**Node** + +```bash +node -e "const f=require('fusion-framework'); console.log('node ok', f.status.HTTP_SUCCESS)" +node examples/node_hello.mjs +node --check crates/fusion-node/index.js +``` + +**C#** + +```bash +export FUSION_FFI_PATH="$PWD/target/debug/libfusion_ffi.so" # adjust per OS +dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj +dotnet build examples/csharp_hello/csharp_hello.csproj +dotnet run --project examples/csharp_hello +``` + +**Rust core** (optional, independent of the install scripts): + +```bash +cargo test -p fusion-core +cargo check --workspace +``` + +## Troubleshooting + +### `missing required command: …` + +Install the tool listed in the error (see [Prerequisites](#prerequisites)). On Debian/Ubuntu, `python3-venv` may be required for `python3 -m venv .venv`. + +### Python: `maturin` or build failures + +- Use a dedicated venv: `./scripts/dev-install-python.sh --create-venv` +- Ensure Python headers are installed (`python3-dev` on Linux). +- Re-run after `cargo clean` if the PyO3 extension is stale. + +### Node: `smoke failed` or missing `.node` file + +- Confirm Rust is installed and `napi build` completed without errors. +- Do not run `napi build` without `--js false`; it can overwrite `index.js`. The npm scripts in `crates/fusion-node` already pass the correct flags. +- If `npm link` causes confusion, rebuild with `./scripts/dev-install-node.sh --no-link` and require `./crates/fusion-node/index.js` directly. + +### C#: `DllNotFoundException` / could not load `fusion_ffi` + +- Run `./scripts/dev-install-csharp.sh` (or `cargo build -p fusion-ffi`) so `target/debug/` contains the native library. +- Set `FUSION_FFI_PATH` to the absolute path printed by the script. +- The binding also searches upward from the app output directory for `target/debug` and `target/release`; keep your app inside or near this monorepo, or use a `ProjectReference` as in the examples. + +### C#: SDK or TFM errors + +- Install **.NET SDK 10.0** to match `net10.0` in `FusionFramework.csproj`, or retarget the project locally if you must use an older SDK. + +### Slow or repeated full rebuilds + +- Run only the script for the binding you are working on. +- Use debug builds (default) during development; pass `--release` when you need optimized native code. + +### Permission denied running a script + +Make scripts executable once: + +```bash +chmod +x scripts/dev-install-*.sh +``` + +## Related scripts + +- `set-version.sh` — bump version numbers across Cargo, Python, Node, and C# manifests (release tooling; not part of local dev install). diff --git a/scripts/_dev-common.sh b/scripts/_dev-common.sh new file mode 100755 index 0000000..f9fdf5e --- /dev/null +++ b/scripts/_dev-common.sh @@ -0,0 +1,46 @@ +# Shared helpers for local dev install scripts. Source from bash only. +if [[ -z "${BASH_VERSION:-}" ]]; then + echo "scripts must be run with bash" >&2 + exit 1 +fi + +_DEV_COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$_DEV_COMMON_DIR/.." && pwd)" + +info() { + echo "==> $*" +} + +ok() { + echo "✓ $*" +} + +warn() { + echo "warning: $*" >&2 +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +fusion_ffi_lib() { + local profile="${1:-debug}" + local target_dir="${CARGO_TARGET_DIR:-$ROOT/target}" + local base="$target_dir/$profile" + local candidate + case "$(uname -s)" in + Darwin) candidate="$base/libfusion_ffi.dylib" ;; + MINGW*|MSYS*|CYGWIN*) candidate="$base/fusion_ffi.dll" ;; + *) candidate="$base/libfusion_ffi.so" ;; + esac + if [[ -f "$candidate" ]]; then + echo "$candidate" + return 0 + fi + find "$base" -maxdepth 1 \( -name 'libfusion_ffi.so' -o -name 'libfusion_ffi.dylib' -o -name 'fusion_ffi.dll' \) -print -quit 2>/dev/null || true +} diff --git a/scripts/dev-install-all.sh b/scripts/dev-install-all.sh new file mode 100755 index 0000000..ac67233 --- /dev/null +++ b/scripts/dev-install-all.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Install all Fusion bindings from source (Python, Node, C#). +# +# Usage (from repo root): +# ./scripts/dev-install-all.sh +# ./scripts/dev-install-all.sh --create-venv +set -euo pipefail + +# shellcheck source=_dev-common.sh +source "$(cd "$(dirname "$0")" && pwd)/_dev-common.sh" + +CREATE_VENV=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --create-venv) + CREATE_VENV=1 + shift + ;; + -h|--help) + cat <<'EOF' +Run all local dev install scripts in order: Python, Node, C#. + +Options: + --create-venv Pass through to dev-install-python.sh + -h, --help Show this help +EOF + exit 0 + ;; + *) + shift + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +info "1/3 Python" +if [[ "$CREATE_VENV" -eq 1 ]]; then + bash "$SCRIPT_DIR/dev-install-python.sh" --create-venv +else + bash "$SCRIPT_DIR/dev-install-python.sh" +fi + +echo "" +info "2/3 Node" +bash "$SCRIPT_DIR/dev-install-node.sh" + +echo "" +info "3/3 C#" +bash "$SCRIPT_DIR/dev-install-csharp.sh" + +ok "all bindings installed from source" + +cat <<'EOF' + +Quick verify: + python -c "from fusion_framework import settings; print('python ok')" + node -e "const f=require('fusion-framework'); console.log('node ok', f.status.HTTP_SUCCESS)" + export FUSION_FFI_PATH=$PWD/target/debug/libfusion_ffi.so # adjust extension on macOS/Windows + dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj +EOF diff --git a/scripts/dev-install-csharp.sh b/scripts/dev-install-csharp.sh new file mode 100755 index 0000000..23b3629 --- /dev/null +++ b/scripts/dev-install-csharp.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Build fusion-ffi and the C# FusionFramework project from source. +# +# Usage (from repo root): +# ./scripts/dev-install-csharp.sh +# +# Verify: +# dotnet build examples/csharp_hello/csharp_hello.csproj +set -euo pipefail + +# shellcheck source=_dev-common.sh +source "$(cd "$(dirname "$0")" && pwd)/_dev-common.sh" +cd "$ROOT" + +PROFILE=debug +PACK=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + PROFILE=release + shift + ;; + --pack) + PACK=1 + shift + ;; + -h|--help) + cat <<'EOF' +Build fusion-ffi and the C# binding for local development. + +Options: + --release Release build (default: debug) + --pack Also run dotnet pack (local NuGet in dist/) + -h, --help Show this help + +Examples: + ./scripts/dev-install-csharp.sh + export FUSION_FFI_PATH=$PWD/target/debug/libfusion_ffi.so + dotnet run --project examples/csharp_hello +EOF + exit 0 + ;; + *) + shift + ;; + esac +done + +require_cmd cargo +require_cmd dotnet + +CARGO_PROFILE="$PROFILE" +[[ "$PROFILE" == "release" ]] && CARGO_PROFILE=release + +info "building fusion-ffi ($CARGO_PROFILE)" +if [[ "$CARGO_PROFILE" == "release" ]]; then + cargo build -p fusion-ffi --release +else + cargo build -p fusion-ffi +fi + +FFI_PATH="$(fusion_ffi_lib "$CARGO_PROFILE")" +[[ -n "$FFI_PATH" && -f "$FFI_PATH" ]] || die "native library not found under ${CARGO_TARGET_DIR:-$ROOT/target}/$CARGO_PROFILE (run: cargo build -p fusion-ffi)" + +export FUSION_FFI_PATH="$FFI_PATH" +ok "fusion-ffi: $FFI_PATH" + +CONFIG=Debug +[[ "$PROFILE" == "release" ]] && CONFIG=Release + +PROJ="$ROOT/bindings/csharp/FusionFramework/FusionFramework.csproj" +info "building FusionFramework ($CONFIG)" +dotnet build -c "$CONFIG" "$PROJ" + +if [[ "$PACK" -eq 1 ]]; then + mkdir -p "$ROOT/dist" + info "packing NuGet package" + dotnet pack -c "$CONFIG" -o "$ROOT/dist" "$PROJ" + ok "NuGet package(s) in dist/" +fi + +cat < +EOF diff --git a/scripts/dev-install-node.sh b/scripts/dev-install-node.sh new file mode 100755 index 0000000..6cb2e8d --- /dev/null +++ b/scripts/dev-install-node.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Build and link fusion-framework (Node) from this repo. +# +# Usage (from repo root): +# ./scripts/dev-install-node.sh +# +# Verify: +# node -e "const f=require('fusion-framework'); console.log(f.status.HTTP_SUCCESS)" +set -euo pipefail + +# shellcheck source=_dev-common.sh +source "$(cd "$(dirname "$0")" && pwd)/_dev-common.sh" +cd "$ROOT" + +LINK=1 +PROFILE=debug + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-link) + LINK=0 + shift + ;; + --release) + PROFILE=release + shift + ;; + -h|--help) + cat <<'EOF' +Build the Node native addon and optionally npm link it globally. + +Options: + --no-link Build only; skip npm link + --release Release build (default: debug) + -h, --help Show this help + +Examples: + ./scripts/dev-install-node.sh + node examples/node_hello.mjs +EOF + exit 0 + ;; + *) + shift + ;; + esac +done + +require_cmd node +require_cmd npm +require_cmd cargo + +NODE_DIR="$ROOT/crates/fusion-node" +cd "$NODE_DIR" + +info "installing npm devDependencies" +npm install + +if [[ "$PROFILE" == "release" ]]; then + info "building native addon (release)" + npm run build:napi +else + info "building native addon (debug)" + npm run build:napi:debug +fi + +info "smoke test (local require)" +node -e "const f=require('./index.js'); if(!f.FusionApp||!f.status) throw new Error('smoke failed'); console.log('ok', f.status.HTTP_SUCCESS)" + +if [[ "$LINK" -eq 1 ]]; then + info "npm link (global fusion-framework -> this repo)" + if npm link 2>/dev/null; then + ok "linked package name 'fusion-framework'" + VERIFY="node -e \"const f=require('fusion-framework'); console.log('ok', f.status.HTTP_SUCCESS)\"" + else + warn "npm link failed (permission denied?). Use the local path instead:" + VERIFY="node -e \"const f=require('./crates/fusion-node/index.js'); console.log('ok', f.status.HTTP_SUCCESS)\"" + echo " cd crates/fusion-node && npm link # retry with sudo or a user npm prefix" + fi +else + ok "built at crates/fusion-node (not linked)" + VERIFY="node -e \"const f=require('./crates/fusion-node/index.js'); console.log('ok', f.status.HTTP_SUCCESS)\"" +fi + +cat <()); + 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")); + } + + [Fact] + public void SnapshotAndPanelContext() + { + Cache.Set("demo", new { n = 1 }); + var snap = Cache.Snapshot(); + Assert.Equal("moka", snap["driver"]?.GetValue()); + Assert.Equal(1, snap["entry_count"]?.GetValue()); + Assert.Equal("demo", snap["entries"]?[0]?["key"]?.GetValue()); + Assert.Equal("set", snap["events"]?[0]?["op"]?.GetValue()); + Assert.NotNull(snap["tasks"]); + Assert.NotNull(snap["tasks"]?["tasks"]?.AsArray()); + + var ctx = Cache.PanelContext(); + Assert.Equal("Fusion Monitor", ctx["title"]?.GetValue()); + Assert.False(ctx["empty_entries"]!.GetValue()); + Assert.Equal("demo", ctx["entry_rows"]?[0]?[0]?.GetValue()); + Assert.EndsWith("/json", ctx["json_path"]!.GetValue()); + Assert.NotNull(ctx["task_headers"]); + Assert.Contains("tasks", ctx["task_badge"]!.GetValue()); + } + + [Fact] + public void MountRespectsEnabledFlag() + { + var off = new FusionSettings(); + off.Merge(new JsonObject + { + ["monitor"] = new JsonObject { ["enabled"] = false }, + }); + using var appOff = new FusionApp(off); + Assert.False(FusionMonitor.Mount(appOff, off)); + + var on = new FusionSettings(); + on.Merge(new JsonObject + { + ["monitor"] = new JsonObject + { + ["enabled"] = true, + ["path"] = "/__fusion/monitor", + }, + ["cache"] = new JsonObject + { + ["driver"] = "moka", + }, + }); + using var appOn = new FusionApp(on); + Assert.True(FusionMonitor.Mount(appOn, on)); + } +} diff --git a/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj b/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj new file mode 100644 index 0000000..430e16c --- /dev/null +++ b/tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + true + FusionFramework.Tests + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs new file mode 100644 index 0000000..97f4d4c --- /dev/null +++ b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Nodes; +using FusionFramework; +using Xunit; + +namespace FusionFramework.Tests; + +public class MiddlewareTests +{ + static object Handler(FusionRequest request) => + new Dictionary + { + ["status"] = 200, + ["body"] = new Dictionary { ["state"] = request.State }, + }; + + [Fact] + public void BearerJwt_populates_state() + { + const string token = + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0."; + var request = new FusionRequest + { + Headers = new Dictionary { ["Authorization"] = $"Bearer {token}" }, + }; + + var result = Middleware.RunChain( + request, + new[] { Middleware.BearerJwt() }, + Handler) as Dictionary; + + Assert.NotNull(result); + Assert.Equal(200, result["status"]); + var body = Assert.IsType>(result["body"]); + var state = Assert.IsType>(body["state"]); + Assert.Equal("1", state["jwt"]!["sub"]!.GetValue()); + } + + [Fact] + public void RequireRoles_blocks_missing_role() + { + var request = new FusionRequest + { + State = new Dictionary + { + ["jwt"] = System.Text.Json.Nodes.JsonNode.Parse("{\"roles\":[\"user\"]}"), + }, + }; + + var result = Middleware.RunChain( + request, + new[] { Middleware.RequireRoles("admin") }, + Handler) as Dictionary; + + Assert.NotNull(result); + Assert.Equal(403, result["status"]); + } + + [Fact] + public void Cors_answers_options_with_204() + { + var request = new FusionRequest + { + Method = "OPTIONS", + Path = "/api", + Headers = new Dictionary { ["Origin"] = "https://example.com" }, + }; + + var result = Middleware.RunChain(request, new[] { Middleware.Cors() }, Handler) + as Dictionary; + + Assert.NotNull(result); + Assert.Equal(204, result["status"]); + 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/csharp/FusionFramework.Tests/RouteTests.cs b/tests/csharp/FusionFramework.Tests/RouteTests.cs new file mode 100644 index 0000000..2957579 --- /dev/null +++ b/tests/csharp/FusionFramework.Tests/RouteTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Nodes; +using FusionFramework; +using FusionFramework.Testing; +using Xunit; + +namespace FusionFramework.Tests; + +public class RouteTests +{ + public RouteTests() => FusionTestSupport.ClearRoutes(); + + [Fact] + public void ResolvePath_expands_module_token() + { + var path = Route.ResolvePath("/api/[module]", "ProductModule"); + Assert.Equal("/api/product", path); + } + + [Fact] + public void Register_mounts_convention_and_custom_http_slots() + { + Route.Register(typeof(ProductModule), "/api/[module]"); + + var entry = Assert.Single(Route.Snapshot()); + Assert.Equal("/api/product", entry.ClassBasePath); + Assert.Contains(entry.Slots, s => s.Path == "/api/product" && s.HttpMethod == "get"); + Assert.Contains(entry.Slots, s => s.Path == "/api/product/catalog/catalog" && s.HttpMethod == "get"); + } + + [Fact] + public void OpenApi_lists_registered_api_paths() + { + Route.Register(typeof(ProductModule), "/api/[module]", tags: new[] { "products" }); + + var spec = FusionTestSupport.OpenApiSpec(); + var paths = spec["paths"]!.AsObject(); + Assert.True(paths.ContainsKey("/api/product")); + Assert.True(paths["/api/product"]!.AsObject().ContainsKey("get")); + } + + [Fact] + public void Template_routes_are_omitted_from_openapi() + { + Route.Register(typeof(SampleHomePage), "/pages/home"); + Route.Register(typeof(ProductModule), "/api/[module]", version: "v1"); + + var combined = FusionTestSupport.OpenApiSpec(); + Assert.False(combined["paths"]!.AsObject().ContainsKey("/pages/home")); + + var v1 = FusionTestSupport.OpenApiSpec("v1"); + Assert.False(v1["paths"]!.AsObject().ContainsKey("/pages/home")); + Assert.True(v1["paths"]!.AsObject().ContainsKey("/v1/api/product")); + } + + [Route("/api/[module]")] + sealed class ProductModule : FusionBaseApi + { + public object Get() => Response(new { ok = true }); + + [HttpGet("catalog/[action]")] + public object CatalogAction() => Response(new { items = Array.Empty() }); + } + + [Route("/pages/home")] + sealed class SampleHomePage : FusionBaseTemplate + { + static SampleHomePage() + { + Template = "home/index.html"; + } + + public override Dictionary Context() => new() + { + ["title"] = "Home", + }; + } +} diff --git a/tests/csharp/FusionFramework.Tests/TaskTests.cs b/tests/csharp/FusionFramework.Tests/TaskTests.cs new file mode 100644 index 0000000..3bd5bf4 --- /dev/null +++ b/tests/csharp/FusionFramework.Tests/TaskTests.cs @@ -0,0 +1,79 @@ +using System.Linq; +using FusionFramework; +using Xunit; + +namespace FusionFramework.Tests; + +public class TaskTests +{ + public TaskTests() + { + BackgroundTasks.Reset(); + } + + static void WaitDone(string tid, Func sideEffect, int timeoutMs = 2000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (sideEffect() && BackgroundTasks.Status(tid) == "done") + return; + Thread.Sleep(20); + } + } + + [Fact] + public void SpawnRunsToDone() + { + var n = 0; + var tid = BackgroundTasks.Spawn(() => Interlocked.Increment(ref n)); + WaitDone(tid, () => n == 1); + Assert.Equal(1, n); + Assert.Equal("done", BackgroundTasks.Status(tid)); + } + + [Fact] + public void SpawnAfterRunsToDone() + { + var n = 0; + var tid = BackgroundTasks.SpawnAfter(80, () => Interlocked.Increment(ref n)); + Assert.Contains(BackgroundTasks.Status(tid), new[] { "pending", "running" }); + Thread.Sleep(30); + Assert.Equal(0, n); + WaitDone(tid, () => n == 1); + Assert.Equal(1, n); + Assert.Equal("done", BackgroundTasks.Status(tid)); + } + + [Fact] + public void SpawnAfterCanBeCancelled() + { + var n = 0; + var tid = BackgroundTasks.SpawnAfter(400, () => Interlocked.Increment(ref n)); + Assert.Contains(BackgroundTasks.Status(tid), new[] { "pending", "running" }); + Assert.True(BackgroundTasks.Cancel(tid)); + Thread.Sleep(150); + Assert.Equal(0, n); + Assert.Equal("cancelled", BackgroundTasks.Status(tid)); + } + + [Fact] + public void StatusUnknownId() + { + Assert.Null(BackgroundTasks.Status("task-does-not-exist")); + Assert.False(BackgroundTasks.Cancel("task-does-not-exist")); + } + + [Fact] + public void SnapshotListsTasks() + { + var tid = BackgroundTasks.SpawnAfter(5000, () => { }); + var snap = BackgroundTasks.Snapshot(); + Assert.True(snap["task_count"]!.GetValue() >= 1); + Assert.True(snap["active_count"]!.GetValue() >= 1); + var ids = snap["tasks"]!.AsArray().Select(t => t!["id"]!.GetValue()); + Assert.Contains(tid, ids); + Assert.True(BackgroundTasks.Cancel(tid)); + Assert.Equal("cancelled", BackgroundTasks.Snapshot()["tasks"]![0]!["status"]!.GetValue()); + } +} diff --git a/tests/fixtures/templates/sample/page.html b/tests/fixtures/templates/sample/page.html new file mode 100644 index 0000000..c530a1f --- /dev/null +++ b/tests/fixtures/templates/sample/page.html @@ -0,0 +1 @@ +{{}} diff --git a/tests/node/helpers/load-fusion.js b/tests/node/helpers/load-fusion.js new file mode 100644 index 0000000..aca5a6b --- /dev/null +++ b/tests/node/helpers/load-fusion.js @@ -0,0 +1,4 @@ +const path = require('path') + +/** Resolve the local fusion-framework package from the monorepo. */ +module.exports = require(path.resolve(__dirname, '../../../crates/fusion-node')) diff --git a/tests/node/unit/bindings.test.js b/tests/node/unit/bindings.test.js new file mode 100644 index 0000000..34368b2 --- /dev/null +++ b/tests/node/unit/bindings.test.js @@ -0,0 +1,168 @@ +const { describe, it, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const fusion = require('../helpers/load-fusion') +const { + FusionBaseApi, + FusionBaseTemplate, + route, + httpGet, + clearRouteRegistry, + openapiSpec, + routeVersions, + hasUnversionedRoutes, + runMiddlewareChain, + bearerJwt, + cors, + requireRoles, + frameworkHeaders, + staticFiles, + resolveRoutePath, + apiResourceName, +} = fusion + +function handler(request) { + return { status: 200, body: { state: request.state || {} } } +} + +describe('routing helpers', () => { + it('resolveRoutePath expands [module]', () => { + assert.equal(resolveRoutePath('/api/[module]', { name: 'ProductModule' }), '/api/product') + }) + + it('apiResourceName strips Module suffix', () => { + assert.equal(apiResourceName({ name: 'ProductModule' }), 'product') + }) +}) + +describe('http routes', () => { + beforeEach(() => clearRouteRegistry()) + + it('registers custom http_get with [action] token', () => { + class UserModule extends FusionBaseApi { + UserAction() { + return { ok: true } + } + } + httpGet('test/[action]')(UserModule.prototype.UserAction) + route('/api/[module]')(UserModule) + + const spec = openapiSpec() + assert.ok(spec.paths['/api/user/test/user']) + assert.ok(spec.paths['/api/user/test/user'].get) + assert.equal(spec.paths['/api/user/test/user'].get.operationId, 'UserModule_UserAction') + }) + + it('splits openapi specs by version', () => { + class V1Hello extends FusionBaseApi { + get() { + return { v: 1 } + } + } + class V2Hello extends FusionBaseApi { + get() { + return { v: 2 } + } + } + class Health extends FusionBaseApi { + get() { + return { ok: true } + } + } + + route('/hello', { version: 'v1' })(V1Hello) + route('/hello', { version: 'v2' })(V2Hello) + route('/health')(Health) + + assert.deepEqual(routeVersions(), ['v1', 'v2']) + assert.equal(hasUnversionedRoutes(), true) + + const v1 = openapiSpec('v1') + assert.ok(v1.paths['/v1/hello']) + assert.equal(v1.paths['/v2/hello'], undefined) + assert.equal(v1.paths['/health'], undefined) + }) + + it('omits template routes from openapi', () => { + class HomePage extends FusionBaseTemplate { + static template = 'home/index.html' + context() { + return { title: 'Home' } + } + } + class ItemsApi extends FusionBaseApi { + get() { + return { items: [] } + } + } + + route('/pages/home')(HomePage) + route('/api/items', { version: 'v1', tags: ['items'] })(ItemsApi) + + const combined = openapiSpec() + assert.equal(combined.paths['/pages/home'], undefined) + + const v1 = openapiSpec('v1') + assert.equal(v1.paths['/pages/home'], undefined) + assert.ok(v1.paths['/v1/api/items']) + }) +}) + +describe('middleware', () => { + beforeEach(() => clearRouteRegistry()) + + it('bearerJwt stores payload in state', async () => { + const token = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0.' + const request = { headers: { Authorization: `Bearer ${token}` } } + const result = await runMiddlewareChain(request, [bearerJwt()], handler) + assert.equal(result.status, 200) + assert.equal(result.body.state.jwt.sub, '1') + }) + + it('requireRoles returns 403 when role missing', async () => { + const request = { headers: {}, state: { jwt: { roles: ['user'] } } } + const result = await runMiddlewareChain(request, [requireRoles('admin')], handler) + assert.equal(result.status, 403) + }) + + it('cors short-circuits OPTIONS preflight', async () => { + const request = { + method: 'OPTIONS', + path: '/api', + headers: { Origin: 'https://example.com' }, + } + const result = await runMiddlewareChain(request, [cors()], handler) + assert.equal(result.status, 204) + const headers = Object.fromEntries( + Object.entries(result.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) + ) + assert.ok(headers['access-control-allow-origin']) + }) + + it('frameworkHeaders merges identity headers', async () => { + const request = { path: '/', headers: {}, method: 'GET' } + 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/node/unit/cache.test.js b/tests/node/unit/cache.test.js new file mode 100644 index 0000000..4e38a6e --- /dev/null +++ b/tests/node/unit/cache.test.js @@ -0,0 +1,95 @@ +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) + }) + + it('snapshot and panelContext', () => { + cache.set('demo', { n: 1 }) + const snap = cache.snapshot() + assert.equal(snap.driver, 'moka') + assert.equal(snap.entry_count, 1) + assert.equal(snap.entries[0].key, 'demo') + assert.equal(snap.events[0].op, 'set') + assert.ok(snap.tasks) + assert.ok(Array.isArray(snap.tasks.tasks)) + + const ctx = cache.panelContext() + assert.equal(ctx.title, 'Fusion Monitor') + assert.equal(ctx.empty_entries, false) + assert.equal(ctx.entry_rows[0][0], 'demo') + assert.match(String(ctx.json_path), /\/json$/) + assert.ok(ctx.task_headers) + assert.ok(ctx.task_badge) + }) +}) diff --git a/tests/node/unit/tasks.test.js b/tests/node/unit/tasks.test.js new file mode 100644 index 0000000..76cf2f3 --- /dev/null +++ b/tests/node/unit/tasks.test.js @@ -0,0 +1,69 @@ +const { describe, it, beforeEach } = require('node:test') +const assert = require('node:assert/strict') +const { setTimeout: sleep } = require('node:timers/promises') + +const { tasks } = require('../helpers/load-fusion') + +async function waitDone(tid, check, { timeoutMs = 2000 } = {}) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (check() && tasks.status(tid) === 'done') return + await sleep(20) + } +} + +describe('background tasks', () => { + beforeEach(() => { + tasks.reset() + }) + + it('spawn runs to done', async () => { + let n = 0 + const tid = tasks.spawn(() => { + n += 1 + }) + await waitDone(tid, () => n === 1) + assert.equal(n, 1) + assert.equal(tasks.status(tid), 'done') + }) + + it('spawnAfter runs to done', async () => { + let n = 0 + const tid = tasks.spawnAfter(80, () => { + n += 1 + }) + assert.ok(['pending', 'running'].includes(tasks.status(tid))) + await sleep(30) + assert.equal(n, 0) + await waitDone(tid, () => n === 1) + assert.equal(n, 1) + assert.equal(tasks.status(tid), 'done') + }) + + it('spawnAfter can be cancelled', async () => { + let n = 0 + const tid = tasks.spawnAfter(400, () => { + n += 1 + }) + assert.ok(['pending', 'running'].includes(tasks.status(tid))) + assert.equal(tasks.cancel(tid), true) + await sleep(150) + assert.equal(n, 0) + assert.equal(tasks.status(tid), 'cancelled') + }) + + it('status is null for unknown id', () => { + assert.equal(tasks.status('task-does-not-exist'), null) + assert.equal(tasks.cancel('task-does-not-exist'), false) + }) + + it('snapshot lists tasks', () => { + const tid = tasks.spawnAfter(5000, () => {}) + const snap = tasks.snapshot() + assert.ok(snap.task_count >= 1) + assert.ok(snap.active_count >= 1) + assert.ok(snap.tasks.some((t) => t.id === tid)) + assert.equal(tasks.cancel(tid), true) + assert.equal(tasks.snapshot().tasks[0].status, 'cancelled') + }) +}) diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000..c691af9 --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,20 @@ +"""Shared pytest fixtures for Fusion Framework Python tests.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("fusion_framework") + +from fusion_framework._fusion import clear_routes +from fusion_framework.middleware import clear_active_global + + +@pytest.fixture(autouse=True) +def _isolate_fusion_state(): + """Reset route registry and global middleware between tests.""" + clear_routes() + clear_active_global() + yield + clear_routes() + clear_active_global() diff --git a/tests/python/integration/README.md b/tests/python/integration/README.md new file mode 100644 index 0000000..3fb8614 --- /dev/null +++ b/tests/python/integration/README.md @@ -0,0 +1,9 @@ +# Integration tests (future) + +HTTP-level tests that start a real `FusionApp` and hit endpoints with `httpx` or `curl` belong here. + +Mark with `@pytest.mark.integration` and run: + +```bash +pytest tests/python/integration -m integration +``` diff --git a/tests/python/unit/test_cache.py b/tests/python/unit/test_cache.py new file mode 100644 index 0000000..a6c3bb0 --- /dev/null +++ b/tests/python/unit/test_cache.py @@ -0,0 +1,146 @@ +"""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()) + + +def test_snapshot_and_panel_context(): + cache.set("demo", {"n": 1}) + snap = cache.snapshot() + assert snap["driver"] == "moka" + assert snap["entry_count"] == 1 + assert snap["entries"][0]["key"] == "demo" + assert snap["events"][0]["op"] == "set" + assert "tasks" in snap + assert isinstance(snap["tasks"]["tasks"], list) + + ctx = cache.panel_context() + assert ctx["title"] == "Fusion Monitor" + assert ctx["empty_entries"] is False + assert ctx["entry_rows"][0][0] == "demo" + assert ctx["json_path"].endswith("/json") + assert "task_headers" in ctx + assert "task_badge" in ctx + + +def test_mount_monitor_respects_enabled_flag(): + from fusion_framework._fusion import App, Settings + from fusion_framework.monitor import mount_monitor + + settings_off = Settings() + settings_off.merge({"monitor": {"enabled": False}}) + engine_off = App() + assert mount_monitor(engine_off, settings_off) is False + + settings_on = Settings() + settings_on.merge( + { + "monitor": {"enabled": True, "path": "/__fusion/monitor"}, + "cache": {"driver": "moka"}, + } + ) + engine_on = App() + assert mount_monitor(engine_on, settings_on) is True diff --git a/crates/fusion-py/python/fusion_framework/test_http_route.py b/tests/python/unit/test_http_route.py similarity index 86% rename from crates/fusion-py/python/fusion_framework/test_http_route.py rename to tests/python/unit/test_http_route.py index b7e06b6..2fd5306 100644 --- a/crates/fusion-py/python/fusion_framework/test_http_route.py +++ b/tests/python/unit/test_http_route.py @@ -1,19 +1,11 @@ """Tests for custom HTTP method routes (@http_get / HttpGet).""" -from fusion_framework._fusion import clear_routes +from fusion_framework._fusion import openapi_spec from fusion_framework.api import FusionBaseApi from fusion_framework.http_route import http_get from fusion_framework.route import route -def setup_function(): - clear_routes() - - -def teardown_function(): - clear_routes() - - def test_custom_http_get_with_action_token(): @route("/api/[module]") class UserModule(FusionBaseApi): @@ -21,8 +13,6 @@ class UserModule(FusionBaseApi): def UserAction(self): return {"ok": True} - from fusion_framework._fusion import openapi_spec - spec = openapi_spec() assert "/api/user/test/user" in spec["paths"] assert "get" in spec["paths"]["/api/user/test/user"] @@ -39,8 +29,6 @@ def get(self): def ListAction(self): return {"mode": "custom"} - from fusion_framework._fusion import openapi_spec - spec = openapi_spec() assert "/api/product" in spec["paths"] assert "get" in spec["paths"]["/api/product"] @@ -61,8 +49,6 @@ def CatalogAction(self): def AdminAction(self): return {"ok": True} - from fusion_framework._fusion import openapi_spec - spec = openapi_spec() convention = spec["paths"]["/api/product"]["get"] catalog = spec["paths"]["/api/product/catalog/catalog"]["get"] diff --git a/tests/python/unit/test_middleware.py b/tests/python/unit/test_middleware.py new file mode 100644 index 0000000..9baea8a --- /dev/null +++ b/tests/python/unit/test_middleware.py @@ -0,0 +1,142 @@ +"""Unit tests for middleware chain (no server required).""" + +import asyncio +import inspect + +from fusion_framework.middleware import ( + bearer_jwt, + clear_active_global, + cors, + dispatch_route, + framework_headers, + request_id, + require_roles, + set_active_global, + static_files, +) + + +def _handler(request): + return {"status": 200, "body": {"state": request.get("state", {})}} + + +async def _async_handler(request): + return {"status": 200, "body": {"ok": True, "path": request.get("path")}} + + +def _sync_invoker_like(request): + """Mirrors PyO3 HandlerInvoker: sync ``__call__`` that may return a coroutine.""" + return _async_handler(request) + + +def test_require_roles_allows_matching_role(): + request = {"headers": {}, "state": {"jwt": {"roles": ["admin"]}}} + chain = [require_roles("admin", "super_admin")] + result = dispatch_route(request, _handler, chain) + assert result["status"] == 200 + + +def test_require_roles_blocks_missing_role(): + request = {"headers": {}, "state": {"jwt": {"roles": ["user"]}}} + chain = [require_roles("admin")] + result = dispatch_route(request, _handler, chain) + assert result["status"] == 403 + + +def test_bearer_jwt_populates_state(): + token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0." + request = {"headers": {"Authorization": f"Bearer {token}"}} + set_active_global([bearer_jwt()]) + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 + assert result["body"]["state"]["jwt"]["sub"] == "1" + + +def test_no_middleware_by_default(): + """FusionApp does not inject framework headers unless explicitly added.""" + set_active_global([]) + request = {"path": "/", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert "x-powered-by" not in headers + + +def test_request_id_header(): + set_active_global([request_id()]) + request = {"path": "/", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert "x-request-id" in headers + assert result["body"]["state"]["request_id"] == headers["x-request-id"] + + +def test_cors_options_preflight(): + set_active_global([cors()]) + request = { + "path": "/api", + "headers": {"Origin": "https://example.com"}, + "method": "OPTIONS", + } + result = dispatch_route(request, _handler, []) + assert result["status"] == 204 + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert headers.get("access-control-allow-origin") + + +def test_framework_headers_awaits_async_handler(): + """Regression: sync framework_headers must not stringify coroutine bodies.""" + set_active_global([framework_headers()]) + request = {"path": "/membership", "headers": {}, "method": "GET"} + result = dispatch_route(request, _sync_invoker_like, []) + assert inspect.isawaitable(result), "async handler result must stay awaitable for Rust" + resolved = asyncio.run(result) + assert resolved["status"] == 200 + assert resolved["body"] == {"ok": True, "path": "/membership"} + headers = {str(k).lower(): v for k, v in (resolved.get("headers") or {}).items()} + assert "x-powered-by" in headers + body = resolved.get("body") + assert not (isinstance(body, str) and body.startswith(" None: + tasks.reset() + + +def _wait_done(tid: str, side_effect, *, timeout_s: float = 2.0) -> None: + """Poll until status is done and side_effect() is truthy.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if side_effect() and tasks.status(tid) == "done": + return + time.sleep(0.02) + + +def test_spawn_runs(): + box = {"n": 0} + + def work(): + box["n"] += 1 + + tid = tasks.spawn(work) + _wait_done(tid, lambda: box["n"] == 1) + assert box["n"] == 1 + assert tasks.status(tid) == "done" + + +def test_spawn_after_runs(): + box = {"n": 0} + + def work(): + box["n"] += 1 + + tid = tasks.spawn_after(80, work) + assert tasks.status(tid) in ("pending", "running") + time.sleep(0.03) + assert box["n"] == 0 + _wait_done(tid, lambda: box["n"] == 1) + assert box["n"] == 1 + assert tasks.status(tid) == "done" + + +def test_spawn_after_and_cancel(): + box = {"n": 0} + + def work(): + box["n"] += 1 + + tid = tasks.spawn_after(400, work) + assert tasks.status(tid) in ("pending", "running") + assert tasks.cancel(tid) is True + time.sleep(0.15) + assert box["n"] == 0 + assert tasks.status(tid) == "cancelled" + + +def test_status_unknown_id(): + assert tasks.status("task-does-not-exist") is None + assert tasks.cancel("task-does-not-exist") is False + + +def test_snapshot_lists_tasks(): + tid = tasks.spawn_after(5_000, lambda: None) + snap = tasks.snapshot() + assert snap["task_count"] >= 1 + assert snap["active_count"] >= 1 + ids = [t["id"] for t in snap["tasks"]] + assert tid in ids + assert tasks.cancel(tid) is True + assert tasks.snapshot()["tasks"][0]["status"] == "cancelled" diff --git a/tests/python/unit/test_template_form.py b/tests/python/unit/test_template_form.py new file mode 100644 index 0000000..8c5f018 --- /dev/null +++ b/tests/python/unit/test_template_form.py @@ -0,0 +1,88 @@ +"""Unit tests for template form helpers (form / ok / fail).""" + +from __future__ import annotations + +from fusion_framework.template import FusionBaseTemplate, parse_form_body + + +def test_parse_form_body_urlencoded(): + data = parse_form_body( + "name=Ada&phone=0912", + "application/x-www-form-urlencoded", + ) + assert data["name"] == "Ada" + assert data["phone"] == "0912" + + +def test_parse_form_body_json(): + data = parse_form_body('{"name":"Ada","phone":null}', "application/json") + assert data["name"] == "Ada" + assert data["phone"] == "" + + +class _Page(FusionBaseTemplate): + template = "home/index.html" + + def context(self): + return {"title": "t", "message": "m", "errors": {}, "ok": False} + + +def test_fail_returns_json_when_accept_json(tmp_path, monkeypatch): + monkeypatch.setenv("FUSION_ENV", "dev") + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "phone=", + "headers": { + "accept": "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + "params": {}, + "query": {}, + "state": {}, + } + ) + page.templates_dir = str(tmp_path) + out = page.fail({"phone": "required"}, message="bad", name="Ada") + assert out["status"] == 400 + body = out["body"] + assert body["ok"] is False + assert body["errors"]["phone"] == "required" + assert body["fields"]["name"] == "Ada" + + +def test_ok_returns_json_when_accept_json(tmp_path): + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "", + "headers": {"accept": "application/json"}, + "params": {}, + "query": {}, + "state": {}, + } + ) + page.templates_dir = str(tmp_path) + out = page.ok(message="Saved.", name="Ada") + assert out["status"] == 200 + assert out["body"]["ok"] is True + assert out["body"]["message"] == "Saved." + assert out["body"]["fields"]["name"] == "Ada" + + +def test_form_property_parses_body(): + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "email=a%40b.com&phone=09", + "headers": {"content-type": "application/x-www-form-urlencoded"}, + "params": {}, + "query": {}, + "state": {}, + } + ) + assert page.form["email"] == "a@b.com" + assert page.form["phone"] == "09" diff --git a/tests/python/unit/test_templates.py b/tests/python/unit/test_templates.py new file mode 100644 index 0000000..3d8daf6 --- /dev/null +++ b/tests/python/unit/test_templates.py @@ -0,0 +1,165 @@ +"""Tests for Tera template rendering.""" + +from __future__ import annotations + +import asyncio +import inspect +from pathlib import Path + +import pytest + +from fusion_framework.template import FusionBaseTemplate, render_template + + +def test_render_builtin_button_macro(): + root = Path(__file__).resolve().parents[2] / "fixtures" / "templates" + html = render_template("sample/page.html", {}, templates_root=root) + assert "fusion-btn" in html + assert "Go" in html + + +def test_render_badge_and_table(tmp_path: Path): + """Badge + table components render with the welcome-page styles.""" + (tmp_path / "page.html").write_text( + """ + {{}} + {{}} + """, + encoding="utf-8", + ) + html = render_template( + "page.html", + {"headers": ["A"], "rows": [["1"], ["2"]]}, + templates_root=tmp_path, + ) + assert "fusion-badge__dot" in html + assert "fusion-table" in html + assert "data-page-size=\"10\"" in html + assert "fusion-table-pager" in html + assert "1" in html + + +def test_fusion_base_template_context(tmp_path: Path): + class Page(FusionBaseTemplate): + template = "hello.html" + + def context(self): + return {"name": "Fusion"} + + (tmp_path / "hello.html").write_text("

Hello {{ name }}!

", encoding="utf-8") + + page = Page({"method": "GET", "path": "/"}) + page.templates_dir = str(tmp_path) + out = page.render() + assert out["status"] == 200 + assert out["headers"]["content-type"].startswith("text/html") + assert "Hello Fusion!" in out["body"] + + +def test_template_name_required(): + class Bad(FusionBaseTemplate): + pass + + with pytest.raises(ValueError, match="template"): + Bad({}).template_name() + + +def test_template_get_returns_json_with_accept(tmp_path: Path): + class Page(FusionBaseTemplate): + template = "hello.html" + + def context(self): + return {"name": "Fusion"} + + (tmp_path / "hello.html").write_text("

Hello {{ name }}!

", encoding="utf-8") + + page = Page( + { + "method": "GET", + "path": "/pages/home", + "headers": {"accept": "application/json"}, + } + ) + page.templates_dir = str(tmp_path) + assert page.get() == {"name": "Fusion"} + + +def test_template_get_returns_json_with_format_query(): + class Page(FusionBaseTemplate): + template = "hello.html" + + def context(self): + return {"name": "Fusion"} + + page = Page( + { + "method": "GET", + "path": "/pages/home", + "query": {"format": "json"}, + } + ) + assert page.get() == {"name": "Fusion"} + + +def test_async_context_renders_html(tmp_path: Path): + """async def context() is awaited by get()/render().""" + + class Page(FusionBaseTemplate): + template = "hello.html" + + async def context(self): + return {"name": "AsyncFusion"} + + (tmp_path / "hello.html").write_text("

Hello {{ name }}!

", encoding="utf-8") + page = Page({"method": "GET", "path": "/"}) + page.templates_dir = str(tmp_path) + + out = page.get() + assert inspect.isawaitable(out) + resolved = asyncio.run(out) + assert resolved["status"] == 200 + assert "Hello AsyncFusion!" in resolved["body"] + + +def test_async_context_json_accept(): + class Page(FusionBaseTemplate): + template = "hello.html" + + async def context(self): + return {"title": "from-db"} + + page = Page( + { + "method": "GET", + "path": "/", + "headers": {"accept": "application/json"}, + } + ) + out = page.get() + assert inspect.isawaitable(out) + assert asyncio.run(out) == {"title": "from-db"} + + +def test_template_get_returns_html_for_browser_accept(tmp_path: Path): + class Page(FusionBaseTemplate): + template = "hello.html" + + def context(self): + return {"name": "Fusion"} + + (tmp_path / "hello.html").write_text("

Hello {{ name }}!

", encoding="utf-8") + + page = Page( + { + "method": "GET", + "path": "/pages/home", + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9" + }, + } + ) + page.templates_dir = str(tmp_path) + out = page.get() + assert out["status"] == 200 + assert out["headers"]["content-type"].startswith("text/html") + assert "Hello Fusion!" in out["body"] diff --git a/tests/scripts/run-all.sh b/tests/scripts/run-all.sh new file mode 100755 index 0000000..a33b482 --- /dev/null +++ b/tests/scripts/run-all.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Run the full Fusion Framework test suite (local). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +echo "==> Rust (fusion-core)" +cargo test -p fusion-core + +echo "==> Node syntax" +node --check crates/fusion-node/index.js + +echo "==> Node unit tests" +if ls crates/fusion-node/*.node >/dev/null 2>&1; then + ./tests/scripts/run-node.sh +else + echo " skip: build addon with (cd crates/fusion-node && npm run build:debug)" +fi + +echo "==> C# tests" +cargo build -p fusion-ffi --release -q +./tests/scripts/run-csharp.sh -q + +echo "==> Python (pytest)" +if ! python3 -c "import fusion_framework" 2>/dev/null; then + echo " fusion_framework not installed — run: ./scripts/dev-install-python.sh --venv .venv" + exit 1 +fi +python3 -m pytest tests/python -q + +echo "==> All checks passed" diff --git a/tests/scripts/run-csharp.sh b/tests/scripts/run-csharp.sh new file mode 100755 index 0000000..44f9709 --- /dev/null +++ b/tests/scripts/run-csharp.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +echo "Building fusion-ffi (required for C# tests)..." +cargo build -p fusion-ffi --release + +dotnet test tests/csharp/FusionFramework.Tests/FusionFramework.Tests.csproj -c Release "$@" diff --git a/tests/scripts/run-node.sh b/tests/scripts/run-node.sh new file mode 100755 index 0000000..acf099c --- /dev/null +++ b/tests/scripts/run-node.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT/crates/fusion-node" + +if ! ls ./*.node >/dev/null 2>&1; then + echo "Native addon missing — run: npm run build:debug (in crates/fusion-node)" >&2 + exit 1 +fi + +node --test "$ROOT/tests/node/unit/"*.test.js "$@" diff --git a/tests/scripts/run-python.sh b/tests/scripts/run-python.sh new file mode 100755 index 0000000..0a9f49f --- /dev/null +++ b/tests/scripts/run-python.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +if ! python3 -c "import fusion_framework" 2>/dev/null; then + echo "fusion_framework not installed — run: ./scripts/dev-install-python.sh --venv .venv" >&2 + exit 1 +fi + +python3 -m pytest tests/python "$@"