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 3f4e68a..14d4365 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 + + - name: ruff format + run: ruff format --check crates/fusion-py/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: @@ -51,6 +104,7 @@ jobs: node: name: Node addon (${{ matrix.settings.target }}) + needs: lint strategy: fail-fast: false matrix: @@ -84,6 +138,7 @@ jobs: csharp: name: C# package + needs: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index db6e486..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. 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/Cargo.lock b/Cargo.lock index 9bafb2f..69622de 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", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -133,6 +147,7 @@ dependencies = [ "hyper", "hyper-util", "serde_json", + "tera", "tokio", ] @@ -209,6 +224,18 @@ dependencies = [ "pin-project-lite", ] +[[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", + "wasip2", +] + [[package]] name = "h2" version = "0.4.15" @@ -529,6 +556,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 +633,12 @@ 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 = "redox_syscall" version = "0.5.18" @@ -789,6 +828,18 @@ 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 +943,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[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 +964,15 @@ 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 = "windows-link" version = "0.2.1" @@ -951,6 +1017,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/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/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..0672a51 --- /dev/null +++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs @@ -0,0 +1,65 @@ +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; } = ""; + + public virtual Dictionary Context() => new(); + + public virtual object Get() + { + if (WantsJson()) + return Context(); + return Render(); + } + + 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 ctx = new Dictionary(Context(), StringComparer.Ordinal); + if (context != null) + { + foreach (var kv in context) + ctx[kv.Key] = kv.Value; + } + 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..1bd4cc0 100644 --- a/bindings/csharp/FusionFramework/FusionFramework.csproj +++ b/bindings/csharp/FusionFramework/FusionFramework.csproj @@ -25,5 +25,6 @@ + diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 36fd0c6..ad1c598 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -53,32 +53,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; - } } } diff --git a/bindings/csharp/FusionFramework/Native.cs b/bindings/csharp/FusionFramework/Native.cs index 88123ee..8768268 100644 --- a/bindings/csharp/FusionFramework/Native.cs +++ b/bindings/csharp/FusionFramework/Native.cs @@ -186,6 +186,12 @@ 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); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate IntPtr FusionHandlerFn( IntPtr userData, diff --git a/bindings/csharp/FusionFramework/Swagger.cs b/bindings/csharp/FusionFramework/Swagger.cs index ede2688..349a98f 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) @@ -400,7 +401,7 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa """ : ""; var standalone = navbarEnabled - ? """""" + ? $"""""" : ""; var navbarJs = navbarEnabled ? "true" : "false"; @@ -411,12 +412,12 @@ static string UiHtml(SwaggerConfig swagger, string openapiUrl, string? primaryNa {{title}} - + {{hideUrlCss}}
- + {{standalone}} ` + ? `` : '' return ` @@ -854,12 +975,12 @@ function swaggerUiHtml(swagger, openapiUrl, primaryName = null) { ${title} - + ${hideUrlCss}
- + ${standaloneScript} ' + f'' ) bootstrap = f""" @@ -262,12 +298,12 @@ def _swagger_ui_html(swagger: dict[str, Any], openapi_url: str, primary_name: st {title} - + {hide_url_css}
- + {standalone_script}