Skip to content
Merged

Dev #28

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Each skill teaches domain-specific workflows for Fusion (Rust core + Python / No
| `fusion-release` | Version bumps, manifests, publish prep |
| `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` |

## Always-on rules

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/fusion-bindings-parity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas
| 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

Expand Down
3 changes: 3 additions & 0 deletions .agents/skills/fusion-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ 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]`).
Expand Down Expand Up @@ -151,6 +152,8 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`.
}
```

For TypeScript apps, `commands.run` must invoke a TypeScript runner (e.g. `npx tsx main.ts`); plain `node main.ts` fails with `ERR_UNKNOWN_FILE_EXTENSION`. With `reload: true`, the framework reloader forwards `process.execArgv` so tsx loaders survive child respawns. Older CLI scaffolds that still emit `node main.ts` should be updated in **fusion-tool** (`environment.rs` / `structure.rs`).

`FUSION_ENV` selects `fusion.<env>.json` (default `dev`). Unresolved `HOST` placeholders must not crash listen — framework resolves safe defaults.

## Compatibility duties (framework ↔ CLI)
Expand Down
54 changes: 54 additions & 0 deletions .agents/skills/fusion-template-forms/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
<form method="post" action="/register" data-fusion-form>
<div id="fusion-form-status"></div>
<input name="phone" />
<span class="fusion-field-error" data-field="phone"></span>
<button type="submit">Save</button>
</form>
<script>{% include "fusion/form.js" %}</script>
```

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}`.
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
]

[workspace.package]
version = "2.0.0"
version = "2.0.1"
edition = "2024"

[workspace.dependencies]
Expand Down
5 changes: 4 additions & 1 deletion bindings/csharp/FusionFramework/FusionBaseApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ public object Response(object? body = null, int status = 200, IDictionary<string
_ => JsonSerializer.SerializeToNode(body),
},
};
// Keep Dictionary<string, string> so middleware MergeResponseHeaders can
// preserve content-type (e.g. text/html from templates). Boxing as object
// made the typed merge check fail and dropped headers before the browser.
if (headers is { Count: > 0 })
envelope["headers"] = headers.ToDictionary(kv => kv.Key, kv => (object)kv.Value);
envelope["headers"] = new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase);
return envelope;
}

Expand Down
157 changes: 156 additions & 1 deletion bindings/csharp/FusionFramework/FusionBaseTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,71 @@ public abstract class FusionBaseTemplate : FusionBaseApi
public static string TemplateAddress { get; set; } = "";
public static string TemplatesDir { get; set; } = "";

/// <summary>Sync template variables (override in subclasses).</summary>
/// <summary>
/// Template variables (not an HTTP verb). Override in subclasses.
/// <see cref="Get"/> renders this as HTML; POST handlers use <see cref="Form"/> / <see cref="Ok"/> / <see cref="Fail"/>.
/// </summary>
public virtual Dictionary<string, JsonNode?> Context() => new();

/// <summary>Async template variables; default wraps <see cref="Context"/>.</summary>
public virtual Task<Dictionary<string, JsonNode?>> ContextAsync() =>
Task.FromResult(Context());

/// <summary>Parsed POST body (urlencoded or JSON) as flat string fields.</summary>
public Dictionary<string, string> Form => ParseFormBody(Body, ContentType());

/// <summary>Parse urlencoded or JSON body into flat string fields.</summary>
public static Dictionary<string, string> ParseFormBody(string? body, string? contentType)
{
var raw = body ?? "";
var ct = (contentType ?? "").ToLowerInvariant();
var outDict = new Dictionary<string, string>(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;
}

/// <summary>
/// Default GET — HTML or JSON context. Uses <see cref="ContextAsync"/> so
/// subclasses can override that for DB/API-backed pages (Python async context parity).
Expand All @@ -42,6 +100,103 @@ object FinishGet(Dictionary<string, JsonNode?> ctx)
return HtmlResponse(ctx);
}

/// <summary>Validation failure — JSON for SPA fetch, else same template with errors.</summary>
public object Fail(
IDictionary<string, string>? errors = null,
string? message = null,
IDictionary<string, string>? fields = null)
{
var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var flat = fields?.ToDictionary(
kv => kv.Key,
kv => kv.Value ?? "",
StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var msg = message ?? "Validation failed";
if (WantsJson())
{
return Response(new Dictionary<string, object?>
{
["ok"] = false,
["message"] = msg,
["errors"] = err,
["fields"] = flat,
}, 400);
}
return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400);
}

/// <summary>Success — JSON for SPA fetch, else same template with ok=true.</summary>
public object Ok(string? message = null, IDictionary<string, string>? fields = null)
{
var flat = fields?.ToDictionary(
kv => kv.Key,
kv => kv.Value ?? "",
StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var msg = message ?? "OK";
if (WantsJson())
{
return Response(new Dictionary<string, object?>
{
["ok"] = true,
["message"] = msg,
["errors"] = new Dictionary<string, string>(),
["fields"] = flat,
}, 200);
}
return FormHtmlResult(
ok: true,
message: msg,
errors: new Dictionary<string, string>(),
fields: flat,
status: 200);
}

object FormHtmlResult(
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> 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<object> FormHtmlResultAsync(
Task<Dictionary<string, JsonNode?>> task,
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> fields,
int status)
{
var ctx = await task.ConfigureAwait(false);
return FinishFormHtml(ctx, ok, message, errors, fields, status);
}

object FinishFormHtml(
Dictionary<string, JsonNode?> ctx,
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> fields,
int status)
{
var data = new Dictionary<string, JsonNode?>(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;
Expand Down
12 changes: 10 additions & 2 deletions bindings/csharp/FusionFramework/FusionFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<RootNamespace>FusionFramework</RootNamespace>
<AssemblyName>FusionFramework</AssemblyName>
<PackageId>Fusion-Framework</PackageId>
<Version>2.0.0</Version>
<Version>2.0.1</Version>
<Authors>CipherUnits</Authors>
<Company>CipherUnits</Company>
<Description>Fusion Framework managed bindings (C#) over fusion-ffi / fusion-core</Description>
Expand All @@ -28,6 +28,14 @@
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
<Content Include="runtimes/**" Pack="true" PackagePath="runtimes" CopyToOutputDirectory="PreserveNewest" />
<Content Include="static/swagger-ui/**" Pack="true" PackagePath="static/swagger-ui/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<!-- Disk copy for ProjectReference / local builds; also pack beside the DLL for NuGet. -->
<Content Include="static/swagger-ui/**"
Pack="true"
PackagePath="lib/$(TargetFramework)/static/swagger-ui/%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<!-- Embedded so NuGet consumers still serve Swagger UI without copy-to-output. -->
<EmbeddedResource Include="static/swagger-ui/swagger-ui-bundle.js" />
<EmbeddedResource Include="static/swagger-ui/swagger-ui-standalone-preset.js" />
<EmbeddedResource Include="static/swagger-ui/swagger-ui.css" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion bindings/csharp/FusionFramework/Header.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public static Dictionary<string, string> Fingerprint()
{
["X-Powered-By"] = "Fusion Framework",
["X-Framework"] = "Fusion",
["X-Fusion-Version"] = "2.0.0",
["X-Fusion-Version"] = "2.0.1",
};
}
}
Expand Down
Loading
Loading