diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b65933b7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "build" + include: "scope" + groups: + dotnet: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "ci" + include: "scope" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..74f78b97 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: CodeQL + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + schedule: + - cron: "27 3 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (C#) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: none + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:csharp" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f4011b47 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,47 @@ +name: Docs + +on: + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install DocFX + run: dotnet tool update -g docfx + + - name: Build site + run: docfx docs/docfx.json + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 5f70bc98..3207c162 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -47,13 +47,11 @@ jobs: - name: Pack publishable packages run: | set -euo pipefail - packages=( - "src/SquidStd.Abstractions/SquidStd.Abstractions.csproj" - "src/SquidStd.Core/SquidStd.Core.csproj" - "src/SquidStd.Network/SquidStd.Network.csproj" - "src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj" - "src/SquidStd.Services.Core/SquidStd.Services.Core.csproj" - ) + # Discover every project opted-in with true. + mapfile -t packages < <(grep -ril --include='*.csproj' 'true' src | sort) + if [[ ${#packages[@]} -eq 0 ]]; then + echo "No project has true"; exit 1 + fi for project in "${packages[@]}"; do echo "Packing ${project}" dotnet pack "${project}" -c Release --no-build diff --git a/.gitignore b/.gitignore index 10f58e87..92b52a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -589,3 +589,6 @@ definitions.lua dist/ benchmarks/.compare + +# CodeGraph local index +.codegraph/ diff --git a/CODE_CONVENTION.md b/CODE_CONVENTION.md index 43b9f66a..8f318fcf 100644 --- a/CODE_CONVENTION.md +++ b/CODE_CONVENTION.md @@ -99,7 +99,7 @@ public enum DirectoryType { Scripts, Logs, Plugins, Configs } ## 7. Strings -- Always use `""` instead of `string.Empty`. +- Always use `string.Empty` instead of `""`. ## 8. Logging @@ -201,7 +201,7 @@ tests/SquidStd.Tests/Support/FakeSourcePlugin.cs → namespace SquidStd.Tests - No TODO comments without a tracked follow-up. - No inconsistent naming across domains. - Keep warnings under control; do not normalize noisy warnings. -- No `string.Empty` — use `""`. +- No literal `""` — use `string.Empty`. - No primary constructors. - No expression-bodied constructors. diff --git a/Directory.Build.props b/Directory.Build.props index 6237807f..b69d2be4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -72,4 +72,12 @@ + + + README.md + + + + + diff --git a/README.md b/README.md index 8f4ae6b9..a373b75d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@

- SquidStd + squid-std

-

SquidStd

+

squid-std

+ .NET 10 CI tests coverage @@ -13,17 +14,96 @@ ## Overview -Short description goes here. +**squid-std** is a batteries-included standard library for .NET, distilled from years and years +of building real-world server software. Instead of re-solving the same problems on every project, +it bundles the foundations you reach for again and again behind small, well-defined contracts: + +- **Security & hashing** — password hashing and verification (`HashUtils`), AES-GCM secret + protection and a pluggable secret store (`ISecretProtector` / `ISecretStore`). +- **Configuration** — a YAML-backed config manager with section registration and environment-variable expansion. +- **Serialization** — unified JSON/YAML utilities (`JsonUtils`, `YamlUtils`) and a shared `IDataSerializer` / `IDataDeserializer`. +- **String & platform helpers** — case converters (camel/kebab/snake/pascal/…), network, version, platform and resource utilities. +- **Runtime services** — DI bootstrap, event bus, job system, timer/cron scheduler, metrics and storage. +- **Infrastructure modules** — messaging (in-memory + RabbitMQ), caching (in-memory + Redis), + database access, networking (TCP/UDP) and Lua scripting. + +Everything is modular: take only the packages you need, each behind a clean abstraction with an +in-memory implementation for tests and an external backend for production. + +## Requirements + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Docker](https://www.docker.com/) — only for running the integration tests (Testcontainers spin up RabbitMQ and Redis). + +## Quick Start + +```bash +dotnet add package SquidStd.Services.Core +dotnet add package SquidStd.Caching +``` + +```csharp +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +// Create the bootstrapper (registers the core services), then opt into the modules you need. +var bootstrap = SquidStdBootstrap.Create() + .ConfigureServices(container => container.AddInMemoryCache()); + +await bootstrap.StartAsync(); + +var cache = bootstrap.Resolve(); +await cache.SetAsync("answer", 42, TimeSpan.FromMinutes(5)); +var answer = await cache.GetAsync("answer"); + +await bootstrap.StopAsync(); +``` + +`SquidStdBootstrap` owns a DryIoc container, wires the core services, and drives the +`StartAsync` / `StopAsync` lifecycle of every registered `ISquidStdService`. Use `RunAsync` to +block until cancellation for long-running hosts. ## Packages -| Package | Version | Downloads | -|---------|---------|-----------| -| `SquidStd.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Abstractions.svg) | -| `SquidStd.Core` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Core.svg) | -| `SquidStd.Network` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Network.svg) | -| `SquidStd.Plugin.Abstractions` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Plugin.Abstractions.svg) | -| `SquidStd.Services.Core` | [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | ![Downloads](https://img.shields.io/nuget/dt/SquidStd.Services.Core.svg) | +| Package | Description | Links | +|---------|-------------|-------| +| `SquidStd.Core` | Foundational contracts & utilities (config, event bus, jobs, metrics, storage, YAML/JSON, Serilog sink). | [readme](src/SquidStd.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Core.svg)](https://www.nuget.org/packages/SquidStd.Core/) | +| `SquidStd.Abstractions` | DI registration plumbing (`ISquidStdService`, `RegisterStdService`, `RegisterConfigSection`). | [readme](src/SquidStd.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Abstractions/) | +| `SquidStd.Services.Core` | Concrete services: config, event bus, jobs, timer/cron scheduler, dispatcher, metrics, storage. | [readme](src/SquidStd.Services.Core/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Services.Core.svg)](https://www.nuget.org/packages/SquidStd.Services.Core/) | +| `SquidStd.AspNetCore` | ASP.NET Core host integration for the SquidStd service stack. | [readme](src/SquidStd.AspNetCore/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.AspNetCore.svg)](https://www.nuget.org/packages/SquidStd.AspNetCore/) | +| `SquidStd.Network` | TCP/UDP servers & clients, sessions, framing/middleware pipeline, span readers/writers. | [readme](src/SquidStd.Network/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Network.svg)](https://www.nuget.org/packages/SquidStd.Network/) | +| `SquidStd.Plugin.Abstractions` | Plugin contracts (`ISquidStdPlugin`, metadata, context). | [readme](src/SquidStd.Plugin.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Plugin.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Plugin.Abstractions/) | +| `SquidStd.Database.Abstractions` | Provider-agnostic data-access contracts (`IDataAccess`, `BaseEntity`, `PagedResultData`). | [readme](src/SquidStd.Database.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Database.Abstractions/) | +| `SquidStd.Database` | FreeSql-backed data access (CRUD/bulk/paging, URI connection strings, ZLinq helpers). | [readme](src/SquidStd.Database/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Database.svg)](https://www.nuget.org/packages/SquidStd.Database/) | +| `SquidStd.Messaging.Abstractions` | Messaging contracts (`IMessageQueue`, `IQueueProvider`, serializer/metrics, listeners). | [readme](src/SquidStd.Messaging.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Messaging.Abstractions/) | +| `SquidStd.Messaging` | In-memory messaging transport (`AddInMemoryMessaging`). | [readme](src/SquidStd.Messaging/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.svg)](https://www.nuget.org/packages/SquidStd.Messaging/) | +| `SquidStd.Messaging.RabbitMq` | RabbitMQ messaging transport (`AddRabbitMqMessaging`). | [readme](src/SquidStd.Messaging.RabbitMq/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Messaging.RabbitMq.svg)](https://www.nuget.org/packages/SquidStd.Messaging.RabbitMq/) | +| `SquidStd.Caching.Abstractions` | Caching contracts (`ICacheService`, `ICacheProvider`, `CacheService` facade, metrics, connection string). | [readme](src/SquidStd.Caching.Abstractions/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Abstractions.svg)](https://www.nuget.org/packages/SquidStd.Caching.Abstractions/) | +| `SquidStd.Caching` | In-memory cache backend (`AddInMemoryCache`). | [readme](src/SquidStd.Caching/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.svg)](https://www.nuget.org/packages/SquidStd.Caching/) | +| `SquidStd.Caching.Redis` | Redis cache backend (`AddRedisCache`). | [readme](src/SquidStd.Caching.Redis/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Caching.Redis.svg)](https://www.nuget.org/packages/SquidStd.Caching.Redis/) | +| `SquidStd.Scripting.Lua` | Lua scripting engine with attribute-based modules and event bridging. | [readme](src/SquidStd.Scripting.Lua/README.md) · [![NuGet](https://img.shields.io/nuget/v/SquidStd.Scripting.Lua.svg)](https://www.nuget.org/packages/SquidStd.Scripting.Lua/) | + +## Architecture + +squid-std follows a few consistent principles across every module: + +- **KISS** — small, focused types; one class / record / enum per file. +- **Abstractions first** — each capability is split into an `*.Abstractions` package (interfaces, + DTOs, shared facade) plus one or more provider packages. Consumers depend on the abstraction. +- **In-memory + external provider** — every infrastructure module ships an in-memory provider + (great for tests and local dev) and a production backend behind the same interface + (messaging: in-memory / RabbitMQ; caching: in-memory / Redis). +- **DI-driven** — services are registered with DryIoc through `AddXxx(...)` extensions and resolved + through `SquidStdBootstrap`, which manages the `ISquidStdService` lifecycle. +- **Convention over restructure** — interfaces under `Interfaces`, DTOs under `Data`, enums under + `Types`, internals under `Internal`. See [`CODE_CONVENTION.md`](CODE_CONVENTION.md). + +## Documentation + +Full API documentation is published with DocFX to GitHub Pages: +**[tgiachi.github.io/SquidStd](https://tgiachi.github.io/SquidStd/)**. Each package also ships its +own README (linked in the table above). ## Build @@ -37,6 +117,23 @@ dotnet build SquidStd.slnx dotnet test SquidStd.slnx ``` +Integration tests (RabbitMQ, Redis) need Docker running; they use Testcontainers to start +disposable containers automatically. + +## Contributing + +- Work happens on `develop`; `main` holds released code. +- Use [Conventional Commits](https://www.conventionalcommits.org/) for messages + (`feat:`, `fix:`, `refactor:`, `docs:`, `build:`, `test:`). +- Follow the project conventions in [`CODE_CONVENTION.md`](CODE_CONVENTION.md). +- Add tests for new behaviour and keep the suite green before opening a PR. + +## Versioning & Releases + +Releases are automated with [semantic-release](https://semantic-release.gitbook.io/): version +numbers and the changelog are derived from the conventional-commit history, and the NuGet packages +are published on release. Package versions follow [Semantic Versioning](https://semver.org/). + ## License MIT - see [LICENSE](LICENSE). diff --git a/SquidStd.slnx b/SquidStd.slnx index 81f362b6..12acb065 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -1,8 +1,22 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..b7772619 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,4 @@ +_site/ +api/*.yml +api/.manifest +obj/ diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 00000000..f94ad68c --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,5 @@ +# API Reference + +Auto-generated reference for all published SquidStd packages, grouped by namespace. Use the sidebar to +browse types and members. Each package's purpose and a usage example are in its +[article](../articles/getting-started.md). diff --git a/docs/articles/abstractions.md b/docs/articles/abstractions.md new file mode 100644 index 00000000..bf188aec --- /dev/null +++ b/docs/articles/abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Abstractions/README.md)] diff --git a/docs/articles/aspnetcore.md b/docs/articles/aspnetcore.md new file mode 100644 index 00000000..63458816 --- /dev/null +++ b/docs/articles/aspnetcore.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.AspNetCore/README.md)] diff --git a/docs/articles/caching-abstractions.md b/docs/articles/caching-abstractions.md new file mode 100644 index 00000000..722af412 --- /dev/null +++ b/docs/articles/caching-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching.Abstractions/README.md)] diff --git a/docs/articles/caching-redis.md b/docs/articles/caching-redis.md new file mode 100644 index 00000000..f013c25c --- /dev/null +++ b/docs/articles/caching-redis.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching.Redis/README.md)] diff --git a/docs/articles/caching.md b/docs/articles/caching.md new file mode 100644 index 00000000..f98cf138 --- /dev/null +++ b/docs/articles/caching.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Caching/README.md)] diff --git a/docs/articles/core.md b/docs/articles/core.md new file mode 100644 index 00000000..1c84ca7f --- /dev/null +++ b/docs/articles/core.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Core/README.md)] diff --git a/docs/articles/database-abstractions.md b/docs/articles/database-abstractions.md new file mode 100644 index 00000000..2bc73ad3 --- /dev/null +++ b/docs/articles/database-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Database.Abstractions/README.md)] diff --git a/docs/articles/database.md b/docs/articles/database.md new file mode 100644 index 00000000..b739dab6 --- /dev/null +++ b/docs/articles/database.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Database/README.md)] diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md new file mode 100644 index 00000000..88533d39 --- /dev/null +++ b/docs/articles/getting-started.md @@ -0,0 +1,20 @@ +# Getting Started + +Install the package(s) you need and bootstrap the core services. + +```bash +dotnet add package SquidStd.Services.Core +``` + +```csharp +using DryIoc; +using SquidStd.Services.Core.Extensions; + +var container = new Container(); + +// config manager + event bus + jobs + timer wheel + dispatcher + metrics + storage + secrets +container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); +``` + +From here, add focused packages as needed — see the per-package guides in the sidebar and the +[API reference](../api/index.md). diff --git a/docs/articles/messaging-abstractions.md b/docs/articles/messaging-abstractions.md new file mode 100644 index 00000000..7868e11e --- /dev/null +++ b/docs/articles/messaging-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging.Abstractions/README.md)] diff --git a/docs/articles/messaging-rabbitmq.md b/docs/articles/messaging-rabbitmq.md new file mode 100644 index 00000000..1f0e3f35 --- /dev/null +++ b/docs/articles/messaging-rabbitmq.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging.RabbitMq/README.md)] diff --git a/docs/articles/messaging.md b/docs/articles/messaging.md new file mode 100644 index 00000000..070fedeb --- /dev/null +++ b/docs/articles/messaging.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Messaging/README.md)] diff --git a/docs/articles/network.md b/docs/articles/network.md new file mode 100644 index 00000000..9d4f9475 --- /dev/null +++ b/docs/articles/network.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Network/README.md)] diff --git a/docs/articles/plugin-abstractions.md b/docs/articles/plugin-abstractions.md new file mode 100644 index 00000000..63250ddb --- /dev/null +++ b/docs/articles/plugin-abstractions.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Plugin.Abstractions/README.md)] diff --git a/docs/articles/scripting-lua.md b/docs/articles/scripting-lua.md new file mode 100644 index 00000000..38a403f6 --- /dev/null +++ b/docs/articles/scripting-lua.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Scripting.Lua/README.md)] diff --git a/docs/articles/services-core.md b/docs/articles/services-core.md new file mode 100644 index 00000000..0079679d --- /dev/null +++ b/docs/articles/services-core.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Services.Core/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml new file mode 100644 index 00000000..730dd3da --- /dev/null +++ b/docs/articles/toc.yml @@ -0,0 +1,32 @@ +- name: Getting Started + href: getting-started.md +- name: SquidStd.Core + href: core.md +- name: SquidStd.Abstractions + href: abstractions.md +- name: SquidStd.Services.Core + href: services-core.md +- name: SquidStd.AspNetCore + href: aspnetcore.md +- name: SquidStd.Network + href: network.md +- name: SquidStd.Plugin.Abstractions + href: plugin-abstractions.md +- name: SquidStd.Database.Abstractions + href: database-abstractions.md +- name: SquidStd.Database + href: database.md +- name: SquidStd.Messaging.Abstractions + href: messaging-abstractions.md +- name: SquidStd.Messaging + href: messaging.md +- name: SquidStd.Messaging.RabbitMq + href: messaging-rabbitmq.md +- name: SquidStd.Caching.Abstractions + href: caching-abstractions.md +- name: SquidStd.Caching + href: caching.md +- name: SquidStd.Caching.Redis + href: caching-redis.md +- name: SquidStd.Scripting.Lua + href: scripting-lua.md diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 00000000..399777c9 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,34 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "../", + "files": [ "src/**/*.csproj" ], + "exclude": [ "**/bin/**", "**/obj/**" ] + } + ], + "dest": "api", + "properties": { "TargetFramework": "net10.0" } + } + ], + "build": { + "content": [ + { "files": [ "api/**.{yml,md}" ] }, + { "files": [ "articles/**.{md,yml}", "*.md", "toc.yml" ] } + ], + "resource": [ + { "files": [ "images/**" ] } + ], + "output": "_site", + "template": [ "default", "modern", "templates/squidstd" ], + "globalMetadata": { + "_appName": "SquidStd", + "_appTitle": "SquidStd", + "_appLogoPath": "images/logo.png", + "_appFaviconPath": "images/favicon.png", + "_enableSearch": true, + "_gitContribute": { "repo": "https://github.com/tgiachi/SquidStd", "branch": "main" } + } + } +} diff --git a/docs/images/favicon.png b/docs/images/favicon.png new file mode 100644 index 00000000..948c92e7 Binary files /dev/null and b/docs/images/favicon.png differ diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 00000000..1b45462c Binary files /dev/null and b/docs/images/logo.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..5e765d5f --- /dev/null +++ b/docs/index.md @@ -0,0 +1,16 @@ +--- +_layout: landing +--- + +

+ SquidStd +

+ +# SquidStd + +A modular .NET toolkit: foundational service contracts and utilities, a DryIoc-based service stack, +networking, plugins, data access, messaging, and Lua scripting — published as focused NuGet packages. + +- **[Getting started](articles/getting-started.md)** — install and bootstrap the core services. +- **[API reference](api/index.md)** — the full type/member documentation. +- **Packages** — see the per-package guides under [Articles](articles/getting-started.md). diff --git a/docs/templates/squidstd/public/main.css b/docs/templates/squidstd/public/main.css new file mode 100644 index 00000000..f98ddc0c --- /dev/null +++ b/docs/templates/squidstd/public/main.css @@ -0,0 +1,54 @@ +/* SquidStd theme — palette derived from the logo. */ +:root { + --sqd-brand: #1390A3; + --sqd-brand-dark: #0F404C; + --sqd-brand-deep: #0D7786; + --sqd-accent: #4BB4BD; + --sqd-warm: #E3BEBB; + --sqd-ink: #0B1418; + + --bs-primary: var(--sqd-brand); + --bs-primary-rgb: 19, 144, 163; + --bs-link-color: var(--sqd-brand-deep); + --bs-link-hover-color: var(--sqd-brand); +} + +[data-bs-theme="dark"] { + --bs-primary: var(--sqd-accent); + --bs-primary-rgb: 75, 180, 189; + --bs-link-color: var(--sqd-accent); + --bs-link-hover-color: #7fd0d7; + --bs-body-bg: var(--sqd-ink); +} + +/* Navbar branded in brand-dark with light contrast. */ +.navbar { + background-color: var(--sqd-brand-dark) !important; + border-bottom: 2px solid var(--sqd-brand); +} +.navbar .navbar-brand, +.navbar .nav-link, +.navbar .navbar-nav .nav-link.active { + color: #eaf6f8 !important; +} +.navbar .nav-link:hover { color: var(--sqd-accent) !important; } + +/* Buttons / primary accents. */ +.btn-primary { + --bs-btn-bg: var(--sqd-brand); + --bs-btn-border-color: var(--sqd-brand); + --bs-btn-hover-bg: var(--sqd-brand-deep); + --bs-btn-hover-border-color: var(--sqd-brand-deep); + --bs-btn-active-bg: var(--sqd-brand-deep); +} + +/* Active TOC item + focus ring. */ +.toc .nav-link.active { color: var(--sqd-brand) !important; } +:focus-visible { outline-color: var(--sqd-accent); } + +/* Inline code accent + table header tint. */ +:not(pre) > code { color: var(--sqd-brand-deep); } +table > thead { background-color: rgba(75, 180, 189, 0.12); } + +/* Affix (right-hand "In this article") active marker. */ +.affix ul li.active > a { color: var(--sqd-brand); border-left-color: var(--sqd-brand); } diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 00000000..019419e8 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,6 @@ +- name: Home + href: index.md +- name: Articles + href: articles/ +- name: API + href: api/ diff --git a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs index 4479c40d..98326a6f 100644 --- a/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Config/RegisterConfigSectionExtension.cs @@ -42,10 +42,8 @@ public static IContainer RegisterConfigSection( } } - var factory = createDefault ?? (() => new TConfig()); - container.AddToRegisterTypedList( - new ConfigRegistrationData(sectionName, configType, () => factory(), priority) - ); + var factory = createDefault ?? (() => new()); + container.AddToRegisterTypedList(new ConfigRegistrationData(sectionName, configType, () => factory(), priority)); return container; } diff --git a/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs b/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs index e2ffdc7b..10cb1f99 100644 --- a/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs +++ b/src/SquidStd.Abstractions/Extensions/Services/RegisterStdServiceExtension.cs @@ -6,8 +6,7 @@ namespace SquidStd.Abstractions.Extensions.Services; public static class RegisterStdServiceExtension { - - public static IContainer RegisterStdService(this IContainer container, int priority = 0) + public static IContainer RegisterStdService(this IContainer container, int priority = 0) where TService : class where TImplementation : class, TService { @@ -17,5 +16,4 @@ public static IContainer RegisterStdService(this ICont return container; } - } diff --git a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs index 3acc621c..4e9b5066 100644 --- a/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs +++ b/src/SquidStd.Abstractions/Interfaces/Services/ISquidStdService.cs @@ -14,5 +14,4 @@ public interface ISquidStdService /// Token used to cancel the asynchronous operation. /// A task that represents the asynchronous stop operation. ValueTask StopAsync(CancellationToken cancellationToken = default); - } diff --git a/src/SquidStd.Abstractions/README.md b/src/SquidStd.Abstractions/README.md new file mode 100644 index 00000000..000247c5 --- /dev/null +++ b/src/SquidStd.Abstractions/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Abstractions

+ +

+ NuGet + Downloads + license +

+ +DryIoc-based dependency-injection plumbing for SquidStd. It defines the `ISquidStdService` lifecycle +contract and the container extensions used to register services and configuration sections in a uniform, +discoverable way (tracked through ordered registration lists). + +## Install + +```bash +dotnet add package SquidStd.Abstractions +``` + +## Features + +- `ISquidStdService` — a `StartAsync`/`StopAsync` lifecycle contract for managed services. +- `RegisterStdService()` — register a singleton service and record it in the + ordered service list (with optional priority). +- `RegisterConfigSection(sectionName)` — register a YAML config section for the config manager. +- `AddToRegisterTypedList(...)` — maintain ordered registration lists in the container. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; + +var container = new Container(); + +container.RegisterStdService(); +container.RegisterConfigSection("my"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ISquidStdService` | Async start/stop lifecycle for managed services. | +| `RegisterStdServiceExtension` | `RegisterStdService<,>` container extension. | +| `RegisterConfigSectionExtension` | `RegisterConfigSection<>` container extension. | +| `ServiceRegistrationData` | Ordered service registration record. | +| `ConfigRegistrationData` | Config section registration record. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj index 0b7093d9..d214510d 100644 --- a/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj +++ b/src/SquidStd.Abstractions/SquidStd.Abstractions.csproj @@ -1,13 +1,14 @@  + true net10.0 enable enable - + diff --git a/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs new file mode 100644 index 00000000..8d995a7c --- /dev/null +++ b/src/SquidStd.AspNetCore/Extensions/SquidStdAspNetCoreBuilderExtensions.cs @@ -0,0 +1,71 @@ +using DryIoc; +using DryIoc.Microsoft.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; + +namespace SquidStd.AspNetCore.Extensions; + +/// +/// Extension methods for connecting SquidStd to ASP.NET Core Minimal API applications. +/// +public static class SquidStdAspNetCoreBuilderExtensions +{ + /// ASP.NET Core application builder. + extension(WebApplicationBuilder builder) + { + /// + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// + /// Optional SquidStd bootstrap options callback. + /// The same builder for chaining. + public WebApplicationBuilder UseSquidStd(Action? configureOptions = null) + => builder.UseSquidStd(configureOptions, null); + + /// + /// Registers SquidStd using DryIoc as the ASP.NET Core service provider. + /// + /// Optional SquidStd bootstrap options callback. + /// Optional DryIoc registration callback. + /// The same builder for chaining. + public WebApplicationBuilder UseSquidStd( + Action? configureOptions, + Func? configureContainer + ) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new SquidStdOptions + { + RootDirectory = builder.Environment.ContentRootPath + }; + configureOptions?.Invoke(options); + ValidateOptions(options); + + var container = new Container(); + builder.Host.UseServiceProviderFactory(new DryIocServiceProviderFactory(container)); + SquidStdBootstrap.Create(options, container); + + var configuredContainer = configureContainer?.Invoke(container) ?? container; + + if (!ReferenceEquals(configuredContainer, container)) + { + throw new InvalidOperationException("ConfigureSquidStdContainer must return the DryIoc container instance."); + } + + builder.Services.AddHostedService(); + + return builder; + } + } + + private static void ValidateOptions(SquidStdOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); + } +} diff --git a/src/SquidStd.AspNetCore/README.md b/src/SquidStd.AspNetCore/README.md new file mode 100644 index 00000000..ff627d45 --- /dev/null +++ b/src/SquidStd.AspNetCore/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.AspNetCore

+ +

+ NuGet + Downloads + license +

+ +ASP.NET Core integration for SquidStd. A single `builder.UseSquidStd(...)` call wires the SquidStd +DryIoc container into the web host and registers a hosted service that starts and stops every +`ISquidStdService` alongside the application lifecycle. + +## Install + +```bash +dotnet add package SquidStd.AspNetCore +``` + +## Features + +- `WebApplicationBuilder.UseSquidStd(...)` — plug the SquidStd container and services into a web app. +- Configures DryIoc as the host's service-provider factory. +- Registers `SquidStdHostedService` to start/stop SquidStd services with the host. +- Optional `SquidStdOptions` configuration callback. + +## Usage + +```csharp +using SquidStd.AspNetCore.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.UseSquidStd(options => +{ + // configure SquidStd options here +}); + +var app = builder.Build(); +app.Run(); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `SquidStdAspNetCoreBuilderExtensions` | `UseSquidStd(...)` builder extension. | +| `SquidStdHostedService` | Hosted service bridging SquidStd service lifecycle to the host. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs new file mode 100644 index 00000000..158e7d04 --- /dev/null +++ b/src/SquidStd.AspNetCore/Services/SquidStdHostedService.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Hosting; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.AspNetCore.Services; + +/// +/// Bridges the ASP.NET Core host lifecycle to the SquidStd bootstrap lifecycle. +/// +internal sealed class SquidStdHostedService : IHostedService +{ + private readonly ISquidStdBootstrap _bootstrap; + + /// + /// Initializes the hosted service. + /// + /// SquidStd bootstrap instance started with the ASP.NET host. + public SquidStdHostedService(ISquidStdBootstrap bootstrap) + { + _bootstrap = bootstrap; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await _bootstrap.StartAsync(cancellationToken); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + await _bootstrap.StopAsync(cancellationToken); + } +} diff --git a/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj new file mode 100644 index 00000000..994d0dbb --- /dev/null +++ b/src/SquidStd.AspNetCore/SquidStd.AspNetCore.csproj @@ -0,0 +1,26 @@ + + + + true + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs new file mode 100644 index 00000000..33ab8f4f --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheConnectionString.cs @@ -0,0 +1,87 @@ +using System.Collections.Frozen; +using System.Web; + +namespace SquidStd.Caching.Abstractions.Data.Config; + +/// +/// Parsed cache connection string of the form scheme://[user:pass@]host[:port][?params]. +/// +public sealed class CacheConnectionString +{ + private CacheConnectionString( + string scheme, + string host, + int? port, + string? userName, + string? password, + IReadOnlyDictionary parameters + ) + { + Scheme = scheme; + Host = host; + Port = port; + UserName = userName; + Password = password; + Parameters = parameters; + } + + /// URI scheme, e.g. "memory" or "redis". + public string Scheme { get; } + + /// Host component. + public string Host { get; } + + /// Port, when specified. + public int? Port { get; } + + /// User name from the user-info component, when present. + public string? UserName { get; } + + /// Password from the user-info component, when present. + public string? Password { get; } + + /// Query-string parameters. + public IReadOnlyDictionary Parameters { get; } + + /// Parses a cache connection string. + public static CacheConnectionString Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var uri = new Uri(connectionString, UriKind.Absolute); + + string? userName = null; + string? password = null; + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + userName = Uri.UnescapeDataString(parts[0]); + password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : null; + } + + var query = HttpUtility.ParseQueryString(uri.Query); + var parameters = query.AllKeys + .Where(static key => key is not null) + .ToFrozenDictionary(key => key!, key => query[key] ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return new( + uri.Scheme, + uri.Host, + uri.Port > 0 ? uri.Port : null, + userName, + password, + parameters + ); + } + + /// Builds from the query parameters. + public CacheOptions ToCacheOptions() + => new() + { + DefaultTtl = Parameters.TryGetValue("defaultTtlSeconds", out var ttl) && int.TryParse(ttl, out var seconds) + ? TimeSpan.FromSeconds(seconds) + : null, + KeyPrefix = Parameters.TryGetValue("keyPrefix", out var prefix) ? prefix : string.Empty + }; +} diff --git a/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs new file mode 100644 index 00000000..68fda982 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Data/Config/CacheOptions.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Caching.Abstractions.Data.Config; + +/// +/// Options shared by all cache providers. +/// +public sealed class CacheOptions +{ + /// Default time-to-live applied when a set call passes no TTL. null means no expiry. + public TimeSpan? DefaultTtl { get; set; } + + /// Prefix prepended to every key. Default empty. + public string KeyPrefix { get; set; } = string.Empty; +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs new file mode 100644 index 00000000..cf7be0af --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheMetrics.cs @@ -0,0 +1,19 @@ +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Sink for cache instrumentation events. +/// +public interface ICacheMetrics +{ + /// Records a cache hit. + void OnHit(string key); + + /// Records a cache miss. + void OnMiss(string key); + + /// Records a value being stored. + void OnSet(string key); + + /// Records a key being removed. + void OnRemove(string key); +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs new file mode 100644 index 00000000..7b27ddab --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheProvider.cs @@ -0,0 +1,21 @@ +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Byte-level cache backend. Implemented by each provider (in-memory, Redis, ...). +/// +public interface ICacheProvider : ISquidStdService +{ + /// Gets the raw value for a key, or null when absent. + Task?> GetAsync(string key, CancellationToken cancellationToken = default); + + /// Stores a raw value with an optional time-to-live. + Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default); + + /// Removes a key; returns whether it existed. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// Returns whether a key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs new file mode 100644 index 00000000..8d8dec96 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Interfaces/ICacheService.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Caching.Abstractions.Interfaces; + +/// +/// Typed cache-aside facade over an . +/// +public interface ICacheService +{ + /// Gets a typed value, or default when absent. + Task GetAsync(string key, CancellationToken cancellationToken = default); + + /// Stores a typed value with an optional time-to-live (falls back to the default TTL). + Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default); + + /// Removes a key; returns whether it existed. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); + + /// Returns whether a key exists. + Task ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// Returns the cached value, or computes, stores and returns it on a miss. + Task GetOrSetAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/SquidStd.Caching.Abstractions/README.md b/src/SquidStd.Caching.Abstractions/README.md new file mode 100644 index 00000000..5adae69a --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.Caching.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Backend-agnostic caching contracts for SquidStd. It defines the typed cache-aside facade +(`ICacheService`), the low-level byte provider/metrics contracts, and the shared `CacheService` +facade that applies key-prefixing, default TTL and cache-aside once over any provider. Pick a +backend implementation (in-memory or Redis) from a companion package. + +## Install + +```bash +dotnet add package SquidStd.Caching.Abstractions +``` + +## Features + +- `ICacheService` — typed `GetAsync` / `SetAsync` / `RemoveAsync` / `ExistsAsync` / `GetOrSetAsync` facade. +- `ICacheProvider` — the raw byte-level backend contract implemented per provider. +- `CacheService` — shared facade that serializes values, applies the key prefix and default TTL, and implements cache-aside. +- `ICacheMetrics` (+ `CacheMetricsProvider`, `NoOpCacheMetrics`) — hit/miss/set/remove metrics. +- `CacheOptions` and `CacheConnectionString` — configuration and connection parsing. + +## Usage + +```csharp +using SquidStd.Caching.Abstractions.Interfaces; + +// Resolve ICacheService from a backend package (in-memory or Redis). +public Task GetOrComputeAsync(ICacheService cache) + => cache.GetOrSetAsync("answer", _ => Task.FromResult(42), TimeSpan.FromMinutes(5)); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ICacheService` | Typed cache-aside facade. | +| `ICacheProvider` | Byte-level backend contract (per provider). | +| `CacheService` | Shared facade: serialization, key prefix, default TTL, cache-aside. | +| `ICacheMetrics` | Hit/miss/set/remove metrics sink. | +| `CacheOptions` | Default TTL and key prefix. | +| `CacheConnectionString` | `scheme://host[?params]` parsing into `CacheOptions`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs new file mode 100644 index 00000000..36722cb3 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/CacheMetricsProvider.cs @@ -0,0 +1,56 @@ +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types.Metrics; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Accumulates aggregate cache metrics and exposes them to the metrics collection system. +/// +public sealed class CacheMetricsProvider : ICacheMetrics, IMetricProvider +{ + private long _hits; + private long _misses; + private long _sets; + private long _removes; + + /// + public string ProviderName => "cache"; + + /// + public void OnHit(string key) + => Interlocked.Increment(ref _hits); + + /// + public void OnMiss(string key) + => Interlocked.Increment(ref _misses); + + /// + public void OnSet(string key) + => Interlocked.Increment(ref _sets); + + /// + public void OnRemove(string key) + => Interlocked.Increment(ref _removes); + + /// + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + var hits = Interlocked.Read(ref _hits); + var misses = Interlocked.Read(ref _misses); + var total = hits + misses; + var hitRatio = total == 0 ? 0d : (double)hits / total; + + var samples = new List + { + new("hits", hits, Type: MetricType.Counter), + new("misses", misses, Type: MetricType.Counter), + new("sets", Interlocked.Read(ref _sets), Type: MetricType.Counter), + new("removes", Interlocked.Read(ref _removes), Type: MetricType.Counter), + new("hit_ratio", hitRatio) + }; + + return ValueTask.FromResult>(samples); + } +} diff --git a/src/SquidStd.Caching.Abstractions/Services/CacheService.cs b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs new file mode 100644 index 00000000..be189fa6 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/CacheService.cs @@ -0,0 +1,108 @@ +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Typed cache-aside facade: serializes values, applies the key prefix and default TTL, and +/// implements once over any . +/// +public sealed class CacheService : ICacheService +{ + private readonly ICacheProvider _provider; + private readonly IDataSerializer _serializer; + private readonly IDataDeserializer _deserializer; + private readonly ICacheMetrics _metrics; + private readonly TimeSpan? _defaultTtl; + private readonly string _keyPrefix; + + public CacheService( + ICacheProvider provider, + IDataSerializer serializer, + IDataDeserializer deserializer, + CacheOptions options, + ICacheMetrics? metrics = null + ) + { + ArgumentNullException.ThrowIfNull(options); + + _provider = provider; + _serializer = serializer; + _deserializer = deserializer; + _metrics = metrics ?? NoOpCacheMetrics.Instance; + _defaultTtl = options.DefaultTtl; + _keyPrefix = options.KeyPrefix; + } + + /// + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + var bytes = await _provider.GetAsync(Prefixed(key), cancellationToken); + + if (!bytes.HasValue) + { + _metrics.OnMiss(key); + + return default; + } + + _metrics.OnHit(key); + + return _deserializer.Deserialize(bytes.Value); + } + + /// + public async Task SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + { + var bytes = _serializer.Serialize(value); + await _provider.SetAsync(Prefixed(key), bytes, ttl ?? _defaultTtl, cancellationToken); + _metrics.OnSet(key); + } + + /// + public async Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var removed = await _provider.RemoveAsync(Prefixed(key), cancellationToken); + + if (removed) + { + _metrics.OnRemove(key); + } + + return removed; + } + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => _provider.ExistsAsync(Prefixed(key), cancellationToken); + + /// + public async Task GetOrSetAsync( + string key, + Func> factory, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(factory); + + var bytes = await _provider.GetAsync(Prefixed(key), cancellationToken); + + if (bytes.HasValue) + { + _metrics.OnHit(key); + + return _deserializer.Deserialize(bytes.Value); + } + + _metrics.OnMiss(key); + var value = await factory(cancellationToken); + await SetAsync(key, value, ttl, cancellationToken); + + return value; + } + + private string Prefixed(string key) + => _keyPrefix.Length == 0 ? key : _keyPrefix + key; +} diff --git a/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs new file mode 100644 index 00000000..c52877a1 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/Services/NoOpCacheMetrics.cs @@ -0,0 +1,28 @@ +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Caching.Abstractions.Services; + +/// +/// Metrics sink that ignores all events. Used when no metrics are configured. +/// +public sealed class NoOpCacheMetrics : ICacheMetrics +{ + /// Shared instance. + public static NoOpCacheMetrics Instance { get; } = new(); + + public void OnHit(string key) + { + } + + public void OnMiss(string key) + { + } + + public void OnSet(string key) + { + } + + public void OnRemove(string key) + { + } +} diff --git a/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj new file mode 100644 index 00000000..f73ae1d5 --- /dev/null +++ b/src/SquidStd.Caching.Abstractions/SquidStd.Caching.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs new file mode 100644 index 00000000..30dd72d4 --- /dev/null +++ b/src/SquidStd.Caching.Redis/Data/Config/RedisCacheOptions.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Caching.Redis.Data.Config; + +/// +/// Connection options for the Redis cache provider. +/// +public sealed class RedisCacheOptions +{ + /// StackExchange.Redis configuration string. Default "localhost:6379". + public string Configuration { get; init; } = "localhost:6379"; +} diff --git a/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs new file mode 100644 index 00000000..24eda31a --- /dev/null +++ b/src/SquidStd.Caching.Redis/Extensions/RedisCacheRegistrationExtensions.cs @@ -0,0 +1,66 @@ +using DryIoc; +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Caching.Redis.Data.Config; +using SquidStd.Caching.Redis.Services; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; + +namespace SquidStd.Caching.Redis.Extensions; + +/// +/// DryIoc registration helpers for the Redis cache provider. +/// +public static class RedisCacheRegistrationExtensions +{ + /// Registers the Redis cache from explicit options. + public static IContainer AddRedisCache( + this IContainer container, + RedisCacheOptions options, + CacheOptions? cacheOptions = null + ) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + container.RegisterInstance(cacheOptions ?? new CacheOptions()); + container.RegisterInstance(options); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + var metrics = new CacheMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers the Redis cache from a connection string (scheme must be "redis"). + public static IContainer AddRedisCache(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = CacheConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "redis", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'redis://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + var host = string.IsNullOrEmpty(cs.Host) ? "localhost" : cs.Host; + var port = cs.Port ?? 6379; + var options = new RedisCacheOptions { Configuration = $"{host}:{port}" }; + + return container.AddRedisCache(options, cs.ToCacheOptions()); + } +} diff --git a/src/SquidStd.Caching.Redis/README.md b/src/SquidStd.Caching.Redis/README.md new file mode 100644 index 00000000..7e100ece --- /dev/null +++ b/src/SquidStd.Caching.Redis/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Caching.Redis

+ +

+ NuGet + Downloads + license +

+ +Redis backend for SquidStd.Caching. Implements `ICacheProvider` on top of StackExchange.Redis, +so the same `ICacheService` API reads and writes a real Redis server with native key expiry. +Registered with a single `AddRedisCache(...)` call. + +## Install + +```bash +dotnet add package SquidStd.Caching.Redis +``` + +## Features + +- One-line registration: `container.AddRedisCache(connectionString)` or with `RedisCacheOptions`. +- Redis-backed `ICacheProvider` reusing the shared `ICacheService` facade and serializer. +- Native TTL via Redis key expiry (`SET ... EX`). +- Connection via a `redis://` connection string or an explicit StackExchange.Redis configuration string. +- Built-in hit/miss metrics via `CacheMetricsProvider`. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Redis.Extensions; + +var container = new Container(); +container.AddRedisCache("redis://localhost:6379?defaultTtlSeconds=300&keyPrefix=app:"); + +var cache = container.Resolve(); +await cache.SetAsync("user:1", new { Name = "squid" }, TimeSpan.FromMinutes(10)); +var user = await cache.GetAsync("user:1"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RedisCacheRegistrationExtensions` | `AddRedisCache(...)` registration. | +| `RedisCacheProvider` | StackExchange.Redis-backed `ICacheProvider`. | +| `RedisCacheOptions` | StackExchange.Redis connection configuration. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs new file mode 100644 index 00000000..2da7d8a9 --- /dev/null +++ b/src/SquidStd.Caching.Redis/Services/RedisCacheProvider.cs @@ -0,0 +1,76 @@ +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Redis.Data.Config; +using StackExchange.Redis; + +namespace SquidStd.Caching.Redis.Services; + +/// +/// Redis backed by a StackExchange.Redis connection multiplexer. +/// +public sealed class RedisCacheProvider : ICacheProvider, IAsyncDisposable +{ + private readonly RedisCacheOptions _options; + private IConnectionMultiplexer? _connection; + private int _disposed; + + public RedisCacheProvider(RedisCacheOptions options) + { + _options = options; + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _connection = await ConnectionMultiplexer.ConnectAsync(_options.Configuration); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + var value = await Database.StringGetAsync(key); + + if (value.IsNull) + { + return null; + } + + return new ReadOnlyMemory((byte[])value!); + } + + /// + public async Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + var expiry = ttl is null ? Expiration.Default : new Expiration(ttl.Value); + await Database.StringSetAsync(key, value.ToArray(), expiry); + } + + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Database.KeyDeleteAsync(key); + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Database.KeyExistsAsync(key); + + private IDatabase Database + => (_connection ?? throw new InvalidOperationException("Provider not started.")).GetDatabase(); + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + _connection.Dispose(); + } + } +} diff --git a/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj new file mode 100644 index 00000000..4ea81181 --- /dev/null +++ b/src/SquidStd.Caching.Redis/SquidStd.Caching.Redis.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs new file mode 100644 index 00000000..06875df5 --- /dev/null +++ b/src/SquidStd.Caching/Extensions/CacheRegistrationExtensions.cs @@ -0,0 +1,58 @@ +using DryIoc; +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Caching.Services; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; + +namespace SquidStd.Caching.Extensions; + +/// +/// DryIoc registration helpers for the in-memory cache. +/// +public static class CacheRegistrationExtensions +{ + /// Registers the in-memory cache (provider, facade, metrics, serializer). + public static IContainer AddInMemoryCache(this IContainer container, CacheOptions? options = null) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(options ?? new CacheOptions()); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + container.RegisterInstance(new MemoryCache(new MemoryCacheOptions()), IfAlreadyRegistered.Keep); + + var metrics = new CacheMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers the in-memory cache from a connection string (scheme must be "memory"). + public static IContainer AddInMemoryCache(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = CacheConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "memory", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'memory://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + return container.AddInMemoryCache(cs.ToCacheOptions()); + } +} diff --git a/src/SquidStd.Caching/README.md b/src/SquidStd.Caching/README.md new file mode 100644 index 00000000..cfaea647 --- /dev/null +++ b/src/SquidStd.Caching/README.md @@ -0,0 +1,55 @@ +

+ SquidStd +

+ +

SquidStd.Caching

+ +

+ NuGet + Downloads + license +

+ +In-memory backend for SquidStd.Caching. Provides an `IMemoryCache`-backed `ICacheProvider` with +absolute TTL and eviction, wired to the shared typed `ICacheService` facade — registered with a +single `AddInMemoryCache()` call. Ideal for single-process apps, tests, and local dev. + +## Install + +```bash +dotnet add package SquidStd.Caching +``` + +## Features + +- One-line registration: `container.AddInMemoryCache()` (provider, facade, serializer, metrics). +- `IMemoryCache`-backed storage with absolute per-entry TTL and built-in eviction. +- Reuses the shared `CacheService` facade (key prefix, default TTL, cache-aside). +- Built-in hit/miss metrics via `CacheMetricsProvider`. +- Configure via `CacheOptions` or a `memory://` connection string (`?defaultTtlSeconds=`, `?keyPrefix=`). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; + +var container = new Container(); +container.AddInMemoryCache("memory://localhost?defaultTtlSeconds=300&keyPrefix=app:"); + +var cache = container.Resolve(); +await cache.SetAsync("user:1", new { Name = "squid" }); +var user = await cache.GetAsync("user:1"); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `CacheRegistrationExtensions` | `AddInMemoryCache(...)` registration. | +| `InMemoryCacheProvider` | `IMemoryCache`-backed `ICacheProvider`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs new file mode 100644 index 00000000..1d8934e8 --- /dev/null +++ b/src/SquidStd.Caching/Services/InMemoryCacheProvider.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Caching.Services; + +/// +/// In-memory backed by . +/// +public sealed class InMemoryCacheProvider : ICacheProvider +{ + private readonly IMemoryCache _cache; + + public InMemoryCacheProvider(IMemoryCache cache) + { + _cache = cache; + } + + /// + public Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(key, out byte[]? value) && value is not null) + { + return Task.FromResult?>(new ReadOnlyMemory(value)); + } + + return Task.FromResult?>(null); + } + + /// + public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + var options = new MemoryCacheEntryOptions(); + + if (ttl is not null) + { + options.AbsoluteExpirationRelativeToNow = ttl; + } + + _cache.Set(key, value.ToArray(), options); + + return Task.CompletedTask; + } + + /// + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + var existed = _cache.TryGetValue(key, out _); + _cache.Remove(key); + + return Task.FromResult(existed); + } + + /// + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_cache.TryGetValue(key, out _)); + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; +} diff --git a/src/SquidStd.Caching/SquidStd.Caching.csproj b/src/SquidStd.Caching/SquidStd.Caching.csproj new file mode 100644 index 00000000..2e317d0f --- /dev/null +++ b/src/SquidStd.Caching/SquidStd.Caching.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs new file mode 100644 index 00000000..e361a730 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdLoggerOptions.cs @@ -0,0 +1,39 @@ +using SquidStd.Core.Types; + +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Defines YAML-backed logger options for SquidStd bootstrap. +/// +public sealed class SquidStdLoggerOptions +{ + /// + /// Gets or sets the minimum logger level. + /// + public LogLevelType MinimumLevel { get; set; } = LogLevelType.Information; + + /// + /// Gets or sets whether console logging is enabled. + /// + public bool EnableConsole { get; set; } = true; + + /// + /// Gets or sets whether file logging is enabled. + /// + public bool EnableFile { get; set; } + + /// + /// Gets or sets the file log directory. Relative paths are resolved from the SquidStd root directory. + /// + public string LogDirectory { get; set; } = "logs"; + + /// + /// Gets or sets the file log name or rolling file pattern. + /// + public string FileName { get; set; } = "squidstd-.log"; + + /// + /// Gets or sets the file log rolling interval. + /// + public SquidStdLogRollingIntervalType RollingInterval { get; set; } = SquidStdLogRollingIntervalType.Day; +} diff --git a/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs new file mode 100644 index 00000000..614508d6 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/SquidStdOptions.cs @@ -0,0 +1,17 @@ +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Defines bootstrap-only options used to locate SquidStd runtime resources. +/// +public sealed class SquidStdOptions +{ + /// + /// Gets or sets the root directory for configuration, logs, and runtime data. + /// + public string RootDirectory { get; set; } = Directory.GetCurrentDirectory(); + + /// + /// Gets or sets the logical configuration name or YAML file name. + /// + public string ConfigName { get; set; } = "squidstd"; +} diff --git a/src/SquidStd.Core/Data/Metrics/MetricSample.cs b/src/SquidStd.Core/Data/Metrics/MetricSample.cs new file mode 100644 index 00000000..b4612060 --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricSample.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Types.Metrics; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Represents one collected metric value. +/// +/// Metric name emitted by the provider. +/// Metric numeric value. +/// Optional timestamp for the metric value. +/// Optional dimensions associated with the value. +/// Metric semantic type. +/// Optional human-readable metric description. +public sealed record MetricSample( + string Name, + double Value, + DateTimeOffset? Timestamp = null, + IReadOnlyDictionary? Tags = null, + MetricType Type = MetricType.Gauge, + string? Help = null +); diff --git a/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs new file mode 100644 index 00000000..854a3f2b --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsCollectedEvent.cs @@ -0,0 +1,9 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Event published after a metrics snapshot has been collected. +/// +/// The collected metrics snapshot. +public sealed record MetricsCollectedEvent(MetricsSnapshot Snapshot) : IEvent; diff --git a/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs new file mode 100644 index 00000000..5add62b2 --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsConfig.cs @@ -0,0 +1,37 @@ +using SquidStd.Core.Types; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Metrics; + +/// +/// Configuration for periodic metrics collection. +/// +public sealed class MetricsConfig : IConfigEntry +{ + /// + /// Gets or sets whether metrics collection is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the collection interval in milliseconds. + /// + public int IntervalMilliseconds { get; set; } = 1000; + + /// + /// Gets or sets whether each provider collection is logged. + /// + public bool LogEnabled { get; set; } = true; + + /// + /// Gets or sets the log level used for metrics collection logs. + /// + public LogLevelType LogLevel { get; set; } = LogLevelType.Trace; + + string IConfigEntry.SectionName => "metrics"; + + Type IConfigEntry.ConfigType => typeof(MetricsConfig); + + object IConfigEntry.CreateDefault() + => new MetricsConfig(); +} diff --git a/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs new file mode 100644 index 00000000..f0343dba --- /dev/null +++ b/src/SquidStd.Core/Data/Metrics/MetricsSnapshot.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Core.Data.Metrics; + +/// +/// Stores the last collected metrics batch. +/// +public sealed class MetricsSnapshot +{ + /// + /// Initializes a metrics snapshot. + /// + /// Timestamp when the snapshot was collected. + /// Metrics keyed by their flattened metric name. + public MetricsSnapshot(DateTimeOffset collectedAt, IReadOnlyDictionary metrics) + { + CollectedAt = collectedAt; + Metrics = metrics; + } + + /// + /// Gets the timestamp when the snapshot was collected. + /// + public DateTimeOffset CollectedAt { get; } + + /// + /// Gets metrics keyed by their flattened metric name. + /// + public IReadOnlyDictionary Metrics { get; } +} diff --git a/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs new file mode 100644 index 00000000..860db3fd --- /dev/null +++ b/src/SquidStd.Core/Data/Scheduling/CronJobInfo.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Core.Data.Scheduling; + +/// +/// Immutable snapshot describing a registered cron job. +/// +public sealed class CronJobInfo +{ + /// Gets the unique job id. + public required string JobId { get; init; } + + /// Gets the logical job name. + public required string Name { get; init; } + + /// Gets the raw cron expression. + public required string CronExpression { get; init; } + + /// Gets the next planned occurrence in UTC, or null when none. + public DateTime? NextOccurrenceUtc { get; init; } + + /// Gets a value indicating whether a run is currently in progress. + public bool IsRunning { get; init; } + + /// Gets the UTC time of the last successful run, or null. + public DateTime? LastRunUtc { get; init; } + + /// Gets the number of successful runs so far. + public long RunCount { get; init; } +} diff --git a/src/SquidStd.Core/Data/Storage/SecretsConfig.cs b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs new file mode 100644 index 00000000..ee471b1a --- /dev/null +++ b/src/SquidStd.Core/Data/Storage/SecretsConfig.cs @@ -0,0 +1,26 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Storage; + +/// +/// Configuration for encrypted local secret storage. +/// +public sealed class SecretsConfig : IConfigEntry +{ + /// + /// Gets or sets the root directory used by local secret storage. + /// + public string RootDirectory { get; set; } = "secrets"; + + /// + /// Gets or sets the environment variable that contains the base64 AES key. + /// + public string KeyEnvironmentVariable { get; set; } = "SQUIDSTD_SECRETS_KEY"; + + string IConfigEntry.SectionName => "secrets"; + + Type IConfigEntry.ConfigType => typeof(SecretsConfig); + + object IConfigEntry.CreateDefault() + => new SecretsConfig(); +} diff --git a/src/SquidStd.Core/Data/Storage/StorageConfig.cs b/src/SquidStd.Core/Data/Storage/StorageConfig.cs new file mode 100644 index 00000000..c866ab3f --- /dev/null +++ b/src/SquidStd.Core/Data/Storage/StorageConfig.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Core.Data.Storage; + +/// +/// Configuration for local file storage. +/// +public sealed class StorageConfig : IConfigEntry +{ + /// + /// Gets or sets the root directory used by local storage. + /// + public string RootDirectory { get; set; } = "storage"; + + string IConfigEntry.SectionName => "storage"; + + Type IConfigEntry.ConfigType => typeof(StorageConfig); + + object IConfigEntry.CreateDefault() + => new StorageConfig(); +} diff --git a/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs new file mode 100644 index 00000000..c106b8f8 --- /dev/null +++ b/src/SquidStd.Core/Data/Timing/TimerWheelPumpConfig.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Core.Data.Timing; + +/// +/// Configuration for the timer wheel pump service. +/// +public sealed class TimerWheelPumpConfig +{ + /// + /// Gets or sets how often the pump advances the timer wheel. + /// + public TimeSpan PumpInterval { get; set; } = TimeSpan.FromMilliseconds(250); +} diff --git a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs index 354fb6e5..de4f34fe 100644 --- a/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs +++ b/src/SquidStd.Core/Extensions/Env/EnvExtensions.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Text.RegularExpressions; namespace SquidStd.Core.Extensions.Env; @@ -28,4 +29,32 @@ public static string ExpandEnvironmentVariables(this string input) return input; } + + /// + /// Replaces "$VAR" tokens with the matching environment variable value. Unknown variables are + /// left unchanged. + /// + /// The input string. + /// The string with known $VAR tokens substituted. + public static string ReplaceEnv(this string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return EnvTokenRegex.Replace( + input, + match => + { + var name = match.Groups[1].Value; + var value = Environment.GetEnvironmentVariable(name); + + return value ?? match.Value; + }); + } + + private static readonly Regex EnvTokenRegex = new( + @"\$([A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Compiled); } diff --git a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs index d0242047..4f79d5e5 100644 --- a/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs +++ b/src/SquidStd.Core/Extensions/Logger/LogLevelExtensions.cs @@ -21,6 +21,7 @@ public static LogEventLevel ToSerilogLogLevel(this LogLevelType logLevel) LogLevelType.Information => LogEventLevel.Information, LogLevelType.Warning => LogEventLevel.Warning, LogLevelType.Error => LogEventLevel.Error, + LogLevelType.Critical => LogEventLevel.Fatal, _ => LogEventLevel.Information }; } diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs new file mode 100644 index 00000000..86e74330 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -0,0 +1,62 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; + +namespace SquidStd.Core.Interfaces.Bootstrap; + +/// +/// Coordinates SquidStd dependency registration, configuration loading, and service lifecycle. +/// +public interface ISquidStdBootstrap : IAsyncDisposable +{ + /// + /// Gets the bootstrap options used to configure runtime resources. + /// + SquidStdOptions Options { get; } + + /// + /// Gets the owned dependency injection container. + /// + IContainer Container { get; } + + /// + /// Applies custom service registrations before the bootstrap lifecycle starts. + /// + /// Callback that receives and returns the container. + /// The same bootstrap instance for chaining. + ISquidStdBootstrap ConfigureService(Func configure); + + /// + /// Applies custom service registrations before the bootstrap lifecycle starts. + /// + /// Callback that receives and returns the container. + /// The same bootstrap instance for chaining. + ISquidStdBootstrap ConfigureServices(Func configure); + + /// + /// Resolves a service from the owned dependency injection container. + /// + /// The service type to resolve. + /// The resolved service instance. + TService Resolve(); + + /// + /// Starts registered lifecycle services in priority order. + /// + /// Token used to cancel the start operation. + /// A task that represents the asynchronous start operation. + ValueTask StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops started lifecycle services in reverse priority order. + /// + /// Token used to cancel the stop operation. + /// A task that represents the asynchronous stop operation. + ValueTask StopAsync(CancellationToken cancellationToken = default); + + /// + /// Starts services, waits until cancellation, and then stops services. + /// + /// Token that controls the run lifetime. + /// A task that completes after services have stopped. + Task RunAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs index 5acda04c..0ae4a387 100644 --- a/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs +++ b/src/SquidStd.Core/Interfaces/Config/IConfigManagerService.cs @@ -25,6 +25,12 @@ public interface IConfigManagerService /// IReadOnlyCollection Entries { get; } + /// + /// Composes the currently loaded sections into YAML. + /// + /// The composed YAML document. + string Compose(); + /// /// Gets a loaded configuration section from DI. /// @@ -32,12 +38,6 @@ public interface IConfigManagerService /// The loaded configuration section. TConfig GetConfig() where TConfig : class; - /// - /// Composes the currently loaded sections into YAML. - /// - /// The composed YAML document. - string Compose(); - /// /// Loads or creates the configured YAML file and registers every section into DI. /// diff --git a/src/SquidStd.Core/Interfaces/Events/IEvent.cs b/src/SquidStd.Core/Interfaces/Events/IEvent.cs index adf26c8b..3b4e0027 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEvent.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEvent.cs @@ -1,6 +1,6 @@ namespace SquidStd.Core.Interfaces.Events; /// -/// Marker contract for events dispatched through the Orion IRCd event bus. +/// Marker contract for events dispatched through the SquidStd event bus. /// public interface IEvent { } diff --git a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs index f07e08fe..87b076e3 100644 --- a/src/SquidStd.Core/Interfaces/Events/IEventBus.cs +++ b/src/SquidStd.Core/Interfaces/Events/IEventBus.cs @@ -5,20 +5,6 @@ namespace SquidStd.Core.Interfaces.Events; /// public interface IEventBus { - /// - /// Registers a synchronous listener for the specified event type. - /// - /// Listener that handles published events. - /// The event type. - void RegisterListener(ISyncEventListener listener) where TEvent : IEvent; - - /// - /// Registers an asynchronous listener for the specified event type. - /// - /// Listener that handles published events asynchronously. - /// The event type. - void RegisterAsyncListener(IAsyncEventListener listener) where TEvent : IEvent; - /// /// Dispatches an event to synchronous listeners and waits until every listener has completed. /// @@ -34,4 +20,18 @@ public interface IEventBus /// The event type. /// A task that completes after all asynchronous listeners finish. Task PublishAsync(TEvent eventData, CancellationToken cancellationToken) where TEvent : IEvent; + + /// + /// Registers an asynchronous listener for the specified event type. + /// + /// Listener that handles published events asynchronously. + /// The event type. + void RegisterAsyncListener(IAsyncEventListener listener) where TEvent : IEvent; + + /// + /// Registers a synchronous listener for the specified event type. + /// + /// Listener that handles published events. + /// The event type. + void RegisterListener(ISyncEventListener listener) where TEvent : IEvent; } diff --git a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs index 063c749e..79c5336e 100644 --- a/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs +++ b/src/SquidStd.Core/Interfaces/Jobs/IJobSystem.cs @@ -31,7 +31,7 @@ public interface IJobSystem : IDisposable /// Work invoked on a worker thread. /// Token used to cancel the job before it starts. /// A task that completes when the job finishes. - Task Schedule(Action work, CancellationToken cancellationToken = default); + Task ScheduleAsync(Action work, CancellationToken cancellationToken = default); /// /// Schedules work on a worker thread and returns the result. @@ -40,5 +40,5 @@ public interface IJobSystem : IDisposable /// Token used to cancel the job before it starts. /// The result type. /// A task that completes with the job result. - Task Schedule(Func work, CancellationToken cancellationToken = default); + Task ScheduleAsync(Func work, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs new file mode 100644 index 00000000..cd13980e --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricProvider.cs @@ -0,0 +1,21 @@ +using SquidStd.Core.Data.Metrics; + +namespace SquidStd.Core.Interfaces.Metrics; + +/// +/// Provides metric samples for one subsystem domain. +/// +public interface IMetricProvider +{ + /// + /// Gets the unique provider name used as metric name prefix. + /// + string ProviderName { get; } + + /// + /// Collects the current metric samples for this provider. + /// + /// Token used to cancel collection. + /// The collected metric samples. + ValueTask> CollectAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs new file mode 100644 index 00000000..ef6c5e20 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Metrics/IMetricsCollectionService.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Data.Metrics; + +namespace SquidStd.Core.Interfaces.Metrics; + +/// +/// Exposes the latest metrics snapshot collected from registered providers. +/// +public interface IMetricsCollectionService +{ + /// + /// Gets all metrics from the latest snapshot. + /// + /// Metrics keyed by their flattened metric name. + IReadOnlyDictionary GetAllMetrics(); + + /// + /// Gets the current metrics status. + /// + /// The current metrics snapshot. + MetricsSnapshot GetStatus(); + + /// + /// Gets the latest collected metrics snapshot. + /// + /// The current metrics snapshot. + MetricsSnapshot GetSnapshot(); +} diff --git a/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs new file mode 100644 index 00000000..3d43bc23 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Scheduling/ICronScheduler.cs @@ -0,0 +1,31 @@ +using SquidStd.Core.Data.Scheduling; + +namespace SquidStd.Core.Interfaces.Scheduling; + +/// +/// Schedules asynchronous jobs on standard 5-field cron expressions (UTC). +/// +public interface ICronScheduler +{ + /// + /// Registers a cron job. + /// + /// Logical job name. + /// Standard 5-field cron expression, evaluated in UTC. + /// Asynchronous work invoked on each occurrence. + /// The unique job id. + string Schedule(string name, string cronExpression, Func handler); + + /// Removes a job by id. + /// The job id returned by . + /// true when a job was removed; otherwise false. + bool Unschedule(string jobId); + + /// Removes all jobs with the given name. + /// The job name. + /// The number of removed jobs. + int UnscheduleByName(string name); + + /// Gets a snapshot of all registered jobs. + IReadOnlyCollection Jobs { get; } +} diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs new file mode 100644 index 00000000..07fc86cb --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretProtector.cs @@ -0,0 +1,21 @@ +namespace SquidStd.Core.Interfaces.Secrets; + +/// +/// Protects and unprotects secret payloads. +/// +public interface ISecretProtector +{ + /// + /// Protects a plaintext payload. + /// + /// Plaintext bytes to protect. + /// Protected payload bytes. + byte[] Protect(byte[] plaintext); + + /// + /// Unprotects a protected payload. + /// + /// Protected payload bytes. + /// Plaintext bytes. + byte[] Unprotect(byte[] protectedData); +} diff --git a/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs new file mode 100644 index 00000000..a22b87a4 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Secrets/ISecretStore.cs @@ -0,0 +1,39 @@ +namespace SquidStd.Core.Interfaces.Secrets; + +/// +/// Stores encrypted secret values by logical name. +/// +public interface ISecretStore +{ + /// + /// Deletes a secret. + /// + /// The secret name. + /// Token used to cancel the operation. + /// true when a secret was deleted; otherwise false. + ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Checks whether a secret exists. + /// + /// The secret name. + /// Token used to cancel the operation. + /// true when the secret exists; otherwise false. + ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Gets a secret value. + /// + /// The secret name. + /// Token used to cancel the operation. + /// The secret value, or null when it does not exist. + ValueTask GetAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Sets a secret value. + /// + /// The secret name. + /// The secret value. + /// Token used to cancel the operation. + ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs new file mode 100644 index 00000000..0c649821 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataDeserializer.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Core.Interfaces.Serialization; + +/// +/// Deserializes bytes to typed values. +/// +public interface IDataDeserializer +{ + /// Deserializes bytes to a value. + T Deserialize(ReadOnlyMemory data); +} diff --git a/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs new file mode 100644 index 00000000..6b4b140c --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Serialization/IDataSerializer.cs @@ -0,0 +1,10 @@ +namespace SquidStd.Core.Interfaces.Serialization; + +/// +/// Serializes typed values to bytes. +/// +public interface IDataSerializer +{ + /// Serializes a value to bytes. + ReadOnlyMemory Serialize(T value); +} diff --git a/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs b/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs new file mode 100644 index 00000000..03647d4a --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Storage/IObjectStorageService.cs @@ -0,0 +1,41 @@ +namespace SquidStd.Core.Interfaces.Storage; + +/// +/// Stores typed objects by logical key. +/// +public interface IObjectStorageService +{ + /// + /// Deletes a stored object. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when a payload was deleted; otherwise false. + ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks whether an object exists. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when the object exists; otherwise false. + ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Loads a stored object. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// The object type. + /// The object, or null when it does not exist. + ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Saves an object. + /// + /// The logical storage key. + /// The object value. + /// Token used to cancel the operation. + /// The object type. + ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs b/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs new file mode 100644 index 00000000..50b0771b --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Storage/IStorageService.cs @@ -0,0 +1,39 @@ +namespace SquidStd.Core.Interfaces.Storage; + +/// +/// Stores binary payloads by logical key. +/// +public interface IStorageService +{ + /// + /// Deletes a stored payload. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when a payload was deleted; otherwise false. + ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks whether a payload exists. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// true when the payload exists; otherwise false. + ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Loads a binary payload. + /// + /// The logical storage key. + /// Token used to cancel the operation. + /// The payload, or null when it does not exist. + ValueTask LoadAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Saves a binary payload atomically. + /// + /// The logical storage key. + /// The payload to store. + /// Token used to cancel the operation. + ValueTask SaveAsync(string key, ReadOnlyMemory data, CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Core/Json/JsonDataSerializer.cs b/src/SquidStd.Core/Json/JsonDataSerializer.cs new file mode 100644 index 00000000..b06c8739 --- /dev/null +++ b/src/SquidStd.Core/Json/JsonDataSerializer.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using SquidStd.Core.Interfaces.Serialization; + +namespace SquidStd.Core.Json; + +/// +/// Default JSON data serializer based on Web defaults +/// (reflection-based, supports arbitrary types). Implements both +/// and . +/// +public sealed class JsonDataSerializer : IDataSerializer, IDataDeserializer +{ + private readonly JsonSerializerOptions _options; + + public JsonDataSerializer() + { + _options = new(JsonSerializerDefaults.Web); + } + + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON serialization may require runtime code generation.")] + public ReadOnlyMemory Serialize(T value) + => JsonSerializer.SerializeToUtf8Bytes(value, _options); + + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed."), + RequiresDynamicCode("JSON deserialization may require runtime code generation.")] + public T Deserialize(ReadOnlyMemory data) + => JsonSerializer.Deserialize(data.Span, _options) ?? + throw new InvalidOperationException($"Deserialization returned null for type {typeof(T).Name}."); +} diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md new file mode 100644 index 00000000..c750c50d --- /dev/null +++ b/src/SquidStd.Core/README.md @@ -0,0 +1,60 @@ +

+ SquidStd +

+ +

SquidStd.Core

+ +

+ NuGet + Downloads + license +

+ +Foundational contracts and utilities for the SquidStd stack. It defines the core service interfaces +(configuration, event bus, jobs, timing, metrics, storage) and ships dependency-free helpers — YAML/JSON +serialization, a Serilog event sink, and string/environment/directory extensions — that the other +SquidStd packages build on. + +## Install + +```bash +dotnet add package SquidStd.Core +``` + +## Features + +- Configuration contracts: `IConfigEntry` (a YAML section) and `IConfigManagerService`. +- In-process messaging: `IEventBus` with `ISyncEventListener` / `IAsyncEventListener` over `IEvent`. +- Background work & timing: `IJobSystem`, `ITimerService`, `IMainThreadDispatcher`. +- Metrics & storage: `IMetricProvider`, `IStorageService`, `IObjectStorageService`, secrets contracts. +- Utilities: `YamlUtils`, `JsonUtils`, a Serilog `EventSink`, and string/env/directory extensions. +- Shared domain enums under `Types` (e.g. `LogLevelType`, `PlatformType`). + +## Usage + +```csharp +using SquidStd.Core.Extensions.Env; +using SquidStd.Core.Yaml; + +// Expand "$VAR" tokens against the environment (unknown vars are left untouched). +var path = "$HOME/squidstd/data".ReplaceEnv(); + +// Serialize / deserialize YAML. +var yaml = YamlUtils.Serialize(new { name = "squid", port = 9000 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IConfigEntry` | A registrable YAML configuration section. | +| `IConfigManagerService` | Loads YAML config and exposes typed sections. | +| `IEventBus` | Publish/subscribe in-process event bus. | +| `IJobSystem` | Background job scheduling/execution. | +| `ITimerService` | Timer-wheel based scheduling. | +| `IMetricProvider` | Source of metric samples for collection. | +| `IStorageService` | File/object storage abstraction. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Core/SquidStd.Core.csproj b/src/SquidStd.Core/SquidStd.Core.csproj index f601c589..3f12457d 100644 --- a/src/SquidStd.Core/SquidStd.Core.csproj +++ b/src/SquidStd.Core/SquidStd.Core.csproj @@ -1,6 +1,7 @@  + true net10.0 enable enable diff --git a/src/SquidStd.Core/Types/Metrics/MetricType.cs b/src/SquidStd.Core/Types/Metrics/MetricType.cs new file mode 100644 index 00000000..f59560ff --- /dev/null +++ b/src/SquidStd.Core/Types/Metrics/MetricType.cs @@ -0,0 +1,22 @@ +namespace SquidStd.Core.Types.Metrics; + +/// +/// Defines the semantic type of a metric sample. +/// +public enum MetricType +{ + /// + /// A value that can go up or down. + /// + Gauge, + + /// + /// A cumulative value that only increases. + /// + Counter, + + /// + /// A value that represents a distribution bucket or aggregate. + /// + Histogram +} diff --git a/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs new file mode 100644 index 00000000..9aa4830c --- /dev/null +++ b/src/SquidStd.Core/Types/SquidStdLogRollingIntervalType.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Core.Types; + +/// +/// Defines file log rolling intervals. +/// +public enum SquidStdLogRollingIntervalType +{ + /// + /// Does not roll log files automatically. + /// + Infinite = 0, + + /// + /// Rolls log files every year. + /// + Year = 1, + + /// + /// Rolls log files every month. + /// + Month = 2, + + /// + /// Rolls log files every day. + /// + Day = 3, + + /// + /// Rolls log files every hour. + /// + Hour = 4, + + /// + /// Rolls log files every minute. + /// + Minute = 5 +} diff --git a/src/SquidStd.Core/Utils/ResourceUtils.cs b/src/SquidStd.Core/Utils/ResourceUtils.cs index e1616400..2559ba47 100644 --- a/src/SquidStd.Core/Utils/ResourceUtils.cs +++ b/src/SquidStd.Core/Utils/ResourceUtils.cs @@ -288,13 +288,6 @@ public static string[] GetEmbeddedResourceNames(Assembly assembly = null, string return resourceNames.Where(name => name.Contains(normalizedPath)).ToArray(); } - /// - /// Gets a stream for an embedded resource - /// - /// The assembly containing the resource - /// The full resource name - /// A stream for the resource - /// Thrown when the resource cannot be found /// /// Gets a stream for an embedded resource, inferring the assembly from . /// @@ -305,6 +298,13 @@ public static string[] GetEmbeddedResourceNames(Assembly assembly = null, string public static Stream GetEmbeddedResourceStream(string resourceName) => GetEmbeddedResourceStream(typeof(TClass).Assembly, resourceName); + /// + /// Gets a stream for an embedded resource. + /// + /// The assembly containing the resource. + /// The full resource name. + /// A stream for the resource. + /// Thrown when the resource cannot be found. public static Stream GetEmbeddedResourceStream(Assembly assembly, string resourceName) { ArgumentNullException.ThrowIfNull(assembly); diff --git a/src/SquidStd.Core/Yaml/YamlUtils.cs b/src/SquidStd.Core/Yaml/YamlUtils.cs index 3d9e8b5a..1e8dd7ca 100644 --- a/src/SquidStd.Core/Yaml/YamlUtils.cs +++ b/src/SquidStd.Core/Yaml/YamlUtils.cs @@ -45,6 +45,21 @@ public static object Deserialize(string yaml, Type type) throw new InvalidDataException($"Deserialization returned null for type {type.Name}"); } + /// + /// Deserializes YAML from a file using reflection-based metadata. + /// + /// The YAML file path. + /// The target type. + /// The deserialized object. + public static T DeserializeFromFile(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + var yaml = File.ReadAllText(GetExistingFilePath(filePath)); + + return Deserialize(yaml); + } + /// /// Deserializes a top-level YAML section to the specified runtime type. /// @@ -68,21 +83,6 @@ public static object Deserialize(string yaml, Type type) return Deserialize(Serializer.Serialize(section), type); } - /// - /// Deserializes YAML from a file using reflection-based metadata. - /// - /// The YAML file path. - /// The target type. - /// The deserialized object. - public static T DeserializeFromFile(string filePath) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - - var yaml = File.ReadAllText(GetExistingFilePath(filePath)); - - return Deserialize(yaml); - } - /// /// Serializes an object to YAML using reflection-based metadata. /// diff --git a/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs new file mode 100644 index 00000000..39fdc203 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/Database/DatabaseConfig.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Database.Abstractions.Data.Database; + +/// +/// Database connection configuration. +/// +public sealed class DatabaseConfig : IConfigEntry +{ + /// + /// Gets or sets the URI-style connection string (e.g. "sqlite://squidstd.db", + /// "postgres://user:pass@host:5432/db"). The scheme selects the provider. + /// + public string ConnectionString { get; set; } = "sqlite://squidstd.db"; + + /// + /// Gets or sets a value indicating whether the schema is auto-synchronized on startup. + /// + public bool AutoMigrate { get; set; } = true; + + string IConfigEntry.SectionName => "database"; + + Type IConfigEntry.ConfigType => typeof(DatabaseConfig); + + object IConfigEntry.CreateDefault() + => new DatabaseConfig(); +} diff --git a/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs new file mode 100644 index 00000000..aee115e9 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/Entities/BaseEntity.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Database.Abstractions.Data.Entities; + +/// +/// Base class for all persisted entities: a Guid identity plus UTC create/update timestamps. +/// +public abstract class BaseEntity +{ + /// Gets or sets the primary key (assigned on insert when empty). + public Guid Id { get; set; } + + /// Gets or sets the UTC creation timestamp (set on insert). + public DateTimeOffset Created { get; set; } + + /// Gets or sets the UTC last-update timestamp (set on insert and update). + public DateTimeOffset Updated { get; set; } +} diff --git a/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs new file mode 100644 index 00000000..30c3f641 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Data/PagedResultData.cs @@ -0,0 +1,48 @@ +namespace SquidStd.Database.Abstractions.Data; + +/// +/// A paginated result set with paging metadata. +/// +/// The item type. +public sealed class PagedResultData +{ + /// Gets the items on the current page. + public required IReadOnlyList Items { get; init; } + + /// Gets the 1-based page number. + public required int Page { get; init; } + + /// Gets the page size. + public required int PageSize { get; init; } + + /// Gets the total number of matching rows. + public required long TotalCount { get; init; } + + /// Gets the total number of pages. + public int TotalPages => PageSize <= 0 ? 0 : (int)((TotalCount + PageSize - 1) / PageSize); + + /// Gets a value indicating whether a next page exists. + public bool HasNext => Page < TotalPages; + + /// Gets a value indicating whether a previous page exists. + public bool HasPrevious => Page > 1 && TotalPages > 0; + + /// + /// Creates a paged result. + /// + /// The current page items. + /// The 1-based page number. + /// The page size. + /// The total matching row count. + /// The paged result. + public static PagedResultData Create(IReadOnlyList items, int page, int pageSize, long totalCount) + { + return new PagedResultData + { + Items = items, + Page = page, + PageSize = pageSize, + TotalCount = totalCount + }; + } +} diff --git a/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs new file mode 100644 index 00000000..587633e4 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Interfaces/Data/IDataAccess.cs @@ -0,0 +1,59 @@ +using System.Linq.Expressions; +using FreeSql; +using SquidStd.Database.Abstractions.Data; +using SquidStd.Database.Abstractions.Data.Entities; + +namespace SquidStd.Database.Abstractions.Interfaces.Data; + +/// +/// Generic data access for a type: CRUD, bulk, and querying. +/// +/// The entity type. +public interface IDataAccess + where TEntity : BaseEntity +{ + /// Inserts a single entity (assigns Id/Created/Updated) inside a transaction. + Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Gets an entity by its identifier, or null if not found. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// Updates an entity (bumps Updated) inside a transaction. + Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Deletes an entity by id. Returns true if a row was removed. + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// Deletes the given entity. Returns true if a row was removed. + Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default); + + /// Counts entities matching the optional predicate. + Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + + /// Returns true if any entity matches the predicate. + Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default); + + /// Bulk-inserts entities inside a transaction. Returns affected rows. + Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// Bulk-updates entities inside a transaction. Returns affected rows. + Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default); + + /// Bulk-deletes entities matching the predicate inside a transaction. Returns affected rows. + Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default); + + /// Returns a composable, SQL-translated query (FreeSql ISelect) over the entity set. + ISelect Query(); + + /// Materializes entities matching the optional predicate. + Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default); + + /// Returns a page of entities with total-count metadata. + Task> GetPagedAsync( + int page, + int pageSize, + Expression>? predicate = null, + Expression>? orderBy = null, + bool descending = false, + CancellationToken cancellationToken = default); +} diff --git a/src/SquidStd.Database.Abstractions/README.md b/src/SquidStd.Database.Abstractions/README.md new file mode 100644 index 00000000..20073769 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Database.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Provider-agnostic data-access contracts for SquidStd. Entities derive from `BaseEntity` (a `Guid` id +plus UTC timestamps) and are accessed through the generic `IDataAccess` — full CRUD, bulk +operations, paging, and composable queries — without binding to any specific ORM. + +## Install + +```bash +dotnet add package SquidStd.Database.Abstractions +``` + +## Features + +- `IDataAccess` — `InsertAsync`, `GetByIdAsync`, `UpdateAsync`, `DeleteAsync`, `CountAsync`, + `ExistsAsync`, bulk insert/update/delete, `QueryAsync`, and `GetPagedAsync`. +- `BaseEntity` — `Guid Id` plus `DateTimeOffset Created` / `Updated` (UTC), set by the data layer. +- `PagedResultData` — items + `Page`, `PageSize`, `TotalCount`, `TotalPages`, `HasNext`, `HasPrevious`. +- `DatabaseConfig` — URI connection string + `AutoMigrate` flag. +- `DatabaseProviderType` — `Sqlite`, `Postgres`, `SqlServer`, `MySql`. + +## Usage + +```csharp +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Abstractions.Interfaces.Data; + +public sealed class User : BaseEntity +{ + public string Name { get; set; } = string.Empty; +} + +// IDataAccess is resolved from DI (see the SquidStd.Database package). +public async Task ExampleAsync(IDataAccess users) +{ + await users.InsertAsync(new User { Name = "Ann" }); + var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name); +} +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IDataAccess` | CRUD + bulk + paged + composable query contract. | +| `BaseEntity` | Guid id + UTC created/updated base entity. | +| `PagedResultData` | Paginated result with metadata. | +| `DatabaseConfig` | Connection string + auto-migrate config section. | +| `DatabaseProviderType` | Supported provider enum. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj new file mode 100644 index 00000000..31ba5cd6 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/SquidStd.Database.Abstractions.csproj @@ -0,0 +1,20 @@ + + + + true + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs new file mode 100644 index 00000000..81955055 --- /dev/null +++ b/src/SquidStd.Database.Abstractions/Types/Data/DatabaseProviderType.cs @@ -0,0 +1,19 @@ +namespace SquidStd.Database.Abstractions.Types.Data; + +/// +/// Supported database providers. +/// +public enum DatabaseProviderType +{ + /// SQLite. + Sqlite, + + /// PostgreSQL. + Postgres, + + /// Microsoft SQL Server. + SqlServer, + + /// MySQL. + MySql +} diff --git a/src/SquidStd.Database/Connection/ConnectionStringParser.cs b/src/SquidStd.Database/Connection/ConnectionStringParser.cs new file mode 100644 index 00000000..89a95a01 --- /dev/null +++ b/src/SquidStd.Database/Connection/ConnectionStringParser.cs @@ -0,0 +1,136 @@ +using SquidStd.Database.Abstractions.Types.Data; + +namespace SquidStd.Database.Connection; + +/// +/// Parses URI-style connection strings ("scheme://...") into a provider and native connection string. +/// +public static class ConnectionStringParser +{ + /// + /// Parses the given URI connection string. + /// + /// The URI connection string. + /// The parsed provider and native connection string. + public static ParsedConnection Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var schemeEnd = connectionString.IndexOf("://", StringComparison.Ordinal); + + if (schemeEnd <= 0) + { + throw new FormatException($"Connection string '{connectionString}' is not a valid URI."); + } + + var scheme = connectionString[..schemeEnd].ToLowerInvariant(); + var remainder = connectionString[(schemeEnd + 3)..]; + var provider = ResolveProvider(scheme); + + var native = provider == DatabaseProviderType.Sqlite + ? BuildSqlite(remainder) + : BuildServer(provider, remainder); + + return new ParsedConnection(provider, native); + } + + private static DatabaseProviderType ResolveProvider(string scheme) + => scheme switch + { + "sqlite" => DatabaseProviderType.Sqlite, + "postgres" or "postgresql" => DatabaseProviderType.Postgres, + "sqlserver" or "mssql" => DatabaseProviderType.SqlServer, + "mysql" => DatabaseProviderType.MySql, + _ => throw new NotSupportedException($"Unsupported database scheme '{scheme}'.") + }; + + private static string BuildSqlite(string remainder) + { + if (string.IsNullOrWhiteSpace(remainder)) + { + throw new FormatException("SQLite connection string requires a file path or ':memory:'."); + } + + return $"Data Source={remainder}"; + } + + private static string BuildServer(DatabaseProviderType provider, string remainder) + { + // Split "[user[:pass]@]host[:port]/database[?query]" into authority and path. + var slash = remainder.IndexOf('/', StringComparison.Ordinal); + + if (slash < 0) + { + throw new FormatException("Connection string requires a database name."); + } + + var authority = remainder[..slash]; + var pathAndQuery = remainder[(slash + 1)..]; + + var query = pathAndQuery.IndexOf('?', StringComparison.Ordinal); + var database = (query < 0 ? pathAndQuery : pathAndQuery[..query]).Trim('/'); + + if (string.IsNullOrEmpty(database)) + { + throw new FormatException("Connection string requires a database name."); + } + + var (user, password, hostPort) = SplitAuthority(authority); + var (host, port) = SplitHostPort(hostPort); + + if (string.IsNullOrEmpty(host)) + { + throw new FormatException("Connection string requires a host."); + } + + return provider switch + { + DatabaseProviderType.Postgres => + $"Host={host};Port={port ?? 5432};Username={user};Password={password};Database={database}", + DatabaseProviderType.MySql => + $"Server={host};Port={port ?? 3306};Uid={user};Pwd={password};Database={database}", + DatabaseProviderType.SqlServer => + $"Server={host},{port ?? 1433};User Id={user};Password={password};Database={database};TrustServerCertificate=true", + _ => throw new NotSupportedException($"Unsupported provider {provider}.") + }; + } + + private static (string User, string Password, string HostPort) SplitAuthority(string authority) + { + var at = authority.LastIndexOf('@'); + + if (at < 0) + { + return (string.Empty, string.Empty, authority); + } + + var userInfo = authority[..at]; + var hostPort = authority[(at + 1)..]; + var separator = userInfo.IndexOf(':', StringComparison.Ordinal); + + if (separator < 0) + { + return (Uri.UnescapeDataString(userInfo), string.Empty, hostPort); + } + + var user = Uri.UnescapeDataString(userInfo[..separator]); + var password = Uri.UnescapeDataString(userInfo[(separator + 1)..]); + + return (user, password, hostPort); + } + + private static (string Host, int? Port) SplitHostPort(string hostPort) + { + var separator = hostPort.IndexOf(':', StringComparison.Ordinal); + + if (separator < 0) + { + return (hostPort, null); + } + + var host = hostPort[..separator]; + var port = int.Parse(hostPort[(separator + 1)..], System.Globalization.CultureInfo.InvariantCulture); + + return (host, port); + } +} diff --git a/src/SquidStd.Database/Connection/ParsedConnection.cs b/src/SquidStd.Database/Connection/ParsedConnection.cs new file mode 100644 index 00000000..b03c0d84 --- /dev/null +++ b/src/SquidStd.Database/Connection/ParsedConnection.cs @@ -0,0 +1,10 @@ +using SquidStd.Database.Abstractions.Types.Data; + +namespace SquidStd.Database.Connection; + +/// +/// The result of parsing a URI connection string: the provider and the native connection string. +/// +/// The resolved database provider. +/// The provider-native connection string for FreeSql. +public sealed record ParsedConnection(DatabaseProviderType Provider, string NativeConnectionString); diff --git a/src/SquidStd.Database/Data/FreeSqlDataAccess.cs b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs new file mode 100644 index 00000000..feb89b40 --- /dev/null +++ b/src/SquidStd.Database/Data/FreeSqlDataAccess.cs @@ -0,0 +1,251 @@ +using System.Linq.Expressions; +using FreeSql; +using Serilog; +using SquidStd.Database.Abstractions.Data; +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Interfaces.Services; + +namespace SquidStd.Database.Data; + +/// +/// FreeSql-backed . Writes run inside a unit of work with rollback. +/// +/// The entity type. +public sealed class FreeSqlDataAccess : IDataAccess + where TEntity : BaseEntity +{ + private static readonly ILogger Logger = Log.ForContext>(); + + private readonly IFreeSql _orm; + + /// + /// Initializes the data access over the shared FreeSql instance. + /// + /// The database service that owns the FreeSql instance. + public FreeSqlDataAccess(IDatabaseService databaseService) + { + _orm = databaseService.Orm; + } + + /// + public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.CreateVersion7(); + } + + entity.Created = now; + entity.Updated = now; + + await RunInTransactionAsync( + transaction => _orm.Insert(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Insert", + 1, + cancellationToken); + + return entity; + } + + /// + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _orm.Select().Where(e => e.Id == id).FirstAsync(cancellationToken)!; + + /// + public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + entity.Updated = DateTimeOffset.UtcNow; + + await RunInTransactionAsync( + transaction => _orm.Update().SetSource(entity).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Update", + 1, + cancellationToken); + + return entity; + } + + /// + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var affected = await RunInTransactionAsync( + transaction => _orm.Delete().Where(e => e.Id == id).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "Delete", + null, + cancellationToken); + + return affected > 0; + } + + /// + public Task DeleteAsync(TEntity entity, CancellationToken cancellationToken = default) + => DeleteAsync(entity.Id, cancellationToken); + + /// + public Task CountAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + return select.CountAsync(cancellationToken); + } + + /// + public Task ExistsAsync(Expression> predicate, CancellationToken cancellationToken = default) + => _orm.Select().Where(predicate).AnyAsync(cancellationToken); + + /// + public Task BulkInsertAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var list = Materialize(entities, stampUpdated: true); + + return RunInTransactionAsync( + transaction => _orm.Insert(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkInsert", + list.Count, + cancellationToken); + } + + /// + public Task BulkUpdateAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + var list = entities.ToList(); + var now = DateTimeOffset.UtcNow; + + foreach (var entity in list) + { + entity.Updated = now; + } + + return RunInTransactionAsync( + transaction => _orm.Update().SetSource(list).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkUpdate", + list.Count, + cancellationToken); + } + + /// + public Task BulkDeleteAsync(Expression> predicate, CancellationToken cancellationToken = default) + => RunInTransactionAsync( + transaction => _orm.Delete().Where(predicate).WithTransaction(transaction).ExecuteAffrowsAsync(cancellationToken), + "BulkDelete", + null, + cancellationToken); + + /// + public ISelect Query() + => _orm.Select(); + + /// + public async Task> QueryAsync(Expression>? predicate = null, CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + return await select.ToListAsync(cancellationToken); + } + + /// + public async Task> GetPagedAsync( + int page, + int pageSize, + Expression>? predicate = null, + Expression>? orderBy = null, + bool descending = false, + CancellationToken cancellationToken = default) + { + var select = _orm.Select(); + + if (predicate is not null) + { + select = select.Where(predicate); + } + + var total = await select.CountAsync(cancellationToken); + + if (orderBy is not null) + { + select = descending ? select.OrderByDescending(orderBy) : select.OrderBy(orderBy); + } + + var items = await select.Page(page, pageSize).ToListAsync(cancellationToken); + + return PagedResultData.Create(items, page, pageSize, total); + } + + private void EnsureSynced() + { + // Create/alter the table outside any transaction (idempotent, cached by FreeSql) so that + // SQLite DDL never collides with an open unit-of-work file lock. + if (_orm.CodeFirst.IsAutoSyncStructure) + { + _orm.CodeFirst.SyncStructure(); + } + } + + private static List Materialize(IEnumerable entities, bool stampUpdated) + { + var now = DateTimeOffset.UtcNow; + var list = entities.ToList(); + + foreach (var entity in list) + { + if (entity.Id == Guid.Empty) + { + entity.Id = Guid.CreateVersion7(); + } + + entity.Created = now; + + if (stampUpdated) + { + entity.Updated = now; + } + } + + return list; + } + + private async Task RunInTransactionAsync( + Func> action, + string operation, + int? expectedCount, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + EnsureSynced(); + + using var uow = _orm.CreateUnitOfWork(); + + try + { + var transaction = uow.GetOrBeginTransaction(); + Logger.Verbose("{Operation} starting (expected {Expected})", operation, expectedCount); + + var affected = await action(transaction); + + uow.Commit(); + Logger.Verbose("{Operation} committed ({Affected} rows)", operation, affected); + + return affected; + } + catch (Exception ex) + { + uow.Rollback(); + Logger.Error(ex, "{Operation} rolled back", operation); + throw; + } + } +} diff --git a/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs new file mode 100644 index 00000000..fa4f99bf --- /dev/null +++ b/src/SquidStd.Database/Extensions/RegisterDatabaseExtension.cs @@ -0,0 +1,30 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Data; +using SquidStd.Database.Interfaces.Services; +using SquidStd.Database.Services; + +namespace SquidStd.Database.Extensions; + +/// +/// DI registration for the database subsystem. +/// +public static class RegisterDatabaseExtension +{ + /// + /// Registers the database config section, the database service, and the open-generic data access. + /// + /// The DI container. + /// The same container for chaining. + public static IContainer RegisterDatabase(this IContainer container) + { + container.RegisterConfigSection("database"); + container.RegisterStdService(); + container.Register(typeof(IDataAccess<>), typeof(FreeSqlDataAccess<>), Reuse.Transient); + + return container; + } +} diff --git a/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs new file mode 100644 index 00000000..219b9cdf --- /dev/null +++ b/src/SquidStd.Database/Extensions/ZLinqResultExtensions.cs @@ -0,0 +1,39 @@ +using ZLinq; + +namespace SquidStd.Database.Extensions; + +/// +/// Zero-allocation, in-memory helpers (ZLinq) over already-materialized result lists. +/// +public static class ZLinqResultExtensions +{ + /// + /// Projects each materialized item to a new form using ZLinq, returning a list. + /// + /// The source item type. + /// The projected item type. + /// The materialized source items. + /// The projection. + /// The projected list. + public static List MapToList( + this IReadOnlyList source, + Func selector) + { + return source.AsValueEnumerable().Select(selector).ToList(); + } + + /// + /// Takes an in-memory page of a materialized list using ZLinq (no SQL involved). + /// + /// The item type. + /// The materialized source items. + /// The 1-based page number. + /// The page size. + /// The page items. + public static List PageInMemory(this IReadOnlyList source, int page, int pageSize) + { + var skip = Math.Max(0, (page - 1) * pageSize); + + return source.AsValueEnumerable().Skip(skip).Take(pageSize).ToList(); + } +} diff --git a/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs new file mode 100644 index 00000000..b9253e53 --- /dev/null +++ b/src/SquidStd.Database/Interfaces/Services/IDatabaseService.cs @@ -0,0 +1,13 @@ +using FreeSql; +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Database.Interfaces.Services; + +/// +/// Owns the application's singleton FreeSql instance and its lifecycle. +/// +public interface IDatabaseService : ISquidStdService +{ + /// Gets the underlying FreeSql ORM instance. + IFreeSql Orm { get; } +} diff --git a/src/SquidStd.Database/README.md b/src/SquidStd.Database/README.md new file mode 100644 index 00000000..412484cd --- /dev/null +++ b/src/SquidStd.Database/README.md @@ -0,0 +1,61 @@ +

+ SquidStd +

+ +

SquidStd.Database

+ +

+ NuGet + Downloads + license +

+ +FreeSql-backed implementation of the SquidStd.Database.Abstractions contracts. It owns a singleton +`IFreeSql`, exposes a generic `FreeSqlDataAccess` with transactional writes (rollback on +failure), bulk operations and paging, parses URI-style connection strings, and can auto-sync the schema +on startup. + +## Install + +```bash +dotnet add package SquidStd.Database +``` + +## Features + +- One-line registration: `container.RegisterDatabase()` (config section + service + open-generic `IDataAccess<>`). +- Providers via URI scheme: `sqlite://`, `postgres://`, `sqlserver://`, `mysql://`. +- `FreeSqlDataAccess` — CRUD, bulk insert/update/delete, `QueryAsync`, `GetPagedAsync`; writes + run inside a unit of work and roll back on error. Sets `Id`/`Created`/`Updated` automatically. +- Optional `AutoMigrate` (FreeSql `AutoSyncStructure`) to create/update tables on startup. +- ZLinq in-memory helpers for zero-allocation post-processing of materialized results. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Database.Abstractions.Interfaces.Data; +using SquidStd.Database.Extensions; + +var container = new Container(); +container.RegisterDatabase(); // reads the "database" config section + +// DatabaseConfig: ConnectionString = "postgres://user:pass@host:5432/app", AutoMigrate = true +var users = container.Resolve>(); +await users.InsertAsync(new User { Name = "Ann" }); +var page = await users.GetPagedAsync(page: 1, pageSize: 20, orderBy: u => u.Name); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RegisterDatabaseExtension` | `RegisterDatabase()` DI registration. | +| `DatabaseService` | Owns the singleton `IFreeSql`; builds it and (optionally) migrates. | +| `FreeSqlDataAccess` | FreeSql `IDataAccess` implementation. | +| `ConnectionStringParser` | URI → provider + native connection string. | +| `ZLinqResultExtensions` | Zero-alloc in-memory result helpers. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs new file mode 100644 index 00000000..6a095358 --- /dev/null +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -0,0 +1,84 @@ +using FreeSql; +using Serilog; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Types.Data; +using SquidStd.Database.Connection; +using SquidStd.Database.Interfaces.Services; + +namespace SquidStd.Database.Services; + +/// +/// Builds and owns the singleton FreeSql instance, logging SQL and migrations verbosely. +/// +public sealed class DatabaseService : IDatabaseService +{ + private static readonly ILogger Logger = Log.ForContext(); + + private readonly DatabaseConfig _config; + private IFreeSql? _orm; + private int _started; + + /// + public IFreeSql Orm + => _orm ?? throw new InvalidOperationException("Database service is not started."); + + /// + /// Initializes the database service. + /// + /// The database configuration section. + public DatabaseService(DatabaseConfig config) + { + _config = config; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Interlocked.Exchange(ref _started, 1) != 0) + { + return ValueTask.CompletedTask; + } + + var parsed = ConnectionStringParser.Parse(_config.ConnectionString); + Logger.Verbose("Building FreeSql for provider {Provider}", parsed.Provider); + + var builder = new FreeSqlBuilder() + .UseConnectionString(MapDataType(parsed.Provider), parsed.NativeConnectionString) + .UseAutoSyncStructure(_config.AutoMigrate) + .UseMonitorCommand(cmd => Logger.Verbose("SQL {Sql}", cmd.CommandText)); + + _orm = builder.Build(); + _orm.Aop.SyncStructureAfter += (_, e) => + Logger.Verbose("Migrated {Entities} -> {Sql}", e.EntityTypes, e.Sql); + + Logger.Information( + "Database service started ({Provider}, autoMigrate={AutoMigrate})", + parsed.Provider, + _config.AutoMigrate); + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + _orm?.Dispose(); + _orm = null; + + return ValueTask.CompletedTask; + } + + private static DataType MapDataType(DatabaseProviderType provider) + => provider switch + { + DatabaseProviderType.Sqlite => DataType.Sqlite, + DatabaseProviderType.Postgres => DataType.PostgreSQL, + DatabaseProviderType.SqlServer => DataType.SqlServer, + DatabaseProviderType.MySql => DataType.MySql, + _ => throw new NotSupportedException($"Unsupported provider {provider}.") + }; +} diff --git a/src/SquidStd.Database/SquidStd.Database.csproj b/src/SquidStd.Database/SquidStd.Database.csproj new file mode 100644 index 00000000..c1629ed3 --- /dev/null +++ b/src/SquidStd.Database/SquidStd.Database.csproj @@ -0,0 +1,26 @@ + + + + true + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs new file mode 100644 index 00000000..0f123b59 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingConnectionString.cs @@ -0,0 +1,97 @@ +using System.Collections.Frozen; +using System.Web; + +namespace SquidStd.Messaging.Abstractions.Data.Config; + +/// +/// Parsed messaging connection string of the form scheme://[user:pass@]host[:port][/vhost][?params]. +/// +public sealed class MessagingConnectionString +{ + private MessagingConnectionString( + string scheme, + string host, + int? port, + string? userName, + string? password, + string virtualHost, + IReadOnlyDictionary parameters + ) + { + Scheme = scheme; + Host = host; + Port = port; + UserName = userName; + Password = password; + VirtualHost = virtualHost; + Parameters = parameters; + } + + /// URI scheme, e.g. "memory" or "rabbitmq". + public string Scheme { get; } + + /// Host component. + public string Host { get; } + + /// Port, when specified. + public int? Port { get; } + + /// User name from the user-info component, when present. + public string? UserName { get; } + + /// Password from the user-info component, when present. + public string? Password { get; } + + /// Virtual host from the path (default "/"). + public string VirtualHost { get; } + + /// Query-string parameters. + public IReadOnlyDictionary Parameters { get; } + + /// Parses a messaging connection string. + public static MessagingConnectionString Parse(string connectionString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + var uri = new Uri(connectionString, UriKind.Absolute); + + string? userName = null; + string? password = null; + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + var parts = uri.UserInfo.Split(':', 2); + userName = Uri.UnescapeDataString(parts[0]); + password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : null; + } + + var virtualHost = uri.AbsolutePath.Trim('/'); + var query = HttpUtility.ParseQueryString(uri.Query); + var parameters = query.AllKeys + .Where(static key => key is not null) + .ToFrozenDictionary(key => key!, key => query[key] ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + return new( + uri.Scheme, + uri.Host, + uri.Port > 0 ? uri.Port : null, + userName, + password, + string.IsNullOrEmpty(virtualHost) ? "/" : virtualHost, + parameters + ); + } + + /// Builds from the query parameters. + public MessagingOptions ToMessagingOptions() + => new() + { + MaxDeliveryAttempts = Parameters.TryGetValue("maxDeliveryAttempts", out var max) && int.TryParse(max, out var parsedMax) + ? parsedMax + : 3, + DeadLetterQueueSuffix = Parameters.TryGetValue("deadLetterSuffix", out var suffix) ? suffix : ".dlq", + RetryDelay = Parameters.TryGetValue("retryDelayMs", out var delay) && int.TryParse(delay, out var parsedDelay) + ? TimeSpan.FromMilliseconds(parsedDelay) + : TimeSpan.Zero + }; +} diff --git a/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs new file mode 100644 index 00000000..97afa226 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Data/Config/MessagingOptions.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Messaging.Abstractions.Data.Config; + +/// +/// Configuration for the messaging system. +/// +public sealed class MessagingOptions +{ + /// Maximum delivery attempts before dead-lettering. Default 3. + public int MaxDeliveryAttempts { get; init; } = 3; + + /// Suffix appended to a queue name to form its dead-letter queue. Default ".dlq". + public string DeadLetterQueueSuffix { get; init; } = ".dlq"; + + /// Delay applied before re-enqueueing a failed message. Default zero. + public TimeSpan RetryDelay { get; init; } = TimeSpan.Zero; +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs new file mode 100644 index 00000000..32f7011e --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessageQueue.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Typed facade for publishing to and subscribing to named queues. +/// +public interface IMessageQueue +{ + /// Publishes a message to a named queue. + Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default); + + /// Subscribes a synchronous listener to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, IQueueMessageListener listener); + + /// Subscribes an asynchronous listener to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs new file mode 100644 index 00000000..f3058c03 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IMessagingMetrics.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Sink for messaging metric events. Implementations must be thread-safe. +/// +public interface IMessagingMetrics +{ + /// Records a message published to a queue. + void OnPublished(string queueName); + + /// Records a message delivered successfully. + void OnDelivered(string queueName); + + /// Records a failed delivery attempt. + void OnFailed(string queueName); + + /// Records a retry of a message. + void OnRetried(string queueName); + + /// Records a message moved to the dead-letter queue. + void OnDeadLettered(string queueName); + + /// Sets the current buffered depth of a queue. + void SetQueueDepth(string queueName, int depth); + + /// Sets the current subscriber count of a queue. + void SetSubscriberCount(string queueName, int count); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs new file mode 100644 index 00000000..b384b5f1 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListener.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Handles a queue message synchronously. +/// +/// The message payload type. +public interface IQueueMessageListener +{ + /// Handles a delivered message. + void Handle(TMessage message); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs new file mode 100644 index 00000000..e7708457 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueMessageListenerAsync.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Handles a queue message asynchronously. +/// +/// The message payload type. +public interface IQueueMessageListenerAsync +{ + /// Handles a delivered message. + Task HandleAsync(TMessage message, CancellationToken cancellationToken); +} diff --git a/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs new file mode 100644 index 00000000..18e26062 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Interfaces/IQueueProvider.cs @@ -0,0 +1,15 @@ +using SquidStd.Abstractions.Interfaces.Services; + +namespace SquidStd.Messaging.Abstractions.Interfaces; + +/// +/// Byte-level queue transport owning buffering, round-robin delivery, retry and dead-lettering. +/// +public interface IQueueProvider : ISquidStdService, IAsyncDisposable +{ + /// Publishes a raw payload to a named queue. + Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default); + + /// Subscribes a raw handler to a named queue. Dispose to unsubscribe. + IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler); +} diff --git a/src/SquidStd.Messaging.Abstractions/README.md b/src/SquidStd.Messaging.Abstractions/README.md new file mode 100644 index 00000000..c9046c3c --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Messaging.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Transport-agnostic messaging contracts for SquidStd. It defines the typed queue facade +(`IMessageQueue`), the low-level provider/serializer/metrics contracts, the message-listener interfaces, +and the shared `MessageQueue` facade plus default serializer and metrics. Pick a transport +implementation (in-memory or RabbitMQ) from a companion package. + +## Install + +```bash +dotnet add package SquidStd.Messaging.Abstractions +``` + +## Features + +- `IMessageQueue` — typed `PublishAsync` / `Subscribe` facade over named queues. +- `IQueueProvider` — the raw transport contract implemented per backend. +- `IMessageSerializer` (+ default `JsonMessageSerializer`) — payload (de)serialization. +- `IQueueMessageListener` / `IQueueMessageListenerAsync` — sync/async subscribers. +- `IMessagingMetrics` (+ `MessagingMetricsProvider`, `NoOpMessagingMetrics`) — delivery metrics. +- `MessagingOptions` and `MessagingConnectionString` — configuration and connection parsing. + +## Usage + +```csharp +using SquidStd.Messaging.Abstractions.Interfaces; + +public sealed class OrderCreated { public int Id { get; init; } } + +public sealed class OrderListener : IQueueMessageListener +{ + public void Handle(OrderCreated message) { /* ... */ } +} + +// Resolve IMessageQueue from a transport package (in-memory or RabbitMQ). +public async Task PublishAsync(IMessageQueue queue) + => await queue.PublishAsync("orders", new OrderCreated { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IMessageQueue` | Typed publish/subscribe facade. | +| `IQueueProvider` | Transport contract (per backend). | +| `IMessageSerializer` | Payload (de)serialization. | +| `IQueueMessageListener` | Subscriber callbacks. | +| `IMessagingMetrics` | Delivery metrics sink. | +| `MessagingOptions` | Delivery attempts, retry delay, and dead-letter-queue suffix. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs new file mode 100644 index 00000000..7bcaa5bd --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Services/MessageQueue.cs @@ -0,0 +1,53 @@ +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.Abstractions.Services; + +/// +/// Typed facade over an : serializes outgoing messages and +/// deserializes incoming payloads before handing them to typed listeners. +/// +public sealed class MessageQueue : IMessageQueue +{ + private readonly IQueueProvider _provider; + private readonly IDataSerializer _serializer; + private readonly IDataDeserializer _deserializer; + + public MessageQueue(IQueueProvider provider, IDataSerializer serializer, IDataDeserializer deserializer) + { + _provider = provider; + _serializer = serializer; + _deserializer = deserializer; + } + + /// + public Task PublishAsync(string queueName, TMessage message, CancellationToken cancellationToken = default) + => _provider.PublishAsync(queueName, _serializer.Serialize(message), cancellationToken); + + /// + public IDisposable Subscribe(string queueName, IQueueMessageListener listener) + { + ArgumentNullException.ThrowIfNull(listener); + + return _provider.Subscribe( + queueName, + (payload, _) => + { + listener.Handle(_deserializer.Deserialize(payload)); + + return Task.CompletedTask; + } + ); + } + + /// + public IDisposable Subscribe(string queueName, IQueueMessageListenerAsync listener) + { + ArgumentNullException.ThrowIfNull(listener); + + return _provider.Subscribe( + queueName, + (payload, cancellationToken) => listener.HandleAsync(_deserializer.Deserialize(payload), cancellationToken) + ); + } +} diff --git a/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs new file mode 100644 index 00000000..ffefab6e --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Services/MessagingMetricsProvider.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types.Metrics; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.Abstractions.Services; + +/// +/// Accumulates messaging metrics and exposes them to the metrics collection system. +/// +public sealed class MessagingMetricsProvider : IMessagingMetrics, IMetricProvider +{ + private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); + + /// + public string ProviderName => "messaging"; + + /// + public void OnPublished(string queueName) + => Interlocked.Increment(ref Counters(queueName).Published); + + /// + public void OnDelivered(string queueName) + => Interlocked.Increment(ref Counters(queueName).Delivered); + + /// + public void OnFailed(string queueName) + => Interlocked.Increment(ref Counters(queueName).Failed); + + /// + public void OnRetried(string queueName) + => Interlocked.Increment(ref Counters(queueName).Retried); + + /// + public void OnDeadLettered(string queueName) + => Interlocked.Increment(ref Counters(queueName).DeadLettered); + + /// + public void SetQueueDepth(string queueName, int depth) + => Volatile.Write(ref Counters(queueName).Depth, depth); + + /// + public void SetSubscriberCount(string queueName, int count) + => Volatile.Write(ref Counters(queueName).Subscribers, count); + + /// + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + var samples = new List(); + + foreach (var (queueName, counters) in _queues) + { + var tags = new Dictionary(StringComparer.Ordinal) { ["queue"] = queueName }; + + samples.Add(new("published", Interlocked.Read(ref counters.Published), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("delivered", Interlocked.Read(ref counters.Delivered), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("failed", Interlocked.Read(ref counters.Failed), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("retried", Interlocked.Read(ref counters.Retried), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("dead_lettered", Interlocked.Read(ref counters.DeadLettered), Tags: tags, Type: MetricType.Counter)); + samples.Add(new("queue_depth", Volatile.Read(ref counters.Depth), Tags: tags)); + samples.Add(new("subscribers", Volatile.Read(ref counters.Subscribers), Tags: tags)); + } + + return ValueTask.FromResult>(samples); + } + + private QueueCounters Counters(string queueName) + => _queues.GetOrAdd(queueName, static _ => new QueueCounters()); + + private sealed class QueueCounters + { + public long Published; + public long Delivered; + public long Failed; + public long Retried; + public long DeadLettered; + public int Depth; + public int Subscribers; + } +} diff --git a/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs new file mode 100644 index 00000000..b6c7bf71 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/Services/NoOpMessagingMetrics.cs @@ -0,0 +1,40 @@ +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Messaging.Abstractions.Services; + +/// +/// Metrics sink that ignores all events. Used when no metrics are configured. +/// +public sealed class NoOpMessagingMetrics : IMessagingMetrics +{ + /// Shared instance. + public static NoOpMessagingMetrics Instance { get; } = new(); + + public void OnPublished(string queueName) + { + } + + public void OnDelivered(string queueName) + { + } + + public void OnFailed(string queueName) + { + } + + public void OnRetried(string queueName) + { + } + + public void OnDeadLettered(string queueName) + { + } + + public void SetQueueDepth(string queueName, int depth) + { + } + + public void SetSubscriberCount(string queueName, int count) + { + } +} diff --git a/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj new file mode 100644 index 00000000..5f9c6603 --- /dev/null +++ b/src/SquidStd.Messaging.Abstractions/SquidStd.Messaging.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + true + net10.0 + enable + enable + + + + + + + + diff --git a/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs new file mode 100644 index 00000000..4e939ab7 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/Data/Config/RabbitMqOptions.cs @@ -0,0 +1,28 @@ +namespace SquidStd.Messaging.RabbitMq.Data.Config; + +/// +/// Connection options for the RabbitMQ queue provider. +/// +public sealed class RabbitMqOptions +{ + /// Broker host. Default "localhost". + public string HostName { get; init; } = "localhost"; + + /// Broker port. Default 5672. + public int Port { get; init; } = 5672; + + /// Virtual host. Default "/". + public string VirtualHost { get; init; } = "/"; + + /// User name. Default "guest". + public string UserName { get; init; } = "guest"; + + /// Password. Default "guest". + public string Password { get; init; } = "guest"; + + /// When set, the AMQP URI overrides the individual fields. + public Uri? Uri { get; init; } + + /// Consumer prefetch count. Default 10. + public ushort PrefetchCount { get; init; } = 10; +} diff --git a/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs new file mode 100644 index 00000000..b56cd98c --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/Extensions/RabbitMqMessagingRegistrationExtensions.cs @@ -0,0 +1,84 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; + +using SquidStd.Messaging.RabbitMq.Data.Config; +using SquidStd.Messaging.RabbitMq.Services; + +namespace SquidStd.Messaging.RabbitMq.Extensions; + +/// +/// DryIoc registration helpers for the RabbitMQ messaging provider. +/// +public static class RabbitMqMessagingRegistrationExtensions +{ + /// Registers RabbitMQ messaging from an explicit options object. + public static IContainer AddRabbitMqMessaging( + this IContainer container, + RabbitMqOptions options, + MessagingOptions? messagingOptions = null + ) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(options); + + var resolvedMessaging = messagingOptions ?? new MessagingOptions(); + + container.RegisterInstance(resolvedMessaging); + container.RegisterInstance(options); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + var metrics = new MessagingMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.RegisterDelegate( + r => new RabbitMqQueueProvider( + r.Resolve(), + r.Resolve(), + r.Resolve() + ), + Reuse.Singleton + ); + container.Register(Reuse.Singleton); + + return container; + } + + /// Registers RabbitMQ messaging from a connection string (scheme must be "rabbitmq"). + public static IContainer AddRabbitMqMessaging(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = MessagingConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "rabbitmq", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'rabbitmq://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + var options = new RabbitMqOptions + { + HostName = string.IsNullOrEmpty(cs.Host) ? "localhost" : cs.Host, + Port = cs.Port ?? 5672, + VirtualHost = cs.VirtualHost, + UserName = cs.UserName ?? "guest", + Password = cs.Password ?? "guest", + PrefetchCount = cs.Parameters.TryGetValue("prefetch", out var prefetch) && ushort.TryParse(prefetch, out var parsed) + ? parsed + : (ushort)10 + }; + + return container.AddRabbitMqMessaging(options, cs.ToMessagingOptions()); + } +} diff --git a/src/SquidStd.Messaging.RabbitMq/README.md b/src/SquidStd.Messaging.RabbitMq/README.md new file mode 100644 index 00000000..69578ac6 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/README.md @@ -0,0 +1,54 @@ +

+ SquidStd +

+ +

SquidStd.Messaging.RabbitMq

+ +

+ NuGet + Downloads + license +

+ +RabbitMQ transport for SquidStd.Messaging. Implements `IQueueProvider` on top of the RabbitMQ client, +so the same `IMessageQueue` API publishes to and consumes from a real broker. Registered with a single +`AddRabbitMqMessaging(...)` call. + +## Install + +```bash +dotnet add package SquidStd.Messaging.RabbitMq +``` + +## Features + +- One-line registration: `container.AddRabbitMqMessaging(connectionString)` or with `RabbitMqOptions`. +- Broker-backed `IQueueProvider` reusing the shared `IMessageQueue` facade and serializer. +- Connection via a `rabbitmq://` connection string or explicit `RabbitMqOptions` (host/port/vhost/credentials). +- Configurable consumer prefetch count (`?prefetch=` on the connection string, or `RabbitMqOptions.PrefetchCount`). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.RabbitMq.Extensions; + +var container = new Container(); +container.AddRabbitMqMessaging("rabbitmq://guest:guest@localhost:5672/"); + +var queue = container.Resolve(); +await queue.PublishAsync("orders", new { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RabbitMqMessagingRegistrationExtensions` | `AddRabbitMqMessaging(...)` registration. | +| `RabbitMqQueueProvider` | RabbitMQ-backed `IQueueProvider`. | +| `RabbitMqOptions` | Connection + prefetch configuration. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs new file mode 100644 index 00000000..aa2d2127 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/Services/RabbitMqQueueProvider.cs @@ -0,0 +1,273 @@ +using System.Collections.Concurrent; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Serilog; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; + +using SquidStd.Messaging.RabbitMq.Data.Config; +namespace SquidStd.Messaging.RabbitMq.Services; + +/// +/// RabbitMQ : named queues map to quorum queues with a delivery limit +/// and a dead-letter exchange; round-robin is the broker's native competing-consumers behaviour. +/// +public sealed class RabbitMqQueueProvider : IQueueProvider +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly RabbitMqOptions _options; + private readonly MessagingOptions _messagingOptions; + private readonly IMessagingMetrics _metrics; + private readonly SemaphoreSlim _publishLock = new(1, 1); + private readonly Lock _topologySync = new(); + private readonly HashSet _declared = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _subscriberCounts = new(StringComparer.Ordinal); + private IConnection? _connection; + private IChannel? _publishChannel; + private int _disposed; + + public RabbitMqQueueProvider(RabbitMqOptions options, MessagingOptions messagingOptions, IMessagingMetrics? metrics = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(messagingOptions); + + _options = options; + _messagingOptions = messagingOptions; + _metrics = metrics ?? NoOpMessagingMetrics.Instance; + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + var factory = new ConnectionFactory { AutomaticRecoveryEnabled = true }; + + if (_options.Uri is not null) + { + factory.Uri = _options.Uri; + } + else + { + factory.HostName = _options.HostName; + factory.Port = _options.Port; + factory.VirtualHost = _options.VirtualHost; + factory.UserName = _options.UserName; + factory.Password = _options.Password; + } + + _connection = await factory.CreateConnectionAsync(cancellationToken); + _publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public async Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + var channel = _publishChannel ?? throw new InvalidOperationException("Provider not started."); + + await EnsureTopologyAsync(channel, queueName, cancellationToken); + + var properties = new BasicProperties { Persistent = true }; + + await _publishLock.WaitAsync(cancellationToken); + + try + { + await channel.BasicPublishAsync( + exchange: string.Empty, + routingKey: queueName, + mandatory: false, + basicProperties: properties, + body: payload, + cancellationToken: cancellationToken + ); + } + finally + { + _publishLock.Release(); + } + + _metrics.OnPublished(queueName); + } + + /// + public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + ArgumentNullException.ThrowIfNull(handler); + + var connection = _connection ?? throw new InvalidOperationException("Provider not started."); + var subscription = new Subscription(this, connection, queueName, handler); + subscription.Start(); + _metrics.SetSubscriberCount(queueName, _subscriberCounts.AddOrUpdate(queueName, 1, static (_, count) => count + 1)); + + return subscription; + } + + private async Task EnsureTopologyAsync(IChannel channel, string queueName, CancellationToken cancellationToken) + { + lock (_topologySync) + { + if (!_declared.Add(queueName)) + { + return; + } + } + + // Dead-letter queues are terminal: declare them plainly, no DLX. + if (queueName.EndsWith(_messagingOptions.DeadLetterQueueSuffix, StringComparison.Ordinal)) + { + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + cancellationToken: cancellationToken + ); + + return; + } + + var deadLetterQueue = queueName + _messagingOptions.DeadLetterQueueSuffix; + + await channel.QueueDeclareAsync( + queue: deadLetterQueue, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary { ["x-queue-type"] = "quorum" }, + cancellationToken: cancellationToken + ); + + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + ["x-queue-type"] = "quorum", + ["x-delivery-limit"] = _messagingOptions.MaxDeliveryAttempts, + ["x-dead-letter-exchange"] = string.Empty, + ["x-dead-letter-routing-key"] = deadLetterQueue + }, + cancellationToken: cancellationToken + ); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_publishChannel is not null) + { + await _publishChannel.CloseAsync(); + await _publishChannel.DisposeAsync(); + } + + if (_connection is not null) + { + await _connection.CloseAsync(); + await _connection.DisposeAsync(); + } + + _publishLock.Dispose(); + } + + private sealed class Subscription : IDisposable + { + private readonly RabbitMqQueueProvider _provider; + private readonly IConnection _connection; + private readonly string _queueName; + private readonly Func, CancellationToken, Task> _handler; + private IChannel? _channel; + private string? _consumerTag; + private int _disposed; + + public Subscription( + RabbitMqQueueProvider provider, + IConnection connection, + string queueName, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _connection = connection; + _queueName = queueName; + _handler = handler; + } + + public void Start() + => StartAsync().GetAwaiter().GetResult(); + + private async Task StartAsync() + { + _channel = await _connection.CreateChannelAsync(); + await _provider.EnsureTopologyAsync(_channel, _queueName, CancellationToken.None); + await _channel.BasicQosAsync(0, _provider._options.PrefetchCount, false); + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += OnReceivedAsync; + + _consumerTag = await _channel.BasicConsumeAsync(_queueName, autoAck: false, consumer: consumer); + } + + private async Task OnReceivedAsync(object sender, BasicDeliverEventArgs args) + { + var channel = _channel!; + + try + { + await _handler(args.Body, CancellationToken.None); + await channel.BasicAckAsync(args.DeliveryTag, multiple: false); + _provider._metrics.OnDelivered(_queueName); + } + catch (Exception ex) + { + _provider._logger.Warning(ex, "RabbitMq handler failed for {QueueName}", _queueName); + _provider._metrics.OnFailed(_queueName); + await channel.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: true); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (_channel is not null) + { + try + { + if (_consumerTag is not null) + { + _channel.BasicCancelAsync(_consumerTag).GetAwaiter().GetResult(); + } + + _channel.CloseAsync().GetAwaiter().GetResult(); + _channel.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort teardown. + } + } + + _provider._metrics.SetSubscriberCount( + _queueName, + _provider._subscriberCounts.AddOrUpdate(_queueName, 0, static (_, count) => Math.Max(0, count - 1)) + ); + } + } +} diff --git a/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj new file mode 100644 index 00000000..f63ef6a3 --- /dev/null +++ b/src/SquidStd.Messaging.RabbitMq/SquidStd.Messaging.RabbitMq.csproj @@ -0,0 +1,19 @@ + + + + true + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs new file mode 100644 index 00000000..92a3299a --- /dev/null +++ b/src/SquidStd.Messaging/Extensions/MessagingRegistrationExtensions.cs @@ -0,0 +1,62 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Json; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.Services; + +namespace SquidStd.Messaging.Extensions; + +/// +/// DryIoc registration helpers for the in-memory messaging system. +/// +public static class MessagingRegistrationExtensions +{ + /// + /// Registers the in-memory messaging services (facade, provider, serializer, metrics). + /// + /// The container to register into. + /// Optional messaging options; defaults are used when null. + /// The container for chaining. + public static IContainer AddInMemoryMessaging(this IContainer container, MessagingOptions? options = null) + { + ArgumentNullException.ThrowIfNull(container); + + container.RegisterInstance(options ?? new MessagingOptions()); + + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + var metrics = new MessagingMetricsProvider(); + container.RegisterInstance(metrics); + container.RegisterInstance(metrics); + + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; + } + + /// + /// Registers the in-memory messaging services from a connection string (scheme must be "memory"). + /// + public static IContainer AddInMemoryMessaging(this IContainer container, string connectionString) + { + ArgumentNullException.ThrowIfNull(container); + + var cs = MessagingConnectionString.Parse(connectionString); + + if (!string.Equals(cs.Scheme, "memory", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Expected a 'memory://' connection string but got '{cs.Scheme}://'.", + nameof(connectionString) + ); + } + + return container.AddInMemoryMessaging(cs.ToMessagingOptions()); + } +} diff --git a/src/SquidStd.Messaging/Internal/InMemoryQueue.cs b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs new file mode 100644 index 00000000..8ae6a7c3 --- /dev/null +++ b/src/SquidStd.Messaging/Internal/InMemoryQueue.cs @@ -0,0 +1,72 @@ +using System.Threading.Channels; + +namespace SquidStd.Messaging.Internal; + +/// +/// Per-queue in-memory state: the buffer channel, the registered handlers, and the round-robin index. +/// +internal sealed class InMemoryQueue +{ + private readonly Lock _handlerSync = new(); + private readonly List, CancellationToken, Task>> _handlers = []; + private int _roundRobinIndex; + private int _depth; + + public Channel Channel { get; } = System.Threading.Channels.Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); + + public Task? ConsumerLoop { get; set; } + + /// Increments the buffered depth and returns the new value. + public int IncrementDepth() + => Interlocked.Increment(ref _depth); + + /// Decrements the buffered depth and returns the new value. + public int DecrementDepth() + => Interlocked.Decrement(ref _depth); + + public int HandlerCount + { + get + { + lock (_handlerSync) + { + return _handlers.Count; + } + } + } + + public void AddHandler(Func, CancellationToken, Task> handler) + { + lock (_handlerSync) + { + _handlers.Add(handler); + } + } + + public void RemoveHandler(Func, CancellationToken, Task> handler) + { + lock (_handlerSync) + { + _handlers.Remove(handler); + } + } + + /// Returns the next handler in round-robin order, or null when none are registered. + public Func, CancellationToken, Task>? NextHandler() + { + lock (_handlerSync) + { + if (_handlers.Count == 0) + { + return null; + } + + var handler = _handlers[_roundRobinIndex % _handlers.Count]; + _roundRobinIndex = (_roundRobinIndex + 1) % _handlers.Count; + + return handler; + } + } +} diff --git a/src/SquidStd.Messaging/Internal/QueuedMessage.cs b/src/SquidStd.Messaging/Internal/QueuedMessage.cs new file mode 100644 index 00000000..0ff2c295 --- /dev/null +++ b/src/SquidStd.Messaging/Internal/QueuedMessage.cs @@ -0,0 +1,8 @@ +namespace SquidStd.Messaging.Internal; + +/// +/// A buffered queue message with its delivery attempt count. +/// +/// The raw message payload. +/// Number of delivery attempts already made (0 on first enqueue). +internal sealed record QueuedMessage(ReadOnlyMemory Payload, int Attempt); diff --git a/src/SquidStd.Messaging/README.md b/src/SquidStd.Messaging/README.md new file mode 100644 index 00000000..fbc4310b --- /dev/null +++ b/src/SquidStd.Messaging/README.md @@ -0,0 +1,54 @@ +

+ SquidStd +

+ +

SquidStd.Messaging

+ +

+ NuGet + Downloads + license +

+ +In-memory transport for SquidStd.Messaging. Provides a channel-backed `IQueueProvider` with per-queue +buffering, round-robin delivery to subscribers, retry/dead-letter handling, and metrics — registered +with a single `AddInMemoryMessaging()` call. Ideal for single-process apps, tests, and local dev. + +## Install + +```bash +dotnet add package SquidStd.Messaging +``` + +## Features + +- One-line registration: `container.AddInMemoryMessaging()` (facade, provider, serializer, metrics). +- Channel-based per-queue buffering with a dedicated consumer loop. +- Round-robin delivery across multiple subscribers of the same queue. +- Retry with configurable max attempts and dead-letter queues (`MessagingOptions`). +- Built-in delivery metrics via `MessagingMetricsProvider`. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Extensions; + +var container = new Container(); +container.AddInMemoryMessaging(); + +var queue = container.Resolve(); +await queue.PublishAsync("orders", new { Id = 1 }); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `MessagingRegistrationExtensions` | `AddInMemoryMessaging(...)` registration. | +| `InMemoryQueueProvider` | Channel-based in-memory `IQueueProvider`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs new file mode 100644 index 00000000..82bf7c66 --- /dev/null +++ b/src/SquidStd.Messaging/Services/InMemoryQueueProvider.cs @@ -0,0 +1,255 @@ +using System.Collections.Concurrent; +using Serilog; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Interfaces; +using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.Internal; + +namespace SquidStd.Messaging.Services; + +/// +/// In-memory : one buffered channel + consumer loop per named queue, +/// round-robin delivery, retry and dead-lettering. +/// +public sealed class InMemoryQueueProvider : IQueueProvider +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ConcurrentDictionary _queues = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _shutdownCts = new(); + private readonly MessagingOptions _options; + private readonly IMessagingMetrics _metrics; + private readonly TimeProvider _timeProvider; + private int _disposed; + + public InMemoryQueueProvider(MessagingOptions options, IMessagingMetrics? metrics = null, TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _metrics = metrics ?? NoOpMessagingMetrics.Instance; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => DisposeAsync(); + + /// + public Task PublishAsync(string queueName, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + cancellationToken.ThrowIfCancellationRequested(); + + Enqueue(queueName, new(payload, 0)); + _metrics.OnPublished(queueName); + + return Task.CompletedTask; + } + + /// + public IDisposable Subscribe(string queueName, Func, CancellationToken, Task> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(queueName); + ArgumentNullException.ThrowIfNull(handler); + + var queue = GetOrCreate(queueName); + queue.AddHandler(handler); + _metrics.SetSubscriberCount(queueName, queue.HandlerCount); + + return new Subscription(this, queueName, queue, handler); + } + + private InMemoryQueue GetOrCreate(string queueName) + => _queues.GetOrAdd( + queueName, + name => + { + var queue = new InMemoryQueue(); + queue.ConsumerLoop = Task.Run(() => ConsumeLoopAsync(name, queue, _shutdownCts.Token), CancellationToken.None); + + return queue; + } + ); + + private void Enqueue(string queueName, QueuedMessage message) + => Write(GetOrCreate(queueName), queueName, message); + + private void Write(InMemoryQueue queue, string queueName, QueuedMessage message) + { + queue.Channel.Writer.TryWrite(message); + _metrics.SetQueueDepth(queueName, queue.IncrementDepth()); + } + + private async Task ConsumeLoopAsync(string queueName, InMemoryQueue queue, CancellationToken cancellationToken) + { + try + { + await foreach (var message in queue.Channel.Reader.ReadAllAsync(cancellationToken)) + { + _metrics.SetQueueDepth(queueName, queue.DecrementDepth()); + + var handler = await WaitForHandlerAsync(queue, cancellationToken); + + if (handler is null) + { + return; // shutting down + } + + try + { + await handler(message.Payload, cancellationToken); + _metrics.OnDelivered(queueName); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + HandleFailure(queueName, queue, message, ex); + } + } + } + catch (OperationCanceledException) + { + // Expected during shutdown. + } + catch (Exception ex) + { + _logger.Error(ex, "In-memory queue consumer loop failed for {QueueName}", queueName); + } + } + + private async Task, CancellationToken, Task>?> WaitForHandlerAsync( + InMemoryQueue queue, + CancellationToken cancellationToken + ) + { + // Messages can arrive before any subscriber; wait until a handler exists. + while (!cancellationToken.IsCancellationRequested) + { + var handler = queue.NextHandler(); + + if (handler is not null) + { + return handler; + } + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(10), _timeProvider, cancellationToken); + } + catch (OperationCanceledException) + { + return null; + } + } + + return null; + } + + private void HandleFailure(string queueName, InMemoryQueue queue, QueuedMessage message, Exception exception) + { + _metrics.OnFailed(queueName); + var nextAttempt = message.Attempt + 1; + + if (nextAttempt < _options.MaxDeliveryAttempts) + { + _metrics.OnRetried(queueName); + _ = RequeueAsync(queue, queueName, message with { Attempt = nextAttempt }); + + return; + } + + _logger.Warning(exception, "Message dead-lettered on queue {QueueName} after {Attempts} attempts", queueName, nextAttempt); + _metrics.OnDeadLettered(queueName); + Enqueue(queueName + _options.DeadLetterQueueSuffix, new(message.Payload, 0)); + } + + private async Task RequeueAsync(InMemoryQueue queue, string queueName, QueuedMessage message) + { + if (_options.RetryDelay > TimeSpan.Zero) + { + try + { + await Task.Delay(_options.RetryDelay, _timeProvider, _shutdownCts.Token); + } + catch (OperationCanceledException) + { + return; + } + } + + Write(queue, queueName, message); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await _shutdownCts.CancelAsync(); + + foreach (var queue in _queues.Values) + { + queue.Channel.Writer.TryComplete(); + } + + foreach (var queue in _queues.Values) + { + if (queue.ConsumerLoop is not null) + { + try + { + await queue.ConsumerLoop; + } + catch + { + // Loop failures are already logged. + } + } + } + + _shutdownCts.Dispose(); + } + + private sealed class Subscription : IDisposable + { + private readonly InMemoryQueueProvider _provider; + private readonly string _queueName; + private readonly InMemoryQueue _queue; + private readonly Func, CancellationToken, Task> _handler; + private int _disposed; + + public Subscription( + InMemoryQueueProvider provider, + string queueName, + InMemoryQueue queue, + Func, CancellationToken, Task> handler + ) + { + _provider = provider; + _queueName = queueName; + _queue = queue; + _handler = handler; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _queue.RemoveHandler(_handler); + _provider._metrics.SetSubscriberCount(_queueName, _queue.HandlerCount); + } + } +} diff --git a/src/SquidStd.Messaging/SquidStd.Messaging.csproj b/src/SquidStd.Messaging/SquidStd.Messaging.csproj new file mode 100644 index 00000000..0d0fa10f --- /dev/null +++ b/src/SquidStd.Messaging/SquidStd.Messaging.csproj @@ -0,0 +1,23 @@ + + + + true + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/SquidStd.Network/Client/SquidStdTcpClient.cs b/src/SquidStd.Network/Client/SquidStdTcpClient.cs index 411f2236..b409dbb3 100644 --- a/src/SquidStd.Network/Client/SquidStdTcpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdTcpClient.cs @@ -288,34 +288,6 @@ public bool ContainsMiddleware() where TMiddleware : INetMiddleware => _middlewarePipeline.ContainsMiddleware(); - /// - public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - { - await CloseAsync(CancellationToken.None); - - // Drain the receive loop before disposing the resources it relies on. - if (_receiveLoopTask is not null) - { - try - { - await _receiveLoopTask; - } - catch - { - // Loop failures are already surfaced via OnException. - } - } - - await _stream.DisposeAsync(); - _sendLock.Dispose(); - _internalCancellationTokenSource.Dispose(); - _socket.Dispose(); - } - /// /// Returns a snapshot of recent received bytes from the circular history buffer. /// @@ -602,4 +574,32 @@ private void ReleasePendingBuffer() _pendingBuffer = null; _pendingLength = 0; } + + /// + public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + { + await CloseAsync(CancellationToken.None); + + // Drain the receive loop before disposing the resources it relies on. + if (_receiveLoopTask is not null) + { + try + { + await _receiveLoopTask; + } + catch + { + // Loop failures are already surfaced via OnException. + } + } + + await _stream.DisposeAsync(); + _sendLock.Dispose(); + _internalCancellationTokenSource.Dispose(); + _socket.Dispose(); + } } diff --git a/src/SquidStd.Network/Client/SquidStdUdpClient.cs b/src/SquidStd.Network/Client/SquidStdUdpClient.cs index 65bcf7d6..bbd52973 100644 --- a/src/SquidStd.Network/Client/SquidStdUdpClient.cs +++ b/src/SquidStd.Network/Client/SquidStdUdpClient.cs @@ -10,7 +10,7 @@ namespace SquidStd.Network.Client; /// Connectionless UDP client that binds a local socket and surfaces inbound datagrams through an /// async receive loop. Datagrams can be sent to any endpoint with , or to /// an optional default remote endpoint via . Mirrors the lifecycle surface of -/// (session id, connect/disconnect/data/exception events, and +/// (session id, connect/disconnect/data/exception events, and /// ). Supports Start once; recreate the instance to listen again. ///
public sealed class SquidStdUdpClient : INetworkConnection, IAsyncDisposable, IDisposable @@ -91,31 +91,33 @@ public EndPoint? LocalEndPoint /// public SquidStdUdpClient(IPEndPoint? localEndPoint = null, IPEndPoint? defaultRemoteEndPoint = null) { - _udpClient = new UdpClient(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); + _udpClient = new(localEndPoint ?? new IPEndPoint(IPAddress.Any, 0)); _defaultRemoteEndPoint = defaultRemoteEndPoint; SessionId = Interlocked.Increment(ref _sessionIdSequence); } /// - /// Starts the receive loop and raises . + /// Closes the client and raises once. /// - public Task StartAsync(CancellationToken cancellationToken) + public async Task CloseAsync(CancellationToken cancellationToken = default) { - if (Interlocked.Exchange(ref _started, 1) != 0) + if (Interlocked.Exchange(ref _closed, 1) != 0) { - return Task.CompletedTask; + return; } - if (cancellationToken.CanBeCanceled) + try { - _externalCancellationTokenRegistration = - cancellationToken.Register(() => _ = CloseAsync(CancellationToken.None)); + await _internalCancellationTokenSource.CancelAsync().WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Once close has started, still tear down the socket below. } - RaiseConnected(); - _receiveLoopTask = Task.Run(ReceiveLoopAsync, CancellationToken.None); - - return Task.CompletedTask; + _udpClient.Close(); + _externalCancellationTokenRegistration.Dispose(); + RaiseDisconnected(); } /// @@ -157,27 +159,51 @@ public async Task SendToAsync(ReadOnlyMemory payload, IPEndPoint endPoint, } /// - /// Closes the client and raises once. + /// Starts the receive loop and raises . /// - public async Task CloseAsync(CancellationToken cancellationToken = default) + public Task StartAsync(CancellationToken cancellationToken) { - if (Interlocked.Exchange(ref _closed, 1) != 0) + if (Interlocked.Exchange(ref _started, 1) != 0) { - return; + return Task.CompletedTask; } - try - { - await _internalCancellationTokenSource.CancelAsync().WaitAsync(cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + if (cancellationToken.CanBeCanceled) { - // Once close has started, still tear down the socket below. + _externalCancellationTokenRegistration = + cancellationToken.Register(() => _ = CloseAsync(CancellationToken.None)); } - _udpClient.Close(); - _externalCancellationTokenRegistration.Dispose(); - RaiseDisconnected(); + RaiseConnected(); + _receiveLoopTask = Task.Run(ReceiveLoopAsync, CancellationToken.None); + + return Task.CompletedTask; + } + + private void RaiseConnected() + { + _logger.Information( + "UDP client started. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", + SessionId, + LocalEndPoint + ); + OnConnected?.Invoke(this, new(this)); + } + + private void RaiseDisconnected() + { + _logger.Information( + "UDP client closed. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", + SessionId, + LocalEndPoint + ); + OnDisconnected?.Invoke(this, new(this)); + } + + private void RaiseException(Exception exception) + { + _logger.Error(exception, "UDP client exception. SessionId={SessionId}", SessionId); + OnException?.Invoke(this, new(exception, this)); } private async Task ReceiveLoopAsync() @@ -215,32 +241,6 @@ private async Task ReceiveLoopAsync() } } - private void RaiseConnected() - { - _logger.Information( - "UDP client started. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", - SessionId, - LocalEndPoint - ); - OnConnected?.Invoke(this, new(this)); - } - - private void RaiseDisconnected() - { - _logger.Information( - "UDP client closed. SessionId={SessionId}, LocalEndPoint={LocalEndPoint}", - SessionId, - LocalEndPoint - ); - OnDisconnected?.Invoke(this, new(this)); - } - - private void RaiseException(Exception exception) - { - _logger.Error(exception, "UDP client exception. SessionId={SessionId}", SessionId); - OnException?.Invoke(this, new(exception, this)); - } - /// public void Dispose() // Sync-over-async: best effort. Prefer DisposeAsync. => DisposeAsync().AsTask().GetAwaiter().GetResult(); diff --git a/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs new file mode 100644 index 00000000..2cd200f2 --- /dev/null +++ b/src/SquidStd.Network/Data/Events/SquidStdUdpDatagramReceivedEventArgs.cs @@ -0,0 +1,21 @@ +using System.Net; + +namespace SquidStd.Network.Data.Events; + +/// +/// Event payload for a datagram received by the UDP server, carrying the sender endpoint. +/// +public sealed class SquidStdUdpDatagramReceivedEventArgs : EventArgs +{ + /// Endpoint that sent the datagram. + public IPEndPoint RemoteEndPoint { get; } + + /// Received datagram payload. + public ReadOnlyMemory Data { get; } + + public SquidStdUdpDatagramReceivedEventArgs(IPEndPoint remoteEndPoint, ReadOnlyMemory data) + { + RemoteEndPoint = remoteEndPoint; + Data = data; + } +} diff --git a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs index 0e638ac2..cc41e88c 100644 --- a/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs +++ b/src/SquidStd.Network/Interfaces/Sessions/ISessionManager.cs @@ -15,11 +15,14 @@ public interface ISessionManager /// Snapshot of all active sessions. IReadOnlyCollection> Sessions { get; } - /// Looks up a session by its id. - bool TryGetSession(long sessionId, out Session? session); + /// Raised when a new session is created. + event EventHandler>? OnSessionCreated; - /// Sends a payload to a single session. No-op when the id is unknown. - Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default); + /// Raised when a session is removed. + event EventHandler>? OnSessionRemoved; + + /// Raised when a session receives data. + event EventHandler>? OnSessionData; /// Sends a payload to every active session, isolating per-session failures. Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default); @@ -27,12 +30,9 @@ public interface ISessionManager /// Closes a session's connection. No-op when the id is unknown. Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default); - /// Raised when a new session is created. - event EventHandler>? OnSessionCreated; - - /// Raised when a session is removed. - event EventHandler>? OnSessionRemoved; + /// Sends a payload to a single session. No-op when the id is unknown. + Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default); - /// Raised when a session receives data. - event EventHandler>? OnSessionData; + /// Looks up a session by its id. + bool TryGetSession(long sessionId, out Session? session); } diff --git a/src/SquidStd.Network/README.md b/src/SquidStd.Network/README.md new file mode 100644 index 00000000..436c4062 --- /dev/null +++ b/src/SquidStd.Network/README.md @@ -0,0 +1,56 @@ +

+ SquidStd +

+ +

SquidStd.Network

+ +

+ NuGet + Downloads + license +

+ +Networking primitives for SquidStd: TCP and UDP servers and clients with per-connection sessions, a +pluggable framing + middleware pipeline, span-based binary readers/writers, and a circular buffer — +designed for low-allocation, high-throughput byte processing. + +## Install + +```bash +dotnet add package SquidStd.Network +``` + +## Features + +- TCP server/client (`SquidTcpServer`, `SquidStdTcpClient`) with optional TLS. +- UDP server/client (`SquidStdUdpServer`, `SquidStdUdpClient`). +- Session management (`ISessionManager`) with typed per-connection state. +- Composable framing (`INetFramer`) and middleware pipeline (`INetMiddleware`). +- Zero-copy binary I/O via `SpanReader` / `SpanWriter` and a reusable `CircularBuffer`. + +## Usage + +```csharp +using System.Net; +using SquidStd.Network.Server; + +await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Any, 9000)); +await server.StartAsync(CancellationToken.None); +// ... server.IsRunning, server.Port ... +await server.StopAsync(CancellationToken.None); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `INetworkServer` | TCP/UDP server contract (`StartAsync`/`StopAsync`). | +| `INetworkConnection` | A single client connection. | +| `ISessionManager` | Tracks sessions and their typed state. | +| `INetFramer` | Splits the byte stream into messages. | +| `INetMiddleware` | Pipeline stage over inbound/outbound data. | +| `SpanReader` / `SpanWriter` | Allocation-free binary read/write. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Network/Server/SquidStdUdpServer.cs b/src/SquidStd.Network/Server/SquidStdUdpServer.cs index 06b6177c..f3868104 100644 --- a/src/SquidStd.Network/Server/SquidStdUdpServer.cs +++ b/src/SquidStd.Network/Server/SquidStdUdpServer.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using Serilog; @@ -22,6 +23,7 @@ public sealed class SquidStdUdpServer : INetworkServer, IAsyncDisposable, IDispo private readonly ILogger _logger = Log.ForContext(); private readonly List _receiveLoops = []; private readonly Lock _sync = new(); + private readonly ConcurrentDictionary _endpointListeners = new(); private CancellationTokenSource? _cancellationTokenSource; private int _started; @@ -78,13 +80,19 @@ public int ListenerCount } } + /// + /// Raised for every datagram received, carrying the sender endpoint. Always raised, regardless of + /// . + /// + public event EventHandler? OnDatagramReceived; + /// /// Raised when receive loops throw an unexpected exception. /// public event EventHandler? OnException; /// - /// Initializes a UDP server bound to the given endpoint on every . + /// Initializes a UDP server bound to the given endpoint on every StartAsync. /// /// Endpoint supplying the port (and address when not binding all interfaces). /// @@ -99,14 +107,6 @@ public SquidStdUdpServer(IPEndPoint endPoint, bool bindAllInterfaces = true) _bindAllInterfaces = bindAllInterfaces; } - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); - /// /// Starts listening, binding sockets and launching a receive loop per socket. Recreates the /// sockets on every call, so Stop/Start cycles are supported. @@ -236,6 +236,9 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel try { var result = await listener.ReceiveAsync(cancellationToken); + _endpointListeners[result.RemoteEndPoint] = listener; + OnDatagramReceived?.Invoke(this, new(result.RemoteEndPoint, result.Buffer)); + var response = OnDatagram is null ? result.Buffer : OnDatagram(result.Buffer, result.RemoteEndPoint); @@ -265,6 +268,38 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel } } + /// + /// Sends a datagram to a specific endpoint, using the listener that last received from it + /// (falling back to the first listener). No-op when no listener is available. + /// + public async Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endPoint); + + if (!_endpointListeners.TryGetValue(endPoint, out var listener)) + { + lock (_sync) + { + listener = _listeners.FirstOrDefault(); + } + } + + if (listener is null) + { + return; + } + + try + { + await listener.SendAsync(payload, endPoint, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "UDP SendToAsync failed for {EndPoint}", endPoint); + OnException?.Invoke(this, new(ex)); + } + } + private IEnumerable ResolveBindEndPoints() { if (!_bindAllInterfaces) @@ -272,6 +307,18 @@ private IEnumerable ResolveBindEndPoints() return [_endPoint]; } - return [.. NetworkUtils.GetListeningAddresses(_endPoint).Select(address => new IPEndPoint(address.Address, _endPoint.Port))]; + return + [ + .. NetworkUtils.GetListeningAddresses(_endPoint) + .Select(address => new IPEndPoint(address.Address, _endPoint.Port)) + ]; } + + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Server/SquidTcpServer.cs b/src/SquidStd.Network/Server/SquidTcpServer.cs index 4d6b59f1..5456abcc 100644 --- a/src/SquidStd.Network/Server/SquidTcpServer.cs +++ b/src/SquidStd.Network/Server/SquidTcpServer.cs @@ -74,7 +74,7 @@ public sealed class SquidTcpServer : INetworkServer, IAsyncDisposable, IDisposab /// /// Initializes a TCP server bound to the given endpoint. /// - /// Endpoint to bind on every . + /// Endpoint to bind on every StartAsync. /// /// Optional framer template. The same instance is shared by all accepted clients, /// so implementations must be stateless or thread-safe. @@ -109,14 +109,6 @@ public SquidTcpServer AddMiddleware(INetMiddleware middleware) return this; } - /// - public void Dispose() - => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// - public async ValueTask DisposeAsync() - => await StopAsync(CancellationToken.None); - /// /// Starts accepting clients. Recreates the listening socket on every call, /// so Stop/Start cycles are supported. @@ -312,4 +304,12 @@ private void WireClientEvents(SquidStdTcpClient client) OnClientDisconnect?.Invoke(this, args); }; } + + /// + public void Dispose() + => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public async ValueTask DisposeAsync() + => await StopAsync(CancellationToken.None); } diff --git a/src/SquidStd.Network/Sessions/Session.cs b/src/SquidStd.Network/Sessions/Session.cs index d2cb224a..6ff1a269 100644 --- a/src/SquidStd.Network/Sessions/Session.cs +++ b/src/SquidStd.Network/Sessions/Session.cs @@ -9,13 +9,11 @@ namespace SquidStd.Network.Sessions; /// Application-defined per-connection state. public sealed class Session { - private readonly INetworkConnection _connection; - /// Unique connection identifier assigned by the transport. public long SessionId { get; } /// Underlying transport connection. - public INetworkConnection Connection => _connection; + public INetworkConnection Connection { get; } /// Application-defined state for this session. public TState State { get; } @@ -24,26 +22,26 @@ public sealed class Session public DateTimeOffset CreatedAtUtc { get; } /// Remote endpoint of the connection, when available. - public EndPoint? RemoteEndPoint => _connection.RemoteEndPoint; + public EndPoint? RemoteEndPoint => Connection.RemoteEndPoint; /// Whether the underlying connection is still open. - public bool IsConnected => _connection.IsConnected; + public bool IsConnected => Connection.IsConnected; public Session(long sessionId, INetworkConnection connection, TState state, DateTimeOffset createdAtUtc) { ArgumentNullException.ThrowIfNull(connection); - _connection = connection; + Connection = connection; SessionId = sessionId; State = state; CreatedAtUtc = createdAtUtc; } - /// Sends a payload over the underlying connection. - public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => _connection.SendAsync(payload, cancellationToken); - /// Closes the underlying connection. public Task CloseAsync(CancellationToken cancellationToken = default) - => _connection.CloseAsync(cancellationToken); + => Connection.CloseAsync(cancellationToken); + + /// Sends a payload over the underlying connection. + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => Connection.SendAsync(payload, cancellationToken); } diff --git a/src/SquidStd.Network/Sessions/SessionManager.cs b/src/SquidStd.Network/Sessions/SessionManager.cs index e2daf45c..86a448e1 100644 --- a/src/SquidStd.Network/Sessions/SessionManager.cs +++ b/src/SquidStd.Network/Sessions/SessionManager.cs @@ -48,16 +48,6 @@ public SessionManager(SquidTcpServer server, Func st _server.OnDataReceived += HandleServerDataReceived; } - /// - public bool TryGetSession(long sessionId, out Session? session) - => _sessions.TryGetValue(sessionId, out session); - - /// - public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) - => _sessions.TryGetValue(sessionId, out var session) - ? session.SendAsync(payload, cancellationToken) - : Task.CompletedTask; - /// public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) { @@ -78,6 +68,16 @@ public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken ? session.CloseAsync(cancellationToken) : Task.CompletedTask; + /// + public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => _sessions.TryGetValue(sessionId, out var session) + ? session.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// + public bool TryGetSession(long sessionId, out Session? session) + => _sessions.TryGetValue(sessionId, out session); + internal void HandleConnected(INetworkConnection connection) { var session = new Session( @@ -113,18 +113,6 @@ internal void HandleDisconnected(INetworkConnection connection) } } - private async Task SendSafelyAsync(Session session, ReadOnlyMemory payload, CancellationToken cancellationToken) - { - try - { - await session.SendAsync(payload, cancellationToken); - } - catch (Exception ex) - { - _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); - } - } - private void HandleServerClientConnect(object? sender, SquidStdTcpClientEventArgs e) => HandleConnected(e.Client); @@ -146,6 +134,18 @@ private void RaiseSessionCreated(Session session) } } + private void RaiseSessionData(Session session, ReadOnlyMemory data) + { + try + { + OnSessionData?.Invoke(this, new(session, data)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + } + } + private void RaiseSessionRemoved(Session session) { try @@ -158,15 +158,19 @@ private void RaiseSessionRemoved(Session session) } } - private void RaiseSessionData(Session session, ReadOnlyMemory data) + private async Task SendSafelyAsync( + Session session, + ReadOnlyMemory payload, + CancellationToken cancellationToken + ) { try { - OnSessionData?.Invoke(this, new(session, data)); + await session.SendAsync(payload, cancellationToken); } catch (Exception ex) { - _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); } } diff --git a/src/SquidStd.Network/Sessions/UdpSessionConnection.cs b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs new file mode 100644 index 00000000..35755e24 --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionConnection.cs @@ -0,0 +1,42 @@ +using System.Net; +using SquidStd.Network.Interfaces.Client; +using SquidStd.Network.Server; + +namespace SquidStd.Network.Sessions; + +/// +/// Virtual per-endpoint connection backing a UDP session. Sends route to the server's +/// ; closing removes the session via a callback. +/// +internal sealed class UdpSessionConnection : INetworkConnection +{ + private readonly SquidStdUdpServer _server; + private readonly IPEndPoint _remoteEndPoint; + private readonly Action _onClose; + private int _closed; + + public long SessionId { get; } + public EndPoint? RemoteEndPoint => _remoteEndPoint; + public bool IsConnected => Volatile.Read(ref _closed) == 0; + + public UdpSessionConnection(SquidStdUdpServer server, IPEndPoint remoteEndPoint, long sessionId, Action onClose) + { + _server = server; + _remoteEndPoint = remoteEndPoint; + SessionId = sessionId; + _onClose = onClose; + } + + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + => _server.SendToAsync(_remoteEndPoint, payload, cancellationToken); + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _closed, 1) == 0) + { + _onClose(); + } + + return Task.CompletedTask; + } +} diff --git a/src/SquidStd.Network/Sessions/UdpSessionEntry.cs b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs new file mode 100644 index 00000000..3d4d3903 --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionEntry.cs @@ -0,0 +1,17 @@ +namespace SquidStd.Network.Sessions; + +/// +/// Internal registry entry pairing a UDP session with its last-activity timestamp. +/// +/// Application-defined per-connection state. +internal sealed class UdpSessionEntry +{ + public Session Session { get; } + public DateTimeOffset LastActivityUtc { get; set; } + + public UdpSessionEntry(Session session, DateTimeOffset lastActivityUtc) + { + Session = session; + LastActivityUtc = lastActivityUtc; + } +} diff --git a/src/SquidStd.Network/Sessions/UdpSessionManager.cs b/src/SquidStd.Network/Sessions/UdpSessionManager.cs new file mode 100644 index 00000000..4ccd022b --- /dev/null +++ b/src/SquidStd.Network/Sessions/UdpSessionManager.cs @@ -0,0 +1,282 @@ +using System.Collections.Concurrent; +using System.Net; +using Serilog; +using SquidStd.Network.Data.Events; +using SquidStd.Network.Interfaces.Client; +using SquidStd.Network.Interfaces.Sessions; +using SquidStd.Network.Server; + +namespace SquidStd.Network.Sessions; + +/// +/// Tracks per-endpoint UDP sessions over a . Sessions are created on +/// the first datagram from an endpoint and removed by idle-timeout sweep or explicit disconnect. +/// +/// Application-defined per-connection state. +public sealed class UdpSessionManager : ISessionManager, IDisposable +{ + private readonly ILogger _logger = Log.ForContext>(); + private readonly ConcurrentDictionary> _byEndpoint = new(); + private readonly ConcurrentDictionary _byId = new(); + private readonly Lock _createLock = new(); + private readonly SquidStdUdpServer _server; + private readonly Func _stateFactory; + private readonly TimeSpan _idleTimeout; + private readonly TimeProvider _timeProvider; + private readonly ITimer _sweepTimer; + private long _sessionIdSequence; + private int _disposed; + + /// + public int Count => _byEndpoint.Count; + + /// + public IReadOnlyCollection> Sessions + => _byEndpoint.Values.Select(entry => entry.Session).ToArray(); + + /// Idle period after which an inactive session is removed. + public TimeSpan IdleTimeout => _idleTimeout; + + /// + public event EventHandler>? OnSessionCreated; + + /// + public event EventHandler>? OnSessionRemoved; + + /// + public event EventHandler>? OnSessionData; + + public UdpSessionManager( + SquidStdUdpServer server, + Func stateFactory, + TimeSpan? idleTimeout = null, + TimeSpan? sweepInterval = null, + TimeProvider? timeProvider = null + ) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(stateFactory); + + _server = server; + _stateFactory = stateFactory; + _idleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30); + _timeProvider = timeProvider ?? TimeProvider.System; + + // Suppress the server's default echo while sessions are managed here. + _server.OnDatagram = static (_, _) => ReadOnlyMemory.Empty; + _server.OnDatagramReceived += HandleServerDatagram; + + var interval = sweepInterval ?? TimeSpan.FromSeconds(10); + _sweepTimer = _timeProvider.CreateTimer(_ => SafeSweep(), null, interval, interval); + } + + /// + public bool TryGetSession(long sessionId, out Session? session) + { + if (_byId.TryGetValue(sessionId, out var endPoint) && _byEndpoint.TryGetValue(endPoint, out var entry)) + { + session = entry.Session; + + return true; + } + + session = null; + + return false; + } + + /// Looks up a session by remote endpoint. + public bool TryGetSession(IPEndPoint endPoint, out Session? session) + { + if (_byEndpoint.TryGetValue(endPoint, out var entry)) + { + session = entry.Session; + + return true; + } + + session = null; + + return false; + } + + /// + public Task SendAsync(long sessionId, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => TryGetSession(sessionId, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// Sends a payload to the session for the given endpoint. No-op when unknown. + public Task SendToAsync(IPEndPoint endPoint, ReadOnlyMemory payload, CancellationToken cancellationToken = default) + => TryGetSession(endPoint, out var session) + ? session!.SendAsync(payload, cancellationToken) + : Task.CompletedTask; + + /// + public async Task BroadcastAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + var snapshot = _byEndpoint.Values.ToArray(); + var tasks = new List(snapshot.Length); + + foreach (var entry in snapshot) + { + tasks.Add(SendSafelyAsync(entry.Session, payload, cancellationToken)); + } + + await Task.WhenAll(tasks); + } + + /// + public Task DisconnectAsync(long sessionId, CancellationToken cancellationToken = default) + => TryGetSession(sessionId, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; + + /// Closes the session for the given endpoint. No-op when unknown. + public Task DisconnectAsync(IPEndPoint endPoint, CancellationToken cancellationToken = default) + => TryGetSession(endPoint, out var session) + ? session!.CloseAsync(cancellationToken) + : Task.CompletedTask; + + internal void HandleDatagram(IPEndPoint remoteEndPoint, ReadOnlyMemory data) + { + var entry = GetOrCreate(remoteEndPoint, out var created); + entry.LastActivityUtc = _timeProvider.GetUtcNow(); + + if (created) + { + RaiseSessionCreated(entry.Session); + } + + RaiseSessionData(entry.Session, data); + } + + internal void SweepExpiredSessions() + { + var now = _timeProvider.GetUtcNow(); + + foreach (var kvp in _byEndpoint.ToArray()) + { + if (now - kvp.Value.LastActivityUtc > _idleTimeout) + { + RemoveSession(kvp.Key); + } + } + } + + private UdpSessionEntry GetOrCreate(IPEndPoint remoteEndPoint, out bool created) + { + if (_byEndpoint.TryGetValue(remoteEndPoint, out var existing)) + { + created = false; + + return existing; + } + + lock (_createLock) + { + if (_byEndpoint.TryGetValue(remoteEndPoint, out existing)) + { + created = false; + + return existing; + } + + var sessionId = Interlocked.Increment(ref _sessionIdSequence); + var connection = new UdpSessionConnection(_server, remoteEndPoint, sessionId, () => RemoveSession(remoteEndPoint)); + var session = new Session(sessionId, connection, _stateFactory(connection), _timeProvider.GetUtcNow()); + var entry = new UdpSessionEntry(session, _timeProvider.GetUtcNow()); + + _byEndpoint[remoteEndPoint] = entry; + _byId[sessionId] = remoteEndPoint; + created = true; + + return entry; + } + } + + private void RemoveSession(IPEndPoint endPoint) + { + if (_byEndpoint.TryRemove(endPoint, out var entry)) + { + _byId.TryRemove(entry.Session.SessionId, out _); + RaiseSessionRemoved(entry.Session); + } + } + + private void SafeSweep() + { + try + { + SweepExpiredSessions(); + } + catch (Exception ex) + { + _logger.Error(ex, "UDP session sweep failed"); + } + } + + private async Task SendSafelyAsync(Session session, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + try + { + await session.SendAsync(payload, cancellationToken); + } + catch (Exception ex) + { + _logger.Warning(ex, "Broadcast send failed for session {SessionId}", session.SessionId); + } + } + + private void HandleServerDatagram(object? sender, SquidStdUdpDatagramReceivedEventArgs e) + => HandleDatagram(e.RemoteEndPoint, e.Data); + + private void RaiseSessionCreated(Session session) + { + try + { + OnSessionCreated?.Invoke(this, new(session)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionCreated handler failed for session {SessionId}", session.SessionId); + } + } + + private void RaiseSessionRemoved(Session session) + { + try + { + OnSessionRemoved?.Invoke(this, new(session)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionRemoved handler failed for session {SessionId}", session.SessionId); + } + } + + private void RaiseSessionData(Session session, ReadOnlyMemory data) + { + try + { + OnSessionData?.Invoke(this, new(session, data)); + } + catch (Exception ex) + { + _logger.Error(ex, "OnSessionData handler failed for session {SessionId}", session.SessionId); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _server.OnDatagramReceived -= HandleServerDatagram; + _sweepTimer.Dispose(); + _byEndpoint.Clear(); + _byId.Clear(); + } +} diff --git a/src/SquidStd.Network/Spans/SpanReader.cs b/src/SquidStd.Network/Spans/SpanReader.cs index dd89efb1..3168a69e 100644 --- a/src/SquidStd.Network/Spans/SpanReader.cs +++ b/src/SquidStd.Network/Spans/SpanReader.cs @@ -21,13 +21,6 @@ public SpanReader(ReadOnlySpan span) Length = span.Length; } - public void Dispose() - { - _buffer = default; - Position = 0; - Length = 0; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public int Read(scoped Span bytes) { @@ -398,4 +391,11 @@ private static int IndexOfTerminator(ReadOnlySpan span, int terminatorWidt [DoesNotReturn] private static void ThrowInsufficientData() => throw new InvalidOperationException("Insufficient data in buffer."); + + public void Dispose() + { + _buffer = default; + Position = 0; + Length = 0; + } } diff --git a/src/SquidStd.Network/Spans/SpanWriter.cs b/src/SquidStd.Network/Spans/SpanWriter.cs index 6f9bf8e2..1672a363 100644 --- a/src/SquidStd.Network/Spans/SpanWriter.cs +++ b/src/SquidStd.Network/Spans/SpanWriter.cs @@ -94,18 +94,6 @@ public void Clear(int count) Position += count; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Dispose() - { - var toReturn = _arrayToReturnToPool; - this = default; - - if (toReturn is not null) - { - ArrayPool.Shared.Return(toReturn); - } - } - public void EnsureCapacity(int capacity) { if (capacity > _buffer.Length) @@ -442,4 +430,16 @@ private void GrowIfNeeded(int count) Grow(count); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + var toReturn = _arrayToReturnToPool; + this = default; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } } diff --git a/src/SquidStd.Network/SquidStd.Network.csproj b/src/SquidStd.Network/SquidStd.Network.csproj index dc6e8585..80883ec6 100644 --- a/src/SquidStd.Network/SquidStd.Network.csproj +++ b/src/SquidStd.Network/SquidStd.Network.csproj @@ -1,17 +1,18 @@  + true net10.0 enable enable - + - + diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs index 56986223..93fed50e 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContext.cs @@ -2,10 +2,8 @@ namespace SquidStd.Plugin.Abstractions.Data; public class PluginContext { - public Dictionary Data { get; } = new Dictionary(); + public Dictionary Data { get; } = new(); public TData GetData(string key) - { - return (TData)Data[key]; - } + => (TData)Data[key]; } diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs index 7b575827..d761f5a0 100644 --- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs +++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs @@ -4,7 +4,7 @@ namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins; /// -/// Implemented by trusted .NET plugins loaded by Moongate during server startup. +/// Implemented by trusted .NET plugins loaded by Moongate during server startup. /// public interface ISquidStdPlugin { @@ -12,8 +12,8 @@ public interface ISquidStdPlugin PluginMetadata Metadata { get; } /// - /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. - /// Called during container configuration before global server YAML config is loaded. + /// Registers the plugin's services, handlers, config sections, Lua modules, and other integrations. + /// Called during container configuration before global server YAML config is loaded. /// /// The DryIoc container being configured. /// The plugin-specific boot context. diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md new file mode 100644 index 00000000..838afec0 --- /dev/null +++ b/src/SquidStd.Plugin.Abstractions/README.md @@ -0,0 +1,63 @@ +

+ SquidStd +

+ +

SquidStd.Plugin.Abstractions

+ +

+ NuGet + Downloads + license +

+ +Contracts for building SquidStd plugins. A plugin declares its identity through `PluginMetadata` and +registers its services into the host's DryIoc container via `Configure`, receiving a `PluginContext` +with shared boot data. + +## Install + +```bash +dotnet add package SquidStd.Plugin.Abstractions +``` + +## Features + +- `ISquidStdPlugin` — the plugin entry point: `Metadata` + `Configure(IContainer, PluginContext)`. +- `PluginMetadata` — id, name, `Version`, author, optional description, and dependency declarations. +- `PluginContext` — a typed bag of boot data shared with the plugin (`GetData(key)`). + +## Usage + +```csharp +using DryIoc; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +public sealed class MyPlugin : ISquidStdPlugin +{ + public PluginMetadata Metadata { get; } = new() + { + Id = "com.example.myplugin", + Name = "My Plugin", + Version = new Version(1, 0, 0), + Author = "me" + }; + + public void Configure(IContainer container, PluginContext context) + { + // register the plugin's services / config sections here + } +} +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `ISquidStdPlugin` | Plugin entry point (`Metadata`, `Configure`). | +| `PluginMetadata` | Plugin identity and dependency declarations. | +| `PluginContext` | Shared boot data passed to the plugin. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj index 0b7093d9..d214510d 100644 --- a/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj +++ b/src/SquidStd.Plugin.Abstractions/SquidStd.Plugin.Abstractions.csproj @@ -1,13 +1,14 @@  + true net10.0 enable enable - + diff --git a/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs new file mode 100644 index 00000000..3ef67b55 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/LuaFieldAttribute.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Scripting.Lua.Attributes; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class LuaFieldAttribute : Attribute +{ + public LuaFieldAttribute(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + } + + public string Name { get; } +} diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs new file mode 100644 index 00000000..5f20e670 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptFunctionAttribute.cs @@ -0,0 +1,29 @@ +namespace SquidStd.Scripting.Lua.Attributes.Scripts; + +/// +/// Attribute that marks a method as a script function exposed to scripting languages. +/// +[AttributeUsage(AttributeTargets.Method)] +public class ScriptFunctionAttribute : Attribute +{ + /// + /// Gets the optional name override for the script function. + /// + public string? FunctionName { get; } + + /// + /// Gets the optional help text describing the function's purpose. + /// + public string? HelpText { get; } + + /// + /// Initializes a new instance of the ScriptFunctionAttribute class. + /// + /// The optional name override for the script function. + /// The optional help text describing the function's purpose. + public ScriptFunctionAttribute(string? functionName = null, string? helpText = null) + { + FunctionName = functionName; + HelpText = helpText; + } +} diff --git a/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs new file mode 100644 index 00000000..0e54b821 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Attributes/Scripts/ScriptModuleAttribute.cs @@ -0,0 +1,27 @@ +namespace SquidStd.Scripting.Lua.Attributes.Scripts; + +/// +/// Attribute that marks a class as a script module exposed to scripting languages. +/// +[AttributeUsage(AttributeTargets.Class)] +public class ScriptModuleAttribute : Attribute +{ + /// Gets the name under which the module will be accessible in Lua. + public string Name { get; } + + /// Gets the optional help text describing the module's purpose. + public string? HelpText { get; } + + /// + /// Initializes a new instance of the ScriptModuleAttribute class. + /// + /// The name under which the module will be accessible in Lua. + /// The optional help text describing the module's purpose. + public ScriptModuleAttribute(string name, string? helpText = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + HelpText = helpText; + } +} diff --git a/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs new file mode 100644 index 00000000..485076cb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Context/SquidStdScriptJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using SquidStd.Scripting.Lua.Data.Luarc; + +namespace SquidStd.Scripting.Lua.Context; + +[JsonSerializable(typeof(LuarcConfig))] +[JsonSerializable(typeof(LuarcRuntimeConfig))] +[JsonSerializable(typeof(LuarcWorkspaceConfig))] +[JsonSerializable(typeof(LuarcDiagnosticsConfig))] +[JsonSerializable(typeof(LuarcCompletionConfig))] +[JsonSerializable(typeof(LuarcFormatConfig))] +/// +/// JSON serialization context for Lua scripting configuration types. +/// +public partial class SquidStdScriptJsonContext : JsonSerializerContext +{ +} diff --git a/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs new file mode 100644 index 00000000..5a3821b0 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Config/LuaEngineConfig.cs @@ -0,0 +1,18 @@ +namespace SquidStd.Scripting.Lua.Data.Config; + +public sealed record LuaEngineConfig +{ + public string LuarcDirectory { get; } + public string ScriptsDirectory { get; } + public string EngineVersion { get; } + + public string EngineName { get; } + + public LuaEngineConfig(string luarcDirectory, string scriptsDirectory, string engineName, string engineVersion) + { + EngineName = engineName; + LuarcDirectory = luarcDirectory; + ScriptsDirectory = scriptsDirectory; + EngineVersion = engineVersion; + } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs new file mode 100644 index 00000000..531d682d --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptModuleData.cs @@ -0,0 +1,23 @@ +namespace SquidStd.Scripting.Lua.Data.Internal; + +/// +/// Record containing data about a script module for internal processing. +/// +public sealed record ScriptModuleData +{ + /// + /// The .NET type of the script module. + /// + public Type ModuleType { get; } + + /// + /// Initializes a new instance of the ScriptModuleData record. + /// + /// The .NET type of the script module. + public ScriptModuleData(Type moduleType) + { + ArgumentNullException.ThrowIfNull(moduleType); + + ModuleType = moduleType; + } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs new file mode 100644 index 00000000..6f518bbb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Internal/ScriptUserData.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Scripting.Lua.Data.Internal; + +/// +/// Represents user data for scripts. +/// +public class ScriptUserData +{ + /// + /// Gets or sets the user type. + /// + public Type UserType { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs new file mode 100644 index 00000000..b7086634 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcCompletionConfig.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Completion configuration for Lua Language Server +/// +public class LuarcCompletionConfig +{ + /// + /// Gets or sets whether completion is enabled. + /// + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + /// + /// Gets or sets the call snippet setting. + /// + [JsonPropertyName("callSnippet")] + public string CallSnippet { get; set; } = "Replace"; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs new file mode 100644 index 00000000..8726bd11 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcConfig.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Configuration class for Lua Language Server (.luarc.json file) +/// +public class LuarcConfig +{ + [JsonPropertyName("$schema")] + + /// + /// + /// + public string Schema { get; set; } = "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json"; + + /// + /// Gets or sets the runtime configuration. + /// + [JsonPropertyName("runtime")] + public LuarcRuntimeConfig Runtime { get; set; } = new(); + + /// + /// Gets or sets the workspace configuration. + /// + [JsonPropertyName("workspace")] + public LuarcWorkspaceConfig Workspace { get; set; } = new(); + + /// + /// Gets or sets the diagnostics configuration. + /// + [JsonPropertyName("diagnostics")] + public LuarcDiagnosticsConfig Diagnostics { get; set; } = new(); + + /// + /// Gets or sets the completion configuration. + /// + [JsonPropertyName("completion")] + public LuarcCompletionConfig Completion { get; set; } = new(); + + /// + /// Gets or sets the format configuration. + /// + [JsonPropertyName("format")] + public LuarcFormatConfig Format { get; set; } = new(); +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs new file mode 100644 index 00000000..bb22e469 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcDiagnosticsConfig.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Diagnostics configuration for Lua Language Server +/// +public class LuarcDiagnosticsConfig +{ + [JsonPropertyName("globals")] + public string[] Globals { get; set; } = []; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs new file mode 100644 index 00000000..0828099e --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Format configuration for Lua Language Server +/// +public class LuarcFormatConfig +{ + [JsonPropertyName("enable")] + public bool Enable { get; set; } = true; + + [JsonPropertyName("defaultConfig")] + public LuarcFormatDefaultConfig DefaultConfig { get; set; } = new(); +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs new file mode 100644 index 00000000..e34ca695 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcFormatDefaultConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Default format configuration for Lua Language Server +/// +public class LuarcFormatDefaultConfig +{ + [JsonPropertyName("indent_style")] + public string IndentStyle { get; set; } = "space"; + + [JsonPropertyName("indent_size")] + public string IndentSize { get; set; } = "4"; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs new file mode 100644 index 00000000..b6ecbe4c --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcRuntimeConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Runtime configuration for Lua Language Server +/// +public class LuarcRuntimeConfig +{ + [JsonPropertyName("version")] + public string Version { get; set; } = "Lua 5.4"; + + [JsonPropertyName("path")] + public string[] Path { get; set; } = []; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs new file mode 100644 index 00000000..72ba1948 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Luarc/LuarcWorkspaceConfig.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SquidStd.Scripting.Lua.Data.Luarc; + +/// +/// Workspace configuration for Lua Language Server +/// +public class LuarcWorkspaceConfig +{ + [JsonPropertyName("library")] + public string[] Library { get; set; } = []; + + [JsonPropertyName("checkThirdParty")] + public bool CheckThirdParty { get; set; } = false; +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs new file mode 100644 index 00000000..5b88190f --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptErrorInfo.cs @@ -0,0 +1,62 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Detailed information about a Lua execution error. +/// +public class ScriptErrorInfo +{ + /// + /// Gets or sets the error message. + /// + public string Message { get; set; } = ""; + + /// + /// Gets or sets the stack trace. + /// + public string? StackTrace { get; set; } + + /// + /// Gets or sets the line number. + /// + public int? LineNumber { get; set; } + + /// + /// Gets or sets the column number. + /// + public int? ColumnNumber { get; set; } + + /// + /// Gets or sets the file name. + /// + public string? FileName { get; set; } + + /// + /// Gets or sets the error type. + /// + public string? ErrorType { get; set; } + + /// + /// Gets or sets the source code. + /// + public string? SourceCode { get; set; } + + /// + /// Original source file name when a mapped source is available. + /// + public string? OriginalFileName { get; set; } + + /// + /// Original line number when a mapped source is available. + /// + public int? OriginalLineNumber { get; set; } + + /// + /// Original column number when a mapped source is available. + /// + public int? OriginalColumnNumber { get; set; } + + /// + /// Optional origin label — e.g. the Lua component name when the error came from a lifecycle hook. + /// + public string? Source { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs new file mode 100644 index 00000000..a14d7fd1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptExecutionMetrics.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Metrics about script execution performance. +/// +public class ScriptExecutionMetrics +{ + /// + /// Gets or sets the execution time in milliseconds. + /// + public long ExecutionTimeMs { get; set; } + + /// + /// Gets or sets the memory used in bytes. + /// + public long MemoryUsedBytes { get; set; } + + /// + /// Gets or sets the number of statements executed. + /// + public int StatementsExecuted { get; set; } + + /// + /// Gets or sets the number of cache hits. + /// + public int CacheHits { get; set; } + + /// + /// Gets or sets the number of cache misses. + /// + public int CacheMisses { get; set; } + + /// + /// Gets or sets the total number of scripts cached. + /// + public int TotalScriptsCached { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs new file mode 100644 index 00000000..a6e991f1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResult.cs @@ -0,0 +1,22 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Represents the result of a script execution. +/// +public class ScriptResult +{ + /// + /// Gets or sets a value indicating whether the script execution was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the message associated with the script result. + /// + public string Message { get; set; } + + /// + /// Gets or sets the data returned by the script execution. + /// + public object? Data { get; set; } +} diff --git a/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs new file mode 100644 index 00000000..147af4e1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Data/Scripts/ScriptResultBuilder.cs @@ -0,0 +1,84 @@ +namespace SquidStd.Scripting.Lua.Data.Scripts; + +/// +/// Builder class for creating ScriptResult instances. +/// +public class ScriptResultBuilder +{ + private object? _data; + private string _message = ""; + private bool _success; + + /// + /// Builds the ScriptResult instance. + /// + public ScriptResult Build() + => new() + { + Success = _success, + Message = _message, + Data = _data + }; + + /// + /// Creates a ScriptResultBuilder initialized for an error result. + /// + public static ScriptResultBuilder CreateError() + => new ScriptResultBuilder().WithSuccess(false); + + /// + /// Creates a ScriptResultBuilder initialized for a successful result. + /// + public static ScriptResultBuilder CreateSuccess() + => new ScriptResultBuilder().WithSuccess(true); + + /// + /// Sets the result as failed. + /// + public ScriptResultBuilder Failure() + { + _success = false; + + return this; + } + + /// + /// Sets the result as successful. + /// + public ScriptResultBuilder Success() + { + _success = true; + + return this; + } + + /// + /// Sets the data of the result. + /// + public ScriptResultBuilder WithData(object? data) + { + _data = data; + + return this; + } + + /// + /// Sets the message of the result. + /// + public ScriptResultBuilder WithMessage(string message) + { + _message = message; + + return this; + } + + /// + /// Sets the success status of the result. + /// + public ScriptResultBuilder WithSuccess(bool success) + { + _success = success; + + return this; + } +} diff --git a/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs new file mode 100644 index 00000000..245c9db8 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Descriptors/GenericUserDataDescriptor.cs @@ -0,0 +1,60 @@ +using MoonSharp.Interpreter; +using MoonSharp.Interpreter.Interop; + +namespace SquidStd.Scripting.Lua.Descriptors; + +/// +/// Generic UserData descriptor that adds support for string concatenation and conversion. +/// Implements the __tostring metamethod to allow Lua to convert any userdata type to strings. +/// +public class GenericUserDataDescriptor : StandardUserDataDescriptor +{ + private readonly bool _isXnaType; + + /// + /// Creates a new descriptor for a type with reflection access mode. + /// Automatically detects if the type is an XNA Framework type. + /// + /// The type to describe (can be XNA or any other .NET type). + public GenericUserDataDescriptor(Type type) + : base(type, InteropAccessMode.Reflection) + { + ArgumentNullException.ThrowIfNull(type); + _isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + } + + /// + /// Converts the object to its string representation. + /// This is used by MoonSharp when the object is converted to a string in Lua (concatenation, tostring(), etc.). + /// + /// The object to convert to string. + /// + /// For XNA types: Uses ToString() for a readable representation. + /// For other types: Uses ToString() or the type name if ToString() returns null. + /// + /// + /// In Lua, this allows: + /// + /// local vec = Vector2(10, 20) + /// print("Position: " .. vec) -- ✅ Calls AsString(vec) instead of erroring + /// local str = tostring(vec) -- ✅ Works with tostring() + /// + /// + public override string AsString(object obj) + { + if (obj == null) + { + return "null"; + } + + // Use the object's ToString() method + var str = obj.ToString(); + + return string.IsNullOrWhiteSpace(str) + ? + + // Fallback: use the type name if ToString() returns empty/null + $"{Type.Name}({{}})" + : str; + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs b/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs new file mode 100644 index 00000000..50e2b746 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/LuaTableReader.cs @@ -0,0 +1,66 @@ +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Extensions; + +public static class LuaTableReader +{ + public static bool GetBool(Table table, string key, bool defaultValue = false) + { + var value = GetValue(table, key); + + return value.Type == DataType.Boolean ? value.Boolean : defaultValue; + } + + public static TEnum GetEnum(Table table, string key, TEnum defaultValue) + where TEnum : struct, Enum + { + var value = GetValue(table, key); + + if (value.Type == DataType.String && + Enum.TryParse(value.String, true, out TEnum parsedByName)) + { + return parsedByName; + } + + if (value.Type == DataType.Number) + { + var numericValue = (int)value.Number; + + if (Enum.IsDefined(typeof(TEnum), numericValue)) + { + return (TEnum)Enum.ToObject(typeof(TEnum), numericValue); + } + } + + return defaultValue; + } + + public static float GetFloat(Table table, string key, float defaultValue = 0f) + { + var value = GetValue(table, key); + + return value.Type == DataType.Number ? (float)value.Number : defaultValue; + } + + public static int GetInt(Table table, string key, int defaultValue = 0) + { + var value = GetValue(table, key); + + return value.Type == DataType.Number ? (int)value.Number : defaultValue; + } + + public static string GetString(Table table, string key, string defaultValue = "") + { + var value = GetValue(table, key); + + return value.Type == DataType.String ? value.String : defaultValue; + } + + private static DynValue GetValue(Table table, string key) + { + ArgumentNullException.ThrowIfNull(table); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return table.Get(key); + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs new file mode 100644 index 00000000..4cf0e5f0 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/AddScriptModuleExtension.cs @@ -0,0 +1,69 @@ +using DryIoc; +using MoonSharp.Interpreter; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Scripting.Lua.Data.Internal; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Extension methods for registering Lua script modules in the dependency injection container. +/// +public static class AddScriptModuleExtension +{ + /// The dependency injection container. + extension(IContainer container) + { + /// + /// Registers a user data type with the container for Lua scripting. + /// + public IContainer RegisterLuaUserData(Type userDataType) + { + if (userDataType == null) + { + throw new ArgumentNullException(nameof(userDataType), "User data type cannot be null."); + } + + container.AddToRegisterTypedList(new ScriptUserData { UserType = userDataType }); + + return container; + } + + /// + /// Registers a user data type with the container for Lua scripting using generics. + /// + public IContainer RegisterLuaUserData() + { + UserData.RegisterType(); + + return container.RegisterLuaUserData(typeof(TUserData)); + } + + /// + /// Registers a Lua script module type with the container. + /// + /// The type of the script module to register. + /// The container for method chaining. + /// Thrown when scriptModule is null. + public IContainer RegisterScriptModule(Type scriptModule) + { + if (scriptModule == null) + { + throw new ArgumentNullException(nameof(scriptModule), "Script module type cannot be null."); + } + + container.AddToRegisterTypedList(new ScriptModuleData(scriptModule)); + + container.Register(scriptModule, Reuse.Singleton); + + return container; + } + + /// + /// Registers a Lua script module type with the container using a generic type parameter. + /// + /// The type of the script module to register. + /// The container for method chaining. + public IContainer RegisterScriptModule() where TScriptModule : class + => container.RegisterScriptModule(typeof(TScriptModule)); + } +} diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs new file mode 100644 index 00000000..7b36a728 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/TableExtensions.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Proxies; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Provides extension methods for MoonSharp Table objects to enable proxying to interfaces. +/// +public static class TableExtensions +{ + /// + /// Converts a MoonSharp Table to a proxy implementing the specified interface. + /// + public static TInterface ToProxy(this Table table) + where TInterface : class + { + var proxy = DispatchProxy.Create>(); + ((LuaProxy)(object)proxy).Table = table; + + return proxy; + } +} diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs new file mode 100644 index 00000000..d7e37dbb --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Interfaces/Events/ILuaEventBridge.cs @@ -0,0 +1,37 @@ +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Interfaces.Events; + +/// +/// Bridges named server events and internal callbacks into Lua closures. +/// +public interface ILuaEventBridge +{ + /// + /// Attaches the active MoonSharp script runtime used to invoke registered closures. + /// + /// Active Lua script runtime. + void Attach(Script script); + + /// + /// Invokes a single closure with the supplied payload. + /// + /// Lua closure to invoke. + /// Payload exposed to Lua as a table. + /// The Lua callback result. + DynValue Invoke(Closure callback, IReadOnlyDictionary payload); + + /// + /// Publishes a named event to every Lua callback registered for it. + /// + /// Stable event name, such as player.connected. + /// Payload exposed to Lua as a table. + void Publish(string eventName, IReadOnlyDictionary payload); + + /// + /// Registers a Lua callback for a named event. + /// + /// Stable event name, such as player.connected. + /// Lua closure to invoke when the event is published. + void Register(string eventName, Closure callback); +} diff --git a/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs new file mode 100644 index 00000000..4380ce33 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Interfaces/Scripts/IScriptEngineService.cs @@ -0,0 +1,181 @@ +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Scripting.Lua.Data.Scripts; + +namespace SquidStd.Scripting.Lua.Interfaces.Scripts; + +/// +/// Interface for the script engine service that manages Lua execution. +/// +public interface IScriptEngineService : ISquidStdService +{ + /// + /// Delegate for handling script file change events. + /// + /// The path to the changed file. + /// True if the file change was handled successfully, false otherwise. + delegate bool LuaFileChangedHandler(string filePath); + + /// + /// Event raised when a script file is modified. + /// + event LuaFileChangedHandler? FileChanged; + + /// + /// Event raised when a script error occurs + /// + event EventHandler? OnScriptError; + + /// + /// Fires once during StartAsync, after script modules have been registered + /// but before bootstrap scripts run. Handlers can install additional UserData types, globals, + /// or scanners that depend on the script runtime being ready. The argument is the underlying + /// MoonSharp Script, typed as so the interface stays + /// implementation-agnostic; callers cast as needed. + /// + event Action? AfterModulesRegistered; + + /// + /// Fires when a .lua file under a components/ subdirectory of the scripts + /// directory changes on disk. Carries the full file path. Used by the engine-side + /// component loader to hot-reload Lua-defined components. + /// + event Action? OnComponentFileChanged; + + /// + /// Adds a callback function that can be called from Lua. + /// + /// The name of the callback function in Lua. + /// The C# action to execute when the callback is invoked. + void AddCallback(string name, Action callback); + + /// + /// Adds a constant value accessible from Lua. + /// + /// The name of the constant in Lua. + /// The value of the constant. + void AddConstant(string name, object value); + + /// + /// Adds a script to be executed during engine initialization. + /// + /// The Lua code to execute on startup. + void AddInitScript(string script); + + /// + /// Adds a manual module function that can be called from scripts. + /// + /// The name of the module. + /// The name of the function. + /// The callback to execute when the function is called. + void AddManualModuleFunction(string moduleName, string functionName, Action callback); + + /// + /// Adds a typed manual module function that can be called from scripts. + /// + /// The input parameter type. + /// The output return type. + /// The name of the module. + /// The name of the function. + /// The callback function to execute. + void AddManualModuleFunction( + string moduleName, + string functionName, + Func callback + ); + + /// + /// Adds a .NET type as a module accessible from Lua. + /// + /// The type to register as a script module. + void AddScriptModule(Type type); + + /// + /// Adds a directory to the Lua script search paths. + /// + /// Directory path to search for scripts. + void AddSearchDirectory(string path); + + /// + /// Clears the script cache + /// + void ClearScriptCache(); + + /// + /// Executes a previously registered callback function. + /// + /// The name of the callback to execute. + /// Arguments to pass to the callback. + void ExecuteCallback(string name, params object[] args); + + /// + /// Notifies the script engine that the engine initialization is complete and ready. + /// + void ExecuteEngineReady(); + + /// + /// Executes a Lua function or expression and returns the result. + /// + /// The Lua function call or expression to execute. + /// A ScriptResult containing the execution outcome. + ScriptResult ExecuteFunction(string command); + + /// + /// Asynchronously executes a Lua function or expression and returns the result. + /// + /// The Lua function call or expression to execute. + /// Token used to cancel the operation. + /// A task containing a ScriptResult with the execution outcome. + Task ExecuteFunctionAsync(string command, CancellationToken cancellationToken = default); + + /// + /// Executes a function defined in the bootstrap script. + /// + /// + void ExecuteFunctionFromBootstrap(string name); + + /// + /// Executes a Lua script string. + /// + /// The Lua code to execute. + void ExecuteScript(string script); + + /// + /// Executes a Lua file. + /// + /// The path to the Lua file to execute. + void ExecuteScriptFile(string scriptFile); + + /// + /// Gets execution metrics for performance monitoring + /// + /// Metrics about script execution + ScriptExecutionMetrics GetExecutionMetrics(); + + /// + /// Registers a global object/value accessible from scripts. + /// + /// The name of the global in scripts. + /// The object/value to register. + void RegisterGlobal(string name, object value); + + /// + /// Registers a global function that can be called from scripts. + /// + /// The name of the global function in scripts. + /// The delegate to register as a global function. + void RegisterGlobalFunction(string name, Delegate func); + + /// + /// Converts a .NET method name to a Lua-compatible function name. + /// + /// The .NET method name to convert. + /// The Lua-compatible function name. + string ToScriptEngineFunctionName(string name); + + /// + /// Unregisters a global function or value. + /// + /// The name of the global to unregister. + /// True if the global was found and removed, false otherwise. + bool UnregisterGlobal(string name); +} diff --git a/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs new file mode 100644 index 00000000..f4571e88 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Loaders/LuaScriptLoader.cs @@ -0,0 +1,191 @@ +using MoonSharp.Interpreter; +using MoonSharp.Interpreter.Loaders; +using Serilog; +using SquidStd.Core.Extensions.Directories; + +namespace SquidStd.Scripting.Lua.Loaders; + +/// +/// Custom script loader for MoonSharp that loads Lua modules from the configured Scripts directory. +/// Implements the MoonSharp script loader interface to provide require() functionality. +/// +public class LuaScriptLoader : ScriptLoaderBase +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly List _scriptsDirectories; + + /// + /// Initializes a new instance of the LuaScriptLoader class. + /// + /// The directories configuration to resolve the scripts directory. + public LuaScriptLoader(string rootDirectory) + { + ArgumentNullException.ThrowIfNull(rootDirectory); + + _scriptsDirectories = new() { Path.GetFullPath(rootDirectory) }; + + // Configure default module search paths + ModulePaths = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ]; + + _logger.Debug("Lua script loader initialized with scripts directories: {ScriptsDirectories}", _scriptsDirectories); + } + + /// + /// Initializes a new instance of the LuaScriptLoader class with multiple search directories. + /// + /// Ordered list of directories to search. + public LuaScriptLoader(IReadOnlyList searchDirectories) + : this(searchDirectories, true) { } + + private LuaScriptLoader(IReadOnlyList searchDirectories, bool _) + { + ArgumentNullException.ThrowIfNull(searchDirectories); + + if (searchDirectories.Count == 0) + { + throw new ArgumentException("Search directories cannot be empty.", nameof(searchDirectories)); + } + + _scriptsDirectories = searchDirectories.Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => Path.GetFullPath(d.ResolvePathAndEnvs()).ResolvePathAndEnvs()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (_scriptsDirectories.Count == 0) + { + throw new ArgumentException("Search directories cannot be empty.", nameof(searchDirectories)); + } + + ModulePaths = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ]; + + _logger.Debug("Lua script loader initialized with scripts directories: {ScriptsDirectories}", _scriptsDirectories); + } + + public void AddSearchDirectory(string directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + var fullPath = Path.GetFullPath(directory); + + if (_scriptsDirectories.Contains(fullPath, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + _scriptsDirectories.Add(fullPath); + _logger.Debug("Lua script loader added scripts directory: {ScriptsDirectory}", fullPath); + } + + /// + /// Loads a Lua script file from the configured scripts directory. + /// + /// The filename or module name to load. + /// The global context table. + /// The script content as a string, or null if the file doesn't exist. + public override object LoadFile(string file, Table globalContext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(file); + + // Remove .lua extension if present (MoonSharp sometimes adds it) + // This matches the behavior of ScriptFileExists + file = file.Replace(".lua", ""); + + var resolvedPath = ResolveModulePath(file); + + if (resolvedPath == null) + { + _logger.Warning("Script file not found: {FileName}", file); + + return null; + } + + try + { + var content = File.ReadAllText(resolvedPath); + _logger.Debug("Loaded script file: {FileName} from {Path}", file, resolvedPath); + + return content; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to load script file: {FileName}", file); + + throw new ScriptRuntimeException($"Failed to load module '{file}': {ex.Message}"); + } + } + + /// + /// Checks if a script file exists in the configured scripts directory. + /// + /// The filename or module name to check. + /// True if the file exists, false otherwise. + public override bool ScriptFileExists(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + name = name.Replace(".lua", ""); + var resolvedPath = ResolveModulePath(name); + + return resolvedPath != null; + } + + /// + /// Resolves a module name to a full file path by searching through configured module paths. + /// + /// The module name to resolve. + /// The full path to the module file, or null if not found. + private string? ResolveModulePath(string moduleName) + { + // Try each module path pattern + foreach (var searchDirectory in _scriptsDirectories) + { + foreach (var pattern in ModulePaths) + { + var fileName = pattern.Replace("?", moduleName); + var fullPath = Path.Combine(searchDirectory, fileName); + + if (File.Exists(fullPath)) + { + _logger.Debug( + "Resolved module '{ModuleName}' to path: {FullPath}", + moduleName, + fullPath + ); + + return fullPath; + } + } + + // If no pattern matched, try the direct path + var directPath = Path.Combine(searchDirectory, moduleName); + + if (File.Exists(directPath)) + { + _logger.Debug( + "Resolved module '{ModuleName}' to direct path: {DirectPath}", + moduleName, + directPath + ); + + return directPath; + } + } + + return null; + } +} diff --git a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs new file mode 100644 index 00000000..9137a3e8 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs @@ -0,0 +1,20 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("events", "Allows Lua scripts to subscribe to named server events.")] +public sealed class EventsModule +{ + private readonly ILuaEventBridge _events; + + public EventsModule(ILuaEventBridge events) + { + _events = events; + } + + [ScriptFunction("on", "Registers a callback for a named server event.")] + public void On(string eventName, Closure callback) + => _events.Register(eventName, callback); +} diff --git a/src/SquidStd.Scripting.Lua/Modules/LogModule.cs b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs new file mode 100644 index 00000000..dd462dbc --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/LogModule.cs @@ -0,0 +1,22 @@ +using Serilog; +using SquidStd.Scripting.Lua.Attributes.Scripts; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("log", "Provides logging functionalities to scripts.")] +public class LogModule +{ + private readonly ILogger _logger = Log.ForContext(); + + [ScriptFunction(helpText: "Logs a message at the ERROR level.")] + public void Error(string message, params object[]? args) + => _logger.Error(message, args); + + [ScriptFunction(helpText: "Logs a message at the INFO level.")] + public void Info(string message, params object[]? args) + => _logger.Information(message, args); + + [ScriptFunction(helpText: "Logs a message at the WARNING level.")] + public void Warning(string message, params object[]? args) + => _logger.Warning(message, args); +} diff --git a/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs new file mode 100644 index 00000000..4547ab25 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Modules/RandomModule.cs @@ -0,0 +1,109 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Attributes.Scripts; + +namespace SquidStd.Scripting.Lua.Modules; + +[ScriptModule("random", "Provides safe random helpers to Lua scripts.")] +public sealed class RandomModule +{ + [ScriptFunction("chance", "Returns true when a random roll is within the given percentage.")] + public bool Chance(double percent) + { + if (percent <= 0) + { + return false; + } + + if (percent >= 100) + { + return true; + } + + return Random.Shared.NextDouble() * 100 < percent; + } + + [ScriptFunction("float", "Returns a random floating-point number between 0 and 1.")] + public double Float() + => Random.Shared.NextDouble(); + + [ScriptFunction("int", "Returns a random integer in the inclusive range.")] + public int Int(int min, int max) + { + if (max < min) + { + throw new ArgumentOutOfRangeException(nameof(max), "Max must be greater than or equal to min."); + } + + if (max == int.MaxValue) + { + return Random.Shared.Next(min, max) + Random.Shared.Next(0, 2); + } + + return Random.Shared.Next(min, max + 1); + } + + [ScriptFunction("pick", "Returns one random value from a Lua array table.")] + public DynValue Pick(Table values) + { + ArgumentNullException.ThrowIfNull(values); + + var length = values.Length; + + if (length <= 0) + { + throw new ArgumentException("Values table cannot be empty.", nameof(values)); + } + + return values.Get(Random.Shared.Next(1, length + 1)); + } + + [ScriptFunction("weighted", "Returns one value from weighted Lua entries.")] + public DynValue Weighted(Table entries) + { + ArgumentNullException.ThrowIfNull(entries); + + var length = entries.Length; + var total = 0d; + + for (var i = 1; i <= length; i++) + { + var entry = entries.Get(i).Table; + total += ReadWeight(entry); + } + + if (total <= 0) + { + throw new ArgumentException("At least one weighted entry must have a positive weight.", nameof(entries)); + } + + var roll = Random.Shared.NextDouble() * total; + var cursor = 0d; + + for (var i = 1; i <= length; i++) + { + var entry = entries.Get(i).Table; + cursor += ReadWeight(entry); + + if (roll <= cursor) + { + return entry.Get("value"); + } + } + + return entries.Get(length).Table.Get("value"); + } + + private static double ReadWeight(Table entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var weight = entry.Get("weight"); + + if (weight.Type != DataType.Number) + { + throw new ArgumentException("Weighted entries must contain a numeric weight."); + } + + return Math.Max(0, weight.Number); + } +} diff --git a/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs new file mode 100644 index 00000000..bf5c8bd1 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Proxies/LuaProxy.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using MoonSharp.Interpreter; + +namespace SquidStd.Scripting.Lua.Proxies; + +/// +/// A proxy class that implements an interface by delegating method calls to a MoonSharp Table. +/// +/// The interface type to implement. +public class LuaProxy : DispatchProxy +{ + public Table Table { get; set; } + + /// + /// Invokes the Lua function corresponding to the method name on the associated table. + /// + /// The method information for the invoked method. + /// The arguments passed to the method. + /// The result of the Lua function call, converted to the appropriate type. + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var fn = Table.Get(targetMethod.Name); + + if (fn.Type != DataType.Function) + { + throw new MissingMethodException(targetMethod.Name); + } + + var dynArgs = args + .Select(a => DynValue.FromObject(null, a)) + .ToArray(); + var result = fn.Function.Call(dynArgs); + + return result.ToObject(targetMethod.ReturnType); + } +} diff --git a/src/SquidStd.Scripting.Lua/README.md b/src/SquidStd.Scripting.Lua/README.md new file mode 100644 index 00000000..4ada8e28 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/README.md @@ -0,0 +1,61 @@ +

+ SquidStd +

+ +

SquidStd.Scripting.Lua

+ +

+ NuGet + Downloads + license +

+ +Lua scripting for SquidStd. Hosts a Lua engine (`IScriptEngineService`) that exposes .NET methods to +scripts through attribute-decorated modules, bridges events, generates `.luarc` typings/docs, and +supports init scripts, constants, and callbacks. + +## Install + +```bash +dotnet add package SquidStd.Scripting.Lua +``` + +## Features + +- `IScriptEngineService` — load and run Lua scripts; register modules, constants, callbacks, init scripts. +- Attribute-based modules: mark a class `[ScriptModule]` and methods `[ScriptFunction]` to expose them. +- `container.RegisterScriptModule()` / `RegisterLuaUserData()` registration extensions. +- Event bridging to the SquidStd event bus (`ILuaEventBridge`). +- Built-in modules (logging, events, random) and `.luarc` documentation generation. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +[ScriptModule("math2")] +public sealed class MathModule +{ + [ScriptFunction("add")] + public int Add(int a, int b) => a + b; +} + +var container = new Container(); +container.RegisterScriptModule(); +// Resolve IScriptEngineService to load and execute scripts that call math2.add(1, 2). +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `IScriptEngineService` | Lua engine: load/run scripts, register modules/constants/callbacks. | +| `ILuaEventBridge` | Bridges Lua scripts to the event bus. | +| `ScriptModuleAttribute` / `ScriptFunctionAttribute` | Expose .NET classes/methods to Lua. | +| `AddScriptModuleExtension` | `RegisterScriptModule()` / `RegisterLuaUserData()`. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs new file mode 100644 index 00000000..fe473fb3 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs @@ -0,0 +1,122 @@ +using System.Collections.Concurrent; +using MoonSharp.Interpreter; +using Serilog; +using SquidStd.Scripting.Lua.Interfaces.Events; + +namespace SquidStd.Scripting.Lua.Services; + +/// +/// Default Lua event bridge backed by named MoonSharp closures. +/// +public sealed class LuaEventBridge : ILuaEventBridge +{ + private readonly ConcurrentDictionary> _callbacks = new(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger = Log.ForContext(); + + private Script? _script; + + public void Attach(Script script) + { + ArgumentNullException.ThrowIfNull(script); + + _script = script; + } + + public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) + { + ArgumentNullException.ThrowIfNull(callback); + ArgumentNullException.ThrowIfNull(payload); + + var script = _script ?? throw new InvalidOperationException("Lua event bridge is not attached to a script."); + var table = CreatePayloadTable(script, payload); + + return script.Call(callback, table); + } + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + ArgumentNullException.ThrowIfNull(payload); + + if (!_callbacks.TryGetValue(eventName, out var callbacks)) + { + return; + } + + Closure[] snapshot; + + lock (callbacks) + { + snapshot = callbacks.ToArray(); + } + + for (var i = 0; i < snapshot.Length; i++) + { + try + { + Invoke(snapshot[i], payload); + } + catch (Exception ex) + { + _logger.Error(ex, "Lua event callback failed for {EventName}", eventName); + } + } + } + + public void Register(string eventName, Closure callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + ArgumentNullException.ThrowIfNull(callback); + + var callbacks = _callbacks.GetOrAdd(eventName, static _ => []); + + lock (callbacks) + { + callbacks.Add(callback); + } + } + + private static DynValue ConvertValue(Script script, object? value) + { + if (value is null) + { + return DynValue.Nil; + } + + if (value is IReadOnlyDictionary dictionary) + { + return DynValue.NewTable(CreatePayloadTable(script, dictionary)); + } + + if (value is IReadOnlyList list) + { + return DynValue.NewTable(CreateArrayTable(script, list)); + } + + return DynValue.FromObject(script, value); + } + + private static Table CreateArrayTable(Script script, IReadOnlyList values) + { + var table = new Table(script); + + for (var i = 0; i < values.Count; i++) + { + table[i + 1] = ConvertValue(script, values[i]); + } + + return table; + } + + private static Table CreatePayloadTable(Script script, IReadOnlyDictionary payload) + { + var table = new Table(script); + + foreach (var (key, value) in payload) + { + table[key] = ConvertValue(script, value); + } + + return table; + } +} diff --git a/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs new file mode 100644 index 00000000..4afb9ff3 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Services/LuaScriptEngineService.cs @@ -0,0 +1,1498 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using DryIoc; +using MoonSharp.Interpreter; +using Serilog; +using SquidStd.Core.Directories; +using SquidStd.Core.Extensions.Strings; +using SquidStd.Core.Json; +using SquidStd.Core.Utils; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Context; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Data.Luarc; +using SquidStd.Scripting.Lua.Data.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Interfaces.Scripts; +using SquidStd.Scripting.Lua.Loaders; +using SquidStd.Scripting.Lua.Utils; + +#pragma warning disable IL2026 // RequiresUnreferencedCode - Lua scripting uses reflection for dynamic functionality +#pragma warning disable IL2072 // DynamicallyAccessedMemberTypes - Reflection access is necessary for scripting + +namespace SquidStd.Scripting.Lua.Services; + +/// +/// Lua engine service that integrates MoonSharp with the SquidCraft game engine +/// Provides script execution, module loading, and Lua meta file generation +/// +public class LuaScriptEngineService : IScriptEngineService, IDisposable +{ + private const string OnReadyFunctionName = "on_ready"; + private const string OnEngineRunFunctionName = "on_initialize"; + + private static readonly string[] _completionExcludedGlobals = ["delay", "toString"]; + + private readonly LuaEngineConfig _engineConfig; + private readonly ConcurrentDictionary> _callbacks = new(); + private readonly ConcurrentDictionary _constants = new(); + private readonly ConcurrentDictionary> _manualModuleFunctions = new(); + private readonly DirectoriesConfig _directoriesConfig; + private readonly List _initScripts; + private readonly ConcurrentDictionary _loadedModules = new(); + private readonly ILogger _logger = Log.ForContext(); + private readonly ConcurrentDictionary _scriptCache = new(); + private readonly List _scriptModules; + private readonly List _loadedUserData; + private readonly IContainer _serviceProvider; + + private int _cacheHits; + private int _cacheMisses; + private bool _disposed; + private bool _isInitialized; + private Func _nameResolver; + private LuaScriptLoader _scriptLoader; + private FileSystemWatcher? _watcher; + + /// + /// Gets the MoonSharp script instance. + /// + public Script LuaScript { get; } + + /// + /// Gets the script engine instance. + /// + public object Engine => LuaScript; + + /// + /// Raised when a watched Lua file changes. + /// + public event IScriptEngineService.LuaFileChangedHandler? FileChanged; + + /// + /// Event raised when a script error occurs + /// + public event EventHandler? OnScriptError; + + /// + public event Action? AfterModulesRegistered; + + /// + public event Action? OnComponentFileChanged; + + /// + /// Initializes a new instance of the LuaScriptEngineService class. + /// + /// The directories configuration. + /// The list of script modules. + /// The list of loaded user data. + /// The service provider. + /// The Lua engine configuration. + public LuaScriptEngineService( + DirectoriesConfig directoriesConfig, + IContainer serviceProvider, + LuaEngineConfig engineConfig, + List scriptModules = null, + List loadedUserData = null + ) + { + JsonUtils.RegisterJsonContext(SquidStdScriptJsonContext.Default); + + scriptModules ??= new(); + loadedUserData ??= new(); + + _scriptModules = scriptModules; + _directoriesConfig = directoriesConfig; + _serviceProvider = serviceProvider; + _engineConfig = engineConfig; + _loadedUserData = loadedUserData ?? new(); + _initScripts = ["bootstrap.lua", "init.lua", "main.lua"]; + + CreateNameResolver(); + + LuaScript = CreateOptimizedEngine(); + + LoadToUserData(); + } + + /// + /// Adds a callback function that can be called from Lua scripts. + /// + /// The name of the callback. + /// The callback action. + public void AddCallback(string name, Action callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(callback); + + var normalizedName = name.ToSnakeCaseUpper(); + _callbacks[normalizedName] = callback; + + _logger.Debug("Callback registered: {Name}", normalizedName); + } + + /// + /// Adds a constant value that can be accessed from Lua scripts. + /// + /// The name of the constant. + /// The value of the constant. + public void AddConstant(string name, object? value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var normalizedName = name.ToSnakeCaseUpper(); + + if (_constants.ContainsKey(normalizedName)) + { + _logger.Warning("Constant {Name} already exists, overwriting", normalizedName); + } + + _constants[normalizedName] = value; + + var valueToSet = value; + + if (value != null && !IsSimpleType(value.GetType())) + { + valueToSet = ObjectToTable(value); + } + + LuaScript.Globals[normalizedName] = valueToSet; + + _logger.Debug("Constant added: {Name}", normalizedName); + } + + /// + /// Adds an initialization script. + /// + /// The script to add. + public void AddInitScript(string script) + { + if (string.IsNullOrWhiteSpace(script)) + { + throw new ArgumentException("Script cannot be null or empty", nameof(script)); + } + + _initScripts.Add(script); + } + + /// + /// Adds a manual module function that can be called from Lua scripts with a callback. + /// + public void AddManualModuleFunction(string moduleName, string functionName, Action callback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(functionName); + ArgumentNullException.ThrowIfNull(callback); + + var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); + + moduleTable[normalizedFunction] = DynValue.NewCallback( + (_, args) => + { + try + { + var parameters = ConvertArgumentsToArray(args); + callback(parameters); + + return DynValue.Nil; + } + catch (Exception ex) + { + _logger.Error( + ex, + "Error executing manual module action {FunctionName} in {ModuleName}", + normalizedFunction, + normalizedModule + ); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + RegisterManualModuleFunction(normalizedModule, normalizedFunction); + } + + /// + /// Adds a manual module function with typed input and output that can be called from Lua scripts. + /// + public void AddManualModuleFunction( + string moduleName, + string functionName, + Func callback + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(moduleName); + ArgumentException.ThrowIfNullOrWhiteSpace(functionName); + ArgumentNullException.ThrowIfNull(callback); + + var (normalizedModule, normalizedFunction, moduleTable) = PrepareManualModule(moduleName, functionName); + + moduleTable[normalizedFunction] = DynValue.NewCallback( + (_, args) => + { + try + { + var input = PrepareManualInput(args); + var result = callback(input); + + return ConvertToLua(result); + } + catch (Exception ex) + { + _logger.Error( + ex, + "Error executing manual module function {FunctionName} in {ModuleName}", + normalizedFunction, + normalizedModule + ); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + RegisterManualModuleFunction(normalizedModule, normalizedFunction); + } + + /// + /// Adds a script module to the engine. + /// + /// The type of the script module. + public void AddScriptModule(Type type) + { + ArgumentNullException.ThrowIfNull(type); + _scriptModules.Add(new(type)); + } + + public void AddSearchDirectory(string path) + => _scriptLoader.AddSearchDirectory(path); + + /// + /// Clears the script cache + /// + public void ClearScriptCache() + { + _scriptCache.Clear(); + _cacheHits = 0; + _cacheMisses = 0; + _logger.Information("Script cache cleared"); + } + + /// + /// Executes a registered callback with the specified arguments. + /// + /// The name of the callback. + /// The arguments to pass to the callback. + public void ExecuteCallback(string name, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var normalizedName = name.ToSnakeCaseUpper(); + + if (_callbacks.TryGetValue(normalizedName, out var callback)) + { + try + { + _logger.Debug("Executing callback {Name}", normalizedName); + callback(args); + } + catch (Exception ex) + { + _logger.Error(ex, "Error executing callback {Name}", normalizedName); + + throw; + } + } + else + { + _logger.Warning("Callback {Name} not found", normalizedName); + } + } + + /// + /// Executes the engine ready function from bootstrap scripts. + /// + public void ExecuteEngineReady() + => ExecuteFunctionFromBootstrap(OnEngineRunFunctionName); + + /// + /// Executes a Lua function and returns the result. + /// + /// The function command to execute. + /// The result of the function execution. + public ScriptResult ExecuteFunction(string command) + { + try + { + var result = LuaScript.DoString($"return {command}"); + + return ScriptResultBuilder.CreateSuccess().WithData(result.ToObject()).Build(); + } + catch (ScriptRuntimeException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, command); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua error at line {Line}, column {Column}: {Message}", + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + return ScriptResultBuilder.CreateError() + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); + } + catch (InterpreterException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, command); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua {ErrorType} at line {Line}, column {Column}: {Message}", + errorInfo.ErrorType, + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + return ScriptResultBuilder.CreateError() + .WithMessage( + $"{errorInfo.ErrorType}: {errorInfo.Message} at line {errorInfo.LineNumber}" + ) + .Build(); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute function: {Command}", command); + + return ScriptResultBuilder.CreateError().WithMessage(ex.Message).Build(); + } + } + + /// + /// Executes a Lua function asynchronously and returns the result. + /// + /// The function command to execute. + /// Token used to cancel the operation. + /// A task representing the asynchronous operation. + public Task ExecuteFunctionAsync(string command, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + return Task.FromResult(ExecuteFunction(command)); + } + + public void ExecuteFunctionFromBootstrap(string name) + { + try + { + var onReadyFunc = LuaScript.Globals.Get(name); + + if (onReadyFunc.Type == DataType.Nil) + { + _logger.Warning("No {FuncName} function defined in scripts", name); + + return; + } + + // Verify it's actually a function before calling + if (onReadyFunc.Type != DataType.Function) + { + _logger.Error( + "'{FuncName}' is defined but is not a function, it's a {Type}. Skipping execution.", + name, + onReadyFunc.Type + ); + + return; + } + + LuaScript.Call(onReadyFunc); + _logger.Debug("Boot function executed successfully"); + } + catch (Exception ex) + { + _logger.Error(ex, "Error executing onReady function"); + + throw; + } + } + + /// + /// Executes a script string. + /// + /// The script to execute. + public void ExecuteScript(string script) + => ExecuteScript(script, null); + + /// + /// Executes a script from a file. + /// + /// The path to the script file. + public void ExecuteScriptFile(string scriptFile) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); + + if (!File.Exists(scriptFile)) + { + throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); + } + + try + { + var content = File.ReadAllText(scriptFile); + _logger.Debug("Executing script file: {FileName}", Path.GetFileName(scriptFile)); + ExecuteScript(content, scriptFile); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute script file: {FileName}", Path.GetFileName(scriptFile)); + } + } + + /// + /// Executes a script file asynchronously. + /// + /// The path to the script file. + /// Token used to cancel the operation. + /// A task representing the asynchronous operation. + public async Task ExecuteScriptFileAsync(string scriptFile, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scriptFile); + + if (!File.Exists(scriptFile)) + { + throw new FileNotFoundException($"Script file not found: {scriptFile}", scriptFile); + } + + try + { + var content = await File.ReadAllTextAsync(scriptFile, cancellationToken).ConfigureAwait(false); + _logger.Debug("Executing script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + ExecuteScript(content, scriptFile); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to execute script file asynchronously: {FileName}", Path.GetFileName(scriptFile)); + + throw; + } + } + + /// + /// Gets execution metrics for performance monitoring + /// + public ScriptExecutionMetrics GetExecutionMetrics() + => new() + { + CacheHits = _cacheHits, + CacheMisses = _cacheMisses, + TotalScriptsCached = _scriptCache.Count + }; + + /// + /// Gets the statistics of the script engine. + /// + /// A tuple containing the module count, callback count, constant count, and initialization status. + public (int ModuleCount, int CallbackCount, int ConstantCount, bool IsInitialized) GetStats() + => (_loadedModules.Count, _callbacks.Count, _constants.Count, _isInitialized); + + /// + /// Registers a global variable in the Lua environment. + /// + /// The name of the variable. + /// The value of the variable. + public void RegisterGlobal(string name, object value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(value); + + LuaScript.Globals[name] = value; + _logger.Debug("Global registered: {Name} (Type: {Type})", name, value.GetType().Name); + } + + /// + /// Registers a global function in the Lua environment. + /// + /// The name of the function. + /// The delegate representing the function. + public void RegisterGlobalFunction(string name, Delegate func) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(func); + + LuaScript.Globals[name] = func; + _logger.Debug("Global function registered: {Name}", name); + } + + /// + /// Registers a global type user data. + /// + /// The type to register. + public void RegisterGlobalTypeUserData(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Registers a global type user data for the specified type. + /// + /// The type to register. + public void RegisterGlobalTypeUserData() + { + var type = typeof(T); + _logger.Debug("Global type user data registered: {TypeName}", type.Name); + + LuaScript.Globals[type.Name] = UserData.CreateStatic(type); + } + + /// + /// Resets the script engine to its initial state. + /// + public void Reset() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + _isInitialized = false; + + _logger.Debug("Lua engine reset"); + } + + /// + /// Stops the script engine asynchronously. + /// + /// A task representing the asynchronous operation. + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + /// Starts the script engine asynchronously. + /// + /// A task representing the asynchronous operation. + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + if (_isInitialized) + { + _logger.Warning("Script engine is already initialized"); + + return; + } + + try + { + await RegisterScriptModulesAsync(CancellationToken.None); + AttachLuaEventBridge(); + + // Hook for engine-side consumers to install UserData types, globals, and per-feature + // scanners (e.g. LuaComponentLoader) once the script is ready but before bootstrap runs. + AfterModulesRegistered?.Invoke(LuaScript); + + AddConstant("version", _engineConfig.EngineVersion); + AddConstant("engine", _engineConfig.EngineName); + AddConstant("platform", PlatformUtils.GetCurrentPlatform().ToString()); + + _ = Task.Run(() => GenerateLuaMetaFileAsync(CancellationToken.None), CancellationToken.None); + + RegisterGlobalFunctions(); + + ExecuteBootstrap(); + + ExecuteBootFunction(); + _isInitialized = true; + _logger.Information("Lua engine initialized successfully"); + + if (_watcher == null) + { + _watcher = new(_engineConfig.ScriptsDirectory, "*.lua") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + + _watcher.Changed += OnLuaFilesChanged; + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to initialize Lua engine"); + + throw; + } + } + + /// + /// Converts a name to the script engine function name format. + /// + /// The name to convert. + /// The converted function name. + public string ToScriptEngineFunctionName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return _nameResolver(name); + } + + /// + /// Unregisters a global variable from the Lua environment. + /// + /// The name of the variable to unregister. + /// True if the variable was unregistered, false otherwise. + public bool UnregisterGlobal(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var existingValue = LuaScript.Globals.Get(name); + + if (existingValue.Type != DataType.Nil) + { + LuaScript.Globals[name] = DynValue.Nil; + _logger.Debug("Global unregistered: {Name}", name); + + return true; + } + + _logger.Warning("Attempted to unregister non-existent global: {Name}", name); + + return false; + } + + private void AttachLuaEventBridge() + { + var eventBridge = _serviceProvider.Resolve(IfUnresolved.ReturnDefault); + eventBridge?.Attach(LuaScript); + } + + private static object?[] ConvertArgumentsToArray(CallbackArguments args) + { + if (args.Count == 0) + { + return Array.Empty(); + } + + var converted = new object?[args.Count]; + + for (var i = 0; i < args.Count; i++) + { + converted[i] = args[i].ToObject(); + } + + return converted; + } + + private static object? ConvertFromLua(DynValue dynValue, Type targetType) + => dynValue.Type switch + { + DataType.Nil => null, + DataType.Boolean => dynValue.Boolean, + DataType.Number => Convert.ChangeType(dynValue.Number, targetType, CultureInfo.InvariantCulture), + DataType.String => dynValue.String, + DataType.Table => dynValue.ToObject(), + _ => dynValue.ToObject() + }; + + private DynValue ConvertToLua(object? value) + => value == null ? DynValue.Nil : DynValue.FromObject(LuaScript, value); + + /// + /// Creates a Lua callback that invokes the constructor matching the number of arguments passed from Lua. + /// + private DynValue CreateConstructorCallback( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type + ) + { + var constructorsByParamCount = new Dictionary(); + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + foreach (var ctor in constructors) + { + var paramCount = ctor.GetParameters().Length; + constructorsByParamCount.TryAdd(paramCount, ctor); + } + + return DynValue.NewCallback( + (_, args) => + { + var argCount = args.Count; + + if (!constructorsByParamCount.TryGetValue(argCount, out var ctor)) + { + var availableCtors = string.Join(", ", constructorsByParamCount.Keys.OrderBy(k => k)); + + throw new ScriptRuntimeException( + $"No constructor found for {type.Name} with {argCount} arguments. Available: {availableCtors}" + ); + } + + try + { + var parameters = ctor.GetParameters(); + var convertedArgs = new object?[argCount]; + + for (var i = 0; i < argCount; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + + return ConvertToLua(Activator.CreateInstance(type, convertedArgs)); + } + catch (Exception ex) + { + throw new ScriptRuntimeException( + $"Constructor of {type.Name} with {argCount} arguments failed: {ex.Message}", + ex + ); + } + } + ); + } + + /// + /// Creates detailed error information from a Lua exception + /// + private static ScriptErrorInfo CreateErrorInfo(ScriptRuntimeException luaEx, string sourceCode, string? fileName = null) + { + var errorInfo = new ScriptErrorInfo + { + Message = luaEx.DecoratedMessage ?? luaEx.Message, + StackTrace = luaEx.StackTrace, + LineNumber = 0, + ColumnNumber = 0, + ErrorType = "LuaError", + SourceCode = sourceCode, + FileName = fileName ?? "script.lua" + }; + + return errorInfo; + } + + /// + /// Creates detailed error information from a Lua interpreter exception (syntax errors, etc.) + /// + private static ScriptErrorInfo CreateErrorInfo(InterpreterException luaEx, string sourceCode, string? fileName = null) + { + // Extract line and column info from the exception message if available + // SyntaxErrorException typically has format like "chunk_1:(1,5-10): unexpected symbol near '?'" + int? lineNumber = null; + int? columnNumber = null; + var errorType = "LuaError"; + + if (luaEx is SyntaxErrorException) + { + errorType = "SyntaxError"; + } + + // Try to extract line and column from the message + var message = luaEx.Message; + + if (message.Contains('(')) + { + var match = Regex.Match(message, @"\((\d+),(\d+)"); + + if (match.Success) + { + lineNumber = int.Parse(match.Groups[1].Value, CultureInfo.CurrentCulture); + columnNumber = int.Parse(match.Groups[2].Value, CultureInfo.CurrentCulture); + } + } + + var errorInfo = new ScriptErrorInfo + { + Message = luaEx.DecoratedMessage ?? luaEx.Message, + StackTrace = luaEx.StackTrace, + LineNumber = lineNumber, + ColumnNumber = columnNumber, + ErrorType = errorType, + SourceCode = sourceCode, + FileName = fileName ?? "script.lua" + }; + + return errorInfo; + } + + private DynValue CreateMethodClosure(object instance, MethodInfo method) + => DynValue.NewCallback( + (context, args) => + { + try + { + var parameters = method.GetParameters(); + + // Check if the last parameter is a params array + var hasParamsArray = parameters.Length > 0 && + parameters[^1].IsDefined(typeof(ParamArrayAttribute), false); + + object?[] convertedArgs; + + if (hasParamsArray) + { + var regularParamsCount = parameters.Length - 1; + convertedArgs = new object?[parameters.Length]; + + // Convert regular parameters + for (var i = 0; i < regularParamsCount && i < args.Count; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + + // Collect remaining arguments into params array + var paramsArrayType = parameters[^1].ParameterType.GetElementType()!; + var paramsCount = Math.Max(0, args.Count - regularParamsCount); + var paramsArray = Array.CreateInstance(paramsArrayType, paramsCount); + + for (var i = 0; i < paramsCount; i++) + { + var argIndex = regularParamsCount + i; + paramsArray.SetValue(ConvertFromLua(args[argIndex], paramsArrayType), i); + } + + convertedArgs[^1] = paramsArray; + } + else + { + // Normal parameter handling + convertedArgs = new object?[parameters.Length]; + + for (var i = 0; i < parameters.Length && i < args.Count; i++) + { + convertedArgs[i] = ConvertFromLua(args[i], parameters[i].ParameterType); + } + } + + var result = method.Invoke(instance, convertedArgs); + + return method.ReturnType == typeof(void) ? DynValue.Nil : ConvertToLua(result); + } + catch (Exception ex) + { + _logger.Error(ex, "Error calling method {MethodName}", method.Name); + + throw new ScriptRuntimeException(ex.Message); + } + } + ); + + private Table CreateModuleTable( + object instance, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] + Type moduleType + ) + { + var moduleTable = new Table(LuaScript); + + var methods = moduleType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() is not null); + + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + + // Create a closure that captures the instance and method + var closure = CreateMethodClosure(instance, method); + moduleTable[functionName] = closure; + } + + return moduleTable; + } + + private void CreateNameResolver() + => _nameResolver = name => name.ToSnakeCase(); + + // _nameResolver = _scriptEngineConfig.ScriptNameConversion switch + // { + // ScriptNameConversion.CamelCase => name => name.ToCamelCase(), + // ScriptNameConversion.PascalCase => name => name.ToPascalCase(), + // ScriptNameConversion.SnakeCase => name => name.ToSnakeCase(), + // _ => _nameResolver + // }; + private Script CreateOptimizedEngine() + { + _scriptLoader = new(new[] { _engineConfig.ScriptsDirectory }); + var script = new Script + { + Options = + { + // Configure MoonSharp options + DebugPrint = s => _logger.Debug("[Lua] {Message}", s), + ScriptLoader = _scriptLoader + } + }; + + _logger.Debug("Lua script loader configured for require() functionality"); + + return script; + } + + private void ExecuteBootFunction() + => ExecuteFunctionFromBootstrap(OnReadyFunctionName); + + private void ExecuteBootstrap() + { + foreach (var file in _initScripts.Select(s => Path.Combine(_engineConfig.ScriptsDirectory, s))) + { + if (File.Exists(file)) + { + var fileName = Path.GetFileName(file); + _logger.Information("Executing {FileName} script", fileName); + ExecuteScriptFile(file); + } + } + } + + /// + /// Executes a script string with an optional file name for error reporting. + /// + /// The script to execute. + /// Optional file name for error reporting. + private void ExecuteScript(string script, string? fileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(script); + + var stopwatch = Stopwatch.GetTimestamp(); + + try + { + var scriptHash = GetScriptHash(script); + + if (_scriptCache.ContainsKey(scriptHash)) + { + Interlocked.Increment(ref _cacheHits); + _logger.Debug("Script found in cache"); + } + else + { + Interlocked.Increment(ref _cacheMisses); + _scriptCache.TryAdd(scriptHash, script); + } + + LuaScript.DoString(script); + var elapsedMs = Stopwatch.GetElapsedTime(stopwatch); + _logger.Debug("Script executed successfully in {ElapsedMs}ms", elapsedMs); + } + catch (ScriptRuntimeException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, script, fileName); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua error at line {Line}, column {Column}: {Message}", + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + throw; + } + catch (InterpreterException luaEx) + { + var errorInfo = CreateErrorInfo(luaEx, script, fileName); + OnScriptError?.Invoke(this, errorInfo); + + _logger.Error( + luaEx, + "Lua {ErrorType} at line {Line}, column {Column}: {Message}", + errorInfo.ErrorType, + errorInfo.LineNumber, + errorInfo.ColumnNumber, + errorInfo.Message + ); + + throw; + } + catch (Exception e) + { + var elapsedMs = Stopwatch.GetElapsedTime(stopwatch); + _logger.Error( + e, + "Error executing script: {ScriptPreview}", + script.Length > 100 ? script[..100] + "..." : script + ); + + throw; + } + } + + [RequiresUnreferencedCode( + "Lua meta generation relies on reflection-heavy LuaDocumentationGenerator which is not trim-safe." + )] + private async Task GenerateLuaMetaFileAsync(CancellationToken cancellationToken) + { + try + { + _logger.Debug("Generating Lua meta files"); + + var definitionDirectory = _engineConfig.LuarcDirectory; + + if (!Directory.Exists(definitionDirectory)) + { + Directory.CreateDirectory(definitionDirectory); + } + + foreach (var userData in _loadedUserData) + { + // check if is enum + + if (userData.UserType.IsEnum) + { + LuaDocumentationGenerator.FoundEnums.Add(userData.UserType); + + continue; + } + + LuaDocumentationGenerator.AddClassToGenerate(userData.UserType); + } + + AddConstant("engine_version", _engineConfig.EngineVersion); + + // Generate meta.lua + var manualModulesSnapshot = _manualModuleFunctions.ToDictionary( + kvp => kvp.Key, + IReadOnlyCollection (kvp) => kvp.Value.Keys.ToArray() + ); + + var documentation = LuaDocumentationGenerator.GenerateDocumentation( + "Moongate", + _engineConfig.EngineVersion, + _scriptModules, + new(_constants), + manualModulesSnapshot, + _nameResolver + ); + + var metaLuaPath = Path.Combine(definitionDirectory, "definitions.lua"); + await File.WriteAllTextAsync(metaLuaPath, documentation, cancellationToken); + _logger.Debug("Lua meta file generated at {Path}", metaLuaPath); + + // Generate .luarc.json + var luarcJson = GenerateLuarcJson(); + var luarcPath = Path.Combine(_engineConfig.LuarcDirectory, ".luarc.json"); + await File.WriteAllTextAsync(luarcPath, luarcJson, cancellationToken); + _logger.Debug("Lua configuration file generated at {Path}", luarcPath); + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to generate Lua meta files"); + } + } + + private string GenerateLuarcJson() + { + var globalsList = _constants.Keys.ToList(); + globalsList.AddRange(_completionExcludedGlobals); + + // Add registered user data types (Vector3, Vector2, Quaternion, etc.) + foreach (var userData in _loadedUserData) + { + globalsList.Add(userData.UserType.Name); + } + + var luarcConfig = new LuarcConfig + { + Runtime = new() + { + Path = + [ + "?.lua", + "?/init.lua", + "modules/?.lua", + "modules/?/init.lua" + ] + }, + Workspace = new() + { + Library = [_engineConfig.ScriptsDirectory] + }, + Diagnostics = new() + { + Globals = [..globalsList] + } + }; + + return JsonUtils.Serialize(luarcConfig); + } + + /// + /// Generates a hash for script caching + /// + private static string GetScriptHash(string script) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(script)); + + return Convert.ToBase64String(hashBytes); + } + + private static bool IsSimpleType(Type type) + => type.IsPrimitive || type == typeof(string) || type.IsEnum; + + private void LoadToUserData() + { + if (_loadedUserData == null) + { + return; + } + + foreach (var scriptUserData in _loadedUserData) + { + // Register the type to allow MoonSharp to access its members and methods + UserData.RegisterType(scriptUserData.UserType); + + // Check if type has public constructors (instantiable) + var publicConstructors = scriptUserData.UserType.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + if (publicConstructors.Length > 0) + { + // Instantiable type - use constructor wrapper for easier instance creation + var constructorWrapper = CreateConstructorCallback(scriptUserData.UserType); + LuaScript.Globals[scriptUserData.UserType.Name] = constructorWrapper; + } + else + { + // Static class or no public constructors - expose the type itself for static method access + LuaScript.Globals[scriptUserData.UserType.Name] = scriptUserData.UserType; + } + + _logger.Debug("User data type registered: {TypeName}", scriptUserData.UserType.Name); + + LuaDocumentationGenerator.AddClassToGenerate(scriptUserData.UserType); + } + } + + private Table ObjectToTable(object obj) + { + var table = new Table(LuaScript); + var properties = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + var value = prop.GetValue(obj); + table[prop.Name] = value; + } + + return table; + } + + private void OnLuaFilesChanged(object sender, FileSystemEventArgs e) + { + if (_initScripts.Contains(e.Name)) + { + _logger.Information("Lua script file changed: {FileName}. Clearing script cache.", e.Name); + + if (FileChanged != null) + { + ClearScriptCache(); + + if (FileChanged(e.FullPath)) + { + _logger.Information("File change handled successfully: {FileName}", e.Name); + + ExecuteBootstrap(); + ExecuteBootFunction(); + } + } + + return; + } + + // Route changes under any `components/` subdirectory to the engine-side component loader. + var sep = Path.DirectorySeparatorChar; + + if (e.FullPath.Contains($"{sep}components{sep}", StringComparison.OrdinalIgnoreCase)) + { + _logger.Information("Lua component file changed: {FileName}", e.Name); + OnComponentFileChanged?.Invoke(e.FullPath); + } + } + + private TInput? PrepareManualInput(CallbackArguments args) + { + if (typeof(TInput) == typeof(object[])) + { + return (TInput?)(object?)ConvertArgumentsToArray(args); + } + + if (args.Count == 0) + { + return default; + } + + var firstArg = args[0]; + var converted = ConvertFromLua(firstArg, typeof(TInput)); + + return converted is null ? default : (TInput?)converted; + } + + private (string ModuleName, string FunctionName, Table ModuleTable) PrepareManualModule( + string moduleName, + string functionName + ) + { + var normalizedModuleName = _nameResolver(moduleName); + var normalizedFunctionName = _nameResolver(functionName); + + var existing = LuaScript.Globals.Get(normalizedModuleName); + Table moduleTable; + + if (existing.Type == DataType.Table) + { + moduleTable = existing.Table; + } + else + { + moduleTable = new(LuaScript); + LuaScript.Globals[normalizedModuleName] = moduleTable; + } + + _loadedModules.TryAdd(normalizedModuleName, moduleTable); + + return (normalizedModuleName, normalizedFunctionName, moduleTable); + } + + [RequiresUnreferencedCode("Enum registration uses reflection to access enum metadata.")] + private void RegisterEnum(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + if (!enumType.IsEnum) + { + _logger.Warning("Type {TypeName} is not an enum, skipping registration", enumType.Name); + + return; + } + + var enumName = _nameResolver(enumType.Name); + var enumTable = new Table(LuaScript); + var enumValuesByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Populate enum values + var names = Enum.GetNames(enumType); + var underlyingValues = Enum.GetValuesAsUnderlyingType(enumType); + + for (var i = 0; i < names.Length; i++) + { + var name = names[i]; + var rawValue = underlyingValues.GetValue(i); + + if (rawValue is null) + { + continue; + } + + var coercedValue = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture); + enumTable[name] = coercedValue; + enumValuesByName[name] = coercedValue; + } + + // Create metatable for read-only and case-insensitive access + var metatable = new Table(LuaScript); + + // __index: allows case-insensitive access + metatable["__index"] = DynValue.NewCallback( + (ctx, args) => + { + var key = args[1].String; + + if (string.IsNullOrEmpty(key)) + { + return DynValue.Nil; + } + + // Try exact match first + var value = enumTable.Get(key); + + if (value.Type != DataType.Nil) + { + return value; + } + + // Try case-insensitive match + if (enumValuesByName.TryGetValue(key, out var intValue)) + { + return DynValue.NewNumber(intValue); + } + + _logger.Warning( + "Attempt to access undefined enum value {EnumName}.{ValueName}", + enumName, + key + ); + + return DynValue.Nil; + } + ); + + // __newindex: prevents modifications (read-only) + metatable["__newindex"] = DynValue.NewCallback( + (ctx, args) => + { + var key = args[1].String; + + throw new ScriptRuntimeException($"Cannot modify enum {enumName}.{key}: enums are read-only"); + } + ); + + // __tostring: pretty print + metatable["__tostring"] = DynValue.NewCallback( + (ctx, args) => + { + return DynValue.NewString($"enum<{enumName}>"); + } + ); + + // Set the enum table first + var enumTableDynValue = DynValue.NewTable(enumTable); + + // Try to apply metatable (may not work perfectly in all MoonSharp versions) + try + { + // Create a reference for the metatable + var metatableValue = DynValue.NewTable(metatable); + enumTable.MetaTable = metatable; + } + catch + { + _logger.Warning("Could not apply metatable to enum {EnumName}, using fallback", enumName); + } + + // Register the enum table in globals + LuaScript.Globals[enumName] = enumTableDynValue; + + _logger.Debug( + "Registered enum {EnumName} with {ValueCount} values (read-only, case-insensitive)", + enumName, + enumValuesByName.Count + ); + } + + [RequiresUnreferencedCode("Enum metadata is discovered dynamically when building Lua documentation.")] + private void RegisterEnums() + { + var enumsFound = LuaDocumentationGenerator.FoundEnums.ToArray(); + + foreach (var enumType in enumsFound) + { + RegisterEnum(enumType); + } + } + + private void RegisterGlobalFunctions() + { + LuaScript.Globals["delay"] = (Func)(async milliseconds => + { + await Task.Delay(Math.Min(milliseconds, 5000)); + }); + + // NOTE: do NOT define a bare 'log' global here — the LogModule registered above already + // exposes 'log.info / log.warning / log.error' as a table; overwriting it with a function + // here would shadow that table and break log.info(...) calls from scripts. + + LuaScript.Globals["toString"] = (Func)(obj => obj?.ToString() ?? "nil"); + } + + private void RegisterManualModuleFunction(string moduleName, string functionName) + { + var functions = _manualModuleFunctions.GetOrAdd(moduleName, _ => new()); + functions.TryAdd(functionName, 0); + } + + private async Task RegisterScriptModulesAsync(CancellationToken cancellationToken) + { + foreach (var module in _scriptModules) + { + cancellationToken.ThrowIfCancellationRequested(); + + var scriptModuleAttribute = module.ModuleType.GetCustomAttribute(); + + if (scriptModuleAttribute is null) + { + continue; + } + + if (!_serviceProvider.IsRegistered(module.ModuleType)) + { + _serviceProvider.Register(module.ModuleType, Reuse.Singleton); + } + + var instance = _serviceProvider.GetService(module.ModuleType); + + if (instance is null) + { + throw new InvalidOperationException($"Unable to create instance of script module {module.ModuleType.Name}"); + } + + var moduleName = scriptModuleAttribute.Name; + _logger.Debug("Registering script module {Name}", moduleName); + + // Register the type with MoonSharp + UserData.RegisterType(module.ModuleType, InteropAccessMode.Reflection); + + // Create a table for the module + var moduleTable = CreateModuleTable(instance, module.ModuleType); + LuaScript.Globals[moduleName] = moduleTable; + + _loadedModules[moduleName] = instance; + } + + RegisterEnums(); + } + + /// + /// Disposes of the resources used by the LuaScriptEngineService. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + _loadedModules.Clear(); + _callbacks.Clear(); + _constants.Clear(); + + GC.SuppressFinalize(this); + + _logger.Debug("Lua engine disposed successfully"); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error during Lua engine disposal"); + } + finally + { + _disposed = true; + } + } +} diff --git a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj new file mode 100644 index 00000000..57b7c331 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj @@ -0,0 +1,24 @@ + + + + true + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + diff --git a/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs new file mode 100644 index 00000000..bc69e34c --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Utils/LuaDocumentationGenerator.cs @@ -0,0 +1,1054 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Text; +using SquidStd.Core.Extensions.Strings; +using SquidStd.Scripting.Lua.Attributes; +using SquidStd.Scripting.Lua.Attributes.Scripts; +using SquidStd.Scripting.Lua.Data.Internal; + +namespace SquidStd.Scripting.Lua.Utils; + +/// +/// Utility class for generating Lua meta files with EmmyLua/LuaLS annotations +/// Automatically creates meta.lua files with function signatures, types, and documentation +/// +[RequiresUnreferencedCode( + "This class uses reflection to analyze types for Lua meta generation and requires full type metadata." +)] +public static class LuaDocumentationGenerator +{ + private static readonly HashSet _processedTypes = new(); + private static readonly StringBuilder _classesBuilder = new(); + private static readonly StringBuilder _constantsBuilder = new(); + private static readonly StringBuilder _enumsBuilder = new(); + private static readonly HashSet _classTypesToGenerate = new(); + private static readonly Dictionary _recordTypeCache = new(); + private static readonly Lock _syncLock = new(); + + private static Func _nameResolver = name => name.ToSnakeCase(); + + /// + /// List of enums found during documentation generation + /// + public static List FoundEnums { get; } = new(16); + + /// + /// Adds a class type to be generated in the documentation + /// + public static void AddClassToGenerate(Type type) + { + ArgumentNullException.ThrowIfNull(type); + _classTypesToGenerate.Add(type); + } + + /// + /// Clears all internal caches and state + /// + public static void ClearCaches() + { + lock (_syncLock) + { + _processedTypes.Clear(); + _recordTypeCache.Clear(); + _classTypesToGenerate.Clear(); + FoundEnums.Clear(); + _classesBuilder.Clear(); + _constantsBuilder.Clear(); + _enumsBuilder.Clear(); + } + } + + [SuppressMessage("Trimming", "IL2075:Reflection", Justification = "Reflection is required for script module analysis"), + SuppressMessage( + "Trimming", + "IL2072:Reflection", + Justification = "Reflection is required for parameter and return type analysis" + )] + + /// + /// Generates Lua documentation meta file with all module functions, classes, and constants + /// + public static string GenerateDocumentation( + string appName, + string appVersion, + List scriptModules, + Dictionary constants, + Dictionary> manualModules, + Func? nameResolver = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appName); + ArgumentException.ThrowIfNullOrWhiteSpace(appVersion); + ArgumentNullException.ThrowIfNull(scriptModules); + ArgumentNullException.ThrowIfNull(constants); + ArgumentNullException.ThrowIfNull(manualModules); + + lock (_syncLock) + { + if (nameResolver != null) + { + _nameResolver = nameResolver; + } + + var sb = new StringBuilder(); + sb.AppendLine("---@meta"); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {appName} v{appVersion} Lua API"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- Auto-generated on {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine("---"); + sb.AppendLine(); + + // Save data before clearing (they may have been added via AddClassToGenerate, AddCommonXnaTypesToGenerate, or FoundEnums) + var typesToGenerate = new List(_classTypesToGenerate); + var foundEnums = new List(FoundEnums); + + // Reset processed types and builders + _processedTypes.Clear(); + _classesBuilder.Clear(); + _constantsBuilder.Clear(); + _enumsBuilder.Clear(); + _classTypesToGenerate.Clear(); + FoundEnums.Clear(); + + // Restore types and enums to generate + foreach (var type in typesToGenerate) + { + _classTypesToGenerate.Add(type); + } + + foreach (var enumType in foundEnums) + { + FoundEnums.Add(enumType); + } + + var distinctConstants = constants + .GroupBy(kvp => kvp.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + + ProcessConstants(distinctConstants); + sb.Append(_constantsBuilder); + + foreach (var module in scriptModules) + { + var scriptModuleAttribute = module.ModuleType.GetCustomAttribute(); + + if (scriptModuleAttribute is null) + { + continue; + } + + var moduleName = scriptModuleAttribute.Name; + var moduleHelpText = scriptModuleAttribute.HelpText; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {module.ModuleType.Name} module"); + + if (!string.IsNullOrWhiteSpace(moduleHelpText)) + { + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {moduleHelpText}"); + } + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@class {moduleName}"); + + // Get all methods with ScriptFunction attribute + var methods = module.ModuleType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.GetCustomAttribute() is not null) + .ToList(); + + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName}.{functionName} = function() end"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName} = {{}}"); + sb.AppendLine(); + + // Now generate detailed function documentation + foreach (var method in methods) + { + var scriptFunctionAttr = method.GetCustomAttribute(); + + if (scriptFunctionAttr is null) + { + continue; + } + + var functionName = string.IsNullOrWhiteSpace(scriptFunctionAttr.FunctionName) + ? _nameResolver(method.Name) + : scriptFunctionAttr.FunctionName; + var description = scriptFunctionAttr.HelpText ?? "No description available"; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {description}"); + sb.AppendLine("---"); + + // Add parameter documentation + var parameters = method.GetParameters(); + + foreach (var param in parameters) + { + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var paramType = isParams + ? ConvertToLuaType(param.ParameterType.GetElementType()!) + : ConvertToLuaType(param.ParameterType); + var paramName = isParams ? "..." : param.Name ?? $"param{Array.IndexOf(parameters, param)}"; + var paramDescription = GetParameterDescription(param, paramType); + sb.AppendLine( + CultureInfo.InvariantCulture, + $"---@param {_nameResolver(paramName)} {paramType} {paramDescription}" + ); + } + + // Add return type documentation + if (method.ReturnType != typeof(void) && method.ReturnType != typeof(Task)) + { + var returnType = ConvertToLuaType(method.ReturnType); + var returnDescription = GetReturnDescription(method.ReturnType, returnType); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@return {returnType} {returnDescription}"); + } + + // Function signature + sb.Append(CultureInfo.InvariantCulture, $"function {moduleName}.{functionName}("); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var paramName = isParams ? "..." : param.Name ?? $"param{i}"; + sb.Append(_nameResolver(paramName)); + + if (i < parameters.Length - 1) + { + sb.Append(", "); + } + } + + sb.AppendLine(") end"); + sb.AppendLine(); + } + } + + if (manualModules.Count > 0) + { + foreach (var manualModule in manualModules.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) + { + var moduleName = manualModule.Key; + var functions = manualModule.Value; + + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"--- {moduleName} module "); + sb.AppendLine("---"); + sb.AppendLine(CultureInfo.InvariantCulture, $"---@class {moduleName}"); + sb.AppendLine(CultureInfo.InvariantCulture, $"{moduleName} = {{}}"); + sb.AppendLine(); + + foreach (var function in functions.OrderBy(f => f, StringComparer.Ordinal)) + { + sb.AppendLine("---"); + sb.AppendLine("--- Dynamically registered function"); + sb.AppendLine("---"); + sb.AppendLine("---@param ... any"); + sb.AppendLine("---@return any"); + sb.AppendLine(CultureInfo.InvariantCulture, $"function {moduleName}.{function}(...) end"); + sb.AppendLine(); + } + } + } + + // Generate all classes that were collected during type conversion + GenerateAllClasses(); + + // Append enums and classes + sb.Append(_enumsBuilder); + sb.AppendLine(); + sb.Append(_classesBuilder); + + // Add global declarations for all registered types + sb.AppendLine(); + sb.AppendLine("--- Global type constructors"); + + foreach (var type in _classTypesToGenerate) + { + var typeName = type.Name; + sb.AppendLine(CultureInfo.InvariantCulture, $"{typeName} = {{}}"); + } + + return sb.ToString(); + } + } + + private static bool CanProcessType(Type type) + => true; + + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for Lua type conversion"), + SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for Lua type conversion"), + SuppressMessage("Trimming", "IL2062:Reflection", Justification = "Reflection is required for Lua type conversion")] + private static string ConvertToLuaType( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicConstructors + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + // Handle ref/out/in parameters (ByRef types) + if (type.IsByRef) + { + var underlyingType = type.GetElementType(); + + if (underlyingType is not null) + { + return ConvertToLuaType(underlyingType); + } + } + + // Handle void + if (type == typeof(void)) + { + return "nil"; + } + + // Handle string + if (type == typeof(string)) + { + return "string"; + } + + // Handle numbers + if (type == typeof(int) || + type == typeof(long) || + type == typeof(float) || + type == typeof(double) || + type == typeof(decimal) || + type == typeof(short) || + type == typeof(ushort) || + type == typeof(uint) || + type == typeof(ulong) || + type == typeof(byte) || + type == typeof(sbyte)) + { + return "number"; + } + + // Handle boolean + if (type == typeof(bool)) + { + return "boolean"; + } + + // Handle Guid + if (type == typeof(Guid)) + { + return "string"; + } + + // Handle DateTime types + if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) + { + return "number"; + } + + // Handle TimeSpan + if (type == typeof(TimeSpan)) + { + return "number"; + } + + // Handle object + if (type == typeof(object)) + { + return "any"; + } + + // Handle Task (async) + if (type == typeof(Task)) + { + return "nil"; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + { + var taskResultType = type.GetGenericArguments()[0]; + + return ConvertToLuaType(taskResultType); + } + + // Handle Span and Memory types + if (type.IsGenericType) + { + var typeDef = type.GetGenericTypeDefinition(); + + if (typeDef == typeof(Span<>) || + typeDef == typeof(Memory<>) || + typeDef == typeof(ReadOnlySpan<>) || + typeDef == typeof(ReadOnlyMemory<>)) + { + var elementType = type.GetGenericArguments()[0]; + + return $"{ConvertToLuaType(elementType)}[]"; + } + } + + // Handle arrays + if (type.IsArray) + { + var elementType = type.GetElementType(); + + if (elementType is null) + { + return "table"; + } + + return $"{ConvertToLuaType(elementType)}[]"; + } + + // Handle tuples + if (type.IsGenericType && type.Name.StartsWith("ValueTuple", StringComparison.Ordinal)) + { + var tupleArgs = type.GetGenericArguments(); + var typeList = string.Join(", ", tupleArgs.Select(ConvertToLuaType)); + + return $"table"; + } + + // Handle nullable types + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + var underlyingType = Nullable.GetUnderlyingType(type); + + if (underlyingType is null) + { + return "any"; + } + + return $"{ConvertToLuaType(underlyingType)}?"; + } + + // Handle generic types + if (type.IsGenericType) + { + var genericTypeDefinition = type.GetGenericTypeDefinition(); + var genericArgs = type.GetGenericArguments(); + + // Handle Dictionary + if (genericTypeDefinition == typeof(Dictionary<,>)) + { + var keyType = ConvertToLuaType(genericArgs[0]); + var valueType = ConvertToLuaType(genericArgs[1]); + + return $"table<{keyType}, {valueType}>"; + } + + // Handle IReadOnlyDictionary + if (genericTypeDefinition == typeof(IReadOnlyDictionary<,>)) + { + var keyType = ConvertToLuaType(genericArgs[0]); + var valueType = ConvertToLuaType(genericArgs[1]); + + return $"table<{keyType}, {valueType}>"; + } + + // Handle List, IEnumerable, ICollection, IList, IReadOnlyList, IReadOnlyCollection + if (genericTypeDefinition == typeof(List<>) || + genericTypeDefinition == typeof(IEnumerable<>) || + genericTypeDefinition == typeof(ICollection<>) || + genericTypeDefinition == typeof(IList<>) || + genericTypeDefinition == typeof(IReadOnlyList<>) || + genericTypeDefinition == typeof(IReadOnlyCollection<>)) + { + return $"{ConvertToLuaType(genericArgs[0])}[]"; + } + + // Handle Action delegates + if (genericTypeDefinition == typeof(Action) || + genericTypeDefinition.Name.StartsWith("Action`", StringComparison.Ordinal)) + { + return "fun()"; + } + + // Handle Func delegates + if (genericTypeDefinition.Name.StartsWith("Func`", StringComparison.Ordinal)) + { + var returnType = ConvertToLuaType(genericArgs[^1]); + + return $"fun():{returnType}"; + } + } + + // Handle enums + if (type.IsEnum) + { + GenerateEnumClass(type); + + return _nameResolver(type.Name); + } + + // Handle MoonSharp Closure (represents Lua functions) + if (type.Name == "Closure" && type.Namespace == "MoonSharp.Interpreter") + { + return "function"; + } + + // Handle record types + if (IsRecordType(type)) + { + var className = type.Name; + + if (_processedTypes.Contains(type)) + { + return className; + } + + _classTypesToGenerate.Add(type); + + return className; + } + + // Handle other complex types + if ((type.IsClass || type.IsValueType) && + !type.IsPrimitive && + type.Namespace is not null && + !type.Namespace.StartsWith("System", StringComparison.Ordinal)) + { + var className = type.Name; + + if (_processedTypes.Contains(type)) + { + return className; + } + + _classTypesToGenerate.Add(type); + + return className; + } + + // Handle delegates + if (typeof(Delegate).IsAssignableFrom(type)) + { + return "function"; + } + + return "any"; + } + + [SuppressMessage( + "Trimming", + "IL2072:Reflection", + Justification = "Reflection is required for constant value formatting" + )] + private static string FormatConstantValue(object? value, Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (value is null) + { + return "nil"; + } + + if (type == typeof(string)) + { + return $"\"{value}\""; + } + + if (type == typeof(bool)) + { + return value.ToString()!.ToLowerInvariant(); + } + + if (type.IsEnum) + { + return $"{_nameResolver(type.Name)}.{value}"; + } + + return value.ToString() ?? "nil"; + } + + /// + /// Generate all classes after collecting them, with robust circular dependency handling + /// + private static void GenerateAllClasses() + { + var processedInIteration = new HashSet(); + var remainingTypes = new List(_classTypesToGenerate); + var maxIterations = remainingTypes.Count + 5; + var iterationCount = 0; + + while (remainingTypes.Count > 0 && iterationCount < maxIterations) + { + iterationCount++; + processedInIteration.Clear(); + + for (var i = remainingTypes.Count - 1; i >= 0; i--) + { + var type = remainingTypes[i]; + + if (_processedTypes.Contains(type)) + { + remainingTypes.RemoveAt(i); + + continue; + } + + if (CanProcessType(type)) + { + GenerateClass(type); + processedInIteration.Add(type); + remainingTypes.RemoveAt(i); + } + } + + // If no progress was made, force process remaining types to avoid infinite loop + if (processedInIteration.Count == 0 && remainingTypes.Count > 0) + { + foreach (var type in remainingTypes) + { + if (!_processedTypes.Contains(type)) + { + GenerateClass(type); + } + } + + break; + } + } + } + + /// + /// Generate a single class with properties, constructors, and methods + /// + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for class generation"), + SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for class generation")] + private static void GenerateClass( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.PublicConstructors | + DynamicallyAccessedMemberTypes.PublicMethods + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + if (!_processedTypes.Add(type)) + { + return; + } + + var className = type.Name; + + _classesBuilder.AppendLine(); + _classesBuilder.AppendLine("---"); + + if (IsRecordType(type)) + { + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Record type {type.FullName}"); + } + else + { + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Class {type.FullName}"); + } + + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@class {className}"); + + // Generate properties with documentation (exclude indexers which have parameters) + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToList(); + + foreach (var property in properties) + { + var propertyName = property.Name; + + if (string.IsNullOrEmpty(propertyName)) + { + continue; + } + + var propertyType = ConvertToLuaType(property.PropertyType); + var xmlDocAttr = property.GetCustomAttribute(); + var description = xmlDocAttr?.Description ?? "Property"; + var luaFieldAttr = property.GetCustomAttribute(); + + // Use original property name for built-in types (XNA), apply resolver for custom types + var displayName = luaFieldAttr?.Name ?? + (type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true + ? propertyName + : _nameResolver(propertyName)); + + _classesBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field {displayName} {propertyType} # {description}" + ); + } + + // Generate public constructors + var constructors = type.GetConstructors(BindingFlags.Public) + .Where(c => c.GetParameters().Length > 0) + .ToList(); + + if (constructors.Count > 0) + { + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine("--- Constructors:"); + + // Check if it's an XNA type to preserve original names + var isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + + foreach (var constructor in constructors) + { + _classesBuilder.Append("---@overload fun("); + + var parameters = constructor.GetParameters(); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var paramType = ConvertToLuaType(param.ParameterType); + + // Use original parameter names for XNA types, apply resolver for custom types + var paramName = isXnaType + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); + _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); + + if (i < parameters.Length - 1) + { + _classesBuilder.Append(", "); + } + } + + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"):void"); + } + } + + // Generate public methods (excluding getters/setters from properties) + var propertyMethods = new HashSet(); + + foreach (var prop in properties) + { + if (prop.GetMethod != null) + { + propertyMethods.Add(prop.GetMethod.Name); + } + + if (prop.SetMethod != null) + { + propertyMethods.Add(prop.SetMethod.Name); + } + } + + // Methods to exclude (system methods that are not useful for Lua) + var excludedMethods = new HashSet + { + "GetHashCode", + "Equals", + "ToString", + "GetType", + "MemberwiseClone" + }; + + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where( + m => !propertyMethods.Contains(m.Name) && + !m.IsSpecialName && + !excludedMethods.Contains(m.Name) + ) + .ToList(); + + if (methods.Count > 0) + { + _classesBuilder.AppendLine("---"); + _classesBuilder.AppendLine("--- Methods:"); + + // Group methods by name to handle overloads + var methodsByName = methods.GroupBy(m => m.Name); + + // Check if it's an XNA type to preserve original names + var isXnaType = type.Namespace?.StartsWith("Microsoft.Xna.Framework", StringComparison.Ordinal) == true; + + foreach (var methodGroup in methodsByName) + { + var methodName = methodGroup.Key; + + foreach (var method in methodGroup) + { + var returnType = method.ReturnType == typeof(void) + ? "nil" + : ConvertToLuaType(method.ReturnType); + + var parameters = method.GetParameters(); + + _classesBuilder.Append("---@overload fun("); + + for (var i = 0; i < parameters.Length; i++) + { + var param = parameters[i]; + var paramType = ConvertToLuaType(param.ParameterType); + + // Use original parameter names for XNA types, apply resolver for custom types + var paramName = isXnaType + ? param.Name ?? $"param{i}" + : _nameResolver(param.Name ?? $"param{i}"); + _classesBuilder.Append(CultureInfo.InvariantCulture, $"{paramName}: {paramType}"); + + if (i < parameters.Length - 1) + { + _classesBuilder.Append(", "); + } + } + + _classesBuilder.AppendLine(CultureInfo.InvariantCulture, $"):{returnType}"); + } + } + } + + _classesBuilder.AppendLine(); + } + + [SuppressMessage( + "Trimming", + "IL2070:Reflection", + Justification = "Reflection is required for enum class generation" + )] + private static void GenerateEnumClass(Type enumType) + { + ArgumentNullException.ThrowIfNull(enumType); + + if (!enumType.IsEnum) + { + throw new ArgumentException("Type must be an enum", nameof(enumType)); + } + + if (!_processedTypes.Add(enumType)) + { + return; + } + + FoundEnums.Add(enumType); + + _enumsBuilder.AppendLine(); + _enumsBuilder.AppendLine("---"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Enum: {enumType.FullName}"); + _enumsBuilder.AppendLine("--- This enum is read-only and case-insensitive"); + _enumsBuilder.AppendLine("---"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@class {_nameResolver(enumType.Name)}"); + + var enumValues = Enum.GetNames(enumType); + var enumUnderlyingType = Enum.GetUnderlyingType(enumType); + + foreach (var value in enumValues) + { + try + { + var enumValue = Enum.Parse(enumType, value); + var numericValue = Convert.ChangeType(enumValue, enumUnderlyingType, CultureInfo.InvariantCulture); + _enumsBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field public readonly {value} number # Enum value: {numericValue}" + ); + } + catch (Exception ex) when (ex is InvalidCastException or OverflowException) + { + _enumsBuilder.AppendLine( + CultureInfo.InvariantCulture, + $"---@field public readonly {value} number # Unable to determine value" + ); + } + } + + _enumsBuilder.AppendLine(); + var enumTypeName = _nameResolver(enumType.Name); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Read-only enum table"); + _enumsBuilder.AppendLine(CultureInfo.InvariantCulture, $"{enumTypeName} = {{}}"); + _enumsBuilder.AppendLine(); + } + + /// + /// Gets enhanced parameter description with type information + /// + private static string GetParameterDescription(ParameterInfo param, string luaType) + { + var isParams = param.IsDefined(typeof(ParamArrayAttribute), false); + var baseName = param.Name ?? "parameter"; + + string description; + + if (isParams) + { + description = "The arguments"; + } + else + { + description = $"The {baseName.ToLowerInvariant()}"; + } + + if (luaType.Contains("number")) + { + description += isParams ? " values" : " value"; + } + else if (luaType.Contains("string")) + { + description += isParams ? " texts" : " text"; + } + else if (luaType.Contains("boolean")) + { + description += isParams ? " flags" : " flag"; + } + else if (luaType.Contains("[]") || luaType.Contains("table")) + { + description += " table"; + } + else + { + description += isParams ? $" of type {luaType}" : $" of type {luaType}"; + } + + if (param.IsOptional && !isParams) + { + description += " (optional)"; + } + + return description; + } + + /// + /// Gets enhanced return description with type information + /// + private static string GetReturnDescription(Type returnType, string luaType) + { + var description = "The "; + + if (luaType.Contains("number")) + { + description += "computed numeric value"; + } + else if (luaType.Contains("string")) + { + description += "resulting text"; + } + else if (luaType.Contains("boolean")) + { + description += "result of the operation"; + } + else if (luaType.Contains("[]") || luaType.Contains("table")) + { + description += "collection of results"; + } + else if (luaType.Contains("nil")) + { + description += "operation completes without returning a value"; + } + else + { + description += $"result as {luaType}"; + } + + return description; + } + + /// + /// Check if a type is a C# record type + /// + [SuppressMessage("Trimming", "IL2070:Reflection", Justification = "Reflection is required for record type detection")] + private static bool IsRecordType( + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.NonPublicProperties | DynamicallyAccessedMemberTypes.PublicMethods + )] + Type type + ) + { + ArgumentNullException.ThrowIfNull(type); + + if (_recordTypeCache.TryGetValue(type, out var isRecord)) + { + return isRecord; + } + + if (!type.IsClass) + { + _recordTypeCache[type] = false; + + return false; + } + + var equalityContract = type.GetProperty( + "EqualityContract", + BindingFlags.NonPublic | BindingFlags.Instance + ); + + if (equalityContract is not null && equalityContract.PropertyType == typeof(Type)) + { + _recordTypeCache[type] = true; + + return true; + } + + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + var hasCompilerGeneratedToString = methods.Any( + m => + m.Name == "ToString" && + m.GetParameters().Length == 0 && + m.GetCustomAttributes().Any(attr => attr.GetType().Name.Contains("CompilerGenerated")) + ); + + var result = hasCompilerGeneratedToString; + _recordTypeCache[type] = result; + + return result; + } + + [SuppressMessage("Trimming", "IL2072:Reflection", Justification = "Reflection is required for constant type analysis")] + private static void ProcessConstants(Dictionary constants) + { + ArgumentNullException.ThrowIfNull(constants); + + if (constants.Count == 0) + { + return; + } + + _constantsBuilder.AppendLine("--- Global constants"); + _constantsBuilder.AppendLine(); + + foreach (var constant in constants) + { + var constantName = constant.Key ?? "unnamed"; + var constantValue = constant.Value; + var constantType = constantValue?.GetType() ?? typeof(object); + + var luaType = ConvertToLuaType(constantType); + var formattedValue = FormatConstantValue(constantValue, constantType); + + _constantsBuilder.AppendLine("---"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- {constantName} constant"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"--- Value: {formattedValue}"); + _constantsBuilder.AppendLine("---"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"---@type {luaType}"); + _constantsBuilder.AppendLine(CultureInfo.InvariantCulture, $"{constantName} = {formattedValue}"); + _constantsBuilder.AppendLine(); + } + + _constantsBuilder.AppendLine(); + } +} diff --git a/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs new file mode 100644 index 00000000..92d9d929 --- /dev/null +++ b/src/SquidStd.Services.Core/Extensions/Logger/SquidStdLogRollingIntervalExtensions.cs @@ -0,0 +1,27 @@ +using Serilog; +using SquidStd.Core.Types; + +namespace SquidStd.Services.Core.Extensions.Logger; + +/// +/// Extension methods for converting SquidStd logger options to Serilog values. +/// +public static class SquidStdLogRollingIntervalExtensions +{ + /// + /// Converts a SquidStd rolling interval to a Serilog rolling interval. + /// + /// The rolling interval to convert. + /// The corresponding Serilog rolling interval. + public static RollingInterval ToSerilogRollingInterval(this SquidStdLogRollingIntervalType interval) + => interval switch + { + SquidStdLogRollingIntervalType.Infinite => RollingInterval.Infinite, + SquidStdLogRollingIntervalType.Year => RollingInterval.Year, + SquidStdLogRollingIntervalType.Month => RollingInterval.Month, + SquidStdLogRollingIntervalType.Day => RollingInterval.Day, + SquidStdLogRollingIntervalType.Hour => RollingInterval.Hour, + SquidStdLogRollingIntervalType.Minute => RollingInterval.Minute, + _ => RollingInterval.Day + }; +} diff --git a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs index 0af02c63..51d1d88b 100644 --- a/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs +++ b/src/SquidStd.Services.Core/Extensions/RegisterDefaultServicesExtensions.cs @@ -3,14 +3,23 @@ using SquidStd.Abstractions.Extensions.Config; using SquidStd.Abstractions.Extensions.Container; using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Data.Storage; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Core.Interfaces.Storage; using SquidStd.Core.Interfaces.Threading; using SquidStd.Core.Interfaces.Timing; +using SquidStd.Core.Json; using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Storage; namespace SquidStd.Services.Core.Extensions; @@ -22,6 +31,38 @@ public static class RegisterDefaultServicesExtensions /// Container that receives the core service registrations. extension(IContainer container) { + /// + /// Registers the default config manager service as a singleton instance. + /// + /// The logical config name or YAML file name. + /// The directory where the config file is searched. + /// The same container for chaining. + /// + /// Registers the default JSON data serializer for and + /// (same singleton instance). + /// + /// The same container for chaining. + public IContainer RegisterDataSerializer() + { + var serializer = new JsonDataSerializer(); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + container.RegisterInstance(serializer, IfAlreadyRegistered.Keep); + + return container; + } + + public IContainer RegisterConfigManagerService(string configName, string configDirectory) + { + var service = new ConfigManagerService(container, configName, configDirectory); + container.RegisterInstance(service, IfAlreadyRegistered.Replace); + container.RegisterInstance(service, IfAlreadyRegistered.Replace); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(IConfigManagerService), typeof(ConfigManagerService), -1000) + ); + + return container; + } + /// /// Registers the default SquidStd core services using the default config file location. /// @@ -37,82 +78,105 @@ public IContainer RegisterCoreServices() /// The same container for chaining. public IContainer RegisterCoreServices(string configName, string configDirectory) { + container.RegisterDataSerializer(); container.RegisterDefaultCoreConfigSections(); container.RegisterConfigManagerService(configName, configDirectory); container.RegisterEventBusService(); container.RegisterJobSystemService(); container.RegisterMainThreadDispatcherService(); container.RegisterTimerWheelService(); + container.RegisterMetricsCollectionService(); + container.RegisterStorageServices(); + container.RegisterSecretServices(); return container; } /// - /// Registers the default config manager service as a singleton instance. + /// Registers the default SquidStd core configuration sections. /// - /// The logical config name or YAML file name. - /// The directory where the config file is searched. /// The same container for chaining. - public IContainer RegisterConfigManagerService(string configName, string configDirectory) + public IContainer RegisterDefaultCoreConfigSections() { - var service = new ConfigManagerService(container, configName, configDirectory); - container.RegisterInstance(service, IfAlreadyRegistered.Replace); - container.RegisterInstance(service, IfAlreadyRegistered.Replace); - container.AddToRegisterTypedList( - new ServiceRegistrationData(typeof(IConfigManagerService), typeof(ConfigManagerService), -1000) - ); + container.RegisterConfigSection("logger", static () => new SquidStdLoggerOptions(), -1000); + container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); + container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); return container; } /// - /// Registers the default SquidStd core configuration sections. + /// Registers the default event bus service in the container. /// /// The same container for chaining. - public IContainer RegisterDefaultCoreConfigSections() + public IContainer RegisterEventBusService() + => container.RegisterStdService(-1); + + /// + /// Registers the default job system service in the container. + /// + /// The same container for chaining. + public IContainer RegisterJobSystemService() { - container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); - container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); - return container; + return container.RegisterStdService(-1); } /// - /// Registers the default event bus service in the container. + /// Registers the default metrics collection service in the container. /// /// The same container for chaining. - public IContainer RegisterEventBusService() + public IContainer RegisterMetricsCollectionService() { - return container.RegisterStdService(-1); + container.RegisterConfigSection("metrics", static () => new MetricsConfig(), -80); + + return container.RegisterStdService(1000); } /// - /// Registers the default job system service in the container. + /// Registers default local storage services in the container. /// /// The same container for chaining. - public IContainer RegisterJobSystemService() + public IContainer RegisterStorageServices() { - container.RegisterConfigSection("jobs", static () => new JobsConfig(), -100); + container.RegisterConfigSection("storage", static () => new StorageConfig(), -70); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); - return container.RegisterStdService(-1); + return container; } /// - /// Registers the default main-thread dispatcher service in the container. + /// Registers default encrypted local secret services in the container. /// /// The same container for chaining. - public IContainer RegisterMainThreadDispatcherService() + public IContainer RegisterSecretServices() { - return container.RegisterStdService(-1); + container.RegisterConfigSection("secrets", static () => new SecretsConfig(), -60); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + return container; } + /// + /// Registers the default main-thread dispatcher service in the container. + /// + /// The same container for chaining. + public IContainer RegisterMainThreadDispatcherService() + => container.RegisterStdService(-1); + /// /// Registers the default timer wheel service in the container. /// /// The same container for chaining. public IContainer RegisterTimerWheelService() { - container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); + container.RegisterConfigSection("timerWheel", static () => new TimerWheelConfig(), -90); return container.RegisterStdService(-1); } diff --git a/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs new file mode 100644 index 00000000..dbed6903 --- /dev/null +++ b/src/SquidStd.Services.Core/Extensions/RegisterSchedulerServicesExtension.cs @@ -0,0 +1,32 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Services.Core.Services.Scheduling; + +namespace SquidStd.Services.Core.Extensions; + +/// +/// Extension methods for registering the SquidStd cron scheduler. +/// +public static class RegisterSchedulerServicesExtension +{ + /// Container that receives the scheduler registrations. + extension(IContainer container) + { + /// + /// Registers the timer wheel pump and the cron scheduler. Must be called after + /// RegisterCoreServices so that ITimerService and IJobSystem exist. + /// + /// The same container for chaining. + public IContainer RegisterSchedulerServices() + { + container.RegisterConfigSection("timerWheelPump", static () => new TimerWheelPumpConfig(), -90); + container.RegisterStdService(-1); + container.RegisterStdService(-1); + + return container; + } + } +} diff --git a/src/SquidStd.Services.Core/README.md b/src/SquidStd.Services.Core/README.md new file mode 100644 index 00000000..f0a16681 --- /dev/null +++ b/src/SquidStd.Services.Core/README.md @@ -0,0 +1,59 @@ +

+ SquidStd +

+ +

SquidStd.Services.Core

+ +

+ NuGet + Downloads + license +

+ +Concrete implementations of the SquidStd.Core contracts, wired for DryIoc. A single +`RegisterCoreServices()` call brings up the configuration manager, event bus, job system, timer wheel, +main-thread dispatcher, metrics collection, storage, and secrets services. + +## Install + +```bash +dotnet add package SquidStd.Services.Core +``` + +## Features + +- One-line bootstrap: `container.RegisterCoreServices()` registers the full default service set. +- `ConfigManagerService` — loads/saves YAML config sections and substitutes `$ENV_VAR` tokens. +- `EventBusService` — in-process publish/subscribe over `IEvent`. +- `JobSystemService` — background job execution; `TimerWheelService` + cron scheduling for timed work. +- `MainThreadDispatcherService` — marshal work back onto a main thread. +- `MetricsCollectionService` — aggregates `IMetricProvider` samples. +- File storage, object storage, and AES-GCM-protected secret store. + +## Usage + +```csharp +using DryIoc; +using SquidStd.Services.Core.Extensions; + +var container = new Container(); + +// Registers config manager + event bus + jobs + timer wheel + dispatcher + metrics + storage + secrets. +container.RegisterCoreServices("squidstd", Directory.GetCurrentDirectory()); +``` + +## Key types + +| Type | Purpose | +|------|---------| +| `RegisterDefaultServicesExtensions` | `RegisterCoreServices()` / `RegisterConfigManagerService()` entry points. | +| `ConfigManagerService` | YAML config load/save with env-var substitution. | +| `EventBusService` | In-process event bus implementation. | +| `JobSystemService` | Background job execution. | +| `TimerWheelService` | Timer-wheel scheduling. | +| `MainThreadDispatcherService` | Main-thread work dispatch. | +| `MetricsCollectionService` | Metric sample aggregation. | + +## License + +MIT — part of [SquidStd](https://github.com/tgiachi/SquidStd). diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs new file mode 100644 index 00000000..e870864a --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -0,0 +1,347 @@ +using DryIoc; +using Serilog; +using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Extensions.Logger; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Extensions.Logger; +using SquidStd.Services.Core.Types; + +namespace SquidStd.Services.Core.Services.Bootstrap; + +/// +/// Default SquidStd bootstrapper and service lifecycle orchestrator. +/// +public sealed class SquidStdBootstrap : ISquidStdBootstrap +{ + private readonly Lock _syncRoot = new(); + private readonly List _startedServices = []; + private readonly bool _ownsContainer; + private int _disposed; + private bool _loggerConfigured; + private BootstrapStateType _state; + + /// + public SquidStdOptions Options { get; } + + /// + public IContainer Container { get; } + + /// + /// Initializes a bootstrapper with default options. + /// + public SquidStdBootstrap() + : this(new SquidStdOptions()) + { + } + + /// + /// Initializes a bootstrapper with the specified options. + /// + /// Bootstrap options used to register core services. + public SquidStdBootstrap(SquidStdOptions options) + : this(options, new Container(), true) + { + } + + private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ownsContainer) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(container); + ArgumentException.ThrowIfNullOrWhiteSpace(options.ConfigName); + ArgumentException.ThrowIfNullOrWhiteSpace(options.RootDirectory); + + Options = options; + Container = container; + _ownsContainer = ownsContainer; + + Container.RegisterInstance(this, IfAlreadyRegistered.Replace); + Container.RegisterInstance(this, IfAlreadyRegistered.Replace); + Container.RegisterInstance(Options, IfAlreadyRegistered.Replace); + Container.RegisterCoreServices(Options.ConfigName, Options.RootDirectory); + } + + /// + /// Creates a bootstrapper with default options. + /// + /// The created bootstrapper. + public static SquidStdBootstrap Create() + => new(); + + /// + /// Creates a bootstrapper with the specified options. + /// + /// Bootstrap options used to register core services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options) + => new(options); + + /// + /// Creates a bootstrapper using an externally owned DryIoc container. + /// + /// Bootstrap options used to register core services. + /// Externally owned container that receives SquidStd services. + /// The created bootstrapper. + public static SquidStdBootstrap Create(SquidStdOptions options, IContainer container) + => new(options, container, false); + + /// + public ISquidStdBootstrap ConfigureService(Func configure) + => ConfigureServices(configure); + + /// + public ISquidStdBootstrap ConfigureServices(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + ThrowIfDisposed(); + + lock (_syncRoot) + { + if (_state != BootstrapStateType.Created) + { + throw new InvalidOperationException("Services cannot be configured after bootstrap start."); + } + } + + var configuredContainer = configure(Container); + + return !ReferenceEquals(configuredContainer, Container) + ? throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance.") + : this; + } + + /// + public TService Resolve() + { + ThrowIfDisposed(); + + return Container.Resolve(); + } + + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + MarkStarting(); + + try + { + var registrations = GetServiceRegistrations(); + var startedInstances = new HashSet(ReferenceEqualityComparer.Instance); + + for (var i = 0; i < registrations.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var registration = registrations[i]; + var instance = Container.Resolve(registration.ServiceType); + + if (instance is not ISquidStdService service || !startedInstances.Add(service)) + { + continue; + } + + await service.StartAsync(cancellationToken); + _startedServices.Add(service); + + if (service is IConfigManagerService) + { + ConfigureLogger(); + } + } + } + catch + { + MarkCreated(); + + throw; + } + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (!MarkStopping()) + { + return; + } + + try + { + for (var i = _startedServices.Count - 1; i >= 0; i--) + { + cancellationToken.ThrowIfCancellationRequested(); + await _startedServices[i].StopAsync(cancellationToken); + } + } + finally + { + _startedServices.Clear(); + } + } + + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + finally + { + await StopAsync(CancellationToken.None); + } + } + + private void ConfigureLogger() + { + if (!Container.IsRegistered()) + { + return; + } + + var options = Container.Resolve(); + var loggerConfiguration = new LoggerConfiguration(); + + if (options.MinimumLevel != LogLevelType.None) + { + var minimumLevel = options.MinimumLevel.ToSerilogLogLevel(); + loggerConfiguration.MinimumLevel.Is(minimumLevel); + + if (options.EnableConsole) + { + loggerConfiguration.WriteTo.Console(restrictedToMinimumLevel: minimumLevel); + } + + if (options.EnableFile) + { + loggerConfiguration.WriteTo.File( + ResolveLogPath(options), + rollingInterval: options.RollingInterval.ToSerilogRollingInterval(), + restrictedToMinimumLevel: minimumLevel + ); + } + } + + var logger = loggerConfiguration.CreateLogger(); + Log.Logger = logger; + Container.RegisterInstance(logger, IfAlreadyRegistered.Replace); + _loggerConfigured = true; + } + + private ServiceRegistrationData[] GetServiceRegistrations() + { + if (!Container.IsRegistered>()) + { + return []; + } + + return + [ + .. Container.Resolve>() + .OrderBy(registration => registration.Priority) + .ThenBy(registration => registration.ServiceType.FullName, StringComparer.Ordinal) + .ThenBy(registration => registration.ImplementationType.FullName, StringComparer.Ordinal) + ]; + } + + private string ResolveLogPath(SquidStdLoggerOptions options) + { + var logDirectory = string.IsNullOrWhiteSpace(options.LogDirectory) ? "logs" : options.LogDirectory; + var fileName = string.IsNullOrWhiteSpace(options.FileName) ? "squidstd-.log" : options.FileName; + var directory = Path.IsPathRooted(logDirectory) + ? logDirectory + : Path.Combine(Options.RootDirectory, logDirectory); + + return Path.Combine(directory, fileName); + } + + private void MarkStarting() + { + lock (_syncRoot) + { + if (_state == BootstrapStateType.Started) + { + return; + } + + if (_state == BootstrapStateType.Stopped) + { + throw new InvalidOperationException("Bootstrap cannot be restarted after stop."); + } + + _state = BootstrapStateType.Started; + } + } + + private void MarkCreated() + { + lock (_syncRoot) + { + _state = BootstrapStateType.Created; + } + } + + private bool MarkStopping() + { + lock (_syncRoot) + { + if (_state == BootstrapStateType.Stopped) + { + return false; + } + + if (_state == BootstrapStateType.Created) + { + _state = BootstrapStateType.Stopped; + + return false; + } + + _state = BootstrapStateType.Stopped; + + return true; + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(SquidStdBootstrap)); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + await StopAsync(CancellationToken.None); + } + finally + { + if (_loggerConfigured) + { + await Log.CloseAndFlushAsync(); + } + + if (_ownsContainer) + { + Container.Dispose(); + } + } + } +} diff --git a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs index 18b80a19..038d96ef 100644 --- a/src/SquidStd.Services.Core/Services/ConfigManagerService.cs +++ b/src/SquidStd.Services.Core/Services/ConfigManagerService.cs @@ -1,6 +1,8 @@ +using System.Reflection; using DryIoc; using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Extensions.Env; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Yaml; @@ -15,6 +17,18 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi private readonly Dictionary _values = []; private int _started; + /// + public string ConfigName { get; } + + /// + public string ConfigDirectory { get; } + + /// + public string ConfigPath { get; } + + /// + public IReadOnlyCollection Entries => GetEntries(); + /// /// Initializes the config manager service. /// @@ -23,7 +37,6 @@ public sealed class ConfigManagerService : IConfigManagerService, ISquidStdServi /// Directory where the configuration file is searched. public ConfigManagerService(IContainer container, string configName, string configDirectory) { - ArgumentNullException.ThrowIfNull(container); ArgumentException.ThrowIfNullOrWhiteSpace(configName); ArgumentException.ThrowIfNullOrWhiteSpace(configDirectory); @@ -33,18 +46,6 @@ public ConfigManagerService(IContainer container, string configName, string conf ConfigPath = ResolveConfigPath(configName, ConfigDirectory); } - /// - public string ConfigName { get; } - - /// - public string ConfigDirectory { get; } - - /// - public string ConfigPath { get; } - - /// - public IReadOnlyCollection Entries => GetEntries(); - /// public string Compose() => YamlUtils.SerializeSections(BuildSectionMap()); @@ -66,7 +67,10 @@ public void Load() var entry = entries[i]; var value = string.IsNullOrWhiteSpace(yaml) ? entry.CreateDefault() - : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? entry.CreateDefault(); + : YamlUtils.DeserializeSection(yaml, entry.SectionName, entry.ConfigType) ?? + entry.CreateDefault(); + + ApplyEnvSubstitution(value, new HashSet(ReferenceEqualityComparer.Instance)); _values[entry.ConfigType] = value; _container.RegisterInstance(entry.ConfigType, value, IfAlreadyRegistered.Replace); @@ -135,10 +139,52 @@ private List GetRegistrations() return []; } - return _container.Resolve>() + return + [ + .. _container.Resolve>() .OrderBy(entry => entry.Priority) .ThenBy(entry => entry.SectionName, StringComparer.Ordinal) - .ToList(); + ]; + } + + private static void ApplyEnvSubstitution(object? instance, HashSet visited) + { + if (instance is null || !visited.Add(instance)) + { + return; + } + + var type = instance.GetType(); + + if (type.Namespace is null || !type.Namespace.StartsWith("SquidStd", StringComparison.Ordinal)) + { + return; + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + if (property.PropertyType == typeof(string) && property.CanRead && property.CanWrite) + { + var current = (string?)property.GetValue(instance); + + if (!string.IsNullOrEmpty(current)) + { + property.SetValue(instance, current.ReplaceEnv()); + } + + continue; + } + + if (property.PropertyType.IsClass && property.PropertyType != typeof(string) && property.CanRead) + { + ApplyEnvSubstitution(property.GetValue(instance), visited); + } + } } private static string ResolveConfigPath(string configName, string configDirectory) diff --git a/src/SquidStd.Services.Core/Services/EventBusService.cs b/src/SquidStd.Services.Core/Services/EventBusService.cs index 69ca6164..8d969a26 100644 --- a/src/SquidStd.Services.Core/Services/EventBusService.cs +++ b/src/SquidStd.Services.Core/Services/EventBusService.cs @@ -1,5 +1,6 @@ using System.Threading.Channels; using SquidStd.Core.Interfaces.Events; +using SquidStd.Services.Core.Services.Internal; namespace SquidStd.Services.Core.Services; @@ -21,7 +22,7 @@ public sealed class EventBusService : IEventBus, IDisposable public EventBusService() { _dispatches = Channel.CreateUnbounded( - new UnboundedChannelOptions + new() { SingleReader = true, SingleWriter = false @@ -30,22 +31,6 @@ public EventBusService() _dispatcher = Task.Run(ProcessDispatchesAsync); } - /// - public void RegisterListener(ISyncEventListener listener) - where TEvent : IEvent - { - ArgumentNullException.ThrowIfNull(listener); - AddListener(_syncListeners, listener); - } - - /// - public void RegisterAsyncListener(IAsyncEventListener listener) - where TEvent : IEvent - { - ArgumentNullException.ThrowIfNull(listener); - AddListener(_asyncListeners, listener); - } - /// public void Publish(TEvent eventData) where TEvent : IEvent @@ -93,19 +78,20 @@ public async Task PublishAsync(TEvent eventData, CancellationToken cance await dispatch.Completion; } - /// - /// Stops the internal dispatcher. - /// - public void Dispose() + /// + public void RegisterAsyncListener(IAsyncEventListener listener) + where TEvent : IEvent { - if (_disposed) - { - return; - } + ArgumentNullException.ThrowIfNull(listener); + AddListener(_asyncListeners, listener); + } - _disposed = true; - _dispatches.Writer.TryComplete(); - _dispatcher.GetAwaiter().GetResult(); + /// + public void RegisterListener(ISyncEventListener listener) + where TEvent : IEvent + { + ArgumentNullException.ThrowIfNull(listener); + AddListener(_syncListeners, listener); } private void AddListener(Dictionary> listenersByType, object listener) @@ -127,6 +113,16 @@ private void AddListener(Dictionary> listenersByType, } } + private void Enqueue(EventDispatch dispatch) + { + ThrowIfDisposed(); + + if (!_dispatches.Writer.TryWrite(dispatch)) + { + throw new ObjectDisposedException(nameof(EventBusService)); + } + } + private TListener[] GetListeners(Dictionary> listenersByType) where TEvent : IEvent where TListener : class @@ -144,16 +140,6 @@ private TListener[] GetListeners(Dictionary + /// Stops the internal dispatcher. + /// + public void Dispose() { - private readonly CancellationToken _cancellationToken; - private readonly Func _dispatch; - private readonly TaskCompletionSource _completion; - - public Task Completion => _completion.Task; - - public EventDispatch(Func dispatch, CancellationToken cancellationToken) + if (_disposed) { - _dispatch = dispatch; - _cancellationToken = cancellationToken; - _completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return; } - public async Task ExecuteAsync() - { - try - { - await _dispatch(); - _completion.TrySetResult(); - } - catch (OperationCanceledException) - { - _completion.TrySetCanceled(_cancellationToken); - } - catch (Exception exception) - { - _completion.TrySetException(exception); - } - } + _disposed = true; + _dispatches.Writer.TryComplete(); + _dispatcher.GetAwaiter().GetResult(); } } diff --git a/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs new file mode 100644 index 00000000..4cbd953d --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/CronJobEntry.cs @@ -0,0 +1,21 @@ +using Cronos; + +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Internal mutable state for a registered cron job. +/// +internal sealed class CronJobEntry +{ + public required string JobId { get; init; } + public required string Name { get; init; } + public required string CronText { get; init; } + public required CronExpression Expression { get; init; } + public required Func Handler { get; init; } + + public string? TimerId; + public DateTime? NextOccurrenceUtc; + public DateTime? LastRunUtc; + public int Running; + public long RunCount; +} diff --git a/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs b/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs new file mode 100644 index 00000000..751616c8 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/EventDispatch.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// A queued event dispatch with a completion signal, executed by the event bus dispatcher loop. +/// +internal sealed class EventDispatch +{ + private readonly CancellationToken _cancellationToken; + private readonly Func _dispatch; + private readonly TaskCompletionSource _completion; + + public Task Completion => _completion.Task; + + public EventDispatch(Func dispatch, CancellationToken cancellationToken) + { + _dispatch = dispatch; + _cancellationToken = cancellationToken; + _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async Task ExecuteAsync() + { + try + { + await _dispatch(); + _completion.TrySetResult(); + } + catch (OperationCanceledException) + { + _completion.TrySetCanceled(_cancellationToken); + } + catch (Exception exception) + { + _completion.TrySetException(exception); + } + } +} diff --git a/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs b/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs new file mode 100644 index 00000000..048fcd98 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Internal/StoragePathResolver.cs @@ -0,0 +1,53 @@ +namespace SquidStd.Services.Core.Services.Internal; + +/// +/// Resolves logical storage keys into paths constrained to one root directory. +/// +internal static class StoragePathResolver +{ + public static string ResolveFilePath(string rootDirectory, string key, string? extension = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (Path.IsPathRooted(key)) + { + throw new ArgumentException("Storage key cannot be rooted.", nameof(key)); + } + + var normalizedRoot = Path.GetFullPath(rootDirectory); + var segments = key.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length == 0) + { + throw new ArgumentException("Storage key cannot be empty.", nameof(key)); + } + + for (var i = 0; i < segments.Length; i++) + { + if (segments[i] is "." or "..") + { + throw new ArgumentException("Storage key cannot contain relative path segments.", nameof(key)); + } + } + + var relativePath = Path.Combine(segments); + + if (!string.IsNullOrWhiteSpace(extension)) + { + relativePath += extension; + } + + var fullPath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath)); + var rootPrefix = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + + if (!fullPath.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException("Storage key resolves outside the storage root.", nameof(key)); + } + + return fullPath; + } +} diff --git a/src/SquidStd.Services.Core/Services/JobSystemService.cs b/src/SquidStd.Services.Core/Services/JobSystemService.cs index 5b3f6490..07ae39d1 100644 --- a/src/SquidStd.Services.Core/Services/JobSystemService.cs +++ b/src/SquidStd.Services.Core/Services/JobSystemService.cs @@ -23,6 +23,18 @@ public sealed class JobSystemService : IJobSystem, ISquidStdService private int _pendingCount; private int _started; + /// + public int ActiveCount => Volatile.Read(ref _activeCount); + + /// + public long CompletedCount => Interlocked.Read(ref _completedCount); + + /// + public int PendingCount => Volatile.Read(ref _pendingCount); + + /// + public int WorkerCount { get; } + /// /// Initializes the job system service. /// @@ -33,7 +45,7 @@ public JobSystemService(JobsConfig config) _config = config; WorkerCount = ResolveWorkerCount(config.WorkerThreadCount); _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions + new() { SingleReader = false, SingleWriter = false @@ -43,25 +55,7 @@ public JobSystemService(JobsConfig config) } /// - public int ActiveCount => Volatile.Read(ref _activeCount); - - /// - public long CompletedCount => Interlocked.Read(ref _completedCount); - - /// - public int PendingCount => Volatile.Read(ref _pendingCount); - - /// - public int WorkerCount { get; } - - /// - /// Releases worker resources. - /// - public void Dispose() - => Stop(CancellationToken.None); - - /// - public Task Schedule(Action work, CancellationToken cancellationToken = default) + public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(work); ThrowIfDisposed(); @@ -106,7 +100,7 @@ public Task Schedule(Action work, CancellationToken cancellationToken = default) } /// - public Task Schedule(Func work, CancellationToken cancellationToken = default) + public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(work); ThrowIfDisposed(); @@ -187,6 +181,27 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } + private void Enqueue(JobItem item) + { + Interlocked.Increment(ref _pendingCount); + + if (!_channel.Writer.TryWrite(item)) + { + Interlocked.Decrement(ref _pendingCount); + + throw new ObjectDisposedException(nameof(JobSystemService)); + } + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs args) + { + _logger.Error(args.Exception, "Unobserved task exception"); + args.SetObserved(); + } + + private static int ResolveWorkerCount(int configured) + => configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); + private void Stop(CancellationToken cancellationToken) { if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) @@ -234,27 +249,6 @@ private void Stop(CancellationToken cancellationToken) _shutdownCts.Dispose(); } - private void Enqueue(JobItem item) - { - Interlocked.Increment(ref _pendingCount); - - if (!_channel.Writer.TryWrite(item)) - { - Interlocked.Decrement(ref _pendingCount); - - throw new ObjectDisposedException(nameof(JobSystemService)); - } - } - - private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs args) - { - _logger.Error(args.Exception, "Unobserved task exception"); - args.SetObserved(); - } - - private static int ResolveWorkerCount(int configured) - => configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount - 1); - private void ThrowIfDisposed() { if (Volatile.Read(ref _disposed) != 0) @@ -317,4 +311,10 @@ private void WorkerLoop() _logger.Error(ex, "JobSystemService worker loop terminated unexpectedly"); } } + + /// + /// Releases worker resources. + /// + public void Dispose() + => Stop(CancellationToken.None); } diff --git a/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs new file mode 100644 index 00000000..8bac0a6a --- /dev/null +++ b/src/SquidStd.Services.Core/Services/MetricsCollectionService.cs @@ -0,0 +1,256 @@ +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Extensions.Logger; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Types; + +namespace SquidStd.Services.Core.Services; + +/// +/// Periodically collects metrics from registered providers and stores the latest snapshot. +/// +public sealed class MetricsCollectionService : IMetricsCollectionService, ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ILogger _metricsLogger = Log.ForContext().ForContext("MetricsData", true); + private readonly IReadOnlyList _providers; + private readonly MetricsConfig _config; + private readonly IEventBus? _eventBus; + private readonly Lock _syncRoot = new(); + private CancellationTokenSource _lifetimeCts = new(); + private Task _collectionTask = Task.CompletedTask; + private int _disposed; + private int _started; + + private MetricsSnapshot _snapshot = new( + DateTimeOffset.MinValue, + new Dictionary(StringComparer.Ordinal) + ); + + /// + /// Initializes the metrics collection service. + /// + /// Metric providers to collect from. + /// Metrics collection configuration. + /// Optional event bus used to publish metrics collection events. + public MetricsCollectionService(IEnumerable providers, MetricsConfig config, IEventBus? eventBus = null) + { + ArgumentNullException.ThrowIfNull(providers); + ArgumentNullException.ThrowIfNull(config); + + _providers = [.. providers]; + _config = config; + _eventBus = eventBus; + } + + /// + public IReadOnlyDictionary GetAllMetrics() + { + lock (_syncRoot) + { + return _snapshot.Metrics; + } + } + + /// + public MetricsSnapshot GetStatus() + => GetSnapshot(); + + /// + public MetricsSnapshot GetSnapshot() + { + lock (_syncRoot) + { + return _snapshot; + } + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (!_config.Enabled) + { + return ValueTask.CompletedTask; + } + + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return ValueTask.CompletedTask; + } + + if (_lifetimeCts.IsCancellationRequested) + { + _lifetimeCts.Dispose(); + _lifetimeCts = new(); + } + + _collectionTask = Task.Run(() => RunCollectionLoopAsync(_lifetimeCts.Token), _lifetimeCts.Token); + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Interlocked.CompareExchange(ref _started, 0, 1) != 1) + { + return; + } + + await _lifetimeCts.CancelAsync(); + + try + { + await _collectionTask; + } + catch (OperationCanceledException) + { + } + } + + private async Task CollectOnceAsync(CancellationToken cancellationToken) + { + var values = new Dictionary(StringComparer.Ordinal); + var now = DateTimeOffset.UtcNow; + + for (var i = 0; i < _providers.Count; i++) + { + var provider = _providers[i]; + + try + { + var samples = await provider.CollectAsync(cancellationToken); + + for (var sampleIndex = 0; sampleIndex < samples.Count; sampleIndex++) + { + var sample = samples[sampleIndex]; + var key = CreateMetricKey(provider.ProviderName, sample.Name); + values[key] = sample with { Timestamp = sample.Timestamp ?? now }; + } + + LogProviderCollection(provider, samples.Count); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics provider collection failed for {ProviderName}", provider.ProviderName); + } + } + + var snapshot = new MetricsSnapshot(now, values); + + lock (_syncRoot) + { + _snapshot = snapshot; + } + + await PublishMetricsCollectedAsync(snapshot, cancellationToken); + } + + private static string CreateMetricKey(string providerName, string metricName) + => string.IsNullOrWhiteSpace(providerName) ? metricName : + string.IsNullOrWhiteSpace(metricName) ? providerName : providerName + "." + metricName; + + private void LogProviderCollection(IMetricProvider provider, int metricCount) + { + if (!_config.LogEnabled || _config.LogLevel == LogLevelType.None) + { + return; + } + + _metricsLogger.Write( + _config.LogLevel.ToSerilogLogLevel(), + "Collected {MetricCount} metrics from provider {ProviderName}", + metricCount, + provider.ProviderName + ); + } + + private async Task PublishMetricsCollectedAsync(MetricsSnapshot snapshot, CancellationToken cancellationToken) + { + if (_eventBus is null) + { + return; + } + + var eventData = new MetricsCollectedEvent(snapshot); + + try + { + _eventBus.Publish(eventData); + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics collected event dispatch failed for synchronous listeners"); + } + + try + { + await _eventBus.PublishAsync(eventData, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Metrics collected event dispatch failed for asynchronous listeners"); + } + } + + private async Task RunCollectionLoopAsync(CancellationToken cancellationToken) + { + await CollectOnceAsync(cancellationToken); + + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(Math.Max(1, _config.IntervalMilliseconds))); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await CollectOnceAsync(cancellationToken); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(MetricsCollectionService)); + } + } + + /// + /// Releases metrics collection resources. + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (Volatile.Read(ref _started) != 0) + { + _lifetimeCts.Cancel(); + + try + { + _collectionTask.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + } + + _lifetimeCts.Dispose(); + } +} diff --git a/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs new file mode 100644 index 00000000..6b404fb1 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Scheduling/CronSchedulerService.cs @@ -0,0 +1,206 @@ +using System.Collections.Concurrent; +using Cronos; +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Scheduling; +using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Services.Core.Services.Internal; + +namespace SquidStd.Services.Core.Services.Scheduling; + +/// +/// Cron scheduler built on the timer wheel: each job is a one-shot, self-rescheduling +/// timer. On fire, the handler is dispatched through ; an occurrence +/// is skipped when the previous run of the same job is still in flight. +/// +public sealed class CronSchedulerService : ICronScheduler, ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ITimerService _timer; + private readonly IJobSystem _jobs; + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _cts = new(); + private int _disposed; + + public CronSchedulerService(ITimerService timer, IJobSystem jobs) + { + _timer = timer; + _jobs = jobs; + } + + /// + public IReadOnlyCollection Jobs + => _entries.Values + .Select( + entry => new CronJobInfo + { + JobId = entry.JobId, + Name = entry.Name, + CronExpression = entry.CronText, + NextOccurrenceUtc = entry.NextOccurrenceUtc, + IsRunning = Volatile.Read(ref entry.Running) == 1, + LastRunUtc = entry.LastRunUtc, + RunCount = Interlocked.Read(ref entry.RunCount) + } + ) + .ToArray(); + + /// + public string Schedule(string name, string cronExpression, Func handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(cronExpression); + ArgumentNullException.ThrowIfNull(handler); + + var expression = CronExpression.Parse(cronExpression); + + var entry = new CronJobEntry + { + JobId = Guid.NewGuid().ToString("N"), + Name = name, + CronText = cronExpression, + Expression = expression, + Handler = handler + }; + + _entries[entry.JobId] = entry; + ScheduleNext(entry); + + return entry.JobId; + } + + /// + public bool Unschedule(string jobId) + { + if (!_entries.TryRemove(jobId, out var entry)) + { + return false; + } + + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); + } + + return true; + } + + /// + public int UnscheduleByName(string name) + { + var ids = _entries.Values.Where(entry => entry.Name == name).Select(entry => entry.JobId).ToArray(); + var removed = 0; + + foreach (var id in ids) + { + if (Unschedule(id)) + { + removed++; + } + } + + return removed; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _cts.Cancel(); + + foreach (var entry in _entries.Values) + { + if (entry.TimerId is not null) + { + _timer.UnregisterTimer(entry.TimerId); + } + } + + _entries.Clear(); + + return ValueTask.CompletedTask; + } + + private void ScheduleNext(CronJobEntry entry) + { + var now = DateTime.UtcNow; + var next = entry.Expression.GetNextOccurrence(now); + + if (next is null) + { + _logger.Information("Cron job '{Name}' ({JobId}) has no further occurrences", entry.Name, entry.JobId); + entry.TimerId = null; + entry.NextOccurrenceUtc = null; + + return; + } + + var delay = next.Value - now; + + if (delay <= TimeSpan.Zero) + { + delay = TimeSpan.FromMilliseconds(1); + } + + entry.NextOccurrenceUtc = next.Value; + entry.TimerId = _timer.RegisterTimer(entry.Name, delay, () => OnTimer(entry)); + } + + private void OnTimer(CronJobEntry entry) + { + if (!_entries.ContainsKey(entry.JobId)) + { + return; + } + + if (Interlocked.CompareExchange(ref entry.Running, 1, 0) != 0) + { + _logger.Warning( + "Cron job '{Name}' ({JobId}) skipped: previous run still in progress", + entry.Name, + entry.JobId + ); + } + else + { + _ = _jobs.ScheduleAsync(() => RunJob(entry)); + } + + ScheduleNext(entry); + } + + private void RunJob(CronJobEntry entry) + { + try + { + entry.Handler(_cts.Token).GetAwaiter().GetResult(); + entry.LastRunUtc = DateTime.UtcNow; + Interlocked.Increment(ref entry.RunCount); + } + catch (Exception ex) + { + _logger.Error(ex, "Cron job '{Name}' ({JobId}) failed", entry.Name, entry.JobId); + } + finally + { + Interlocked.Exchange(ref entry.Running, 0); + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs new file mode 100644 index 00000000..1554e3e9 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Scheduling/TimerWheelPumpService.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Timing; + +namespace SquidStd.Services.Core.Services.Scheduling; + +/// +/// Periodically advances the timer wheel so that wheel-backed timers fire in a normal +/// (non-game-loop) application. Drives on a +/// background loop. +/// +public sealed class TimerWheelPumpService : ISquidStdService, IDisposable +{ + private readonly ILogger _logger = Log.ForContext(); + private readonly ITimerService _timer; + private readonly TimeSpan _pumpInterval; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + private int _disposed; + + public TimerWheelPumpService(ITimerService timer, TimerWheelPumpConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + if (config.PumpInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(config), "PumpInterval must be positive."); + } + + _timer = timer; + _pumpInterval = config.PumpInterval; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + _loop = Task.Run(() => PumpLoopAsync(_cts.Token), CancellationToken.None); + + return ValueTask.CompletedTask; + } + + /// + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + if (_loop is null) + { + return; + } + + await _cts.CancelAsync(); + + try + { + await _loop; + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + + _loop = null; + } + + private async Task PumpLoopAsync(CancellationToken token) + { + var clock = Stopwatch.StartNew(); + using var ticker = new PeriodicTimer(_pumpInterval); + + try + { + while (await ticker.WaitForNextTickAsync(token)) + { + try + { + _timer.UpdateTicksDelta(clock.ElapsedMilliseconds); + } + catch (Exception ex) + { + _logger.Error(ex, "Timer wheel pump tick failed"); + } + } + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs new file mode 100644 index 00000000..53de0334 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/AesGcmSecretProtector.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using Serilog; +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Secrets; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// Protects secrets using AES-GCM and a key supplied by environment variable. +/// +public sealed class AesGcmSecretProtector : ISecretProtector +{ + private const string EnvelopePrefix = "SQUIDSTD-AESGCM-V1"; + private const string DefaultKeyMaterial = "SquidStd.DefaultDevelopmentSecretsKey.v1"; + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly byte[] _key; + + /// + /// Initializes the AES-GCM secret protector. + /// + /// Secret storage configuration. + public AesGcmSecretProtector(SecretsConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _key = ResolveKey(config.KeyEnvironmentVariable); + } + + /// + public byte[] Protect(byte[] plaintext) + { + ArgumentNullException.ThrowIfNull(plaintext); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(_key, TagSize); + aes.Encrypt(nonce, plaintext, ciphertext, tag); + + var envelope = string.Join( + ":", + EnvelopePrefix, + Convert.ToBase64String(nonce), + Convert.ToBase64String(tag), + Convert.ToBase64String(ciphertext) + ); + + return Encoding.UTF8.GetBytes(envelope); + } + + /// + public byte[] Unprotect(byte[] protectedData) + { + ArgumentNullException.ThrowIfNull(protectedData); + + var envelope = Encoding.UTF8.GetString(protectedData); + var parts = envelope.Split(':'); + + if (parts.Length != 4 || !string.Equals(parts[0], EnvelopePrefix, StringComparison.Ordinal)) + { + throw new CryptographicException("Unsupported secret payload format."); + } + + var nonce = Convert.FromBase64String(parts[1]); + var tag = Convert.FromBase64String(parts[2]); + var ciphertext = Convert.FromBase64String(parts[3]); + var plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(_key, TagSize); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return plaintext; + } + + private static byte[] ResolveKey(string environmentVariable) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environmentVariable); + + var value = Environment.GetEnvironmentVariable(environmentVariable); + + if (string.IsNullOrWhiteSpace(value)) + { + Log.Warning( + "Secret key environment variable {EnvironmentVariable} is not set. Using the default development secret key.", + environmentVariable + ); + + return CreateDefaultKey(); + } + + byte[] key; + + try + { + key = Convert.FromBase64String(value); + } + catch (FormatException ex) + { + throw new InvalidOperationException("Secret key environment variable must contain a base64 value.", ex); + } + + return key.Length is 16 or 24 or 32 + ? key + : throw new InvalidOperationException("Secret key must be 16, 24, or 32 bytes after base64 decoding."); + } + + private static byte[] CreateDefaultKey() + => SHA256.HashData(Encoding.UTF8.GetBytes(DefaultKeyMaterial)); +} diff --git a/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs new file mode 100644 index 00000000..17a441b7 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/FileSecretStore.cs @@ -0,0 +1,70 @@ +using System.Text; +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Storage; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// File-backed encrypted secret store. +/// +public sealed class FileSecretStore : ISecretStore +{ + private readonly ISecretProtector _secretProtector; + private readonly IStorageService _storageService; + + /// + /// Initializes the encrypted file secret store. + /// + /// Secret storage configuration. + /// Secret protector used for encryption. + public FileSecretStore(SecretsConfig config, ISecretProtector secretProtector) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(secretProtector); + + _secretProtector = secretProtector; + _storageService = new FileStorageService(new() { RootDirectory = config.RootDirectory }); + } + + /// + public ValueTask DeleteAsync(string name, CancellationToken cancellationToken = default) + => _storageService.DeleteAsync(ToStorageKey(name), cancellationToken); + + /// + public ValueTask ExistsAsync(string name, CancellationToken cancellationToken = default) + => _storageService.ExistsAsync(ToStorageKey(name), cancellationToken); + + /// + public async ValueTask GetAsync(string name, CancellationToken cancellationToken = default) + { + var protectedData = await _storageService.LoadAsync(ToStorageKey(name), cancellationToken); + + if (protectedData is null) + { + return null; + } + + var plaintext = _secretProtector.Unprotect(protectedData); + + return Encoding.UTF8.GetString(plaintext); + } + + /// + public async ValueTask SetAsync(string name, string value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + + var plaintext = Encoding.UTF8.GetBytes(value); + var protectedData = _secretProtector.Protect(plaintext); + + await _storageService.SaveAsync(ToStorageKey(name), protectedData, cancellationToken); + } + + private static string ToStorageKey(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return name + ".secret"; + } +} diff --git a/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs new file mode 100644 index 00000000..debadf4c --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/FileStorageService.cs @@ -0,0 +1,94 @@ +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Storage; +using SquidStd.Services.Core.Services.Internal; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// Local file-backed binary storage. +/// +public sealed class FileStorageService : IStorageService +{ + private readonly string _rootDirectory; + + /// + /// Initializes local file storage. + /// + /// Storage configuration. + public FileStorageService(StorageConfig config) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentException.ThrowIfNullOrWhiteSpace(config.RootDirectory); + + _rootDirectory = Path.GetFullPath(config.RootDirectory); + } + + /// + public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = ResolvePath(key); + + if (!File.Exists(path)) + { + return ValueTask.FromResult(false); + } + + File.Delete(path); + + return ValueTask.FromResult(true); + } + + /// + public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + return ValueTask.FromResult(File.Exists(ResolvePath(key))); + } + + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + var path = ResolvePath(key); + + return !File.Exists(path) ? null : await File.ReadAllBytesAsync(path, cancellationToken); + } + + /// + public async ValueTask SaveAsync( + string key, + ReadOnlyMemory data, + CancellationToken cancellationToken = default + ) + { + var path = ResolvePath(key); + var directory = Path.GetDirectoryName(path); + + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = Path.Combine( + directory ?? _rootDirectory, + "." + Path.GetFileName(path) + "." + Guid.NewGuid().ToString("N") + ".tmp" + ); + + try + { + await File.WriteAllBytesAsync(tempPath, data.ToArray(), cancellationToken); + File.Move(tempPath, path, true); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private string ResolvePath(string key) + => StoragePathResolver.ResolveFilePath(_rootDirectory, key); +} diff --git a/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs new file mode 100644 index 00000000..63ddb76a --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Storage/YamlObjectStorageService.cs @@ -0,0 +1,54 @@ +using System.Text; +using SquidStd.Core.Interfaces.Storage; +using SquidStd.Core.Yaml; + +namespace SquidStd.Services.Core.Services.Storage; + +/// +/// YAML object storage built on top of binary storage. +/// +public sealed class YamlObjectStorageService : IObjectStorageService +{ + private readonly IStorageService _storageService; + + /// + /// Initializes YAML object storage. + /// + /// Underlying binary storage service. + public YamlObjectStorageService(IStorageService storageService) + { + _storageService = storageService; + } + + /// + public ValueTask DeleteAsync(string key, CancellationToken cancellationToken = default) + => _storageService.DeleteAsync(key, cancellationToken); + + /// + public ValueTask ExistsAsync(string key, CancellationToken cancellationToken = default) + => _storageService.ExistsAsync(key, cancellationToken); + + /// + public async ValueTask LoadAsync(string key, CancellationToken cancellationToken = default) + { + var data = await _storageService.LoadAsync(key, cancellationToken); + + if (data is null) + { + return default; + } + + var yaml = Encoding.UTF8.GetString(data); + + return YamlUtils.Deserialize(yaml); + } + + /// + public async ValueTask SaveAsync(string key, T value, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + + var yaml = YamlUtils.Serialize(value); + await _storageService.SaveAsync(key, Encoding.UTF8.GetBytes(yaml), cancellationToken); + } +} diff --git a/src/SquidStd.Services.Core/Services/TimerWheelService.cs b/src/SquidStd.Services.Core/Services/TimerWheelService.cs index 41c365cc..a7fc3f15 100644 --- a/src/SquidStd.Services.Core/Services/TimerWheelService.cs +++ b/src/SquidStd.Services.Core/Services/TimerWheelService.cs @@ -53,7 +53,7 @@ public TimerWheelService(TimerWheelConfig config) for (var i = 0; i < _wheel.Length; i++) { - _wheel[i] = new LinkedList(); + _wheel[i] = new(); } } diff --git a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj index cfa54a83..60a1f51f 100644 --- a/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj +++ b/src/SquidStd.Services.Core/SquidStd.Services.Core.csproj @@ -1,19 +1,23 @@  + true net10.0 enable enable - - + + - - + + + + + diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs new file mode 100644 index 00000000..2b1d72ff --- /dev/null +++ b/src/SquidStd.Services.Core/Types/BootstrapStateType.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Services.Core.Types; + +/// +/// Lifecycle state of the SquidStd bootstrapper. +/// +internal enum BootstrapStateType +{ + /// Created but not started. + Created, + + /// Started and running. + Started, + + /// Stopped; cannot be restarted. + Stopped +} diff --git a/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs index c0d785c1..3e52a88d 100644 --- a/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/AddTypedListMethodExtensionTests.cs @@ -6,42 +6,42 @@ namespace SquidStd.Tests.Abstractions; public class AddTypedListMethodExtensionTests { [Fact] - public void AddToRegisterTypedList_FirstEntry_RegistersNewList() + public void AddToRegisterTypedList_DistinctTypes_RegisterSeparateLists() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.AddToRegisterTypedList("a"); + container.AddToRegisterTypedList("text"); + container.AddToRegisterTypedList(42); - Assert.Equal(["a"], container.Resolve>()); + Assert.Equal(["text"], container.Resolve>()); + Assert.Equal([42], container.Resolve>()); } [Fact] - public void AddToRegisterTypedList_MultipleEntries_AppendsToSameList() + public void AddToRegisterTypedList_FirstEntry_RegistersNewList() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.AddToRegisterTypedList("a"); - container.AddToRegisterTypedList("b"); - Assert.Equal(["a", "b"], container.Resolve>()); + Assert.Equal(["a"], container.Resolve>()); } [Fact] - public void AddToRegisterTypedList_DistinctTypes_RegisterSeparateLists() + public void AddToRegisterTypedList_MultipleEntries_AppendsToSameList() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.AddToRegisterTypedList("text"); - container.AddToRegisterTypedList(42); + container.AddToRegisterTypedList("a"); + container.AddToRegisterTypedList("b"); - Assert.Equal(["text"], container.Resolve>()); - Assert.Equal([42], container.Resolve>()); + Assert.Equal(["a", "b"], container.Resolve>()); } [Fact] public void AddToRegisterTypedList_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.AddToRegisterTypedList(1); diff --git a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs index 34d613b5..7b01f332 100644 --- a/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs +++ b/tests/SquidStd.Tests/Abstractions/ConfigRegistrationDataTests.cs @@ -6,16 +6,12 @@ namespace SquidStd.Tests.Abstractions; public class ConfigRegistrationDataTests { [Fact] - public void Ctor_InvalidSectionName_Throws() - => Assert.Throws( - () => new ConfigRegistrationData(string.Empty, typeof(TestConfig), () => new TestConfig()) - ); + public void CreateDefault_IncompatibleFactory_Throws() + { + var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new()); - [Fact] - public void Ctor_NullType_Throws() - => Assert.Throws( - () => new ConfigRegistrationData("test", null!, () => new TestConfig()) - ); + Assert.Throws(() => entry.CreateDefault()); + } [Fact] public void CreateDefault_UsesFactory() @@ -24,7 +20,7 @@ public void CreateDefault_UsesFactory() "test", typeof(TestConfig), () => new TestConfig { Name = "default", Count = 7 }, - priority: 3 + 3 ); var config = Assert.IsType(entry.CreateDefault()); @@ -37,10 +33,12 @@ public void CreateDefault_UsesFactory() } [Fact] - public void CreateDefault_IncompatibleFactory_Throws() - { - var entry = new ConfigRegistrationData("test", typeof(TestConfig), () => new object()); + public void Ctor_InvalidSectionName_Throws() + => Assert.Throws( + () => new ConfigRegistrationData(string.Empty, typeof(TestConfig), () => new TestConfig()) + ); - Assert.Throws(() => entry.CreateDefault()); - } + [Fact] + public void Ctor_NullType_Throws() + => Assert.Throws(() => new ConfigRegistrationData("test", null!, () => new TestConfig())); } diff --git a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs index 4d7f6b50..cb169580 100644 --- a/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/RegisterConfigSectionExtensionTests.cs @@ -10,12 +10,12 @@ public class RegisterConfigSectionExtensionTests [Fact] public void RegisterConfigSection_AddsRegistrationData() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterConfigSection( + container.RegisterConfigSection( "test", static () => new TestConfig { Name = "default", Count = 2 }, - priority: 4 + 4 ); var entry = Assert.Single(container.Resolve>()); @@ -29,48 +29,44 @@ public void RegisterConfigSection_AddsRegistrationData() Assert.False(container.IsRegistered()); } - [Fact] - public void RegisterConfigSection_SameTypeAndSection_IsIdempotent() - { - using var container = new DryIoc.Container(); - - container.RegisterConfigSection("test"); - container.RegisterConfigSection("test"); - - Assert.Single(container.Resolve>()); - } - [Fact] public void RegisterConfigSection_DuplicateSectionWithDifferentType_Throws() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigSection("test"); - Assert.Throws( - () => container.RegisterConfigSection("test") - ); + Assert.Throws(() => container.RegisterConfigSection("test")); } [Fact] public void RegisterConfigSection_DuplicateTypeWithDifferentSection_Throws() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigSection("first"); - Assert.Throws( - () => container.RegisterConfigSection("second") - ); + Assert.Throws(() => container.RegisterConfigSection("second")); } [Fact] public void RegisterConfigSection_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.RegisterConfigSection("test"); Assert.Same(container, result); } + + [Fact] + public void RegisterConfigSection_SameTypeAndSection_IsIdempotent() + { + using var container = new Container(); + + container.RegisterConfigSection("test"); + container.RegisterConfigSection("test"); + + Assert.Single(container.Resolve>()); + } } diff --git a/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs b/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs index d8f519af..6b6bc5db 100644 --- a/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs +++ b/tests/SquidStd.Tests/Abstractions/RegisterStdServiceExtensionTests.cs @@ -9,56 +9,56 @@ namespace SquidStd.Tests.Abstractions; public class RegisterStdServiceExtensionTests { [Fact] - public void RegisterStdService_ResolvesImplementation() + public void RegisterStdService_AddsRegistrationDataWithPriority() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterStdService(); + container.RegisterStdService(5); - Assert.IsType(container.Resolve()); + var entry = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(ISquidStdService), entry.ServiceType); + Assert.Equal(typeof(FakeStdService), entry.ImplementationType); + Assert.Equal(5, entry.Priority); } [Fact] - public void RegisterStdService_RegistersAsSingleton() + public void RegisterStdService_DefaultPriority_IsZero() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterStdService(); - var first = container.Resolve(); - var second = container.Resolve(); - - Assert.Same(first, second); + var entry = Assert.Single(container.Resolve>()); + Assert.Equal(0, entry.Priority); } [Fact] - public void RegisterStdService_AddsRegistrationDataWithPriority() + public void RegisterStdService_RegistersAsSingleton() { - using var container = new DryIoc.Container(); + using var container = new Container(); - container.RegisterStdService(5); + container.RegisterStdService(); - var entry = Assert.Single(container.Resolve>()); - Assert.Equal(typeof(ISquidStdService), entry.ServiceType); - Assert.Equal(typeof(FakeStdService), entry.ImplementationType); - Assert.Equal(5, entry.Priority); + var first = container.Resolve(); + var second = container.Resolve(); + + Assert.Same(first, second); } [Fact] - public void RegisterStdService_DefaultPriority_IsZero() + public void RegisterStdService_ResolvesImplementation() { - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterStdService(); - var entry = Assert.Single(container.Resolve>()); - Assert.Equal(0, entry.Priority); + Assert.IsType(container.Resolve()); } [Fact] public void RegisterStdService_ReturnsSameContainerForChaining() { - using var container = new DryIoc.Container(); + using var container = new Container(); var result = container.RegisterStdService(); diff --git a/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs b/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs index 38882865..5d1cef9c 100644 --- a/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs +++ b/tests/SquidStd.Tests/Abstractions/ServiceRegistrationDataTests.cs @@ -4,6 +4,14 @@ namespace SquidStd.Tests.Abstractions; public class ServiceRegistrationDataTests { + [Fact] + public void Constructor_DefaultPriority_IsZero() + { + var data = new ServiceRegistrationData(typeof(IDisposable), typeof(string)); + + Assert.Equal(0, data.Priority); + } + [Fact] public void Constructor_SetsProperties() { @@ -15,11 +23,12 @@ public void Constructor_SetsProperties() } [Fact] - public void Constructor_DefaultPriority_IsZero() + public void Records_WithDifferentPriority_AreNotEqual() { - var data = new ServiceRegistrationData(typeof(IDisposable), typeof(string)); + var first = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 1); + var second = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 2); - Assert.Equal(0, data.Priority); + Assert.NotEqual(first, second); } [Fact] @@ -30,13 +39,4 @@ public void Records_WithSameValues_AreEqual() Assert.Equal(first, second); } - - [Fact] - public void Records_WithDifferentPriority_AreNotEqual() - { - var first = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 1); - var second = new ServiceRegistrationData(typeof(IDisposable), typeof(string), 2); - - Assert.NotEqual(first, second); - } } diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs new file mode 100644 index 00000000..52218c51 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -0,0 +1,60 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.Tests.AspNetCore; + +internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap +{ + public int StartCount { get; private set; } + + public int StopCount { get; private set; } + + public SquidStdOptions Options { get; } = new(); + + public IContainer Container { get; } = new Container(); + + public ISquidStdBootstrap ConfigureService(Func configure) + => ConfigureServices(configure); + + public ISquidStdBootstrap ConfigureServices(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var configuredContainer = configure(Container); + + return ReferenceEquals(configuredContainer, Container) + ? this + : throw new InvalidOperationException("ConfigureServices must return the bootstrap container instance."); + } + + public TService Resolve() + => Container.Resolve(); + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + StartCount++; + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + StopCount++; + + return ValueTask.CompletedTask; + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + await StartAsync(cancellationToken); + } + + public ValueTask DisposeAsync() + { + Container.Dispose(); + + return ValueTask.CompletedTask; + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs new file mode 100644 index 00000000..2a5f8a9f --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdAspNetCoreBuilderExtensionsTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using DryIoc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SquidStd.AspNetCore.Extensions; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdAspNetCoreBuilderExtensionsTests +{ + [Fact] + public async Task UseSquidStd_RegistersBootstrapWithContentRootDefaults() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var returned = builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + var bootstrap = app.Services.GetRequiredService(); + + Assert.Same(builder, returned); + Assert.Equal("app", bootstrap.Options.ConfigName); + Assert.Equal(Path.GetFullPath(temp.Path), Path.GetFullPath(bootstrap.Options.RootDirectory)); + } + + [Fact] + public async Task UseSquidStd_ResolvesSquidStdServicesAfterHostStart() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + await app.StartAsync(); + + try + { + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + Assert.NotNull(app.Services.GetRequiredService()); + } + finally + { + await app.StopAsync(); + } + } + + [Fact] + public async Task UseSquidStd_AllowsMinimalApiInjection() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + builder.UseSquidStd(options => options.ConfigName = "app"); + + await using var app = builder.Build(); + app.MapGet("/timer", (ITimerService timer) => Results.Ok(timer.GetType().Name)); + + await app.StartAsync(); + + try + { + var client = app.GetTestClient(); + var response = await client.GetAsync("/timer"); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("TimerWheelService", content, StringComparison.Ordinal); + } + finally + { + await app.StopAsync(); + } + } + + [Fact] + public async Task UseSquidStd_AppliesCustomDryIocRegistrations() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + builder.UseSquidStd( + options => options.ConfigName = "app", + container => + { + container.RegisterInstance(new TestAspNetCoreMarker { Value = "custom" }); + + return container; + } + ); + + await using var app = builder.Build(); + + Assert.Equal("custom", app.Services.GetRequiredService().Value); + } + + [Fact] + public void UseSquidStd_WhenContainerCallbackReturnsDifferentContainer_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + var ex = Assert.Throws( + () => builder.UseSquidStd( + options => options.ConfigName = "app", + _ => new Container() + ) + ); + + Assert.Equal("ConfigureSquidStdContainer must return the DryIoc container instance.", ex.Message); + } + + [Fact] + public void UseSquidStd_WhenOptionsAreInvalid_Throws() + { + using var temp = new TempDirectory(); + var builder = CreateBuilder(temp.Path); + + Assert.Throws( + () => builder.UseSquidStd(options => options.ConfigName = string.Empty) + ); + } + + private static WebApplicationBuilder CreateBuilder(string contentRootPath) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions + { + ContentRootPath = contentRootPath, + EnvironmentName = Environments.Development + } + ); + + builder.WebHost.UseTestServer(); + + return builder; + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs b/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs new file mode 100644 index 00000000..ad72ca88 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/SquidStdHostedServiceTests.cs @@ -0,0 +1,19 @@ +using SquidStd.AspNetCore.Services; + +namespace SquidStd.Tests.AspNetCore; + +public class SquidStdHostedServiceTests +{ + [Fact] + public async Task StartAsync_StopAsync_DelegatesToBootstrap() + { + await using var bootstrap = new FakeSquidStdBootstrap(); + var service = new SquidStdHostedService(bootstrap); + + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + Assert.Equal(1, bootstrap.StartCount); + Assert.Equal(1, bootstrap.StopCount); + } +} diff --git a/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs b/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs new file mode 100644 index 00000000..d9d9ee35 --- /dev/null +++ b/tests/SquidStd.Tests/AspNetCore/TestAspNetCoreMarker.cs @@ -0,0 +1,6 @@ +namespace SquidStd.Tests.AspNetCore; + +internal sealed class TestAspNetCoreMarker +{ + public string Value { get; set; } = string.Empty; +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs new file mode 100644 index 00000000..ad32af8d --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapTests.cs @@ -0,0 +1,295 @@ +using DryIoc; +using Serilog; +using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +[Collection(SerilogEventSinkCollection.Name)] +public class SquidStdBootstrapTests +{ + [Fact] + public async Task Create_RegistersBootstrapAndDefaultServices() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + var resolved = bootstrap.Resolve(); + var configManager = bootstrap.Resolve(); + + Assert.Same(bootstrap, resolved); + Assert.Equal(temp.Path, bootstrap.Options.RootDirectory); + Assert.Equal(Path.Combine(temp.Path, "app.yaml"), configManager.ConfigPath); + } + + [Fact] + public async Task Create_WithContainer_UsesProvidedContainer() + { + using var temp = new TempDirectory(); + var container = new Container(); + + await using var bootstrap = SquidStdBootstrap.Create( + new() + { + ConfigName = "app", + RootDirectory = temp.Path + }, + container + ); + + Assert.Same(container, bootstrap.Container); + Assert.Same(bootstrap, container.Resolve()); + Assert.Same(bootstrap.Options, container.Resolve()); + + container.Dispose(); + } + + [Fact] + public async Task DisposeAsync_WithProvidedContainer_DoesNotDisposeContainer() + { + using var temp = new TempDirectory(); + var container = new Container(); + + await using (SquidStdBootstrap.Create( + new() + { + ConfigName = "app", + RootDirectory = temp.Path + }, + container + )) + { + } + + container.RegisterInstance("still-open"); + + Assert.Equal("still-open", container.Resolve()); + + container.Dispose(); + } + + [Fact] + public async Task StartAsync_LoadsConfigBeforeResolvingRegisteredServices() + { + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + """ + logger: + MinimumLevel: Warning + EnableConsole: false + """ + ); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(ConfigConsumerService), + typeof(ConfigConsumerService), + -10 + ) + ); + + return container; + } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + + Assert.Contains("config:Warning", events); + } + + [Fact] + public async Task StartAsync_OrdersServicesByPriorityAndStopAsync_ReversesOrder() + { + using var temp = new TempDirectory(); + var events = new List(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureServices( + container => + { + container.RegisterInstance(events); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(EarlyTrackedService), + typeof(EarlyTrackedService), + -20 + ) + ); + container.AddToRegisterTypedList( + new ServiceRegistrationData(typeof(LateTrackedService), typeof(LateTrackedService), 40) + ); + + return container; + } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + + Assert.True(events.IndexOf("early:start") < events.IndexOf("late:start")); + Assert.True(events.IndexOf("late:stop") < events.IndexOf("early:stop")); + } + + [Fact] + public async Task RunAsync_StartsUntilCancellationThenStops() + { + using var temp = new TempDirectory(); + using var cancellation = new CancellationTokenSource(); + var state = new RunTrackedState(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + bootstrap.ConfigureService( + container => + { + container.RegisterInstance(state); + container.Register(Reuse.Singleton); + container.AddToRegisterTypedList( + new ServiceRegistrationData( + typeof(RunTrackedService), + typeof(RunTrackedService), + -20 + ) + ); + + return container; + } + ); + + var runTask = bootstrap.RunAsync(cancellation.Token); + + await state.Started.Task.WaitAsync(TimeSpan.FromSeconds(3)); + await cancellation.CancelAsync(); + await runTask.WaitAsync(TimeSpan.FromSeconds(3)); + await state.Stopped.Task.WaitAsync(TimeSpan.FromSeconds(3)); + + Assert.True(state.Stopped.Task.IsCompletedSuccessfully); + } + + [Fact] + public async Task StartAsync_ConfiguresFileSinkFromLoggerOptions() + { + using var temp = new TempDirectory(); + File.WriteAllText( + temp.Combine("app.yaml"), + """ + logger: + MinimumLevel: Information + EnableConsole: false + EnableFile: true + LogDirectory: logs + FileName: app.log + RollingInterval: Infinite + """ + ); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + await bootstrap.StartAsync(CancellationToken.None); + await bootstrap.StopAsync(CancellationToken.None); + Log.CloseAndFlush(); + + var logPath = temp.Combine(Path.Combine("logs", "app.log")); + var content = File.ReadAllText(logPath); + + Assert.Contains("JobSystemService started", content); + } + + private sealed class ConfigConsumerService(SquidStdLoggerOptions options, List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add($"config:{options.MinimumLevel}"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + } + + private sealed class EarlyTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("early:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("early:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class LateTrackedService(List events) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + events.Add("late:start"); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + events.Add("late:stop"); + + return ValueTask.CompletedTask; + } + } + + private sealed class RunTrackedState + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class RunTrackedService(RunTrackedState state) : ISquidStdService + { + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + state.Started.SetResult(); + + return ValueTask.CompletedTask; + } + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + state.Stopped.SetResult(); + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs new file mode 100644 index 00000000..3661cd21 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdOptionsTests.cs @@ -0,0 +1,29 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Types; + +namespace SquidStd.Tests.Bootstrap; + +public class SquidStdOptionsTests +{ + [Fact] + public void SquidStdOptions_Defaults_AreUsable() + { + var options = new SquidStdOptions(); + + Assert.False(string.IsNullOrWhiteSpace(options.RootDirectory)); + Assert.Equal("squidstd", options.ConfigName); + } + + [Fact] + public void SquidStdLoggerOptions_Defaults_EnableConsoleAndDisableFile() + { + var options = new SquidStdLoggerOptions(); + + Assert.Equal(LogLevelType.Information, options.MinimumLevel); + Assert.True(options.EnableConsole); + Assert.False(options.EnableFile); + Assert.Equal("logs", options.LogDirectory); + Assert.Equal("squidstd-.log", options.FileName); + Assert.Equal(SquidStdLogRollingIntervalType.Day, options.RollingInterval); + } +} diff --git a/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs new file mode 100644 index 00000000..f588e5f9 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheConnectionStringTests.cs @@ -0,0 +1,47 @@ +using SquidStd.Caching.Abstractions.Data.Config; + +namespace SquidStd.Tests.Caching; + +public class CacheConnectionStringTests +{ + [Fact] + public void Parse_Memory_ReadsScheme() + { + var cs = CacheConnectionString.Parse("memory://localhost"); + + Assert.Equal("memory", cs.Scheme); + Assert.Equal("localhost", cs.Host); + } + + [Fact] + public void Parse_Redis_ReadsHostPortAndUserInfo() + { + var cs = CacheConnectionString.Parse("redis://user:pass@cache-host:6380"); + + Assert.Equal("redis", cs.Scheme); + Assert.Equal("cache-host", cs.Host); + Assert.Equal(6380, cs.Port); + Assert.Equal("user", cs.UserName); + Assert.Equal("pass", cs.Password); + } + + [Fact] + public void ToCacheOptions_ReadsTtlAndPrefix() + { + var cs = CacheConnectionString.Parse("memory://localhost?defaultTtlSeconds=30&keyPrefix=app:"); + + var options = cs.ToCacheOptions(); + + Assert.Equal(TimeSpan.FromSeconds(30), options.DefaultTtl); + Assert.Equal("app:", options.KeyPrefix); + } + + [Fact] + public void ToCacheOptions_Defaults_WhenNoParams() + { + var options = CacheConnectionString.Parse("memory://localhost").ToCacheOptions(); + + Assert.Null(options.DefaultTtl); + Assert.Equal(string.Empty, options.KeyPrefix); + } +} diff --git a/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs new file mode 100644 index 00000000..c64f83c6 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheMetricsProviderTests.cs @@ -0,0 +1,31 @@ +using SquidStd.Caching.Abstractions.Services; + +namespace SquidStd.Tests.Caching; + +public class CacheMetricsProviderTests +{ + [Fact] + public void ProviderName_IsCache() + => Assert.Equal("cache", new CacheMetricsProvider().ProviderName); + + [Fact] + public async Task CollectAsync_ReportsCountersAndHitRatio() + { + var metrics = new CacheMetricsProvider(); + metrics.OnHit("a"); + metrics.OnHit("b"); + metrics.OnMiss("c"); + metrics.OnSet("c"); + metrics.OnRemove("a"); + + var samples = await metrics.CollectAsync(); + + double Value(string name) => samples.Single(s => s.Name == name).Value; + + Assert.Equal(2, Value("hits")); + Assert.Equal(1, Value("misses")); + Assert.Equal(1, Value("sets")); + Assert.Equal(1, Value("removes")); + Assert.Equal(2d / 3d, Value("hit_ratio"), 3); + } +} diff --git a/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs new file mode 100644 index 00000000..693f8c90 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheRegistrationTests.cs @@ -0,0 +1,39 @@ +using DryIoc; +using SquidStd.Caching.Abstractions.Interfaces; +using SquidStd.Caching.Extensions; + +namespace SquidStd.Tests.Caching; + +public class CacheRegistrationTests +{ + [Fact] + public void AddInMemoryCache_ResolvesService() + { + var container = new Container(); + + container.AddInMemoryCache(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + } + + [Fact] + public void AddInMemoryCache_FromUrl_WrongScheme_Throws() + { + var container = new Container(); + + Assert.Throws(() => container.AddInMemoryCache("redis://localhost")); + } + + [Fact] + public async Task AddInMemoryCache_FromUrl_ResolvesAndWorks() + { + var container = new Container(); + + container.AddInMemoryCache("memory://localhost?keyPrefix=app:"); + var cache = container.Resolve(); + + await cache.SetAsync("k", 5); + Assert.Equal(5, await cache.GetAsync("k")); + } +} diff --git a/tests/SquidStd.Tests/Caching/CacheServiceTests.cs b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs new file mode 100644 index 00000000..c458ada4 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/CacheServiceTests.cs @@ -0,0 +1,110 @@ +using SquidStd.Caching.Abstractions.Data.Config; +using SquidStd.Caching.Abstractions.Services; +using SquidStd.Core.Json; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Caching; + +public class CacheServiceTests +{ + private static CacheService NewService( + FakeCacheProvider provider, + CacheOptions? options = null, + CacheMetricsProvider? metrics = null + ) + { + var serializer = new JsonDataSerializer(); + + return new CacheService(provider, serializer, serializer, options ?? new CacheOptions(), metrics); + } + + [Fact] + public async Task SetThenGet_RoundTrips() + { + var service = NewService(new FakeCacheProvider()); + + await service.SetAsync("k", 42); + + Assert.Equal(42, await service.GetAsync("k")); + } + + [Fact] + public async Task Get_Missing_ReturnsDefault() + => Assert.Null(await NewService(new FakeCacheProvider()).GetAsync("absent")); + + [Fact] + public async Task Set_UsesDefaultTtl_WhenNoneGiven() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + + await service.SetAsync("k", "v"); + + Assert.Equal(TimeSpan.FromSeconds(30), provider.LastTtl); + } + + [Fact] + public async Task Set_PerEntryTtl_OverridesDefault() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { DefaultTtl = TimeSpan.FromSeconds(30) }); + + await service.SetAsync("k", "v", TimeSpan.FromSeconds(5)); + + Assert.Equal(TimeSpan.FromSeconds(5), provider.LastTtl); + } + + [Fact] + public async Task KeyPrefix_IsAppliedToProvider() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider, new CacheOptions { KeyPrefix = "app:" }); + + await service.SetAsync("k", "v"); + + Assert.True(await provider.ExistsAsync("app:k")); + Assert.False(await provider.ExistsAsync("k")); + } + + [Fact] + public async Task GetOrSet_Miss_InvokesFactoryAndStores() + { + var provider = new FakeCacheProvider(); + var service = NewService(provider); + var calls = 0; + + var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(99); }); + + Assert.Equal(99, value); + Assert.Equal(1, calls); + Assert.Equal(99, await service.GetAsync("k")); + } + + [Fact] + public async Task GetOrSet_Hit_DoesNotInvokeFactory() + { + var service = NewService(new FakeCacheProvider()); + await service.SetAsync("k", 7); + var calls = 0; + + var value = await service.GetOrSetAsync("k", _ => { calls++; return Task.FromResult(0); }); + + Assert.Equal(7, value); + Assert.Equal(0, calls); + } + + [Fact] + public async Task Metrics_RecordHitAndMiss() + { + var metrics = new CacheMetricsProvider(); + var service = NewService(new FakeCacheProvider(), metrics: metrics); + + await service.GetAsync("absent"); // miss + await service.SetAsync("k", 1); + await service.GetAsync("k"); // hit + + var samples = await metrics.CollectAsync(); + Assert.Equal(1, samples.Single(s => s.Name == "hits").Value); + Assert.Equal(1, samples.Single(s => s.Name == "misses").Value); + } +} diff --git a/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs new file mode 100644 index 00000000..810c8818 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/InMemoryCacheProviderTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Caching.Memory; +using SquidStd.Caching.Services; + +namespace SquidStd.Tests.Caching; + +public class InMemoryCacheProviderTests +{ + private static InMemoryCacheProvider NewProvider() + => new(new MemoryCache(new MemoryCacheOptions())); + + private static ReadOnlyMemory Bytes(string s) => System.Text.Encoding.UTF8.GetBytes(s); + + [Fact] + public async Task SetThenGet_ReturnsValue() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), null); + + var value = await provider.GetAsync("k"); + + Assert.NotNull(value); + Assert.Equal("v", System.Text.Encoding.UTF8.GetString(value!.Value.Span)); + } + + [Fact] + public async Task Get_Missing_ReturnsNull() + => Assert.Null(await NewProvider().GetAsync("absent")); + + [Fact] + public async Task Exists_And_Remove() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), null); + + Assert.True(await provider.ExistsAsync("k")); + Assert.True(await provider.RemoveAsync("k")); + Assert.False(await provider.ExistsAsync("k")); + Assert.False(await provider.RemoveAsync("k")); + } + + [Fact] + public async Task Ttl_Expires() + { + var provider = NewProvider(); + await provider.SetAsync("k", Bytes("v"), TimeSpan.FromMilliseconds(50)); + + await Task.Delay(120); + + Assert.Null(await provider.GetAsync("k")); + } +} diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs new file mode 100644 index 00000000..27918634 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/Redis/RedisCacheProviderTests.cs @@ -0,0 +1,65 @@ +using System.Text; +using SquidStd.Caching.Redis.Data.Config; +using SquidStd.Caching.Redis.Services; + +namespace SquidStd.Tests.Caching.Redis; + +[Collection(RedisCollection.Name)] +public class RedisCacheProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly RedisContainerFixture _fixture; + + public RedisCacheProviderTests(RedisContainerFixture fixture) + { + _fixture = fixture; + } + + private RedisCacheProvider NewProvider() + => new(new RedisCacheOptions { Configuration = _fixture.ConnectionString }); + + private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); + private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); + private static string Key() => "k-" + Guid.NewGuid().ToString("N"); + + [Fact] + public async Task SetThenGet_RoundTrips() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + + await provider.SetAsync(key, Bytes("hello"), null); + var value = await provider.GetAsync(key); + + Assert.NotNull(value); + Assert.Equal("hello", Text(value!.Value)); + } + + [Fact] + public async Task Exists_And_Remove() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + await provider.SetAsync(key, Bytes("v"), null); + + Assert.True(await provider.ExistsAsync(key)); + Assert.True(await provider.RemoveAsync(key)); + Assert.False(await provider.ExistsAsync(key)); + } + + [Fact] + public async Task Ttl_Expires() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var key = Key(); + + await provider.SetAsync(key, Bytes("v"), TimeSpan.FromMilliseconds(200)); + await Task.Delay(500); + + Assert.Null(await provider.GetAsync(key).WaitAsync(Timeout)); + } +} diff --git a/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs new file mode 100644 index 00000000..e6ee7358 --- /dev/null +++ b/tests/SquidStd.Tests/Caching/Redis/RedisContainerFixture.cs @@ -0,0 +1,25 @@ +using Testcontainers.Redis; + +namespace SquidStd.Tests.Caching.Redis; + +/// +/// Starts a Redis container once for the whole collection and exposes its connection string. +/// +public sealed class RedisContainerFixture : IAsyncLifetime +{ + private readonly RedisContainer _container = new RedisBuilder().Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public Task InitializeAsync() + => _container.StartAsync(); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class RedisCollection : ICollectionFixture +{ + public const string Name = "Redis"; +} diff --git a/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs new file mode 100644 index 00000000..82587c95 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Json/JsonDataSerializerTests.cs @@ -0,0 +1,33 @@ +using SquidStd.Core.Json; + +namespace SquidStd.Tests.Core.Json; + +public class JsonDataSerializerTests +{ + private sealed class Sample + { + public string Name { get; set; } = string.Empty; + public int Count { get; set; } + } + + [Fact] + public void RoundTrip_PreservesValue() + { + var serializer = new JsonDataSerializer(); + var bytes = serializer.Serialize(new Sample { Name = "abc", Count = 7 }); + + var result = serializer.Deserialize(bytes); + + Assert.Equal("abc", result.Name); + Assert.Equal(7, result.Count); + } + + [Fact] + public void Deserialize_NullJson_Throws() + { + var serializer = new JsonDataSerializer(); + var bytes = serializer.Serialize(null); + + Assert.Throws(() => serializer.Deserialize(bytes)); + } +} diff --git a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs new file mode 100644 index 00000000..d536854f --- /dev/null +++ b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs @@ -0,0 +1,76 @@ +using SquidStd.Database.Abstractions.Types.Data; +using SquidStd.Database.Connection; + +namespace SquidStd.Tests.Database; + +public class ConnectionStringParserTests +{ + [Fact] + public void Parse_SqliteRelativePath() + { + var result = ConnectionStringParser.Parse("sqlite://app.db"); + Assert.Equal(DatabaseProviderType.Sqlite, result.Provider); + Assert.Equal("Data Source=app.db", result.NativeConnectionString); + } + + [Fact] + public void Parse_SqliteAbsolutePath() + { + var result = ConnectionStringParser.Parse("sqlite:///var/data/app.db"); + Assert.Equal("Data Source=/var/data/app.db", result.NativeConnectionString); + } + + [Fact] + public void Parse_SqliteInMemory() + { + var result = ConnectionStringParser.Parse("sqlite://:memory:"); + Assert.Equal("Data Source=:memory:", result.NativeConnectionString); + } + + [Fact] + public void Parse_Postgres_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("postgres://user:pass@db.host:5433/appdb"); + Assert.Equal(DatabaseProviderType.Postgres, result.Provider); + Assert.Equal("Host=db.host;Port=5433;Username=user;Password=pass;Database=appdb", + result.NativeConnectionString); + } + + [Fact] + public void Parse_Postgres_DefaultsPortAndDecodesCredentials() + { + var result = ConnectionStringParser.Parse("postgresql://u%40corp:p%3Aw@h/appdb"); + Assert.Equal("Host=h;Port=5432;Username=u@corp;Password=p:w;Database=appdb", + result.NativeConnectionString); + } + + [Fact] + public void Parse_MySql_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("mysql://root:secret@127.0.0.1:3307/shop"); + Assert.Equal(DatabaseProviderType.MySql, result.Provider); + Assert.Equal("Server=127.0.0.1;Port=3307;Uid=root;Pwd=secret;Database=shop", + result.NativeConnectionString); + } + + [Fact] + public void Parse_SqlServer_BuildsNativeString() + { + var result = ConnectionStringParser.Parse("sqlserver://sa:Str0ng@localhost:1433/master"); + Assert.Equal(DatabaseProviderType.SqlServer, result.Provider); + Assert.Equal("Server=localhost,1433;User Id=sa;Password=Str0ng;Database=master;TrustServerCertificate=true", + result.NativeConnectionString); + } + + [Fact] + public void Parse_UnknownScheme_Throws() + { + Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + } + + [Fact] + public void Parse_MissingHostForServerProvider_Throws() + { + Assert.Throws(() => ConnectionStringParser.Parse("postgres:///db")); + } +} diff --git a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs new file mode 100644 index 00000000..04b42344 --- /dev/null +++ b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs @@ -0,0 +1,152 @@ +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Abstractions.Data.Entities; +using SquidStd.Database.Data; +using SquidStd.Database.Services; + +namespace SquidStd.Tests.Database; + +public sealed class SampleUser : BaseEntity +{ + public string Name { get; set; } = string.Empty; + public int Age { get; set; } +} + +public class FreeSqlDataAccessTests : IAsyncLifetime +{ + private string _dbPath = string.Empty; + private DatabaseService _service = null!; + + public async Task InitializeAsync() + { + _dbPath = Path.Combine(Path.GetTempPath(), "squidstd-db-" + Guid.NewGuid().ToString("N") + ".db"); + _service = new DatabaseService(new DatabaseConfig + { + ConnectionString = $"sqlite://{_dbPath}", + AutoMigrate = true + }); + await _service.StartAsync(); + } + + public async Task DisposeAsync() + { + await _service.StopAsync(); + + if (File.Exists(_dbPath)) + { + File.Delete(_dbPath); + } + } + + private FreeSqlDataAccess NewAccess() + => new(_service); + + [Fact] + public async Task InsertAsync_SetsIdAndTimestamps() + { + var access = NewAccess(); + var inserted = await access.InsertAsync(new SampleUser { Name = "Ann", Age = 30 }); + + Assert.NotEqual(Guid.Empty, inserted.Id); + Assert.NotEqual(default, inserted.Created); + Assert.Equal(inserted.Created, inserted.Updated); + + var fetched = await access.GetByIdAsync(inserted.Id); + Assert.NotNull(fetched); + Assert.Equal("Ann", fetched!.Name); + } + + [Fact] + public async Task UpdateAsync_BumpsUpdated() + { + var access = NewAccess(); + var user = await access.InsertAsync(new SampleUser { Name = "Bob", Age = 20 }); + + user.Age = 21; + var updated = await access.UpdateAsync(user); + + Assert.Equal(21, updated.Age); + Assert.True(updated.Updated >= updated.Created); + } + + [Fact] + public async Task DeleteAsync_RemovesRow() + { + var access = NewAccess(); + var user = await access.InsertAsync(new SampleUser { Name = "Cara", Age = 40 }); + + Assert.True(await access.DeleteAsync(user.Id)); + Assert.Null(await access.GetByIdAsync(user.Id)); + Assert.False(await access.DeleteAsync(user.Id)); + } + + [Fact] + public async Task CountAndExists_RespectPredicate() + { + var access = NewAccess(); + await access.BulkInsertAsync(new[] + { + new SampleUser { Name = "x", Age = 10 }, + new SampleUser { Name = "y", Age = 50 } + }); + + Assert.Equal(2, await access.CountAsync()); + Assert.Equal(1, await access.CountAsync(u => u.Age >= 50)); + Assert.True(await access.ExistsAsync(u => u.Age == 10)); + Assert.False(await access.ExistsAsync(u => u.Age == 999)); + } + + [Fact] + public async Task BulkUpdateAndDelete_AffectRows() + { + var access = NewAccess(); + var users = new List + { + new() { Name = "a", Age = 1 }, + new() { Name = "b", Age = 2 }, + new() { Name = "c", Age = 3 } + }; + await access.BulkInsertAsync(users); + + foreach (var u in users) + { + u.Age += 100; + } + + Assert.Equal(3, await access.BulkUpdateAsync(users)); + Assert.Equal(3, await access.CountAsync(u => u.Age > 100)); + Assert.Equal(3, await access.BulkDeleteAsync(u => u.Age > 100)); + Assert.Equal(0, await access.CountAsync()); + } + + [Fact] + public async Task GetPagedAsync_ReturnsMetadata() + { + var access = NewAccess(); + for (var i = 0; i < 25; i++) + { + await access.InsertAsync(new SampleUser { Name = $"u{i}", Age = i }); + } + + var page = await access.GetPagedAsync(page: 2, pageSize: 10, orderBy: u => u.Age); + + Assert.Equal(10, page.Items.Count); + Assert.Equal(25, page.TotalCount); + Assert.Equal(3, page.TotalPages); + Assert.True(page.HasNext); + Assert.True(page.HasPrevious); + Assert.Equal(10, page.Items[0].Age); + } + + [Fact] + public async Task InsertAsync_RollsBackOnFailure() + { + var access = NewAccess(); + var first = await access.InsertAsync(new SampleUser { Name = "first", Age = 1 }); + + // Re-using an existing primary key forces a unique-constraint violation. + var duplicate = new SampleUser { Id = first.Id, Name = "dup", Age = 2 }; + + await Assert.ThrowsAnyAsync(() => access.InsertAsync(duplicate)); + Assert.Equal(1, await access.CountAsync()); + } +} diff --git a/tests/SquidStd.Tests/Database/PagedResultDataTests.cs b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs new file mode 100644 index 00000000..abee62ff --- /dev/null +++ b/tests/SquidStd.Tests/Database/PagedResultDataTests.cs @@ -0,0 +1,43 @@ +using SquidStd.Database.Abstractions.Data; + +namespace SquidStd.Tests.Database; + +public class PagedResultDataTests +{ + [Fact] + public void Create_ComputesPagingMetadata() + { + var items = new[] { 1, 2, 3 }; + + var result = PagedResultData.Create(items, page: 2, pageSize: 3, totalCount: 10); + + Assert.Equal(items, result.Items); + Assert.Equal(2, result.Page); + Assert.Equal(3, result.PageSize); + Assert.Equal(10, result.TotalCount); + Assert.Equal(4, result.TotalPages); + Assert.True(result.HasNext); + Assert.True(result.HasPrevious); + } + + [Fact] + public void Create_FirstPageHasNoPrevious() + { + var result = PagedResultData.Create(new[] { 1 }, page: 1, pageSize: 10, totalCount: 1); + + Assert.Equal(1, result.TotalPages); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + } + + [Fact] + public void Create_EmptyResultHasZeroPages() + { + var result = PagedResultData.Create(Array.Empty(), page: 1, pageSize: 10, totalCount: 0); + + Assert.Empty(result.Items); + Assert.Equal(0, result.TotalPages); + Assert.False(result.HasNext); + Assert.False(result.HasPrevious); + } +} diff --git a/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs b/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs index 5073dc4a..5a242bd7 100644 --- a/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs +++ b/tests/SquidStd.Tests/Directories/DirectoriesConfigTests.cs @@ -19,12 +19,12 @@ public void Constructor_CreatesRootAndConfiguredSubdirectories() } [Fact] - public void GetPath_String_ReturnsSnakeCasedPath() + public void EnumIndexer_ResolvesPathFromEnumName() { using var temp = new TempDirectory(); - var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); + var config = new DirectoriesConfig(temp.Combine("app"), []); - Assert.Equal(Path.Combine(config.Root, "logs"), config.GetPath("Logs")); + Assert.Equal(Path.Combine(config.Root, "plugins"), config[TestDirectoryType.Plugins]); } [Fact] @@ -40,30 +40,30 @@ public void GetPath_CreatesMissingDirectoryOnDemand() } [Fact] - public void StringIndexer_ReturnsSamePathAsGetPath() + public void GetPath_String_ReturnsSnakeCasedPath() { using var temp = new TempDirectory(); var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); - Assert.Equal(config.GetPath("Logs"), config["Logs"]); + Assert.Equal(Path.Combine(config.Root, "logs"), config.GetPath("Logs")); } [Fact] - public void EnumIndexer_ResolvesPathFromEnumName() + public void GetPathGeneric_ResolvesPathFromEnumValue() { using var temp = new TempDirectory(); var config = new DirectoriesConfig(temp.Combine("app"), []); - Assert.Equal(Path.Combine(config.Root, "plugins"), config[TestDirectoryType.Plugins]); + Assert.Equal(Path.Combine(config.Root, "config_files"), config.GetPath(TestDirectoryType.ConfigFiles)); } [Fact] - public void GetPathGeneric_ResolvesPathFromEnumValue() + public void StringIndexer_ReturnsSamePathAsGetPath() { using var temp = new TempDirectory(); - var config = new DirectoriesConfig(temp.Combine("app"), []); + var config = new DirectoriesConfig(temp.Combine("app"), ["Logs"]); - Assert.Equal(Path.Combine(config.Root, "config_files"), config.GetPath(TestDirectoryType.ConfigFiles)); + Assert.Equal(config.GetPath("Logs"), config["Logs"]); } [Fact] diff --git a/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs new file mode 100644 index 00000000..5928bdf8 --- /dev/null +++ b/tests/SquidStd.Tests/Env/ReplaceEnvTests.cs @@ -0,0 +1,52 @@ +using SquidStd.Core.Extensions.Env; + +namespace SquidStd.Tests.Env; + +public class ReplaceEnvTests +{ + [Fact] + public void ReplaceEnv_SubstitutesKnownVariable() + { + Environment.SetEnvironmentVariable("SQUID_TEST_VAR", "secret"); + try + { + Assert.Equal("pwd=secret;", "pwd=$SQUID_TEST_VAR;".ReplaceEnv()); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_TEST_VAR", null); + } + } + + [Fact] + public void ReplaceEnv_LeavesUnknownVariableUntouched() + { + Environment.SetEnvironmentVariable("SQUID_MISSING_VAR", null); + Assert.Equal("x=$SQUID_MISSING_VAR", "x=$SQUID_MISSING_VAR".ReplaceEnv()); + } + + [Fact] + public void ReplaceEnv_SubstitutesMultipleTokens() + { + Environment.SetEnvironmentVariable("SQUID_A", "1"); + Environment.SetEnvironmentVariable("SQUID_B", "2"); + try + { + Assert.Equal("1-2", "$SQUID_A-$SQUID_B".ReplaceEnv()); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_A", null); + Environment.SetEnvironmentVariable("SQUID_B", null); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("no tokens here")] + public void ReplaceEnv_PassesThroughWhenNothingToReplace(string? input) + { + Assert.Equal(input, input!.ReplaceEnv()); + } +} diff --git a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs index 1f5d87c3..7cd656a5 100644 --- a/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Directories/DirectoriesExtensionTests.cs @@ -4,6 +4,10 @@ namespace SquidStd.Tests.Extensions.Directories; public class DirectoriesExtensionTests { + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void ResolvePathAndEnvs_NullOrWhitespace_ReturnsNull(string? path) + => Assert.Null(path!.ResolvePathAndEnvs()); + [Fact] public void ResolvePathAndEnvs_TildePrefix_ExpandsToUserProfile() { @@ -13,11 +17,4 @@ public void ResolvePathAndEnvs_TildePrefix_ExpandsToUserProfile() Assert.Equal(home + "/sub", result); } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void ResolvePathAndEnvs_NullOrWhitespace_ReturnsNull(string? path) - => Assert.Null(path!.ResolvePathAndEnvs()); } diff --git a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs index 7c9e7c8b..1a408dfc 100644 --- a/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Env/EnvExtensionsTests.cs @@ -6,6 +6,10 @@ public class EnvExtensionsTests { private const string TestVariable = "SQUIDSTD_UNIT_TEST_VAR"; + [Theory, InlineData(""), InlineData("no variables here")] + public void ExpandEnvironmentVariables_NoMatch_ReturnsInput(string input) + => Assert.Equal(input, input.ExpandEnvironmentVariables()); + [Fact] public void ExpandEnvironmentVariables_ReplacesDollarPrefixedVariable() { @@ -22,10 +26,4 @@ public void ExpandEnvironmentVariables_ReplacesDollarPrefixedVariable() Environment.SetEnvironmentVariable(TestVariable, null); } } - - [Theory] - [InlineData("")] - [InlineData("no variables here")] - public void ExpandEnvironmentVariables_NoMatch_ReturnsInput(string input) - => Assert.Equal(input, input.ExpandEnvironmentVariables()); } diff --git a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs index 03f75f09..bb72c066 100644 --- a/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs +++ b/tests/SquidStd.Tests/Extensions/Logger/LogLevelExtensionsTests.cs @@ -6,18 +6,14 @@ namespace SquidStd.Tests.Extensions.Logger; public class LogLevelExtensionsTests { - [Theory] - [InlineData(LogLevelType.Trace, LogEventLevel.Verbose)] - [InlineData(LogLevelType.Debug, LogEventLevel.Debug)] - [InlineData(LogLevelType.Information, LogEventLevel.Information)] - [InlineData(LogLevelType.Warning, LogEventLevel.Warning)] - [InlineData(LogLevelType.Error, LogEventLevel.Error)] + [Theory, InlineData(LogLevelType.Trace, LogEventLevel.Verbose), InlineData(LogLevelType.Debug, LogEventLevel.Debug), + InlineData(LogLevelType.Information, LogEventLevel.Information), + InlineData(LogLevelType.Warning, LogEventLevel.Warning), InlineData(LogLevelType.Error, LogEventLevel.Error), + InlineData(LogLevelType.Critical, LogEventLevel.Fatal)] public void ToSerilogLogLevel_KnownLevels_MapsExpected(LogLevelType input, LogEventLevel expected) => Assert.Equal(expected, input.ToSerilogLogLevel()); - [Theory] - [InlineData(LogLevelType.None)] - [InlineData(LogLevelType.Critical)] - public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation(LogLevelType input) - => Assert.Equal(LogEventLevel.Information, input.ToSerilogLogLevel()); + [Fact] + public void ToSerilogLogLevel_UnmappedLevels_FallsBackToInformation() + => Assert.Equal(LogEventLevel.Information, ((LogLevelType)255).ToSerilogLogLevel()); } diff --git a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs index d3b66405..9aee1867 100644 --- a/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs +++ b/tests/SquidStd.Tests/Extensions/Strings/StringMethodExtensionTests.cs @@ -9,8 +9,8 @@ public void ToCamelCase_DelegatesToStringUtils() => Assert.Equal("helloWorld", "HelloWorld".ToCamelCase()); [Fact] - public void ToSnakeCase_DelegatesToStringUtils() - => Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); + public void ToDotCase_DelegatesToStringUtils() + => Assert.Equal("hello.world", "HelloWorld".ToDotCase()); [Fact] public void ToKebabCase_DelegatesToStringUtils() @@ -21,26 +21,26 @@ public void ToPascalCase_DelegatesToStringUtils() => Assert.Equal("HelloWorld", "hello_world".ToPascalCase()); [Fact] - public void ToSnakeCaseUpper_DelegatesToStringUtils() - => Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); + public void ToPathCase_DelegatesToStringUtils() + => Assert.Equal("hello/world", "HelloWorld".ToPathCase()); [Fact] - public void ToTitleCase_DelegatesToStringUtils() - => Assert.Equal("Hello World", "hello_world".ToTitleCase()); + public void ToSentenceCase_DelegatesToStringUtils() + => Assert.Equal("Hello world", "hello world".ToSentenceCase()); [Fact] - public void ToTrainCase_DelegatesToStringUtils() - => Assert.Equal("Hello-World", "hello_world".ToTrainCase()); + public void ToSnakeCase_DelegatesToStringUtils() + => Assert.Equal("hello_world", "HelloWorld".ToSnakeCase()); [Fact] - public void ToDotCase_DelegatesToStringUtils() - => Assert.Equal("hello.world", "HelloWorld".ToDotCase()); + public void ToSnakeCaseUpper_DelegatesToStringUtils() + => Assert.Equal("HELLO_WORLD", "HelloWorld".ToSnakeCaseUpper()); [Fact] - public void ToPathCase_DelegatesToStringUtils() - => Assert.Equal("hello/world", "HelloWorld".ToPathCase()); + public void ToTitleCase_DelegatesToStringUtils() + => Assert.Equal("Hello World", "hello_world".ToTitleCase()); [Fact] - public void ToSentenceCase_DelegatesToStringUtils() - => Assert.Equal("Hello world", "hello world".ToSentenceCase()); + public void ToTrainCase_DelegatesToStringUtils() + => Assert.Equal("Hello-World", "hello_world".ToTrainCase()); } diff --git a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs index 8c3ae252..bb476cef 100644 --- a/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs +++ b/tests/SquidStd.Tests/Json/JsonContextTypeResolverTests.cs @@ -15,20 +15,12 @@ public void GetRegisteredTypes_ReturnsAllSerializableTypes() } [Fact] - public void IsTypeRegistered_RegisteredType_ReturnsTrue() - => Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); - - [Fact] - public void IsTypeRegistered_UnregisteredType_ReturnsFalse() - => Assert.False(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(JsonContextTypeResolverTests))); - - [Fact] - public void GetTypeInfo_RegisteredType_ReturnsTypeInfo() + public void GetRegisteredTypesGeneric_FiltersByBaseType() { - var typeInfo = JsonContextTypeResolver.GetTypeInfo(TestJsonContext.Default); + var types = JsonContextTypeResolver.GetRegisteredTypes(TestJsonContext.Default).ToList(); - Assert.NotNull(typeInfo); - Assert.Equal(typeof(SampleDto), typeInfo.Type); + Assert.Contains(typeof(SampleDto), types); + Assert.DoesNotContain(typeof(OtherDto), types); } [Fact] @@ -41,11 +33,21 @@ public void GetRegisteredTypesWithInfo_ReturnsEntriesForAllTypes() } [Fact] - public void GetRegisteredTypesGeneric_FiltersByBaseType() + public void GetTypeInfo_RegisteredType_ReturnsTypeInfo() { - var types = JsonContextTypeResolver.GetRegisteredTypes(TestJsonContext.Default).ToList(); + var typeInfo = JsonContextTypeResolver.GetTypeInfo(TestJsonContext.Default); - Assert.Contains(typeof(SampleDto), types); - Assert.DoesNotContain(typeof(OtherDto), types); + Assert.NotNull(typeInfo); + Assert.Equal(typeof(SampleDto), typeInfo.Type); } + + [Fact] + public void IsTypeRegistered_RegisteredType_ReturnsTrue() + => Assert.True(JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(SampleDto))); + + [Fact] + public void IsTypeRegistered_UnregisteredType_ReturnsFalse() + => Assert.False( + JsonContextTypeResolver.IsTypeRegistered(TestJsonContext.Default, typeof(JsonContextTypeResolverTests)) + ); } diff --git a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs index c9b4e64e..47880313 100644 --- a/tests/SquidStd.Tests/Json/JsonUtilsTests.cs +++ b/tests/SquidStd.Tests/Json/JsonUtilsTests.cs @@ -14,19 +14,36 @@ public JsonUtilsTests() JsonUtils.RegisterJsonContext(TestJsonContext.Default); } + // Local type whose name ends with "Entity" to verify suffix stripping. + private sealed class UserEntity { } + [Fact] - public void SerializeDeserialize_RoundTrip_PreservesValues() + public void AddAndRemoveJsonConverter_MutatesConverterList() { - var original = new SampleDto { Name = "squid", Count = 42 }; - - var json = JsonUtils.Serialize(original); - var restored = JsonUtils.Deserialize(json); + try + { + JsonUtils.AddJsonConverter(new DummyGuidConverter()); + Assert.Contains(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - Assert.NotNull(restored); - Assert.Equal(original.Name, restored.Name); - Assert.Equal(original.Count, restored.Count); + // Adding a second instance of the same type is ignored. + JsonUtils.AddJsonConverter(new DummyGuidConverter()); + Assert.Single(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + } + finally + { + Assert.True(JsonUtils.RemoveJsonConverter()); + Assert.DoesNotContain(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + } } + [Fact] + public void Deserialize_InvalidJson_ThrowsJsonException() + => Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); + + [Theory, InlineData(""), InlineData(" ")] + public void Deserialize_NullOrWhitespace_Throws(string json) + => Assert.Throws(() => JsonUtils.Deserialize(json)); + [Fact] public void Deserialize_WithExplicitContext_ReturnsObject() { @@ -39,33 +56,10 @@ public void Deserialize_WithExplicitContext_ReturnsObject() } [Fact] - public void Serialize_NullObject_Throws() - => Assert.Throws(() => JsonUtils.Serialize(null!)); - - [Theory] - [InlineData("")] - [InlineData(" ")] - public void Deserialize_NullOrWhitespace_Throws(string json) - => Assert.Throws(() => JsonUtils.Deserialize(json)); - - [Fact] - public void Deserialize_InvalidJson_ThrowsJsonException() - => Assert.Throws(() => JsonUtils.Deserialize("{ not valid")); - - [Theory] - [InlineData("{\"a\":1}", true)] - [InlineData("[1,2,3]", true)] - [InlineData("not json", false)] - [InlineData("", false)] - public void IsValidJson_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsValidJson(json)); - - [Theory] - [InlineData("[1,2,3]", true)] - [InlineData("{\"a\":1}", false)] - [InlineData("invalid", false)] - public void IsArray_VariousInputs_ReturnsExpected(string json, bool expected) - => Assert.Equal(expected, JsonUtils.IsArray(json)); + public void DeserializeFromFile_MissingFile_Throws() + => Assert.Throws( + () => JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) + ); [Fact] public void DeserializeOrDefault_EmptyJson_ReturnsDefault() @@ -86,9 +80,11 @@ public void DeserializeOrDefault_ValidJson_ReturnsObject() Assert.Equal("ok", result.Name); } - [Theory] - [InlineData("UserEntity", "user.schema.json")] - [InlineData("SampleDto", "sample_dto.schema.json")] + [Fact] + public void GetJsonConverters_ContainsDefaultEnumConverter() + => Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + + [Theory, InlineData("UserEntity", "user.schema.json"), InlineData("SampleDto", "sample_dto.schema.json")] public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, string expected) { // Map the parameterized type name to a real type with the same Name. @@ -97,27 +93,30 @@ public void GetSchemaFileName_GeneratesSnakeCaseSchemaName(string typeName, stri Assert.Equal(expected, JsonUtils.GetSchemaFileName(type)); } + [Theory, InlineData("[1,2,3]", true), InlineData("{\"a\":1}", false), InlineData("invalid", false)] + public void IsArray_VariousInputs_ReturnsExpected(string json, bool expected) + => Assert.Equal(expected, JsonUtils.IsArray(json)); + + [Theory, InlineData("{\"a\":1}", true), InlineData("[1,2,3]", true), InlineData("not json", false), + InlineData("", false)] + public void IsValidJson_VariousInputs_ReturnsExpected(string json, bool expected) + => Assert.Equal(expected, JsonUtils.IsValidJson(json)); + [Fact] - public void GetJsonConverters_ContainsDefaultEnumConverter() - => Assert.Contains(JsonUtils.GetJsonConverters(), converter => converter is JsonStringEnumConverter); + public void Serialize_NullObject_Throws() + => Assert.Throws(() => JsonUtils.Serialize(null!)); [Fact] - public void AddAndRemoveJsonConverter_MutatesConverterList() + public void SerializeDeserialize_RoundTrip_PreservesValues() { - try - { - JsonUtils.AddJsonConverter(new DummyGuidConverter()); - Assert.Contains(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); + var original = new SampleDto { Name = "squid", Count = 42 }; - // Adding a second instance of the same type is ignored. - JsonUtils.AddJsonConverter(new DummyGuidConverter()); - Assert.Single(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - } - finally - { - Assert.True(JsonUtils.RemoveJsonConverter()); - Assert.DoesNotContain(JsonUtils.GetJsonConverters(), c => c is DummyGuidConverter); - } + var json = JsonUtils.Serialize(original); + var restored = JsonUtils.Deserialize(json); + + Assert.NotNull(restored); + Assert.Equal(original.Name, restored.Name); + Assert.Equal(original.Count, restored.Count); } [Fact] @@ -133,15 +132,4 @@ public void SerializeToFile_DeserializeFromFile_RoundTrips() Assert.Equal("file", restored.Name); Assert.Equal(9, restored.Count); } - - [Fact] - public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => JsonUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json")) - ); - - // Local type whose name ends with "Entity" to verify suffix stripping. - private sealed class UserEntity - { - } } diff --git a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs index 3d48c2de..8e2aa0a5 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkExtensionsTests.cs @@ -11,13 +11,16 @@ public class EventSinkExtensionsTests public void EventSink_WiredIntoSerilog_RaisesEventsForLogs() { LogEventData? captured = null; - void Handler(object? sender, LogEventData data) => captured = data; + + void Handler(object? sender, LogEventData data) + => captured = data; EventSink.OnLogReceived += Handler; var logger = new LoggerConfiguration() - .WriteTo.EventSink() - .CreateLogger(); + .WriteTo + .EventSink() + .CreateLogger(); try { diff --git a/tests/SquidStd.Tests/Logging/EventSinkTests.cs b/tests/SquidStd.Tests/Logging/EventSinkTests.cs index b23bd7f2..1a0b90a8 100644 --- a/tests/SquidStd.Tests/Logging/EventSinkTests.cs +++ b/tests/SquidStd.Tests/Logging/EventSinkTests.cs @@ -9,29 +9,21 @@ namespace SquidStd.Tests.Logging; public class EventSinkTests { [Fact] - public void Emit_WithSubscriber_RaisesEventWithMappedData() + public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() { - LogEventData? captured = null; - void Handler(object? sender, LogEventData data) => captured = data; + var invoked = false; + + void Handler(object? sender, LogEventData data) + => invoked = true; EventSink.OnLogReceived += Handler; + EventSink.ClearSubscribers(); try { - var logEvent = CreateLogEvent( - LogEventLevel.Warning, - "Hello {Name}", - new LogEventProperty("Name", new ScalarValue("World")), - new LogEventProperty("SourceContext", new ScalarValue("MyClass")) - ); - - new EventSink().Emit(logEvent); + new EventSink().Emit(CreateLogEvent(LogEventLevel.Information, "ignored")); - Assert.NotNull(captured); - Assert.Equal(LogEventLevel.Warning, captured.Level); - Assert.Contains("World", captured.Message); - Assert.Equal("World", captured.Properties["Name"]); - Assert.Equal("MyClass", captured.SourceContext); + Assert.False(invoked); } finally { @@ -41,19 +33,41 @@ public void Emit_WithSubscriber_RaisesEventWithMappedData() } [Fact] - public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() + public void Emit_NoSubscribers_DoesNotThrow() { - var invoked = false; - void Handler(object? sender, LogEventData data) => invoked = true; + EventSink.ClearSubscribers(); + + var exception = Record.Exception(() => new EventSink().Emit(CreateLogEvent(LogEventLevel.Error, "no listeners"))); + + Assert.Null(exception); + } + + [Fact] + public void Emit_WithSubscriber_RaisesEventWithMappedData() + { + LogEventData? captured = null; + + void Handler(object? sender, LogEventData data) + => captured = data; EventSink.OnLogReceived += Handler; - EventSink.ClearSubscribers(); try { - new EventSink().Emit(CreateLogEvent(LogEventLevel.Information, "ignored")); + var logEvent = CreateLogEvent( + LogEventLevel.Warning, + "Hello {Name}", + new LogEventProperty("Name", new ScalarValue("World")), + new LogEventProperty("SourceContext", new ScalarValue("MyClass")) + ); - Assert.False(invoked); + new EventSink().Emit(logEvent); + + Assert.NotNull(captured); + Assert.Equal(LogEventLevel.Warning, captured.Level); + Assert.Contains("World", captured.Message); + Assert.Equal("World", captured.Properties["Name"]); + Assert.Equal("MyClass", captured.SourceContext); } finally { @@ -62,20 +76,10 @@ public void Emit_AfterClearSubscribers_DoesNotInvokeHandler() } } - [Fact] - public void Emit_NoSubscribers_DoesNotThrow() - { - EventSink.ClearSubscribers(); - - var exception = Record.Exception(() => new EventSink().Emit(CreateLogEvent(LogEventLevel.Error, "no listeners"))); - - Assert.Null(exception); - } - private static LogEvent CreateLogEvent(LogEventLevel level, string template, params LogEventProperty[] properties) { var parsedTemplate = new MessageTemplateParser().Parse(template); - return new LogEvent(DateTimeOffset.UtcNow, level, exception: null, parsedTemplate, properties); + return new(DateTimeOffset.UtcNow, level, null, parsedTemplate, properties); } } diff --git a/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs new file mode 100644 index 00000000..0ea816cf --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/InMemoryQueueProviderTests.cs @@ -0,0 +1,122 @@ +using System.Text; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; + +namespace SquidStd.Tests.Messaging; + +public class InMemoryQueueProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static InMemoryQueueProvider NewProvider(MessagingMetricsProvider? metrics = null, MessagingOptions? options = null) + => new(options ?? new MessagingOptions(), metrics ?? new MessagingMetricsProvider()); + + private static ReadOnlyMemory Bytes(string s) + => Encoding.UTF8.GetBytes(s); + + private static string Text(ReadOnlyMemory b) + => Encoding.UTF8.GetString(b.Span); + + [Fact] + public async Task Publish_DeliversToSubscriber() + { + await using var provider = NewProvider(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync("q", Bytes("hello")); + + Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task MessagesPublishedBeforeSubscribe_AreBuffered() + { + await using var provider = NewProvider(); + await provider.PublishAsync("q", Bytes("early")); + + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q", (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + Assert.Equal("early", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task TwoSubscribers_ReceiveRoundRobin() + { + await using var provider = NewProvider(); + var aCount = 0; + var bCount = 0; + var done = new CountdownEvent(4); + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref aCount); done.Signal(); return Task.CompletedTask; }); + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref bCount); done.Signal(); return Task.CompletedTask; }); + + for (var i = 0; i < 4; i++) + { + await provider.PublishAsync("q", Bytes("m")); + } + + Assert.True(done.Wait(Timeout)); + Assert.Equal(2, aCount); + Assert.Equal(2, bCount); + } + + [Fact] + public async Task FailingThenSucceeding_IsRetriedAndDelivered() + { + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 3 }); + var attempts = 0; + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe( + "q", + (_, _) => + { + if (Interlocked.Increment(ref attempts) < 2) + { + throw new InvalidOperationException("transient"); + } + + delivered.TrySetResult(); + + return Task.CompletedTask; + } + ); + + await provider.PublishAsync("q", Bytes("m")); + + await delivered.Task.WaitAsync(Timeout); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task AlwaysFailing_IsDeadLetteredAfterMaxAttempts() + { + await using var provider = NewProvider(options: new MessagingOptions { MaxDeliveryAttempts = 2 }); + var attempts = 0; + provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref attempts); throw new InvalidOperationException("always"); }); + + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe("q.dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync("q", Bytes("poison")); + + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task DisposedSubscription_StopsReceiving() + { + await using var provider = NewProvider(); + var count = 0; + var subscription = provider.Subscribe("q", (_, _) => { Interlocked.Increment(ref count); return Task.CompletedTask; }); + subscription.Dispose(); + + await provider.PublishAsync("q", Bytes("m")); + await Task.Delay(200); + + Assert.Equal(0, count); + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs new file mode 100644 index 00000000..f84be209 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessageQueueTests.cs @@ -0,0 +1,47 @@ +using SquidStd.Core.Json; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Tests.Messaging; + +public class MessageQueueTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private sealed class Order + { + public string Id { get; set; } = ""; + public int Amount { get; set; } + } + + private sealed class CapturingListener : IQueueMessageListenerAsync + { + public TaskCompletionSource Received { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task HandleAsync(Order message, CancellationToken cancellationToken) + { + Received.TrySetResult(message); + + return Task.CompletedTask; + } + } + + [Fact] + public async Task PublishAsync_DeliversTypedMessageToListener() + { + await using var provider = new InMemoryQueueProvider(new MessagingOptions(), new MessagingMetricsProvider()); + var serializer = new JsonDataSerializer(); + IMessageQueue queue = new MessageQueue(provider, serializer, serializer); + var listener = new CapturingListener(); + queue.Subscribe("orders", listener); + + await queue.PublishAsync("orders", new Order { Id = "A1", Amount = 42 }); + + var received = await listener.Received.Task.WaitAsync(Timeout); + Assert.Equal("A1", received.Id); + Assert.Equal(42, received.Amount); + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs new file mode 100644 index 00000000..0b036357 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingConnectionStringTests.cs @@ -0,0 +1,55 @@ +using SquidStd.Messaging.Abstractions; +using SquidStd.Messaging.Abstractions.Data.Config; + +namespace SquidStd.Tests.Messaging; + +public class MessagingConnectionStringTests +{ + [Fact] + public void Parse_Memory_ReadsScheme() + { + var cs = MessagingConnectionString.Parse("memory://localhost"); + + Assert.Equal("memory", cs.Scheme); + Assert.Equal("localhost", cs.Host); + Assert.Null(cs.Port); + } + + [Fact] + public void Parse_RabbitMq_ReadsCredentialsHostPortVhost() + { + var cs = MessagingConnectionString.Parse("rabbitmq://user:pass@broker:5672/myvhost"); + + Assert.Equal("rabbitmq", cs.Scheme); + Assert.Equal("user", cs.UserName); + Assert.Equal("pass", cs.Password); + Assert.Equal("broker", cs.Host); + Assert.Equal(5672, cs.Port); + Assert.Equal("myvhost", cs.VirtualHost); + } + + [Fact] + public void Parse_ReadsQueryParameters() + { + var cs = MessagingConnectionString.Parse("rabbitmq://broker/?prefetch=20&maxDeliveryAttempts=5"); + + Assert.Equal("20", cs.Parameters["prefetch"]); + Assert.Equal("5", cs.Parameters["maxDeliveryAttempts"]); + } + + [Fact] + public void ToMessagingOptions_ReadsParameters() + { + var cs = MessagingConnectionString.Parse("rabbitmq://broker/?maxDeliveryAttempts=5&deadLetterSuffix=.dead&retryDelayMs=250"); + + var options = cs.ToMessagingOptions(); + + Assert.Equal(5, options.MaxDeliveryAttempts); + Assert.Equal(".dead", options.DeadLetterQueueSuffix); + Assert.Equal(TimeSpan.FromMilliseconds(250), options.RetryDelay); + } + + [Fact] + public void Parse_NullOrWhitespace_Throws() + => Assert.Throws(() => MessagingConnectionString.Parse(" ")); +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs new file mode 100644 index 00000000..490d402a --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingMetricsProviderTests.cs @@ -0,0 +1,38 @@ +using SquidStd.Messaging.Abstractions.Services; + +namespace SquidStd.Tests.Messaging; + +public class MessagingMetricsProviderTests +{ + [Fact] + public void ProviderName_IsMessaging() + => Assert.Equal("messaging", new MessagingMetricsProvider().ProviderName); + + [Fact] + public async Task CollectAsync_ReportsCountersAndGaugesPerQueue() + { + var metrics = new MessagingMetricsProvider(); + + metrics.OnPublished("orders"); + metrics.OnPublished("orders"); + metrics.OnDelivered("orders"); + metrics.OnFailed("orders"); + metrics.OnRetried("orders"); + metrics.OnDeadLettered("orders"); + metrics.SetQueueDepth("orders", 5); + metrics.SetSubscriberCount("orders", 2); + + var samples = await metrics.CollectAsync(); + + double Value(string name) => samples.Single( + s => s.Name == name && s.Tags != null && s.Tags["queue"] == "orders").Value; + + Assert.Equal(2, Value("published")); + Assert.Equal(1, Value("delivered")); + Assert.Equal(1, Value("failed")); + Assert.Equal(1, Value("retried")); + Assert.Equal(1, Value("dead_lettered")); + Assert.Equal(5, Value("queue_depth")); + Assert.Equal(2, Value("subscribers")); + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs new file mode 100644 index 00000000..733c3884 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingOptionsTests.cs @@ -0,0 +1,17 @@ +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.Abstractions.Services; + +namespace SquidStd.Tests.Messaging; + +public class MessagingOptionsTests +{ + [Fact] + public void Defaults_AreApplied() + { + var options = new MessagingOptions(); + + Assert.Equal(3, options.MaxDeliveryAttempts); + Assert.Equal(".dlq", options.DeadLetterQueueSuffix); + Assert.Equal(TimeSpan.Zero, options.RetryDelay); + } +} diff --git a/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs new file mode 100644 index 00000000..140608b0 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/MessagingRegistrationExtensionsTests.cs @@ -0,0 +1,37 @@ +using DryIoc; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Serialization; +using SquidStd.Messaging.Extensions; +using SquidStd.Messaging.Services; +using SquidStd.Messaging.Abstractions.Interfaces; + +namespace SquidStd.Tests.Messaging; + +public class MessagingRegistrationExtensionsTests +{ + [Fact] + public void AddInMemoryMessaging_RegistersResolvableServices() + { + using var container = new Container(); + + container.AddInMemoryMessaging(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + + Assert.Contains(container.Resolve>(), p => p.ProviderName == "messaging"); + } + + [Fact] + public void AddInMemoryMessaging_MetricsAndProviderShareInstance() + { + using var container = new Container(); + + container.AddInMemoryMessaging(); + + Assert.Same(container.Resolve(), container.Resolve()); + } +} diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs new file mode 100644 index 00000000..64acc41a --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqContainerFixture.cs @@ -0,0 +1,25 @@ +using Testcontainers.RabbitMq; + +namespace SquidStd.Tests.Messaging.RabbitMq; + +/// +/// Starts a RabbitMQ container once for the whole collection and exposes its AMQP URI. +/// +public sealed class RabbitMqContainerFixture : IAsyncLifetime +{ + private readonly RabbitMqContainer _container = new RabbitMqBuilder().Build(); + + public string AmqpUri => _container.GetConnectionString(); + + public Task InitializeAsync() + => _container.StartAsync(); + + public Task DisposeAsync() + => _container.DisposeAsync().AsTask(); +} + +[CollectionDefinition(Name)] +public sealed class RabbitMqCollection : ICollectionFixture +{ + public const string Name = "RabbitMq"; +} diff --git a/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs new file mode 100644 index 00000000..3876c036 --- /dev/null +++ b/tests/SquidStd.Tests/Messaging/RabbitMq/RabbitMqQueueProviderTests.cs @@ -0,0 +1,80 @@ +using System.Text; +using SquidStd.Messaging.Abstractions.Data.Config; +using SquidStd.Messaging.RabbitMq.Data.Config; +using SquidStd.Messaging.RabbitMq.Services; + +namespace SquidStd.Tests.Messaging.RabbitMq; + +[Collection(RabbitMqCollection.Name)] +public class RabbitMqQueueProviderTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private readonly RabbitMqContainerFixture _fixture; + + public RabbitMqQueueProviderTests(RabbitMqContainerFixture fixture) + { + _fixture = fixture; + } + + private RabbitMqQueueProvider NewProvider(MessagingOptions? options = null) + => new(new RabbitMqOptions { Uri = new Uri(_fixture.AmqpUri) }, options ?? new MessagingOptions()); + + private static ReadOnlyMemory Bytes(string s) => Encoding.UTF8.GetBytes(s); + private static string Text(ReadOnlyMemory b) => Encoding.UTF8.GetString(b.Span); + private static string Queue() => "q-" + Guid.NewGuid().ToString("N"); + + [Fact] + public async Task Publish_DeliversToSubscriber() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var queue = Queue(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe(queue, (payload, _) => { received.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync(queue, Bytes("hello")); + + Assert.Equal("hello", await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task TwoSubscribers_ShareTheLoad() + { + await using var provider = NewProvider(); + await provider.StartAsync(); + var queue = Queue(); + const int total = 10; + var a = 0; + var b = 0; + using var done = new CountdownEvent(total); + provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref a); done.Signal(); return Task.CompletedTask; }); + provider.Subscribe(queue, (_, _) => { Interlocked.Increment(ref b); done.Signal(); return Task.CompletedTask; }); + + for (var i = 0; i < total; i++) + { + await provider.PublishAsync(queue, Bytes("m")); + } + + Assert.True(done.Wait(Timeout)); + Assert.Equal(total, a + b); + Assert.True(a > 0); + Assert.True(b > 0); + } + + [Fact] + public async Task AlwaysFailing_IsDeadLettered() + { + await using var provider = NewProvider(new MessagingOptions { MaxDeliveryAttempts = 2 }); + await provider.StartAsync(); + var queue = Queue(); + provider.Subscribe(queue, (_, _) => throw new InvalidOperationException("always")); + + var deadLettered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + provider.Subscribe(queue + ".dlq", (payload, _) => { deadLettered.TrySetResult(Text(payload)); return Task.CompletedTask; }); + + await provider.PublishAsync(queue, Bytes("poison")); + + Assert.Equal("poison", await deadLettered.Task.WaitAsync(Timeout)); + } +} diff --git a/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs b/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs new file mode 100644 index 00000000..8a0814ad --- /dev/null +++ b/tests/SquidStd.Tests/Metrics/MetricsConfigTests.cs @@ -0,0 +1,17 @@ +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Tests.Metrics; + +public class MetricsConfigTests +{ + [Fact] + public void MetricsConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new MetricsConfig(); + + Assert.Equal("metrics", entry.SectionName); + Assert.Equal(typeof(MetricsConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } +} diff --git a/tests/SquidStd.Tests/Network/CircularBufferTests.cs b/tests/SquidStd.Tests/Network/CircularBufferTests.cs index e075cd09..8b1215db 100644 --- a/tests/SquidStd.Tests/Network/CircularBufferTests.cs +++ b/tests/SquidStd.Tests/Network/CircularBufferTests.cs @@ -5,6 +5,17 @@ namespace SquidStd.Tests.Network; public class CircularBufferTests { + [Fact] + public void Clear_ResetsSizeButKeepsCapacity() + { + var buffer = new CircularBuffer(4, [1, 2, 3]); + buffer.Clear(); + + Assert.Equal(0, buffer.Size); + Assert.True(buffer.IsEmpty); + Assert.Equal(4, buffer.Capacity); + } + [Fact] public void Constructor_NonPositiveCapacity_Throws() => Assert.Throws(() => new CircularBuffer(0)); @@ -23,53 +34,52 @@ public void Constructor_WithItems_PopulatesBuffer() } [Fact] - public void PushBack_WithinCapacity_AppendsInOrder() + public void Enumerator_YieldsLogicalOrder() { var buffer = new CircularBuffer(4); - buffer.PushBack(1); - buffer.PushBack(2); - buffer.PushBack(3); + buffer.PushBack(10); + buffer.PushBack(20); + buffer.PushBack(30); - Assert.Equal(3, buffer.Size); - Assert.Equal([1, 2, 3], buffer.ToArray()); - Assert.Equal(1, buffer.Front()); - Assert.Equal(3, buffer.Back()); + Assert.Equal([10, 20, 30], buffer.ToList()); } [Fact] - public void PushBack_WhenFull_DropsOldest() + public void Front_EmptyBuffer_Throws() + { + var buffer = new CircularBuffer(3); + + Assert.Throws(() => { buffer.Front(); }); + } + + [Fact] + public void Indexer_AccessesLogicalOrderAfterWrap() { var buffer = new CircularBuffer(3); buffer.PushBack(1); buffer.PushBack(2); buffer.PushBack(3); - buffer.PushBack(4); + buffer.PushBack(4); // wraps, drops 1 -> [2,3,4] - Assert.True(buffer.IsFull); - Assert.Equal([2, 3, 4], buffer.ToArray()); - Assert.Equal(2, buffer.Front()); - Assert.Equal(4, buffer.Back()); + Assert.Equal(2, buffer[0]); + Assert.Equal(3, buffer[1]); + Assert.Equal(4, buffer[2]); } [Fact] - public void PushFront_PrependsInReverseOrder() + public void Indexer_EmptyBuffer_Throws() { var buffer = new CircularBuffer(3); - buffer.PushFront(1); - buffer.PushFront(2); - buffer.PushFront(3); - Assert.Equal([3, 2, 1], buffer.ToArray()); + Assert.Throws(() => { _ = buffer[0]; }); } [Fact] - public void PopFront_RemovesFrontElement() + public void Indexer_OutOfRange_Throws() { - var buffer = new CircularBuffer(3, [1, 2, 3]); - buffer.PopFront(); + var buffer = new CircularBuffer(3, [1]); - Assert.Equal([2, 3], buffer.ToArray()); - Assert.Equal(2, buffer.Front()); + Assert.Throws(() => { _ = buffer[5]; }); } [Fact] @@ -83,82 +93,72 @@ public void PopBack_RemovesBackElement() } [Fact] - public void PushBackRange_LargerThanCapacity_KeepsLastElements() - { - var buffer = new CircularBuffer(4); - buffer.PushBackRange([1, 2, 3, 4, 5, 6]); - - Assert.True(buffer.IsFull); - Assert.Equal([3, 4, 5, 6], buffer.ToArray()); - } - - [Fact] - public void PushBackRange_WithinCapacity_Appends() - { - var buffer = new CircularBuffer(8); - buffer.PushBackRange([1, 2, 3]); - buffer.PushBackRange([4, 5]); - - Assert.Equal([1, 2, 3, 4, 5], buffer.ToArray()); - } - - [Fact] - public void Clear_ResetsSizeButKeepsCapacity() + public void PopFront_RemovesFrontElement() { - var buffer = new CircularBuffer(4, [1, 2, 3]); - buffer.Clear(); + var buffer = new CircularBuffer(3, [1, 2, 3]); + buffer.PopFront(); - Assert.Equal(0, buffer.Size); - Assert.True(buffer.IsEmpty); - Assert.Equal(4, buffer.Capacity); + Assert.Equal([2, 3], buffer.ToArray()); + Assert.Equal(2, buffer.Front()); } [Fact] - public void Indexer_AccessesLogicalOrderAfterWrap() + public void PushBack_WhenFull_DropsOldest() { var buffer = new CircularBuffer(3); buffer.PushBack(1); buffer.PushBack(2); buffer.PushBack(3); - buffer.PushBack(4); // wraps, drops 1 -> [2,3,4] + buffer.PushBack(4); - Assert.Equal(2, buffer[0]); - Assert.Equal(3, buffer[1]); - Assert.Equal(4, buffer[2]); + Assert.True(buffer.IsFull); + Assert.Equal([2, 3, 4], buffer.ToArray()); + Assert.Equal(2, buffer.Front()); + Assert.Equal(4, buffer.Back()); } [Fact] - public void Indexer_EmptyBuffer_Throws() + public void PushBack_WithinCapacity_AppendsInOrder() { - var buffer = new CircularBuffer(3); + var buffer = new CircularBuffer(4); + buffer.PushBack(1); + buffer.PushBack(2); + buffer.PushBack(3); - Assert.Throws(() => { _ = buffer[0]; }); + Assert.Equal(3, buffer.Size); + Assert.Equal([1, 2, 3], buffer.ToArray()); + Assert.Equal(1, buffer.Front()); + Assert.Equal(3, buffer.Back()); } [Fact] - public void Indexer_OutOfRange_Throws() + public void PushBackRange_LargerThanCapacity_KeepsLastElements() { - var buffer = new CircularBuffer(3, [1]); + var buffer = new CircularBuffer(4); + buffer.PushBackRange([1, 2, 3, 4, 5, 6]); - Assert.Throws(() => { _ = buffer[5]; }); + Assert.True(buffer.IsFull); + Assert.Equal([3, 4, 5, 6], buffer.ToArray()); } [Fact] - public void Front_EmptyBuffer_Throws() + public void PushBackRange_WithinCapacity_Appends() { - var buffer = new CircularBuffer(3); + var buffer = new CircularBuffer(8); + buffer.PushBackRange([1, 2, 3]); + buffer.PushBackRange([4, 5]); - Assert.Throws(() => { buffer.Front(); }); + Assert.Equal([1, 2, 3, 4, 5], buffer.ToArray()); } [Fact] - public void Enumerator_YieldsLogicalOrder() + public void PushFront_PrependsInReverseOrder() { - var buffer = new CircularBuffer(4); - buffer.PushBack(10); - buffer.PushBack(20); - buffer.PushBack(30); + var buffer = new CircularBuffer(3); + buffer.PushFront(1); + buffer.PushFront(2); + buffer.PushFront(3); - Assert.Equal([10, 20, 30], buffer.ToList()); + Assert.Equal([3, 2, 1], buffer.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs index 217553c3..c205684e 100644 --- a/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs +++ b/tests/SquidStd.Tests/Network/NetMiddlewarePipelineTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Network.Client; using SquidStd.Network.Pipeline; using SquidStd.Tests.Support; @@ -7,23 +6,33 @@ namespace SquidStd.Tests.Network; public class NetMiddlewarePipelineTests { [Fact] - public async Task ExecuteAsync_NoMiddleware_ReturnsInputUnchanged() + public void AddMiddleware_RegistersMiddleware() { var pipeline = new NetMiddlewarePipeline(); + pipeline.AddMiddleware(new DroppingMiddleware()); - var result = await pipeline.ExecuteAsync(null, new byte[] { 1, 2 }, CancellationToken.None); - - Assert.Equal([1, 2], result.ToArray()); + Assert.True(pipeline.ContainsMiddleware()); } [Fact] - public async Task ExecuteAsync_RunsMiddlewareInRegistrationOrder() + public void ContainsMiddleware_ReflectsRegistration() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0x01), new AppendingMiddleware(0x02)]); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); - var result = await pipeline.ExecuteAsync(null, new byte[] { 0x00 }, CancellationToken.None); + Assert.True(pipeline.ContainsMiddleware()); + Assert.False(pipeline.ContainsMiddleware()); + } - Assert.Equal([0x00, 0x01, 0x02], result.ToArray()); + [Fact] + public async Task ExecuteAsync_CancelledToken_Throws() + { + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync( + async () => await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) + ); } [Fact] @@ -37,31 +46,33 @@ public async Task ExecuteAsync_DroppingMiddleware_ShortCircuits() } [Fact] - public async Task ExecuteSendAsync_RunsMiddlewareInRegistrationOrder() + public async Task ExecuteAsync_NoMiddleware_ReturnsInputUnchanged() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0xAA), new AppendingMiddleware(0xBB)]); + var pipeline = new NetMiddlewarePipeline(); - var result = await pipeline.ExecuteSendAsync(null, new byte[] { 0 }, CancellationToken.None); + var result = await pipeline.ExecuteAsync(null, new byte[] { 1, 2 }, CancellationToken.None); - Assert.Equal([0x00, 0xAA, 0xBB], result.ToArray()); + Assert.Equal([1, 2], result.ToArray()); } [Fact] - public void ContainsMiddleware_ReflectsRegistration() + public async Task ExecuteAsync_RunsMiddlewareInRegistrationOrder() { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0x01), new AppendingMiddleware(0x02)]); - Assert.True(pipeline.ContainsMiddleware()); - Assert.False(pipeline.ContainsMiddleware()); + var result = await pipeline.ExecuteAsync(null, new byte[] { 0x00 }, CancellationToken.None); + + Assert.Equal([0x00, 0x01, 0x02], result.ToArray()); } [Fact] - public void AddMiddleware_RegistersMiddleware() + public async Task ExecuteSendAsync_RunsMiddlewareInRegistrationOrder() { - var pipeline = new NetMiddlewarePipeline(); - pipeline.AddMiddleware(new DroppingMiddleware()); + var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(0xAA), new AppendingMiddleware(0xBB)]); - Assert.True(pipeline.ContainsMiddleware()); + var result = await pipeline.ExecuteSendAsync(null, new byte[] { 0 }, CancellationToken.None); + + Assert.Equal([0x00, 0xAA, 0xBB], result.ToArray()); } [Fact] @@ -73,16 +84,4 @@ public void RemoveMiddleware_RemovesRegisteredAndReportsResult() Assert.False(pipeline.ContainsMiddleware()); Assert.False(pipeline.RemoveMiddleware()); } - - [Fact] - public async Task ExecuteAsync_CancelledToken_Throws() - { - var pipeline = new NetMiddlewarePipeline([new AppendingMiddleware(1)]); - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAsync( - async () => await pipeline.ExecuteAsync(null, new byte[] { 1 }, cts.Token) - ); - } } diff --git a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs index 80dc069c..5f680b54 100644 --- a/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs +++ b/tests/SquidStd.Tests/Network/PacketExtensionsTests.cs @@ -4,11 +4,7 @@ namespace SquidStd.Tests.Network; public class PacketExtensionsTests { - [Theory] - [InlineData(0x00, "0x00")] - [InlineData(0x0F, "0x0F")] - [InlineData(0xAB, "0xAB")] - [InlineData(0xFF, "0xFF")] + [Theory, InlineData(0x00, "0x00"), InlineData(0x0F, "0x0F"), InlineData(0xAB, "0xAB"), InlineData(0xFF, "0xFF")] public void ToPacketString_FormatsAsTwoDigitHex(byte opCode, string expected) => Assert.Equal(expected, opCode.ToPacketString()); } diff --git a/tests/SquidStd.Tests/Network/SessionManagerTests.cs b/tests/SquidStd.Tests/Network/SessionManagerTests.cs index bfaeb11d..535fa308 100644 --- a/tests/SquidStd.Tests/Network/SessionManagerTests.cs +++ b/tests/SquidStd.Tests/Network/SessionManagerTests.cs @@ -9,52 +9,79 @@ namespace SquidStd.Tests.Network; public class SessionManagerTests { - private static SquidTcpServer NewServer() - => new(new IPEndPoint(IPAddress.Loopback, 0)); + [Fact] + public async Task BroadcastAsync_SendsToAll_IsolatesFailures() + { + using var server = NewServer(); + using var manager = NewManager(server); - private static SessionManager NewManager(SquidTcpServer server) - => new(server, connection => $"state-{connection.SessionId}"); + var good = new FakeNetworkConnection(1); + var faulty = new FakeNetworkConnection(2) + { + SendCallback = _ => throw new InvalidOperationException("boom") + }; + manager.HandleConnected(good); + manager.HandleConnected(faulty); + + await manager.BroadcastAsync(new byte[] { 0xAB }, CancellationToken.None); + + Assert.Single(good.SentPayloads); + Assert.Equal([0xAB], good.SentPayloads[0]); + } [Fact] - public void HandleConnected_CreatesSessionAndRaisesEvent() + public async Task DisconnectAsync_KnownSession_ClosesConnection() { using var server = NewServer(); using var manager = NewManager(server); - Session? created = null; - manager.OnSessionCreated += (_, e) => created = e.Session; - - var connection = new FakeNetworkConnection(sessionId: 7); + var connection = new FakeNetworkConnection(6); manager.HandleConnected(connection); - Assert.Equal(1, manager.Count); - Assert.NotNull(created); - Assert.Equal(7, created!.SessionId); - Assert.Equal("state-7", created.State); - Assert.Same(connection, created.Connection); + await manager.DisconnectAsync(6, CancellationToken.None); + + Assert.Equal(1, connection.CloseCount); } [Fact] - public void TryGetSession_HitAndMiss() + public async Task DisconnectAsync_UnknownSession_IsNoOp() { using var server = NewServer(); using var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 3)); - Assert.True(manager.TryGetSession(3, out var session)); - Assert.Equal(3, session!.SessionId); - Assert.False(manager.TryGetSession(999, out var missing)); - Assert.Null(missing); + await manager.DisconnectAsync(999, CancellationToken.None); + + // No exception = pass. } [Fact] - public void Sessions_ReturnsSnapshotOfAll() + public void Dispose_ClearsSessionsAndIsIdempotent() + { + using var server = NewServer(); + var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection(1)); + + manager.Dispose(); + manager.Dispose(); // idempotent, no throw + + Assert.Equal(0, manager.Count); + } + + [Fact] + public void HandleConnected_CreatesSessionAndRaisesEvent() { using var server = NewServer(); using var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 1)); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 2)); + Session? created = null; + manager.OnSessionCreated += (_, e) => created = e.Session; - Assert.Equal(2, manager.Sessions.Count); + var connection = new FakeNetworkConnection(7); + manager.HandleConnected(connection); + + Assert.Equal(1, manager.Count); + Assert.NotNull(created); + Assert.Equal(7, created!.SessionId); + Assert.Equal("state-7", created.State); + Assert.Same(connection, created.Connection); } [Fact] @@ -62,7 +89,7 @@ public void HandleData_KnownSession_RaisesData() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 5); + var connection = new FakeNetworkConnection(5); manager.HandleConnected(connection); SquidStdSessionDataEventArgs? received = null; @@ -83,7 +110,7 @@ public void HandleData_UnknownSession_DoesNotRaise() var raised = false; manager.OnSessionData += (_, _) => raised = true; - manager.HandleData(new FakeNetworkConnection(sessionId: 123), new byte[] { 1 }); + manager.HandleData(new FakeNetworkConnection(123), new byte[] { 1 }); Assert.False(raised); } @@ -93,7 +120,7 @@ public void HandleDisconnected_RemovesSessionAndRaisesOnce() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 8); + var connection = new FakeNetworkConnection(8); manager.HandleConnected(connection); var removals = 0; @@ -108,114 +135,89 @@ public void HandleDisconnected_RemovesSessionAndRaisesOnce() } [Fact] - public async Task SendAsync_KnownSession_SendsToConnection() + public async Task Integration_LifecycleOverLoopback() { - using var server = NewServer(); + var timeout = TimeSpan.FromSeconds(5); + + await using var server = new SquidTcpServer(new(IPAddress.Loopback, 0)); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 4); - manager.HandleConnected(connection); - await manager.SendAsync(4, new byte[] { 7, 8 }, CancellationToken.None); + var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var dataReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var removed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); + manager.OnSessionData += (_, e) => dataReceived.TrySetResult(e.Data.ToArray()); + manager.OnSessionRemoved += (_, e) => removed.TrySetResult(e.Session.SessionId); - Assert.Single(connection.SentPayloads); - Assert.Equal([7, 8], connection.SentPayloads[0]); - } + await server.StartAsync(CancellationToken.None); + var port = server.Port; - [Fact] - public async Task SendAsync_UnknownSession_IsNoOp() - { - using var server = NewServer(); - using var manager = NewManager(server); + var client = await SquidStdTcpClient.ConnectAsync(new(IPAddress.Loopback, port)); - await manager.SendAsync(999, new byte[] { 1 }, CancellationToken.None); - // No exception = pass. + var session = await created.Task.WaitAsync(timeout); + Assert.Equal(1, manager.Count); + + await client.SendAsync(new byte[] { 10, 20, 30 }, CancellationToken.None); + Assert.Equal([10, 20, 30], await dataReceived.Task.WaitAsync(timeout)); + + await client.DisposeAsync(); + var removedId = await removed.Task.WaitAsync(timeout); + + Assert.Equal(session.SessionId, removedId); } [Fact] - public async Task DisconnectAsync_KnownSession_ClosesConnection() + public async Task SendAsync_KnownSession_SendsToConnection() { using var server = NewServer(); using var manager = NewManager(server); - var connection = new FakeNetworkConnection(sessionId: 6); + var connection = new FakeNetworkConnection(4); manager.HandleConnected(connection); - await manager.DisconnectAsync(6, CancellationToken.None); + await manager.SendAsync(4, new byte[] { 7, 8 }, CancellationToken.None); - Assert.Equal(1, connection.CloseCount); + Assert.Single(connection.SentPayloads); + Assert.Equal([7, 8], connection.SentPayloads[0]); } [Fact] - public async Task DisconnectAsync_UnknownSession_IsNoOp() + public async Task SendAsync_UnknownSession_IsNoOp() { using var server = NewServer(); using var manager = NewManager(server); - await manager.DisconnectAsync(999, CancellationToken.None); + await manager.SendAsync(999, new byte[] { 1 }, CancellationToken.None); + // No exception = pass. } [Fact] - public async Task BroadcastAsync_SendsToAll_IsolatesFailures() + public void Sessions_ReturnsSnapshotOfAll() { using var server = NewServer(); using var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection()); + manager.HandleConnected(new FakeNetworkConnection(2)); - var good = new FakeNetworkConnection(sessionId: 1); - var faulty = new FakeNetworkConnection(sessionId: 2) - { - SendCallback = _ => throw new InvalidOperationException("boom") - }; - manager.HandleConnected(good); - manager.HandleConnected(faulty); - - await manager.BroadcastAsync(new byte[] { 0xAB }, CancellationToken.None); - - Assert.Single(good.SentPayloads); - Assert.Equal([0xAB], good.SentPayloads[0]); + Assert.Equal(2, manager.Sessions.Count); } [Fact] - public void Dispose_ClearsSessionsAndIsIdempotent() + public void TryGetSession_HitAndMiss() { using var server = NewServer(); - var manager = NewManager(server); - manager.HandleConnected(new FakeNetworkConnection(sessionId: 1)); - - manager.Dispose(); - manager.Dispose(); // idempotent, no throw - - Assert.Equal(0, manager.Count); - } - - [Fact] - public async Task Integration_LifecycleOverLoopback() - { - var timeout = TimeSpan.FromSeconds(5); - - await using var server = new SquidTcpServer(new IPEndPoint(IPAddress.Loopback, 0)); using var manager = NewManager(server); + manager.HandleConnected(new FakeNetworkConnection(3)); - var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - var dataReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var removed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); - manager.OnSessionData += (_, e) => dataReceived.TrySetResult(e.Data.ToArray()); - manager.OnSessionRemoved += (_, e) => removed.TrySetResult(e.Session.SessionId); - - await server.StartAsync(CancellationToken.None); - var port = server.Port; - - var client = await SquidStdTcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port)); - - var session = await created.Task.WaitAsync(timeout); - Assert.Equal(1, manager.Count); - - await client.SendAsync(new byte[] { 10, 20, 30 }, CancellationToken.None); - Assert.Equal([10, 20, 30], await dataReceived.Task.WaitAsync(timeout)); + Assert.True(manager.TryGetSession(3, out var session)); + Assert.Equal(3, session!.SessionId); + Assert.False(manager.TryGetSession(999, out var missing)); + Assert.Null(missing); + } - await client.DisposeAsync(); - var removedId = await removed.Task.WaitAsync(timeout); + private static SessionManager NewManager(SquidTcpServer server) + => new(server, connection => $"state-{connection.SessionId}"); - Assert.Equal(session.SessionId, removedId); - } + private static SquidTcpServer NewServer() + => new(new(IPAddress.Loopback, 0)); } diff --git a/tests/SquidStd.Tests/Network/SessionTests.cs b/tests/SquidStd.Tests/Network/SessionTests.cs index c79d55ba..5fc4b245 100644 --- a/tests/SquidStd.Tests/Network/SessionTests.cs +++ b/tests/SquidStd.Tests/Network/SessionTests.cs @@ -1,4 +1,5 @@ using System.Net; +using SquidStd.Network.Data.Events; using SquidStd.Network.Sessions; using SquidStd.Tests.Support; @@ -8,10 +9,22 @@ public class SessionTests { private static readonly DateTimeOffset CreatedAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + [Fact] + public async Task CloseAsync_DelegatesToConnection() + { + var connection = new FakeNetworkConnection(); + var session = new Session(1, connection, "s", CreatedAt); + + await session.CloseAsync(); + + Assert.Equal(1, connection.CloseCount); + Assert.False(session.IsConnected); + } + [Fact] public void Constructor_PopulatesProperties() { - var connection = new FakeNetworkConnection(sessionId: 42, remoteEndPoint: new IPEndPoint(IPAddress.Loopback, 7000)); + var connection = new FakeNetworkConnection(42, new IPEndPoint(IPAddress.Loopback, 7000)); var session = new Session(42, connection, "state", CreatedAt); Assert.Equal(42, session.SessionId); @@ -35,33 +48,21 @@ public async Task SendAsync_DelegatesToConnection() } [Fact] - public async Task CloseAsync_DelegatesToConnection() - { - var connection = new FakeNetworkConnection(); - var session = new Session(1, connection, "s", CreatedAt); - - await session.CloseAsync(); - - Assert.Equal(1, connection.CloseCount); - Assert.False(session.IsConnected); - } - - [Fact] - public void SessionEventArgs_CarriesSession() + public void SessionDataEventArgs_CarriesSessionAndData() { var session = new Session(1, new FakeNetworkConnection(), "s", CreatedAt); - var args = new SquidStd.Network.Data.Events.SquidStdSessionEventArgs(session); + var args = new SquidStdSessionDataEventArgs(session, new byte[] { 9 }); Assert.Same(session, args.Session); + Assert.Equal([9], args.Data.ToArray()); } [Fact] - public void SessionDataEventArgs_CarriesSessionAndData() + public void SessionEventArgs_CarriesSession() { var session = new Session(1, new FakeNetworkConnection(), "s", CreatedAt); - var args = new SquidStd.Network.Data.Events.SquidStdSessionDataEventArgs(session, new byte[] { 9 }); + var args = new SquidStdSessionEventArgs(session); Assert.Same(session, args.Session); - Assert.Equal([9], args.Data.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/SpanReaderTests.cs b/tests/SquidStd.Tests/Network/SpanReaderTests.cs index e633c180..0c420d51 100644 --- a/tests/SquidStd.Tests/Network/SpanReaderTests.cs +++ b/tests/SquidStd.Tests/Network/SpanReaderTests.cs @@ -5,39 +5,20 @@ namespace SquidStd.Tests.Network; public class SpanReaderTests { [Fact] - public void ReadInt32_ReadsBigEndian() - { - var reader = new SpanReader([0x01, 0x02, 0x03, 0x04]); - - Assert.Equal(0x01020304, reader.ReadInt32()); - Assert.Equal(0, reader.Remaining); - } - - [Fact] - public void ReadInt32LE_ReadsLittleEndian() - { - var reader = new SpanReader([0x04, 0x03, 0x02, 0x01]); - - Assert.Equal(0x01020304, reader.ReadInt32LE()); - } - - [Fact] - public void ReadByte_AdvancesPosition() + public void ReadAscii_FixedLength_ReadsExactBytes() { - var reader = new SpanReader([0xAA, 0xBB]); + var reader = new SpanReader([(byte)'h', (byte)'i', (byte)'!']); - Assert.Equal(0xAA, reader.ReadByte()); - Assert.Equal(1, reader.Position); - Assert.Equal(0xBB, reader.ReadByte()); + Assert.Equal("hi", reader.ReadAscii(2)); + Assert.Equal(1, reader.Remaining); } [Fact] - public void ReadBytes_ReturnsRequestedSlice() + public void ReadAscii_NullTerminated_StopsAtTerminator() { - var reader = new SpanReader([1, 2, 3, 4, 5]); + var reader = new SpanReader([(byte)'h', (byte)'i', 0]); - Assert.Equal([1, 2, 3], reader.ReadBytes(3)); - Assert.Equal(2, reader.Remaining); + Assert.Equal("hi", reader.ReadAscii()); } [Fact] @@ -50,30 +31,13 @@ public void ReadBoolean_NonZeroIsTrue() } [Fact] - public void Seek_FromBegin_SetsPosition() - { - var reader = new SpanReader([1, 2, 3, 4]); - reader.Seek(2, SeekOrigin.Begin); - - Assert.Equal(2, reader.Position); - Assert.Equal(3, reader.ReadByte()); - } - - [Fact] - public void ReadAscii_FixedLength_ReadsExactBytes() - { - var reader = new SpanReader([(byte)'h', (byte)'i', (byte)'!']); - - Assert.Equal("hi", reader.ReadAscii(2)); - Assert.Equal(1, reader.Remaining); - } - - [Fact] - public void ReadAscii_NullTerminated_StopsAtTerminator() + public void ReadByte_AdvancesPosition() { - var reader = new SpanReader([(byte)'h', (byte)'i', 0]); + var reader = new SpanReader([0xAA, 0xBB]); - Assert.Equal("hi", reader.ReadAscii()); + Assert.Equal(0xAA, reader.ReadByte()); + Assert.Equal(1, reader.Position); + Assert.Equal(0xBB, reader.ReadByte()); } [Fact] @@ -96,6 +60,15 @@ public void ReadByte_BeyondEnd_Throws() Assert.True(threw); } + [Fact] + public void ReadBytes_ReturnsRequestedSlice() + { + var reader = new SpanReader([1, 2, 3, 4, 5]); + + Assert.Equal([1, 2, 3], reader.ReadBytes(3)); + Assert.Equal(2, reader.Remaining); + } + [Fact] public void ReadInt32_InsufficientData_Throws() { @@ -115,6 +88,23 @@ public void ReadInt32_InsufficientData_Throws() Assert.True(threw); } + [Fact] + public void ReadInt32_ReadsBigEndian() + { + var reader = new SpanReader([0x01, 0x02, 0x03, 0x04]); + + Assert.Equal(0x01020304, reader.ReadInt32()); + Assert.Equal(0, reader.Remaining); + } + + [Fact] + public void ReadInt32LE_ReadsLittleEndian() + { + var reader = new SpanReader([0x04, 0x03, 0x02, 0x01]); + + Assert.Equal(0x01020304, reader.ReadInt32LE()); + } + [Fact] public void RoundTrip_WithSpanWriter_PreservesValues() { @@ -131,4 +121,14 @@ public void RoundTrip_WithSpanWriter_PreservesValues() Assert.True(reader.ReadBoolean()); Assert.Equal("squid", reader.ReadAscii()); } + + [Fact] + public void Seek_FromBegin_SetsPosition() + { + var reader = new SpanReader([1, 2, 3, 4]); + reader.Seek(2, SeekOrigin.Begin); + + Assert.Equal(2, reader.Position); + Assert.Equal(3, reader.ReadByte()); + } } diff --git a/tests/SquidStd.Tests/Network/SpanWriterTests.cs b/tests/SquidStd.Tests/Network/SpanWriterTests.cs index 75c94117..26359d6e 100644 --- a/tests/SquidStd.Tests/Network/SpanWriterTests.cs +++ b/tests/SquidStd.Tests/Network/SpanWriterTests.cs @@ -5,21 +5,50 @@ namespace SquidStd.Tests.Network; public class SpanWriterTests { [Fact] - public void Write_Int32_WritesBigEndian() + public void NoResize_Overflow_Throws() { - using var writer = new SpanWriter(8); + var buffer = new byte[2]; + var writer = new SpanWriter(buffer); + + var threw = false; + + try + { + writer.Write(0x01020304); + } + catch (InvalidOperationException) + { + threw = true; + } + + Assert.True(threw); + } + + [Fact] + public void Resize_GrowsBeyondInitialCapacity() + { + using var writer = new SpanWriter(2, true); writer.Write(0x01020304); + writer.Write(0x05060708); - Assert.Equal([0x01, 0x02, 0x03, 0x04], writer.ToArray()); + Assert.Equal(8, writer.BytesWritten); } [Fact] - public void WriteLE_Int32_WritesLittleEndian() + public void ToArray_EmptyWriter_ReturnsEmpty() { using var writer = new SpanWriter(8); - writer.WriteLE(0x01020304); - Assert.Equal([0x04, 0x03, 0x02, 0x01], writer.ToArray()); + Assert.Empty(writer.ToArray()); + } + + [Fact] + public void Write_Int32_WritesBigEndian() + { + using var writer = new SpanWriter(8); + writer.Write(0x01020304); + + Assert.Equal([0x01, 0x02, 0x03, 0x04], writer.ToArray()); } [Fact] @@ -43,40 +72,11 @@ public void WriteAsciiNull_AppendsNullTerminator() } [Fact] - public void Resize_GrowsBeyondInitialCapacity() - { - using var writer = new SpanWriter(2, resize: true); - writer.Write(0x01020304); - writer.Write(0x05060708); - - Assert.Equal(8, writer.BytesWritten); - } - - [Fact] - public void NoResize_Overflow_Throws() - { - var buffer = new byte[2]; - var writer = new SpanWriter(buffer); - - var threw = false; - - try - { - writer.Write(0x01020304); - } - catch (InvalidOperationException) - { - threw = true; - } - - Assert.True(threw); - } - - [Fact] - public void ToArray_EmptyWriter_ReturnsEmpty() + public void WriteLE_Int32_WritesLittleEndian() { using var writer = new SpanWriter(8); + writer.WriteLE(0x01020304); - Assert.Empty(writer.ToArray()); + Assert.Equal([0x04, 0x03, 0x02, 0x01], writer.ToArray()); } } diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs index 6bc1e83c..e58b6dbf 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpClientTests.cs @@ -7,11 +7,26 @@ public class SquidStdUdpClientTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + [Fact] + public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() + { + var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var disconnects = 0; + client.OnDisconnected += (_, _) => Interlocked.Increment(ref disconnects); + await client.StartAsync(CancellationToken.None); + + await client.CloseAsync(CancellationToken.None); + await client.CloseAsync(CancellationToken.None); + + Assert.False(client.IsConnected); + Assert.Equal(1, disconnects); + } + [Fact] public void Constructor_AssignsUniqueSessionIds() { - using var first = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - using var second = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var first = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + using var second = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); Assert.NotEqual(first.SessionId, second.SessionId); } @@ -19,7 +34,7 @@ public void Constructor_AssignsUniqueSessionIds() [Fact] public void Constructor_BindsLocalEndPoint() { - using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var local = Assert.IsType(client.LocalEndPoint); Assert.NotEqual(0, local.Port); @@ -31,15 +46,36 @@ public void RemoteEndPoint_ReflectsConfiguredDefault() { var remote = new IPEndPoint(IPAddress.Loopback, 9999); - using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0), remote); + using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0), remote); Assert.Equal(remote, client.RemoteEndPoint); } + [Fact] + public async Task SendAsync_UsesConfiguredDefaultRemote() + { + await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await receiver.StartAsync(CancellationToken.None); + + var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; + + await using var sender = new SquidStdUdpClient( + new(IPAddress.Loopback, 0), + new(IPAddress.Loopback, receiverPort) + ); + await sender.StartAsync(CancellationToken.None); + await sender.SendAsync(new byte[] { 9, 8, 7 }, CancellationToken.None); + + var payload = await received.Task.WaitAsync(Timeout); + Assert.Equal([9, 8, 7], payload); + } + [Fact] public async Task SendAsync_WithoutDefaultRemote_Throws() { - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); await Assert.ThrowsAsync( async () => await client.SendAsync(new byte[] { 1 }, CancellationToken.None) @@ -49,18 +85,18 @@ await Assert.ThrowsAsync( [Fact] public async Task SendToAsync_DeliversDatagramToPeer() { - await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var receiver = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await receiver.StartAsync(CancellationToken.None); var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; - await using var sender = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var sender = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); await sender.StartAsync(CancellationToken.None); await sender.SendToAsync( new byte[] { 1, 2, 3, 4 }, - new IPEndPoint(IPAddress.Loopback, receiverPort), + new(IPAddress.Loopback, receiverPort), CancellationToken.None ); @@ -68,46 +104,10 @@ await sender.SendToAsync( Assert.Equal([1, 2, 3, 4], payload); } - [Fact] - public async Task SendAsync_UsesConfiguredDefaultRemote() - { - await using var receiver = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - receiver.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); - await receiver.StartAsync(CancellationToken.None); - - var receiverPort = ((IPEndPoint)receiver.LocalEndPoint!).Port; - - await using var sender = new SquidStdUdpClient( - new IPEndPoint(IPAddress.Loopback, 0), - new IPEndPoint(IPAddress.Loopback, receiverPort) - ); - await sender.StartAsync(CancellationToken.None); - await sender.SendAsync(new byte[] { 9, 8, 7 }, CancellationToken.None); - - var payload = await received.Task.WaitAsync(Timeout); - Assert.Equal([9, 8, 7], payload); - } - - [Fact] - public async Task CloseAsync_RaisesDisconnectedOnceAndMarksDisconnected() - { - var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var disconnects = 0; - client.OnDisconnected += (_, _) => Interlocked.Increment(ref disconnects); - await client.StartAsync(CancellationToken.None); - - await client.CloseAsync(CancellationToken.None); - await client.CloseAsync(CancellationToken.None); - - Assert.False(client.IsConnected); - Assert.Equal(1, disconnects); - } - [Fact] public async Task StartAsync_RaisesConnected() { - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); var connected = false; client.OnConnected += (_, _) => connected = true; diff --git a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs index cd3b6579..90737b6a 100644 --- a/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs +++ b/tests/SquidStd.Tests/Network/SquidStdUdpServerTests.cs @@ -9,10 +9,57 @@ public class SquidStdUdpServerTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + [Fact] + public async Task BindSingleInterface_HasOneListener() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await server.StartAsync(CancellationToken.None); + + Assert.Equal(1, server.ListenerCount); + } + + [Fact] + public async Task DefaultBehaviour_EchoesDatagramBackToSender() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await client.StartAsync(CancellationToken.None); + + var payload = new byte[] { 1, 2, 3, 4, 5 }; + await client.SendToAsync(payload, new(IPAddress.Loopback, serverPort), CancellationToken.None); + + Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); + } + + [Fact] + public async Task OnDatagram_CustomResponse_IsReturnedToSender() + { + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) + { + OnDatagram = (_, _) => new byte[] { 0xFF, 0xFE } + }; + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await client.StartAsync(CancellationToken.None); + + await client.SendToAsync(new byte[] { 1 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + + Assert.Equal([0xFF, 0xFE], await received.Task.WaitAsync(Timeout)); + } + [Fact] public void ServerType_IsUdp() { - using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); Assert.Equal(ServerType.UDP, server.ServerType); } @@ -20,7 +67,7 @@ public void ServerType_IsUdp() [Fact] public async Task StartStop_TogglesIsRunning() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); Assert.False(server.IsRunning); await server.StartAsync(CancellationToken.None); @@ -35,49 +82,44 @@ public async Task StartStop_TogglesIsRunning() } [Fact] - public async Task DefaultBehaviour_EchoesDatagramBackToSender() + public async Task OnDatagramReceived_RaisedForIncomingDatagram() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.OnDatagramReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); await client.StartAsync(CancellationToken.None); + await client.SendToAsync(new byte[] { 1, 2, 3 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); - var payload = new byte[] { 1, 2, 3, 4, 5 }; - await client.SendToAsync(payload, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); - - Assert.Equal(payload, await received.Task.WaitAsync(Timeout)); + Assert.Equal([1, 2, 3], await received.Task.WaitAsync(Timeout)); } [Fact] - public async Task OnDatagram_CustomResponse_IsReturnedToSender() + public async Task SendToAsync_DeliversToEndpointThatWasSeen() { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false) + await using var server = new SquidStdUdpServer(new(IPAddress.Loopback, 0), false) { - OnDatagram = (_, _) => new byte[] { 0xFF, 0xFE } + OnDatagram = static (_, _) => ReadOnlyMemory.Empty // suppress default echo for this test }; + var senderEndpoint = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.OnDatagramReceived += (_, e) => senderEndpoint.TrySetResult(e.RemoteEndPoint); await server.StartAsync(CancellationToken.None); var serverPort = server.Port; - await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); - var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - client.OnDataReceived += (_, e) => received.TrySetResult(e.Data.ToArray()); + await using var client = new SquidStdUdpClient(new(IPAddress.Loopback, 0)); + var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); await client.StartAsync(CancellationToken.None); - await client.SendToAsync(new byte[] { 1 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); - - Assert.Equal([0xFF, 0xFE], await received.Task.WaitAsync(Timeout)); - } + // Make the server "see" the client endpoint first. + await client.SendToAsync(new byte[] { 0 }, new(IPAddress.Loopback, serverPort), CancellationToken.None); + var clientEndpoint = await senderEndpoint.Task.WaitAsync(Timeout); - [Fact] - public async Task BindSingleInterface_HasOneListener() - { - await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); - await server.StartAsync(CancellationToken.None); + await server.SendToAsync(clientEndpoint, new byte[] { 9, 9 }, CancellationToken.None); - Assert.Equal(1, server.ListenerCount); + Assert.Equal([9, 9], await clientReceived.Task.WaitAsync(Timeout)); } } diff --git a/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs new file mode 100644 index 00000000..f0d2d9e2 --- /dev/null +++ b/tests/SquidStd.Tests/Network/UdpSessionManagerTests.cs @@ -0,0 +1,178 @@ +using System.Net; +using SquidStd.Network.Client; +using SquidStd.Network.Server; +using SquidStd.Network.Sessions; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Network; + +public class UdpSessionManagerTests +{ + private static readonly DateTimeOffset Start = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static SquidStdUdpServer NewServer() + => new(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + + private static UdpSessionManager NewManager(SquidStdUdpServer server, FakeTimeProvider time) + => new( + server, + connection => $"state-{connection.SessionId}", + idleTimeout: TimeSpan.FromSeconds(30), + sweepInterval: TimeSpan.FromSeconds(10), + timeProvider: time); + + private static IPEndPoint Peer(int port) + => new(IPAddress.Loopback, port); + + [Fact] + public void FirstDatagram_CreatesSessionAndRaisesCreatedThenData() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + Session? created = null; + var dataCount = 0; + manager.OnSessionCreated += (_, e) => created = e.Session; + manager.OnSessionData += (_, _) => dataCount++; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + Assert.Equal(1, manager.Count); + Assert.NotNull(created); + Assert.Equal("state-1", created!.State); + Assert.Equal(1, dataCount); + Assert.True(manager.TryGetSession(Peer(5000), out var byEp)); + Assert.True(manager.TryGetSession(created.SessionId, out var byId)); + Assert.Same(byEp, byId); + } + + [Fact] + public void SecondDatagram_SameEndpoint_RaisesDataOnly() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + var createdCount = 0; + var dataCount = 0; + manager.OnSessionCreated += (_, _) => createdCount++; + manager.OnSessionData += (_, _) => dataCount++; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + manager.HandleDatagram(Peer(5000), new byte[] { 2 }); + + Assert.Equal(1, createdCount); + Assert.Equal(2, dataCount); + Assert.Equal(1, manager.Count); + } + + [Fact] + public void SweepExpiredSessions_RemovesIdleSessions() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + Session? removed = null; + manager.OnSessionRemoved += (_, e) => removed = e.Session; + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + time.Advance(TimeSpan.FromSeconds(31)); + manager.SweepExpiredSessions(); + + Assert.Equal(0, manager.Count); + Assert.NotNull(removed); + Assert.False(manager.TryGetSession(Peer(5000), out _)); + } + + [Fact] + public void SweepExpiredSessions_KeepsRecentlyActiveSessions() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + time.Advance(TimeSpan.FromSeconds(20)); + manager.HandleDatagram(Peer(5000), new byte[] { 2 }); // refresh activity + time.Advance(TimeSpan.FromSeconds(20)); // 20s since last activity < 30s + manager.SweepExpiredSessions(); + + Assert.Equal(1, manager.Count); + } + + [Fact] + public async Task DisconnectAsync_ByEndpoint_RemovesSessionOnce() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + var removals = 0; + manager.OnSessionRemoved += (_, _) => removals++; + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); + await manager.DisconnectAsync(Peer(5000), CancellationToken.None); // idempotent + + Assert.Equal(0, manager.Count); + Assert.Equal(1, removals); + } + + [Fact] + public async Task DisconnectAsync_UnknownId_IsNoOp() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + using var manager = NewManager(server, time); + + await manager.DisconnectAsync(999, CancellationToken.None); + // No exception = pass. + } + + [Fact] + public void Dispose_ClearsSessionsAndIsIdempotent() + { + using var server = NewServer(); + var time = new FakeTimeProvider(Start); + var manager = NewManager(server, time); + manager.HandleDatagram(Peer(5000), new byte[] { 1 }); + + manager.Dispose(); + manager.Dispose(); + + Assert.Equal(0, manager.Count); + } + + [Fact] + public async Task Integration_DatagramCreatesSessionAndManagerCanReply() + { + var timeout = TimeSpan.FromSeconds(5); + + await using var server = new SquidStdUdpServer(new IPEndPoint(IPAddress.Loopback, 0), bindAllInterfaces: false); + using var manager = new UdpSessionManager(server, c => $"state-{c.SessionId}"); + + var created = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var data = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + manager.OnSessionCreated += (_, e) => created.TrySetResult(e.Session); + manager.OnSessionData += (_, e) => data.TrySetResult(e.Data.ToArray()); + + await server.StartAsync(CancellationToken.None); + var serverPort = server.Port; + + await using var client = new SquidStdUdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var clientReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnDataReceived += (_, e) => clientReceived.TrySetResult(e.Data.ToArray()); + await client.StartAsync(CancellationToken.None); + + await client.SendToAsync(new byte[] { 1, 2, 3 }, new IPEndPoint(IPAddress.Loopback, serverPort), CancellationToken.None); + + var session = await created.Task.WaitAsync(timeout); + Assert.Equal([1, 2, 3], await data.Task.WaitAsync(timeout)); + Assert.Equal(1, manager.Count); + + await session.SendAsync(new byte[] { 4, 5 }, CancellationToken.None); + Assert.Equal([4, 5], await clientReceived.Task.WaitAsync(timeout)); + } +} diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs index f5260f33..6b49f609 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginContextTests.cs @@ -9,13 +9,8 @@ public void Data_IsEmptyByDefault() => Assert.Empty(new PluginContext().Data); [Fact] - public void GetData_ValueType_ReturnsStoredValue() - { - var context = new PluginContext(); - context.Data["count"] = 42; - - Assert.Equal(42, context.GetData("count")); - } + public void GetData_MissingKey_Throws() + => Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); [Fact] public void GetData_ReferenceType_ReturnsStoredValue() @@ -28,8 +23,13 @@ public void GetData_ReferenceType_ReturnsStoredValue() } [Fact] - public void GetData_MissingKey_Throws() - => Assert.Throws(() => { _ = new PluginContext().GetData("missing"); }); + public void GetData_ValueType_ReturnsStoredValue() + { + var context = new PluginContext(); + context.Data["count"] = 42; + + Assert.Equal(42, context.GetData("count")); + } [Fact] public void GetData_WrongType_Throws() diff --git a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs index b21c3062..bbaa345e 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/PluginMetadataTests.cs @@ -11,28 +11,29 @@ public void Constructor_SetsRequiredProperties() { Id = "squidstd.weather", Name = "Weather", - Version = new Version(2, 1, 0), + Version = new(2, 1, 0), Author = "squid" }; Assert.Equal("squidstd.weather", metadata.Id); Assert.Equal("Weather", metadata.Name); - Assert.Equal(new Version(2, 1, 0), metadata.Version); + Assert.Equal(new(2, 1, 0), metadata.Version); Assert.Equal("squid", metadata.Author); } [Fact] - public void Description_DefaultsToNull() + public void Dependencies_CanBeProvided() { var metadata = new PluginMetadata { Id = "id", Name = "name", - Version = new Version(1, 0), - Author = "author" + Version = new(1, 0), + Author = "author", + Dependencies = ["squidstd.core", "squidstd.net"] }; - Assert.Null(metadata.Description); + Assert.Equal(["squidstd.core", "squidstd.net"], metadata.Dependencies); } [Fact] @@ -42,7 +43,7 @@ public void Dependencies_DefaultsToEmpty() { Id = "id", Name = "name", - Version = new Version(1, 0), + Version = new(1, 0), Author = "author" }; @@ -50,17 +51,16 @@ public void Dependencies_DefaultsToEmpty() } [Fact] - public void Dependencies_CanBeProvided() + public void Description_DefaultsToNull() { var metadata = new PluginMetadata { Id = "id", Name = "name", - Version = new Version(1, 0), - Author = "author", - Dependencies = ["squidstd.core", "squidstd.net"] + Version = new(1, 0), + Author = "author" }; - Assert.Equal(["squidstd.core", "squidstd.net"], metadata.Dependencies); + Assert.Null(metadata.Description); } } diff --git a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs index 88257ea4..2fc16ed9 100644 --- a/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs +++ b/tests/SquidStd.Tests/PluginAbstractions/SquidStdPluginTests.cs @@ -8,34 +8,34 @@ namespace SquidStd.Tests.PluginAbstractions; public class SquidStdPluginTests { [Fact] - public void Metadata_ExposesPluginIdentity() + public void Configure_ReceivesProvidedContext() { - ISquidStdPlugin plugin = new FakePlugin(); + using var container = new Container(); + var plugin = new FakePlugin(); + var context = new PluginContext(); - Assert.Equal("squidstd.fake", plugin.Metadata.Id); - Assert.Equal(new Version(1, 2, 3), plugin.Metadata.Version); + ((ISquidStdPlugin)plugin).Configure(container, context); + + Assert.Same(context, plugin.ReceivedContext); } [Fact] public void Configure_RegistersServicesIntoContainer() { - using var container = new DryIoc.Container(); + using var container = new Container(); var plugin = new FakePlugin(); - ((ISquidStdPlugin)plugin).Configure(container, new PluginContext()); + ((ISquidStdPlugin)plugin).Configure(container, new()); Assert.Same(plugin, container.Resolve()); } [Fact] - public void Configure_ReceivesProvidedContext() + public void Metadata_ExposesPluginIdentity() { - using var container = new DryIoc.Container(); - var plugin = new FakePlugin(); - var context = new PluginContext(); - - ((ISquidStdPlugin)plugin).Configure(container, context); + ISquidStdPlugin plugin = new FakePlugin(); - Assert.Same(context, plugin.ReceivedContext); + Assert.Equal("squidstd.fake", plugin.Metadata.Id); + Assert.Equal(new(1, 2, 3), plugin.Metadata.Version); } } diff --git a/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs new file mode 100644 index 00000000..00ee9e12 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/AddScriptModuleExtensionTests.cs @@ -0,0 +1,44 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Container; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class AddScriptModuleExtensionTests +{ + [Fact] + public void RegisterLuaUserData_AddsUserDataMetadata() + { + using var container = new Container(); + + container.RegisterLuaUserData(); + + var registration = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(TestUserData), registration.UserType); + } + + [Fact] + public void RegisterScriptModule_AddsMetadataAndRegistersSingleton() + { + using var container = new Container(); + + container.RegisterScriptModule(); + + var registration = Assert.Single(container.Resolve>()); + Assert.Equal(typeof(TestModule), registration.ModuleType); + Assert.Same(container.Resolve(), container.Resolve()); + } + + [Fact] + public void RegisterLuaUserData_NullTypeThrows() + { + using var container = new Container(); + + Assert.Throws(() => container.RegisterLuaUserData(null!)); + } + + public sealed class TestUserData; + + public sealed class TestModule; +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs new file mode 100644 index 00000000..a66070dd --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBridgeTests.cs @@ -0,0 +1,67 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Services; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaEventBridgeTests +{ + [Fact] + public void Invoke_WithoutAttachThrows() + { + var script = new Script(); + var callback = script.DoString("return function(payload) return payload.name end").Function; + var bridge = new LuaEventBridge(); + + Assert.Throws(() => bridge.Invoke(callback, new Dictionary())); + } + + [Fact] + public void Invoke_ConvertsNestedPayloadToLuaTable() + { + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var callback = script.DoString( + """ + return function(payload) + return payload.actor.name .. ':' .. payload.values[2] + end + """ + ).Function; + + var result = bridge.Invoke( + callback, + new Dictionary + { + ["actor"] = new Dictionary { ["name"] = "squid" }, + ["values"] = new List { 3, 7 } + } + ); + + Assert.Equal("squid:7", result.String); + } + + [Fact] + public void Publish_InvokesRegisteredCallbacksCaseInsensitively() + { + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var callback = script.DoString( + """ + calls = 0 + captured = nil + return function(payload) + calls = calls + 1 + captured = payload.name + end + """ + ).Function; + + bridge.Register("Spawned", callback); + bridge.Publish("spawned", new Dictionary { ["name"] = "slime" }); + + Assert.Equal(1, (int)script.Globals.Get("calls").Number); + Assert.Equal("slime", script.Globals.Get("captured").String); + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs new file mode 100644 index 00000000..6eb5b921 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs @@ -0,0 +1,90 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Modules; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaModuleTests +{ + [Fact] + public void RandomModule_ChanceHandlesBoundaries() + { + var module = new RandomModule(); + + Assert.False(module.Chance(0)); + Assert.False(module.Chance(-1)); + Assert.True(module.Chance(100)); + Assert.True(module.Chance(101)); + } + + [Fact] + public void RandomModule_IntRejectsInvalidRange() + { + var module = new RandomModule(); + + Assert.Throws(() => module.Int(5, 4)); + } + + [Fact] + public void RandomModule_PickRejectsEmptyTable() + { + var module = new RandomModule(); + + Assert.Throws(() => module.Pick(new Table(new Script()))); + } + + [Fact] + public void RandomModule_WeightedRejectsEntriesWithoutPositiveWeight() + { + var script = new Script(); + var entries = script.DoString( + """ + return { + { value = 'a', weight = 0 }, + { value = 'b', weight = -3 } + } + """ + ).Table; + var module = new RandomModule(); + + Assert.Throws(() => module.Weighted(entries)); + } + + [Fact] + public void EventsModule_RegistersCallbackWithBridge() + { + var script = new Script(); + var bridge = new CapturingLuaEventBridge(); + var callback = script.DoString("return function() end").Function; + var module = new EventsModule(bridge); + + module.On("spawned", callback); + + Assert.Equal("spawned", bridge.EventName); + Assert.Same(callback, bridge.Callback); + } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Closure? Callback { get; private set; } + + public string? EventName { get; private set; } + + public void Attach(Script script) + { + } + + public DynValue Invoke(Closure callback, IReadOnlyDictionary payload) + => DynValue.Nil; + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + EventName = eventName; + Callback = callback; + } + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs new file mode 100644 index 00000000..4aa71e7c --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptEngineServiceTests.cs @@ -0,0 +1,231 @@ +using MoonSharp.Interpreter; +using DryIoc; +using SquidStd.Core.Directories; +using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Data.Internal; +using SquidStd.Scripting.Lua.Data.Scripts; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Services; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaScriptEngineServiceTests +{ + [Fact] + public void AddCallback_AndExecuteCallback_NormalizeNameAndPassArguments() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + object[]? captured = null; + + engine.AddCallback("onSpawn", args => captured = args); + engine.ExecuteCallback("on_spawn", "squid", 7); + + Assert.NotNull(captured); + Assert.Equal(["squid", 7], captured); + } + + [Fact] + public void AddConstant_MakesSimpleAndObjectValuesAvailableToLua() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.AddConstant("engineName", "SquidStd"); + engine.AddConstant("limits", new LimitConfig("jobs", 4)); + + Assert.Equal("SquidStd", engine.ExecuteFunction("ENGINE_NAME").Data); + Assert.Equal("jobs:4", engine.ExecuteFunction("LIMITS.Name .. ':' .. LIMITS.Count").Data); + } + + [Fact] + public void AddManualModuleFunction_RegistersActionAndTypedFunction() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + object[]? captured = null; + + engine.AddManualModuleFunction("testModule", "captureValue", args => captured = args); + engine.AddManualModuleFunction("testModule", "doubleValue", value => value * 2); + + engine.ExecuteScript("test_module.capture_value('abc', 12)"); + var result = engine.ExecuteFunction("test_module.double_value(6)"); + + Assert.NotNull(captured); + Assert.Equal("abc", captured[0]); + Assert.Equal(12d, captured[1]); + Assert.Equal(12d, result.Data); + } + + [Fact] + public void ExecuteFunction_ReturnsSuccessForValidExpression() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + var result = engine.ExecuteFunction("2 + 3"); + + Assert.True(result.Success); + Assert.Equal(5d, result.Data); + } + + [Fact] + public void ExecuteFunction_RaisesScriptErrorForInvalidExpression() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + ScriptErrorInfo? captured = null; + engine.OnScriptError += (_, error) => captured = error; + + var result = engine.ExecuteFunction("missing.value"); + + Assert.False(result.Success); + Assert.NotNull(captured); + Assert.Contains("nil", captured.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ExecuteScript_TracksCacheHitsAndMisses() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.ExecuteScript("cached_value = 1"); + engine.ExecuteScript("cached_value = 1"); + + var metrics = engine.GetExecutionMetrics(); + Assert.Equal(1, metrics.CacheMisses); + Assert.Equal(1, metrics.CacheHits); + Assert.Equal(1, metrics.TotalScriptsCached); + } + + [Fact] + public void RegisterGlobalAndUnregisterGlobal_UpdateLuaGlobals() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine(temp, container); + + engine.RegisterGlobal("answer", 42); + + Assert.Equal(42d, engine.ExecuteFunction("answer").Data); + Assert.True(engine.UnregisterGlobal("answer")); + Assert.False(engine.UnregisterGlobal("answer")); + } + + [Fact] + public void AddSearchDirectory_AllowsRequireFromAdditionalDirectory() + { + using var temp = new TempDirectory(); + using var extra = new TempDirectory(); + using var container = new Container(); + File.WriteAllText(extra.Combine("feature.lua"), "return { value = 42 }"); + using var engine = CreateEngine(temp, container); + + engine.AddSearchDirectory(extra.Path); + + Assert.Equal(42d, engine.ExecuteFunction("require('feature').value").Data); + } + + [Fact] + public async Task StartAsync_AttachesEventBridgeAndAddsBootstrapConstants() + { + using var temp = new TempDirectory(); + using var container = new Container(); + var bridge = new CapturingLuaEventBridge(); + container.RegisterInstance(bridge); + using var engine = CreateEngine(temp, container); + object? hookArgument = null; + engine.AfterModulesRegistered += value => hookArgument = value; + + await engine.StartAsync(CancellationToken.None); + await engine.StartAsync(CancellationToken.None); + + var stats = engine.GetStats(); + Assert.True(stats.IsInitialized); + Assert.Same(engine.LuaScript, bridge.AttachedScript); + Assert.Same(engine.LuaScript, hookArgument); + Assert.Equal("1.0.0", engine.ExecuteFunction("VERSION").Data); + Assert.Equal("SquidStd", engine.ExecuteFunction("ENGINE").Data); + } + + [Fact] + public void RegisteredUserData_CanBeConstructedFromLuaWithMoreThanFourArguments() + { + using var temp = new TempDirectory(); + using var container = new Container(); + using var engine = CreateEngine( + temp, + container, + loadedUserData: + [ + new() { UserType = typeof(FiveArgumentUserData) } + ] + ); + + var result = engine.LuaScript.DoString( + """ + local value = FiveArgumentUserData(1, 2, 3, 4, 5) + return value.Total + """ + ); + + Assert.Equal(15, (int)result.Number); + } + + private static LuaScriptEngineService CreateEngine( + TempDirectory temp, + IContainer container, + List? scriptModules = null, + List? loadedUserData = null + ) + { + var scriptsDirectory = temp.Combine("scripts"); + var luarcDirectory = temp.Combine("luarc"); + Directory.CreateDirectory(scriptsDirectory); + + return new( + new DirectoriesConfig(temp.Path, []), + container, + new LuaEngineConfig(luarcDirectory, scriptsDirectory, "SquidStd", "1.0.0"), + scriptModules, + loadedUserData + ); + } + + private sealed record LimitConfig(string Name, int Count); + + public sealed class FiveArgumentUserData(int first, int second, int third, int fourth, int fifth) + { + public int Total { get; } = first + second + third + fourth + fifth; + } + + private sealed class CapturingLuaEventBridge : ILuaEventBridge + { + public Script? AttachedScript { get; private set; } + + public void Attach(Script script) + => AttachedScript = script; + + public DynValue Invoke( + Closure callback, + IReadOnlyDictionary payload + ) + => DynValue.Nil; + + public void Publish(string eventName, IReadOnlyDictionary payload) + { + } + + public void Register(string eventName, Closure callback) + { + } + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs new file mode 100644 index 00000000..ed3856c2 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaScriptLoaderTests.cs @@ -0,0 +1,55 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Loaders; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaScriptLoaderTests +{ + [Fact] + public void ScriptFileExists_FindsModulePathPatterns() + { + using var temp = new TempDirectory(); + Directory.CreateDirectory(temp.Combine("modules")); + Directory.CreateDirectory(temp.Combine(Path.Combine("modules", "inventory"))); + File.WriteAllText(temp.Combine("plain.lua"), "return 1"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "combat.lua")), "return 2"); + File.WriteAllText(temp.Combine(Path.Combine("modules", "inventory", "init.lua")), "return 3"); + var loader = new LuaScriptLoader(temp.Path); + + Assert.True(loader.ScriptFileExists("plain")); + Assert.True(loader.ScriptFileExists("combat")); + Assert.True(loader.ScriptFileExists("inventory")); + Assert.False(loader.ScriptFileExists("missing")); + } + + [Fact] + public void LoadFile_LoadsContentFromFirstMatchingSearchDirectory() + { + using var first = new TempDirectory(); + using var second = new TempDirectory(); + File.WriteAllText(second.Combine("feature.lua"), "return 'loaded'"); + var loader = new LuaScriptLoader([first.Path, second.Path]); + + var content = loader.LoadFile("feature.lua", new Table(new Script())); + + Assert.Equal("return 'loaded'", content); + } + + [Fact] + public void AddSearchDirectory_AddsAdditionalLookupRoot() + { + using var first = new TempDirectory(); + using var second = new TempDirectory(); + File.WriteAllText(second.Combine("extra.lua"), "return 'extra'"); + var loader = new LuaScriptLoader(first.Path); + + loader.AddSearchDirectory(second.Path); + + Assert.True(loader.ScriptFileExists("extra")); + } + + [Fact] + public void Constructor_EmptySearchDirectoriesThrows() + => Assert.Throws(() => new LuaScriptLoader(Array.Empty())); +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs new file mode 100644 index 00000000..cfd6e8cc --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaTableReaderTests.cs @@ -0,0 +1,47 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Extensions; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaTableReaderTests +{ + [Fact] + public void Readers_ReturnTypedValues() + { + var script = new Script(); + var table = new Table(script); + table["name"] = "squid"; + table["count"] = 3; + table["ratio"] = 1.5; + table["enabled"] = true; + table["mode"] = "Second"; + + Assert.Equal("squid", LuaTableReader.GetString(table, "name")); + Assert.Equal(3, LuaTableReader.GetInt(table, "count")); + Assert.Equal(1.5f, LuaTableReader.GetFloat(table, "ratio")); + Assert.True(LuaTableReader.GetBool(table, "enabled")); + Assert.Equal(TestMode.Second, LuaTableReader.GetEnum(table, "mode", TestMode.First)); + } + + [Fact] + public void Readers_ReturnDefaultsForMissingOrWrongTypes() + { + var script = new Script(); + var table = new Table(script); + table["name"] = 10; + table["count"] = "wrong"; + table["mode"] = "Unknown"; + + Assert.Equal("default", LuaTableReader.GetString(table, "name", "default")); + Assert.Equal(7, LuaTableReader.GetInt(table, "count", 7)); + Assert.Equal(2.5f, LuaTableReader.GetFloat(table, "missingFloat", 2.5f)); + Assert.True(LuaTableReader.GetBool(table, "missingBool", true)); + Assert.Equal(TestMode.First, LuaTableReader.GetEnum(table, "mode", TestMode.First)); + } + + private enum TestMode + { + First = 1, + Second = 2 + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs new file mode 100644 index 00000000..06ffbd07 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/ScriptResultBuilderTests.cs @@ -0,0 +1,31 @@ +using SquidStd.Scripting.Lua.Data.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class ScriptResultBuilderTests +{ + [Fact] + public void CreateSuccess_BuildsSuccessfulResult() + { + var result = ScriptResultBuilder.CreateSuccess() + .WithMessage("ok") + .WithData(42) + .Build(); + + Assert.True(result.Success); + Assert.Equal("ok", result.Message); + Assert.Equal(42, result.Data); + } + + [Fact] + public void CreateError_BuildsFailedResult() + { + var result = ScriptResultBuilder.CreateError() + .WithMessage("failed") + .Build(); + + Assert.False(result.Success); + Assert.Equal("failed", result.Message); + Assert.Null(result.Data); + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs new file mode 100644 index 00000000..dd20bcbe --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/TableExtensionsTests.cs @@ -0,0 +1,39 @@ +using MoonSharp.Interpreter; +using SquidStd.Scripting.Lua.Extensions.Scripts; + +namespace SquidStd.Tests.Scripting.Lua; + +public class TableExtensionsTests +{ + [Fact] + public void ToProxy_DelegatesInterfaceCallsToLuaFunctions() + { + var script = new Script(); + var table = script.DoString( + """ + return { + Sum = function(left, right) + return left + right + end + } + """ + ).Table; + + var proxy = table.ToProxy(); + + Assert.Equal(7, proxy.Sum(3, 4)); + } + + [Fact] + public void ToProxy_MissingFunctionThrowsMissingMethodException() + { + var proxy = new Table(new Script()).ToProxy(); + + Assert.Throws(() => proxy.Sum(1, 2)); + } + + public interface ICalculator + { + int Sum(int left, int right); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs new file mode 100644 index 00000000..d15f07ba --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceEnvTests.cs @@ -0,0 +1,37 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Services.Core; + +public class ConfigManagerServiceEnvTests +{ + [Fact] + public void Load_SubstitutesEnvTokensInStringProperties() + { + var dir = Path.Combine(Path.GetTempPath(), "squidstd-cfg-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable("SQUID_DB_PASS", "p@ss"); + try + { + File.WriteAllText( + Path.Combine(dir, "app.yaml"), + "database:\n ConnectionString: postgres://u:$SQUID_DB_PASS@h:5432/db\n AutoMigrate: true\n"); + + var container = new Container(); + container.RegisterConfigSection("database"); + var service = new ConfigManagerService(container, "app", dir); + + service.Load(); + + var config = container.Resolve(); + Assert.Equal("postgres://u:p@ss@h:5432/db", config.ConnectionString); + } + finally + { + Environment.SetEnvironmentVariable("SQUID_DB_PASS", null); + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs index 5962b734..db670d1a 100644 --- a/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/ConfigManagerServiceTests.cs @@ -9,22 +9,25 @@ namespace SquidStd.Tests.Services.Core; public class ConfigManagerServiceTests { [Fact] - public async Task StartAsync_MissingFile_CreatesDefaultFileAndRegistersSection() + public void ConfigPath_AppendsYamlExtension() + { + using var container = new Container(); + IConfigManagerService manager = new ConfigManagerService(container, "app", "/tmp/config"); + + Assert.Equal(Path.Combine("/tmp/config", "app.yaml"), manager.ConfigPath); + } + + [Fact] + public async Task GetConfig_ReturnsRegisteredSectionAfterStart() { using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test"); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); - var path = Path.Combine(temp.Path, "app.yaml"); - var config = container.Resolve(); - - Assert.True(File.Exists(path)); - Assert.Equal("default", config.Name); - Assert.Equal(3, config.Count); - Assert.Contains("test:", File.ReadAllText(path)); + Assert.Same(container.Resolve(), manager.GetConfig()); } [Fact] @@ -39,8 +42,8 @@ public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() Count: 9 """ ); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); @@ -50,6 +53,25 @@ public async Task StartAsync_ExistingFile_LoadsAndRegistersSection() Assert.Equal(9, config.Count); } + [Fact] + public async Task StartAsync_MissingFile_CreatesDefaultFileAndRegistersSection() + { + using var temp = new TempDirectory(); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); + + await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); + + var path = Path.Combine(temp.Path, "app.yaml"); + var config = container.Resolve(); + + Assert.True(File.Exists(path)); + Assert.Equal("default", config.Name); + Assert.Equal(3, config.Count); + Assert.Contains("test:", File.ReadAllText(path)); + } + [Fact] public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() { @@ -61,8 +83,8 @@ public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() Enabled: true """ ); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); + using var container = new Container(); + container.RegisterConfigSection("test", static () => new TestConfig { Name = "default", Count = 3 }); IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); @@ -76,26 +98,4 @@ public async Task StartAsync_MissingSection_UsesDefaultAndSavesIt() Assert.Contains("test:", yaml); Assert.DoesNotContain("other:", yaml); } - - [Fact] - public async Task GetConfig_ReturnsRegisteredSectionAfterStart() - { - using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - container.RegisterConfigSection("test"); - IConfigManagerService manager = new ConfigManagerService(container, "app", temp.Path); - - await ((ConfigManagerService)manager).StartAsync(CancellationToken.None); - - Assert.Same(container.Resolve(), manager.GetConfig()); - } - - [Fact] - public void ConfigPath_AppendsYamlExtension() - { - using var container = new DryIoc.Container(); - IConfigManagerService manager = new ConfigManagerService(container, "app", "/tmp/config"); - - Assert.Equal(Path.Combine("/tmp/config", "app.yaml"), manager.ConfigPath); - } } diff --git a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs index 372bf475..af266259 100644 --- a/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/EventBusServiceTests.cs @@ -5,6 +5,78 @@ namespace SquidStd.Tests.Services.Core; public class EventBusServiceTests { + private sealed record TestEvent(string Payload) : IEvent; + + private sealed class SyncListener : ISyncEventListener + { + private readonly List _calls; + private readonly string _name; + + public TestEvent? LastEvent { get; private set; } + + public SyncListener(string name, List calls) + { + _name = name; + _calls = calls; + } + + public void Handle(TestEvent eventData) + { + LastEvent = eventData; + _calls.Add($"{_name}:{eventData.Payload}"); + } + } + + private sealed class ThrowingSyncListener : ISyncEventListener + { + private readonly InvalidOperationException _exception; + + public ThrowingSyncListener(InvalidOperationException exception) + { + _exception = exception; + } + + public void Handle(TestEvent eventData) + => throw _exception; + } + + private sealed class AsyncListener : IAsyncEventListener + { + private readonly List _calls; + private readonly string _name; + + public CancellationToken CancellationToken { get; private set; } + public TestEvent? LastEvent { get; private set; } + + public AsyncListener(string name, List calls) + { + _name = name; + _calls = calls; + } + + public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) + { + await Task.Yield(); + + CancellationToken = cancellationToken; + LastEvent = eventData; + _calls.Add($"{_name}:{eventData.Payload}"); + } + } + + private sealed class ThrowingAsyncListener : IAsyncEventListener + { + private readonly InvalidOperationException _exception; + + public ThrowingAsyncListener(InvalidOperationException exception) + { + _exception = exception; + } + + public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) + => throw _exception; + } + [Fact] public void Publish_NoSyncListeners_DoesNotThrow() { @@ -54,11 +126,26 @@ public async Task PublishAsync_NoAsyncListeners_Completes() using var eventBus = new EventBusService(); IEventBus bus = eventBus; - var exception = await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); + var exception = + await Record.ExceptionAsync(() => bus.PublishAsync(new TestEvent("ignored"), CancellationToken.None)); Assert.Null(exception); } + [Fact] + public async Task PublishAsync_PassesCancellationToken() + { + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + using var cancellationTokenSource = new CancellationTokenSource(); + var listener = new AsyncListener("listener", []); + bus.RegisterAsyncListener(listener); + + await bus.PublishAsync(new TestEvent("payload"), cancellationTokenSource.Token); + + Assert.Equal(cancellationTokenSource.Token, listener.CancellationToken); + } + [Fact] public async Task PublishAsync_RegisteredAsyncListeners_InvokesEachInRegistrationOrder() { @@ -78,20 +165,6 @@ public async Task PublishAsync_RegisteredAsyncListeners_InvokesEachInRegistratio Assert.Same(eventData, second.LastEvent); } - [Fact] - public async Task PublishAsync_PassesCancellationToken() - { - using var eventBus = new EventBusService(); - IEventBus bus = eventBus; - using var cancellationTokenSource = new CancellationTokenSource(); - var listener = new AsyncListener("listener", []); - bus.RegisterAsyncListener(listener); - - await bus.PublishAsync(new TestEvent("payload"), cancellationTokenSource.Token); - - Assert.Equal(cancellationTokenSource.Token, listener.CancellationToken); - } - [Fact] public async Task PublishAsync_WhenAsyncListenerThrows_PropagatesException() { @@ -101,81 +174,9 @@ public async Task PublishAsync_WhenAsyncListenerThrows_PropagatesException() bus.RegisterAsyncListener(new ThrowingAsyncListener(expected)); var actual = await Assert.ThrowsAsync( - () => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None) - ); + () => bus.PublishAsync(new TestEvent("payload"), CancellationToken.None) + ); Assert.Same(expected, actual); } - - private sealed record TestEvent(string Payload) : IEvent; - - private sealed class SyncListener : ISyncEventListener - { - private readonly List _calls; - private readonly string _name; - - public TestEvent? LastEvent { get; private set; } - - public SyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } - - public void Handle(TestEvent eventData) - { - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } - } - - private sealed class ThrowingSyncListener : ISyncEventListener - { - private readonly InvalidOperationException _exception; - - public ThrowingSyncListener(InvalidOperationException exception) - { - _exception = exception; - } - - public void Handle(TestEvent eventData) - => throw _exception; - } - - private sealed class AsyncListener : IAsyncEventListener - { - private readonly List _calls; - private readonly string _name; - - public CancellationToken CancellationToken { get; private set; } - public TestEvent? LastEvent { get; private set; } - - public AsyncListener(string name, List calls) - { - _name = name; - _calls = calls; - } - - public async Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - { - await Task.Yield(); - - CancellationToken = cancellationToken; - LastEvent = eventData; - _calls.Add($"{_name}:{eventData.Payload}"); - } - } - - private sealed class ThrowingAsyncListener : IAsyncEventListener - { - private readonly InvalidOperationException _exception; - - public ThrowingAsyncListener(InvalidOperationException exception) - { - _exception = exception; - } - - public Task HandleAsync(TestEvent eventData, CancellationToken cancellationToken) - => throw _exception; - } } diff --git a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs index 33dc878a..dd796748 100644 --- a/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/JobSystemServiceTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Data.Jobs; using SquidStd.Core.Interfaces.Jobs; using SquidStd.Services.Core.Services; @@ -7,35 +6,35 @@ namespace SquidStd.Tests.Services.Core; public class JobSystemServiceTests { [Fact] - public async Task Schedule_Action_RunsAndCompletes() + public async Task ScheduleAsync_Action_RunsAndCompletes() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; var called = false; await jobs.StartAsync(CancellationToken.None); - await system.Schedule(() => called = true); + await system.ScheduleAsync(() => called = true); Assert.True(called); Assert.Equal(1, system.CompletedCount); } [Fact] - public async Task Schedule_Func_ReturnsValue() + public async Task ScheduleAsync_Func_ReturnsValue() { - using var jobs = NewService(workerCount: 2); + using var jobs = NewService(2); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); - var result = await system.Schedule(() => 42); + var result = await system.ScheduleAsync(() => 42); Assert.Equal(42, result); } [Fact] - public async Task Schedule_ManyJobs_AllComplete() + public async Task ScheduleAsync_ManyJobs_AllComplete() { - using var jobs = NewService(workerCount: 4); + using var jobs = NewService(4); IJobSystem system = jobs; var sum = 0; var sync = new Lock(); @@ -46,7 +45,7 @@ public async Task Schedule_ManyJobs_AllComplete() { var value = i; tasks.Add( - system.Schedule( + system.ScheduleAsync( () => { lock (sync) @@ -65,43 +64,43 @@ public async Task Schedule_ManyJobs_AllComplete() } [Fact] - public async Task Schedule_ThrowingAction_PropagatesExceptionToAwaiter() + public async Task ScheduleAsync_ThrowingAction_PropagatesExceptionToAwaiter() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; await jobs.StartAsync(CancellationToken.None); await Assert.ThrowsAsync( - () => system.Schedule(() => throw new InvalidOperationException("boom")) + () => system.ScheduleAsync(() => throw new InvalidOperationException("boom")) ); } [Fact] - public async Task Schedule_TokenAlreadyCancelled_ReturnsCanceledTask() + public async Task ScheduleAsync_TokenAlreadyCancelled_ReturnsCanceledTask() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; using var cancellationTokenSource = new CancellationTokenSource(); await jobs.StartAsync(CancellationToken.None); await cancellationTokenSource.CancelAsync(); - var task = system.Schedule(() => { }, cancellationTokenSource.Token); + var task = system.ScheduleAsync(() => { }, cancellationTokenSource.Token); await Assert.ThrowsAsync(() => task); Assert.Equal(TaskStatus.Canceled, task.Status); } [Fact] - public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() + public async Task ScheduleAsync_TokenCancelledBeforePickup_TransitionsToCanceled() { - using var jobs = NewService(workerCount: 1); + using var jobs = NewService(1); IJobSystem system = jobs; using var gate = new ManualResetEventSlim(false); using var firstStarted = new ManualResetEventSlim(false); using var cancellationTokenSource = new CancellationTokenSource(); await jobs.StartAsync(CancellationToken.None); - _ = system.Schedule( + _ = system.ScheduleAsync( () => { firstStarted.Set(); @@ -110,7 +109,7 @@ public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() ); Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(2)), "first job did not start"); - var task = system.Schedule(() => Assert.Fail("queued job should not run"), cancellationTokenSource.Token); + var task = system.ScheduleAsync(() => Assert.Fail("queued job should not run"), cancellationTokenSource.Token); await cancellationTokenSource.CancelAsync(); gate.Set(); @@ -121,13 +120,13 @@ public async Task Schedule_TokenCancelledBeforePickup_TransitionsToCanceled() [Fact] public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() { - var jobs = NewService(workerCount: 1); + var jobs = NewService(1); IJobSystem system = jobs; using var gate = new ManualResetEventSlim(false); using var firstStarted = new ManualResetEventSlim(false); await jobs.StartAsync(CancellationToken.None); - _ = system.Schedule( + _ = system.ScheduleAsync( () => { firstStarted.Set(); @@ -135,7 +134,7 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() } ); Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(2)), "first job did not start"); - var queued = system.Schedule(() => Assert.Fail("queued job should not run")); + var queued = system.ScheduleAsync(() => Assert.Fail("queued job should not run")); await jobs.StopAsync(CancellationToken.None); gate.Set(); @@ -144,31 +143,31 @@ public async Task StopAsync_CancelsQueuedJobsAndPreventsNewSchedules() Assert.Throws( () => { - _ = system.Schedule(() => { }); + _ = system.ScheduleAsync(() => { }); } ); jobs.Dispose(); } [Fact] - public void WorkerCount_UsesExplicitValue() + public void WorkerCount_AutoDetectsAtLeastOne() { - using var jobs = NewService(workerCount: 3); + using var jobs = NewService(0); - Assert.Equal(3, jobs.WorkerCount); + Assert.True(jobs.WorkerCount >= 1); } [Fact] - public void WorkerCount_AutoDetectsAtLeastOne() + public void WorkerCount_UsesExplicitValue() { - using var jobs = NewService(workerCount: 0); + using var jobs = NewService(3); - Assert.True(jobs.WorkerCount >= 1); + Assert.Equal(3, jobs.WorkerCount); } private static JobSystemService NewService(int workerCount) => new( - new JobsConfig + new() { WorkerThreadCount = workerCount, ShutdownTimeoutSeconds = 1.0 diff --git a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs index a0446b27..fb13ca65 100644 --- a/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/MainThreadDispatcherServiceTests.cs @@ -8,32 +8,6 @@ namespace SquidStd.Tests.Services.Core; public class MainThreadDispatcherServiceTests { - [Fact] - public void DrainPending_EmptyQueue_ReturnsZero() - { - IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); - - var executed = dispatcher.DrainPending(); - - Assert.Equal(0, executed); - Assert.Equal(0, dispatcher.PendingCount); - } - - [Fact] - public void DrainPending_NoBudget_DrainsAll() - { - IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); - var calls = new List(); - dispatcher.Post(() => calls.Add(1)); - dispatcher.Post(() => calls.Add(2)); - - var executed = dispatcher.DrainPending(); - - Assert.Equal(2, executed); - Assert.Equal([1, 2], calls); - Assert.Equal(0, dispatcher.PendingCount); - } - [Fact] public void DrainPending_BudgetExceededAfterFirstCallback_DefersRest() { @@ -91,6 +65,32 @@ public void DrainPending_CallbackThrows_ContinuesWithNext() Assert.Equal(1, calls); } + [Fact] + public void DrainPending_EmptyQueue_ReturnsZero() + { + IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); + + var executed = dispatcher.DrainPending(); + + Assert.Equal(0, executed); + Assert.Equal(0, dispatcher.PendingCount); + } + + [Fact] + public void DrainPending_NoBudget_DrainsAll() + { + IMainThreadDispatcher dispatcher = new MainThreadDispatcherService(); + var calls = new List(); + dispatcher.Post(() => calls.Add(1)); + dispatcher.Post(() => calls.Add(2)); + + var executed = dispatcher.DrainPending(); + + Assert.Equal(2, executed); + Assert.Equal([1, 2], calls); + Assert.Equal(0, dispatcher.PendingCount); + } + [Fact] public void Post_NullAction_ThrowsArgumentNullException() { @@ -99,6 +99,16 @@ public void Post_NullAction_ThrowsArgumentNullException() Assert.Throws(() => dispatcher.Post(null!)); } + [Fact] + public void RegisterCoreServices_RegistersMainThreadDispatcher() + { + using var container = new Container(); + + container.RegisterCoreServices(); + + Assert.IsType(container.Resolve()); + } + [Fact] public void SynchronizationContext_Post_EnqueuesIntoDispatcher() { @@ -125,14 +135,4 @@ public void SynchronizationContext_Send_InvokesDelegateSynchronously() Assert.Equal("sent", stateValue); Assert.Equal(0, dispatcher.PendingCount); } - - [Fact] - public void RegisterCoreServices_RegistersMainThreadDispatcher() - { - using var container = new Container(); - - container.RegisterCoreServices(); - - Assert.IsType(container.Resolve()); - } } diff --git a/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs new file mode 100644 index 00000000..a097c643 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/MetricsCollectionServiceTests.cs @@ -0,0 +1,238 @@ +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Services.Core; + +public class MetricsCollectionServiceTests +{ + [Fact] + public async Task StartAsync_CollectsMetricsFromRegisteredProviders() + { + using var service = new MetricsCollectionService( + [new CountingMetricProvider("jobs", "pending.total", 7)], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("jobs.pending.total")); + + var metrics = service.GetAllMetrics(); + var sample = metrics["jobs.pending.total"]; + + Assert.Equal("pending.total", sample.Name); + Assert.Equal(7, sample.Value); + Assert.NotNull(sample.Timestamp); + } + + [Fact] + public async Task StartAsync_WhenProviderThrows_ContinuesCollectingOtherProviders() + { + using var service = new MetricsCollectionService( + [ + new ThrowingMetricProvider("broken"), + new CountingMetricProvider("timer", "callbacks.total", 3) + ], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => service.GetAllMetrics().ContainsKey("timer.callbacks.total")); + + var metrics = service.GetAllMetrics(); + + Assert.Contains("timer.callbacks.total", metrics.Keys); + Assert.DoesNotContain("broken", metrics.Keys); + } + + [Fact] + public async Task StopAsync_StopsCollectionLoop() + { + var provider = new CountingMetricProvider("bus", "dispatch.total", 1); + using var service = new MetricsCollectionService( + [provider], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => provider.CollectionCount > 0); + await service.StopAsync(CancellationToken.None); + var countAfterStop = provider.CollectionCount; + + await Task.Delay(50); + + Assert.Equal(countAfterStop, provider.CollectionCount); + } + + [Fact] + public async Task StartAsync_WhenDisabled_DoesNotCollectMetrics() + { + var provider = new CountingMetricProvider("jobs", "pending.total", 7); + using var service = new MetricsCollectionService( + [provider], + new() + { + Enabled = false, + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + + await service.StartAsync(CancellationToken.None); + await Task.Delay(30); + + Assert.Empty(service.GetAllMetrics()); + Assert.Equal(0, provider.CollectionCount); + } + + [Fact] + public void GetSnapshot_WhenNotStarted_ReturnsEmptySnapshot() + { + using var service = new MetricsCollectionService([], new()); + + var snapshot = service.GetSnapshot(); + + Assert.Equal(DateTimeOffset.MinValue, snapshot.CollectedAt); + Assert.Empty(snapshot.Metrics); + } + + [Fact] + public async Task GetStatus_ReturnsLatestMetricsSnapshot() + { + using var service = new MetricsCollectionService( + [new CountingMetricProvider("jobs", "completed.total", 11)], + new() + { + IntervalMilliseconds = 10, + LogEnabled = false + } + ); + IMetricsCollectionService metrics = service; + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => metrics.GetStatus().Metrics.ContainsKey("jobs.completed.total")); + + var status = metrics.GetStatus(); + + Assert.NotEqual(DateTimeOffset.MinValue, status.CollectedAt); + Assert.Equal(11, status.Metrics["jobs.completed.total"].Value); + } + + [Fact] + public async Task StartAsync_PublishesMetricsCollectedEventThroughEventBus() + { + using var eventBus = new EventBusService(); + IEventBus bus = eventBus; + var syncListener = new MetricsCollectedSyncListener(); + var asyncListener = new MetricsCollectedAsyncListener(); + bus.RegisterListener(syncListener); + bus.RegisterAsyncListener(asyncListener); + using var service = new MetricsCollectionService( + [new CountingMetricProvider("events", "published.total", 5)], + new() + { + IntervalMilliseconds = 1000, + LogEnabled = false + }, + bus + ); + + await service.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => syncListener.LastEvent is not null && asyncListener.LastEvent is not null); + await service.StopAsync(CancellationToken.None); + + Assert.NotNull(syncListener.LastEvent); + Assert.NotNull(asyncListener.LastEvent); + Assert.Same(syncListener.LastEvent, asyncListener.LastEvent); + Assert.Same(service.GetStatus(), syncListener.LastEvent.Snapshot); + Assert.Equal(5, syncListener.LastEvent.Snapshot.Metrics["events.published.total"].Value); + } + + private static async Task WaitUntilAsync(Func predicate) + { + var deadline = DateTime.UtcNow.AddSeconds(2); + + while (DateTime.UtcNow < deadline) + { + if (predicate()) + { + return; + } + + await Task.Delay(10); + } + + Assert.Fail("Condition was not met before timeout."); + } + + private sealed class CountingMetricProvider : IMetricProvider + { + private readonly string _metricName; + private readonly double _value; + private int _collectionCount; + + public CountingMetricProvider(string providerName, string metricName, double value) + { + ProviderName = providerName; + _metricName = metricName; + _value = value; + } + + public int CollectionCount => Volatile.Read(ref _collectionCount); + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _collectionCount); + + return ValueTask.FromResult>([new(_metricName, _value)]); + } + } + + private sealed class ThrowingMetricProvider : IMetricProvider + { + public ThrowingMetricProvider(string providerName) + { + ProviderName = providerName; + } + + public string ProviderName { get; } + + public ValueTask> CollectAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Synthetic test failure."); + } + + private sealed class MetricsCollectedSyncListener : ISyncEventListener + { + public MetricsCollectedEvent? LastEvent { get; private set; } + + public void Handle(MetricsCollectedEvent eventData) + => LastEvent = eventData; + } + + private sealed class MetricsCollectedAsyncListener : IAsyncEventListener + { + public MetricsCollectedEvent? LastEvent { get; private set; } + + public Task HandleAsync(MetricsCollectedEvent eventData, CancellationToken cancellationToken) + { + LastEvent = eventData; + + return Task.CompletedTask; + } + } +} diff --git a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs index d2f5cc7d..3f281b57 100644 --- a/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs +++ b/tests/SquidStd.Tests/Services/Core/RegisterDefaultServicesExtensionsTests.cs @@ -1,37 +1,29 @@ using DryIoc; using SquidStd.Abstractions.Data.Internal.Config; using SquidStd.Abstractions.Data.Internal.Services; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Metrics; +using SquidStd.Core.Data.Storage; using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Interfaces.Metrics; +using SquidStd.Core.Interfaces.Secrets; +using SquidStd.Core.Interfaces.Storage; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Storage; using SquidStd.Tests.Support; namespace SquidStd.Tests.Services.Core; public class RegisterDefaultServicesExtensionsTests { - [Fact] - public void RegisterConfigManagerService_RegistersSingletonInstance() - { - using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); - - container.RegisterConfigManagerService("app", temp.Path); - - var first = container.Resolve(); - var second = container.Resolve(); - - Assert.Same(first, second); - Assert.Equal(Path.Combine(temp.Path, "app.yaml"), first.ConfigPath); - } - [Fact] public void RegisterConfigManagerService_AddsServiceRegistrationDataWithEarliestPriority() { using var temp = new TempDirectory(); - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterConfigManagerService("app", temp.Path); @@ -45,18 +37,18 @@ public void RegisterConfigManagerService_AddsServiceRegistrationDataWithEarliest } [Fact] - public void RegisterDefaultCoreConfigSections_RegistersJobsAndTimerWheelMetadata() + public void RegisterConfigManagerService_RegistersSingletonInstance() { - using var container = new DryIoc.Container(); + using var temp = new TempDirectory(); + using var container = new Container(); - container.RegisterDefaultCoreConfigSections(); + container.RegisterConfigManagerService("app", temp.Path); - var entries = container.Resolve>(); + var first = container.Resolve(); + var second = container.Resolve(); - Assert.Contains(entries, entry => entry.SectionName == "jobs" && entry.ConfigType == typeof(JobsConfig)); - Assert.Contains(entries, entry => entry.SectionName == "timerWheel" && entry.ConfigType == typeof(TimerWheelConfig)); - Assert.False(container.IsRegistered()); - Assert.False(container.IsRegistered()); + Assert.Same(first, second); + Assert.Equal(Path.Combine(temp.Path, "app.yaml"), first.ConfigPath); } [Fact] @@ -74,7 +66,7 @@ public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigC WheelSize: 32 """ ); - using var container = new DryIoc.Container(); + using var container = new Container(); container.RegisterCoreServices("app", temp.Path); var manager = container.Resolve(); @@ -85,4 +77,131 @@ public async Task RegisterCoreServices_StartsConfigManagerBeforeResolvingConfigC Assert.Equal(TimeSpan.FromMilliseconds(10), container.Resolve().TickDuration); Assert.Equal(32, container.Resolve().WheelSize); } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersJobsAndTimerWheelMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "jobs" && entry.ConfigType == typeof(JobsConfig)); + Assert.Contains(entries, entry => entry.SectionName == "timerWheel" && entry.ConfigType == typeof(TimerWheelConfig)); + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); + } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersLoggerMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains( + entries, + entry => entry.SectionName == "logger" && entry.ConfigType == typeof(SquidStdLoggerOptions) + ); + Assert.False(container.IsRegistered()); + } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersMetricsMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "metrics" && entry.ConfigType == typeof(MetricsConfig)); + Assert.False(container.IsRegistered()); + } + + [Fact] + public void RegisterDefaultCoreConfigSections_RegistersStorageAndSecretsMetadata() + { + using var container = new Container(); + + container.RegisterDefaultCoreConfigSections(); + + var entries = container.Resolve>(); + + Assert.Contains(entries, entry => entry.SectionName == "storage" && entry.ConfigType == typeof(StorageConfig)); + Assert.Contains(entries, entry => entry.SectionName == "secrets" && entry.ConfigType == typeof(SecretsConfig)); + Assert.False(container.IsRegistered()); + Assert.False(container.IsRegistered()); + } + + [Fact] + public void RegisterMetricsCollectionService_AddsLateServiceRegistrationData() + { + using var container = new Container(); + + container.RegisterMetricsCollectionService(); + + var entry = Assert.Single( + container.Resolve>(), + registration => registration.ServiceType == typeof(IMetricsCollectionService) + ); + + Assert.Equal(typeof(MetricsCollectionService), entry.ImplementationType); + Assert.Equal(1000, entry.Priority); + } + + [Fact] + public void RegisterCoreServices_RegistersMetricsCollectionService() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + } + + [Fact] + public void RegisterCoreServices_RegistersStorageAndSecretServices() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterCoreServices("app", temp.Path); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + } + + [Fact] + public async Task RegisterStorageServices_RegistersFileAndYamlStorage() + { + using var temp = new TempDirectory(); + using var container = new Container(); + + container.RegisterStorageServices(); + container.RegisterConfigManagerService("app", temp.Path); + await ((ConfigManagerService)container.Resolve()).StartAsync(CancellationToken.None); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.IsType(container.Resolve()); + Assert.IsType(container.Resolve()); + } + + [Fact] + public void RegisterSecretServices_RegistersSecretProtectorAndStore() + { + using var container = new Container(); + + container.RegisterSecretServices(); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + } } diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs new file mode 100644 index 00000000..0295e007 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerRegistrationTests.cs @@ -0,0 +1,30 @@ +using DryIoc; +using SquidStd.Core.Data.Jobs; +using SquidStd.Core.Data.Timing; +using SquidStd.Core.Interfaces.Jobs; +using SquidStd.Core.Interfaces.Scheduling; +using SquidStd.Core.Interfaces.Timing; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services; +using SquidStd.Services.Core.Services.Scheduling; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class CronSchedulerRegistrationTests +{ + [Fact] + public void RegisterSchedulerServices_ResolvesSchedulerAndPump() + { + var container = new Container(); + container.RegisterInstance(new TimerWheelConfig()); + container.RegisterInstance(new TimerWheelPumpConfig()); + container.RegisterInstance(new JobsConfig { WorkerThreadCount = 1 }); + container.Register(Reuse.Singleton); + container.Register(Reuse.Singleton); + + container.RegisterSchedulerServices(); + + Assert.NotNull(container.Resolve()); + Assert.NotNull(container.Resolve()); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs new file mode 100644 index 00000000..b16a19c1 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/CronSchedulerServiceTests.cs @@ -0,0 +1,145 @@ +using Cronos; +using SquidStd.Services.Core.Services.Scheduling; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class CronSchedulerServiceTests +{ + [Fact] + public void Fire_RunsHandler_AndReschedules() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + Assert.Equal(1, timer.Count); // one-shot registered + + timer.FireDue(); // fires -> job queued + rescheduled + Assert.Equal(1, jobs.RunAll()); + Assert.Equal(1, count); + Assert.Equal(1, timer.Count); // rescheduled + + timer.FireDue(); + jobs.RunAll(); + Assert.Equal(2, count); + } + + [Fact] + public void Jobs_ExposesRegisteredJob() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + + var id = scheduler.Schedule("snap", "* * * * *", _ => Task.CompletedTask); + + var info = Assert.Single(scheduler.Jobs); + Assert.Equal(id, info.JobId); + Assert.Equal("snap", info.Name); + Assert.Equal("* * * * *", info.CronExpression); + Assert.NotNull(info.NextOccurrenceUtc); + Assert.False(info.IsRunning); + Assert.Equal(0, info.RunCount); + } + + [Fact] + public void Schedule_InvalidCron_Throws() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + + Assert.Throws(() => { scheduler.Schedule("bad", "not a cron", _ => Task.CompletedTask); }); + } + + [Fact] + public void Fire_WhileRunning_SkipsOverlappingOccurrence() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + timer.FireDue(); // running flag set, job queued (not yet run) + timer.FireDue(); // still running -> occurrence skipped, no second job queued + + Assert.Equal(1, jobs.PendingCount); + jobs.RunAll(); + Assert.Equal(1, count); + } + + [Fact] + public void Unschedule_StopsFutureFirings() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + var id = scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + Assert.True(scheduler.Unschedule(id)); + Assert.Equal(0, timer.Count); + Assert.Equal(0, timer.FireDue()); + jobs.RunAll(); + Assert.Equal(0, count); + Assert.Empty(scheduler.Jobs); + } + + [Fact] + public void UnscheduleByName_RemovesAllMatching() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + scheduler.Schedule("dup", "* * * * *", _ => Task.CompletedTask); + scheduler.Schedule("dup", "* * * * *", _ => Task.CompletedTask); + scheduler.Schedule("other", "* * * * *", _ => Task.CompletedTask); + + Assert.Equal(2, scheduler.UnscheduleByName("dup")); + Assert.Single(scheduler.Jobs); + } + + [Fact] + public void Fire_HandlerThrows_IsLogged_AndKeepsRescheduling() + { + var timer = new FakeTimerService(); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + scheduler.Schedule("boom", "* * * * *", _ => throw new InvalidOperationException("boom")); + + timer.FireDue(); + jobs.RunAll(); // handler throws, swallowed + Assert.Equal(1, timer.Count); // still rescheduled + + timer.FireDue(); + jobs.RunAll(); + Assert.Equal(1, timer.Count); // still alive + Assert.Equal(0, Assert.Single(scheduler.Jobs).RunCount); // failures do not count as runs + } + + [Fact] + public void RealTimerWheel_FiresJob_WhenAdvancedPastAMinute() + { + var timer = new SquidStd.Services.Core.Services.TimerWheelService( + new SquidStd.Core.Data.Timing.TimerWheelConfig + { + TickDuration = TimeSpan.FromMilliseconds(8), + WheelSize = 512 + } + ); + var jobs = new ManualJobSystem(); + using var scheduler = new CronSchedulerService(timer, jobs); + var count = 0; + scheduler.Schedule("tick", "* * * * *", _ => { count++; return Task.CompletedTask; }); + + timer.UpdateTicksDelta(0); // baseline + timer.UpdateTicksDelta(61_000); // advance just over one minute + + Assert.True(jobs.RunAll() >= 1); + Assert.True(count >= 1); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs new file mode 100644 index 00000000..c5eb6cba --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Scheduling/TimerWheelPumpServiceTests.cs @@ -0,0 +1,32 @@ +using SquidStd.Core.Data.Timing; +using SquidStd.Services.Core.Services.Scheduling; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Scheduling; + +public class TimerWheelPumpServiceTests +{ + [Fact] + public void Ctor_NonPositiveInterval_Throws() + { + Assert.Throws( + () => new TimerWheelPumpService(new FakeTimerService(), new TimerWheelPumpConfig { PumpInterval = TimeSpan.Zero }) + ); + } + + [Fact] + public async Task Pump_AdvancesTheWheel() + { + var timer = new FakeTimerService(); + var pump = new TimerWheelPumpService(timer, new TimerWheelPumpConfig { PumpInterval = TimeSpan.FromMilliseconds(20) }); + + await pump.StartAsync(); + + Assert.True(timer.Pumped.Wait(TimeSpan.FromSeconds(2))); + + await pump.StopAsync(); + pump.Dispose(); + + Assert.True(timer.TickUpdates >= 1); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs new file mode 100644 index 00000000..900c5a58 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/SquidStdLogRollingIntervalExtensionsTests.cs @@ -0,0 +1,24 @@ +using Serilog; +using SquidStd.Core.Types; +using SquidStd.Services.Core.Extensions.Logger; + +namespace SquidStd.Tests.Services.Core; + +public class SquidStdLogRollingIntervalExtensionsTests +{ + [Theory, InlineData(SquidStdLogRollingIntervalType.Infinite, RollingInterval.Infinite), + InlineData(SquidStdLogRollingIntervalType.Year, RollingInterval.Year), + InlineData(SquidStdLogRollingIntervalType.Month, RollingInterval.Month), + InlineData(SquidStdLogRollingIntervalType.Day, RollingInterval.Day), + InlineData(SquidStdLogRollingIntervalType.Hour, RollingInterval.Hour), + InlineData(SquidStdLogRollingIntervalType.Minute, RollingInterval.Minute)] + public void ToSerilogRollingInterval_KnownIntervals_MapExpected( + SquidStdLogRollingIntervalType input, + RollingInterval expected + ) + => Assert.Equal(expected, input.ToSerilogRollingInterval()); + + [Fact] + public void ToSerilogRollingInterval_UnmappedInterval_FallsBackToDay() + => Assert.Equal(RollingInterval.Day, ((SquidStdLogRollingIntervalType)255).ToSerilogRollingInterval()); +} diff --git a/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs b/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs new file mode 100644 index 00000000..bb998645 --- /dev/null +++ b/tests/SquidStd.Tests/Services/Core/Storage/FileStorageServiceTests.cs @@ -0,0 +1,181 @@ +using System.Security.Cryptography; +using System.Text; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using SquidStd.Core.Data.Storage; +using SquidStd.Services.Core.Services.Storage; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Services.Core.Storage; + +[Collection(SerilogEventSinkCollection.Name)] +public class FileStorageServiceTests +{ + [Fact] + public async Task SaveAsync_LoadAsync_RoundTripsBytes() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + var data = Encoding.UTF8.GetBytes("hello storage"); + + await service.SaveAsync("profiles/main.bin", data); + + var loaded = await service.LoadAsync("profiles/main.bin"); + + Assert.Equal(data, loaded); + Assert.True(await service.ExistsAsync("profiles/main.bin")); + } + + [Fact] + public async Task DeleteAsync_RemovesStoredValue() + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + await service.SaveAsync("cache/value.bin", new byte[] { 1, 2, 3 }); + + var deleted = await service.DeleteAsync("cache/value.bin"); + + Assert.True(deleted); + Assert.False(await service.ExistsAsync("cache/value.bin")); + Assert.Null(await service.LoadAsync("cache/value.bin")); + } + + [Theory, InlineData("../escape.bin"), InlineData("/absolute.bin"), InlineData("nested/../../escape.bin")] + public async Task SaveAsync_RejectsUnsafeKeys(string key) + { + using var temp = new TempDirectory(); + var service = new FileStorageService(new() { RootDirectory = temp.Path }); + + await Assert.ThrowsAsync(() => service.SaveAsync(key, new byte[] { 1 }).AsTask()); + } + + [Fact] + public async Task ObjectStorage_SaveAsync_LoadAsync_RoundTripsYamlObject() + { + using var temp = new TempDirectory(); + var storage = new FileStorageService(new() { RootDirectory = temp.Path }); + var objects = new YamlObjectStorageService(storage); + var expected = new SampleObject + { + Name = "main", + Value = 42 + }; + + await objects.SaveAsync("objects/sample.yaml", expected); + + var actual = await objects.LoadAsync("objects/sample.yaml"); + + Assert.NotNull(actual); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Value, actual.Value); + } + + [Fact] + public void AesGcmSecretProtector_Protect_Unprotect_RoundTripsWithoutPlaintext() + { + var variableName = "SQUIDSTD_TEST_SECRET_KEY"; + var previous = Environment.GetEnvironmentVariable(variableName); + var key = RandomNumberGenerator.GetBytes(32); + + try + { + Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(key)); + var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var plaintext = Encoding.UTF8.GetBytes("super-secret-value"); + + var protectedData = protector.Protect(plaintext); + var unprotected = protector.Unprotect(protectedData); + + Assert.Equal(plaintext, unprotected); + Assert.DoesNotContain("super-secret-value", Encoding.UTF8.GetString(protectedData)); + } + finally + { + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + [Fact] + public void AesGcmSecretProtector_WhenKeyEnvironmentVariableIsMissing_UsesDefaultKeyAndLogsWarning() + { + var variableName = "SQUIDSTD_TEST_SECRET_KEY_MISSING"; + var previous = Environment.GetEnvironmentVariable(variableName); + var previousLogger = Log.Logger; + var sink = new CapturingSink(); + + try + { + Environment.SetEnvironmentVariable(variableName, null); + Log.Logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger(); + var protector = new AesGcmSecretProtector(new() { KeyEnvironmentVariable = variableName }); + var plaintext = Encoding.UTF8.GetBytes("default-key-secret"); + + var protectedData = protector.Protect(plaintext); + var unprotected = protector.Unprotect(protectedData); + + Assert.Equal(plaintext, unprotected); + Assert.Contains( + sink.Events, + logEvent => logEvent.Level == LogEventLevel.Warning && + logEvent.RenderMessage().Contains(variableName, StringComparison.Ordinal) + ); + } + finally + { + Log.Logger = previousLogger; + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + [Fact] + public async Task FileSecretStore_SetAsync_GetAsync_StoresEncryptedPayload() + { + using var temp = new TempDirectory(); + var variableName = "SQUIDSTD_TEST_SECRET_STORE_KEY"; + var previous = Environment.GetEnvironmentVariable(variableName); + var key = RandomNumberGenerator.GetBytes(32); + + try + { + Environment.SetEnvironmentVariable(variableName, Convert.ToBase64String(key)); + var config = new SecretsConfig + { + RootDirectory = temp.Path, + KeyEnvironmentVariable = variableName + }; + var store = new FileSecretStore(config, new AesGcmSecretProtector(config)); + + await store.SetAsync("db/main-password", "super-secret-value"); + + var value = await store.GetAsync("db/main-password"); + var files = Directory.GetFiles(temp.Path, "*", SearchOption.AllDirectories); + var rawPayload = string.Join(Environment.NewLine, files.Select(File.ReadAllText)); + + Assert.Equal("super-secret-value", value); + Assert.True(await store.ExistsAsync("db/main-password")); + Assert.DoesNotContain("super-secret-value", rawPayload); + } + finally + { + Environment.SetEnvironmentVariable(variableName, previous); + } + } + + private sealed class SampleObject + { + public string Name { get; set; } = string.Empty; + + public int Value { get; set; } + } + + private sealed class CapturingSink : ILogEventSink + { + private readonly List _events = []; + + public IReadOnlyList Events => _events; + + public void Emit(LogEvent logEvent) + => _events.Add(logEvent); + } +} diff --git a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs index 94a87ec1..68f4aef6 100644 --- a/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs +++ b/tests/SquidStd.Tests/Services/Core/TimerWheelServiceTests.cs @@ -1,4 +1,3 @@ -using SquidStd.Core.Data.Timing; using SquidStd.Core.Interfaces.Timing; using SquidStd.Services.Core.Services; @@ -7,50 +6,32 @@ namespace SquidStd.Tests.Services.Core; public class TimerWheelServiceTests { [Fact] - public void RegisterTimer_ReturnsNonEmptyDistinctIds() - { - ITimerService timer = NewService(); - - var first = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - var second = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - - Assert.False(string.IsNullOrEmpty(first)); - Assert.False(string.IsNullOrEmpty(second)); - Assert.NotEqual(first, second); - } - - [Fact] - public void OneShot_FiresExactlyOnceAtDueTime() + public void CallbackException_DoesNotStopOtherTimers() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - timer.RegisterTimer("once", TimeSpan.FromMilliseconds(8), () => calls++); + timer.RegisterTimer("bad", TimeSpan.FromMilliseconds(8), () => throw new InvalidOperationException("boom")); + timer.RegisterTimer("good", TimeSpan.FromMilliseconds(8), () => calls++); timer.UpdateTicksDelta(0); timer.UpdateTicksDelta(8); - timer.UpdateTicksDelta(16); - timer.UpdateTicksDelta(24); Assert.Equal(1, calls); } [Fact] - public void Repeating_FiresEveryInterval() + public void Ctor_InvalidConfig_Throws() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); - var calls = 0; - timer.RegisterTimer("repeat", TimeSpan.FromMilliseconds(16), () => calls++, repeat: true); - - timer.UpdateTicksDelta(0); - timer.UpdateTicksDelta(80); - - Assert.Equal(5, calls); + Assert.Throws(() => new TimerWheelService(new() { TickDuration = TimeSpan.Zero })); + Assert.Throws( + () => new TimerWheelService(new() { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) + ); } [Fact] public void Delay_PostponesFirstExecutionThenUsesInterval() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 16); + ITimerService timer = NewService(); var timestamps = new List(); var current = 0L; timer.RegisterTimer( @@ -58,7 +39,7 @@ public void Delay_PostponesFirstExecutionThenUsesInterval() TimeSpan.FromMilliseconds(8), () => timestamps.Add(current), TimeSpan.FromMilliseconds(24), - repeat: true + true ); timer.UpdateTicksDelta(0); @@ -73,57 +54,44 @@ public void Delay_PostponesFirstExecutionThenUsesInterval() } [Fact] - public void UnregisterTimer_BeforeDueTime_PreventsCallback() + public void OneShot_FiresExactlyOnceAtDueTime() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - var timerId = timer.RegisterTimer("cancel", TimeSpan.FromMilliseconds(8), () => calls++); + timer.RegisterTimer("once", TimeSpan.FromMilliseconds(8), () => calls++); timer.UpdateTicksDelta(0); - Assert.True(timer.UnregisterTimer(timerId)); + timer.UpdateTicksDelta(8); timer.UpdateTicksDelta(16); + timer.UpdateTicksDelta(24); - Assert.Equal(0, calls); + Assert.Equal(1, calls); } [Fact] - public void UnregisterTimersByName_RemovesEveryMatchingTimer() + public void RegisterTimer_ReturnsNonEmptyDistinctIds() { ITimerService timer = NewService(); - timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); - timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); - timer.RegisterTimer("other", TimeSpan.FromMilliseconds(8), () => { }); - var removed = timer.UnregisterTimersByName("group"); + var first = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); + var second = timer.RegisterTimer("timer", TimeSpan.FromMilliseconds(8), () => { }); - Assert.Equal(2, removed); - Assert.Equal(0, timer.UnregisterTimersByName("group")); - Assert.Equal(1, timer.UnregisterTimersByName("other")); + Assert.False(string.IsNullOrEmpty(first)); + Assert.False(string.IsNullOrEmpty(second)); + Assert.NotEqual(first, second); } [Fact] - public void CallbackException_DoesNotStopOtherTimers() + public void Repeating_FiresEveryInterval() { - ITimerService timer = NewService(tickDurationMs: 8, wheelSize: 8); + ITimerService timer = NewService(8, 8); var calls = 0; - timer.RegisterTimer("bad", TimeSpan.FromMilliseconds(8), () => throw new InvalidOperationException("boom")); - timer.RegisterTimer("good", TimeSpan.FromMilliseconds(8), () => calls++); - - timer.UpdateTicksDelta(0); - timer.UpdateTicksDelta(8); - - Assert.Equal(1, calls); - } + timer.RegisterTimer("repeat", TimeSpan.FromMilliseconds(16), () => calls++, repeat: true); - [Fact] - public void UpdateTicksDelta_AdvancesByWholeTicks() - { - ITimerService timer = NewService(tickDurationMs: 8); timer.UpdateTicksDelta(0); + timer.UpdateTicksDelta(80); - var processed = timer.UpdateTicksDelta(24); - - Assert.Equal(3, processed); + Assert.Equal(5, calls); } [Fact] @@ -141,19 +109,48 @@ public async Task StopAsync_ClearsState() } [Fact] - public void Ctor_InvalidConfig_Throws() + public void UnregisterTimer_BeforeDueTime_PreventsCallback() { - Assert.Throws( - () => new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.Zero }) - ); - Assert.Throws( - () => new TimerWheelService(new TimerWheelConfig { TickDuration = TimeSpan.FromMilliseconds(8), WheelSize = 0 }) - ); + ITimerService timer = NewService(8, 8); + var calls = 0; + var timerId = timer.RegisterTimer("cancel", TimeSpan.FromMilliseconds(8), () => calls++); + + timer.UpdateTicksDelta(0); + Assert.True(timer.UnregisterTimer(timerId)); + timer.UpdateTicksDelta(16); + + Assert.Equal(0, calls); + } + + [Fact] + public void UnregisterTimersByName_RemovesEveryMatchingTimer() + { + ITimerService timer = NewService(); + timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); + timer.RegisterTimer("group", TimeSpan.FromMilliseconds(8), () => { }); + timer.RegisterTimer("other", TimeSpan.FromMilliseconds(8), () => { }); + + var removed = timer.UnregisterTimersByName("group"); + + Assert.Equal(2, removed); + Assert.Equal(0, timer.UnregisterTimersByName("group")); + Assert.Equal(1, timer.UnregisterTimersByName("other")); + } + + [Fact] + public void UpdateTicksDelta_AdvancesByWholeTicks() + { + ITimerService timer = NewService(); + timer.UpdateTicksDelta(0); + + var processed = timer.UpdateTicksDelta(24); + + Assert.Equal(3, processed); } private static TimerWheelService NewService(int tickDurationMs = 8, int wheelSize = 16) => new( - new TimerWheelConfig + new() { TickDuration = TimeSpan.FromMilliseconds(tickDurationMs), WheelSize = wheelSize diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 6fd31a56..6cf2d3ec 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -1,33 +1,50 @@  - - net10.0 - enable - enable - false - + + net10.0 + enable + enable + false + - - - - - - + + + + + + + + + - - - + + + - - - + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/Storage/StorageConfigTests.cs b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs new file mode 100644 index 00000000..78de2a4b --- /dev/null +++ b/tests/SquidStd.Tests/Storage/StorageConfigTests.cs @@ -0,0 +1,27 @@ +using SquidStd.Core.Data.Storage; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Tests.Storage; + +public class StorageConfigTests +{ + [Fact] + public void StorageConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new StorageConfig(); + + Assert.Equal("storage", entry.SectionName); + Assert.Equal(typeof(StorageConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } + + [Fact] + public void SecretsConfig_ImplementsConfigEntry() + { + IConfigEntry entry = new SecretsConfig(); + + Assert.Equal("secrets", entry.SectionName); + Assert.Equal(typeof(SecretsConfig), entry.ConfigType); + Assert.IsType(entry.CreateDefault()); + } +} diff --git a/tests/SquidStd.Tests/Support/FakeCacheProvider.cs b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs new file mode 100644 index 00000000..8dd5120a --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeCacheProvider.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using SquidStd.Caching.Abstractions.Interfaces; + +namespace SquidStd.Tests.Support; + +/// +/// In-memory for tests. Ignores TTL expiry (records the last TTL seen). +/// +public sealed class FakeCacheProvider : ICacheProvider +{ + private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); + + public TimeSpan? LastTtl { get; private set; } + + public Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + if (_store.TryGetValue(key, out var value)) + { + return Task.FromResult?>(new ReadOnlyMemory(value)); + } + + return Task.FromResult?>(null); + } + + public Task SetAsync(string key, ReadOnlyMemory value, TimeSpan? ttl, CancellationToken cancellationToken = default) + { + LastTtl = ttl; + _store[key] = value.ToArray(); + + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_store.TryRemove(key, out _)); + + public Task ExistsAsync(string key, CancellationToken cancellationToken = default) + => Task.FromResult(_store.ContainsKey(key)); + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; +} diff --git a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs index 8e5a3906..43937999 100644 --- a/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs +++ b/tests/SquidStd.Tests/Support/FakeNetworkConnection.cs @@ -24,13 +24,6 @@ public FakeNetworkConnection(long sessionId = 1, EndPoint? remoteEndPoint = null RemoteEndPoint = remoteEndPoint ?? new IPEndPoint(IPAddress.Loopback, 1234); } - public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) - { - _sent.Add(payload.ToArray()); - - return SendCallback?.Invoke(payload) ?? Task.CompletedTask; - } - public Task CloseAsync(CancellationToken cancellationToken = default) { CloseCount++; @@ -38,4 +31,11 @@ public Task CloseAsync(CancellationToken cancellationToken = default) return Task.CompletedTask; } + + public Task SendAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) + { + _sent.Add(payload.ToArray()); + + return SendCallback?.Invoke(payload) ?? Task.CompletedTask; + } } diff --git a/tests/SquidStd.Tests/Support/FakePlugin.cs b/tests/SquidStd.Tests/Support/FakePlugin.cs index 1c272909..93569292 100644 --- a/tests/SquidStd.Tests/Support/FakePlugin.cs +++ b/tests/SquidStd.Tests/Support/FakePlugin.cs @@ -16,7 +16,7 @@ public class FakePlugin : ISquidStdPlugin { Id = "squidstd.fake", Name = "Fake Plugin", - Version = new Version(1, 2, 3), + Version = new(1, 2, 3), Author = "tests" }; diff --git a/tests/SquidStd.Tests/Support/FakeTimeProvider.cs b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs new file mode 100644 index 00000000..cfff2cde --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeTimeProvider.cs @@ -0,0 +1,37 @@ +namespace SquidStd.Tests.Support; + +/// +/// Test TimeProvider with a manually advanced clock and an inert timer (so periodic sweeps +/// never fire on their own; tests invoke the sweep explicitly). +/// +public sealed class FakeTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset start) + { + _utcNow = start; + } + + public override DateTimeOffset GetUtcNow() + => _utcNow; + + public void Advance(TimeSpan delta) + => _utcNow = _utcNow.Add(delta); + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + => new InertTimer(); + + private sealed class InertTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) + => true; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/SquidStd.Tests/Support/FakeTimerService.cs b/tests/SquidStd.Tests/Support/FakeTimerService.cs new file mode 100644 index 00000000..be9668a5 --- /dev/null +++ b/tests/SquidStd.Tests/Support/FakeTimerService.cs @@ -0,0 +1,71 @@ +using SquidStd.Core.Interfaces.Timing; + +namespace SquidStd.Tests.Support; + +/// +/// In-memory for tests. Timers do not fire on their own: +/// call to invoke and clear the currently-registered timers +/// (one-shot semantics). only records that a pump ran. +/// +public sealed class FakeTimerService : ITimerService +{ + private readonly Dictionary _timers = new(StringComparer.Ordinal); + + public int Count => _timers.Count; + + public int TickUpdates { get; private set; } + + public ManualResetEventSlim Pumped { get; } = new(false); + + public string RegisterTimer(string name, TimeSpan interval, Action callback, TimeSpan? delay = null, bool repeat = false) + { + var id = Guid.NewGuid().ToString("N"); + _timers[id] = (name, callback); + + return id; + } + + public void UnregisterAllTimers() + => _timers.Clear(); + + public bool UnregisterTimer(string timerId) + => _timers.Remove(timerId); + + public int UnregisterTimersByName(string name) + { + var ids = _timers.Where(kv => kv.Value.Name == name).Select(kv => kv.Key).ToArray(); + + foreach (var id in ids) + { + _timers.Remove(id); + } + + return ids.Length; + } + + public int UpdateTicksDelta(long timestampMilliseconds) + { + TickUpdates++; + Pumped.Set(); + + return 0; + } + + /// Invokes and removes every currently-registered timer; returns how many fired. + public int FireDue() + { + var snapshot = _timers.ToArray(); + + foreach (var kv in snapshot) + { + _timers.Remove(kv.Key); + } + + foreach (var kv in snapshot) + { + kv.Value.Callback(); + } + + return snapshot.Length; + } +} diff --git a/tests/SquidStd.Tests/Support/ManualJobSystem.cs b/tests/SquidStd.Tests/Support/ManualJobSystem.cs new file mode 100644 index 00000000..866281ab --- /dev/null +++ b/tests/SquidStd.Tests/Support/ManualJobSystem.cs @@ -0,0 +1,55 @@ +using SquidStd.Core.Interfaces.Jobs; + +namespace SquidStd.Tests.Support; + +/// +/// Single-threaded for tests: +/// queues the work; call to execute it. This keeps overlap and +/// rescheduling tests fully deterministic. +/// +public sealed class ManualJobSystem : IJobSystem +{ + private readonly List _pending = new(); + + public int WorkerCount => 1; + + public int PendingCount => _pending.Count; + + public int ActiveCount => 0; + + public long CompletedCount { get; private set; } + + public Task ScheduleAsync(Action work, CancellationToken cancellationToken = default) + { + _pending.Add(work); + + return Task.CompletedTask; + } + + public Task ScheduleAsync(Func work, CancellationToken cancellationToken = default) + { + var result = work(); + CompletedCount++; + + return Task.FromResult(result); + } + + /// Runs and clears all queued work; returns how many items ran. + public int RunAll() + { + var snapshot = _pending.ToArray(); + _pending.Clear(); + + foreach (var action in snapshot) + { + action(); + CompletedCount++; + } + + return snapshot.Length; + } + + public void Dispose() + { + } +} diff --git a/tests/SquidStd.Tests/Support/TempDirectory.cs b/tests/SquidStd.Tests/Support/TempDirectory.cs index a772aaec..931f7259 100644 --- a/tests/SquidStd.Tests/Support/TempDirectory.cs +++ b/tests/SquidStd.Tests/Support/TempDirectory.cs @@ -32,7 +32,7 @@ public void Dispose() { if (Directory.Exists(Path)) { - Directory.Delete(Path, recursive: true); + Directory.Delete(Path, true); } } catch diff --git a/tests/SquidStd.Tests/Support/TestJsonContext.cs b/tests/SquidStd.Tests/Support/TestJsonContext.cs index 4d59a1e7..f6c211e3 100644 --- a/tests/SquidStd.Tests/Support/TestJsonContext.cs +++ b/tests/SquidStd.Tests/Support/TestJsonContext.cs @@ -5,8 +5,5 @@ namespace SquidStd.Tests.Support; /// /// Source-generated JSON serializer context exposing the test DTO types. /// -[JsonSerializable(typeof(SampleDto))] -[JsonSerializable(typeof(OtherDto))] -public partial class TestJsonContext : JsonSerializerContext -{ -} +[JsonSerializable(typeof(SampleDto)), JsonSerializable(typeof(OtherDto))] +public partial class TestJsonContext : JsonSerializerContext { } diff --git a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs index 7fdf1a8b..2a7c61a9 100644 --- a/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/DirectoriesUtilsTests.cs @@ -6,37 +6,37 @@ namespace SquidStd.Tests.Utils; public class DirectoriesUtilsTests { [Fact] - public void GetFiles_NonExistentDirectory_ReturnsEmpty() - => Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); - - [Fact] - public void GetFiles_NoExtensionFilter_ReturnsAllFiles() + public void GetFiles_ExtensionFilter_ReturnsMatchingFilesOnly() { using var temp = new TempDirectory(); File.WriteAllText(temp.Combine("a.txt"), "a"); File.WriteAllText(temp.Combine("b.json"), "b"); + File.WriteAllText(temp.Combine("c.json"), "c"); - var files = DirectoriesUtils.GetFiles(temp.Path); + var files = DirectoriesUtils.GetFiles(temp.Path, "*.json"); Assert.Equal(2, files.Length); + Assert.All(files, file => Assert.EndsWith(".json", file)); } [Fact] - public void GetFiles_ExtensionFilter_ReturnsMatchingFilesOnly() + public void GetFiles_NoExtensionFilter_ReturnsAllFiles() { using var temp = new TempDirectory(); File.WriteAllText(temp.Combine("a.txt"), "a"); File.WriteAllText(temp.Combine("b.json"), "b"); - File.WriteAllText(temp.Combine("c.json"), "c"); - var files = DirectoriesUtils.GetFiles(temp.Path, "*.json"); + var files = DirectoriesUtils.GetFiles(temp.Path); Assert.Equal(2, files.Length); - Assert.All(files, file => Assert.EndsWith(".json", file)); } [Fact] - public void GetFiles_Recursive_IncludesNestedFiles() + public void GetFiles_NonExistentDirectory_ReturnsEmpty() + => Assert.Empty(DirectoriesUtils.GetFiles(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")))); + + [Fact] + public void GetFiles_NonRecursive_ExcludesNestedFiles() { using var temp = new TempDirectory(); var nested = temp.Combine("nested"); @@ -44,13 +44,14 @@ public void GetFiles_Recursive_IncludesNestedFiles() File.WriteAllText(temp.Combine("root.txt"), "root"); File.WriteAllText(Path.Combine(nested, "child.txt"), "child"); - var files = DirectoriesUtils.GetFiles(temp.Path, recursive: true, "*.txt"); + var files = DirectoriesUtils.GetFiles(temp.Path, false, "*.txt"); - Assert.Equal(2, files.Length); + Assert.Single(files); + Assert.EndsWith("root.txt", files[0]); } [Fact] - public void GetFiles_NonRecursive_ExcludesNestedFiles() + public void GetFiles_Recursive_IncludesNestedFiles() { using var temp = new TempDirectory(); var nested = temp.Combine("nested"); @@ -58,9 +59,8 @@ public void GetFiles_NonRecursive_ExcludesNestedFiles() File.WriteAllText(temp.Combine("root.txt"), "root"); File.WriteAllText(Path.Combine(nested, "child.txt"), "child"); - var files = DirectoriesUtils.GetFiles(temp.Path, recursive: false, "*.txt"); + var files = DirectoriesUtils.GetFiles(temp.Path, true, "*.txt"); - Assert.Single(files); - Assert.EndsWith("root.txt", files[0]); + Assert.Equal(2, files.Length); } } diff --git a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs index 66e11b7c..be8ca6dc 100644 --- a/tests/SquidStd.Tests/Utils/HashUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/HashUtilsTests.cs @@ -4,6 +4,19 @@ namespace SquidStd.Tests.Utils; public class HashUtilsTests { + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void HashPassword_NullOrWhitespace_Throws(string? password) + => Assert.Throws(() => HashUtils.HashPassword(password!)); + + [Fact] + public void HashPassword_SamePasswordTwice_ProducesDifferentHashes() + { + var first = HashUtils.HashPassword("s3cret"); + var second = HashUtils.HashPassword("s3cret"); + + Assert.NotEqual(first, second); + } + [Fact] public void HashPassword_ValidPassword_ReturnsSerializedPayload() { @@ -18,22 +31,6 @@ public void HashPassword_ValidPassword_ReturnsSerializedPayload() Assert.NotEmpty(parts[3]); } - [Fact] - public void HashPassword_SamePasswordTwice_ProducesDifferentHashes() - { - var first = HashUtils.HashPassword("s3cret"); - var second = HashUtils.HashPassword("s3cret"); - - Assert.NotEqual(first, second); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void HashPassword_NullOrWhitespace_Throws(string? password) - => Assert.Throws(() => HashUtils.HashPassword(password!)); - [Fact] public void VerifyPassword_CorrectPassword_ReturnsTrue() { @@ -42,6 +39,16 @@ public void VerifyPassword_CorrectPassword_ReturnsTrue() Assert.True(HashUtils.VerifyPassword("s3cret", hash)); } + [Theory, InlineData("not-a-hash"), InlineData("md5$100000$c2FsdA==$aGFzaA=="), + InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA=="), InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA=="), + InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] + public void VerifyPassword_MalformedStoredHash_ReturnsFalse(string storedHash) + => Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); + + [Theory, InlineData(""), InlineData(" "), InlineData(null)] + public void VerifyPassword_NullOrWhitespacePassword_ReturnsFalse(string? password) + => Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); + [Fact] public void VerifyPassword_WrongPassword_ReturnsFalse() { @@ -49,20 +56,4 @@ public void VerifyPassword_WrongPassword_ReturnsFalse() Assert.False(HashUtils.VerifyPassword("wrong", hash)); } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void VerifyPassword_NullOrWhitespacePassword_ReturnsFalse(string? password) - => Assert.False(HashUtils.VerifyPassword(password!, "pbkdf2-sha256$100000$c2FsdA==$aGFzaA==")); - - [Theory] - [InlineData("not-a-hash")] - [InlineData("md5$100000$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$abc$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$0$c2FsdA==$aGFzaA==")] - [InlineData("pbkdf2-sha256$100000$not-base64$aGFzaA==")] - public void VerifyPassword_MalformedStoredHash_ReturnsFalse(string storedHash) - => Assert.False(HashUtils.VerifyPassword("s3cret", storedHash)); } diff --git a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs index 8051315e..6d634a6c 100644 --- a/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/NetworkUtilsTests.cs @@ -6,72 +6,63 @@ namespace SquidStd.Tests.Utils; public class NetworkUtilsTests { [Fact] - public void ParseIpAddress_Wildcard_ReturnsAny() - => Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); + public void GetListeningAddresses_NullEndpoint_Throws() + => Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); [Fact] - public void ParseIpAddress_ValidAddress_ReturnsParsedAddress() - => Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); + public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() + { + var template = new IPEndPoint(IPAddress.Loopback, 6667); - [Theory] - [InlineData("")] - [InlineData(" ")] + var results = NetworkUtils.GetListeningAddresses(template).ToList(); + + Assert.All( + results, + endpoint => + { + Assert.Equal(6667, endpoint.Port); + Assert.Equal(template.AddressFamily, endpoint.AddressFamily); + } + ); + } + + [Theory, InlineData(""), InlineData(" ")] public void ParseIpAddress_NullOrWhitespace_Throws(string ipAddress) => Assert.Throws(() => NetworkUtils.ParseIpAddress(ipAddress)); [Fact] - public void ParsePorts_SinglePort_ReturnsSingleEntry() - => Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); - - [Fact] - public void ParsePorts_Range_ReturnsExpandedRange() - => Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); + public void ParseIpAddress_ValidAddress_ReturnsParsedAddress() + => Assert.Equal(IPAddress.Parse("127.0.0.1"), NetworkUtils.ParseIpAddress("127.0.0.1")); [Fact] - public void ParsePorts_MixedRangeAndList_ReturnsAllPorts() - => Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); + public void ParseIpAddress_Wildcard_ReturnsAny() + => Assert.Equal(IPAddress.Any, NetworkUtils.ParseIpAddress("*")); - [Fact] - public void ParsePorts_TrimsWhitespaceEntries() - => Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); + [Theory, InlineData("99999"), InlineData("-1"), InlineData("abc")] + public void ParsePorts_InvalidPort_ThrowsFormatException(string ports) + => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); - [Theory] - [InlineData("8000-7000")] - [InlineData("1-2-3")] + [Theory, InlineData("8000-7000"), InlineData("1-2-3")] public void ParsePorts_InvalidRange_ThrowsFormatException(string ports) => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); - [Theory] - [InlineData("99999")] - [InlineData("-1")] - [InlineData("abc")] - public void ParsePorts_InvalidPort_ThrowsFormatException(string ports) - => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); + [Fact] + public void ParsePorts_MixedRangeAndList_ReturnsAllPorts() + => Assert.Equal([6666, 6667, 6668, 6669, 8000], NetworkUtils.ParsePorts("6666-6668,6669,8000")); - [Theory] - [InlineData("")] - [InlineData(" ")] + [Theory, InlineData(""), InlineData(" ")] public void ParsePorts_NullOrWhitespace_ThrowsArgumentException(string ports) => Assert.Throws(() => NetworkUtils.ParsePorts(ports)); [Fact] - public void GetListeningAddresses_NullEndpoint_Throws() - => Assert.Throws(() => NetworkUtils.GetListeningAddresses(null!).ToList()); + public void ParsePorts_Range_ReturnsExpandedRange() + => Assert.Equal([6666, 6667, 6668], NetworkUtils.ParsePorts("6666-6668")); [Fact] - public void GetListeningAddresses_ReturnsEndpointsMatchingFamilyAndPort() - { - var template = new IPEndPoint(IPAddress.Loopback, 6667); - - var results = NetworkUtils.GetListeningAddresses(template).ToList(); + public void ParsePorts_SinglePort_ReturnsSingleEntry() + => Assert.Equal([8000], NetworkUtils.ParsePorts("8000")); - Assert.All( - results, - endpoint => - { - Assert.Equal(6667, endpoint.Port); - Assert.Equal(template.AddressFamily, endpoint.AddressFamily); - } - ); - } + [Fact] + public void ParsePorts_TrimsWhitespaceEntries() + => Assert.Equal([80, 443], NetworkUtils.ParsePorts(" 80 , 443 ")); } diff --git a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs index 824a856f..01e1512e 100644 --- a/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/PlatformUtilsTests.cs @@ -8,10 +8,9 @@ public class PlatformUtilsTests [Fact] public void GetCurrentPlatform_MatchesOperatingSystemDetection() { - var expected = OperatingSystem.IsWindows() ? PlatformType.Windows - : OperatingSystem.IsMacOS() ? PlatformType.MacOS - : OperatingSystem.IsLinux() ? PlatformType.Linux - : PlatformType.Unknown; + var expected = OperatingSystem.IsWindows() ? PlatformType.Windows : + OperatingSystem.IsMacOS() ? PlatformType.MacOS : + OperatingSystem.IsLinux() ? PlatformType.Linux : PlatformType.Unknown; Assert.Equal(expected, PlatformUtils.GetCurrentPlatform()); } diff --git a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs index 4b1ff0b2..5e685662 100644 --- a/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/ResourceUtilsTests.cs @@ -9,41 +9,33 @@ public class ResourceUtilsTests private const string SampleResourceSuffix = "Support.Resources.sample.txt"; [Fact] - public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() + public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() { - var expected = Path.Combine("cfg") + ".json"; + var expected = string.Join(Path.DirectorySeparatorChar, "Folder", "Sub", "file") + ".txt"; - Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.cfg.json", "Asm")); + Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.Folder.Sub.file.txt", "Asm")); } [Fact] - public void ConvertResourceNameToPath_NestedName_ConvertsDotsToSeparators() + public void ConvertResourceNameToPath_NoExtension_Throws() + => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); + + [Fact] + public void ConvertResourceNameToPath_ValidName_ReturnsFilePath() { - var expected = string.Join(Path.DirectorySeparatorChar, "Folder", "Sub", "file") + ".txt"; + var expected = Path.Combine("cfg") + ".json"; - Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.Folder.Sub.file.txt", "Asm")); + Assert.Equal(expected, ResourceUtils.ConvertResourceNameToPath("Asm.cfg.json", "Asm")); } [Fact] public void ConvertResourceNameToPath_WrongNamespace_Throws() => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Other.cfg.json", "Asm")); - [Fact] - public void ConvertResourceNameToPath_NoExtension_Throws() - => Assert.Throws(() => ResourceUtils.ConvertResourceNameToPath("Asm.file", "Asm")); - [Fact] public void EmbeddedNameToPath_StripsPrefixAndConvertsDots() => Assert.Equal("Folder/file/txt", ResourceUtils.EmbeddedNameToPath("Asm.Folder.file.txt", "Asm")); - [Fact] - public void GetDirectoryPathFromResourceName_ReturnsDirectoryPart() - { - var expected = string.Join(Path.DirectorySeparatorChar, "Assets", "Fonts"); - - Assert.Equal(expected, ResourceUtils.GetDirectoryPathFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); - } - [Fact] public void GetDirectoryPathFromResourceName_RemovesBaseNamespace() { @@ -53,15 +45,12 @@ public void GetDirectoryPathFromResourceName_RemovesBaseNamespace() } [Fact] - public void GetFileNameFromResourceName_ReturnsFileNameWithExtension() - => Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + public void GetDirectoryPathFromResourceName_ReturnsDirectoryPart() + { + var expected = string.Join(Path.DirectorySeparatorChar, "Assets", "Fonts"); - [Theory] - [InlineData("a/b/c.txt", "c.txt")] - [InlineData("a\\b\\c.txt", "c.txt")] - [InlineData("c.txt", "c.txt")] - public void GetFileNameFromResourcePath_ReturnsFinalSegment(string input, string expected) - => Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + Assert.Equal(expected, ResourceUtils.GetDirectoryPathFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + } [Fact] public void GetEmbeddedResourceNames_NoFilter_IncludesSampleResource() @@ -71,6 +60,12 @@ public void GetEmbeddedResourceNames_NoFilter_IncludesSampleResource() Assert.Contains(names, name => name.EndsWith(SampleResourceSuffix, StringComparison.Ordinal)); } + [Fact] + public void GetEmbeddedResourceStream_MissingResource_Throws() + => Assert.Throws( + () => ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin") + ); + [Fact] public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() { @@ -79,6 +74,14 @@ public void GetEmbeddedResourceString_ExistingResource_ReturnsContent() Assert.Contains("embedded-resource-content", content); } + [Fact] + public void GetFileNameFromResourceName_ReturnsFileNameWithExtension() + => Assert.Equal("DefaultUiFont.ttf", ResourceUtils.GetFileNameFromResourceName("Assets.Fonts.DefaultUiFont.ttf")); + + [Theory, InlineData("a/b/c.txt", "c.txt"), InlineData("a\\b\\c.txt", "c.txt"), InlineData("c.txt", "c.txt")] + public void GetFileNameFromResourcePath_ReturnsFinalSegment(string input, string expected) + => Assert.Equal(expected, ResourceUtils.GetFileNameFromResourcePath(input)); + [Fact] public void ReadEmbeddedResource_ExistingResource_ReturnsContent() { @@ -88,11 +91,9 @@ public void ReadEmbeddedResource_ExistingResource_ReturnsContent() Assert.Contains("embedded-resource-content", content); } - [Fact] - public void GetEmbeddedResourceStream_MissingResource_Throws() - => Assert.Throws(() => ResourceUtils.GetEmbeddedResourceStream(TestAssembly, "does-not-exist.bin")); - [Fact] public void ReadEmbeddedResource_MissingResource_Throws() - => Assert.Throws(() => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly)); + => Assert.Throws( + () => ResourceUtils.ReadEmbeddedResource("does-not-exist.bin", TestAssembly) + ); } diff --git a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs index d3816d8c..c2850852 100644 --- a/tests/SquidStd.Tests/Utils/StringUtilsTests.cs +++ b/tests/SquidStd.Tests/Utils/StringUtilsTests.cs @@ -4,90 +4,65 @@ namespace SquidStd.Tests.Utils; public class StringUtilsTests { - [Theory] - [InlineData("HelloWorld", "helloWorld")] - [InlineData("API_RESPONSE", "apiResponse")] - [InlineData("user-id", "userId")] - [InlineData("hello world", "helloWorld")] + [Theory, InlineData(""), InlineData(null)] + public void ToCamelCase_NullOrEmpty_ReturnsEmpty(string? input) + => Assert.Equal("", StringUtils.ToCamelCase(input!)); + + [Fact] + public void ToCamelCase_SingleCharacter_ReturnsLowerCase() + => Assert.Equal("a", StringUtils.ToCamelCase("A")); + + [Theory, InlineData("HelloWorld", "helloWorld"), InlineData("API_RESPONSE", "apiResponse"), + InlineData("user-id", "userId"), InlineData("hello world", "helloWorld")] public void ToCamelCase_VariousInputs_ReturnsCamelCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToCamelCase(input)); - [Theory] - [InlineData("HelloWorld", "hello.world")] - [InlineData("API_RESPONSE", "api.response")] + [Theory, InlineData("HelloWorld", "hello.world"), InlineData("API_RESPONSE", "api.response")] public void ToDotCase_VariousInputs_ReturnsDotCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToDotCase(input)); - [Theory] - [InlineData("HelloWorld", "hello-world")] - [InlineData("API_RESPONSE", "api-response")] - [InlineData("userId", "user-id")] + [Theory, InlineData("HelloWorld", "hello-world"), InlineData("API_RESPONSE", "api-response"), + InlineData("userId", "user-id")] public void ToKebabCase_VariousInputs_ReturnsKebabCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToKebabCase(input)); - [Theory] - [InlineData("hello_world", "HelloWorld")] - [InlineData("api-response", "ApiResponse")] - [InlineData("userId", "UserId")] + [Fact] + public void ToPascalCase_SingleCharacter_ReturnsUpperCase() + => Assert.Equal("A", StringUtils.ToPascalCase("a")); + + [Theory, InlineData("hello_world", "HelloWorld"), InlineData("api-response", "ApiResponse"), + InlineData("userId", "UserId")] public void ToPascalCase_VariousInputs_ReturnsPascalCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToPascalCase(input)); - [Theory] - [InlineData("HelloWorld", "hello/world")] - [InlineData("API_RESPONSE", "api/response")] + [Theory, InlineData("HelloWorld", "hello/world"), InlineData("API_RESPONSE", "api/response")] public void ToPathCase_VariousInputs_ReturnsPathCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToPathCase(input)); - [Theory] - [InlineData("hello world", "Hello world")] - [InlineData("API_RESPONSE", "Api response")] + [Theory, InlineData("hello world", "Hello world"), InlineData("API_RESPONSE", "Api response")] public void ToSentenceCase_VariousInputs_ReturnsSentenceCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToSentenceCase(input)); - [Theory] - [InlineData("HelloWorld", "hello_world")] - [InlineData("APIResponse", "api_response")] - [InlineData("userId", "user_id")] + [Theory, InlineData(""), InlineData(null)] + public void ToSnakeCase_NullOrEmpty_ReturnsEmpty(string? input) + => Assert.Equal("", StringUtils.ToSnakeCase(input!)); + + [Theory, InlineData("HelloWorld", "hello_world"), InlineData("APIResponse", "api_response"), + InlineData("userId", "user_id")] public void ToSnakeCase_VariousInputs_ReturnsSnakeCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToSnakeCase(input)); - [Theory] - [InlineData("hello_world", "Hello World")] - [InlineData("API_RESPONSE", "Api Response")] - [InlineData("user-id", "User Id")] + [Theory, InlineData("hello_world", "Hello World"), InlineData("API_RESPONSE", "Api Response"), + InlineData("user-id", "User Id")] public void ToTitleCase_VariousInputs_ReturnsTitleCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToTitleCase(input)); - [Theory] - [InlineData("hello_world", "Hello-World")] - [InlineData("apiResponse", "Api-Response")] + [Theory, InlineData("hello_world", "Hello-World"), InlineData("apiResponse", "Api-Response")] public void ToTrainCase_VariousInputs_ReturnsTrainCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToTrainCase(input)); - [Theory] - [InlineData("HelloWorld", "HELLO_WORLD")] - [InlineData("apiResponse", "API_RESPONSE")] - [InlineData("user-id", "USER_ID")] + [Theory, InlineData("HelloWorld", "HELLO_WORLD"), InlineData("apiResponse", "API_RESPONSE"), + InlineData("user-id", "USER_ID")] public void ToUpperSnakeCase_VariousInputs_ReturnsScreamingSnakeCase(string input, string expected) => Assert.Equal(expected, StringUtils.ToUpperSnakeCase(input)); - - [Theory] - [InlineData("")] - [InlineData(null)] - public void ToCamelCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToCamelCase(input!)); - - [Theory] - [InlineData("")] - [InlineData(null)] - public void ToSnakeCase_NullOrEmpty_ReturnsEmpty(string? input) - => Assert.Equal("", StringUtils.ToSnakeCase(input!)); - - [Fact] - public void ToPascalCase_SingleCharacter_ReturnsUpperCase() - => Assert.Equal("A", StringUtils.ToPascalCase("a")); - - [Fact] - public void ToCamelCase_SingleCharacter_ReturnsLowerCase() - => Assert.Equal("a", StringUtils.ToCamelCase("A")); } diff --git a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs index 03f5bde4..218cd8c7 100644 --- a/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs +++ b/tests/SquidStd.Tests/Yaml/YamlUtilsTests.cs @@ -5,48 +5,10 @@ namespace SquidStd.Tests.Yaml; public class YamlUtilsTests { - [Fact] - public void SerializeDeserialize_RoundTrip_PreservesValues() - { - var original = new SampleDto { Name = "squid", Count = 42 }; - - var yaml = YamlUtils.Serialize(original); - var restored = YamlUtils.Deserialize(yaml); - - Assert.Equal(original.Name, restored.Name); - Assert.Equal(original.Count, restored.Count); - } - - [Fact] - public void Serialize_NullObject_Throws() - => Assert.Throws(() => YamlUtils.Serialize(null!)); - - [Theory] - [InlineData("")] - [InlineData(" ")] + [Theory, InlineData(""), InlineData(" ")] public void Deserialize_NullOrWhitespace_Throws(string yaml) => Assert.Throws(() => YamlUtils.Deserialize(yaml)); - [Fact] - public void SerializeToFile_DeserializeFromFile_RoundTrips() - { - using var temp = new TempDirectory(); - var path = temp.Combine("nested/sample.yaml"); - - YamlUtils.SerializeToFile(new SampleDto { Name = "file", Count = 9 }, path); - var restored = YamlUtils.DeserializeFromFile(path); - - Assert.True(File.Exists(path)); - Assert.Equal("file", restored.Name); - Assert.Equal(9, restored.Count); - } - - [Fact] - public void DeserializeFromFile_MissingFile_Throws() - => Assert.Throws( - () => YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) - ); - [Fact] public void Deserialize_RuntimeType_ReturnsTypedObject() { @@ -63,6 +25,12 @@ public void Deserialize_RuntimeType_ReturnsTypedObject() Assert.Equal(8, dto.Count); } + [Fact] + public void DeserializeFromFile_MissingFile_Throws() + => Assert.Throws( + () => YamlUtils.DeserializeFromFile(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".yaml")) + ); + [Fact] public void DeserializeSection_ExistingSection_ReturnsTypedObject() { @@ -93,6 +61,22 @@ public void DeserializeSection_MissingSection_ReturnsNull() Assert.Null(result); } + [Fact] + public void Serialize_NullObject_Throws() + => Assert.Throws(() => YamlUtils.Serialize(null!)); + + [Fact] + public void SerializeDeserialize_RoundTrip_PreservesValues() + { + var original = new SampleDto { Name = "squid", Count = 42 }; + + var yaml = YamlUtils.Serialize(original); + var restored = YamlUtils.Deserialize(yaml); + + Assert.Equal(original.Name, restored.Name); + Assert.Equal(original.Count, restored.Count); + } + [Fact] public void SerializeSections_WritesRootSectionNames() { @@ -107,4 +91,18 @@ public void SerializeSections_WritesRootSectionNames() Assert.Contains("Name: root", yaml); Assert.Contains("Count: 12", yaml); } + + [Fact] + public void SerializeToFile_DeserializeFromFile_RoundTrips() + { + using var temp = new TempDirectory(); + var path = temp.Combine("nested/sample.yaml"); + + YamlUtils.SerializeToFile(new SampleDto { Name = "file", Count = 9 }, path); + var restored = YamlUtils.DeserializeFromFile(path); + + Assert.True(File.Exists(path)); + Assert.Equal("file", restored.Name); + Assert.Equal(9, restored.Count); + } }