diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..fb36ceb
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,20 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "GitVersion.Tool": {
+ "version": "6.3.0",
+ "commands": [
+ "dotnet-gitversion"
+ ],
+ "rollForward": true
+ },
+ "fallout.globaltool": {
+ "version": "10.3.49",
+ "commands": [
+ "fallout"
+ ],
+ "rollForward": true
+ }
+ }
+}
diff --git a/.fallout/build.schema.json b/.fallout/build.schema.json
new file mode 100644
index 0000000..f453028
--- /dev/null
+++ b/.fallout/build.schema.json
@@ -0,0 +1,105 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "definitions": {
+ "Host": {
+ "type": "string",
+ "enum": [
+ "AppVeyor",
+ "AzurePipelines",
+ "Bamboo",
+ "Bitbucket",
+ "Bitrise",
+ "GitHubActions",
+ "GitLab",
+ "Jenkins",
+ "Rider",
+ "SpaceAutomation",
+ "TeamCity",
+ "Terminal",
+ "TravisCI",
+ "VisualStudio",
+ "VSCode"
+ ]
+ },
+ "ExecutableTarget": {
+ "type": "string",
+ "enum": [
+ "Pack",
+ "Test"
+ ]
+ },
+ "Verbosity": {
+ "type": "string",
+ "description": "",
+ "enum": [
+ "Verbose",
+ "Normal",
+ "Minimal",
+ "Quiet"
+ ]
+ },
+ "FalloutBuild": {
+ "properties": {
+ "Continue": {
+ "type": "boolean",
+ "description": "Indicates to continue a previously failed build attempt"
+ },
+ "Help": {
+ "type": "boolean",
+ "description": "Shows the help text for this build assembly"
+ },
+ "Host": {
+ "description": "Host for execution. Default is 'automatic'",
+ "$ref": "#/definitions/Host"
+ },
+ "NoLogo": {
+ "type": "boolean",
+ "description": "Disables displaying the NUKE logo"
+ },
+ "Partition": {
+ "type": "string",
+ "description": "Partition to use on CI"
+ },
+ "Plan": {
+ "type": "boolean",
+ "description": "Shows the execution plan (HTML)"
+ },
+ "Profile": {
+ "type": "array",
+ "description": "Defines the profiles to load",
+ "items": {
+ "type": "string"
+ }
+ },
+ "Root": {
+ "type": "string",
+ "description": "Root directory during build execution"
+ },
+ "Skip": {
+ "type": "array",
+ "description": "List of targets to be skipped. Empty list skips all dependencies",
+ "items": {
+ "$ref": "#/definitions/ExecutableTarget"
+ }
+ },
+ "Target": {
+ "type": "array",
+ "description": "List of targets to be invoked. Default is '{default_target}'",
+ "items": {
+ "$ref": "#/definitions/ExecutableTarget"
+ }
+ },
+ "Verbosity": {
+ "description": "Logging verbosity during build execution. Default is 'Normal'",
+ "$ref": "#/definitions/Verbosity"
+ }
+ }
+ }
+ },
+ "allOf": [
+ {},
+ {
+ "$ref": "#/definitions/FalloutBuild"
+ }
+ ]
+}
diff --git a/.fallout/parameters.json b/.fallout/parameters.json
new file mode 100644
index 0000000..84e6d1a
--- /dev/null
+++ b/.fallout/parameters.json
@@ -0,0 +1,3 @@
+{
+ "Solution": "Scrutor.Extensions.HttpClient.sln"
+}
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..7e4edfa
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,19 @@
+# Categorises the auto-generated GitHub Release notes by PR label
+# (used by `generate_release_notes` in publish.yml).
+changelog:
+ exclude:
+ labels:
+ - duplicate
+ - invalid
+ - wontfix
+ categories:
+ - title: ✨ Features & Enhancements
+ labels: [enhancement, feature]
+ - title: 🐛 Fixes
+ labels: [bug, fix]
+ - title: 📝 Documentation
+ labels: [documentation]
+ - title: 🔧 Maintenance & CI
+ labels: [chore, ci, cleanup, dependencies, iac]
+ - title: Other Changes
+ labels: ["*"]
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..c0bb2af
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,43 @@
+# ------------------------------------------------------------------------------
+#
+#
+# This code was generated.
+#
+# - To turn off auto-generation set:
+#
+# [GitHubActions (AutoGenerate = false)]
+#
+# - To trigger manual generation invoke:
+#
+# fallout --generate-configuration GitHubActions_build --host GitHubActions
+#
+#
+# ------------------------------------------------------------------------------
+
+name: build
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v4
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Run: Test, Pack'
+ run: ./build.cmd Test Pack
diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml
deleted file mode 100644
index 8b03a9c..0000000
--- a/.github/workflows/publish-nuget.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-name: publish to nuget
-on:
- push:
- branches:
- - main # Default release branch, may also be named 'master' or 'develop'
-jobs:
- publish:
- name: build, pack & publish
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
-
- - name: Setup dotnet
- uses: actions/setup-dotnet@v1
- with:
- dotnet-version: 9.0.x
-
- # Publish
- - name: publish on version change
- id: publish_nuget
- uses: brandedoutcast/publish-nuget@v2
- with:
- # Filepath of the project to be packaged, relative to root of repository
- PROJECT_FILE_PATH: Scrutor.Extensions.HttpClient/Scrutor.Extensions.HttpClient.csproj
-
- # Configuration to build and package
- # BUILD_CONFIGURATION: Release
-
- # Platform target to compile (default is empty/AnyCPU)
- # BUILD_PLATFORM: x64
-
- # NuGet package id, used for version detection & defaults to project name
- # PACKAGE_NAME: Core
-
- # Filepath with version info, relative to root of repository & defaults to PROJECT_FILE_PATH
- # VERSION_FILE_PATH: Directory.Build.props
-
- # Regex pattern to extract version info in a capturing group
- # VERSION_REGEX: ^\s*(.*)<\/Version>\s*$
-
- # Useful with external providers like Nerdbank.GitVersioning, ignores VERSION_FILE_PATH & VERSION_REGEX
- # VERSION_STATIC: 1.0.0
-
- # Flag to toggle git tagging, enabled by default
- # TAG_COMMIT: true
-
- # Format of the git tag, [*] gets replaced with actual version
- # TAG_FORMAT: v*
-
- # API key to authenticate with NuGet server
- NUGET_KEY: ${{secrets.NUGET_API_KEY}}
-
- # NuGet server uri hosting the packages, defaults to https://api.nuget.org
- # NUGET_SOURCE: https://api.nuget.org
-
- # Flag to toggle pushing symbols along with nuget package to the server, disabled by default
- # INCLUDE_SYMBOLS: true
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..9b9c2dd
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,75 @@
+# Publish on a version tag:
+# 1. NuGet.org via Trusted Publishing (OIDC) — no stored API key.
+# 2. GitHub Packages (uses the built-in GITHUB_TOKEN).
+# 3. A GitHub Release with the .nupkg file attached and auto-generated,
+# label-categorized release notes (see .github/release.yml).
+#
+# HAND-WRITTEN, documented exception: Fallout owns .github/workflows/build.yml
+# (generated from the [GitHubActions] attribute — never hand-edit it), but the
+# publish flow (OIDC trusted publishing + GitHub Packages + Release) is not
+# expressible via the pinned Fallout 10.3.49 attribute API, so this workflow is
+# maintained by hand. See CLAUDE.md.
+#
+# One-time nuget.org setup: a Trusted Publisher policy for the package
+# (Scrutor.Extensions.HttpClient): owner Chrison-dev, repo
+# Scrutor.Extensions.HttpClient, workflow publish.yml, environment nuget.org.
+# Repo variable NUGET_USER = your nuget.org username.
+#
+# Triggered by pushing a version tag, e.g. `git tag v5.1.0 && git push origin v5.1.0`.
+name: publish
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+permissions:
+ id-token: write # OIDC trusted publishing (nuget.org)
+ contents: write # create the GitHub Release
+ packages: write # push to GitHub Packages
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ environment: nuget.org
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # GitVersion needs full history
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+
+ - name: 'Pack'
+ run: dotnet run --project build/_build.csproj -- Pack
+
+ - name: 'NuGet.org: trusted-publishing login (OIDC → short-lived key)'
+ uses: NuGet/login@v1
+ id: login
+ with:
+ user: ${{ vars.NUGET_USER }}
+
+ - name: 'NuGet.org: push'
+ run: >
+ dotnet nuget push "artifacts/packages/*.nupkg"
+ --source https://api.nuget.org/v3/index.json
+ --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
+ --skip-duplicate
+
+ - name: 'GitHub Packages: push'
+ run: >
+ dotnet nuget push "artifacts/packages/*.nupkg"
+ --source https://nuget.pkg.github.com/Chrison-dev/index.json
+ --api-key ${{ secrets.GITHUB_TOKEN }}
+ --skip-duplicate
+
+ - name: 'GitHub Release: create with packages + generated notes'
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: artifacts/packages/*.nupkg
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
index a4fe18b..2007dce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -398,3 +398,7 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+
+# Fallout build system
+.fallout/temp/
+artifacts/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..159c049
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,99 @@
+# CLAUDE.md
+
+Guidance for Claude Code when working in this repository.
+
+## What this is
+
+`Scrutor.Extensions.HttpClient` is a tiny (single public class) companion library for
+[Scrutor](https://github.com/khellang/Scrutor). It adds `.AsHttpClient()` /
+`.AsHttpClient(name)` registration selectors to Scrutor's assembly-scan pipeline so that
+each scanned class is registered as a **typed `HttpClient`** (via `IHttpClientFactory` /
+`AddHttpClient()`) instead of a plain service.
+
+It exists to solve [Scrutor issue #180](https://github.com/khellang/Scrutor/issues/180),
+which the Scrutor maintainer declined to ship natively (it would force a
+`Microsoft.Extensions.Http` dependency and needs `MakeGenericMethod` reflection). As of
+Scrutor 7.0.0 / .NET 10 nothing upstream fills this gap, so the library still earns its keep.
+
+```csharp
+services.AddHttpClient("MyClient", c => c.BaseAddress = new Uri("https://api.example.com"));
+services.Scan(scan => scan
+ .FromAssemblyOf()
+ .AddClasses(c => c.AssignableTo())
+ .AsMatchingInterface()
+ .AsHttpClient("MyClient")); // ← all scanned clients become typed clients of "MyClient"
+```
+
+## Build & CI — Fallout owns the workflows (never hand-edit YAML)
+
+CI is defined in C# via [Fallout](https://www.nuget.org/packages/Fallout.GlobalTool/) (a NUKE
+fork), not hand-written pipelines. The build lives in `build/Build.cs`.
+
+- `.github/workflows/build.yml` is **GENERATED** from the `[GitHubActions]` attribute on the
+ `Build` class. **Never hand-edit it.** Regenerate by running any build (`./build.cmd`) or:
+ `dotnet fallout --generate-configuration GitHubActions_build --host GitHubActions`.
+- Run the build locally with the polyglot bootstrapper (bat/sh in one file — works on the
+ Linux CI runner and locally):
+ ```bash
+ ./build.cmd Test # run the *.Specs suite
+ ./build.cmd Pack # Test + pack the NuGet package into artifacts/packages
+ ./build.cmd # default target (Pack)
+ ```
+- Fallout tooling notes (learned the hard way on the TVDB sibling repo): the tool package
+ `Fallout.GlobalTool` is singular and **lags the libraries at 10.3.49** — keep `Fallout.Common`
+ / `Fallout.Components` pinned to `10.3.49` to match. `.fallout/parameters.json` marks the repo
+ root; `.fallout/build.schema.json` is generated.
+
+### Publishing — hand-written `publish.yml` (documented exception)
+
+`.github/workflows/publish.yml` is the **one hand-maintained workflow**. The publish flow
+(OIDC trusted publishing + GitHub Packages + GitHub Release) is not expressible via the pinned
+Fallout 10.3.49 attribute API, so it is written by hand — this is deliberate, not an oversight.
+
+Publishing is **tag-driven**. Pushing a `v*` tag:
+1. Packs via the Fallout `Pack` target.
+2. Pushes to **nuget.org via Trusted Publishing (OIDC)** — no stored API key.
+3. Pushes to **GitHub Packages** (`nuget.pkg.github.com/Chrison-dev`) with `GITHUB_TOKEN`.
+4. Creates a **GitHub Release** with the `.nupkg` attached and label-categorized notes
+ (`.github/release.yml`).
+
+One-time nuget.org setup: a Trusted Publisher policy for `Scrutor.Extensions.HttpClient`
+(owner `Chrison-dev`, repo `Scrutor.Extensions.HttpClient`, workflow `publish.yml`, environment
+`nuget.org`) + repo variable `NUGET_USER`.
+
+## Versioning — GitVersion, tag-driven
+
+`GitVersion.yml` drives the version from `v*` tags. **Convention: MAJOR tracks the compatible
+Scrutor major** — currently **7.x ↔ Scrutor 7.x** (the old 5.x line was Scrutor 5.x; 6.x was
+skipped/never adopted). MINOR.PATCH is our own internal release counter. Cut a release by
+tagging, e.g. `git tag v7.0.0 && git push origin v7.0.0`. Untagged builds fall back to the
+static version in `Directory.Build.props`.
+
+> **Note:** GitVersion only honours a version tag on `main` (feature branches get branch-name
+> prerelease labels). Cut releases/prereleases by tagging `main` after the PR merges.
+
+## Tests — spec-style, no Central Package Management
+
+Tests live in `tests/*.Specs` and follow the Fallout/TVDB convention:
+
+- The test stack (xUnit + FluentAssertions + Verify + PublicApiGenerator) is injected via
+ `tests/Directory.Build.props` with **per-package versions — do NOT introduce Central Package
+ Management** (deliberate preference).
+- `PublicApiSpecs` snapshots the public surface with PublicApiGenerator + Verify. The surface is
+ in the `Microsoft.Extensions.DependencyInjection` namespace, so it must be allow-listed past
+ PublicApiGenerator's default `System`/`Microsoft` deny-list (`AllowNamespacePrefixes`). Accept
+ intentional API changes by updating the `*.verified.txt` baseline.
+- Functional specs exercise the real DI container + a stub `HttpMessageHandler` (no Mockly /
+ NetArchTest here — nothing to mock or architect in a single-class library).
+
+## Library conventions
+
+- Targets **net8.0** (Scrutor 7.x supports net8.0+). Multi-targeting is a separate modernization
+ concern, tracked apart from routine changes.
+- Scrutor pinned `[7.0.0, 8.0.0)` (aligned with the package major) and `Microsoft.Extensions.Http`
+ `[9.0.0, 11.0.0)`. The 5→7 bump was clean — the `.AsHttpClient()` bridge sits on Scrutor's
+ stable `RegistrationStrategy` seam, so no code/test changes were needed. Root
+ `Directory.Build.props` centralizes TFM + GitVersion; the library csproj only carries metadata.
+- Still-open modernization (deliberately deferred, discuss before doing): multi-target TFMs, and a
+ source-generator rewrite to replace the `MakeGenericMethod` reflection (the one axis that isn't
+ trim/Native-AOT friendly).
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..79b23b8
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,30 @@
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+ $([System.DateTime]::Now.ToString("yyyy"))
+
+
+
+
+ true
+
+ 7.0.0
+ 7.0.0.0
+ 7.0.0.0
+ 7.0.0
+
+ $(GitVersion_FullSemVer)
+ $(GitVersion_AssemblySemVer)
+ $(GitVersion_AssemblySemVer)
+ $(GitVersion_InformationalVersion)
+
+
diff --git a/GitVersion.yml b/GitVersion.yml
new file mode 100644
index 0000000..f87589e
--- /dev/null
+++ b/GitVersion.yml
@@ -0,0 +1,36 @@
+mode: ContinuousDelivery
+
+# Versioning scheme: the MAJOR tracks the major version of Scrutor this package is
+# compatible with (currently v7.x → Scrutor 7.x; the old 5.x line was Scrutor 5.x —
+# 6.x was skipped, never adopted). MINOR.PATCH is our own internal release counter.
+# Releases are cut by tagging `v..` (e.g. `git tag v7.0.0`) —
+# GitVersion reads the tag (tag-prefix 'v'). Untagged builds get a prerelease suffix.
+
+branches:
+ main:
+ mode: ContinuousDelivery
+ increment: None
+ develop:
+ mode: ContinuousDeployment
+ label: 'alpha'
+ increment: None
+ release:
+ mode: ContinuousDelivery
+ label: 'beta'
+ increment: None
+ feature:
+ mode: ContinuousDeployment
+ label: 'feature'
+ increment: None
+ hotfix:
+ mode: ContinuousDeployment
+ label: 'hotfix'
+ increment: None
+
+assembly-versioning-scheme: MajorMinorPatch
+assembly-file-versioning-scheme: MajorMinorPatchTag
+
+tag-prefix: 'v'
+
+# Keep major.minor.patch static by not incrementing any version component;
+# the tag drives the release version, prerelease labels provide uniqueness.
diff --git a/Scrutor.Extensions.HttpClient.sln b/Scrutor.Extensions.HttpClient.sln
index e1f6b23..7921ca8 100644
--- a/Scrutor.Extensions.HttpClient.sln
+++ b/Scrutor.Extensions.HttpClient.sln
@@ -13,20 +13,68 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scrutor.Extensions.HttpClient", "Scrutor.Extensions.HttpClient\Scrutor.Extensions.HttpClient.csproj", "{9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DEE5DD87-39C1-BF34-B639-A387DCCF972B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", "build\_build.csproj", "{517BC9BD-296E-4F6B-AE15-6999EC71A74A}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scrutor.Extensions.HttpClient.Specs", "tests\Scrutor.Extensions.HttpClient.Specs\Scrutor.Extensions.HttpClient.Specs.csproj", "{5366CD2A-4383-45F5-BDEE-2F5D4A07A280}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|x64.Build.0 = Debug|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Debug|x86.Build.0 = Debug|Any CPU
{9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|x64.ActiveCfg = Release|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|x64.Build.0 = Release|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|x86.ActiveCfg = Release|Any CPU
+ {9BAB1F5C-57D9-CE14-2C82-0CFEB033CBE6}.Release|x86.Build.0 = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|x64.Build.0 = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Debug|x86.Build.0 = Debug|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|x64.ActiveCfg = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|x64.Build.0 = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|x86.ActiveCfg = Release|Any CPU
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A}.Release|x86.Build.0 = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|x64.Build.0 = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Debug|x86.Build.0 = Debug|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|x64.ActiveCfg = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|x64.Build.0 = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|x86.ActiveCfg = Release|Any CPU
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {517BC9BD-296E-4F6B-AE15-6999EC71A74A} = {DEE5DD87-39C1-BF34-B639-A387DCCF972B}
+ {5366CD2A-4383-45F5-BDEE-2F5D4A07A280} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D2B49AAE-7B4F-4F57-8FF4-0759FE6E73BA}
EndGlobalSection
diff --git a/Scrutor.Extensions.HttpClient/Scrutor.Extensions.HttpClient.csproj b/Scrutor.Extensions.HttpClient/Scrutor.Extensions.HttpClient.csproj
index c99a36b..25aad83 100644
--- a/Scrutor.Extensions.HttpClient/Scrutor.Extensions.HttpClient.csproj
+++ b/Scrutor.Extensions.HttpClient/Scrutor.Extensions.HttpClient.csproj
@@ -1,29 +1,17 @@
-
-
-
- net8.0
- enable
- enable
-
+
- True
Scrutor HttpClient Extension
Chrison Simtian
- $([System.DateTime]::Now.ToString(yyyy)) - $(Company)
+ $(CurrentYear) - $(Company)
Allows you to register HttpClients via Scrutor
- https://github.com/ChrisonSimtian/Scrutor.Extensions.HttpClient
- https://github.com/ChrisonSimtian/Scrutor.Extensions.HttpClient.git
+ https://github.com/Chrison-dev/Scrutor.Extensions.HttpClient
+ https://github.com/Chrison-dev/Scrutor.Extensions.HttpClient.git
git
LICENSE
True
False
README.md
-
-
-
-
- 5.1.2-alpha.3
True
@@ -39,11 +27,14 @@
-
-
+
-
-
+
+
+
+
+
+
diff --git a/build.cmd b/build.cmd
new file mode 100755
index 0000000..61a428a
--- /dev/null
+++ b/build.cmd
@@ -0,0 +1,6 @@
+:; set -eo pipefail
+:; SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
+:; exec "$SCRIPT_DIR/build.sh" "$@"
+
+@echo off
+powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %*
diff --git a/build.ps1 b/build.ps1
new file mode 100755
index 0000000..5fd597f
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,11 @@
+#!/usr/bin/env pwsh
+# Fallout build bootstrapper. Runs the build project directly (no global tool needed).
+# ./build.ps1 Test # run the spec suite
+# ./build.ps1 Pack # test + pack the NuGet package
+# ./build.ps1 # default target (Pack)
+[CmdletBinding()]
+Param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $BuildArguments)
+
+$ErrorActionPreference = 'Stop'
+dotnet run --project "$PSScriptRoot/build/_build.csproj" -- @BuildArguments
+exit $LASTEXITCODE
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..2c9a9b9
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -eo pipefail
+dotnet run --project "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/_build.csproj" -- "$@"
diff --git a/build/Build.cs b/build/Build.cs
new file mode 100644
index 0000000..dbde913
--- /dev/null
+++ b/build/Build.cs
@@ -0,0 +1,52 @@
+using Fallout.Common;
+using Fallout.Common.CI.GitHubActions;
+using Fallout.Common.IO;
+using Fallout.Common.Tools.DotNet;
+using static Fallout.Common.Tools.DotNet.DotNetTasks;
+
+///
+/// Fallout build for Scrutor.Extensions.HttpClient.
+///
+/// CI (build.yml, auto-generated from the [GitHubActions] attribute) runs Test + Pack
+/// on pushes/PRs to main. Publishing is a separate, hand-written workflow (publish.yml,
+/// a documented exception) driven by version tags — see CLAUDE.md.
+///
+// Workflows are GENERATED from this attribute (see CLAUDE.md: never hand-edit
+// .github/workflows/*.yml). Regenerate with `./build.cmd` or:
+// dotnet fallout --generate-configuration GitHubActions_build --host GitHubActions
+[GitHubActions(
+ "build",
+ GitHubActionsImage.UbuntuLatest,
+ FetchDepth = 0,
+ OnPushBranches = new[] { "main" },
+ OnPullRequestBranches = new[] { "main" },
+ InvokedTargets = new[] { nameof(Test), nameof(Pack) })]
+partial class Build : FalloutBuild
+{
+ public static int Main() => Execute(x => x.Pack);
+
+ const string PackageProject = "Scrutor.Extensions.HttpClient";
+
+ AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts";
+ AbsolutePath PackagesDirectory => ArtifactsDirectory / "packages";
+ AbsolutePath SpecsProject => RootDirectory / "tests" / "Scrutor.Extensions.HttpClient.Specs" / "Scrutor.Extensions.HttpClient.Specs.csproj";
+ AbsolutePath PackableProject => RootDirectory / PackageProject / $"{PackageProject}.csproj";
+
+ Target Test => _ => _
+ .Description("Run the *.Specs test suite")
+ .Executes(() => DotNetTest(_ => _
+ .SetProjectFile(SpecsProject)
+ .SetConfiguration("Release")));
+
+ Target Pack => _ => _
+ .Description("Pack the NuGet package into artifacts/packages")
+ .DependsOn(Test)
+ .Executes(() =>
+ {
+ PackagesDirectory.CreateOrCleanDirectory();
+ DotNetPack(_ => _
+ .SetProject(PackableProject)
+ .SetConfiguration("Release")
+ .SetOutputDirectory(PackagesDirectory));
+ });
+}
diff --git a/build/_build.csproj b/build/_build.csproj
new file mode 100644
index 0000000..2bd51d3
--- /dev/null
+++ b/build/_build.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ false
+ _build
+ $(MSBuildProjectDirectory)/..
+ CS0649;CS0169;CA1050;CS8618
+
+
+
+
+
+
+
+
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..1e7fdfa
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestMinor"
+ }
+}
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
new file mode 100644
index 0000000..d5f6b41
--- /dev/null
+++ b/tests/Directory.Build.props
@@ -0,0 +1,30 @@
+
+
+
+
+
+ net10.0
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
+
diff --git a/tests/Scrutor.Extensions.HttpClient.Specs/AsHttpClientSpecs.cs b/tests/Scrutor.Extensions.HttpClient.Specs/AsHttpClientSpecs.cs
new file mode 100644
index 0000000..c95edb8
--- /dev/null
+++ b/tests/Scrutor.Extensions.HttpClient.Specs/AsHttpClientSpecs.cs
@@ -0,0 +1,112 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Scrutor.Extensions.HttpClient.Specs;
+
+///
+/// End-to-end specs for the .AsHttpClient() / .AsHttpClient(name) Scrutor
+/// registration selectors: they must register every scanned class as a typed
+/// HttpClient (via ), not a plain service — bridging
+/// Scrutor's assembly scanning with Microsoft.Extensions.Http.
+///
+public class AsHttpClientSpecs
+{
+ private const string ClientName = "test-client";
+
+ private static IServiceCollection ScanClientsAsNamed(IServiceCollection services) =>
+ services.Scan(scan => scan
+ .FromAssemblyOf()
+ .AddClasses(classes => classes.AssignableTo())
+ .AsMatchingInterface()
+ .AsHttpClient(ClientName));
+
+ [Fact]
+ public void AsHttpClient_named_registers_every_scanned_client_against_its_matching_interface()
+ {
+ var services = new ServiceCollection();
+ services.AddHttpClient(ClientName, c => c.BaseAddress = new Uri("https://api.example.com/"));
+
+ ScanClientsAsNamed(services);
+ var provider = services.BuildServiceProvider();
+
+ provider.GetService().Should().BeOfType();
+ provider.GetService().Should().BeOfType();
+ }
+
+ [Fact]
+ public void AsHttpClient_named_wires_up_the_http_client_factory()
+ {
+ var services = new ServiceCollection();
+ services.AddHttpClient(ClientName, c => c.BaseAddress = new Uri("https://api.example.com/"));
+
+ ScanClientsAsNamed(services);
+ var provider = services.BuildServiceProvider();
+
+ provider.GetService().Should().NotBeNull(
+ "registering a typed client must pull in IHttpClientFactory");
+ }
+
+ [Fact]
+ public void Named_client_configuration_flows_into_the_injected_http_client()
+ {
+ var baseAddress = new Uri("https://api.example.com/");
+ var services = new ServiceCollection();
+ services.AddHttpClient(ClientName, c => c.BaseAddress = baseAddress);
+
+ ScanClientsAsNamed(services);
+ var provider = services.BuildServiceProvider();
+
+ provider.GetRequiredService().Http.BaseAddress.Should().Be(baseAddress);
+ provider.GetRequiredService().Http.BaseAddress.Should().Be(baseAddress);
+ }
+
+ [Fact]
+ public void Typed_clients_are_registered_with_transient_lifetime()
+ {
+ var services = new ServiceCollection();
+ services.AddHttpClient(ClientName, c => c.BaseAddress = new Uri("https://api.example.com/"));
+
+ ScanClientsAsNamed(services);
+ var provider = services.BuildServiceProvider();
+
+ provider.GetRequiredService()
+ .Should().NotBeSameAs(provider.GetRequiredService(),
+ "typed HttpClients are registered transient");
+ }
+
+ [Fact]
+ public async Task Injected_client_flows_through_the_named_clients_handler_pipeline()
+ {
+ var services = new ServiceCollection();
+ services
+ .AddHttpClient(ClientName, c => c.BaseAddress = new Uri("http://stub/"))
+ .AddHttpMessageHandler(() => new StubHandler("pong"));
+
+ ScanClientsAsNamed(services);
+ var provider = services.BuildServiceProvider();
+
+ var weather = provider.GetRequiredService();
+
+ (await weather.PingAsync()).Should().Be("pong",
+ "the scanned client must be a genuine factory client whose configured handlers run");
+ }
+
+ [Fact]
+ public void AsHttpClient_without_a_name_registers_scanned_clients_as_typed_clients()
+ {
+ var services = new ServiceCollection();
+ services.Scan(scan => scan
+ .FromAssemblyOf()
+ .AddClasses(classes => classes.AssignableTo())
+ .AsMatchingInterface()
+ .AsHttpClient());
+
+ var provider = services.BuildServiceProvider();
+
+ provider.GetService().Should().BeOfType();
+ provider.GetService().Should().BeOfType();
+ provider.GetService().Should().NotBeNull();
+ provider.GetRequiredService().Http.Should().NotBeNull();
+ }
+}
diff --git a/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.Public_api_surface_has_not_changed.verified.txt b/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.Public_api_surface_has_not_changed.verified.txt
new file mode 100644
index 0000000..60496c9
--- /dev/null
+++ b/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.Public_api_surface_has_not_changed.verified.txt
@@ -0,0 +1,11 @@
+[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/Chrison-dev/Scrutor.Extensions.HttpClient.git")]
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Scrutor.Extensions.HttpClient.Specs")]
+[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")]
+namespace Microsoft.Extensions.DependencyInjection
+{
+ public static class HttpClientExtensions
+ {
+ public static Scrutor.IServiceTypeSelector AsHttpClient(this Scrutor.IServiceTypeSelector selector) { }
+ public static Scrutor.IServiceTypeSelector AsHttpClient(this Scrutor.IServiceTypeSelector selector, string name = "") { }
+ }
+}
\ No newline at end of file
diff --git a/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.cs b/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.cs
new file mode 100644
index 0000000..2ee714e
--- /dev/null
+++ b/tests/Scrutor.Extensions.HttpClient.Specs/PublicApiSpecs.cs
@@ -0,0 +1,29 @@
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using PublicApiGenerator;
+using VerifyXunit;
+using Xunit;
+
+namespace Scrutor.Extensions.HttpClient.Specs;
+
+///
+/// Roslyn-based public-API baseline (PublicApiGenerator + Verify), mirroring the
+/// approach used in Fallout/TVDB. Any unintended change to the shipped public
+/// surface fails this spec; intended changes are accepted by updating the
+/// *.verified.txt snapshot.
+///
+public class PublicApiSpecs
+{
+ [Fact]
+ public Task Public_api_surface_has_not_changed()
+ {
+ // The public surface lives in the Microsoft.Extensions.DependencyInjection
+ // namespace (by design — it extends Scrutor's selector). PublicApiGenerator
+ // denies "Microsoft"/"System" prefixes by default, so allow-list ours back in.
+ var publicApi = typeof(HttpClientExtensions).Assembly.GeneratePublicApi(new ApiGeneratorOptions
+ {
+ AllowNamespacePrefixes = new[] { "Microsoft.Extensions.DependencyInjection" },
+ });
+ return Verifier.Verify(publicApi);
+ }
+}
diff --git a/tests/Scrutor.Extensions.HttpClient.Specs/Scrutor.Extensions.HttpClient.Specs.csproj b/tests/Scrutor.Extensions.HttpClient.Specs/Scrutor.Extensions.HttpClient.Specs.csproj
new file mode 100644
index 0000000..b996cdb
--- /dev/null
+++ b/tests/Scrutor.Extensions.HttpClient.Specs/Scrutor.Extensions.HttpClient.Specs.csproj
@@ -0,0 +1,13 @@
+
+
+
+ enable
+ enable
+ CS1591
+
+
+
+
+
+
+
diff --git a/tests/Scrutor.Extensions.HttpClient.Specs/TestClients.cs b/tests/Scrutor.Extensions.HttpClient.Specs/TestClients.cs
new file mode 100644
index 0000000..8b971fd
--- /dev/null
+++ b/tests/Scrutor.Extensions.HttpClient.Specs/TestClients.cs
@@ -0,0 +1,54 @@
+using System.Net;
+using System.Text;
+
+namespace Scrutor.Extensions.HttpClient.Specs;
+
+///
+/// Fixtures for the registration specs: a common marker interface plus two API
+/// clients, each with its own matching interface (so AsMatchingInterface()
+/// maps WeatherClient → IWeatherClient, etc.) and an injected
+/// .
+///
+public interface ITestApiClient;
+
+public interface IWeatherClient : ITestApiClient
+{
+ System.Net.Http.HttpClient Http { get; }
+
+ Task PingAsync(CancellationToken ct = default);
+}
+
+public sealed class WeatherClient(System.Net.Http.HttpClient http) : IWeatherClient
+{
+ public System.Net.Http.HttpClient Http => http;
+
+ public async Task PingAsync(CancellationToken ct = default)
+ {
+ var response = await http.GetAsync("ping", ct);
+ return await response.Content.ReadAsStringAsync(ct);
+ }
+}
+
+public interface IStockClient : ITestApiClient
+{
+ System.Net.Http.HttpClient Http { get; }
+}
+
+public sealed class StockClient(System.Net.Http.HttpClient http) : IStockClient
+{
+ public System.Net.Http.HttpClient Http => http;
+}
+
+///
+/// Terminating that returns a canned body without an
+/// inner handler — used to prove the injected client flows through the named client's
+/// configured message-handler pipeline (i.e. it really is an IHttpClientFactory client).
+///
+public sealed class StubHandler(string body) : DelegatingHandler
+{
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "text/plain"),
+ });
+}