From 3b0518a2a75203e008b5e2364a0b587692dd8f01 Mon Sep 17 00:00:00 2001 From: thelocalsim Date: Fri, 28 Aug 2026 09:42:42 +0530 Subject: [PATCH 01/67] Only set AutomaticDecompression when the platform supports it Constructing an EsiClient on Blazor WebAssembly threw "One or more errors occurred. (Operation is not supported on this platform.)" before any request was made. The default handler set HttpClientHandler.AutomaticDecompression unconditionally. On the browser runtime that property is annotated [UnsupportedOSPlatform("browser")] and the underlying BrowserHttpHandler throws PlatformNotSupportedException from both the getter and the setter, because the fetch API performs content decoding itself and exposes no way to configure it. Guard the assignment with HttpClientHandler.SupportsAutomaticDecompression, which the browser handler defines as a compile-time constant false and which returns true on every other supported platform. The property is a plain bool getter that never throws, and it is present in every target framework this project builds for (netstandard2.0, net462 through net48, netcoreapp3.1, net6.0 and net7.0), so no additional conditional compilation is needed. Behaviour is unchanged everywhere decompression is supported: the existing #if NET split between DecompressionMethods.All and GZip|Deflate is preserved. On Blazor WebAssembly the client now constructs successfully and the browser handles gzip/deflate/brotli transparently. The handler construction moves into a small private factory so that a handler is still only allocated when no HttpClient is supplied by the caller. Fixes #77 Co-Authored-By: Claude Opus 5 (1M context) --- ESI.NET/EsiClient.cs | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/ESI.NET/EsiClient.cs b/ESI.NET/EsiClient.cs index 5b00fe4..aa7f45a 100644 --- a/ESI.NET/EsiClient.cs +++ b/ESI.NET/EsiClient.cs @@ -21,17 +21,7 @@ public class EsiClient : IEsiClient public EsiClient(IOptions _config, HttpClient _client = null) { config = _config.Value; - client = _client ?? new HttpClient(new HttpClientHandler - { - - -// Switch to All which adds brotli encoding for .net core due to https://github.com/ccpgames/sso-issues/issues/81 -#if NET - AutomaticDecompression = DecompressionMethods.All -#else - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate -#endif - }); + client = _client ?? new HttpClient(CreateDefaultHandler()); // Enforce user agent value if (string.IsNullOrEmpty(config.UserAgent)) @@ -143,6 +133,32 @@ public void SetCharacterData(AuthorizedCharacterData data) public void SetIfNoneMatchHeader(string eTag) => EsiRequest.ETag = eTag; + + /// + /// Creates the used when no is supplied. + /// + /// + /// Automatic decompression is only configured when the handler reports support for it. + /// On Blazor WebAssembly the underlying browser handler throws + /// from the setter, because the browser's fetch + /// API performs content decoding itself. See https://github.com/seraphx2/ESI.NET/issues/77. + /// + private static HttpClientHandler CreateDefaultHandler() + { + var handler = new HttpClientHandler(); + + if (handler.SupportsAutomaticDecompression) + { + // Switch to All which adds brotli encoding for .net core due to https://github.com/ccpgames/sso-issues/issues/81 +#if NET + handler.AutomaticDecompression = DecompressionMethods.All; +#else + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; +#endif + } + + return handler; + } } public interface IEsiClient From e1f36d1017ae2878446def6cb303c32d756d2343 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Wed, 9 Sep 2026 11:37:48 -0400 Subject: [PATCH 02/67] ci: CalVer release automation + dev/master branch flow Mirrors the versioning/CI system from the dev-prompt project, adapted for a NuGet library. Branch model: - dev is the long-lived integration branch; everything targets it. - master is the release branch. A dev->master merge cuts exactly one release. Versioning (scripts/compute-version.sh): - CalVer YYYY.(MM*100+DD).BUILD, e.g. 2026.909.1. - Computed at release time from today's date + existing git tags (build = highest same-day tag + 1, else 1). Nothing is hand-incremented. - Tags are un-prefixed to match this repo's existing history; the version is passed to `dotnet pack -p:Version=` so no file is mutated or committed. - csproj is now a 0.0.0-dev placeholder. Workflows: - ci.yml PR gate on master + dev; aggregating `check` job for branch protection. Replaces build-check.yml. - ci-dev.yml per-commit build on push to dev. - release.yml push to master (or manual dispatch) -> compute version, pack, push to NuGet, GitHub Release (creates the tag), Discord. Skips on [skip release] / doc- and CI-only changes. A manual draft run packs without pushing to NuGet. Replaces deploy.yml. - .github/actions/ci-dotnet shared restore + all-TFM Release build. dependabot.yml: grouped weekly nuget + github-actions updates targeting dev; semver-majors ignored (taken by hand). Removed GetBuildVersion.psm1 (dead Azure-DevOps-era snippet) and the SAK source-control junk PropertyGroup. .gitattributes keeps *.sh / *.yml LF-only for the Linux runners. Co-Authored-By: Claude Sonnet 5 --- .gitattributes | 8 +++ .github/actions/ci-dotnet/action.yml | 22 +++++++ .github/dependabot.yml | 36 ++++++++++ .github/workflows/build-check.yml | 29 -------- .github/workflows/ci-dev.yml | 26 ++++++++ .github/workflows/ci.yml | 29 ++++++++ .github/workflows/deploy.yml | 96 --------------------------- .github/workflows/release.yml | 98 ++++++++++++++++++++++++++++ ESI.NET/ESI.NET.csproj | 17 +---- ESI.NET/GetBuildVersion.psm1 | 34 ---------- scripts/compute-version.sh | 37 +++++++++++ 11 files changed, 259 insertions(+), 173 deletions(-) create mode 100644 .github/actions/ci-dotnet/action.yml create mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/build-check.yml create mode 100644 .github/workflows/ci-dev.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/release.yml delete mode 100644 ESI.NET/GetBuildVersion.psm1 create mode 100755 scripts/compute-version.sh diff --git a/.gitattributes b/.gitattributes index 1ff0c42..832757f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -61,3 +61,11 @@ #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain + +############################################################################### +# Keep shell scripts and CI YAML LF-only for the Linux CI runners, regardless +# of the checkout platform's autocrlf setting. +############################################################################### +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/actions/ci-dotnet/action.yml b/.github/actions/ci-dotnet/action.yml new file mode 100644 index 0000000..b226c8a --- /dev/null +++ b/.github/actions/ci-dotnet/action.yml @@ -0,0 +1,22 @@ +name: CI — .NET +description: > + Restore and Release-build ESI.NET across every target framework. Runs on + Linux; the net46x/net47x/net48 targets build against the .NET Framework + reference assemblies the SDK restores, no Mono needed. Assumes the repo is + already checked out. + +runs: + using: composite + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + shell: bash + run: dotnet restore ESI.NET.sln + + - name: Build (Release, all target frameworks) + shell: bash + run: dotnet build ESI.NET.sln --configuration Release --no-restore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ad56938 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +# Dependabot version updates. Each ecosystem is grouped, so a run opens at most +# one PR per ecosystem (plus stragglers that can't be grouped). PRs target `dev` +# — the integration branch, where CI (ci-dev.yml) runs. Security alerts are a +# separate repo setting and open PRs only for actual advisories. +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + target-branch: dev + schedule: + interval: weekly + commit-message: + prefix: ci + groups: + actions: + patterns: + - "*" + + - package-ecosystem: nuget + directory: "/ESI.NET" + target-branch: dev + schedule: + interval: weekly + commit-message: + prefix: deps + groups: + nuget: + patterns: + - "*" + # Majors (Microsoft.IdentityModel 7.x/8.x, Microsoft.Extensions.* 9.x, …) + # tend to need code changes — take those by hand; grouped minor/patch keep + # the package graph fresh in the meantime. + ignore: + - dependency-name: "*" + update-types: + - version-update:semver-major diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml deleted file mode 100644 index a8bd659..0000000 --- a/.github/workflows/build-check.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: PR Build Check - -on: - pull_request: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - env: - BUILD_PLATFORM: 'Any CPU' - BUILD_CONFIGURATION: 'Release' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '7.0.x' - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: | - dotnet build "**/*.sln" --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml new file mode 100644 index 0000000..466e28e --- /dev/null +++ b/.github/workflows/ci-dev.yml @@ -0,0 +1,26 @@ +name: CI (dev) + +# Per-commit coverage on the long-lived dev branch, so a break is pinned to the +# commit that caused it instead of surfacing across a whole delta at PR time. +# Separate workflow (not the `check` context) so it never touches the dev->master +# PR gate. Skips doc-only pushes. +on: + push: + branches: [dev] + paths-ignore: + - 'docs/**' + - '**.md' + +concurrency: + group: ci-dev-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci-dotnet diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e5c054 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +# PR gate for both branches: dev->master (the release gate) and feature->dev +# (contributor PRs). The `check` job aggregates the build and is the required +# status check on master (branch protection) — keep its name stable. +on: + pull_request: + branches: [master, dev] + +# Newer commits on the PR branch supersede an in-flight run. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci-dotnet + + check: + needs: [build] + runs-on: ubuntu-latest + steps: + - run: echo "build passed" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 9708669..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Build and Release ESI.NET - -on: - push: - tags: - - '*.*.*' - workflow_dispatch: - inputs: - manual_tag: - description: 'Version tag (example 2025.1.1 (..)' - required: true - -jobs: - build: - runs-on: ubuntu-latest - env: - BUILD_PLATFORM: 'Any CPU' - BUILD_CONFIGURATION: 'Release' - - outputs: - version: ${{ steps.get_version.outputs.version }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '7.0.x' - - - name: Extract version from tag or input - id: get_version - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "version=${{ github.event.inputs.manual_tag }}" >> $GITHUB_OUTPUT - else - echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - fi - - - name: Restore dependencies - run: dotnet restore - - - name: Build solution - run: | - dotnet build ESI.NET.sln --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - - - name: Create Package - shell: bash - run: | - dotnet pack ESI.NET/ESI.NET.csproj \ - --configuration ${{ env.BUILD_CONFIGURATION }} \ - --output ./nupkgs \ - -p:PackageVersion=${{ steps.get_version.outputs.version }} - - - name: Upload package artifact - uses: actions/upload-artifact@v4 - with: - name: nuget-package - path: ./nupkgs/*.nupkg - - deploy: - needs: build - runs-on: ubuntu-latest - - steps: - - name: Download package artifact - uses: actions/download-artifact@v4 - with: - name: nuget-package - - - name: Push to NuGet.org - run: | - dotnet nuget push "nupkgs/*.nupkg" \ - --api-key ${{ secrets.NUGET_API_KEY }} \ - --source https://api.nuget.org/v3/index.json - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.event.inputs.manual_tag || github.ref_name }} - name: ESI.NET ${{ needs.build.outputs.version }} - body: | - Release for version ${{ needs.build.outputs.version }}. - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Send Discord Webhook - if: success() - env: - DISCORD_WEBHOOK: ${{ github.event_name == 'workflow_dispatch' && secrets.DISCORD_TEST_WEBHOOK_URL || secrets.DISCORD_WEBHOOK_URL }} - uses: Ilshidur/action-discord@0.3.2 - with: - args: | - **ESI.NET ${{ needs.build.outputs.version }} NuGet package released** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3c71c75 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +# Fires on every push to master — i.e. every dev->master merge — and publishes a +# release: a CalVer version from today's date + existing tags, the NuGet package +# pushed to nuget.org, and a GitHub Release (which creates the tag). Doc-only and +# CI-only merges are skipped (paths-ignore); to opt any other merge out, put +# "[skip release]" in the merge-commit message. workflow_dispatch stays for +# manual drafts / re-runs — a draft build packs and attaches the .nupkg but does +# not push to NuGet. +on: + push: + branches: [master] + paths-ignore: + - 'docs/**' + - '**.md' + - '.github/**' + workflow_dispatch: + inputs: + draft: + description: "Draft: pack + attach the .nupkg, but don't push to NuGet" + type: boolean + default: false + +# One release at a time; never cancel a run that may have already published. +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + # push: skip when the merge commit opts out. dispatch: always run. + if: >- + github.event_name == 'workflow_dispatch' || + !contains(github.event.head_commit.message, '[skip release]') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # need all tags so the CalVer build segment is correct + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Compute version + id: version + shell: bash + run: echo "value=$(bash scripts/compute-version.sh)" >> "$GITHUB_OUTPUT" + + - name: Pack + shell: bash + run: >- + dotnet pack ESI.NET/ESI.NET.csproj + --configuration Release + --output ./nupkgs + -p:Version=${{ steps.version.outputs.value }} + -p:ContinuousIntegrationBuild=true + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: ./nupkgs/*.nupkg + + - name: Push to NuGet.org + if: ${{ !inputs.draft }} + shell: bash + run: >- + dotnet nuget push "nupkgs/*.nupkg" + --api-key ${{ secrets.NUGET_API_KEY }} + --source https://api.nuget.org/v3/index.json + --skip-duplicate + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.value }} + name: ESI.NET ${{ steps.version.outputs.value }} + draft: ${{ inputs.draft || false }} + generate_release_notes: true + files: ./nupkgs/*.nupkg + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Send Discord webhook + if: ${{ success() && !inputs.draft }} + env: + DISCORD_WEBHOOK: ${{ github.event_name == 'workflow_dispatch' && secrets.DISCORD_TEST_WEBHOOK_URL || secrets.DISCORD_WEBHOOK_URL }} + uses: Ilshidur/action-discord@0.3.2 + with: + args: | + **ESI.NET ${{ steps.version.outputs.value }} NuGet package released** diff --git a/ESI.NET/ESI.NET.csproj b/ESI.NET/ESI.NET.csproj index cb1585f..b010daa 100644 --- a/ESI.NET/ESI.NET.csproj +++ b/ESI.NET/ESI.NET.csproj @@ -1,16 +1,11 @@  - - SAK - SAK - SAK - SAK - - netcoreapp3.1;netstandard2.0;net462;net47;net471;net472;net48;net6.0;net7.0 true - 2023.12.12 + + 0.0.0-dev Psianna Archeia Sukebe Corporation .NET Wrapper for the Eve Online API @@ -40,10 +35,4 @@ - - - Always - - - diff --git a/ESI.NET/GetBuildVersion.psm1 b/ESI.NET/GetBuildVersion.psm1 deleted file mode 100644 index 7c8b304..0000000 --- a/ESI.NET/GetBuildVersion.psm1 +++ /dev/null @@ -1,34 +0,0 @@ -Function GetBuildVersion { - Param ( - [string]$VersionString - ) - - # Process through regex - $VersionString -match "(?\d+)(\.(?\d+))?(\.(?\d+))?(\-(?
[0-9A-Za-z\-\.]+))?(\+(?\d+))?" | Out-Null
-
-    if ($matches -eq $null) {
-        return "1.0.0-build"
-    }
-
-    # Extract the build metadata
-    $BuildRevision = [uint64]$matches['build']
-    # Extract the pre-release tag
-    $PreReleaseTag = [string]$matches['pre']
-    # Extract the patch
-    $Patch = [uint64]$matches['patch']
-    # Extract the minor
-    $Minor = [uint64]$matches['minor']
-    # Extract the major
-    $Major = [uint64]$matches['major']
-
-    $Version = [string]$Major + '.' + [string]$Minor + '.' + [string]$Patch;
-    if ($PreReleaseTag -ne [string]::Empty) {
-        $Version = $Version + '-' + $PreReleaseTag
-    }
-
-    if ($BuildRevision -ne 0) {
-        $Version = $Version + '.' + [string]$BuildRevision
-    }
-
-    return $Version
-}
\ No newline at end of file
diff --git a/scripts/compute-version.sh b/scripts/compute-version.sh
new file mode 100755
index 0000000..1b2ece2
--- /dev/null
+++ b/scripts/compute-version.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# CalVer generator: YYYY.(MM*100+DD).BUILD
+#
+#   major = calendar year            e.g. 2026
+#   minor = month*100 + day          e.g. Sep 9 -> 909, Jan 5 -> 105
+#   build = 1 for the day's first release, then +1 per additional same-day build
+#
+# The build segment is derived from existing git tags (..*), so
+# nothing needs to be committed or hand-incremented. Prints just the version
+# string to stdout for the release workflow to consume; diagnostics go to stderr
+# so VERSION=$(scripts/compute-version.sh) stays clean.
+#
+# Tags here are un-prefixed (2026.909.1), matching this repo's existing tag
+# history and the release workflow's tag_name — dev-prompt prefixes them with a
+# leading `v`, this is the one intentional deviation.
+set -euo pipefail
+
+major=$(date -u +%Y)
+month=$(date -u +%m)
+day=$(date -u +%d)
+# 10# forces base-10 so a leading zero (08, 09) isn't read as octal.
+minor=$(( 10#$month * 100 + 10#$day ))
+
+build=1
+tags=$(git tag --list "${major}.${minor}.*" || true)
+if [ -n "$tags" ]; then
+  highest=$(printf '%s\n' "$tags" \
+    | sed -n "s/^${major}\.${minor}\.\([0-9][0-9]*\)$/\1/p" \
+    | sort -n | tail -1)
+  if [ -n "$highest" ]; then
+    build=$(( highest + 1 ))
+  fi
+fi
+
+version="${major}.${minor}.${build}"
+echo "computed version: ${version}" >&2
+printf '%s' "$version"

From 3e0877a3e418c2a4b7b146cecead690e47d0e073 Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Wed, 9 Sep 2026 11:42:57 -0400
Subject: [PATCH 03/67] ci: bump actions/checkout and actions/setup-dotnet to
 v5

Clears the Node 20 deprecation warning on the runners.

Co-Authored-By: Claude Sonnet 5 
---
 .github/actions/ci-dotnet/action.yml | 2 +-
 .github/workflows/ci-dev.yml         | 2 +-
 .github/workflows/ci.yml             | 2 +-
 .github/workflows/release.yml        | 4 ++--
 4 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/.github/actions/ci-dotnet/action.yml b/.github/actions/ci-dotnet/action.yml
index b226c8a..e730642 100644
--- a/.github/actions/ci-dotnet/action.yml
+++ b/.github/actions/ci-dotnet/action.yml
@@ -9,7 +9,7 @@ runs:
   using: composite
   steps:
     - name: Setup .NET SDK
-      uses: actions/setup-dotnet@v4
+      uses: actions/setup-dotnet@v5
       with:
         dotnet-version: '8.0.x'
 
diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml
index 466e28e..7165e9f 100644
--- a/.github/workflows/ci-dev.yml
+++ b/.github/workflows/ci-dev.yml
@@ -22,5 +22,5 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - uses: ./.github/actions/ci-dotnet
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e5c054..9445fc6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - uses: ./.github/actions/ci-dotnet
 
   check:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3c71c75..0e3f5c0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,12 +39,12 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           fetch-depth: 0 # need all tags so the CalVer build segment is correct
 
       - name: Setup .NET SDK
-        uses: actions/setup-dotnet@v4
+        uses: actions/setup-dotnet@v5
         with:
           dotnet-version: '8.0.x'
 

From 45f390f2d18d871698fada5129b02aae0c758c92 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 9 Sep 2026 15:47:14 +0000
Subject: [PATCH 04/67] ci: bump the actions group with 5 updates

Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `5` | `7` |
| [actions/setup-dotnet](https://github.com/actions/setup-dotnet) | `5` | `6` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2` | `3` |
| [Ilshidur/action-discord](https://github.com/ilshidur/action-discord) | `0.3.2` | `0.4.0` |


Updates `actions/checkout` from 5 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

Updates `actions/setup-dotnet` from 5 to 6
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `softprops/action-gh-release` from 2 to 3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

Updates `Ilshidur/action-discord` from 0.3.2 to 0.4.0
- [Release notes](https://github.com/ilshidur/action-discord/releases)
- [Commits](https://github.com/ilshidur/action-discord/compare/0.3.2...0.4.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: Ilshidur/action-discord
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] 
---
 .github/workflows/ci-dev.yml  |  2 +-
 .github/workflows/ci.yml      |  2 +-
 .github/workflows/release.yml | 10 +++++-----
 3 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml
index 7165e9f..4ea6c5f 100644
--- a/.github/workflows/ci-dev.yml
+++ b/.github/workflows/ci-dev.yml
@@ -22,5 +22,5 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v5
+      - uses: actions/checkout@v7
       - uses: ./.github/actions/ci-dotnet
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9445fc6..6012ba8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v5
+      - uses: actions/checkout@v7
       - uses: ./.github/actions/ci-dotnet
 
   check:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0e3f5c0..fdfad69 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,12 +39,12 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v5
+        uses: actions/checkout@v7
         with:
           fetch-depth: 0 # need all tags so the CalVer build segment is correct
 
       - name: Setup .NET SDK
-        uses: actions/setup-dotnet@v5
+        uses: actions/setup-dotnet@v6
         with:
           dotnet-version: '8.0.x'
 
@@ -63,7 +63,7 @@ jobs:
           -p:ContinuousIntegrationBuild=true
 
       - name: Upload package artifact
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v7
         with:
           name: nuget-package
           path: ./nupkgs/*.nupkg
@@ -78,7 +78,7 @@ jobs:
           --skip-duplicate
 
       - name: Create GitHub Release
-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@v3
         with:
           tag_name: ${{ steps.version.outputs.value }}
           name: ESI.NET ${{ steps.version.outputs.value }}
@@ -92,7 +92,7 @@ jobs:
         if: ${{ success() && !inputs.draft }}
         env:
           DISCORD_WEBHOOK: ${{ github.event_name == 'workflow_dispatch' && secrets.DISCORD_TEST_WEBHOOK_URL || secrets.DISCORD_WEBHOOK_URL }}
-        uses: Ilshidur/action-discord@0.3.2
+        uses: Ilshidur/action-discord@0.4.0
         with:
           args: |
             **ESI.NET ${{ steps.version.outputs.value }} NuGet package released**

From 48bd5921dd084e03b547dcff5f2003287df932f8 Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Wed, 9 Sep 2026 22:18:50 -0400
Subject: [PATCH 05/67] ci: publish to NuGet via Trusted Publishing (OIDC)

Drop the stored NUGET_API_KEY. The release job now requests a GitHub OIDC
token (id-token: write) and exchanges it via NuGet/login@v1 for a ~1-hour
API key, gated by a trusted-publishing policy on nuget.org bound to
seraphx2/ESI.NET -> release.yml. nuget.org account: robmburke.

Co-Authored-By: Claude Sonnet 5 
---
 .github/workflows/release.yml | 23 +++++++++++++++++------
 1 file changed, 17 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0e3f5c0..99bbe13 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,11 +2,11 @@ name: Release
 
 # Fires on every push to master — i.e. every dev->master merge — and publishes a
 # release: a CalVer version from today's date + existing tags, the NuGet package
-# pushed to nuget.org, and a GitHub Release (which creates the tag). Doc-only and
-# CI-only merges are skipped (paths-ignore); to opt any other merge out, put
-# "[skip release]" in the merge-commit message. workflow_dispatch stays for
-# manual drafts / re-runs — a draft build packs and attaches the .nupkg but does
-# not push to NuGet.
+# pushed to nuget.org via Trusted Publishing (OIDC, no stored key), and a GitHub
+# Release (which creates the tag). Doc-only and CI-only merges are skipped
+# (paths-ignore); to opt any other merge out, put "[skip release]" in the
+# merge-commit message. workflow_dispatch stays for manual drafts / re-runs — a
+# draft build packs and attaches the .nupkg but does not push to NuGet.
 on:
   push:
     branches: [master]
@@ -28,6 +28,7 @@ concurrency:
 
 permissions:
   contents: write
+  id-token: write # OIDC token for NuGet Trusted Publishing (no stored API key)
 
 jobs:
   release:
@@ -68,12 +69,22 @@ jobs:
           name: nuget-package
           path: ./nupkgs/*.nupkg
 
+      # Trusted Publishing: exchanges this job's GitHub OIDC token for a NuGet
+      # API key valid ~1 hour. Requires a trusted-publishing policy on nuget.org
+      # for seraphx2/ESI.NET -> release.yml. No secret is stored.
+      - name: NuGet login (OIDC)
+        id: nuget_login
+        if: ${{ !inputs.draft }}
+        uses: NuGet/login@v1
+        with:
+          user: robmburke
+
       - name: Push to NuGet.org
         if: ${{ !inputs.draft }}
         shell: bash
         run: >-
           dotnet nuget push "nupkgs/*.nupkg"
-          --api-key ${{ secrets.NUGET_API_KEY }}
+          --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
           --source https://api.nuget.org/v3/index.json
           --skip-duplicate
 

From f87d146e551dd05a4424fba98bb4f1030b891ddd Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Wed, 9 Sep 2026 22:54:09 -0400
Subject: [PATCH 06/67] build: modernize target frameworks and dependencies

- TFMs: drop the EOL targets (netcoreapp3.1, net6.0, net7.0) and the five
  explicit .NET Framework entries -> netstandard2.0;net8.0. netstandard2.0
  keeps .NET Framework 4.6.1+ consumers working; the library has no
  framework-specific #if, so those targets built byte-identical assemblies.
- Microsoft.IdentityModel.Tokens / System.IdentityModel.Tokens.Jwt: 6.14.1 -> 8.22.0
- Microsoft.Extensions.*: 2.0.0 -> 8.0.x (LTS floor; consumers float up)
- Newtonsoft.Json: 13.0.2 -> 13.0.4
- Drop the explicit System.Net.Http 4.3.4 reference (a known-advisory package);
  it is in-box on both targets now.
- Microsoft.CSharp / System.Collections.Immutable: netstandard2.0-only now,
  in-box in the net8.0 shared framework.
- OpportunitiesLogic: rename the `opportunities` using-alias to `Opportunities`
  (fixes CS8981, lower-cased type name).

Local: dotnet build -c Release -> both TFMs, 0 errors, 0 warnings.
dotnet pack -> clean nupkg, correct per-TFM dependency groups, no System.Net.Http.

Co-Authored-By: Claude Sonnet 5 
---
 ESI.NET/ESI.NET.csproj              | 25 ++++++++++++++-----------
 ESI.NET/Logic/OpportunitiesLogic.cs | 14 +++++++-------
 2 files changed, 21 insertions(+), 18 deletions(-)

diff --git a/ESI.NET/ESI.NET.csproj b/ESI.NET/ESI.NET.csproj
index b010daa..0d66bd8 100644
--- a/ESI.NET/ESI.NET.csproj
+++ b/ESI.NET/ESI.NET.csproj
@@ -1,7 +1,7 @@
 
 
   
-    netcoreapp3.1;netstandard2.0;net462;net47;net471;net472;net48;net6.0;net7.0
+    netstandard2.0;net8.0
     true
     
@@ -16,16 +16,19 @@
   
 
   
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+  
+    
+    
   
 
   
diff --git a/ESI.NET/Logic/OpportunitiesLogic.cs b/ESI.NET/Logic/OpportunitiesLogic.cs
index 08fcc05..19ec377 100644
--- a/ESI.NET/Logic/OpportunitiesLogic.cs
+++ b/ESI.NET/Logic/OpportunitiesLogic.cs
@@ -3,7 +3,7 @@
 using System.Net.Http;
 using System.Threading.Tasks;
 using static ESI.NET.EsiRequest;
-using opportunities = ESI.NET.Models.Opportunities;
+using Opportunities = ESI.NET.Models.Opportunities;
 
 namespace ESI.NET.Logic
 {
@@ -36,8 +36,8 @@ public async Task> Groups()
         /// 
         /// 
         /// 
-        public async Task> Group(int group_id)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/groups/{group_id}/",
+        public async Task> Group(int group_id)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/groups/{group_id}/",
                 replacements: new Dictionary()
                 {
                     { "group_id", group_id.ToString() }
@@ -55,8 +55,8 @@ public async Task> Tasks()
         /// 
         /// 
         /// 
-        public async Task> Task(int task_id)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/tasks/{task_id}/",
+        public async Task> Task(int task_id)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/tasks/{task_id}/",
                 replacements: new Dictionary()
                 {
                     { "task_id", task_id.ToString() }
@@ -67,8 +67,8 @@ public async Task> Tasks()
         /// 
         /// 
         /// 
-        public async Task>> CompletedTasks()
-            => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/opportunities/",
+        public async Task>> CompletedTasks()
+            => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/opportunities/",
                 replacements: new Dictionary()
                 {
                     { "character_id", character_id.ToString() }

From fcbec43b546546172b81f691d5740a75c1df2f8d Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Wed, 9 Sep 2026 23:09:23 -0400
Subject: [PATCH 07/67] test: add SSO token-validation regression suite
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Proves the Microsoft.IdentityModel 6.x -> 8.x bump is behaviour-neutral for
the access-token validation path, offline (no EVE credentials, no network).

- _SSOLogic: extract SsoLogic.ValidateAccessToken(token, ssoUrl, jwksJson) from
  Verify() as an internal seam. Same TokenValidationParameters, same raw claim
  reads, same field projection. Verify() keeps the JWKS fetch + affiliation
  lookup and is unchanged on the success path.
  Also: the JWKS fetch used `.GetAsync(url).Result.Content` — a blocking .Result
  inside an async method (sync-context deadlock risk) — now fully awaited; and
  the `jwtksUrl` typo is fixed.
- ESI.NET.Tests: new xUnit project (net8.0), added to the solution. 7 tests —
  valid token projects claims, claim types are read raw (not remapped, the
  likeliest 6->8 silent break), wrong issuer / wrong signing key / expired
  beyond skew are rejected, expired within the 2s skew still validates, and a
  pinned snapshot of the real login.eveonline.com/oauth/jwks parses under
  IdentityModel 8.22.0 with Keys.First() being the RS256 signing key.
- InternalsVisibleTo ESI.NET.Tests.
- CI: ci-dotnet composite runs `dotnet test`; release.yml gates on it before pack.

Local: restore -> build --no-restore -> test --no-build => 7/7 passed, 0 warnings.

Co-Authored-By: Claude Sonnet 5 
---
 .github/actions/ci-dotnet/action.yml          |  13 +-
 .github/workflows/release.yml                 |   4 +
 ESI.NET.Tests/ESI.NET.Tests.csproj            |  26 +++
 .../Fixtures/login.eveonline.com-jwks.json    |  22 +++
 ESI.NET.Tests/SsoTokenValidationTests.cs      | 150 ++++++++++++++++++
 ESI.NET.sln                                   |  26 +++
 ESI.NET/ESI.NET.csproj                        |   4 +
 ESI.NET/Logic/_SSOLogic.cs                    |  84 +++++-----
 8 files changed, 287 insertions(+), 42 deletions(-)
 create mode 100644 ESI.NET.Tests/ESI.NET.Tests.csproj
 create mode 100644 ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json
 create mode 100644 ESI.NET.Tests/SsoTokenValidationTests.cs

diff --git a/.github/actions/ci-dotnet/action.yml b/.github/actions/ci-dotnet/action.yml
index e730642..39ddd41 100644
--- a/.github/actions/ci-dotnet/action.yml
+++ b/.github/actions/ci-dotnet/action.yml
@@ -1,9 +1,8 @@
 name: CI — .NET
 description: >
-  Restore and Release-build ESI.NET across every target framework. Runs on
-  Linux; the net46x/net47x/net48 targets build against the .NET Framework
-  reference assemblies the SDK restores, no Mono needed. Assumes the repo is
-  already checked out.
+  Restore, Release-build and test the solution. The library multi-targets
+  netstandard2.0 and net8.0; netstandard2.0 covers .NET Framework 4.6.1+
+  consumers. Runs on Linux. Assumes the repo is already checked out.
 
 runs:
   using: composite
@@ -17,6 +16,10 @@ runs:
       shell: bash
       run: dotnet restore ESI.NET.sln
 
-    - name: Build (Release, all target frameworks)
+    - name: Build (Release)
       shell: bash
       run: dotnet build ESI.NET.sln --configuration Release --no-restore
+
+    - name: Test
+      shell: bash
+      run: dotnet test ESI.NET.sln --configuration Release --no-build --verbosity normal
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 99bbe13..ad86d08 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -54,6 +54,10 @@ jobs:
         shell: bash
         run: echo "value=$(bash scripts/compute-version.sh)" >> "$GITHUB_OUTPUT"
 
+      - name: Test
+        shell: bash
+        run: dotnet test ESI.NET.sln --configuration Release
+
       - name: Pack
         shell: bash
         run: >-
diff --git a/ESI.NET.Tests/ESI.NET.Tests.csproj b/ESI.NET.Tests/ESI.NET.Tests.csproj
new file mode 100644
index 0000000..64dd5fc
--- /dev/null
+++ b/ESI.NET.Tests/ESI.NET.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+  
+    net8.0
+    false
+    latest
+    disable
+  
+
+  
+    
+    
+    
+  
+
+  
+    
+  
+
+  
+    
+      PreserveNewest
+    
+  
+
+
diff --git a/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json b/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json
new file mode 100644
index 0000000..de3b638
--- /dev/null
+++ b/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json
@@ -0,0 +1,22 @@
+{
+  "keys": [
+    {
+      "alg": "RS256",
+      "e": "AQAB",
+      "kid": "JWT-Signature-Key",
+      "kty": "RSA",
+      "n": "nehPQ7FQ1YK-leKyIg-aACZaT-DbTL5V1XpXghtLX_bEC-fwxhdE_4yQKDF6cA-V4c-5kh8wMZbfYw5xxgM9DynhMkVrmQFyYB3QMZwydr922UWs3kLz-nO6vi0ldCn-ffM9odUPRHv9UbhM5bB4SZtCrpr9hWQgJ3FjzWO2KosGQ8acLxLtDQfU_lq0OGzoj_oWwUKaN_OVfu80zGTH7mxVeGMJqWXABKd52ByvYZn3wL_hG60DfDWGV_xfLlHMt_WoKZmrXT4V3BCBmbitJ6lda3oNdNeHUh486iqaL43bMR2K4TzrspGMRUYXcudUQ9TycBQBrUlT85NRY9TeOw",
+      "use": "sig"
+    },
+    {
+      "alg": "ES256",
+      "crv": "P-256",
+      "kid": "8878a23f-2489-4045-989e-4d2f3ec1ae1a",
+      "kty": "EC",
+      "use": "sig",
+      "x": "PatzB2HJzZOzmqQyYpQYqn3SAXoVYWrZKmMgJnfK94I",
+      "y": "qDb1kUd13fRTN2UNmcgSoQoyqeF_C1MsFlY_a87csnY"
+    }
+  ],
+  "SkipUnresolvedJsonWebKeys": true
+}
diff --git a/ESI.NET.Tests/SsoTokenValidationTests.cs b/ESI.NET.Tests/SsoTokenValidationTests.cs
new file mode 100644
index 0000000..3769bf5
--- /dev/null
+++ b/ESI.NET.Tests/SsoTokenValidationTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.IO;
+using System.Linq;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text.Json;
+using ESI.NET.Models.SSO;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace ESI.NET.Tests
+{
+    /// 
+    /// Regression coverage for SsoLogic.ValidateAccessToken — the JWT-validation core of
+    /// SsoLogic.Verify. The point is to prove the Microsoft.IdentityModel 6.x -> 8.x bump
+    /// did not silently change how the access token is validated or how its claims are read.
+    /// No EVE credentials and no network: tokens are signed with a throwaway RSA key, except the
+    /// last test which parses a pinned snapshot of the real login.eveonline.com JWKS.
+    /// 
+    public class SsoTokenValidationTests
+    {
+        private const string Host = "login.eveonline.com";
+        private const string Issuer = "https://login.eveonline.com";
+        private const string Kid = "JWT-Signature-Key";
+        private const int CharacterId = 2112625428;
+
+        private static readonly RSA SigningKey = RSA.Create(2048);
+
+        private static string Jwks(RSA key = null)
+        {
+            var parameters = (key ?? SigningKey).ExportParameters(false);
+            var jwk = new
+            {
+                kty = "RSA",
+                use = "sig",
+                alg = "RS256",
+                kid = Kid,
+                n = Base64UrlEncoder.Encode(parameters.Modulus),
+                e = Base64UrlEncoder.Encode(parameters.Exponent),
+            };
+            return "{\"keys\":[" + JsonSerializer.Serialize(jwk) + "]}";
+        }
+
+        private static IEnumerable DefaultClaims() => new[]
+        {
+            new Claim("sub", $"CHARACTER:EVE:{CharacterId}"),
+            new Claim("name", "CCP Zoetrope"),
+            new Claim("owner", "8PmzCeTKb4VFUDrHLc/n4VWtx1M="),
+            new Claim("scp", "esi-skills.read_skills.v1"),
+            new Claim("scp", "esi-wallet.read_character_wallet.v1"),
+        };
+
+        private static string SignToken(
+            RSA key = null,
+            string issuer = Issuer,
+            DateTime? expires = null,
+            IEnumerable claims = null)
+        {
+            var signingKey = new RsaSecurityKey(key ?? SigningKey) { KeyId = Kid };
+            var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
+            var jwt = new JwtSecurityToken(
+                issuer: issuer,
+                claims: claims ?? DefaultClaims(),
+                notBefore: DateTime.UtcNow.AddMinutes(-1),
+                expires: expires ?? DateTime.UtcNow.AddMinutes(20),
+                signingCredentials: credentials);
+            return new JwtSecurityTokenHandler().WriteToken(jwt);
+        }
+
+        private static SsoToken Token(string accessToken) =>
+            new SsoToken { AccessToken = accessToken, RefreshToken = "refresh-token-value" };
+
+        [Fact]
+        public void Valid_token_projects_identity_claims()
+        {
+            var result = SsoLogic.ValidateAccessToken(Token(SignToken()), Host, Jwks());
+
+            Assert.Equal(CharacterId, result.CharacterID);
+            Assert.Equal("CCP Zoetrope", result.CharacterName);
+            Assert.Equal("8PmzCeTKb4VFUDrHLc/n4VWtx1M=", result.CharacterOwnerHash);
+            Assert.Equal("esi-skills.read_skills.v1 esi-wallet.read_character_wallet.v1", result.Scopes);
+            Assert.Equal("refresh-token-value", result.RefreshToken);
+            Assert.True(result.ExpiresOn > DateTime.UtcNow);
+        }
+
+        [Fact]
+        public void Claim_types_are_read_raw_not_remapped()
+        {
+            // Inbound claim-type mapping (sub -> schemas.xmlsoap.org/.../nameidentifier) is the most
+            // likely silent breakage across the 6.x -> 8.x jump. Verify() reads "sub"/"name"/"owner"
+            // /"scp" verbatim off the JwtSecurityToken, so finding the character id at all proves the
+            // claim types survived unmapped.
+            var result = SsoLogic.ValidateAccessToken(Token(SignToken()), Host, Jwks());
+            Assert.Equal(CharacterId, result.CharacterID);
+            Assert.NotEqual(0, result.CharacterID);
+        }
+
+        [Fact]
+        public void Wrong_issuer_is_rejected()
+        {
+            var token = Token(SignToken(issuer: "https://login.evil.example"));
+            Assert.ThrowsAny(() => SsoLogic.ValidateAccessToken(token, Host, Jwks()));
+        }
+
+        [Fact]
+        public void Signature_from_a_different_key_is_rejected()
+        {
+            using var attacker = RSA.Create(2048);
+            var forged = Token(SignToken(key: attacker));           // signed by the attacker's key...
+            Assert.ThrowsAny(
+                () => SsoLogic.ValidateAccessToken(forged, Host, Jwks()));  // ...validated against the real JWKS
+        }
+
+        [Fact]
+        public void Expired_beyond_clock_skew_is_rejected()
+        {
+            var token = Token(SignToken(expires: DateTime.UtcNow.AddSeconds(-30)));
+            Assert.Throws(() => SsoLogic.ValidateAccessToken(token, Host, Jwks()));
+        }
+
+        [Fact]
+        public void Expired_within_clock_skew_still_validates()
+        {
+            // ValidateAccessToken allows a 2s skew for CCP's slightly-fast clocks.
+            var token = Token(SignToken(expires: DateTime.UtcNow.AddSeconds(-1)));
+            var result = SsoLogic.ValidateAccessToken(token, Host, Jwks());
+            Assert.Equal(CharacterId, result.CharacterID);
+        }
+
+        [Fact]
+        public void Pinned_real_eve_jwks_parses_under_current_IdentityModel()
+        {
+            // Snapshot of https://login.eveonline.com/oauth/jwks (public keys only). Proves the real
+            // payload shape — including CCP's non-standard "SkipUnresolvedJsonWebKeys" field and the
+            // trailing EC key — still round-trips through JsonWebKeySet, and that Keys.First() (what
+            // Verify() takes) is the RS256 signing key.
+            var json = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "login.eveonline.com-jwks.json"));
+
+            var set = new JsonWebKeySet(json);
+
+            Assert.Equal(2, set.Keys.Count);
+            var first = set.Keys.First();
+            Assert.Equal(Kid, first.Kid);
+            Assert.Equal("RSA", first.Kty);
+            Assert.Equal("AQAB", first.E);
+        }
+    }
+}
diff --git a/ESI.NET.sln b/ESI.NET.sln
index b6cec91..6ca67da 100644
--- a/ESI.NET.sln
+++ b/ESI.NET.sln
@@ -5,16 +5,42 @@ VisualStudioVersion = 15.0.27004.2010
 MinimumVisualStudioVersion = 10.0.40219.1
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ESI.NET", "ESI.NET\ESI.NET.csproj", "{64F5964F-B659-4EF2-B4ED-45C4F8857012}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ESI.NET.Tests", "ESI.NET.Tests\ESI.NET.Tests.csproj", "{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}"
+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
 		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|x64.Build.0 = Debug|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Debug|x86.Build.0 = Debug|Any CPU
 		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|Any CPU.Build.0 = Release|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x64.ActiveCfg = Release|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x64.Build.0 = Release|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x86.ActiveCfg = Release|Any CPU
+		{64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x86.Build.0 = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x64.Build.0 = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x86.Build.0 = Debug|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x64.ActiveCfg = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x64.Build.0 = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.ActiveCfg = Release|Any CPU
+		{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/ESI.NET/ESI.NET.csproj b/ESI.NET/ESI.NET.csproj
index 0d66bd8..0b0062d 100644
--- a/ESI.NET/ESI.NET.csproj
+++ b/ESI.NET/ESI.NET.csproj
@@ -38,4 +38,8 @@
     
   
 
+  
+    
+  
+
 
diff --git a/ESI.NET/Logic/_SSOLogic.cs b/ESI.NET/Logic/_SSOLogic.cs
index b5b8be2..9c9a7ea 100644
--- a/ESI.NET/Logic/_SSOLogic.cs
+++ b/ESI.NET/Logic/_SSOLogic.cs
@@ -159,6 +159,49 @@ public async Task RevokeToken(string code)
             }
         }
 
+        /// 
+        /// Validates 's access token against the SSO JWKS and projects the
+        /// identity claims (character id, name, owner hash, scopes, expiry) onto a fresh
+        /// . Throws if the token fails validation.
+        /// Split out of  so the validation path can be exercised without a
+        /// live SSO endpoint. The affiliation lookup stays in .
+        /// 
+        internal static AuthorizedCharacterData ValidateAccessToken(SsoToken token, string ssoUrl, string jwksJson)
+        {
+            var tokenHandler = new JwtSecurityTokenHandler();
+            var jwks = new JsonWebKeySet(jwksJson);
+            var jwk = jwks.Keys.First();
+
+            var tokenValidationParams = new TokenValidationParameters
+            {
+                ValidateAudience = false,
+                ValidateIssuer = true,
+                ValidIssuer = $"https://{ssoUrl}",
+                ValidateIssuerSigningKey = true,
+                IssuerSigningKey = jwk,
+                ClockSkew = TimeSpan.FromSeconds(2), // CCP's servers seem slightly ahead (~1s)
+            };
+            tokenHandler.ValidateToken(token.AccessToken, tokenValidationParams, out var validatedToken);
+
+            var jwt = (JwtSecurityToken)validatedToken;
+
+            var subjectClaim = jwt.Claims.SingleOrDefault(c => c.Type == "sub").Value;
+            var nameClaim = jwt.Claims.SingleOrDefault(c => c.Type == "name").Value;
+            var ownerClaim = jwt.Claims.SingleOrDefault(c => c.Type == "owner").Value;
+            var scopesClaim = string.Join(" ", jwt.Claims.Where(c => c.Type == "scp").Select(s => s.Value));
+
+            return new AuthorizedCharacterData
+            {
+                RefreshToken = token.RefreshToken,
+                Token = token.AccessToken,
+                CharacterName = nameClaim,
+                CharacterOwnerHash = ownerClaim,
+                CharacterID = int.Parse(subjectClaim.Split(':').Last()),
+                ExpiresOn = jwt.ValidTo,
+                Scopes = scopesClaim,
+            };
+        }
+
         /// 
         /// Verifies the Character information for the provided Token information.
         /// While this method represents the oauth/verify request, in addition to the verified data that ESI returns, this object also stores the Token and Refresh token
@@ -173,44 +216,11 @@ public async Task Verify(SsoToken token)
 
             try
             {
-                var tokenHandler = new JwtSecurityTokenHandler();
-
-                // Get the eve online JWT to validate against
-                var jwtksUrl = $"https://{_ssoUrl}/oauth/jwks";
-                var response = await _client.GetAsync(jwtksUrl).Result.Content.ReadAsStringAsync();
-                var jwks = new JsonWebKeySet(response);
-                var jwk = jwks.Keys.First();
+                // Get the Eve Online JWKS to validate the access token against
+                var jwksUrl = $"https://{_ssoUrl}/oauth/jwks";
+                var jwksJson = await (await _client.GetAsync(jwksUrl)).Content.ReadAsStringAsync();
 
-                SecurityToken validatedToken;
-
-                // Validate the token
-                TokenValidationParameters tokenValidationParams = new TokenValidationParameters
-                {
-                    ValidateAudience = false,
-                    ValidateIssuer = true,
-                    ValidIssuer = $"https://{_ssoUrl}",
-                    ValidateIssuerSigningKey = true,
-                    IssuerSigningKey = jwk,
-                    ClockSkew = TimeSpan.FromSeconds(2), // CCP's servers seem slightly ahead (~1s)
-                };
-                tokenHandler.ValidateToken(token.AccessToken, tokenValidationParams, out validatedToken);
-
-                JwtSecurityToken jwtValidatedToken = validatedToken as JwtSecurityToken;
-
-                var subjectClaim = jwtValidatedToken.Claims.SingleOrDefault(c => c.Type == "sub").Value;
-                var nameClaim = jwtValidatedToken.Claims.SingleOrDefault(c => c.Type == "name").Value;
-                var ownerClaim = jwtValidatedToken.Claims.SingleOrDefault(c => c.Type == "owner").Value;
-                
-                var returnedScopes = jwtValidatedToken.Claims.Where(c => c.Type == "scp");
-                var scopesClaim = string.Join(" ", returnedScopes.Select(s => s.Value));
-
-                authorizedCharacter.RefreshToken = token.RefreshToken;
-                authorizedCharacter.Token = token.AccessToken;
-                authorizedCharacter.CharacterName = nameClaim;
-                authorizedCharacter.CharacterOwnerHash = ownerClaim;
-                authorizedCharacter.CharacterID = int.Parse(subjectClaim.Split(':').Last());
-                authorizedCharacter.ExpiresOn = jwtValidatedToken.ValidTo;
-                authorizedCharacter.Scopes = scopesClaim;
+                authorizedCharacter = ValidateAccessToken(token, _ssoUrl, jwksJson);
 
                 // Get more specifc details about authorized character to be used in API calls that require this data about the character
                 var url = $"{_config.EsiUrl}latest/characters/affiliation/?datasource={_config.DataSource.ToEsiValue()}";

From 5c9fa9a40786630698f594fa35d924157ac80a90 Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Wed, 9 Sep 2026 23:47:09 -0400
Subject: [PATCH 08/67] fix(dogma): split the attribute/effect id-value pairs
 from the full records

/dogma/dynamic/items/{type_id}/{item_id}/ was typed EsiResponse and
returned an all-empty object; DynamicItem itself was a non-public class.

- Dogma.Attribute is now the { attribute_id, value } pair as it appears on an
  item; Dogma.Effect is now { effect_id, is_default }. Both are what
  /universe/types/{id}/ and the dynamic-items endpoint actually return inline.
- New Dogma.AttributeInfo / Dogma.EffectInfo are the full definitions from
  /dogma/attributes/{id}/ and /dogma/effects/{id}/ (flat, no inheritance).
  Modifier moves to EffectInfo.cs.
- DynamicItem is public; DogmaAttributes/DogmaEffects are List /
  List.
- Universe.Type drops its private duplicate Attribute/Effect classes and binds
  to the Dogma types (via a `using Dogma =` alias so bare `Attribute` can't
  collide with System.Attribute). Its value field goes float -> double.
- DogmaLogic: Attribute() -> EsiResponse,
  Effect() -> EsiResponse, DynamicItem() -> EsiResponse.
- ESI.NET.Tests/DogmaModelTests: 7 checks (the two *Info maps, DynamicItem,
  Universe.Type, and a theory locking each endpoint's payload type). The JSON
  is spec-shaped but illustrative; pinning real ESI response bodies as fixtures
  is a follow-up.

Shapes verified against esi.evetech.net/meta/openapi.json.
Local: build 0 warnings, 14/14 tests.

BREAKING: DogmaLogic.Attribute()/Effect() return types; the meaning of
Dogma.Attribute/Effect; removal of Universe.Attribute/Effect.

Co-Authored-By: lsawin 
Co-Authored-By: Claude Sonnet 5 
---
 ESI.NET.Tests/DogmaModelTests.cs      | 119 ++++++++++++++++++++++++++
 ESI.NET/Logic/DogmaLogic.cs           |  12 +--
 ESI.NET/Models/Dogma/Attribute.cs     |  37 ++------
 ESI.NET/Models/Dogma/AttributeInfo.cs |  41 +++++++++
 ESI.NET/Models/Dogma/DynamicItem.cs   |   8 +-
 ESI.NET/Models/Dogma/Effect.cs        |  90 ++-----------------
 ESI.NET/Models/Dogma/EffectInfo.cs    |  96 +++++++++++++++++++++
 ESI.NET/Models/Universe/Type.cs       |  25 +-----
 8 files changed, 283 insertions(+), 145 deletions(-)
 create mode 100644 ESI.NET.Tests/DogmaModelTests.cs
 create mode 100644 ESI.NET/Models/Dogma/AttributeInfo.cs
 create mode 100644 ESI.NET/Models/Dogma/EffectInfo.cs

diff --git a/ESI.NET.Tests/DogmaModelTests.cs b/ESI.NET.Tests/DogmaModelTests.cs
new file mode 100644
index 0000000..83b83cd
--- /dev/null
+++ b/ESI.NET.Tests/DogmaModelTests.cs
@@ -0,0 +1,119 @@
+using System.Reflection;
+using ESI.NET.Logic;
+using ESI.NET.Models.Dogma;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace ESI.NET.Tests
+{
+    /// 
+    /// Deserialization coverage for the Dogma models against the shapes in the current ESI schema
+    /// (esi.evetech.net/meta/openapi.json), and guards for the type split:
+    /// Attribute/Effect are the id+value pairs on an item; AttributeInfo/
+    /// EffectInfo are the full definitions from /dogma/attributes|effects/{id}/.
+    /// The DynamicItem endpoint used to be mistyped as EsiResponse<Effect>.
+    /// 
+    public class DogmaModelTests
+    {
+        [Fact]
+        public void AttributeInfo_maps_the_full_definition()
+        {
+            // /dogma/attributes/{attribute_id}/
+            const string json = @"{
+                ""attribute_id"": 128, ""default_value"": 1.0, ""description"": ""Charge size"",
+                ""display_name"": ""Charge Size"", ""high_is_good"": false, ""icon_id"": 12,
+                ""name"": ""chargeSize"", ""published"": true, ""stackable"": true, ""unit_id"": 0 }";
+
+            var info = JsonConvert.DeserializeObject(json);
+
+            Assert.Equal(128, info.AttributeId);
+            Assert.Equal("chargeSize", info.Name);
+            Assert.Equal("Charge Size", info.DisplayName);
+            Assert.True(info.Published);
+            Assert.False(info.HighIsGood);
+        }
+
+        [Fact]
+        public void EffectInfo_maps_the_full_definition_including_modifiers()
+        {
+            // /dogma/effects/{effect_id}/
+            const string json = @"{
+                ""effect_id"": 10, ""name"": ""online"", ""display_name"": ""Online"",
+                ""effect_category"": 4, ""published"": true, ""is_offensive"": false,
+                ""modifiers"": [ { ""domain"": ""itemID"", ""func"": ""ItemModifier"",
+                    ""modified_attribute_id"": 50, ""modifying_attribute_id"": 51, ""operator"": 6 } ] }";
+
+            var info = JsonConvert.DeserializeObject(json);
+
+            Assert.Equal(10, info.EffectId);
+            Assert.Equal("online", info.Name);
+            Assert.Single(info.Modifiers);
+            Assert.Equal("ItemModifier", info.Modifiers[0].Func);
+            Assert.Equal(6, info.Modifiers[0].Operator);
+        }
+
+        [Fact]
+        public void DynamicItem_maps_its_id_value_attribute_and_effect_pairs()
+        {
+            // /dogma/dynamic/items/{type_id}/{item_id}/
+            const string json = @"{
+                ""created_by"": 1234567,
+                ""dogma_attributes"": [ { ""attribute_id"": 9, ""value"": 450.5 },
+                                        { ""attribute_id"": 37, ""value"": 1.12 } ],
+                ""dogma_effects"": [ { ""effect_id"": 508, ""is_default"": true } ],
+                ""mutator_type_id"": 47800,
+                ""source_type_id"": 2410 }";
+
+            var item = JsonConvert.DeserializeObject(json);
+
+            Assert.Equal(1234567, item.CreatedBy);
+            Assert.Equal(47800, item.MutatorTypeId);
+            Assert.Equal(2410, item.SourceTypeId);
+
+            Assert.Equal(2, item.DogmaAttributes.Count);
+            Assert.Equal(9, item.DogmaAttributes[0].AttributeId);
+            Assert.Equal(450.5, item.DogmaAttributes[0].Value);
+            Assert.Equal(1.12, item.DogmaAttributes[1].Value, 5);
+
+            var effect = Assert.Single(item.DogmaEffects);
+            Assert.Equal(508, effect.EffectId);
+            Assert.True(effect.IsDefault);
+        }
+
+        [Fact]
+        public void UniverseType_uses_the_same_Dogma_Attribute_and_Effect_pairs()
+        {
+            // /universe/types/{type_id}/ carries the same {attribute_id,value} / {effect_id,is_default}
+            // shape as the dynamic-items endpoint, so both bind to ESI.NET.Models.Dogma.Attribute/Effect.
+            const string json = @"{
+                ""type_id"": 2410, ""name"": ""Heavy Missile Launcher II"", ""group_id"": 510,
+                ""description"": ""..."", ""published"": true,
+                ""dogma_attributes"": [ { ""attribute_id"": 51, ""value"": 5.0 } ],
+                ""dogma_effects"": [ { ""effect_id"": 40, ""is_default"": false } ] }";
+
+            var type = JsonConvert.DeserializeObject(json);
+
+            Assert.Equal(2410, type.TypeId);
+            Attribute attr = Assert.Single(type.DogmaAttributes);
+            Assert.Equal(51, attr.AttributeId);
+            Assert.Equal(5.0, attr.Value);
+            Effect eff = Assert.Single(type.DogmaEffects);
+            Assert.Equal(40, eff.EffectId);
+        }
+
+        [Theory]
+        [InlineData(nameof(DogmaLogic.Attribute), typeof(AttributeInfo))]
+        [InlineData(nameof(DogmaLogic.Effect), typeof(EffectInfo))]
+        [InlineData(nameof(DogmaLogic.DynamicItem), typeof(DynamicItem))]
+        public void DogmaLogic_endpoint_returns_the_expected_payload_type(string method, System.Type expected)
+        {
+            var returnType = typeof(DogmaLogic)
+                .GetMethod(method, BindingFlags.Public | BindingFlags.Instance)
+                .ReturnType; // Task>
+
+            var payloadType = returnType.GetGenericArguments()[0].GetGenericArguments()[0];
+
+            Assert.Equal(expected, payloadType);
+        }
+    }
+}
diff --git a/ESI.NET/Logic/DogmaLogic.cs b/ESI.NET/Logic/DogmaLogic.cs
index 71f7869..2a22be9 100644
--- a/ESI.NET/Logic/DogmaLogic.cs
+++ b/ESI.NET/Logic/DogmaLogic.cs
@@ -25,8 +25,8 @@ public async Task> Attributes()
         /// 
         /// 
         /// 
-        public async Task> Attribute(int attribute_id)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/attributes/{attribute_id}/",
+        public async Task> Attribute(int attribute_id)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/attributes/{attribute_id}/",
                 replacements: new Dictionary()
                 {
                     { "attribute_id", attribute_id.ToString() }
@@ -44,8 +44,8 @@ public async Task> Effects()
         /// 
         /// 
         /// 
-        public async Task> Effect(int effect_id)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/effects/{effect_id}/",
+        public async Task> Effect(int effect_id)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/effects/{effect_id}/",
                 replacements: new Dictionary()
                 {
                     { "effect_id", effect_id.ToString() }
@@ -57,8 +57,8 @@ public async Task> Effect(int effect_id)
         /// 
         /// 
         /// 
-        public async Task> DynamicItem(int type_id, long item_id)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/dynamic/items/{type_id}/{item_id}/",
+        public async Task> DynamicItem(int type_id, long item_id)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/dynamic/items/{type_id}/{item_id}/",
                 replacements: new Dictionary()
                 {
                     { "type_id", type_id.ToString() },
diff --git a/ESI.NET/Models/Dogma/Attribute.cs b/ESI.NET/Models/Dogma/Attribute.cs
index 0bd1c1e..ece67e9 100644
--- a/ESI.NET/Models/Dogma/Attribute.cs
+++ b/ESI.NET/Models/Dogma/Attribute.cs
@@ -2,42 +2,17 @@
 
 namespace ESI.NET.Models.Dogma
 {
+    /// 
+    /// A dogma attribute as it sits on an item — its id and value — as returned inside
+    /// /universe/types/{type_id}/ and /dogma/dynamic/items/{type_id}/{item_id}/.
+    /// The full descriptive record is  (/dogma/attributes/{attribute_id}/).
+    /// 
     public class Attribute
     {
         [JsonProperty("attribute_id")]
         public int AttributeId { get; set; }
 
-        [JsonProperty("name")]
-        public string Name { get; set; }
-
-        [JsonProperty("description")]
-        public string Description { get; set; }
-
-        [JsonProperty("icon_id")]
-        public int IconId { get; set; }
-
-        [JsonProperty("default_value")]
-        public double DefaultValue { get; set; }
-
-        [JsonProperty("published")]
-        public bool Published { get; set; }
-
-        [JsonProperty("display_name")]
-        public string DisplayName { get; set; }
-
-        [JsonProperty("unit_id")]
-        public int UnitId { get; set; }
-
-        [JsonProperty("stackable")]
-        public bool Stackable { get; set; }
-
-        [JsonProperty("high_is_good")]
-        public bool HighIsGood { get; set; }
-
-        /// 
-        /// Only populated when used in DynamicItem; all other properties except AttributeId will be empty
-        /// 
         [JsonProperty("value")]
-        public decimal Value { get; set; }
+        public double Value { get; set; }
     }
 }
diff --git a/ESI.NET/Models/Dogma/AttributeInfo.cs b/ESI.NET/Models/Dogma/AttributeInfo.cs
new file mode 100644
index 0000000..8935f92
--- /dev/null
+++ b/ESI.NET/Models/Dogma/AttributeInfo.cs
@@ -0,0 +1,41 @@
+using Newtonsoft.Json;
+
+namespace ESI.NET.Models.Dogma
+{
+    /// 
+    /// Full dogma attribute definition from /dogma/attributes/{attribute_id}/.
+    /// The id+value pair as it appears on an item is .
+    /// 
+    public class AttributeInfo
+    {
+        [JsonProperty("attribute_id")]
+        public int AttributeId { get; set; }
+
+        [JsonProperty("name")]
+        public string Name { get; set; }
+
+        [JsonProperty("description")]
+        public string Description { get; set; }
+
+        [JsonProperty("icon_id")]
+        public int IconId { get; set; }
+
+        [JsonProperty("default_value")]
+        public double DefaultValue { get; set; }
+
+        [JsonProperty("published")]
+        public bool Published { get; set; }
+
+        [JsonProperty("display_name")]
+        public string DisplayName { get; set; }
+
+        [JsonProperty("unit_id")]
+        public int UnitId { get; set; }
+
+        [JsonProperty("stackable")]
+        public bool Stackable { get; set; }
+
+        [JsonProperty("high_is_good")]
+        public bool HighIsGood { get; set; }
+    }
+}
diff --git a/ESI.NET/Models/Dogma/DynamicItem.cs b/ESI.NET/Models/Dogma/DynamicItem.cs
index a800105..86bfce9 100644
--- a/ESI.NET/Models/Dogma/DynamicItem.cs
+++ b/ESI.NET/Models/Dogma/DynamicItem.cs
@@ -3,7 +3,11 @@
 
 namespace ESI.NET.Models.Dogma
 {
-    class DynamicItem
+    /// 
+    /// Response of /dogma/dynamic/items/{type_id}/{item_id}/ — a mutated (abyssal) item:
+    /// its source and mutator types, who created it, and the rolled dogma attributes/effects.
+    /// 
+    public class DynamicItem
     {
         [JsonProperty("created_by")]
         public int CreatedBy { get; set; }
@@ -20,4 +24,4 @@ class DynamicItem
         [JsonProperty("source_type_id")]
         public int SourceTypeId { get; set; }
     }
-}
\ No newline at end of file
+}
diff --git a/ESI.NET/Models/Dogma/Effect.cs b/ESI.NET/Models/Dogma/Effect.cs
index b141c62..5853583 100644
--- a/ESI.NET/Models/Dogma/Effect.cs
+++ b/ESI.NET/Models/Dogma/Effect.cs
@@ -1,98 +1,18 @@
 using Newtonsoft.Json;
-using System.Collections.Generic;
 
 namespace ESI.NET.Models.Dogma
 {
+    /// 
+    /// A dogma effect as it sits on an item — its id and whether it is the default — as returned
+    /// inside /universe/types/{type_id}/ and /dogma/dynamic/items/{type_id}/{item_id}/.
+    /// The full descriptive record is  (/dogma/effects/{effect_id}/).
+    /// 
     public class Effect
     {
         [JsonProperty("effect_id")]
         public int EffectId { get; set; }
 
-        [JsonProperty("name")]
-        public string Name { get; set; }
-
-        [JsonProperty("display_name")]
-        public string DisplayName { get; set; }
-
-        [JsonProperty("description")]
-        public string Description { get; set; }
-
-        [JsonProperty("icon_id")]
-        public int IconId { get; set; }
-
-        [JsonProperty("effect_category")]
-        public int EffectCategory { get; set; }
-
-        [JsonProperty("pre_expression")]
-        public int PreExpression { get; set; }
-
-        [JsonProperty("post_expression")]
-        public int PostExpression { get; set; }
-
-        [JsonProperty("is_offensive")]
-        public bool IsOffensive { get; set; }
-
-        [JsonProperty("is_assistance")]
-        public bool IsAssistance { get; set; }
-
-        [JsonProperty("disallow_auto_repeat")]
-        public bool DisallowAutoRepeat { get; set; }
-
-        [JsonProperty("published")]
-        public bool Published { get; set; }
-
-        [JsonProperty("is_warp_safe")]
-        public bool IsWarpSafe { get; set; }
-
-        [JsonProperty("range_chance")]
-        public bool RangeChance { get; set; }
-
-        [JsonProperty("electronic_chance")]
-        public bool ElectronicChance { get; set; }
-
-        [JsonProperty("duration_attribute_id")]
-        public int DurationAttributeId { get; set; }
-
-        [JsonProperty("tracking_speed_attribute_id")]
-        public int TrackingSpeedAttributeId { get; set; }
-
-        [JsonProperty("discharge_attribute_id")]
-        public int DischargeAttributeId { get; set; }
-
-        [JsonProperty("range_attribute_id")]
-        public int RangeAttributeId { get; set; }
-
-        [JsonProperty("falloff_attribute_id")]
-        public int FalloffAttributeId { get; set; }
-
-        [JsonProperty("modifiers")]
-        public List Modifiers { get; set; } = new List();
-
-        /// 
-        /// Only populated when used in DynamicItem; all other properties except EffectId will be empty
-        /// 
         [JsonProperty("is_default")]
         public bool IsDefault { get; set; }
     }
-
-    public class Modifier
-    {
-        [JsonProperty("func")]
-        public string Func { get; set; }
-
-        [JsonProperty("domain")]
-        public string Domain { get; set; }
-
-        [JsonProperty("modified_attribute_id")]
-        public int ModifiedAttributeId { get; set; }
-
-        [JsonProperty("modifying_attribute_id")]
-        public int ModifyingAttributeId { get; set; }
-
-        [JsonProperty("effect_id")]
-        public int EffectId { get; set; }
-
-        [JsonProperty("operator")]
-        public int Operator { get; set; }
-    }
 }
diff --git a/ESI.NET/Models/Dogma/EffectInfo.cs b/ESI.NET/Models/Dogma/EffectInfo.cs
new file mode 100644
index 0000000..b5f46e7
--- /dev/null
+++ b/ESI.NET/Models/Dogma/EffectInfo.cs
@@ -0,0 +1,96 @@
+using Newtonsoft.Json;
+using System.Collections.Generic;
+
+namespace ESI.NET.Models.Dogma
+{
+    /// 
+    /// Full dogma effect definition from /dogma/effects/{effect_id}/.
+    /// The id+default pair as it appears on an item is .
+    /// 
+    public class EffectInfo
+    {
+        [JsonProperty("effect_id")]
+        public int EffectId { get; set; }
+
+        [JsonProperty("name")]
+        public string Name { get; set; }
+
+        [JsonProperty("display_name")]
+        public string DisplayName { get; set; }
+
+        [JsonProperty("description")]
+        public string Description { get; set; }
+
+        [JsonProperty("icon_id")]
+        public int IconId { get; set; }
+
+        [JsonProperty("effect_category")]
+        public int EffectCategory { get; set; }
+
+        [JsonProperty("pre_expression")]
+        public int PreExpression { get; set; }
+
+        [JsonProperty("post_expression")]
+        public int PostExpression { get; set; }
+
+        [JsonProperty("is_offensive")]
+        public bool IsOffensive { get; set; }
+
+        [JsonProperty("is_assistance")]
+        public bool IsAssistance { get; set; }
+
+        [JsonProperty("disallow_auto_repeat")]
+        public bool DisallowAutoRepeat { get; set; }
+
+        [JsonProperty("published")]
+        public bool Published { get; set; }
+
+        [JsonProperty("is_warp_safe")]
+        public bool IsWarpSafe { get; set; }
+
+        [JsonProperty("range_chance")]
+        public bool RangeChance { get; set; }
+
+        [JsonProperty("electronic_chance")]
+        public bool ElectronicChance { get; set; }
+
+        [JsonProperty("duration_attribute_id")]
+        public int DurationAttributeId { get; set; }
+
+        [JsonProperty("tracking_speed_attribute_id")]
+        public int TrackingSpeedAttributeId { get; set; }
+
+        [JsonProperty("discharge_attribute_id")]
+        public int DischargeAttributeId { get; set; }
+
+        [JsonProperty("range_attribute_id")]
+        public int RangeAttributeId { get; set; }
+
+        [JsonProperty("falloff_attribute_id")]
+        public int FalloffAttributeId { get; set; }
+
+        [JsonProperty("modifiers")]
+        public List Modifiers { get; set; } = new List();
+    }
+
+    public class Modifier
+    {
+        [JsonProperty("func")]
+        public string Func { get; set; }
+
+        [JsonProperty("domain")]
+        public string Domain { get; set; }
+
+        [JsonProperty("modified_attribute_id")]
+        public int ModifiedAttributeId { get; set; }
+
+        [JsonProperty("modifying_attribute_id")]
+        public int ModifyingAttributeId { get; set; }
+
+        [JsonProperty("effect_id")]
+        public int EffectId { get; set; }
+
+        [JsonProperty("operator")]
+        public int Operator { get; set; }
+    }
+}
diff --git a/ESI.NET/Models/Universe/Type.cs b/ESI.NET/Models/Universe/Type.cs
index 5872d30..4617bef 100644
--- a/ESI.NET/Models/Universe/Type.cs
+++ b/ESI.NET/Models/Universe/Type.cs
@@ -1,4 +1,5 @@
-using Newtonsoft.Json;
+using Dogma = ESI.NET.Models.Dogma;
+using Newtonsoft.Json;
 using System.Collections.Generic;
 
 namespace ESI.NET.Models.Universe
@@ -12,10 +13,10 @@ public class Type
         public string Description { get; set; }
 
         [JsonProperty("dogma_attributes")]
-        public List DogmaAttributes { get; set; } = new List();
+        public List DogmaAttributes { get; set; } = new List();
 
         [JsonProperty("dogma_effects")]
-        public List DogmaEffects { get; set; } = new List();
+        public List DogmaEffects { get; set; } = new List();
 
         [JsonProperty("graphic_id")]
         public int GraphicId { get; set; }
@@ -53,22 +54,4 @@ public class Type
         [JsonProperty("volume")]
         public float Volume { get; set; }
     }
-
-    public class Attribute
-    {
-        [JsonProperty("attribute_id")]
-        public int AttributeId { get; set; }
-
-        [JsonProperty("value")]
-        public float Value { get; set; }
-    }
-
-    public class Effect
-    {
-        [JsonProperty("effect_id")]
-        public int EffectId { get; set; }
-
-        [JsonProperty("is_default")]
-        public bool IsDefault { get; set; }
-    }
 }

From 8a838641bf5852529b930c47ca10952b538a9e92 Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Thu, 10 Sep 2026 00:20:45 -0400
Subject: [PATCH 09/67] refactor(response): build EsiResponse via an async
 factory

EsiResponse was constructed synchronously and read the body twice with
.Result inside the constructor (deadlock risk under a sync context, blocks a
pool thread).

- New internal static EsiResponse.CreateAsync(response, path, ct): reads the
  body once, awaited (with the CancellationToken overload on net8; netstandard2.0
  has no token overload for ReadAsStringAsync). The constructor is now private,
  takes the already-read body, and does only the synchronous header parsing.
  response.Dispose() moves to the factory's finally.
- _noContentMessage was a readonly *instance* field, so the ~25-entry
  ImmutableDictionary was rebuilt on every response -> now static.
- _noContentMessage[path] raw indexer -> TryGetValue(...) ? msg : "No Content".
  A 204 from an endpoint not in the table used to throw KeyNotFoundException into
  the catch and leave Message null.
- Call sites (EsiRequest.Execute, SsoLogic.Verify) updated; both already async.
- ESI.NET.Tests/EsiResponseTests: 10 in-memory cases (json object/array, non-json
  body, known/unknown 204, 304, error string, header parse, captured
  deserialization failure, response disposal).

No caller-facing change beyond removing the public constructor. The ct parameter
on CreateAsync is dormant until EsiCallOptions threads it.

Local: build 0 warnings both TFMs, 24/24 tests.

Co-Authored-By: Claude Sonnet 5 
---
 ESI.NET.Tests/EsiResponseTests.cs | 152 ++++++++++++++++++++++++++++++
 ESI.NET/EsiRequest.cs             |   3 +-
 ESI.NET/EsiResponse.cs            |  66 ++++++++-----
 ESI.NET/Logic/_SSOLogic.cs        |   2 +-
 4 files changed, 197 insertions(+), 26 deletions(-)
 create mode 100644 ESI.NET.Tests/EsiResponseTests.cs

diff --git a/ESI.NET.Tests/EsiResponseTests.cs b/ESI.NET.Tests/EsiResponseTests.cs
new file mode 100644
index 0000000..dc3d8be
--- /dev/null
+++ b/ESI.NET.Tests/EsiResponseTests.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using ESI.NET;
+using Xunit;
+
+namespace ESI.NET.Tests
+{
+    /// 
+    /// Covers .CreateAsync — the header/body projection that used to run
+    /// synchronously (.Result) in the constructor. All in-memory, no network.
+    /// 
+    public class EsiResponseTests
+    {
+        private static HttpResponseMessage Message(
+            HttpStatusCode status,
+            string body = null,
+            Action configure = null)
+        {
+            var msg = new HttpResponseMessage(status);
+            if (body != null)
+                msg.Content = new StringContent(body);
+            else
+                msg.Content = new StringContent(string.Empty);
+            configure?.Invoke(msg);
+            return msg;
+        }
+
+        [Fact]
+        public async Task Ok_with_json_object_populates_Data()
+        {
+            var r = await EsiResponse>.CreateAsync(
+                Message(HttpStatusCode.OK, @"{ ""a"": 1, ""b"": 2 }"),
+                "GET|/x/");
+
+            Assert.Equal(HttpStatusCode.OK, r.StatusCode);
+            Assert.Equal("/x/", r.Endpoint);
+            Assert.Equal(2, r.Data["b"]);
+            Assert.Null(r.Message);
+            Assert.Null(r.Exception);
+        }
+
+        [Fact]
+        public async Task Ok_with_json_array_populates_Data()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.OK, "[10, 20, 30]"), "GET|/x/");
+
+            Assert.Equal(new[] { 10, 20, 30 }, r.Data);
+        }
+
+        [Fact]
+        public async Task Ok_with_non_json_body_goes_to_Message()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.OK, "just text"), "GET|/x/");
+
+            Assert.Equal("just text", r.Message);
+            Assert.Null(r.Data);
+        }
+
+        [Fact]
+        public async Task NoContent_with_a_known_path_maps_to_its_message()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.NoContent),
+                "DELETE|/characters/{character_id}/fittings/{fitting_id}/");
+
+            Assert.Equal("Fitting deleted", r.Message);
+        }
+
+        [Fact]
+        public async Task NoContent_with_an_unknown_path_falls_back_instead_of_throwing()
+        {
+            // Regression: the old code indexed the dictionary directly and threw
+            // KeyNotFoundException into the catch, leaving Message null.
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.NoContent), "DELETE|/some/new/endpoint/");
+
+            Assert.Equal("No Content", r.Message);
+            Assert.Null(r.Exception);
+        }
+
+        [Fact]
+        public async Task NotModified_sets_a_message()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.NotModified), "GET|/x/");
+
+            Assert.Equal("Not Modified", r.Message);
+        }
+
+        [Fact]
+        public async Task Error_status_surfaces_the_esi_error_string()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.NotFound, @"{ ""error"": ""character not found"" }"),
+                "GET|/x/");
+
+            Assert.Equal("character not found", r.Message);
+            Assert.Null(r.Data);
+        }
+
+        [Fact]
+        public async Task Headers_are_parsed_onto_the_response()
+        {
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.OK, "[1]", m =>
+                {
+                    m.Headers.TryAddWithoutValidation("ETag", "\"abc123\"");
+                    m.Headers.TryAddWithoutValidation("X-Pages", "7");
+                    m.Headers.TryAddWithoutValidation("X-Esi-Error-Limit-Remain", "95");
+                    m.Headers.TryAddWithoutValidation("X-Esi-Error-Limit-Reset", "42");
+                    m.Headers.TryAddWithoutValidation("X-ESI-Request-ID", "6f1b2c3d-0000-4000-8000-000000000000");
+                    m.Content.Headers.TryAddWithoutValidation("Expires", "Wed, 10 Sep 2036 12:00:00 GMT");
+                    m.Content.Headers.TryAddWithoutValidation("Last-Modified", "Tue, 09 Sep 2036 12:00:00 GMT");
+                }),
+                "GET|/x/");
+
+            Assert.Equal("abc123", r.ETag);           // quotes stripped
+            Assert.Equal(7, r.Pages);
+            Assert.Equal(95, r.ErrorLimitRemain);
+            Assert.Equal(42, r.ErrorLimitReset);
+            Assert.NotEqual(Guid.Empty, r.RequestId);
+            Assert.True(r.Expires > DateTime.UtcNow);
+            Assert.NotNull(r.LastModified);
+        }
+
+        [Fact]
+        public async Task Deserialization_failure_is_captured_not_thrown()
+        {
+            // Body looks like JSON (starts { ends }) but does not fit the target type.
+            var r = await EsiResponse.CreateAsync(
+                Message(HttpStatusCode.OK, @"{ ""not"": ""an array"" }"), "GET|/x/");
+
+            Assert.NotNull(r.Exception);
+            Assert.Equal(@"{ ""not"": ""an array"" }", r.Message);
+        }
+
+        [Fact]
+        public async Task The_response_is_disposed_by_the_factory()
+        {
+            var msg = Message(HttpStatusCode.OK, "[1]");
+            await EsiResponse.CreateAsync(msg, "GET|/x/");
+
+            // Reading the content of a disposed HttpResponseMessage throws.
+            await Assert.ThrowsAnyAsync(() => msg.Content.ReadAsStringAsync());
+        }
+    }
+}
diff --git a/ESI.NET/EsiRequest.cs b/ESI.NET/EsiRequest.cs
index 73b7606..3425475 100644
--- a/ESI.NET/EsiRequest.cs
+++ b/ESI.NET/EsiRequest.cs
@@ -47,7 +47,8 @@ public static async Task> Execute(HttpClient client, EsiConfig
                 request.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
 
             //Output final object
-            return new EsiResponse(await client.SendAsync(request).ConfigureAwait(false), path);
+            var response = await client.SendAsync(request).ConfigureAwait(false);
+            return await EsiResponse.CreateAsync(response, path).ConfigureAwait(false);
         }
 
         public enum RequestSecurity
diff --git a/ESI.NET/EsiResponse.cs b/ESI.NET/EsiResponse.cs
index 2f163cd..cc0ed02 100644
--- a/ESI.NET/EsiResponse.cs
+++ b/ESI.NET/EsiResponse.cs
@@ -5,12 +5,39 @@
 using System.Linq;
 using System.Net;
 using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
 
 namespace ESI.NET
 {
     public class EsiResponse
     {
-        public EsiResponse(HttpResponseMessage response, string path)
+        /// 
+        /// Reads  and projects its headers and body onto an
+        /// . The response is disposed before this returns.
+        /// 
+        internal static async Task> CreateAsync(HttpResponseMessage response, string path, CancellationToken cancellationToken = default)
+        {
+            try
+            {
+                string body = null;
+
+                if (response.StatusCode != HttpStatusCode.NoContent)
+#if NET
+                    body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+#else
+                    body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+#endif
+
+                return new EsiResponse(response, path, body);
+            }
+            finally
+            {
+                response.Dispose();
+            }
+        }
+
+        private EsiResponse(HttpResponseMessage response, string path, string body)
         {
             try
             {
@@ -38,36 +65,27 @@ public EsiResponse(HttpResponseMessage response, string path)
                 if (response.Headers.Contains("X-Esi-Error-Limit-Reset"))
                     ErrorLimitReset = int.Parse(response.Headers.GetValues("X-Esi-Error-Limit-Reset").First());
 
-                if (response.StatusCode != HttpStatusCode.NoContent)
+                if (response.StatusCode == HttpStatusCode.NoContent)
+                    Message = _noContentMessage.TryGetValue(path, out var noContent) ? noContent : "No Content";
+                else if (response.StatusCode == HttpStatusCode.OK ||
+                         response.StatusCode == HttpStatusCode.Created)
                 {
-                    var result = response.Content.ReadAsStringAsync().Result;
-
-                    if (response.StatusCode == HttpStatusCode.OK ||
-                        response.StatusCode == HttpStatusCode.Created)
-                    {
-                        if ((result.StartsWith("{") && result.EndsWith("}")) || result.StartsWith("[") && result.EndsWith("]"))
-                            Data = JsonConvert.DeserializeObject(result);
-                        else
-                            Message = result;
-                    }
-                    else if (response.StatusCode == HttpStatusCode.NotModified)
-                        Message = "Not Modified";
+                    if ((body.StartsWith("{") && body.EndsWith("}")) ||
+                        (body.StartsWith("[") && body.EndsWith("]")))
+                        Data = JsonConvert.DeserializeObject(body);
                     else
-                        Message = JsonConvert.DeserializeAnonymousType(result, new { error = string.Empty }).error;
+                        Message = body;
                 }
-                else if (response.StatusCode == HttpStatusCode.NoContent)
-                    Message = _noContentMessage[path];
-
+                else if (response.StatusCode == HttpStatusCode.NotModified)
+                    Message = "Not Modified";
+                else
+                    Message = JsonConvert.DeserializeAnonymousType(body, new { error = string.Empty }).error;
             }
             catch (Exception ex)
             {
-                Message = response.Content.ReadAsStringAsync().Result;
+                Message = body;
                 Exception = ex;
             }
-            finally
-            {
-                response.Dispose();
-            }
         }
 
         public Guid RequestId { get; set; }
@@ -84,7 +102,7 @@ public EsiResponse(HttpResponseMessage response, string path)
         public T Data { get; set; }
         public Exception Exception { get; set; }
 
-        private readonly ImmutableDictionary _noContentMessage = new Dictionary()
+        private static readonly ImmutableDictionary _noContentMessage = new Dictionary()
         {
             //Calendar
             {"PUT|/characters/{character_id}/calendar/{event_id}/", "Event updated"},
diff --git a/ESI.NET/Logic/_SSOLogic.cs b/ESI.NET/Logic/_SSOLogic.cs
index 9c9a7ea..93ea21c 100644
--- a/ESI.NET/Logic/_SSOLogic.cs
+++ b/ESI.NET/Logic/_SSOLogic.cs
@@ -231,7 +231,7 @@ public async Task Verify(SsoToken token)
 
                 if (characterResponse.StatusCode == HttpStatusCode.OK)
                 {
-                    EsiResponse> affiliations = new EsiResponse>(characterResponse, "Post|/character/affiliations/");
+                    var affiliations = await EsiResponse>.CreateAsync(characterResponse, "Post|/character/affiliations/").ConfigureAwait(false);
                     var characterData = affiliations.Data.First();
 
                     authorizedCharacter.AllianceID = characterData.AllianceId;

From 84a1aa2b5e45c6d334eb5625e65494c8eed0e92b Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Thu, 10 Sep 2026 00:43:30 -0400
Subject: [PATCH 10/67] refactor!: per-call EsiCallOptions; drop
 SetCharacterData and the static ETag

The client held the authorized character (SetCharacterData re-instantiated ~22
Logic objects) and the If-None-Match ETag in a *static* field shared across the
whole process. Both are now per-call state on a new EsiCallOptions.

EsiCallOptions { Character, CancellationToken, IfNoneMatch, Page }

- EsiRequest.Execute: `string token` param -> `EsiCallOptions options` (required,
  positioned after `endpoint`). Threads CancellationToken into SendAsync and the
  response read, Page -> `&page=`, IfNoneMatch -> header (tolerating quotes).
  The static EsiRequest.ETag field is deleted.
- Every Logic class ctor is now (HttpClient, EsiConfig); the _data /
  character_id / corporation_id / alliance_id fields are gone.
- Every endpoint method takes EsiCallOptions:
    * required (no default) on authenticated endpoints -> forgetting the
      character is now a compile error, not a runtime ArgumentException
    * `= null` on public endpoints (unchanged call sites)
- `int page = 1` parameters folded into EsiCallOptions.Page and removed from 13
  methods.
- EsiClient.SetCharacterData and SetIfNoneMatchHeader removed (class + IEsiClient).
- Stripped the now-unused `using ESI.NET.Models.SSO;` from 24 Logic files.

Tests: ESI.NET.Tests/EsiRequestTests - 8 cases via a capturing HttpMessageHandler
(url/datasource, path replacements, auth-guard throw, bearer token, &page=,
If-None-Match quoting, cancellation propagation, null-options tolerance).

BREAKING:
- Auth is per call: `client.Clones.List(new() { Character = data })` instead of
  `client.SetCharacterData(data); client.Clones.List();`
- ETag: `options.IfNoneMatch` instead of `client.SetIfNoneMatchHeader(...)`
- Pagination: `new() { Page = 2 }` instead of a `page` argument.

Local: build 0 warnings both TFMs, 32/32 tests.

Co-Authored-By: Claude Sonnet 5 
---
 ESI.NET.Tests/EsiRequestTests.cs           | 129 ++++++++++++++
 ESI.NET/EsiCallOptions.cs                  |  33 ++++
 ESI.NET/EsiClient.cs                       |  35 ----
 ESI.NET/EsiRequest.cs                      |  26 +--
 ESI.NET/Logic/AllianceLogic.cs             |  24 ++-
 ESI.NET/Logic/AssetsLogic.cs               |  56 +++---
 ESI.NET/Logic/BookmarksLogic.cs            |  52 ++----
 ESI.NET/Logic/CalendarLogic.cs             |  33 ++--
 ESI.NET/Logic/CharacterLogic.cs            | 109 ++++++------
 ESI.NET/Logic/ClonesLogic.cs               |  21 +--
 ESI.NET/Logic/ContactsLogic.cs             |  80 +++------
 ESI.NET/Logic/ContractsLogic.cs            |  93 +++-------
 ESI.NET/Logic/CorporationLogic.cs          | 171 ++++++++-----------
 ESI.NET/Logic/DogmaLogic.cs                |  30 ++--
 ESI.NET/Logic/FactionWarfareLogic.cs       |  60 +++----
 ESI.NET/Logic/FittingsLogic.cs             |  27 ++-
 ESI.NET/Logic/FleetsLogic.cs               |  69 ++++----
 ESI.NET/Logic/IncursionsLogic.cs           |   6 +-
 ESI.NET/Logic/IndustryLogic.cs             |  75 +++------
 ESI.NET/Logic/InsuranceLogic.cs            |   6 +-
 ESI.NET/Logic/KillmailsLogic.cs            |  38 ++---
 ESI.NET/Logic/LocationLogic.cs             |  27 ++-
 ESI.NET/Logic/LoyaltyLogic.cs              |  21 +--
 ESI.NET/Logic/MailLogic.cs                 |  63 +++----
 ESI.NET/Logic/MarketLogic.cs               |  99 +++++------
 ESI.NET/Logic/OpportunitiesLogic.cs        |  41 ++---
 ESI.NET/Logic/PlanetaryInteractionLogic.cs |  36 ++--
 ESI.NET/Logic/RoutesLogic.cs               |  10 +-
 ESI.NET/Logic/SearchLogic.cs               |  29 ++--
 ESI.NET/Logic/SkillsLogic.cs               |  27 ++-
 ESI.NET/Logic/SovereigntyLogic.cs          |  18 +-
 ESI.NET/Logic/StatusLogic.cs               |   6 +-
 ESI.NET/Logic/UniverseLogic.cs             | 187 +++++++++++++--------
 ESI.NET/Logic/UserInterfaceLogic.cs        |  25 ++-
 ESI.NET/Logic/WalletLogic.cs               |  58 +++----
 ESI.NET/Logic/WarsLogic.cs                 |  18 +-
 36 files changed, 881 insertions(+), 957 deletions(-)
 create mode 100644 ESI.NET.Tests/EsiRequestTests.cs
 create mode 100644 ESI.NET/EsiCallOptions.cs

diff --git a/ESI.NET.Tests/EsiRequestTests.cs b/ESI.NET.Tests/EsiRequestTests.cs
new file mode 100644
index 0000000..79cc0b1
--- /dev/null
+++ b/ESI.NET.Tests/EsiRequestTests.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ESI.NET;
+using ESI.NET.Enumerations;
+using ESI.NET.Models.SSO;
+using Xunit;
+using static ESI.NET.EsiRequest;
+
+namespace ESI.NET.Tests
+{
+    /// 
+    /// Covers how .Execute shapes the outgoing request from an
+    ///  — the state that used to live on the client
+    /// (SetCharacterData) and, for the ETag, in a process-wide static field.
+    /// 
+    public class EsiRequestTests
+    {
+        private sealed class CapturingHandler : HttpMessageHandler
+        {
+            public HttpRequestMessage Last;
+            protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+            {
+                Last = request;
+                cancellationToken.ThrowIfCancellationRequested();
+                return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+                {
+                    Content = new StringContent("{}"),
+                });
+            }
+        }
+
+        private static readonly EsiConfig Config = new EsiConfig
+        {
+            EsiUrl = "https://esi.evetech.net/",
+            DataSource = DataSource.Tranquility,
+        };
+
+        private static (HttpClient client, CapturingHandler handler) NewClient()
+        {
+            var handler = new CapturingHandler();
+            return (new HttpClient(handler), handler);
+        }
+
+        private static EsiCallOptions Auth(string token = "access-token") =>
+            new EsiCallOptions { Character = new AuthorizedCharacterData { Token = token, CharacterID = 42 } };
+
+        [Fact]
+        public async Task Public_call_builds_the_url_with_datasource()
+        {
+            var (client, h) = NewClient();
+            await Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/status/", options: new EsiCallOptions());
+
+            Assert.Equal("https://esi.evetech.net/latest/status/?datasource=tranquility", h.Last.RequestUri.ToString());
+        }
+
+        [Fact]
+        public async Task Replacements_substitute_into_the_path()
+        {
+            var (client, h) = NewClient();
+            await Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/x/{id}/",
+                replacements: new Dictionary { { "id", "999" } }, options: new EsiCallOptions());
+
+            Assert.Contains("/latest/x/999/?", h.Last.RequestUri.ToString());
+        }
+
+        [Fact]
+        public async Task Authenticated_without_a_Character_throws()
+        {
+            var (client, _) = NewClient();
+            await Assert.ThrowsAsync(() =>
+                Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: new EsiCallOptions()));
+        }
+
+        [Fact]
+        public async Task Authenticated_with_a_Character_sends_the_bearer_token()
+        {
+            var (client, h) = NewClient();
+            await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: Auth("tok-123"));
+
+            Assert.Equal("Bearer", h.Last.Headers.Authorization.Scheme);
+            Assert.Equal("tok-123", h.Last.Headers.Authorization.Parameter);
+        }
+
+        [Fact]
+        public async Task Page_option_is_appended_as_a_query_parameter()
+        {
+            var (client, h) = NewClient();
+            var options = new EsiCallOptions { Page = 4 };
+            await Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/x/", options: options);
+
+            Assert.Contains("&page=4", h.Last.RequestUri.ToString());
+        }
+
+        [Fact]
+        public async Task IfNoneMatch_is_sent_quoted_and_tolerates_existing_quotes()
+        {
+            var (client, h) = NewClient();
+            await Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/x/",
+                options: new EsiCallOptions { IfNoneMatch = "\"already-quoted\"" });
+
+            Assert.Equal("\"already-quoted\"", h.Last.Headers.IfNoneMatch.ToString());
+        }
+
+        [Fact]
+        public async Task CancellationToken_flows_through_to_the_send()
+        {
+            var (client, _) = NewClient();
+            using var cts = new CancellationTokenSource();
+            cts.Cancel();
+
+            await Assert.ThrowsAnyAsync(() =>
+                Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/x/",
+                    options: new EsiCallOptions { CancellationToken = cts.Token }));
+        }
+
+        [Fact]
+        public async Task Null_options_is_tolerated_for_public_calls()
+        {
+            var (client, h) = NewClient();
+            await Execute(client, Config, RequestSecurity.Public, HttpMethod.Get, "/x/", options: null);
+
+            Assert.NotNull(h.Last);
+        }
+    }
+}
diff --git a/ESI.NET/EsiCallOptions.cs b/ESI.NET/EsiCallOptions.cs
new file mode 100644
index 0000000..6b28122
--- /dev/null
+++ b/ESI.NET/EsiCallOptions.cs
@@ -0,0 +1,33 @@
+using ESI.NET.Models.SSO;
+using System.Threading;
+
+namespace ESI.NET
+{
+    /// 
+    /// Per-call options threaded through .Execute. Replaces the old
+    /// EsiClient.SetCharacterData / SetIfNoneMatchHeader instance state, which was
+    /// shared across every call on the client (and, for the ETag, across the whole process).
+    /// 
+    public sealed class EsiCallOptions
+    {
+        /// 
+        /// The authorized character for endpoints that require SSO. Its Token is sent as the
+        /// bearer token and its CharacterID fills the {character_id} path segment.
+        /// Required for authenticated endpoints; ignored by public ones.
+        /// 
+        public AuthorizedCharacterData Character { get; set; }
+
+        /// Cancels the HTTP send and the response body read.
+        public CancellationToken CancellationToken { get; set; }
+
+        /// 
+        /// Sent as an If-None-Match header; a match yields 304 Not Modified with no
+        /// body. Pass the  from a previous response. Surrounding
+        /// quotes are optional.
+        /// 
+        public string IfNoneMatch { get; set; }
+
+        /// 1-based page for paginated endpoints; sent as ?page=.
+        public int? Page { get; set; }
+    }
+}
diff --git a/ESI.NET/EsiClient.cs b/ESI.NET/EsiClient.cs
index aa7f45a..f1d10dd 100644
--- a/ESI.NET/EsiClient.cs
+++ b/ESI.NET/EsiClient.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Logic;
-using ESI.NET.Models.SSO;
 using Microsoft.Extensions.Options;
 using System;
 using System.Net;
@@ -103,37 +102,6 @@ public EsiClient(IOptions _config, HttpClient _client = null)
         public WarsLogic Wars { get; set; }
 
 
-        public void SetCharacterData(AuthorizedCharacterData data)
-        {
-            Assets = new AssetsLogic(client, config, data);
-            Bookmarks = new BookmarksLogic(client, config, data);
-            Calendar = new CalendarLogic(client, config, data);
-            Character = new CharacterLogic(client, config, data);
-            Clones = new ClonesLogic(client, config, data);
-            Contacts = new ContactsLogic(client, config, data);
-            Contracts = new ContractsLogic(client, config, data);
-            Corporation = new CorporationLogic(client, config, data);
-            FactionWarfare = new FactionWarfareLogic(client, config, data);
-            Fittings = new FittingsLogic(client, config, data);
-            Fleets = new FleetsLogic(client, config, data);
-            Industry = new IndustryLogic(client, config, data);
-            Killmails = new KillmailsLogic(client, config, data);
-            Location = new LocationLogic(client, config, data);
-            Loyalty = new LoyaltyLogic(client, config, data);
-            Mail = new MailLogic(client, config, data);
-            Market = new MarketLogic(client, config, data);
-            Opportunities = new OpportunitiesLogic(client, config, data);
-            PlanetaryInteraction = new PlanetaryInteractionLogic(client, config, data);
-            Search = new SearchLogic(client, config, data);
-            Skills = new SkillsLogic(client, config, data);
-            UserInterface = new UserInterfaceLogic(client, config, data);
-            Wallet = new WalletLogic(client, config, data);
-            Universe = new UniverseLogic(client, config, data);
-        }
-
-        public void SetIfNoneMatchHeader(string eTag)
-            => EsiRequest.ETag = eTag;
-
         /// 
         /// Creates the  used when no  is supplied.
         /// 
@@ -196,8 +164,5 @@ public interface IEsiClient
         UserInterfaceLogic UserInterface { get; set; }
         WalletLogic Wallet { get; set; }
         WarsLogic Wars { get; set; }
-
-        void SetCharacterData(AuthorizedCharacterData data);
-        void SetIfNoneMatchHeader(string eTag);
     }
 }
diff --git a/ESI.NET/EsiRequest.cs b/ESI.NET/EsiRequest.cs
index 3425475..7810bda 100644
--- a/ESI.NET/EsiRequest.cs
+++ b/ESI.NET/EsiRequest.cs
@@ -10,10 +10,11 @@ namespace ESI.NET
 {
     internal static class EsiRequest
     {
-        internal static string ETag;
-
-        public static async Task> Execute(HttpClient client, EsiConfig config, RequestSecurity security, HttpMethod httpMethod, string endpoint, Dictionary replacements = null, string[] parameters = null, object body = null, string token = null)
+        public static async Task> Execute(HttpClient client, EsiConfig config, RequestSecurity security, HttpMethod httpMethod, string endpoint, EsiCallOptions options, Dictionary replacements = null, string[] parameters = null, object body = null)
         {
+            if (options == null)
+                options = new EsiCallOptions();
+
             var path = $"{httpMethod}|{endpoint}";
 
             if (replacements != null)
@@ -26,29 +27,30 @@ public static async Task> Execute(HttpClient client, EsiConfig
             if (parameters != null)
                 url += $"&{string.Join("&", parameters)}";
 
+            if (options.Page.HasValue)
+                url += $"&page={options.Page.Value}";
+
             var request = new HttpRequestMessage(httpMethod, url);
 
             //Attach token to request header if this endpoint requires an authorized character
             if (security == RequestSecurity.Authenticated)
             {
-                if (token == null)
-                    throw new ArgumentException("The request endpoint requires SSO authentication and a Token has not been provided.");
+                var token = options.Character?.Token;
+                if (string.IsNullOrEmpty(token))
+                    throw new ArgumentException("The request endpoint requires SSO authentication; EsiCallOptions.Character (with a valid Token) has not been provided.");
                 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
             }
 
-            if (ETag != null)
-            {
-                request.Headers.Add("If-None-Match", $"\"{ETag}\"");
-                ETag = null;
-            }
+            if (!string.IsNullOrEmpty(options.IfNoneMatch))
+                request.Headers.Add("If-None-Match", $"\"{options.IfNoneMatch.Trim('"')}\"");
 
             //Serialize post body data
             if (body != null)
                 request.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
 
             //Output final object
-            var response = await client.SendAsync(request).ConfigureAwait(false);
-            return await EsiResponse.CreateAsync(response, path).ConfigureAwait(false);
+            var response = await client.SendAsync(request, options.CancellationToken).ConfigureAwait(false);
+            return await EsiResponse.CreateAsync(response, path, options.CancellationToken).ConfigureAwait(false);
         }
 
         public enum RequestSecurity
diff --git a/ESI.NET/Logic/AllianceLogic.cs b/ESI.NET/Logic/AllianceLogic.cs
index 2433f17..d4f08ae 100644
--- a/ESI.NET/Logic/AllianceLogic.cs
+++ b/ESI.NET/Logic/AllianceLogic.cs
@@ -18,43 +18,51 @@ public class AllianceLogic
         /// /alliances/
         /// 
         /// 
-        public async Task> All()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/alliances/");
+        public async Task> All(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/alliances/",
+                options: options);
+
 
         /// 
         /// /alliances/{alliance_id}/
         /// 
         /// 
         /// 
-        public async Task> Information(int alliance_id)
+        public async Task> Information(int alliance_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/alliances/{alliance_id}/",
                 replacements: new Dictionary()
                 {
                     { "alliance_id", alliance_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /alliances/{alliance_id}/corporations/
         /// 
         /// 
         /// 
-        public async Task> Corporations(int alliance_id)
+        public async Task> Corporations(int alliance_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/alliances/{alliance_id}/corporations/",
                 replacements: new Dictionary()
                 {
                     { "alliance_id", alliance_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /alliances/{alliance_id}/icons/
         /// 
         /// 
         /// 
-        public async Task> Icons(int alliance_id)
+        public async Task> Icons(int alliance_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/alliances/{alliance_id}/icons/",
                 replacements: new Dictionary()
                 {
                     { "alliance_id", alliance_id.ToString() }
-                });
+                },
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/AssetsLogic.cs b/ESI.NET/Logic/AssetsLogic.cs
index 7a3d86d..db41adc 100644
--- a/ESI.NET/Logic/AssetsLogic.cs
+++ b/ESI.NET/Logic/AssetsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Assets;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,20 +10,11 @@ public class AssetsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public AssetsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public AssetsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                character_id = data.CharacterID;
-                corporation_id = data.CorporationID;
-            }
         }
 
         /// 
@@ -32,45 +22,41 @@ public AssetsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData
         /// 
         /// 
         /// 
-        public async Task>> ForCharacter(int page = 1)
+        public async Task>> ForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/assets/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/assets/locations/
         /// 
         /// 
         /// 
-        public async Task>> LocationsForCharacter(List item_ids)
+        public async Task>> LocationsForCharacter(List item_ids, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/assets/locations/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: item_ids.ToArray(),
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/assets/names/
         /// 
         /// 
         /// 
-        public async Task>> NamesForCharacter(List item_ids)
+        public async Task>> NamesForCharacter(List item_ids, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/assets/names/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: item_ids.ToArray(),
-                token: _data.Token);
+                options: options);
 
 
         /// 
@@ -78,44 +64,40 @@ public async Task>> NamesForCharacter(List item
         /// 
         /// 
         /// 
-        public async Task>> ForCorporation(int page = 1)
+        public async Task>> ForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/assets/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/assets/locations/
         /// 
         /// 
         /// 
-        public async Task>> LocationsForCorporation(List item_ids)
+        public async Task>> LocationsForCorporation(List item_ids, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/corporations/{corporation_id}/assets/locations/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
                 body: item_ids.ToArray(),
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/assets/names/
         /// 
         /// 
         /// 
-        public async Task>> NamesForCorporation(List item_ids)
+        public async Task>> NamesForCorporation(List item_ids, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/corporations/{corporation_id}/assets/names/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
                 body: item_ids.ToArray(),
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/BookmarksLogic.cs b/ESI.NET/Logic/BookmarksLogic.cs
index 3c5089a..ad705ce 100644
--- a/ESI.NET/Logic/BookmarksLogic.cs
+++ b/ESI.NET/Logic/BookmarksLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Bookmarks;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,84 +10,59 @@ public class BookmarksLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public BookmarksLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public BookmarksLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                corporation_id = data.CorporationID;
-                character_id = data.CharacterID;
-            }
         }
 
         /// 
         /// /characters/{character_id}/bookmarks/
         /// 
         /// 
-        public async Task>> ForCharacter(int page = 1)
+        public async Task>> ForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/bookmarks/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/bookmarks/folders/
         /// 
         /// 
-        public async Task>> FoldersForCharacter(int page = 1)
+        public async Task>> FoldersForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/bookmarks/folders/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/bookmarks/
         /// 
         /// 
-        public async Task>> ForCorporation(int page = 1)
+        public async Task>> ForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/bookmarks/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/bookmarks/folders/
         /// 
         /// 
-        public async Task>> FoldersForCorporation(int page = 1)
+        public async Task>> FoldersForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/bookmarks/folders/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/CalendarLogic.cs b/ESI.NET/Logic/CalendarLogic.cs
index 18da2b6..8f344ca 100644
--- a/ESI.NET/Logic/CalendarLogic.cs
+++ b/ESI.NET/Logic/CalendarLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Enumerations;
 using ESI.NET.Models.Calendar;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,44 +11,38 @@ public class CalendarLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public CalendarLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public CalendarLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/calendar/
         /// 
         /// 
-        public async Task>> Events()
+        public async Task>> Events(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/calendar/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/calendar/{event_id}/
         /// 
         /// 
         /// 
-        public async Task> Event(int event_id)
+        public async Task> Event(int event_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/calendar/{event_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "event_id", event_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/calendar/{event_id}/
@@ -57,31 +50,31 @@ public async Task> Event(int event_id)
         /// 
         /// 
         /// 
-        public async Task> Respond(int event_id, EventResponse eventResponse)
+        public async Task> Respond(int event_id, EventResponse eventResponse, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/characters/{character_id}/calendar/{event_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "event_id", event_id.ToString() }
                 },
                 body: new
                 {
                     response = eventResponse.ToEsiValue()
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// 
         /// 
         /// 
         /// 
-        public async Task>> Responses(int event_id)
+        public async Task>> Responses(int event_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/calendar/{event_id}/attendees/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "event_id", event_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/CharacterLogic.cs b/ESI.NET/Logic/CharacterLogic.cs
index 965ae03..3142401 100644
--- a/ESI.NET/Logic/CharacterLogic.cs
+++ b/ESI.NET/Logic/CharacterLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Models;
 using ESI.NET.Models.Character;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,17 +11,11 @@ public class CharacterLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public CharacterLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public CharacterLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
@@ -30,195 +23,201 @@ public CharacterLogic(HttpClient client, EsiConfig config, AuthorizedCharacterDa
         /// 
         /// dynamic = long
         /// 
-        public async Task>> Affiliation(int[] character_ids)
+        public async Task>> Affiliation(int[] character_ids, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Post, "/characters/affiliation/",
-                body: character_ids);
+                body: character_ids,
+                options: options);
+
 
         /// 
         /// /characters/names/
         /// 
         /// 
         /// 
-        public async Task>> Names(int[] character_ids)
+        public async Task>> Names(int[] character_ids, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/characters/names/",
                 parameters: new string[]
                 {
                     $"character_ids={string.Join(",", character_ids)}"
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/
         /// 
         /// 
         /// 
-        public async Task> Information(int character_id)
+        public async Task> Information(int character_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/characters/{character_id}/",
                 replacements: new Dictionary()
                 {
                     { "character_id", character_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/agents_research/
         /// 
         /// 
-        public async Task>> AgentsResearch()
+        public async Task>> AgentsResearch(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/agents_research/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/blueprints/
         /// 
         /// Which page of results to return
         /// 
-        public async Task>> Blueprints(int page = 1)
+        public async Task>> Blueprints(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/blueprints/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token,
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /characters/{character_id}/chat_channels/
         /// 
         /// 
-        public async Task>> ChatChannels()
+        public async Task>> ChatChannels(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/chat_channels/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/corporationhistory/
         /// 
         /// 
         /// 
-        public async Task>> CorporationHistory(int character_id)
+        public async Task>> CorporationHistory(int character_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/characters/{character_id}/corporationhistory/",
                 replacements: new Dictionary()
                 {
                     { "character_id", character_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/cspa/
         /// 
         /// The target characters to calculate the charge for
         /// 
-        public async Task> CSPA(object character_ids)
+        public async Task> CSPA(object character_ids, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/cspa/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: character_ids,
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/fatigue/
         /// 
         /// 
-        public async Task> Fatigue()
+        public async Task> Fatigue(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/fatigue/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/medals/
         /// 
         /// 
-        public async Task>> Medals()
+        public async Task>> Medals(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/medals/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/notifications/
         /// 
         /// 
-        public async Task>> Notifications()
+        public async Task>> Notifications(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/notifications/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/notifications/contacts/
         /// 
         /// 
-        public async Task>> ContactNotifications()
+        public async Task>> ContactNotifications(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/notifications/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/portrait/
         /// 
         /// 
         /// 
-        public async Task> Portrait(int character_id)
+        public async Task> Portrait(int character_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/characters/{character_id}/portrait/",
                 replacements: new Dictionary()
                 {
                     { "character_id", character_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/roles/
         /// 
         /// 
-        public async Task> Roles()
+        public async Task> Roles(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/roles/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/standings/
         /// 
         /// 
-        public async Task>> Standings()
+        public async Task>> Standings(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/standings/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/titles/
         /// 
         /// 
-        public async Task>> Titles()
+        public async Task>> Titles(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/titles/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/ClonesLogic.cs b/ESI.NET/Logic/ClonesLogic.cs
index a03e7c4..b42c609 100644
--- a/ESI.NET/Logic/ClonesLogic.cs
+++ b/ESI.NET/Logic/ClonesLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Clones;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,41 +10,35 @@ public class ClonesLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public ClonesLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public ClonesLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/clones/
         /// 
         /// 
-        public async Task> List()
+        public async Task> List(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/clones/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/implants/
         /// 
         /// 
-        public async Task> Implants()
+        public async Task> Implants(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/implants/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/ContactsLogic.cs b/ESI.NET/Logic/ContactsLogic.cs
index 55a25a7..d55df80 100644
--- a/ESI.NET/Logic/ContactsLogic.cs
+++ b/ESI.NET/Logic/ContactsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Contacts;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Linq;
 using System.Net.Http;
@@ -12,22 +11,11 @@ public class ContactsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
 
-        private readonly int character_id, corporation_id, alliance_id;
-
-        public ContactsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public ContactsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (_data != null)
-            {
-                character_id = _data.CharacterID;
-                corporation_id = _data.CorporationID;
-                alliance_id = _data.AllianceID;
-            }
         }
 
         /// 
@@ -35,51 +23,39 @@ public ContactsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterDat
         /// 
         /// 
         /// 
-        public async Task>> ListForCharacter(int page = 1)
+        public async Task>> ListForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/contacts/
         /// 
         /// 
         /// 
-        public async Task>> ListForCorporation(int page = 1)
+        public async Task>> ListForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /alliances/{alliance_id}/contacts/
         /// 
         /// 
         /// 
-        public async Task>> ListForAlliance(int page = 1)
+        public async Task>> ListForAlliance(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/alliances/{alliance_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "alliance_id", alliance_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "alliance_id", options.Character.AllianceID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/contacts/
@@ -89,7 +65,7 @@ public async Task>> ListForAlliance(int page = 1)
         /// 
         /// 
         /// 
-        public async Task> Add(int[] contact_ids, decimal standing, int[] label_ids = null, bool? watched = null)
+        public async Task> Add(int[] contact_ids, decimal standing, int[] label_ids = null, bool? watched = null, EsiCallOptions options = null)
         {
             var body = contact_ids;
 
@@ -104,11 +80,11 @@ public async Task> Add(int[] contact_ids, decimal standing, i
             return await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: parameters.ToArray(),
                 body: body,
-                token: _data.Token);
+                options: options);
         }
 
         /// 
@@ -119,7 +95,7 @@ public async Task> Add(int[] contact_ids, decimal standing, i
         /// 
         /// 
         /// 
-        public async Task> Update(int[] contact_ids, decimal standing, int[] label_ids = null, bool? watched = null)
+        public async Task> Update(int[] contact_ids, decimal standing, int[] label_ids = null, bool? watched = null, EsiCallOptions options = null)
         {
             var body = contact_ids;
 
@@ -134,11 +110,11 @@ public async Task> Update(int[] contact_ids, decimal standin
             return await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/characters/{character_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: parameters.ToArray(),
                 body: body,
-                token: _data.Token);
+                options: options);
         }
 
         /// 
@@ -146,52 +122,52 @@ public async Task> Update(int[] contact_ids, decimal standin
         /// 
         /// 
         /// 
-        public async Task> Delete(int[] contact_ids)
+        public async Task> Delete(int[] contact_ids, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/characters/{character_id}/contacts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: new string[]
                 {
                     $"contact_ids={string.Join(",", contact_ids)}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/contacts/labels/
         /// 
         /// 
-        public async Task>> LabelsForCharacter()
+        public async Task>> LabelsForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/contacts/labels/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/contacts/labels/
         /// 
         /// 
-        public async Task>> LabelsForCorporation()
+        public async Task>> LabelsForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/contacts/labels/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /alliances/{alliance_id}/contacts/labels/
         /// 
         /// 
-        public async Task>> LabelsForAlliance()
+        public async Task>> LabelsForAlliance(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/alliances/{alliance_id}/contacts/labels/",
                 replacements: new Dictionary()
                 {
-                    { "alliance_id", alliance_id.ToString() }
+                    { "alliance_id", options.Character.AllianceID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/ContractsLogic.cs b/ESI.NET/Logic/ContractsLogic.cs
index a83c643..d880553 100644
--- a/ESI.NET/Logic/ContractsLogic.cs
+++ b/ESI.NET/Logic/ContractsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Contracts;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,20 +10,11 @@ public class ContractsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public ContractsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public ContractsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                character_id = data.CharacterID;
-                corporation_id = data.CorporationID;
-            }
         }
 
         /// 
@@ -32,151 +22,118 @@ public ContractsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterDa
         /// 
         /// 
         /// 
-        public async Task>> Contracts(int region_id, int page = 1)
+        public async Task>> Contracts(int region_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/contracts/public/{region_id}/",
                 replacements: new Dictionary()
                 {
                     { "region_id", region_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /contracts/public/items/{contract_id}/
         /// 
         /// 
         /// 
-        public async Task>> ContractItems(int contract_id, int page = 1)
+        public async Task>> ContractItems(int contract_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/contracts/public/items/{contract_id}/",
                 replacements: new Dictionary()
                 {
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// "/contracts/public/bids/{contract_id}/
         /// 
         /// 
         /// 
-        public async Task>> ContractBids(int contract_id, int page = 1)
+        public async Task>> ContractBids(int contract_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/contracts/public/bids/{contract_id}/",
                 replacements: new Dictionary()
                 {
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /characters/{character_id}/contracts/
         /// 
         /// 
-        public async Task>> CharacterContracts(int page = 1)
+        public async Task>> CharacterContracts(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/contracts/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/contracts/{contract_id}/items/
         /// 
         /// 
         /// 
-        public async Task>> CharacterContractItems(int contract_id, int page = 1)
+        public async Task>> CharacterContractItems(int contract_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/contracts/{contract_id}/items/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/contracts/{contract_id}/bids/
         /// 
         /// 
         /// 
-        public async Task>> CharacterContractBids(int contract_id, int page = 1)
+        public async Task>> CharacterContractBids(int contract_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/contracts/{contract_id}/bids/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/contracts/
         /// 
         /// 
-        public async Task>> CorporationContracts(int page = 1)
+        public async Task>> CorporationContracts(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/contracts/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/contracts/{contract_id}/items/
         /// 
         /// 
         /// 
-        public async Task>> CorporationContractItems(int contract_id, int page = 1)
+        public async Task>> CorporationContractItems(int contract_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/contracts/{contract_id}/items/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/contracts/{contract_id}/bids/
         /// 
         /// 
         /// 
-        public async Task>> CorporationContractBids(int contract_id, int page = 1)
+        public async Task>> CorporationContractBids(int contract_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/contracts/{contract_id}/bids/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "contract_id", contract_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/CorporationLogic.cs b/ESI.NET/Logic/CorporationLogic.cs
index ec08b91..a9caf91 100644
--- a/ESI.NET/Logic/CorporationLogic.cs
+++ b/ESI.NET/Logic/CorporationLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Models;
 using ESI.NET.Models.Corporation;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,274 +11,250 @@ public class CorporationLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int corporation_id;
 
-        public CorporationLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public CorporationLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                corporation_id = data.CorporationID;
         }
 
         /// 
         /// /corporations/npccorps/
         /// 
         /// 
-        public async Task> NpcCorps()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/corporations/npccorps/");
+        public async Task> NpcCorps(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/corporations/npccorps/",
+                options: options);
+
 
         /// 
         /// /corporations/{corporation_id}/
         /// 
         /// 
         /// 
-        public async Task> Information(int corporation_id)
+        public async Task> Information(int corporation_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/corporations/{corporation_id}/",
                 replacements: new Dictionary()
                 {
                     { "corporation_id", corporation_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /corporations/{corporation_id}/alliancehistory/
         /// 
         /// 
         /// 
-        public async Task>> AllianceHistory(int corporation_id)
+        public async Task>> AllianceHistory(int corporation_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/corporations/{corporation_id}/alliancehistory/",
                 replacements: new Dictionary()
                 {
                     { "corporation_id", corporation_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /corporations/{corporation_id}/blueprints/
         /// 
         /// 
         /// 
-        public async Task>> Blueprints(int page = 1)
+        public async Task>> Blueprints(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/blueprints/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/containers/logs/
         /// 
         /// 
         /// 
-        public async Task>> ContainerLogs(int page = 1)
+        public async Task>> ContainerLogs(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/containers/logs/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/divisions/
         /// 
         /// 
-        public async Task> Divisions()
+        public async Task> Divisions(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/divisions/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/facilities/
         /// 
         /// 
-        public async Task>> Facilities()
+        public async Task>> Facilities(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/facilities/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/icons/
         /// 
         /// 
         /// 
-        public async Task> Icons(int corporation_id)
+        public async Task> Icons(int corporation_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/corporations/{corporation_id}/icons/",
                 replacements: new Dictionary()
                 {
                     { "corporation_id", corporation_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /corporations/{corporation_id}/medals/
         /// 
         /// 
         /// 
-        public async Task>> Medals(int page = 1)
+        public async Task>> Medals(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/medals/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                }, parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/medals/issued/
         /// 
         /// 
         /// 
-        public async Task>> MedalsIssued(int page = 1)
+        public async Task>> MedalsIssued(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/medals/issued/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/members/
         /// 
         /// 
-        public async Task> Members()
+        public async Task> Members(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/members/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/members/limit/
         /// 
         /// 
-        public async Task> MemberLimit()
+        public async Task> MemberLimit(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/members/limit/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/members/titles/
         /// 
         /// 
-        public async Task>> MemberTitles()
+        public async Task>> MemberTitles(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/members/titles/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/membertracking/
         /// 
         /// 
-        public async Task>> MemberTracking()
+        public async Task>> MemberTracking(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/membertracking/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/roles/
         /// 
         /// 
-        public async Task>> Roles()
+        public async Task>> Roles(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/roles/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/roles/history/
         /// 
         /// 
-        public async Task>> RolesHistory()
+        public async Task>> RolesHistory(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/roles/history/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/shareholders/
         /// 
         /// 
         /// 
-        public async Task>> Shareholders(int page = 1)
+        public async Task>> Shareholders(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/shareholders/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                }, parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/standings/
         /// 
         /// 
         /// 
-        public async Task> Standings(int page = 1)
+        public async Task> Standings(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/standings/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/starbases/
         /// 
         /// 
         /// 
-        public async Task>> Starbases(int page = 1)
+        public async Task>> Starbases(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/starbases/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/starbases/{starbase_id}/
@@ -287,45 +262,41 @@ public async Task>> Starbases(int page = 1)
         /// 
         /// 
         /// 
-        public async Task> Starbase(long starbase_id, int system_id)
+        public async Task> Starbase(long starbase_id, int system_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/starbases/{starbase_id}/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "starbase_id", starbase_id.ToString() }
                 },
                 parameters: new string[]
                 {
                     $"system_id={system_id}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/structures/
         /// 
         /// 
-        public async Task>> Structures(int page = 1)
+        public async Task>> Structures(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/structures/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/titles/
         /// 
         /// 
-        public async Task>> Titles()
+        public async Task>> Titles(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/titles/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/DogmaLogic.cs b/ESI.NET/Logic/DogmaLogic.cs
index 2a22be9..4aa2f23 100644
--- a/ESI.NET/Logic/DogmaLogic.cs
+++ b/ESI.NET/Logic/DogmaLogic.cs
@@ -17,39 +17,47 @@ public class DogmaLogic
         /// /dogma/attributes/
         /// 
         /// 
-        public async Task> Attributes()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/attributes/");
+        public async Task> Attributes(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/attributes/",
+                options: options);
+
 
         /// 
         /// /dogma/attributes/{attribute_id}/
         /// 
         /// 
         /// 
-        public async Task> Attribute(int attribute_id)
+        public async Task> Attribute(int attribute_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/attributes/{attribute_id}/",
                 replacements: new Dictionary()
                 {
                     { "attribute_id", attribute_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /dogma/effects/
         /// 
         /// 
-        public async Task> Effects()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/effects/");
+        public async Task> Effects(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/effects/",
+                options: options);
+
 
         /// 
         /// /dogma/effects/{effect_id}/
         /// 
         /// 
         /// 
-        public async Task> Effect(int effect_id)
+        public async Task> Effect(int effect_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/effects/{effect_id}/",
                 replacements: new Dictionary()
                 {
                     { "effect_id", effect_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /dogma/dynamic/items/{type_id}/{item_id}/
@@ -57,12 +65,14 @@ public async Task> Effect(int effect_id)
         /// 
         /// 
         /// 
-        public async Task> DynamicItem(int type_id, long item_id)
+        public async Task> DynamicItem(int type_id, long item_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/dogma/dynamic/items/{type_id}/{item_id}/",
                 replacements: new Dictionary()
                 {
                     { "type_id", type_id.ToString() },
                     { "item_id", item_id.ToString() }
-                });
+                },
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/FactionWarfareLogic.cs b/ESI.NET/Logic/FactionWarfareLogic.cs
index 8d0b15b..0148ecc 100644
--- a/ESI.NET/Logic/FactionWarfareLogic.cs
+++ b/ESI.NET/Logic/FactionWarfareLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.FactionWarfare;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,86 +10,89 @@ public class FactionWarfareLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public FactionWarfareLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public FactionWarfareLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                corporation_id = data.CorporationID;
-                character_id = data.CharacterID;
-            }
         }
 
         /// 
         /// /fw/wars/
         /// 
         /// 
-        public async Task>> List()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/wars/");
+        public async Task>> List(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/wars/",
+                options: options);
+
 
         /// 
         /// /fw/stats/
         /// 
         /// 
-        public async Task>> Stats()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/stats/");
+        public async Task>> Stats(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/stats/",
+                options: options);
+
 
         /// 
         /// /fw/systems/
         /// 
         /// 
-        public async Task>> Systems()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/systems/");
+        public async Task>> Systems(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/systems/",
+                options: options);
+
 
         /// 
         /// fw/leaderboards/
         /// 
         /// 
-        public async Task>> Leaderboads()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/");
+        public async Task>> Leaderboads(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/",
+                options: options);
+
 
         /// 
         /// /fw/leaderboards/corporations/
         /// 
         /// 
-        public async Task>> LeaderboardsForCorporations()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/corporations/");
+        public async Task>> LeaderboardsForCorporations(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/corporations/",
+                options: options);
+
 
         /// 
         /// /fw/leaderboards/characters/
         /// 
         /// 
-        public async Task>> LeaderboardsForCharacters()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/characters/");
+        public async Task>> LeaderboardsForCharacters(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/fw/leaderboards/characters/",
+                options: options);
+
 
         /// 
         /// /corporations/{corporation_id}/fw/stats/
         /// 
         /// 
-        public async Task> StatsForCorporation()
+        public async Task> StatsForCorporation(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/fw/stats/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/fw/stats/
         /// 
         /// 
-        public async Task> StatsForCharacter()
+        public async Task> StatsForCharacter(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/fw/stats/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/FittingsLogic.cs b/ESI.NET/Logic/FittingsLogic.cs
index 5e8a510..662c70d 100644
--- a/ESI.NET/Logic/FittingsLogic.cs
+++ b/ESI.NET/Logic/FittingsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Fittings;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,57 +10,51 @@ public class FittingsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public FittingsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public FittingsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/fittings/
         /// 
         /// 
-        public async Task>> List()
+        public async Task>> List(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/fittings/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/fittings/
         /// 
         /// 
         /// 
-        public async Task> Add(object fitting)
+        public async Task> Add(object fitting, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/fittings/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: fitting,
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/fittings/{fitting_id}/
         /// 
         /// 
         /// 
-        public async Task> Delete(int fitting_id)
+        public async Task> Delete(int fitting_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/characters/{character_id}/fittings/{fitting_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "fitting_id", fitting_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/FleetsLogic.cs b/ESI.NET/Logic/FleetsLogic.cs
index d316265..53bcd99 100644
--- a/ESI.NET/Logic/FleetsLogic.cs
+++ b/ESI.NET/Logic/FleetsLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Enumerations;
 using ESI.NET.Models.Fleets;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,17 +11,11 @@ public class FleetsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public FleetsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public FleetsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
@@ -30,13 +23,13 @@ public FleetsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData
         /// 
         /// 
         /// 
-        public async Task> Settings(long fleet_id)
+        public async Task> Settings(long fleet_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/fleets/{fleet_id}/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/
@@ -45,39 +38,39 @@ public async Task> Settings(long fleet_id)
         /// 
         /// 
         /// 
-        public async Task> UpdateSettings(long fleet_id, string motd = null, bool? is_free_move = null)
+        public async Task> UpdateSettings(long fleet_id, string motd = null, bool? is_free_move = null, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/fleets/{fleet_id}/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
                 body: BuildUpdateSettingsObject(motd, is_free_move),
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/fleet/
         /// 
         /// 
-        public async Task> FleetInfo()
+        public async Task> FleetInfo(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/fleet/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/members/
         /// 
         /// 
         /// 
-        public async Task>> Members(long fleet_id)
+        public async Task>> Members(long fleet_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/fleets/{fleet_id}/members/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/members/
@@ -88,14 +81,14 @@ public async Task>> Members(long fleet_id)
         /// 
         /// 
         /// 
-        public async Task> InviteCharacter(long fleet_id, int character_id, FleetRole role, long wing_id = 0, long squad_id = 0)
+        public async Task> InviteCharacter(long fleet_id, int character_id, FleetRole role, long wing_id = 0, long squad_id = 0, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/fleets/{fleet_id}/members/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
                 body: BuildFleetInviteObject(character_id, role, wing_id, squad_id),
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/members/{member_id}/
@@ -106,15 +99,15 @@ public async Task> InviteCharacter(long fleet_id, int charac
         /// 
         /// 
         /// 
-        public async Task> MoveCharacter(long fleet_id, int member_id, FleetRole role, long wing_id = 0, long squad_id = 0)
+        public async Task> MoveCharacter(long fleet_id, int member_id, FleetRole role, long wing_id = 0, long squad_id = 0, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/fleets/{fleet_id}/members/{member_id}/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() },
                     { "member_id", member_id.ToString() }
                 },
-                body: BuildFleetInviteObject(character_id, role, wing_id, squad_id),
-                token: _data.Token);
+                body: BuildFleetInviteObject(options.Character.CharacterID, role, wing_id, squad_id),
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/members/{member_id}/
@@ -122,40 +115,40 @@ public async Task> MoveCharacter(long fleet_id, int member_i
         /// 
         /// 
         /// 
-        public async Task> KickCharacter(long fleet_id, int member_id)
+        public async Task> KickCharacter(long fleet_id, int member_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/fleets/{fleet_id}/members/{member_id}/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() },
                     { "member_id", member_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/wings/
         /// 
         /// 
         /// 
-        public async Task>> Wings(long fleet_id)
+        public async Task>> Wings(long fleet_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/fleets/{fleet_id}/wings/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/wings/
         /// 
         /// 
         /// 
-        public async Task> CreateWing(long fleet_id)
+        public async Task> CreateWing(long fleet_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/fleets/{fleet_id}/wings/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/wings/{wing_id}/
@@ -164,7 +157,7 @@ public async Task> CreateWing(long fleet_id)
         /// 
         /// 
         /// 
-        public async Task> RenameWing(long fleet_id, long wing_id, string name)
+        public async Task> RenameWing(long fleet_id, long wing_id, string name, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/fleets/{fleet_id}/wings/{wing_id}/",
                 replacements: new Dictionary()
                 {
@@ -175,7 +168,7 @@ public async Task> RenameWing(long fleet_id, long wing_id, s
                 {
                     name
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/wings/{wing_id}/
@@ -183,14 +176,14 @@ public async Task> RenameWing(long fleet_id, long wing_id, s
         /// 
         /// 
         /// 
-        public async Task> DeleteWing(long fleet_id, long wing_id)
+        public async Task> DeleteWing(long fleet_id, long wing_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/fleets/{fleet_id}/wings/{wing_id}/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() },
                     { "wing_id", wing_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/wings/{wing_id}/squads/
@@ -198,14 +191,14 @@ public async Task> DeleteWing(long fleet_id, long wing_id)
         /// 
         /// 
         /// 
-        public async Task> CreateSquad(long fleet_id, long wing_id)
+        public async Task> CreateSquad(long fleet_id, long wing_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/fleets/{fleet_id}/wings/{wing_id}/squads/",
                 replacements: new Dictionary()
                 {
                     { "fleet_id", fleet_id.ToString() },
                     { "wing_id", wing_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /fleets/{fleet_id}/squads/{squad_id}/
@@ -214,7 +207,7 @@ public async Task> CreateSquad(long fleet_id, long wing_id
         /// 
         /// 
         /// 
-        public async Task> RenameSquad(long fleet_id, long squad_id, string name)
+        public async Task> RenameSquad(long fleet_id, long squad_id, string name, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/fleets/{fleet_id}/squads/{squad_id}/", replacements: new Dictionary()
             {
                 { "fleet_id", fleet_id.ToString() },
@@ -222,7 +215,7 @@ public async Task> RenameSquad(long fleet_id, long squad_id,
             }, body: new
             {
                 name
-            }, token: _data.Token);
+            }, options: options);
 
         /// 
         /// /fleets/{fleet_id}/squads/{squad_id}/
@@ -230,12 +223,12 @@ public async Task> RenameSquad(long fleet_id, long squad_id,
         /// 
         /// 
         /// 
-        public async Task> DeleteSquad(long fleet_id, long squad_id)
+        public async Task> DeleteSquad(long fleet_id, long squad_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/fleets/{fleet_id}/squads/{squad_id}/", replacements: new Dictionary()
             {
                 { "fleet_id", fleet_id.ToString() },
                 { "squad_id", squad_id.ToString() }
-            }, token: _data.Token);
+            }, options: options);
         
         /// 
         /// 
diff --git a/ESI.NET/Logic/IncursionsLogic.cs b/ESI.NET/Logic/IncursionsLogic.cs
index 29c1b23..728ea38 100644
--- a/ESI.NET/Logic/IncursionsLogic.cs
+++ b/ESI.NET/Logic/IncursionsLogic.cs
@@ -17,7 +17,9 @@ public class IncursionsLogic
         /// /incursions/
         /// 
         /// 
-        public async Task>> All()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/incursions/");
+        public async Task>> All(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/incursions/",
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/IndustryLogic.cs b/ESI.NET/Logic/IndustryLogic.cs
index bdc8848..da7ba3f 100644
--- a/ESI.NET/Logic/IndustryLogic.cs
+++ b/ESI.NET/Logic/IndustryLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Industry;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,86 +10,73 @@ public class IndustryLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public IndustryLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public IndustryLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                corporation_id = data.CorporationID;
-                character_id = data.CharacterID;
-            }
         }
 
         /// 
         /// /industry/facilities/
         /// 
         /// 
-        public async Task>> Facilities()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/industry/facilities/");
+        public async Task>> Facilities(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/industry/facilities/",
+                options: options);
+
 
         /// 
         /// /industry/systems/
         /// 
         /// 
-        public async Task>> SolarSystemCostIndices()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/industry/systems/");
+        public async Task>> SolarSystemCostIndices(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/industry/systems/",
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/industry/jobs/
         /// 
         /// 
         /// 
-        public async Task>> JobsForCharacter(bool include_completed = false)
+        public async Task>> JobsForCharacter(bool include_completed = false, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/industry/jobs/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: new string[]
                 {
                     $"include_completed={include_completed}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mining/
         /// 
         /// 
         /// 
-        public async Task>> MiningLedger(int page = 1)
+        public async Task>> MiningLedger(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/mining/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporation/{corporation_id}/mining/observers/
         /// 
         /// 
         /// 
-        public async Task>> Observers(int page = 1)
+        public async Task>> Observers(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporation/{corporation_id}/mining/observers/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporation/{corporation_id}/mining/observers/{observer_id}/
@@ -98,18 +84,14 @@ public async Task>> Observers(int page = 1)
         /// 
         /// 
         /// 
-        public async Task>> ObservedMining(long observer_id, int page = 1)
+        public async Task>> ObservedMining(long observer_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporation/{corporation_id}/mining/observers/{observer_id}/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "observer_id", observer_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/industry/jobs/
@@ -117,29 +99,28 @@ public async Task>> ObservedMining(long observer_
         /// 
         /// 
         /// 
-        public async Task>> JobsForCorporation(bool include_completed = false, int page = 1)
+        public async Task>> JobsForCorporation(bool include_completed = false, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/industry/jobs/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
                 parameters: new string[]
                 {
-                    $"include_completed={include_completed}",
-                    $"page={page}"
+                    $"include_completed={include_completed}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporation/{corporation_id}/mining/extractions/
         /// 
         /// 
-        public async Task>> Extractions()
+        public async Task>> Extractions(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporation/{corporation_id}/mining/extractions/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/InsuranceLogic.cs b/ESI.NET/Logic/InsuranceLogic.cs
index 27d6165..e9ee034 100644
--- a/ESI.NET/Logic/InsuranceLogic.cs
+++ b/ESI.NET/Logic/InsuranceLogic.cs
@@ -17,7 +17,9 @@ public class InsuranceLogic
         /// /insurance/prices/
         /// 
         /// 
-        public async Task>> Levels()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/insurance/prices/");
+        public async Task>> Levels(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/insurance/prices/",
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/KillmailsLogic.cs b/ESI.NET/Logic/KillmailsLogic.cs
index 272338a..adbcd45 100644
--- a/ESI.NET/Logic/KillmailsLogic.cs
+++ b/ESI.NET/Logic/KillmailsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Killmails;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,20 +10,11 @@ public class KillmailsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public KillmailsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public KillmailsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                character_id = data.CharacterID;
-                corporation_id = data.CorporationID;
-            }
         }
 
         /// 
@@ -32,34 +22,26 @@ public KillmailsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterDa
         /// 
         /// 
         /// 
-        public async Task>> ForCharacter(int page = 1)
+        public async Task>> ForCharacter(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/killmails/recent/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/killmails/recent/
         /// 
         /// 
         /// 
-        public async Task>> ForCorporation(int page = 1)
+        public async Task>> ForCorporation(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/killmails/recent/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /killmails/{killmail_id}/{killmail_hash}/
@@ -67,12 +49,14 @@ public async Task>> ForCorporation(int page = 1)
         /// The killmail hash for verification
         /// The killmail ID to be queried
         /// 
-        public async Task> Information(string killmail_hash, int killmail_id)
+        public async Task> Information(string killmail_hash, int killmail_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/killmails/{killmail_id}/{killmail_hash}/",
                 replacements: new Dictionary()
                 {
                     { "killmail_id", killmail_id.ToString() },
                     { "killmail_hash", killmail_hash.ToString() }
-                });
+                },
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/LocationLogic.cs b/ESI.NET/Logic/LocationLogic.cs
index 0583c5a..9766d1f 100644
--- a/ESI.NET/Logic/LocationLogic.cs
+++ b/ESI.NET/Logic/LocationLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Location;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,53 +10,47 @@ public class LocationLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public LocationLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public LocationLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/location/
         /// 
         /// 
-        public async Task> Location()
+        public async Task> Location(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/location/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/ship/
         /// 
         /// 
-        public async Task> Ship()
+        public async Task> Ship(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/ship/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/online/
         /// 
         /// 
-        public async Task> Online()
+        public async Task> Online(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/online/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/LoyaltyLogic.cs b/ESI.NET/Logic/LoyaltyLogic.cs
index 7c2e312..5d1ff72 100644
--- a/ESI.NET/Logic/LoyaltyLogic.cs
+++ b/ESI.NET/Logic/LoyaltyLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Loyalty;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,40 +10,36 @@ public class LoyaltyLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public LoyaltyLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public LoyaltyLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /loyalty/stores/{corporation_id}/offers/
         /// 
         /// 
-        public async Task>> Offers(int corporation_id)
+        public async Task>> Offers(int corporation_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/loyalty/stores/{corporation_id}/offers/",
                 replacements: new Dictionary()
                 {
                     { "corporation_id", corporation_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/loyalty/points/
         /// 
         /// 
-        public async Task>> Points()
+        public async Task>> Points(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/loyalty/points/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/MailLogic.cs b/ESI.NET/Logic/MailLogic.cs
index e708dd9..01f3a6b 100644
--- a/ESI.NET/Logic/MailLogic.cs
+++ b/ESI.NET/Logic/MailLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Mail;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,24 +10,18 @@ public class MailLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public MailLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public MailLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/mail/
         /// 
         /// 
-        public async Task>> Headers(long[] labels = null, int last_mail_id = 0)
+        public async Task>> Headers(long[] labels = null, int last_mail_id = 0, EsiCallOptions options = null)
         {
             var parameters = new List();
 
@@ -41,10 +34,10 @@ public async Task>> Headers(long[] labels = null, int l
             var response = await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/mail/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: parameters.ToArray(),
-                token: _data.Token);
+                options: options);
 
             return response;
         }
@@ -57,11 +50,11 @@ public async Task>> Headers(long[] labels = null, int l
         /// 
         /// 
         /// 
-        public async Task> New(object[] recipients, string subject, string body, int approved_cost = 0)
+        public async Task> New(object[] recipients, string subject, string body, int approved_cost = 0, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/mail/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: new
                 {
@@ -70,19 +63,19 @@ public async Task> New(object[] recipients, string subject, str
                     body,
                     approved_cost
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/labels/
         /// 
         /// 
-        public async Task> Labels()
+        public async Task> Labels(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/mail/labels/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/labels/
@@ -90,58 +83,58 @@ public async Task> Labels()
         /// 
         /// 
         /// 
-        public async Task> NewLabel(string name, string color)
+        public async Task> NewLabel(string name, string color, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/characters/{character_id}/mail/labels/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 body: new
                 {
                     name,
                     color
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/labels/{label_id}/
         /// 
         /// 
         /// 
-        public async Task> DeleteLabel(long label_id)
+        public async Task> DeleteLabel(long label_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/characters/{character_id}/mail/labels/{label_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "label_id", label_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/lists/
         /// 
         /// 
-        public async Task>> MailingLists()
+        public async Task>> MailingLists(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/mail/lists/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/{mail_id}/
         /// 
         /// 
         /// 
-        public async Task> Retrieve(int mail_id)
+        public async Task> Retrieve(int mail_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/mail/{mail_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "mail_id", mail_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/mail/{mail_id}/
@@ -150,29 +143,29 @@ public async Task> Retrieve(int mail_id)
         /// 
         /// 
         /// 
-        public async Task> Update(int mail_id, bool? is_read = null, int[] labels = null)
+        public async Task> Update(int mail_id, bool? is_read = null, int[] labels = null, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Put, "/characters/{character_id}/mail/{mail_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "mail_id", mail_id.ToString() }
                 },
                 body: BuildUpdateObject(is_read, labels),
-                token: _data.Token);
+                options: options);
         
         /// 
         /// /characters/{character_id}/mail/{mail_id}/
         /// 
         /// 
         /// 
-        public async Task> Delete(int mail_id)
+        public async Task> Delete(int mail_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Delete, "/characters/{character_id}/mail/{mail_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "mail_id", mail_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// 
diff --git a/ESI.NET/Logic/MarketLogic.cs b/ESI.NET/Logic/MarketLogic.cs
index 08cea6f..e6b9088 100644
--- a/ESI.NET/Logic/MarketLogic.cs
+++ b/ESI.NET/Logic/MarketLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Enumerations;
 using ESI.NET.Models.Market;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,28 +11,21 @@ public class MarketLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public MarketLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public MarketLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                corporation_id = data.CorporationID;
-                character_id = data.CharacterID;
-            }
         }
 
         /// 
         /// /markets/prices/
         /// 
         /// 
-        public async Task>> Prices()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/prices/");
+        public async Task>> Prices(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/prices/",
+                options: options);
+
 
         /// 
         /// /markets/{region_id}/orders/
@@ -44,13 +36,12 @@ public async Task>> Prices()
         /// 
         /// 
         public async Task>> RegionOrders(
-            int region_id, 
-            MarketOrderType order_type = MarketOrderType.All, 
-            int page = 1, 
-            int? type_id = null)
+            int region_id,
+            MarketOrderType order_type = MarketOrderType.All,
+            int? type_id = null,
+            EsiCallOptions options = null)
         {
             var parameters = new List() { $"order_type={order_type.ToEsiValue()}" };
-            parameters.Add($"page={page}");
 
             if (type_id != null)
                 parameters.Add($"type_id={type_id}");
@@ -60,7 +51,8 @@ public async Task>> RegionOrders(
                 {
                     { "region_id", region_id.ToString() }
                 },
-                parameters: parameters.ToArray());
+                parameters: parameters.ToArray(),
+                options: options);
 
             return response;
         }
@@ -71,7 +63,7 @@ public async Task>> RegionOrders(
         /// 
         /// 
         /// 
-        public async Task>> TypeHistoryInRegion(int region_id, int type_id)
+        public async Task>> TypeHistoryInRegion(int region_id, int type_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/{region_id}/history/",
                 replacements: new Dictionary()
                 {
@@ -80,7 +72,9 @@ public async Task>> TypeHistoryInRegion(int region_i
                 parameters: new string[]
                 {
                     $"type_id={type_id}"
-                });
+                },
+                options: options);
+
 
         /// 
         /// /markets/structures/{structure_id}/
@@ -88,65 +82,61 @@ public async Task>> TypeHistoryInRegion(int region_i
         /// 
         /// 
         /// 
-        public async Task>> StructureOrders(long structure_id, int page = 1)
+        public async Task>> StructureOrders(long structure_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/markets/structures/{structure_id}/",
                 replacements: new Dictionary()
                 {
                     { "structure_id", structure_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /markets/groups/
         /// 
         /// 
-        public async Task> Groups()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/groups/");
+        public async Task> Groups(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/groups/",
+                options: options);
+
 
         /// 
         /// /markets/groups/{market_group_id}/
         /// 
         /// 
         /// 
-        public async Task> Group(int market_group_id)
+        public async Task> Group(int market_group_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/groups/{market_group_id}/",
                 replacements: new Dictionary()
                 {
                     { "market_group_id", market_group_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/orders/
         /// 
         /// 
-        public async Task>> CharacterOrders()
+        public async Task>> CharacterOrders(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/orders/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/orders/history/
         /// 
         /// 
         /// 
-        public async Task>> CharacterOrderHistory(int page = 1)
+        public async Task>> CharacterOrderHistory(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/orders/history/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /markets/{region_id}/types/
@@ -154,49 +144,38 @@ public async Task>> CharacterOrderHistory(int page = 1)
         /// 
         /// 
         /// 
-        public async Task> Types(int region_id, int page = 1)
+        public async Task> Types(int region_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/markets/{region_id}/types/",
                 replacements: new Dictionary()
                 {
                     { "region_id", region_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/orders/
         /// 
         /// 
         /// 
-        public async Task>> CorporationOrders(int page = 1)
+        public async Task>> CorporationOrders(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/orders/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/orders/
         /// 
         /// 
         /// 
-        public async Task>> CorporationOrderHistory(int page = 1)
+        public async Task>> CorporationOrderHistory(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/orders/history/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/OpportunitiesLogic.cs b/ESI.NET/Logic/OpportunitiesLogic.cs
index 19ec377..9b62365 100644
--- a/ESI.NET/Logic/OpportunitiesLogic.cs
+++ b/ESI.NET/Logic/OpportunitiesLogic.cs
@@ -1,5 +1,4 @@
-using ESI.NET.Models.SSO;
-using System.Collections.Generic;
+using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
 using static ESI.NET.EsiRequest;
@@ -11,68 +10,70 @@ public class OpportunitiesLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
         
-        public OpportunitiesLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public OpportunitiesLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /opportunities/groups/
         /// 
         /// 
-        public async Task> Groups()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/groups/");
+        public async Task> Groups(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/groups/",
+                options: options);
+
 
         /// 
         /// /opportunities/groups/{group_id}/
         /// 
         /// 
         /// 
-        public async Task> Group(int group_id)
+        public async Task> Group(int group_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/groups/{group_id}/",
                 replacements: new Dictionary()
                 {
                     { "group_id", group_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /opportunities/tasks/
         /// 
         /// 
-        public async Task> Tasks()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/tasks/");
+        public async Task> Tasks(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/tasks/",
+                options: options);
+
 
         /// 
         /// /opportunities/tasks/{task_id}/
         /// 
         /// 
         /// 
-        public async Task> Task(int task_id)
+        public async Task> Task(int task_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/opportunities/tasks/{task_id}/",
                 replacements: new Dictionary()
                 {
                     { "task_id", task_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /characters/{character_id}/opportunities/
         /// 
         /// 
         /// 
-        public async Task>> CompletedTasks()
+        public async Task>> CompletedTasks(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/opportunities/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/PlanetaryInteractionLogic.cs b/ESI.NET/Logic/PlanetaryInteractionLogic.cs
index d20afed..95c6ba4 100644
--- a/ESI.NET/Logic/PlanetaryInteractionLogic.cs
+++ b/ESI.NET/Logic/PlanetaryInteractionLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.PlanetaryInteraction;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,70 +10,63 @@ public class PlanetaryInteractionLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public PlanetaryInteractionLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public PlanetaryInteractionLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                character_id = data.CharacterID;
-                corporation_id = data.CorporationID;
-            }
         }
 
         /// 
         /// /characters/{character_id}/planets/
         /// 
         /// 
-        public async Task>> Colonies()
+        public async Task>> Colonies(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/planets/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/planets/{planet_id}/
         /// 
         /// 
         /// 
-        public async Task> ColonyLayout(int planet_id)
+        public async Task> ColonyLayout(int planet_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/planets/{planet_id}/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() },
+                    { "character_id", options.Character.CharacterID.ToString() },
                     { "planet_id", planet_id.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/customs_offices/
         /// 
         /// 
-        public async Task>> CorporationCustomsOffices()
+        public async Task>> CorporationCustomsOffices(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/customs_offices/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /universe/schematics/{schematic_id}/
         /// 
         /// 
         /// 
-        public async Task> SchematicInformation(int schematic_id)
+        public async Task> SchematicInformation(int schematic_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/schematics/{schematic_id}/",
                 replacements: new Dictionary()
                 {
                     { "schematic_id", schematic_id.ToString() }
-                });
+                },
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/RoutesLogic.cs b/ESI.NET/Logic/RoutesLogic.cs
index 231120b..5f241ec 100644
--- a/ESI.NET/Logic/RoutesLogic.cs
+++ b/ESI.NET/Logic/RoutesLogic.cs
@@ -25,9 +25,10 @@ public class RoutesLogic
         public async Task> Map(
             int origin, 
             int destination, 
-            RoutesFlag flag = RoutesFlag.Shortest, 
-            int[] avoid = null, 
-            int[] connections = null)
+            RoutesFlag flag = RoutesFlag.Shortest,
+            int[] avoid = null,
+            int[] connections = null,
+            EsiCallOptions options = null)
         {
             var parameters = new List() { $"flag={flag.ToEsiValue()}" };
 
@@ -43,7 +44,8 @@ public async Task> Map(
                     { "origin", origin.ToString() },
                     { "destination", destination.ToString() }
                 },
-                parameters: parameters.ToArray());
+                parameters: parameters.ToArray(),
+                options: options);
 
             return response;
         }
diff --git a/ESI.NET/Logic/SearchLogic.cs b/ESI.NET/Logic/SearchLogic.cs
index 27f5f2d..d628ff3 100644
--- a/ESI.NET/Logic/SearchLogic.cs
+++ b/ESI.NET/Logic/SearchLogic.cs
@@ -1,6 +1,5 @@
 using ESI.NET.Enumerations;
 using ESI.NET.Models;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -12,17 +11,11 @@ public class SearchLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public SearchLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public SearchLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
@@ -33,7 +26,7 @@ public SearchLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData
         /// Whether the search should be a strict match
         /// Language to use in the response
         /// 
-        public async Task> Query(SearchType type, string search, SearchCategory categories, bool isStrict = false, string language = "en-us")
+        public async Task> Query(SearchType type, string search, SearchCategory categories, bool isStrict = false, string language = "en-us", EsiCallOptions options = null)
         {
             var categoryList = categories.ToEsiValue();
 
@@ -45,18 +38,20 @@ public async Task> Query(SearchType type, string sear
                 security = RequestSecurity.Authenticated;
                 replacements = new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 };
                 endpoint = "/characters/{character_id}/search/";
             }
 
-            var response = await Execute(_client, _config, security, HttpMethod.Get, endpoint, replacements, parameters: new string[] {
-                $"search={search}",
-                $"categories={categoryList}",
-                $"strict={isStrict}",
-                $"language={language}"
-            },
-            token: _data?.Token);
+            var response = await Execute(_client, _config, security, HttpMethod.Get, endpoint,
+                options: options,
+                replacements: replacements,
+                parameters: new string[] {
+                    $"search={search}",
+                    $"categories={categoryList}",
+                    $"strict={isStrict}",
+                    $"language={language}"
+                });
 
             return response;
         }
diff --git a/ESI.NET/Logic/SkillsLogic.cs b/ESI.NET/Logic/SkillsLogic.cs
index 57cca3e..f7546f5 100644
--- a/ESI.NET/Logic/SkillsLogic.cs
+++ b/ESI.NET/Logic/SkillsLogic.cs
@@ -1,5 +1,4 @@
 using ESI.NET.Models.Skills;
-using ESI.NET.Models.SSO;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,53 +10,47 @@ public class SkillsLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id;
 
-        public SkillsLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public SkillsLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-                character_id = data.CharacterID;
         }
 
         /// 
         /// /characters/{character_id}/attributes/
         /// 
         /// 
-        public async Task> Attributes()
+        public async Task> Attributes(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/attributes/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/skills/
         /// 
         /// 
-        public async Task> List()
+        public async Task> List(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/skills/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /characters/{character_id}/skillqueue/
         /// 
         /// 
-        public async Task>> Queue()
+        public async Task>> Queue(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/skillqueue/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/SovereigntyLogic.cs b/ESI.NET/Logic/SovereigntyLogic.cs
index 08e7f3d..d8fbf3a 100644
--- a/ESI.NET/Logic/SovereigntyLogic.cs
+++ b/ESI.NET/Logic/SovereigntyLogic.cs
@@ -17,21 +17,27 @@ public class SovereigntyLogic
         /// /sovereignty/campaigns/
         /// 
         /// 
-        public async Task>> Campaigns()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/campaigns/");
+        public async Task>> Campaigns(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/campaigns/",
+                options: options);
+
 
         /// 
         /// /sovereignty/map/
         /// 
         /// 
-        public async Task>> Systems()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/map/");
+        public async Task>> Systems(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/map/",
+                options: options);
+
 
         /// 
         /// /sovereignty/structures/
         /// 
         /// 
-        public async Task>> Structures()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/structures/");
+        public async Task>> Structures(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/sovereignty/structures/",
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/StatusLogic.cs b/ESI.NET/Logic/StatusLogic.cs
index 282b0dd..0566e55 100644
--- a/ESI.NET/Logic/StatusLogic.cs
+++ b/ESI.NET/Logic/StatusLogic.cs
@@ -12,7 +12,9 @@ public class StatusLogic
 
         public StatusLogic(HttpClient client, EsiConfig config) { _client = client; _config = config; }
 
-        public async Task> Retrieve()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/status/");
+        public async Task> Retrieve(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/status/",
+                options: options);
+
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/UniverseLogic.cs b/ESI.NET/Logic/UniverseLogic.cs
index b4eaf33..1354cf5 100644
--- a/ESI.NET/Logic/UniverseLogic.cs
+++ b/ESI.NET/Logic/UniverseLogic.cs
@@ -1,5 +1,4 @@
-using ESI.NET.Models.SSO;
-using ESI.NET.Models.Universe;
+using ESI.NET.Models.Universe;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,290 +10,336 @@ public class UniverseLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
 
-        public UniverseLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public UniverseLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
         }
 
         /// 
         /// /universe/bloodlines/
         /// 
         /// 
-        public async Task>> Bloodlines()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/bloodlines/");
+        public async Task>> Bloodlines(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/bloodlines/",
+                options: options);
+
 
         /// 
         /// /universe/categories/
         /// 
         /// 
-        public async Task> Categories()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/categories/");
+        public async Task> Categories(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/categories/",
+                options: options);
+
 
         /// 
         /// /universe/categories/{category_id}/
         /// 
         /// 
         /// 
-        public async Task> Category(int category_id)
+        public async Task> Category(int category_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/categories/{category_id}/", replacements: new Dictionary()
             {
                 { "category_id", category_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/constellations/
         /// 
         /// 
-        public async Task> Constellations()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/constellations/");
+        public async Task> Constellations(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/constellations/",
+                options: options);
+
 
         /// 
         /// /universe/constellations/{constellation_id}/
         /// 
         /// 
         /// 
-        public async Task> Constellation(int constellation_id)
+        public async Task> Constellation(int constellation_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/constellations/{constellation_id}/", replacements: new Dictionary()
             {
                 { "constellation_id", constellation_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/factions/
         /// 
         /// 
-        public async Task>> Factions()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/factions/");
+        public async Task>> Factions(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/factions/",
+                options: options);
+
 
         /// 
         /// /universe/graphics/
         /// 
         /// 
-        public async Task> Graphics()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/graphics/");
+        public async Task> Graphics(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/graphics/",
+                options: options);
+
 
         /// 
         /// /universe/graphics/{graphic_id}/
         /// 
         /// 
         /// 
-        public async Task> Graphic(int graphic_id)
+        public async Task> Graphic(int graphic_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/graphics/{graphic_id}/", replacements: new Dictionary()
             {
                 { "graphic_id", graphic_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/groups/
         /// 
         /// 
         /// 
-        public async Task> Groups(int page = 1)
+        public async Task> Groups(EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/groups/",
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /universe/groups/{group_id}/
         /// 
         /// 
         /// 
-        public async Task> Group(int group_id)
+        public async Task> Group(int group_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/groups/{group_id}/", replacements: new Dictionary()
             {
                 { "group_id", group_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/moons/{moon_id}/
         /// 
         /// 
         /// 
-        public async Task> Moon(int moon_id)
+        public async Task> Moon(int moon_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/moons/{moon_id}/", replacements: new Dictionary()
             {
                 { "moon_id", moon_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/names/
         /// 
         /// The ids to resolve; Supported IDs for resolving are: Characters, Corporations, Alliances, Stations, Solar Systems, Constellations, Regions, Types.
         /// 
-        public async Task>> Names(List any_ids)
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Post, "/universe/names/", body: any_ids.ToArray());
+        public async Task>> Names(List any_ids, EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Post, "/universe/names/", body: any_ids.ToArray(),
+                options: options);
+
 
         /// 
         /// /universe/ids/
         /// 
         /// Resolve a set of names to IDs in the following categories: agents, alliances, characters, constellations, corporations factions, inventory_types, regions, stations, and systems. Only exact matches will be returned. All names searched for are cached for 12 hours.
         /// 
-        public async Task> IDs(List names)
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Post, "/universe/ids/", body: names.ToArray());
+        public async Task> IDs(List names, EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Post, "/universe/ids/", body: names.ToArray(),
+                options: options);
+
 
         /// 
         /// /universe/planets/{planet_id}/
         /// 
         /// 
         /// 
-        public async Task> Planet(int planet_id)
+        public async Task> Planet(int planet_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/planets/{planet_id}/", replacements: new Dictionary()
             {
                 { "planet_id", planet_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/races/
         /// 
         /// 
-        public async Task>> Races()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/races/");
+        public async Task>> Races(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/races/",
+                options: options);
+
 
         /// 
         /// /universe/regions/
         /// 
         /// 
-        public async Task> Regions()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/regions/");
+        public async Task> Regions(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/regions/",
+                options: options);
+
 
         /// 
         /// /universe/regions/{region_id}/
         /// 
         /// 
         /// 
-        public async Task> Region(int region_id)
+        public async Task> Region(int region_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/regions/{region_id}/", replacements: new Dictionary()
             {
                 { "region_id", region_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/stations/{station_id}/
         /// 
         /// 
         /// 
-        public async Task> Station(int station_id)
+        public async Task> Station(int station_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/stations/{station_id}/", replacements: new Dictionary()
             {
                 { "station_id", station_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/structures/
         /// 
         /// 
-        public async Task> Structures()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/structures/");
+        public async Task> Structures(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/structures/",
+                options: options);
+
 
         /// 
         /// /universe/structures/{structure_id}/
         /// 
         /// 
         /// 
-        public async Task> Structure(long structure_id)
+        public async Task> Structure(long structure_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/universe/structures/{structure_id}/", replacements: new Dictionary()
             {
                 { "structure_id", structure_id.ToString() }
-            }, token: _data.Token);
+            }, options: options);
 
         /// 
         /// /universe/systems/
         /// 
         /// 
-        public async Task> Systems()
-            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/systems/");
+        public async Task> Systems(EsiCallOptions options = null)
+            => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/systems/",
+                options: options);
+
 
         /// 
         /// /universe/systems/{system_id}/
         /// 
         /// 
         /// 
-        public async Task> System(int system_id)
+        public async Task> System(int system_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/systems/{system_id}/", replacements: new Dictionary()
             {
                 { "system_id", system_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/types/
         /// 
         /// 
         /// 
-        public async Task> Types(int page = 1)
+        public async Task> Types(EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/types/",
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
 
         /// 
         /// /universe/types/{type_id}/
         /// 
         /// 
         /// 
-        public async Task> Type(int type_id)
+        public async Task> Type(int type_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/types/{type_id}/", replacements: new Dictionary()
             {
                 { "type_id", type_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/stargates/{stargate_id}/
         /// 
         /// 
         /// 
-        public async Task> Stargate(int stargate_id)
+        public async Task> Stargate(int stargate_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/stargates/{stargate_id}/", replacements: new Dictionary()
             {
                 { "stargate_id", stargate_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/system_jumps/
         /// 
         /// 
-        public async Task>> Jumps()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/system_jumps/");
+        public async Task>> Jumps(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/system_jumps/",
+                options: options);
+
 
         /// 
         /// /universe/system_kills/
         /// 
         /// 
-        public async Task>> Kills()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/system_kills/");
+        public async Task>> Kills(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/system_kills/",
+                options: options);
+
 
         /// 
         /// /universe/stars/{star_id}/
         /// 
         /// 
         /// 
-        public async Task> Star(int star_id)
+        public async Task> Star(int star_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/stars/{star_id}/", replacements: new Dictionary()
             {
                 { "star_id", star_id.ToString() }
-            });
+            },
+                options: options);
+
 
         /// 
         /// /universe/ancestries/
         /// 
         /// 
-        public async Task>> Ancestries()
-            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/ancestries/");
+        public async Task>> Ancestries(EsiCallOptions options = null)
+            => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/ancestries/",
+                options: options);
+
 
         /// 
         /// /universe/asteroid_belts/{asteroid_belt_id}/
         /// 
         /// 
-        public async Task>> AsteroidBelt(int asteroid_belt_id)
+        public async Task>> AsteroidBelt(int asteroid_belt_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/universe/asteroid_belts/{asteroid_belt_id}/", replacements: new Dictionary()
             {
                 { "asteroid_belt_id", asteroid_belt_id.ToString() }
-            });
+            },
+                options: options);
+
     }
 }
diff --git a/ESI.NET/Logic/UserInterfaceLogic.cs b/ESI.NET/Logic/UserInterfaceLogic.cs
index d9e7ac5..6067778 100644
--- a/ESI.NET/Logic/UserInterfaceLogic.cs
+++ b/ESI.NET/Logic/UserInterfaceLogic.cs
@@ -1,6 +1,5 @@
 using System.Net.Http;
 using System.Threading.Tasks;
-using ESI.NET.Models.SSO;
 using static ESI.NET.EsiRequest;
 
 namespace ESI.NET.Logic
@@ -9,13 +8,11 @@ public class UserInterfaceLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
 
-        public UserInterfaceLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public UserInterfaceLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
         }
 
         /// 
@@ -23,39 +20,39 @@ public UserInterfaceLogic(HttpClient client, EsiConfig config, AuthorizedCharact
         /// 
         /// 
         /// 
-        public async Task> MarketDetails(int type_id)
+        public async Task> MarketDetails(int type_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/ui/openwindow/marketdetails/",
                 parameters: new string[]
                 {
                     $"type_id={type_id}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /ui/openwindow/contract/
         /// 
         /// 
         /// 
-        public async Task> Contract(int contract_id)
+        public async Task> Contract(int contract_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/ui/openwindow/contract/",
                 parameters: new string[]
                 {
                     $"contract_id={contract_id}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /ui/openwindow/information/
         /// 
         /// 
         /// 
-        public async Task> Information(int target_id)
+        public async Task> Information(int target_id, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/ui/openwindow/information/",
                 parameters: new string[]
                 {
                     $"target_id={target_id}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /ui/autopilot/waypoint/
@@ -64,7 +61,7 @@ public async Task> Information(int target_id)
         /// 
         /// 
         /// 
-        public async Task> Waypoint(long destination_id, bool add_to_beginning = false, bool clear_other_waypoints = false)
+        public async Task> Waypoint(long destination_id, bool add_to_beginning = false, bool clear_other_waypoints = false, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/ui/autopilot/waypoint/",
                 parameters: new string[]
                 {
@@ -72,7 +69,7 @@ public async Task> Waypoint(long destination_id, bool add_to
                     $"add_to_beginning={add_to_beginning}",
                     $"clear_other_waypoints={clear_other_waypoints}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /ui/openwindow/newmail/
@@ -83,7 +80,7 @@ public async Task> Waypoint(long destination_id, bool add_to
         /// 
         /// 
         /// 
-        public async Task> NewMail(string subject, string body, int[] recipients)
+        public async Task> NewMail(string subject, string body, int[] recipients, EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Post, "/ui/openwindow/newmail/",
                 body: new
                 {
@@ -91,6 +88,6 @@ public async Task> NewMail(string subject, string body, int[
                     body,
                     recipients
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/WalletLogic.cs b/ESI.NET/Logic/WalletLogic.cs
index 47a6a5a..ba67c78 100644
--- a/ESI.NET/Logic/WalletLogic.cs
+++ b/ESI.NET/Logic/WalletLogic.cs
@@ -1,5 +1,4 @@
-using ESI.NET.Models.SSO;
-using ESI.NET.Models.Wallet;
+using ESI.NET.Models.Wallet;
 using System.Collections.Generic;
 using System.Net.Http;
 using System.Threading.Tasks;
@@ -11,48 +10,35 @@ public class WalletLogic
     {
         private readonly HttpClient _client;
         private readonly EsiConfig _config;
-        private readonly AuthorizedCharacterData _data;
-        private readonly int character_id, corporation_id;
 
-        public WalletLogic(HttpClient client, EsiConfig config, AuthorizedCharacterData data = null)
+        public WalletLogic(HttpClient client, EsiConfig config)
         {
             _client = client;
             _config = config;
-            _data = data;
-
-            if (data != null)
-            {
-                character_id = data.CharacterID;
-                corporation_id = data.CorporationID;
-            }
         }
 
         /// 
         /// /characters/{character_id}/wallet/
         /// 
         /// 
-        public async Task> CharacterWallet()
+        public async Task> CharacterWallet(EsiCallOptions options)
             => await Execute(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/wallet/", replacements: new Dictionary()
             {
-                { "character_id", character_id.ToString() }
-            }, token: _data.Token);
+                { "character_id", options.Character.CharacterID.ToString() }
+            }, options: options);
 
         /// 
         /// /characters/{character_id}/wallet/journal/
         /// 
         /// 
         /// 
-        public async Task>> CharacterJournal(int page = 1)
+        public async Task>> CharacterJournal(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/wallet/journal/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
-                },
-                parameters: new string[]
-                {
-                    $"page={page}"
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
 
         /// 
@@ -60,29 +46,29 @@ public async Task>> CharacterJournal(int page = 1
         /// 
         /// 
         /// 
-        public async Task>> CharacterTransactions(long from_id)
+        public async Task>> CharacterTransactions(long from_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/characters/{character_id}/wallet/transactions/",
                 replacements: new Dictionary()
                 {
-                    { "character_id", character_id.ToString() }
+                    { "character_id", options.Character.CharacterID.ToString() }
                 },
                 parameters: new string[]
                 {
                     $"from_id={from_id}"
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/wallets/
         /// 
         /// 
-        public async Task>> CorporationWallets()
+        public async Task>> CorporationWallets(EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/wallets/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() }
+                    { "corporation_id", options.Character.CorporationID.ToString() }
                 },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/wallets/{division}/journal/
@@ -90,18 +76,14 @@ public async Task>> CorporationWallets()
         /// 
         /// 
         /// 
-        public async Task>> CorporationJournal(int division, int page = 1)
+        public async Task>> CorporationJournal(int division, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/wallets/{division}/journal/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "division", division.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                },
-                token: _data.Token);
+                options: options);
 
         /// 
         /// /corporations/{corporation_id}/wallets/{division}/transactions/
@@ -109,17 +91,17 @@ public async Task>> CorporationJournal(int divisi
         /// 
         /// 
         /// 
-        public async Task>> CorporationTransactions(int division, long from_id)
+        public async Task>> CorporationTransactions(int division, long from_id, EsiCallOptions options)
             => await Execute>(_client, _config, RequestSecurity.Authenticated, HttpMethod.Get, "/corporations/{corporation_id}/wallets/{division}/transactions/",
                 replacements: new Dictionary()
                 {
-                    { "corporation_id", corporation_id.ToString() },
+                    { "corporation_id", options.Character.CorporationID.ToString() },
                     { "division", division.ToString() }
                 },
                 parameters: new string[]
                 {
                     $"from_id={from_id}"
                 },
-                token: _data.Token);
+                options: options);
     }
 }
\ No newline at end of file
diff --git a/ESI.NET/Logic/WarsLogic.cs b/ESI.NET/Logic/WarsLogic.cs
index a947118..133058d 100644
--- a/ESI.NET/Logic/WarsLogic.cs
+++ b/ESI.NET/Logic/WarsLogic.cs
@@ -17,7 +17,7 @@ public class WarsLogic
         /// 
         /// Only return wars with ID smaller than this
         /// 
-        public async Task> All(long max_war_id = 0)
+        public async Task> All(long max_war_id = 0, EsiCallOptions options = null)
         {
             var parameters = new List();
 
@@ -25,7 +25,8 @@ public async Task> All(long max_war_id = 0)
                 parameters.Add($"max_war_id={max_war_id}");
 
             var response = await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/wars/",
-                parameters: parameters.ToArray());
+                parameters: parameters.ToArray(),
+                options: options);
 
             return response;
         }
@@ -35,12 +36,14 @@ public async Task> All(long max_war_id = 0)
         /// 
         /// 
         /// 
-        public async Task> Information(int war_id)
+        public async Task> Information(int war_id, EsiCallOptions options = null)
             => await Execute(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/wars/{war_id}/",
                 replacements: new Dictionary()
                 {
                     { "war_id", war_id.ToString() }
-                });
+                },
+                options: options);
+
 
         /// 
         /// /wars/{warId}/killmails/
@@ -48,15 +51,12 @@ public async Task> Information(int war_id)
         /// 
         /// 
         /// 
-        public async Task>> Kills(int war_id, int page = 1)
+        public async Task>> Kills(int war_id, EsiCallOptions options = null)
             => await Execute>(_client, _config, RequestSecurity.Public, HttpMethod.Get, "/wars/{war_id}/killmails/",
                 replacements: new Dictionary()
                 {
                     { "war_id", war_id.ToString() }
                 },
-                parameters: new string[]
-                {
-                    $"page={page}"
-                });
+                options: options);
     }
 }
\ No newline at end of file

From 2e83ee2c40c0eaff91a96e8efc5b90c3e73a7bdf Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Thu, 10 Sep 2026 01:06:42 -0400
Subject: [PATCH 11/67] feat(di): AddEsi uses IHttpClientFactory + ESI header /
 error-limit handlers

- AddEsi now registers IEsiClient as a typed HttpClient
  (AddHttpClient) and returns IHttpClientBuilder so
  callers can chain .AddStandardResilienceHandler() after referencing
  Microsoft.Extensions.Http.Resilience. Polly stays out of core.
  New AddEsi(Action) overload.
- Http/EsiHeadersHandler: sets X-User-Agent (from EsiConfig.UserAgent) and
  Accept: application/json per request, skipping either if already present.
- Http/EsiErrorLimitHandler + EsiErrorLimitState: reads
  X-Esi-Error-Limit-Remain/-Reset; once the budget is spent (or a 420 is
  returned) it blocks further sends on that client until the window resets,
  and throws EsiErrorLimitException on 420.
- EsiClient ctor: a supplied HttpClient (DI pipeline or caller) is used as-is;
  headers/handler are only configured on a client the ctor creates itself.
  CreateDefaultHandler is now internal.
- csproj: + Microsoft.Extensions.Http 8.0.1 (netstandard2.0-compatible).
- ESI.NET.Tests/EsiHandlerTests: 7 cases (header add / no-duplicate /
  missing-UA throw; error-limit pass-through / 420 throw / blocks next send;
  AddEsi end-to-end through a stubbed primary handler).

BREAKING:
- AddEsi returns IHttpClientBuilder, not IServiceCollection; IEsiClient
  lifetime is now the typed-client default, not AddScoped.
- A caller-supplied bare HttpClient no longer gets X-User-Agent / Accept added
  automatically.
- The manual Accept-Encoding gzip/deflate request headers are gone;
  decompression is the primary handler's AutomaticDecompression.

Local: build 0 warnings both TFMs, 39/39 tests.

Co-Authored-By: Claude Sonnet 5 
---
 ESI.NET.Tests/ESI.NET.Tests.csproj     |   1 +
 ESI.NET.Tests/EsiHandlerTests.cs       | 146 +++++++++++++++++++++++++
 ESI.NET/ESI.NET.csproj                 |   1 +
 ESI.NET/EsiClient.cs                   |  27 +++--
 ESI.NET/Extensions.cs                  |  39 ++++++-
 ESI.NET/Http/EsiErrorLimitException.cs |  21 ++++
 ESI.NET/Http/EsiErrorLimitHandler.cs   |  91 +++++++++++++++
 ESI.NET/Http/EsiHeadersHandler.cs      |  40 +++++++
 8 files changed, 352 insertions(+), 14 deletions(-)
 create mode 100644 ESI.NET.Tests/EsiHandlerTests.cs
 create mode 100644 ESI.NET/Http/EsiErrorLimitException.cs
 create mode 100644 ESI.NET/Http/EsiErrorLimitHandler.cs
 create mode 100644 ESI.NET/Http/EsiHeadersHandler.cs

diff --git a/ESI.NET.Tests/ESI.NET.Tests.csproj b/ESI.NET.Tests/ESI.NET.Tests.csproj
index 64dd5fc..3e95d9c 100644
--- a/ESI.NET.Tests/ESI.NET.Tests.csproj
+++ b/ESI.NET.Tests/ESI.NET.Tests.csproj
@@ -8,6 +8,7 @@
   
 
   
+    
     
     
     
diff --git a/ESI.NET.Tests/EsiHandlerTests.cs b/ESI.NET.Tests/EsiHandlerTests.cs
new file mode 100644
index 0000000..010302b
--- /dev/null
+++ b/ESI.NET.Tests/EsiHandlerTests.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ESI.NET;
+using ESI.NET.Enumerations;
+using ESI.NET.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace ESI.NET.Tests
+{
+    public class EsiHandlerTests
+    {
+        private sealed class StubHandler : HttpMessageHandler
+        {
+            public HttpRequestMessage Last;
+            public Func Respond = () => new HttpResponseMessage(HttpStatusCode.OK);
+            public int Calls;
+
+            protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+            {
+                Calls++;
+                Last = request;
+                return Task.FromResult(Respond());
+            }
+        }
+
+        private static HttpResponseMessage WithHeaders(HttpStatusCode status, int remain, int reset)
+        {
+            var r = new HttpResponseMessage(status);
+            r.Headers.TryAddWithoutValidation("X-Esi-Error-Limit-Remain", remain.ToString());
+            r.Headers.TryAddWithoutValidation("X-Esi-Error-Limit-Reset", reset.ToString());
+            return r;
+        }
+
+        // ---- EsiHeadersHandler -------------------------------------------------
+
+        [Fact]
+        public async Task HeadersHandler_adds_user_agent_and_accept()
+        {
+            var stub = new StubHandler();
+            var handler = new EsiHeadersHandler(Options.Create(new EsiConfig { UserAgent = "my-app / me" })) { InnerHandler = stub };
+            using var invoker = new HttpMessageInvoker(handler);
+
+            await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://esi/"), default);
+
+            Assert.Equal("my-app / me", string.Join("", stub.Last.Headers.GetValues("X-User-Agent")));
+            Assert.Contains(stub.Last.Headers.Accept, a => a.MediaType == "application/json");
+        }
+
+        [Fact]
+        public async Task HeadersHandler_does_not_duplicate_existing_headers()
+        {
+            var stub = new StubHandler();
+            var handler = new EsiHeadersHandler(Options.Create(new EsiConfig { UserAgent = "ua" })) { InnerHandler = stub };
+            using var invoker = new HttpMessageInvoker(handler);
+
+            var req = new HttpRequestMessage(HttpMethod.Get, "https://esi/");
+            req.Headers.Add("X-User-Agent", "caller-set");
+            await invoker.SendAsync(req, default);
+
+            Assert.Equal(new[] { "caller-set" }, stub.Last.Headers.GetValues("X-User-Agent"));
+        }
+
+        [Fact]
+        public void HeadersHandler_throws_when_user_agent_missing()
+        {
+            Assert.Throws(() => new EsiHeadersHandler(Options.Create(new EsiConfig { UserAgent = " " })));
+        }
+
+        // ---- EsiErrorLimitHandler -------------------------------------------
+
+        [Fact]
+        public async Task ErrorLimitHandler_passes_through_a_healthy_response()
+        {
+            var stub = new StubHandler { Respond = () => WithHeaders(HttpStatusCode.OK, remain: 95, reset: 50) };
+            var handler = new EsiErrorLimitHandler(new EsiErrorLimitState()) { InnerHandler = stub };
+            using var invoker = new HttpMessageInvoker(handler);
+
+            var resp = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://esi/"), default);
+
+            Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
+        }
+
+        [Fact]
+        public async Task ErrorLimitHandler_throws_EsiErrorLimitException_on_420()
+        {
+            var stub = new StubHandler { Respond = () => WithHeaders((HttpStatusCode)420, remain: 0, reset: 12) };
+            var handler = new EsiErrorLimitHandler(new EsiErrorLimitState()) { InnerHandler = stub };
+            using var invoker = new HttpMessageInvoker(handler);
+
+            var ex = await Assert.ThrowsAsync(() =>
+                invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://esi/"), default));
+
+            Assert.Equal(12, ex.RetryAfter.TotalSeconds);
+        }
+
+        [Fact]
+        public async Task ErrorLimitHandler_blocks_the_next_send_until_the_window_resets()
+        {
+            var state = new EsiErrorLimitState();
+            var stub = new StubHandler { Respond = () => WithHeaders(HttpStatusCode.OK, remain: 0, reset: 1) };
+            var handler = new EsiErrorLimitHandler(state) { InnerHandler = stub };
+            using var invoker = new HttpMessageInvoker(handler);
+
+            // first call exhausts the budget (remain: 0) -> state is now blocked for ~1s
+            await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://esi/"), default);
+
+            var sw = Stopwatch.StartNew();
+            await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://esi/"), default);
+            sw.Stop();
+
+            Assert.True(sw.ElapsedMilliseconds >= 500, $"expected a delay, waited {sw.ElapsedMilliseconds}ms");
+            Assert.Equal(2, stub.Calls);
+        }
+
+        // ---- AddEsi wiring -------------------------------------------------
+
+        [Fact]
+        public async Task AddEsi_wires_the_pipeline_and_resolves_a_working_client()
+        {
+            var stub = new StubHandler { Respond = () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[1,2]") } };
+
+            var services = new ServiceCollection();
+            services.AddEsi(c =>
+            {
+                c.UserAgent = "wiring-test";
+                c.EsiUrl = "https://esi.evetech.net/";
+                c.DataSource = DataSource.Tranquility;
+            }).ConfigurePrimaryHttpMessageHandler(() => stub);
+
+            var provider = services.BuildServiceProvider();
+            var client = provider.GetRequiredService();
+
+            var response = await client.Status.Retrieve();   // public endpoint
+
+            Assert.Equal("wiring-test", string.Join("", stub.Last.Headers.GetValues("X-User-Agent")));
+            Assert.Contains("/latest/status/", stub.Last.RequestUri.ToString());
+        }
+    }
+}
diff --git a/ESI.NET/ESI.NET.csproj b/ESI.NET/ESI.NET.csproj
index 0b0062d..ae72644 100644
--- a/ESI.NET/ESI.NET.csproj
+++ b/ESI.NET/ESI.NET.csproj
@@ -18,6 +18,7 @@
   
     
     
+    
     
     
     
diff --git a/ESI.NET/EsiClient.cs b/ESI.NET/EsiClient.cs
index f1d10dd..59f5c56 100644
--- a/ESI.NET/EsiClient.cs
+++ b/ESI.NET/EsiClient.cs
@@ -16,20 +16,27 @@ public class EsiClient : IEsiClient
         /// Initializes a new instance of the  class.
         /// 
         /// The configuration parameters of the .
-        /// The  to use for HTTP requests.
+        /// 
+        /// The  to use. When supplied (including via AddEsi's
+        ///  pipeline) it is used as-is — the caller
+        /// / pipeline is responsible for the X-User-Agent and Accept headers and for
+        /// content decompression. When omitted, a default client is created and configured here.
+        /// 
         public EsiClient(IOptions _config, HttpClient _client = null)
         {
             config = _config.Value;
-            client = _client ?? new HttpClient(CreateDefaultHandler());
 
-            // Enforce user agent value
-            if (string.IsNullOrEmpty(config.UserAgent))
-                throw new ArgumentException("For your protection, please provide an X-User-Agent value. This can be your character name and/or project name. CCP will be more likely to contact you rather than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy.");
-            client.DefaultRequestHeaders.Add("X-User-Agent", config.UserAgent);
+            if (_client != null)
+                client = _client;
+            else
+            {
+                if (string.IsNullOrWhiteSpace(config.UserAgent))
+                    throw new ArgumentException("EsiConfig.UserAgent is required. Set it to something that identifies your app (character and/or project name) so CCP can contact you rather than cut off ESI access.");
 
-            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
-            client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
-            client.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate"));
+                client = new HttpClient(CreateDefaultHandler());
+                client.DefaultRequestHeaders.Add("X-User-Agent", config.UserAgent);
+                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+            }
 
 
             SSO = new SsoLogic(client, config);
@@ -111,7 +118,7 @@ public EsiClient(IOptions _config, HttpClient _client = null)
         ///  from the setter, because the browser's fetch
         /// API performs content decoding itself. See https://github.com/seraphx2/ESI.NET/issues/77.
         /// 
-        private static HttpClientHandler CreateDefaultHandler()
+        internal static HttpClientHandler CreateDefaultHandler()
         {
             var handler = new HttpClientHandler();
 
diff --git a/ESI.NET/Extensions.cs b/ESI.NET/Extensions.cs
index fedd252..6b16896 100644
--- a/ESI.NET/Extensions.cs
+++ b/ESI.NET/Extensions.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.Configuration;
+using ESI.NET.Http;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using System;
 using System.Collections.Generic;
@@ -10,12 +11,42 @@ namespace ESI.NET
 {
     public static class Extensions
     {
-        public static IServiceCollection AddEsi(this IServiceCollection services, IConfigurationSection section)
+        /// 
+        /// Registers  as a typed 
+        /// (via ) with the ESI header and
+        /// error-limit handlers. Bind configuration from .
+        /// 
+        /// 
+        /// The  so callers can chain, e.g.
+        /// .AddStandardResilienceHandler() after referencing
+        /// Microsoft.Extensions.Http.Resilience.
+        /// 
+        public static IHttpClientBuilder AddEsi(this IServiceCollection services, IConfigurationSection section)
         {
             services.Configure(section);
-            services.AddScoped();
+            return services.AddEsiClient();
+        }
+
+        /// 
+        /// As , configuring
+        ///  inline instead of from configuration.
+        /// 
+        public static IHttpClientBuilder AddEsi(this IServiceCollection services, Action configure)
+        {
+            services.Configure(configure);
+            return services.AddEsiClient();
+        }
+
+        private static IHttpClientBuilder AddEsiClient(this IServiceCollection services)
+        {
+            services.AddSingleton();
+            services.AddTransient();
+            services.AddTransient();
 
-            return services;
+            return services.AddHttpClient()
+                .ConfigurePrimaryHttpMessageHandler(() => EsiClient.CreateDefaultHandler())
+                .AddHttpMessageHandler()
+                .AddHttpMessageHandler();
         }
 
         public static string ToEsiValue(this Enum e)
diff --git a/ESI.NET/Http/EsiErrorLimitException.cs b/ESI.NET/Http/EsiErrorLimitException.cs
new file mode 100644
index 0000000..9f146c0
--- /dev/null
+++ b/ESI.NET/Http/EsiErrorLimitException.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace ESI.NET.Http
+{
+    /// 
+    /// Thrown by  when ESI responds with 420 Error Limited.
+    /// ESI enforces a rolling error budget per client IP; when it is exhausted every request is
+    /// rejected until the window resets. See https://developers.eveonline.com/blog/article/error-limiting-imminent.
+    /// 
+    public sealed class EsiErrorLimitException : Exception
+    {
+        public EsiErrorLimitException(TimeSpan retryAfter)
+            : base($"ESI error limit reached (HTTP 420). Retry after {retryAfter.TotalSeconds:0}s.")
+        {
+            RetryAfter = retryAfter;
+        }
+
+        /// How long until the error-limit window resets.
+        public TimeSpan RetryAfter { get; }
+    }
+}
diff --git a/ESI.NET/Http/EsiErrorLimitHandler.cs b/ESI.NET/Http/EsiErrorLimitHandler.cs
new file mode 100644
index 0000000..619e64a
--- /dev/null
+++ b/ESI.NET/Http/EsiErrorLimitHandler.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ESI.NET.Http
+{
+    /// 
+    /// Client-side guard for ESI's rolling error budget. Reads X-Esi-Error-Limit-Remain /
+    /// X-Esi-Error-Limit-Reset from every response; once the budget is spent (or a
+    /// 420 comes back) it blocks further sends until the window resets, so a burst of
+    /// failures can't get the caller's IP fully cut off. On a 420 it throws
+    /// .
+    /// 
+    public sealed class EsiErrorLimitHandler : DelegatingHandler
+    {
+        private const string RemainHeader = "X-Esi-Error-Limit-Remain";
+        private const string ResetHeader = "X-Esi-Error-Limit-Reset";
+        private const int Status420 = 420;
+
+        private readonly EsiErrorLimitState _state;
+
+        public EsiErrorLimitHandler(EsiErrorLimitState state)
+        {
+            _state = state;
+        }
+
+        protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+        {
+            var wait = _state.TimeUntilReset(DateTimeOffset.UtcNow);
+            if (wait > TimeSpan.Zero)
+                await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
+
+            var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+            var resetSeconds = ReadInt(response, ResetHeader);
+            var remain = ReadInt(response, RemainHeader);
+
+            if (((int)response.StatusCode == Status420 || remain <= 0) && resetSeconds > 0)
+                _state.BlockUntil(DateTimeOffset.UtcNow.AddSeconds(resetSeconds));
+
+            if ((int)response.StatusCode == Status420)
+            {
+                response.Dispose();
+                throw new EsiErrorLimitException(TimeSpan.FromSeconds(resetSeconds > 0 ? resetSeconds : 60));
+            }
+
+            return response;
+        }
+
+        private static int ReadInt(HttpResponseMessage response, string header)
+        {
+            if (response.Headers.TryGetValues(header, out var values))
+                foreach (var v in values)
+                    if (int.TryParse(v, out var n))
+                        return n;
+            return int.MaxValue;
+        }
+    }
+
+    /// 
+    /// Shared error-limit window, tracked across every request on a client. Registered as a
+    /// singleton by AddEsi.
+    /// 
+    public sealed class EsiErrorLimitState
+    {
+        private long _blockedUntilTicks; // DateTimeOffset.UtcTicks, 0 = not blocked
+
+        internal void BlockUntil(DateTimeOffset until)
+        {
+            var ticks = until.UtcTicks;
+            long current;
+            do
+            {
+                current = Interlocked.Read(ref _blockedUntilTicks);
+                if (ticks <= current) return;
+            }
+            while (Interlocked.CompareExchange(ref _blockedUntilTicks, ticks, current) != current);
+        }
+
+        internal TimeSpan TimeUntilReset(DateTimeOffset now)
+        {
+            var ticks = Interlocked.Read(ref _blockedUntilTicks);
+            if (ticks == 0) return TimeSpan.Zero;
+            var until = new DateTimeOffset(ticks, TimeSpan.Zero);
+            var delta = until - now;
+            return delta > TimeSpan.Zero ? delta : TimeSpan.Zero;
+        }
+    }
+}
diff --git a/ESI.NET/Http/EsiHeadersHandler.cs b/ESI.NET/Http/EsiHeadersHandler.cs
new file mode 100644
index 0000000..7731e7d
--- /dev/null
+++ b/ESI.NET/Http/EsiHeadersHandler.cs
@@ -0,0 +1,40 @@
+using Microsoft.Extensions.Options;
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ESI.NET.Http
+{
+    /// 
+    /// Adds the headers ESI expects to every outgoing request: X-User-Agent (from
+    /// ) and Accept: application/json. Content-encoding is
+    /// left to the primary handler's automatic decompression rather than a manual
+    /// Accept-Encoding header. Used by the AddEsi DI pipeline.
+    /// 
+    public sealed class EsiHeadersHandler : DelegatingHandler
+    {
+        private readonly string _userAgent;
+
+        public EsiHeadersHandler(IOptions config)
+        {
+            _userAgent = config.Value?.UserAgent;
+            if (string.IsNullOrWhiteSpace(_userAgent))
+                throw new ArgumentException(
+                    "EsiConfig.UserAgent is required. Set it to something that identifies your app " +
+                    "(character and/or project name) so CCP can contact you rather than cut off ESI access.");
+        }
+
+        protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+        {
+            if (!request.Headers.Contains("X-User-Agent"))
+                request.Headers.Add("X-User-Agent", _userAgent);
+
+            if (request.Headers.Accept.Count == 0)
+                request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+
+            return base.SendAsync(request, cancellationToken);
+        }
+    }
+}

From 95e05b9fdc09b76405e9d5922bb455830ba043e6 Mon Sep 17 00:00:00 2001
From: seraphx2 
Date: Thu, 10 Sep 2026 01:15:12 -0400
Subject: [PATCH 12/67] docs+fix: CHANGELOG/README for the modernization;
 harden SsoLogic.Verify

- SsoLogic.Verify: reuse the injected HttpClient instead of `new HttpClient()`
  per call; dispose the JWKS response; on validation failure throw
  InvalidOperationException instead of returning a blank AuthorizedCharacterData;
  affiliation lookup is now explicitly best-effort. XML docs note the
  CharacterOwnerHash re-login check.
- CHANGELOG.md: full breaking-change list since 2023.12.12 with a before/after
  migration table (EsiCallOptions, SetCharacterData/SetIfNoneMatchHeader
  removal, AddEsi -> IHttpClientBuilder, Dogma Attribute/AttributeInfo split,
  TFM + dependency changes).
- README.md: rewrote the DI / AddEsi / per-call-options / SSO sections for the
  new API; fixed the dead Azure DevOps build badge and swagger link.
- ci-dotnet composite action: setup-dotnet@v5 -> v6 (consistency with #81).
- Test: Verify throws InvalidOperationException on a bad token.

Local: build 0 warnings both TFMs, 40/40 tests.

Co-Authored-By: Claude Sonnet 5 
---
 .github/actions/ci-dotnet/action.yml     |   2 +-
 CHANGELOG.md                             | 101 +++++++++++++++++++++++
 ESI.NET.Tests/SsoTokenValidationTests.cs |  22 +++++
 ESI.NET/Logic/_SSOLogic.cs               |  47 +++++++----
 README.md                                |  74 ++++++++++++-----
 5 files changed, 209 insertions(+), 37 deletions(-)
 create mode 100644 CHANGELOG.md

diff --git a/.github/actions/ci-dotnet/action.yml b/.github/actions/ci-dotnet/action.yml
index 39ddd41..734b005 100644
--- a/.github/actions/ci-dotnet/action.yml
+++ b/.github/actions/ci-dotnet/action.yml
@@ -8,7 +8,7 @@ runs:
   using: composite
   steps:
     - name: Setup .NET SDK
-      uses: actions/setup-dotnet@v5
+      uses: actions/setup-dotnet@v6
       with:
         dotnet-version: '8.0.x'
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e879817
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,101 @@
+# Changelog
+
+## Unreleased — next major
+
+The first release since `2023.12.12`. It modernizes the target frameworks and
+dependencies and reworks how per-call state (the authorized character, the ETag,
+cancellation, pagination) is passed. **Every consumer needs code changes** — see
+_Migration_ below.
+
+### Breaking changes
+
+**Target frameworks & dependencies**
+
+- Targets are now `netstandard2.0;net8.0` (was `netcoreapp3.1;netstandard2.0;net462;net47;net471;net472;net48;net6.0;net7.0`).
+  Consumers on a dropped runtime resolve the `netstandard2.0` assembly.
+- `Microsoft.IdentityModel.Tokens` / `System.IdentityModel.Tokens.Jwt` `6.14.1` → `8.22.0`.
+- `Microsoft.Extensions.*` `2.0.0` → `8.0.x`; added `Microsoft.Extensions.Http`.
+- `Newtonsoft.Json` → `13.0.4`. Removed the explicit `System.Net.Http` package
+  reference (in-box on both targets).
+
+**Per-call options**
+
+- Every endpoint method takes a trailing `EsiCallOptions` parameter
+  (`{ Character, CancellationToken, IfNoneMatch, Page }`). It is **required** on
+  authenticated endpoints (a missing character is now a compile error) and
+  optional (`= null`) on public ones.
+- `EsiClient.SetCharacterData(AuthorizedCharacterData)` is **removed**. Pass the
+  character per call: `client.Assets.ForCharacter(new() { Character = data })`.
+- `EsiClient.SetIfNoneMatchHeader(string)` is **removed** (and the process-wide
+  `static` ETag field it set — a concurrency bug — is gone). Use
+  `new EsiCallOptions { IfNoneMatch = response.ETag }`.
+- The `int page = 1` parameter is **removed** from the 13 paginated methods.
+  Use `new EsiCallOptions { Page = 2 }`.
+
+**Dependency injection**
+
+- `AddEsi(...)` now returns `IHttpClientBuilder` (was `IServiceCollection`) and
+  registers `IEsiClient` as a typed `HttpClient` via `IHttpClientFactory` — the
+  lifetime is the typed-client default, no longer `AddScoped`.
+- A `HttpClient` you pass to `new EsiClient(config, client)` is used as-is; the
+  `X-User-Agent` / `Accept` headers are only added to a client the constructor
+  creates itself. `new EsiClient(config)` (no client) still self-configures.
+- The manual `Accept-Encoding: gzip, deflate` request headers are gone;
+  decompression is handled by the primary handler's `AutomaticDecompression`.
+
+**Dogma models**
+
+- `ESI.NET.Models.Dogma.Attribute` / `Effect` are now the id-value pairs
+  (`{ attribute_id, value }` / `{ effect_id, is_default }`) that appear on a type
+  or a dynamic item.
+- The full definitions from `/dogma/attributes/{id}/` and `/dogma/effects/{id}/`
+  are new types `AttributeInfo` / `EffectInfo`. `DogmaLogic.Attribute()` /
+  `Effect()` return those.
+- `DogmaLogic.DynamicItem()` returns `EsiResponse` (was mistyped
+  `EsiResponse` and returned an all-empty object). `DynamicItem` is now
+  `public`.
+- `ESI.NET.Models.Universe.Attribute` / `Effect` are removed; `Universe.Type`
+  binds to the `Dogma` types.
+
+**Other**
+
+- `SsoLogic.Verify()` now throws `InvalidOperationException` when access-token
+  validation fails, instead of returning a blank `AuthorizedCharacterData`.
+- `EsiResponse`'s public constructor is removed; it is built by an internal
+  async factory. Consumers never constructed it.
+
+### Added
+
+- `EsiCallOptions.CancellationToken` — honoured by every request.
+- `EsiCallOptions.IfNoneMatch` + `EsiResponse.ETag` — per-call conditional
+  requests (`304 Not Modified`).
+- `EsiErrorLimitHandler` — reads `X-Esi-Error-Limit-*` and blocks further sends
+  on the client until the window resets; throws `EsiErrorLimitException` on
+  `420`. Wired by `AddEsi`.
+- `AddEsi(Action)` overload.
+- Resilience-ready: chain `.AddStandardResilienceHandler()` (Polly) off the
+  `IHttpClientBuilder` that `AddEsi` returns, after referencing
+  `Microsoft.Extensions.Http.Resilience`.
+
+### Fixed
+
+- Constructing `EsiClient` under Blazor WebAssembly no longer throws
+  "Operation is not supported on this platform" (#77) — `AutomaticDecompression`
+  is only set when the handler supports it.
+- `EsiResponse` no longer reads the response body synchronously with
+  `.Result`, and a `204` from an endpoint not in its message table no longer
+  throws `KeyNotFoundException` internally.
+- `SsoLogic.Verify()` reuses the injected `HttpClient` instead of `new`-ing one
+  per call, and no longer swallows every exception.
+
+### Migration
+
+| Before | After |
+| --- | --- |
+| `client.SetCharacterData(data);`
`await client.Clones.List();` | `await client.Clones.List(new() { Character = data });` | +| `client.SetIfNoneMatchHeader(etag);`
`await client.Universe.Names(ids);` | `await client.Universe.Names(ids, new() { IfNoneMatch = etag });` | +| `await client.Assets.ForCharacter(2);` | `await client.Assets.ForCharacter(new() { Character = data, Page = 2 });` | +| `services.AddEsi(cfg);` *(returns `IServiceCollection`)* | `services.AddEsi(cfg);` *(returns `IHttpClientBuilder`)* — optionally `.AddStandardResilienceHandler()` | +| `dogma.Attribute(id).Data.Name` | `dogma.Attribute(id).Data.Name` — the payload type is now `AttributeInfo` | +| `var d = dogma.DynamicItem(t, i).Data;` *(was `Effect`)* | `var d = dogma.DynamicItem(t, i).Data;` *(now `DynamicItem`)* | +| `var c = await sso.Verify(token);`
`if (c.CharacterID == 0) { /* failed */ }` | `try { var c = await sso.Verify(token); }`
`catch (InvalidOperationException) { /* failed */ }` | diff --git a/ESI.NET.Tests/SsoTokenValidationTests.cs b/ESI.NET.Tests/SsoTokenValidationTests.cs index 3769bf5..0447dd6 100644 --- a/ESI.NET.Tests/SsoTokenValidationTests.cs +++ b/ESI.NET.Tests/SsoTokenValidationTests.cs @@ -129,6 +129,28 @@ public void Expired_within_clock_skew_still_validates() Assert.Equal(CharacterId, result.CharacterID); } + [Fact] + public async System.Threading.Tasks.Task Verify_throws_InvalidOperationException_when_the_token_is_bad() + { + var jwks = Jwks(); + var handler = new StubResponder(_ => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(jwks), + }); + var sso = new SsoLogic(new System.Net.Http.HttpClient(handler), + new EsiConfig { DataSource = ESI.NET.Enumerations.DataSource.Tranquility, EsiUrl = "https://esi.evetech.net/", ClientId = "id", SecretKey = "secret" }); + + await Assert.ThrowsAsync(() => sso.Verify(Token("not-a-real-jwt"))); + } + + private sealed class StubResponder : System.Net.Http.HttpMessageHandler + { + private readonly System.Func _fn; + public StubResponder(System.Func fn) => _fn = fn; + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + => System.Threading.Tasks.Task.FromResult(_fn(request)); + } + [Fact] public void Pinned_real_eve_jwks_parses_under_current_IdentityModel() { diff --git a/ESI.NET/Logic/_SSOLogic.cs b/ESI.NET/Logic/_SSOLogic.cs index 93ea21c..6a0667b 100644 --- a/ESI.NET/Logic/_SSOLogic.cs +++ b/ESI.NET/Logic/_SSOLogic.cs @@ -203,37 +203,50 @@ internal static AuthorizedCharacterData ValidateAccessToken(SsoToken token, stri } /// - /// Verifies the Character information for the provided Token information. - /// While this method represents the oauth/verify request, in addition to the verified data that ESI returns, this object also stores the Token and Refresh token - /// and this method also uses ESI retrieves other information pertinent to making calls in the ESI.NET API. (alliance_id, corporation_id, faction_id) - /// You will need a record in your database that stores at least this information. Serialize and store this object for quick retrieval and token refreshing. + /// Validates 's access token against the EVE SSO JWKS and returns an + /// carrying the character identity, the granted scopes, + /// the token/refresh token, and (best-effort) the current alliance/corporation/faction. + /// Persist this per character; you need at least RefreshToken and + /// CharacterOwnerHash for the long term. /// - /// - /// + /// + /// Compare against your stored value + /// on every re-login: EVE reissues it when a character is transferred to another account, and + /// a mismatch means the stored token/data belongs to a previous owner and must be discarded. + /// + /// The access token failed validation. public async Task Verify(SsoToken token) { - AuthorizedCharacterData authorizedCharacter = new AuthorizedCharacterData(); + AuthorizedCharacterData authorizedCharacter; try { - // Get the Eve Online JWKS to validate the access token against + // Get the EVE Online JWKS to validate the access token against var jwksUrl = $"https://{_ssoUrl}/oauth/jwks"; - var jwksJson = await (await _client.GetAsync(jwksUrl)).Content.ReadAsStringAsync(); + string jwksJson; + using (var jwksResponse = await _client.GetAsync(jwksUrl).ConfigureAwait(false)) + jwksJson = await jwksResponse.Content.ReadAsStringAsync().ConfigureAwait(false); authorizedCharacter = ValidateAccessToken(token, _ssoUrl, jwksJson); + } + catch (Exception ex) + { + throw new InvalidOperationException( + "SSO access-token verification failed. The token may be expired, malformed, or issued for a different SSO host.", ex); + } - // Get more specifc details about authorized character to be used in API calls that require this data about the character + // Best-effort enrichment: a failure here does not invalidate the token. + try + { var url = $"{_config.EsiUrl}latest/characters/affiliation/?datasource={_config.DataSource.ToEsiValue()}"; - var body = new StringContent(JsonConvert.SerializeObject(new int[] { authorizedCharacter.CharacterID }), Encoding.UTF8, "application/json"); + var body = new StringContent(JsonConvert.SerializeObject(new[] { authorizedCharacter.CharacterID }), Encoding.UTF8, "application/json"); - var client = new HttpClient(); - var characterResponse = await client.PostAsync(url, body).ConfigureAwait(false); + var affiliationResponse = await _client.PostAsync(url, body).ConfigureAwait(false); + var affiliations = await EsiResponse>.CreateAsync(affiliationResponse, "Post|/character/affiliations/").ConfigureAwait(false); - if (characterResponse.StatusCode == HttpStatusCode.OK) + if (affiliations.StatusCode == HttpStatusCode.OK && affiliations.Data?.Count > 0) { - var affiliations = await EsiResponse>.CreateAsync(characterResponse, "Post|/character/affiliations/").ConfigureAwait(false); var characterData = affiliations.Data.First(); - authorizedCharacter.AllianceID = characterData.AllianceId; authorizedCharacter.CorporationID = characterData.CorporationId; authorizedCharacter.FactionID = characterData.FactionId; @@ -241,7 +254,7 @@ public async Task Verify(SsoToken token) } catch { - // validation failed + // affiliation enrichment is best-effort } return authorizedCharacter; diff --git a/README.md b/README.md index 8dcc759..1e4cae1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Build status](https://robmburke.visualstudio.com/ESI.NET/_apis/build/status/ESI.NET) ![Quality gate](https://sonarcloud.io/api/project_badges/measure?project=ESI.NET&metric=alert_status) ![NuGet](https://img.shields.io/nuget/v/ESI.NET.svg) +[![CI](https://github.com/seraphx2/ESI.NET/actions/workflows/ci.yml/badge.svg)](https://github.com/seraphx2/ESI.NET/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/v/ESI.NET.svg)](https://www.nuget.org/packages/ESI.NET) # What is ESI.NET? @@ -8,7 +8,7 @@ * [Discord - E.N](https://discord.gg/SvdN39f) - This channel is where you can contact me (Psianna Archeia) for questions and where automated webhook notifications will be pushed for github and when builds are completed. (If you have Discord, this is the preferred way to contact me concerning ESI.NET. I **DO NOT** monitor Slack anymore for ESI.NET issues.) * [Tweetfleet - #esi](https://tweetfleet.slack.com/messages/C30KX8UUX/) - This is the official slack channel to speak with CCP devs (and developers) concerning ESI. * [ESI Application Keys](https://developers.eveonline.com/) -* [ESI Swagger Definition](https://esi.tech.ccp.is/swagger.json) +* [ESI OpenAPI Definition](https://esi.evetech.net/meta/openapi.json) * [ESI-Docs](https://docs.esi.evetech.net/) ([source](https://github.com/esi/esi-docs)) - This is the best documentation concerning ESI and the SSO process. It is extremely important to not solely rely on ESI.NET. You may need to refer to the official specifications to understand what data is expected to be provided. For example, in some instances, ESI.NET will ask for specific values in the endpoint method and construct the JSON object that needs to be sent in the POST request body because it is a simple object that requires a few values. Some of the more complex objects will need to be constructed with anonymous objects by the developer and this can be determined when the endpoint method requires an `object` instead of an `int` or a `string`. Refer to the official documentation and construct the anonymous object to reflect what is expected as Json.NET will be able to convert that anonymous object into the appropriate JSON data. @@ -35,16 +35,26 @@ In your appsettings.json, add the following object and fill it in appropriately: ``` *For your protection (and mine), you are required to supply a user_agent value. This can be your character name and/or project name. CCP will be more likely to contact you than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy. Without this property populated, the wrapper will not work.* -Inject the EsiConfig object into your configuration in `Startup.cs` in the `ConfigureServices()` method: +Register the client. `AddEsi` binds the config, registers `IEsiClient` as a typed +`HttpClient` (via `IHttpClientFactory`), and returns an `IHttpClientBuilder`: ```cs -services.AddEsi(Configuration.GetSection("ESIConfig")); +services.AddEsi(Configuration.GetSection("EsiConfig")); + +// or configure inline: +services.AddEsi(esi => { esi.EsiUrl = "https://esi.evetech.net/"; esi.DataSource = DataSource.Tranquility; esi.UserAgent = "my-app / me"; }); +``` + +Opt in to Polly resilience by adding the `Microsoft.Extensions.Http.Resilience` +package and chaining off the returned builder: +```cs +services.AddEsi(Configuration.GetSection("EsiConfig")) + .AddStandardResilienceHandler(); ``` -Lastly, access the client in your class constructor (the config options above will automatically be injected into it: +Then take `IEsiClient` in your constructor: ```cs private readonly IEsiClient _client; public ApiTestController(IEsiClient client) { _client = client; } - ``` ### .NET Framework @@ -67,36 +77,62 @@ EsiClient client = new EsiClient(config); NOTE: You will need to import `Microsoft.Extensions.Options` to accomplish the above. -### Endpoint Example -Accessing a public endpoint is extremely simple: +### Public endpoint ```cs -EsiResponse response = _client.Universe.Names(new List() +EsiResponse> response = await _client.Universe.Names(new List { - 1590304510, - 99006319, - 20000006 -}).Result; + 1590304510, 99006319, 20000006 +}); +``` + +### Per-call options +Every endpoint method takes a trailing `EsiCallOptions`. It is **optional** on +public endpoints and **required** on authenticated ones: +```cs +public sealed class EsiCallOptions +{ + public AuthorizedCharacterData Character { get; set; } // required for authenticated endpoints + public CancellationToken CancellationToken { get; set; } + public string IfNoneMatch { get; set; } // conditional request; a match -> 304, no body + public int? Page { get; set; } // paginated endpoints +} +``` +```cs +var page2 = await _client.Universe.Groups(new() { Page = 2, CancellationToken = ct }); + +var fresh = await _client.Market.RegionOrders(region_id, new() { IfNoneMatch = previous.ETag }); +if (fresh.StatusCode == HttpStatusCode.NotModified) { /* use your cache */ } ``` ## SSO Example ### SSO Login URL generator -ESI.NET has a helper method to generate the URL required to authenticate a character or authorize roles (by providing a List of scopes) for the Eve Online SSO. You should also provide a value for "state" that you verify when it is returned (it will be included in the callback). +ESI.NET has a helper method to generate the URL required to authenticate a character or authorize roles (by providing a `List` of scopes) for the Eve Online SSO. You should also provide a value for "state" that you verify when it is returned (it will be included in the callback). ```cs var url = _client.SSO.CreateAuthenticationUrl(); ``` ### Initial SSO Token Request +`Verify` throws `InvalidOperationException` if the token fails validation. ```cs SsoToken token = await _client.SSO.GetToken(GrantType.AuthorizationCode, code); -AuthorizedCharacterData auth_char = await _client.SSO.Verify(token); +AuthorizedCharacterData authChar = await _client.SSO.Verify(token); +// persist authChar (at least RefreshToken + CharacterOwnerHash) in your database. +// On every re-login, compare the fresh CharacterOwnerHash to your stored one — a +// mismatch means the character was transferred and the old data must be discarded. ``` ### Refresh Token Request ```cs -SsoToken token = await _client.SSO.GetToken(GrantType.RefreshToken, auth_char.RefreshToken); +SsoToken token = await _client.SSO.GetToken(GrantType.RefreshToken, authChar.RefreshToken); ``` -### Performing an authenticated request -Set the character data on the client before performing the request. +### Authenticated request +Pass the stored character on the call: ```cs -_client.SetCharacterData(authorizedCharacterData) +var wallet = await _client.Wallet.CharacterWallet(new() { Character = authChar }); ``` + +--- + +See [CHANGELOG.md](CHANGELOG.md) for the migration guide from `2023.12.12` +(`SetCharacterData` / `SetIfNoneMatchHeader` removal, the Dogma model split, TFM +and dependency changes). From f903453c58d8d8bf1e89211372e893b53e5c38ac Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 01:21:45 -0400 Subject: [PATCH 13/67] feat(sso): transparent access-token refresh via EsiCallOptions.OnTokenRefreshed Set OnTokenRefreshed and an authenticated call whose Character access token is within a minute of expiry is refreshed with its refresh token before the request goes out; Character is updated in place (Token / RefreshToken / ExpiresOn) and the callback is invoked with it so the caller can persist the rotated refresh token. No callback -> unchanged behaviour, no SSO calls made implicitly. - SsoLogic: internal static SsoHost(DataSource), RequestTokenAsync (HTTP Basic when SecretKey is set, else client_id in the body for a PKCE client), and RefreshAccessTokenAsync (mutates the character; ExpiresOn = now + expires_in). - EsiRequest.Execute: RefreshIfNeededAsync runs before the bearer token is attached, so the request (and the DI pipeline handlers) use the fresh token. - Refresh reuses the call's HttpClient. - Tests: expired -> refreshed + callback + new bearer on the real call; still-valid -> untouched; no callback -> untouched. - CHANGELOG / README updated. Local: build 0 warnings both TFMs, 43/43 tests. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 + ESI.NET.Tests/TokenRefreshTests.cs | 121 +++++++++++++++++++++++++++++ ESI.NET/EsiCallOptions.cs | 11 +++ ESI.NET/EsiRequest.cs | 21 +++++ ESI.NET/Logic/_SSOLogic.cs | 58 ++++++++++++++ README.md | 12 +++ 6 files changed, 227 insertions(+) create mode 100644 ESI.NET.Tests/TokenRefreshTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e879817..d3a3192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,10 @@ _Migration_ below. - `EsiCallOptions.CancellationToken` — honoured by every request. - `EsiCallOptions.IfNoneMatch` + `EsiResponse.ETag` — per-call conditional requests (`304 Not Modified`). +- `EsiCallOptions.OnTokenRefreshed` — when set, an authenticated call whose + access token is within a minute of expiry is transparently refreshed with its + refresh token first; the `AuthorizedCharacterData` is updated in place and the + callback fires so you can persist the rotated refresh token. - `EsiErrorLimitHandler` — reads `X-Esi-Error-Limit-*` and blocks further sends on the client until the window resets; throws `EsiErrorLimitException` on `420`. Wired by `AddEsi`. diff --git a/ESI.NET.Tests/TokenRefreshTests.cs b/ESI.NET.Tests/TokenRefreshTests.cs new file mode 100644 index 0000000..433ed8b --- /dev/null +++ b/ESI.NET.Tests/TokenRefreshTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ESI.NET; +using ESI.NET.Enumerations; +using ESI.NET.Models.SSO; +using Xunit; +using static ESI.NET.EsiRequest; + +namespace ESI.NET.Tests +{ + /// + /// Transparent access-token refresh: when is set + /// and the character's token is (near) expired, .Execute exchanges the + /// refresh token before sending, updates the character in place, and invokes the callback. + /// + public class TokenRefreshTests + { + private sealed class RoutingHandler : HttpMessageHandler + { + public HttpRequestMessage LastApiRequest; + public int TokenCalls; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri.AbsolutePath == "/v2/oauth/token") + { + TokenCalls++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + @"{ ""access_token"": ""NEW-ACCESS"", ""token_type"": ""Bearer"", ""expires_in"": 1200, ""refresh_token"": ""NEW-REFRESH"" }"), + }); + } + + LastApiRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + } + } + + private static readonly EsiConfig Config = new EsiConfig + { + EsiUrl = "https://esi.evetech.net/", + DataSource = DataSource.Tranquility, + ClientId = "client-id", + SecretKey = "client-secret", + }; + + private static AuthorizedCharacterData Character(DateTime expiresOn) => new AuthorizedCharacterData + { + CharacterID = 42, + Token = "OLD-ACCESS", + RefreshToken = "OLD-REFRESH", + ExpiresOn = expiresOn, + }; + + [Fact] + public async Task Expired_token_is_refreshed_and_the_callback_fires() + { + var handler = new RoutingHandler(); + var client = new HttpClient(handler); + var character = Character(DateTime.UtcNow.AddMinutes(-5)); + + AuthorizedCharacterData callbackArg = null; + var options = new EsiCallOptions + { + Character = character, + OnTokenRefreshed = c => { callbackArg = c; return Task.CompletedTask; }, + }; + + await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + + Assert.Equal(1, handler.TokenCalls); + Assert.Equal("NEW-ACCESS", character.Token); + Assert.Equal("NEW-REFRESH", character.RefreshToken); + Assert.True(character.ExpiresOn > DateTime.UtcNow.AddMinutes(15)); + Assert.Same(character, callbackArg); + // the actual API call carried the refreshed token + Assert.Equal("NEW-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task A_still_valid_token_is_not_refreshed() + { + var handler = new RoutingHandler(); + var client = new HttpClient(handler); + var character = Character(DateTime.UtcNow.AddMinutes(10)); + + var fired = false; + var options = new EsiCallOptions + { + Character = character, + OnTokenRefreshed = _ => { fired = true; return Task.CompletedTask; }, + }; + + await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + + Assert.Equal(0, handler.TokenCalls); + Assert.False(fired); + Assert.Equal("OLD-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task Without_the_callback_an_expired_token_is_left_alone() + { + var handler = new RoutingHandler(); + var client = new HttpClient(handler); + var character = Character(DateTime.UtcNow.AddMinutes(-5)); + + var options = new EsiCallOptions { Character = character }; // no OnTokenRefreshed + + await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + + Assert.Equal(0, handler.TokenCalls); + Assert.Equal("OLD-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + } + } +} diff --git a/ESI.NET/EsiCallOptions.cs b/ESI.NET/EsiCallOptions.cs index 6b28122..32c5d6f 100644 --- a/ESI.NET/EsiCallOptions.cs +++ b/ESI.NET/EsiCallOptions.cs @@ -1,5 +1,7 @@ using ESI.NET.Models.SSO; +using System; using System.Threading; +using System.Threading.Tasks; namespace ESI.NET { @@ -29,5 +31,14 @@ public sealed class EsiCallOptions /// 1-based page for paginated endpoints; sent as ?page=. public int? Page { get; set; } + + /// + /// When set, an authenticated call whose access token is within a + /// minute of expiry (or already expired) is transparently refreshed with its refresh token + /// before the request is sent. is updated in place and this callback + /// is invoked with it, so you can persist the rotated refresh token. Requires + /// EsiConfig.ClientId (and SecretKey for a confidential client). + /// + public Func OnTokenRefreshed { get; set; } } } diff --git a/ESI.NET/EsiRequest.cs b/ESI.NET/EsiRequest.cs index 7810bda..58b8d97 100644 --- a/ESI.NET/EsiRequest.cs +++ b/ESI.NET/EsiRequest.cs @@ -35,6 +35,8 @@ public static async Task> Execute(HttpClient client, EsiConfig //Attach token to request header if this endpoint requires an authorized character if (security == RequestSecurity.Authenticated) { + await RefreshIfNeededAsync(client, config, options).ConfigureAwait(false); + var token = options.Character?.Token; if (string.IsNullOrEmpty(token)) throw new ArgumentException("The request endpoint requires SSO authentication; EsiCallOptions.Character (with a valid Token) has not been provided."); @@ -53,6 +55,25 @@ public static async Task> Execute(HttpClient client, EsiConfig return await EsiResponse.CreateAsync(response, path, options.CancellationToken).ConfigureAwait(false); } + private static Task RefreshIfNeededAsync(HttpClient client, EsiConfig config, EsiCallOptions options) + { + var character = options.Character; + if (options.OnTokenRefreshed == null + || character == null + || string.IsNullOrEmpty(character.RefreshToken) + || character.ExpiresOn == default + || character.ExpiresOn > DateTime.UtcNow.AddMinutes(1)) + return Task.CompletedTask; + + return RefreshAndNotifyAsync(client, config, options); + } + + private static async Task RefreshAndNotifyAsync(HttpClient client, EsiConfig config, EsiCallOptions options) + { + await SsoLogic.RefreshAccessTokenAsync(client, config, options.Character, options.CancellationToken).ConfigureAwait(false); + await options.OnTokenRefreshed(options.Character).ConfigureAwait(false); + } + public enum RequestSecurity { Public, diff --git a/ESI.NET/Logic/_SSOLogic.cs b/ESI.NET/Logic/_SSOLogic.cs index 6a0667b..e8f55ec 100644 --- a/ESI.NET/Logic/_SSOLogic.cs +++ b/ESI.NET/Logic/_SSOLogic.cs @@ -12,6 +12,7 @@ using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace ESI.NET @@ -41,6 +42,63 @@ public SsoLogic(HttpClient client, EsiConfig config) _clientKey = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.ClientId}:{config.SecretKey}")); } + /// The SSO host for the configured . + internal static string SsoHost(DataSource dataSource) + { + switch (dataSource) + { + case DataSource.Serenity: return "login.evepc.163.com"; + default: return "login.eveonline.com"; + } + } + + /// + /// POSTs to the SSO /v2/oauth/token endpoint. Uses HTTP + /// Basic auth when is set (confidential client); otherwise + /// the caller is expected to have put client_id in the body (PKCE client). + /// + internal static async Task RequestTokenAsync(HttpClient client, EsiConfig config, string requestBody, CancellationToken cancellationToken = default) + { + var host = SsoHost(config.DataSource); + var request = new HttpRequestMessage(HttpMethod.Post, $"https://{host}/v2/oauth/token") + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/x-www-form-urlencoded"), + }; + + if (!string.IsNullOrEmpty(config.SecretKey)) + { + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.ClientId}:{config.SecretKey}"))); + request.Headers.Host = host; + } + + using (var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false)) + { + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + throw new ArgumentException(JsonConvert.DeserializeAnonymousType(content, new { error_description = string.Empty }).error_description); + return JsonConvert.DeserializeObject(content); + } + } + + /// + /// Exchanges 's refresh token for a new access token and updates + /// , + /// (EVE rotates it) and in place. + /// + internal static async Task RefreshAccessTokenAsync(HttpClient client, EsiConfig config, AuthorizedCharacterData character, CancellationToken cancellationToken = default) + { + var body = $"grant_type={GrantType.RefreshToken.ToEsiValue()}&refresh_token={Uri.EscapeDataString(character.RefreshToken)}"; + if (string.IsNullOrEmpty(config.SecretKey)) + body += $"&client_id={config.ClientId}"; + + var token = await RequestTokenAsync(client, config, body, cancellationToken).ConfigureAwait(false); + + character.Token = token.AccessToken; + character.RefreshToken = token.RefreshToken; + character.ExpiresOn = DateTime.UtcNow.AddSeconds(token.ExpiresIn); + } + /// /// /// diff --git a/README.md b/README.md index 1e4cae1..4544879 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,18 @@ Pass the stored character on the call: var wallet = await _client.Wallet.CharacterWallet(new() { Character = authChar }); ``` +### Transparent token refresh +Set `OnTokenRefreshed` and an expired access token is refreshed before the call, +`authChar` is updated in place, and your callback runs so you can persist the +rotated refresh token: +```cs +var wallet = await _client.Wallet.CharacterWallet(new() +{ + Character = authChar, + OnTokenRefreshed = c => db.SaveCharacterAsync(c), +}); +``` + --- See [CHANGELOG.md](CHANGELOG.md) for the migration guide from `2023.12.12` From 3c6fa0753995f5ca5badd40da26c0b9ea0ac9423 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 01:38:01 -0400 Subject: [PATCH 14/67] refactor(sso): move token refresh into EsiTokenRefreshHandler; add IEsiTokenRefreshSink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh is now a pipeline concern, not something EsiRequest.Execute does inline, so a persist hook can be registered once at DI time instead of on every call. - EsiTokenRefreshHandler (DelegatingHandler): reads the character + optional per-call callback off the request, and if the access token is within a minute of expiry, exchanges the refresh token (routed through the rest of the pipeline via base.SendAsync), updates the character in place, swaps the bearer header, then invokes the per-call callback and — when a DI scope is available — a registered IEsiTokenRefreshSink (resolved in a fresh scope, so a scoped DbContext is fine). - IEsiTokenRefreshSink: register one (services.AddScoped()) and it covers every authenticated call. EsiCallOptions.OnTokenRefreshed stays as the per-call / non-DI hook; both fire. - EsiRequest.Execute: stashes the character/callback on HttpRequestMessage (request.Options on net8, request.Properties on netstandard2.0) via EsiRequestState; no longer refreshes itself. - SsoLogic.RequestTokenAsync / RefreshAccessTokenAsync take a send delegate instead of an HttpClient. - AddEsi wires EsiTokenRefreshHandler outermost; non-DI EsiClient wraps its self-made handler with it (per-call callback only, no sink). - csproj: latest. - Tests: 5 cases incl. AddEsi + one sink registration -> every authenticated call refreshes and the sink fires. CHANGELOG / README updated (sink-first). Local: build 0 warnings both TFMs, 45/45 tests. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 ++- ESI.NET.Tests/TokenRefreshTests.cs | 140 +++++++++++++++++-------- ESI.NET/ESI.NET.csproj | 1 + ESI.NET/EsiClient.cs | 8 +- ESI.NET/EsiRequest.cs | 30 ++---- ESI.NET/Extensions.cs | 2 + ESI.NET/Http/EsiRequestState.cs | 34 ++++++ ESI.NET/Http/EsiTokenRefreshHandler.cs | 65 ++++++++++++ ESI.NET/Http/IEsiTokenRefreshSink.cs | 20 ++++ ESI.NET/Logic/_SSOLogic.cs | 8 +- README.md | 32 +++++- 11 files changed, 272 insertions(+), 82 deletions(-) create mode 100644 ESI.NET/Http/EsiRequestState.cs create mode 100644 ESI.NET/Http/EsiTokenRefreshHandler.cs create mode 100644 ESI.NET/Http/IEsiTokenRefreshSink.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a3192..7534140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,10 +69,16 @@ _Migration_ below. - `EsiCallOptions.CancellationToken` — honoured by every request. - `EsiCallOptions.IfNoneMatch` + `EsiResponse.ETag` — per-call conditional requests (`304 Not Modified`). -- `EsiCallOptions.OnTokenRefreshed` — when set, an authenticated call whose - access token is within a minute of expiry is transparently refreshed with its - refresh token first; the `AuthorizedCharacterData` is updated in place and the - callback fires so you can persist the rotated refresh token. +- **Transparent access-token refresh.** An authenticated call whose access token + is within a minute of expiry is refreshed with its refresh token before the + request goes out (done by `EsiTokenRefreshHandler` in the pipeline); the + `AuthorizedCharacterData` is updated in place. The rotated refresh token is + surfaced two ways, and both fire: + - `IEsiTokenRefreshSink` — implement it, register one + (`services.AddScoped()`), and it covers every + authenticated call. This is the DI-friendly "persist once" hook. + - `EsiCallOptions.OnTokenRefreshed` — a per-call `Func` + for one-offs or non-DI use. - `EsiErrorLimitHandler` — reads `X-Esi-Error-Limit-*` and blocks further sends on the client until the window resets; throws `EsiErrorLimitException` on `420`. Wired by `AddEsi`. diff --git a/ESI.NET.Tests/TokenRefreshTests.cs b/ESI.NET.Tests/TokenRefreshTests.cs index 433ed8b..16d82dd 100644 --- a/ESI.NET.Tests/TokenRefreshTests.cs +++ b/ESI.NET.Tests/TokenRefreshTests.cs @@ -1,52 +1,61 @@ using System; -using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using ESI.NET; using ESI.NET.Enumerations; +using ESI.NET.Http; using ESI.NET.Models.SSO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; -using static ESI.NET.EsiRequest; namespace ESI.NET.Tests { /// - /// Transparent access-token refresh: when is set - /// and the character's token is (near) expired, .Execute exchanges the - /// refresh token before sending, updates the character in place, and invokes the callback. + /// Transparent access-token refresh, done by : a near-expired + /// token is exchanged before the request goes out, the character is updated in place, the bearer + /// header is swapped, and the per-call callback and/or a registered + /// are invoked. /// public class TokenRefreshTests { + private const string NewTokenJson = + @"{ ""access_token"": ""NEW-ACCESS"", ""token_type"": ""Bearer"", ""expires_in"": 1200, ""refresh_token"": ""NEW-REFRESH"" }"; + private sealed class RoutingHandler : HttpMessageHandler { public HttpRequestMessage LastApiRequest; public int TokenCalls; + public string ApiBody = "{}"; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.RequestUri.AbsolutePath == "/v2/oauth/token") { TokenCalls++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - @"{ ""access_token"": ""NEW-ACCESS"", ""token_type"": ""Bearer"", ""expires_in"": 1200, ""refresh_token"": ""NEW-REFRESH"" }"), - }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(NewTokenJson) }); } LastApiRequest = request; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(ApiBody) }); } } + private sealed class FakeSink : IEsiTokenRefreshSink + { + public AuthorizedCharacterData Received; + public Task OnRefreshedAsync(AuthorizedCharacterData character) { Received = character; return Task.CompletedTask; } + } + private static readonly EsiConfig Config = new EsiConfig { EsiUrl = "https://esi.evetech.net/", DataSource = DataSource.Tranquility, ClientId = "client-id", SecretKey = "client-secret", + UserAgent = "refresh-tests", }; private static AuthorizedCharacterData Character(DateTime expiresOn) => new AuthorizedCharacterData @@ -57,65 +66,104 @@ protected override Task SendAsync(HttpRequestMessage reques ExpiresOn = expiresOn, }; + private static HttpRequestMessage AuthedRequest(AuthorizedCharacterData character, Func perCall = null) + { + var req = new HttpRequestMessage(HttpMethod.Get, "https://esi.evetech.net/latest/x/"); + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", character.Token); + EsiRequestState.SetCharacter(req, character); + if (perCall != null) EsiRequestState.SetCallback(req, perCall); + return req; + } + + private static async Task SendThrough(EsiTokenRefreshHandler handler, HttpMessageHandler inner, HttpRequestMessage request) + { + handler.InnerHandler = inner; + using var invoker = new HttpMessageInvoker(handler); + await invoker.SendAsync(request, default); + } + [Fact] - public async Task Expired_token_is_refreshed_and_the_callback_fires() + public async Task Expired_token_is_refreshed_before_the_request() { - var handler = new RoutingHandler(); - var client = new HttpClient(handler); + var routing = new RoutingHandler(); var character = Character(DateTime.UtcNow.AddMinutes(-5)); + var handler = new EsiTokenRefreshHandler(Options.Create(Config)); - AuthorizedCharacterData callbackArg = null; - var options = new EsiCallOptions - { - Character = character, - OnTokenRefreshed = c => { callbackArg = c; return Task.CompletedTask; }, - }; - - await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + await SendThrough(handler, routing, AuthedRequest(character)); - Assert.Equal(1, handler.TokenCalls); + Assert.Equal(1, routing.TokenCalls); Assert.Equal("NEW-ACCESS", character.Token); Assert.Equal("NEW-REFRESH", character.RefreshToken); Assert.True(character.ExpiresOn > DateTime.UtcNow.AddMinutes(15)); - Assert.Same(character, callbackArg); - // the actual API call carried the refreshed token - Assert.Equal("NEW-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + Assert.Equal("NEW-ACCESS", routing.LastApiRequest.Headers.Authorization.Parameter); } [Fact] - public async Task A_still_valid_token_is_not_refreshed() + public async Task A_still_valid_token_is_left_alone() { - var handler = new RoutingHandler(); - var client = new HttpClient(handler); + var routing = new RoutingHandler(); var character = Character(DateTime.UtcNow.AddMinutes(10)); + var handler = new EsiTokenRefreshHandler(Options.Create(Config)); - var fired = false; - var options = new EsiCallOptions - { - Character = character, - OnTokenRefreshed = _ => { fired = true; return Task.CompletedTask; }, - }; + await SendThrough(handler, routing, AuthedRequest(character)); - await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + Assert.Equal(0, routing.TokenCalls); + Assert.Equal("OLD-ACCESS", routing.LastApiRequest.Headers.Authorization.Parameter); + } + + [Fact] + public async Task Per_call_callback_fires_on_refresh() + { + var routing = new RoutingHandler(); + var character = Character(DateTime.UtcNow.AddSeconds(-1)); + AuthorizedCharacterData got = null; + var handler = new EsiTokenRefreshHandler(Options.Create(Config)); - Assert.Equal(0, handler.TokenCalls); - Assert.False(fired); - Assert.Equal("OLD-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + await SendThrough(handler, routing, AuthedRequest(character, c => { got = c; return Task.CompletedTask; })); + + Assert.Same(character, got); } [Fact] - public async Task Without_the_callback_an_expired_token_is_left_alone() + public async Task Registered_sink_fires_on_refresh() { - var handler = new RoutingHandler(); - var client = new HttpClient(handler); + var sink = new FakeSink(); + var provider = new ServiceCollection().AddSingleton(sink).BuildServiceProvider(); + var routing = new RoutingHandler(); var character = Character(DateTime.UtcNow.AddMinutes(-5)); + var handler = new EsiTokenRefreshHandler(Options.Create(Config), provider.GetRequiredService()); + + await SendThrough(handler, routing, AuthedRequest(character)); - var options = new EsiCallOptions { Character = character }; // no OnTokenRefreshed + Assert.Same(character, sink.Received); + } + + [Fact] + public async Task AddEsi_plus_one_sink_registration_covers_every_authenticated_call() + { + var routing = new RoutingHandler { ApiBody = "{}" }; + var sink = new FakeSink(); + + var services = new ServiceCollection(); + services.AddSingleton(sink); + services.AddEsi(c => + { + c.EsiUrl = "https://esi.evetech.net/"; + c.DataSource = DataSource.Tranquility; + c.ClientId = "id"; + c.SecretKey = "secret"; + c.UserAgent = "e2e"; + }).ConfigurePrimaryHttpMessageHandler(() => routing); + + var client = services.BuildServiceProvider().GetRequiredService(); + var character = Character(DateTime.UtcNow.AddMinutes(-5)); - await Execute(client, Config, RequestSecurity.Authenticated, HttpMethod.Get, "/x/", options: options); + await client.Clones.List(new EsiCallOptions { Character = character }); - Assert.Equal(0, handler.TokenCalls); - Assert.Equal("OLD-ACCESS", handler.LastApiRequest.Headers.Authorization.Parameter); + Assert.Equal(1, routing.TokenCalls); + Assert.Same(character, sink.Received); + Assert.Equal("NEW-ACCESS", routing.LastApiRequest.Headers.Authorization.Parameter); + Assert.Contains("/latest/characters/42/clones/", routing.LastApiRequest.RequestUri.ToString()); } } } diff --git a/ESI.NET/ESI.NET.csproj b/ESI.NET/ESI.NET.csproj index ae72644..e85ea22 100644 --- a/ESI.NET/ESI.NET.csproj +++ b/ESI.NET/ESI.NET.csproj @@ -2,6 +2,7 @@ netstandard2.0;net8.0 + latest true diff --git a/ESI.NET/EsiClient.cs b/ESI.NET/EsiClient.cs index 59f5c56..0279b5f 100644 --- a/ESI.NET/EsiClient.cs +++ b/ESI.NET/EsiClient.cs @@ -1,4 +1,5 @@ -using ESI.NET.Logic; +using ESI.NET.Http; +using ESI.NET.Logic; using Microsoft.Extensions.Options; using System; using System.Net; @@ -33,7 +34,10 @@ public EsiClient(IOptions _config, HttpClient _client = null) if (string.IsNullOrWhiteSpace(config.UserAgent)) throw new ArgumentException("EsiConfig.UserAgent is required. Set it to something that identifies your app (character and/or project name) so CCP can contact you rather than cut off ESI access."); - client = new HttpClient(CreateDefaultHandler()); + // No DI pipeline here, so wire the token-refresh handler in manually. Without an + // IServiceScopeFactory it honours EsiCallOptions.OnTokenRefreshed but not a sink. + var handler = new EsiTokenRefreshHandler(_config) { InnerHandler = CreateDefaultHandler() }; + client = new HttpClient(handler); client.DefaultRequestHeaders.Add("X-User-Agent", config.UserAgent); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); } diff --git a/ESI.NET/EsiRequest.cs b/ESI.NET/EsiRequest.cs index 58b8d97..9dcb273 100644 --- a/ESI.NET/EsiRequest.cs +++ b/ESI.NET/EsiRequest.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using ESI.NET.Http; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Net.Http; @@ -35,12 +36,16 @@ public static async Task> Execute(HttpClient client, EsiConfig //Attach token to request header if this endpoint requires an authorized character if (security == RequestSecurity.Authenticated) { - await RefreshIfNeededAsync(client, config, options).ConfigureAwait(false); - var token = options.Character?.Token; if (string.IsNullOrEmpty(token)) throw new ArgumentException("The request endpoint requires SSO authentication; EsiCallOptions.Character (with a valid Token) has not been provided."); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Hand the character (and any per-call refresh callback) to EsiTokenRefreshHandler, + // which refreshes a near-expired token and swaps this header before the send. + EsiRequestState.SetCharacter(request, options.Character); + if (options.OnTokenRefreshed != null) + EsiRequestState.SetCallback(request, options.OnTokenRefreshed); } if (!string.IsNullOrEmpty(options.IfNoneMatch)) @@ -55,25 +60,6 @@ public static async Task> Execute(HttpClient client, EsiConfig return await EsiResponse.CreateAsync(response, path, options.CancellationToken).ConfigureAwait(false); } - private static Task RefreshIfNeededAsync(HttpClient client, EsiConfig config, EsiCallOptions options) - { - var character = options.Character; - if (options.OnTokenRefreshed == null - || character == null - || string.IsNullOrEmpty(character.RefreshToken) - || character.ExpiresOn == default - || character.ExpiresOn > DateTime.UtcNow.AddMinutes(1)) - return Task.CompletedTask; - - return RefreshAndNotifyAsync(client, config, options); - } - - private static async Task RefreshAndNotifyAsync(HttpClient client, EsiConfig config, EsiCallOptions options) - { - await SsoLogic.RefreshAccessTokenAsync(client, config, options.Character, options.CancellationToken).ConfigureAwait(false); - await options.OnTokenRefreshed(options.Character).ConfigureAwait(false); - } - public enum RequestSecurity { Public, diff --git a/ESI.NET/Extensions.cs b/ESI.NET/Extensions.cs index 6b16896..6fdf3a1 100644 --- a/ESI.NET/Extensions.cs +++ b/ESI.NET/Extensions.cs @@ -42,9 +42,11 @@ private static IHttpClientBuilder AddEsiClient(this IServiceCollection services) services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => EsiClient.CreateDefaultHandler()) + .AddHttpMessageHandler() .AddHttpMessageHandler() .AddHttpMessageHandler(); } diff --git a/ESI.NET/Http/EsiRequestState.cs b/ESI.NET/Http/EsiRequestState.cs new file mode 100644 index 0000000..db0f1a8 --- /dev/null +++ b/ESI.NET/Http/EsiRequestState.cs @@ -0,0 +1,34 @@ +using ESI.NET.Models.SSO; +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace ESI.NET.Http +{ + /// + /// Carries the per-request state that .Execute knows but a + /// does not — the authorized character and any per-call + /// OnTokenRefreshed callback — through HttpRequestMessage so + /// can act on it. + /// + internal static class EsiRequestState + { + private const string CharacterKey = "ESI.NET.Character"; + private const string CallbackKey = "ESI.NET.OnTokenRefreshed"; + +#if NET + private static readonly HttpRequestOptionsKey Character = new(CharacterKey); + private static readonly HttpRequestOptionsKey> Callback = new(CallbackKey); + + public static void SetCharacter(HttpRequestMessage request, AuthorizedCharacterData character) => request.Options.Set(Character, character); + public static void SetCallback(HttpRequestMessage request, Func callback) => request.Options.Set(Callback, callback); + public static AuthorizedCharacterData GetCharacter(HttpRequestMessage request) => request.Options.TryGetValue(Character, out var v) ? v : null; + public static Func GetCallback(HttpRequestMessage request) => request.Options.TryGetValue(Callback, out var v) ? v : null; +#else + public static void SetCharacter(HttpRequestMessage request, AuthorizedCharacterData character) => request.Properties[CharacterKey] = character; + public static void SetCallback(HttpRequestMessage request, Func callback) => request.Properties[CallbackKey] = callback; + public static AuthorizedCharacterData GetCharacter(HttpRequestMessage request) => request.Properties.TryGetValue(CharacterKey, out var v) ? v as AuthorizedCharacterData : null; + public static Func GetCallback(HttpRequestMessage request) => request.Properties.TryGetValue(CallbackKey, out var v) ? v as Func : null; +#endif + } +} diff --git a/ESI.NET/Http/EsiTokenRefreshHandler.cs b/ESI.NET/Http/EsiTokenRefreshHandler.cs new file mode 100644 index 0000000..e886902 --- /dev/null +++ b/ESI.NET/Http/EsiTokenRefreshHandler.cs @@ -0,0 +1,65 @@ +using ESI.NET.Models.SSO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace ESI.NET.Http +{ + /// + /// Transparently refreshes a near-expired access token before an authenticated request goes + /// out. The character and any per-call callback are read from the request (put there by + /// .Execute); after a refresh the request's bearer header is swapped, + /// the per-call callback runs, and — when a DI scope is available — a registered + /// is invoked so the rotated refresh token can be persisted + /// once, centrally. + /// + public sealed class EsiTokenRefreshHandler : DelegatingHandler + { + private static readonly TimeSpan Skew = TimeSpan.FromMinutes(1); + + private readonly EsiConfig _config; + private readonly IServiceScopeFactory _scopeFactory; + + public EsiTokenRefreshHandler(IOptions config, IServiceScopeFactory scopeFactory = null) + { + _config = config.Value; + _scopeFactory = scopeFactory; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var character = EsiRequestState.GetCharacter(request); + + if (character != null + && !string.IsNullOrEmpty(character.RefreshToken) + && character.ExpiresOn != default + && character.ExpiresOn <= DateTime.UtcNow.Add(Skew)) + { + await SsoLogic.RefreshAccessTokenAsync((r, ct) => base.SendAsync(r, ct), _config, character, cancellationToken).ConfigureAwait(false); + + // the request was built with the stale token + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", character.Token); + + var perCall = EsiRequestState.GetCallback(request); + if (perCall != null) + await perCall(character).ConfigureAwait(false); + + if (_scopeFactory != null) + { + using (var scope = _scopeFactory.CreateScope()) + { + var sink = scope.ServiceProvider.GetService(); + if (sink != null) + await sink.OnRefreshedAsync(character).ConfigureAwait(false); + } + } + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/ESI.NET/Http/IEsiTokenRefreshSink.cs b/ESI.NET/Http/IEsiTokenRefreshSink.cs new file mode 100644 index 0000000..117f2af --- /dev/null +++ b/ESI.NET/Http/IEsiTokenRefreshSink.cs @@ -0,0 +1,20 @@ +using ESI.NET.Models.SSO; +using System.Threading.Tasks; + +namespace ESI.NET.Http +{ + /// + /// Register one implementation (services.AddScoped<IEsiTokenRefreshSink, YourSink>()) + /// and every authenticated call that transparently refreshes an access token will hand the + /// updated to it — one place to persist the rotated + /// refresh token, instead of an OnTokenRefreshed callback on every call. + /// + public interface IEsiTokenRefreshSink + { + /// + /// Called after a refresh, with 's Token, + /// RefreshToken and ExpiresOn already updated in place. + /// + Task OnRefreshedAsync(AuthorizedCharacterData character); + } +} diff --git a/ESI.NET/Logic/_SSOLogic.cs b/ESI.NET/Logic/_SSOLogic.cs index e8f55ec..6c00685 100644 --- a/ESI.NET/Logic/_SSOLogic.cs +++ b/ESI.NET/Logic/_SSOLogic.cs @@ -57,7 +57,7 @@ internal static string SsoHost(DataSource dataSource) /// Basic auth when is set (confidential client); otherwise /// the caller is expected to have put client_id in the body (PKCE client). /// - internal static async Task RequestTokenAsync(HttpClient client, EsiConfig config, string requestBody, CancellationToken cancellationToken = default) + internal static async Task RequestTokenAsync(Func> send, EsiConfig config, string requestBody, CancellationToken cancellationToken = default) { var host = SsoHost(config.DataSource); var request = new HttpRequestMessage(HttpMethod.Post, $"https://{host}/v2/oauth/token") @@ -72,7 +72,7 @@ internal static async Task RequestTokenAsync(HttpClient client, EsiCon request.Headers.Host = host; } - using (var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false)) + using (var response = await send(request, cancellationToken).ConfigureAwait(false)) { var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) @@ -86,13 +86,13 @@ internal static async Task RequestTokenAsync(HttpClient client, EsiCon /// , /// (EVE rotates it) and in place. /// - internal static async Task RefreshAccessTokenAsync(HttpClient client, EsiConfig config, AuthorizedCharacterData character, CancellationToken cancellationToken = default) + internal static async Task RefreshAccessTokenAsync(Func> send, EsiConfig config, AuthorizedCharacterData character, CancellationToken cancellationToken = default) { var body = $"grant_type={GrantType.RefreshToken.ToEsiValue()}&refresh_token={Uri.EscapeDataString(character.RefreshToken)}"; if (string.IsNullOrEmpty(config.SecretKey)) body += $"&client_id={config.ClientId}"; - var token = await RequestTokenAsync(client, config, body, cancellationToken).ConfigureAwait(false); + var token = await RequestTokenAsync(send, config, body, cancellationToken).ConfigureAwait(false); character.Token = token.AccessToken; character.RefreshToken = token.RefreshToken; diff --git a/README.md b/README.md index 4544879..fccc584 100644 --- a/README.md +++ b/README.md @@ -132,14 +132,38 @@ var wallet = await _client.Wallet.CharacterWallet(new() { Character = authChar } ``` ### Transparent token refresh -Set `OnTokenRefreshed` and an expired access token is refreshed before the call, -`authChar` is updated in place, and your callback runs so you can persist the -rotated refresh token: +An authenticated call whose access token is within a minute of expiry is +refreshed with its refresh token before the request goes out; `authChar` is +updated in place. EVE rotates the refresh token, so you must persist the updated +value. + +**Once, via DI (recommended).** Implement `IEsiTokenRefreshSink` — normal +constructor injection works — and register it; it then covers every +authenticated call: +```cs +public class DbTokenSink : IEsiTokenRefreshSink +{ + private readonly MyDbContext _db; + public DbTokenSink(MyDbContext db) => _db = db; + public async Task OnRefreshedAsync(AuthorizedCharacterData c) + { + _db.Characters.Update(c); + await _db.SaveChangesAsync(); + } +} + +services.AddScoped(); +services.AddEsi(Configuration.GetSection("EsiConfig")); +``` +The handler resolves the sink in a fresh scope each time it fires, so a scoped +`DbContext` is safe. + +**Per call**, for one-offs or non-DI use: ```cs var wallet = await _client.Wallet.CharacterWallet(new() { Character = authChar, - OnTokenRefreshed = c => db.SaveCharacterAsync(c), + OnTokenRefreshed = async c => { /* persist c */ }, }); ``` From c87be573efd94d758399dec8c2e10168cb0c62c8 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 02:33:45 -0400 Subject: [PATCH 15/67] chore(tools): add MintToken utility for minting an SSO refresh token A local, run-once console that walks the EVE SSO authorization-code flow in the browser, catches the redirect on http://localhost:8080/callback with an HttpListener, exchanges the code, calls Verify() to confirm the character and exercise the JWKS path, and prints a long-lived refresh token for the live auth probe / pre-release check. Optionally writes the token straight to a GitHub Actions secret via `gh secret set` (value over stdin, never argv). The repo has several GitHub remotes, so --repo is auto-resolved from `git remote get-url origin`. Not packed, not shipped. Added to the solution under a tools/ folder so it keeps building against the library API. --- ESI.NET.sln | 19 ++ tools/MintToken/MintToken.csproj | 28 +++ tools/MintToken/Program.cs | 302 +++++++++++++++++++++++++++++++ tools/MintToken/README.md | 57 ++++++ 4 files changed, 406 insertions(+) create mode 100644 tools/MintToken/MintToken.csproj create mode 100644 tools/MintToken/Program.cs create mode 100644 tools/MintToken/README.md diff --git a/ESI.NET.sln b/ESI.NET.sln index 6ca67da..93d1e2c 100644 --- a/ESI.NET.sln +++ b/ESI.NET.sln @@ -7,6 +7,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ESI.NET", "ESI.NET\ESI.NET. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ESI.NET.Tests", "ESI.NET.Tests\ESI.NET.Tests.csproj", "{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MintToken", "tools\MintToken\MintToken.csproj", "{591D25A6-7FBB-468E-9020-6C516BCF9C33}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,10 +45,25 @@ Global {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x64.Build.0 = Release|Any CPU {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.ActiveCfg = Release|Any CPU {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.Build.0 = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|x64.ActiveCfg = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|x64.Build.0 = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|x86.ActiveCfg = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|x86.Build.0 = Debug|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|Any CPU.Build.0 = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x64.ActiveCfg = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x64.Build.0 = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x86.ActiveCfg = Release|Any CPU + {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {591D25A6-7FBB-468E-9020-6C516BCF9C33} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0222FDF9-CB59-447A-A88B-C9513544E392} EndGlobalSection diff --git a/tools/MintToken/MintToken.csproj b/tools/MintToken/MintToken.csproj new file mode 100644 index 0000000..41a98bd --- /dev/null +++ b/tools/MintToken/MintToken.csproj @@ -0,0 +1,28 @@ + + + + + Exe + net8.0 + latest + enable + enable + false + ESI.NET.Tools.MintToken + mint-token + + + + + + + + + + + diff --git a/tools/MintToken/Program.cs b/tools/MintToken/Program.cs new file mode 100644 index 0000000..c9b5200 --- /dev/null +++ b/tools/MintToken/Program.cs @@ -0,0 +1,302 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using ESI.NET; +using ESI.NET.Enumerations; +using ESI.NET.Models.SSO; +using Microsoft.Extensions.Options; + +// Mints an EVE SSO refresh token for the live auth probe. +// +// 1. reads Client ID / Secret Key (env ESI_CLIENT_ID / ESI_SECRET_KEY, else prompts) +// 2. opens the browser to the SSO consent screen +// 3. catches the redirect on http://localhost:/callback with an HttpListener +// 4. exchanges the code, calls Verify() to confirm the character, prints the refresh token +// +// Assumes a confidential client (Client ID + Secret Key). Register the callback URL +// EXACTLY as printed on your app at https://developers.eveonline.com/. + +string Env(string key) => Environment.GetEnvironmentVariable(key)?.Trim() ?? ""; + +var clientId = Env("ESI_CLIENT_ID"); +var secretKey = Env("ESI_SECRET_KEY"); +if (clientId.Length == 0) clientId = Prompt("ESI Client ID", secret: false); +if (secretKey.Length == 0) secretKey = Prompt("ESI Secret Key", secret: true); + +if (clientId.Length == 0 || secretKey.Length == 0) +{ + Console.Error.WriteLine("Client ID and Secret Key are both required."); + return 1; +} + +var port = int.TryParse(Env("ESI_CALLBACK_PORT"), out var parsedPort) ? parsedPort : 8080; +var callbackUrl = $"http://localhost:{port}/callback"; + +var scopes = (Env("ESI_SCOPES") is { Length: > 0 } raw ? raw : "esi-wallet.read_character_wallet.v1") + .Split(new[] { ' ', ',', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Distinct() + .ToList(); + +var dataSource = Enum.TryParse(Env("ESI_DATASOURCE"), ignoreCase: true, out var ds) + ? ds + : DataSource.Tranquility; + +Console.WriteLine(); +Console.WriteLine($" data source : {dataSource}"); +Console.WriteLine($" callback : {callbackUrl} <- must be registered on your app, character for character"); +Console.WriteLine($" scopes : {string.Join(" ", scopes)}"); +Console.WriteLine(); + +var config = Options.Create(new EsiConfig +{ + EsiUrl = "https://esi.evetech.net/", + DataSource = dataSource, + ClientId = clientId, + SecretKey = secretKey, + CallbackUrl = callbackUrl, + UserAgent = "ESI.NET MintToken (local token minter)", +}); + +var client = new EsiClient(config); +var state = Guid.NewGuid().ToString("N"); +var authUrl = client.SSO.CreateAuthenticationUrl(scopes, state); + +using var listener = new HttpListener(); +listener.Prefixes.Add($"http://localhost:{port}/callback/"); +try +{ + listener.Start(); +} +catch (HttpListenerException ex) +{ + Console.Error.WriteLine($"Could not bind {callbackUrl}: {ex.Message}"); + Console.Error.WriteLine("Set ESI_CALLBACK_PORT to a free port and register that callback on your app."); + return 1; +} + +Console.WriteLine("Opening your browser to log in. If it does not open, paste this URL:"); +Console.WriteLine(); +Console.WriteLine(" " + authUrl); +Console.WriteLine(); +try +{ + Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true }); +} +catch +{ + // headless / no default browser: the printed URL above is the fallback +} + +var timeout = TimeSpan.FromMinutes(5); +var contextTask = listener.GetContextAsync(); +if (await Task.WhenAny(contextTask, Task.Delay(timeout)) != contextTask) +{ + Console.Error.WriteLine($"Timed out after {timeout.TotalMinutes:0} minutes waiting for the SSO redirect."); + return 1; +} + +var ctx = await contextTask; +var query = ctx.Request.QueryString; + +if (query["error"] is { Length: > 0 } ssoError) +{ + Reply($"

SSO returned an error

{WebUtility.HtmlEncode(ssoError)}: {WebUtility.HtmlEncode(query["error_description"])}

", 400); + Console.Error.WriteLine($"SSO error: {ssoError} - {query["error_description"]}"); + return 1; +} + +if (query["state"] != state) +{ + Reply("

State mismatch

Ignoring this response. Re-run the tool.

", 400); + Console.Error.WriteLine("The state parameter did not match - stale or forged redirect. Aborting."); + return 1; +} + +var code = query["code"]; +if (string.IsNullOrEmpty(code)) +{ + Reply("

No authorization code in the redirect.

", 400); + Console.Error.WriteLine("The redirect carried no ?code= value."); + return 1; +} + +SsoToken token; +try +{ + token = await client.SSO.GetToken(GrantType.AuthorizationCode, code); +} +catch (Exception ex) +{ + Reply($"

Token exchange failed

{WebUtility.HtmlEncode(ex.Message)}

", 400); + Console.Error.WriteLine("GetToken failed: " + ex.Message); + return 1; +} + +// Verify() both confirms the character and exercises the JWKS validation path +// (the risky part of the Microsoft.IdentityModel.Tokens 6 -> 8 upgrade). +string who; +try +{ + var authChar = await client.SSO.Verify(token); + who = $"{authChar.CharacterName} (id {authChar.CharacterID}) scopes: {authChar.Scopes}"; +} +catch (Exception ex) +{ + who = "Verify FAILED: " + ex.Message; +} + +Reply($"

Done.

Refresh token minted for {WebUtility.HtmlEncode(who)}.
You can close this tab and return to the terminal.

"); + +var secretName = Env("ESI_SECRET_NAME") is { Length: > 0 } customName ? customName : "ESI_REFRESH_TOKEN"; +// gh refuses to pick when a repo has several GitHub remotes (forks pulled in as +// remotes). Default to origin; ESI_SECRET_REPO overrides. +var secretRepo = Env("ESI_SECRET_REPO") is { Length: > 0 } explicitRepo ? explicitRepo : ResolveOriginRepo(); + +Console.WriteLine(); +Console.WriteLine("======================================================================"); +Console.WriteLine(" Character : " + who); +Console.WriteLine(" Access token : expires in " + token.ExpiresIn + "s (the refresh token below is long-lived)"); +Console.WriteLine(); +Console.WriteLine(" REFRESH TOKEN:"); +Console.WriteLine(); +Console.WriteLine(" " + token.RefreshToken); +Console.WriteLine("======================================================================"); +Console.WriteLine(); + +var setSecret = Env("ESI_SET_SECRET") is "1" or "true" or "TRUE" or "yes"; +if (!setSecret && !Console.IsInputRedirected) +{ + Console.Write($"Set the {secretName} GitHub secret now with gh? [y/N]: "); + setSecret = (Console.ReadLine() ?? "").Trim().ToLowerInvariant() is "y" or "yes"; +} + +var ghError = ""; +if (setSecret && TrySetSecret(secretName, secretRepo, token.RefreshToken, out ghError)) +{ + Console.WriteLine($" {secretName} set via gh{(secretRepo.Length > 0 ? $" ({secretRepo})" : "")}."); +} +else +{ + if (setSecret) + Console.WriteLine(" gh could not set the secret: " + ghError); + + var repoArg = secretRepo.Length > 0 ? $" --repo {secretRepo}" : ""; + Console.WriteLine(); + Console.WriteLine(" Store it yourself with:"); + Console.WriteLine(); + Console.WriteLine($" gh secret set {secretName}{repoArg} --body \"{token.RefreshToken}\""); +} + +return 0; + +static string ResolveOriginRepo() +{ + try + { + var psi = new ProcessStartInfo("git") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("remote"); + psi.ArgumentList.Add("get-url"); + psi.ArgumentList.Add("origin"); + + using var proc = Process.Start(psi); + if (proc is null) return ""; + var url = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(); + if (proc.ExitCode != 0) return ""; + + // git@github.com:owner/repo.git | https://github.com/owner/repo(.git) + var match = System.Text.RegularExpressions.Regex.Match(url, @"github\.com[/:]([^/]+)/(.+?)(?:\.git)?/?$"); + return match.Success ? $"{match.Groups[1].Value}/{match.Groups[2].Value}" : ""; + } + catch + { + return ""; + } +} + +static bool TrySetSecret(string name, string repo, string value, out string error) +{ + error = ""; + try + { + var psi = new ProcessStartInfo("gh") + { + RedirectStandardInput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("secret"); + psi.ArgumentList.Add("set"); + psi.ArgumentList.Add(name); + if (repo.Length > 0) + { + psi.ArgumentList.Add("--repo"); + psi.ArgumentList.Add(repo); + } + + using var proc = Process.Start(psi); + if (proc is null) + { + error = "could not start gh (is it installed and on PATH?)"; + return false; + } + + proc.StandardInput.Write(value); // value goes over stdin, never argv / shell history + proc.StandardInput.Close(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode == 0) + return true; + + error = string.IsNullOrWhiteSpace(stderr) ? $"gh exited {proc.ExitCode}" : stderr.Trim(); + return false; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } +} + +string Reply(string html, int status = 200) +{ + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "text/html; charset=utf-8"; + var bytes = Encoding.UTF8.GetBytes( + $"{html}"); + ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); + ctx.Response.Close(); + return html; +} + +static string Prompt(string label, bool secret) +{ + Console.Write(label + ": "); + + if (!secret || Console.IsInputRedirected) + return (Console.ReadLine() ?? "").Trim(); + + var sb = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) break; + if (key.Key == ConsoleKey.Backspace) + { + if (sb.Length > 0) { sb.Length--; Console.Write("\b \b"); } + } + else if (!char.IsControl(key.KeyChar)) + { + sb.Append(key.KeyChar); + Console.Write('*'); + } + } + Console.WriteLine(); + return sb.ToString().Trim(); +} diff --git a/tools/MintToken/README.md b/tools/MintToken/README.md new file mode 100644 index 0000000..a5189de --- /dev/null +++ b/tools/MintToken/README.md @@ -0,0 +1,57 @@ +# MintToken + +A local, run-once utility that walks the EVE SSO **authorization-code** flow in your +browser and prints a long-lived **refresh token**. That token is what the live auth +probe (and any manual pre-release check) uses to prove that token exchange, JWKS +validation, the bearer pipeline, and transparent refresh all still work against the +real SSO servers. + +It is not packed and not shipped. It exists so re-minting later is `dotnet run`. + +## One-time setup + +1. Create (or reuse) an application at . + - **Confidential client** — you need a **Client ID** *and* a **Secret Key**. + - **Callback URL**: `http://localhost:8080/callback` (character for character; change + the port with `ESI_CALLBACK_PORT` and register that instead). + - **Scopes**: add whatever the probe will call. The default is just + `esi-wallet.read_character_wallet.v1` — the token can do nothing else. +2. Use a throwaway / alt character if you don't want your main's wallet readable. A + fresh free account works; the wallet endpoint returns `0.0` and still `200`s. + +## Run + +```sh +# from the repo root +ESI_CLIENT_ID=xxxx ESI_SECRET_KEY=yyyy dotnet run --project tools/MintToken +``` + +Anything omitted from the environment is prompted for (the secret key is masked). +The browser opens, you log in and authorize, the tool catches the redirect on +`localhost`, exchanges the code, calls `Verify()` to confirm the character, and +prints the refresh token. + +It then offers to store the token as a GitHub Actions secret via `gh` +(`gh secret set ESI_REFRESH_TOKEN`, value piped over stdin so it never lands in +argv or shell history). Answer `y`, or skip it and copy the printed command. + +## Environment variables + +| Variable | Default | Meaning | +| --- | --- | --- | +| `ESI_CLIENT_ID` | *(prompt)* | Application Client ID | +| `ESI_SECRET_KEY` | *(masked prompt)* | Application Secret Key | +| `ESI_SCOPES` | `esi-wallet.read_character_wallet.v1` | space- or comma-separated scope list | +| `ESI_CALLBACK_PORT` | `8080` | loopback port; the callback becomes `http://localhost:/callback` | +| `ESI_DATASOURCE` | `Tranquility` | `Tranquility` or `Serenity` | +| `ESI_SET_SECRET` | *(unset)* | `1` / `true` / `yes` → set the secret with `gh` without prompting | +| `ESI_SECRET_NAME` | `ESI_REFRESH_TOKEN` | secret name to write | +| `ESI_SECRET_REPO` | the `origin` remote | `owner/name` for `gh secret set --repo`; auto-resolved from `git remote get-url origin` (this repo has several GitHub remotes, so `gh` can't guess) | + +## Notes + +- The refresh token has no timer. It dies only if you revoke it, change the app's + scopes, rotate the app secret, or CCP rotates it on use. Re-run this tool if the + probe starts failing with `invalid_grant`. +- Fork pull requests never see repo secrets, so the probe must skip when + `ESI_REFRESH_TOKEN` is absent. From cec7d38ae1b5c499131075b7555d599a58b1256e Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 03:05:32 -0400 Subject: [PATCH 16/67] feat(tools): add SpecCheck - Tier 1 spec coverage drift check Compares the live ESI OpenAPI 3.1 document against the endpoint set the wrapper actually implements. The route, HTTP method and security live inside each Logic method body, so they are read from a Roslyn syntax walk of every Execute(...) call; the response model T comes from reflecting the built ESI.NET assembly and is joined on (class, method). No hand-maintained manifest. Tier 1 diff: - orphaned (implemented, absent from spec) -> error (latent 404) - missing (in spec, not implemented) -> warning (error under --strict) - parameter-name drift -> warning Current run: 195/197 covered; 2 missing (GET /meta/changelog, /meta/compatibility-dates); 12 orphaned - the esi-bookmarks and esi-opportunities removals plus chat_channels, GET /characters/names and the public /search branch. Not packed. Added to the solution under tools/. --- ESI.NET.sln | 15 +++ tools/SpecCheck/CoverageCheck.cs | 103 +++++++++++++++ tools/SpecCheck/Finding.cs | 14 +++ tools/SpecCheck/Program.cs | 72 +++++++++++ tools/SpecCheck/README.md | 57 +++++++++ tools/SpecCheck/Report.cs | 127 +++++++++++++++++++ tools/SpecCheck/Spec.cs | 143 +++++++++++++++++++++ tools/SpecCheck/SpecCheck.csproj | 33 +++++ tools/SpecCheck/Wrapper.cs | 210 +++++++++++++++++++++++++++++++ 9 files changed, 774 insertions(+) create mode 100644 tools/SpecCheck/CoverageCheck.cs create mode 100644 tools/SpecCheck/Finding.cs create mode 100644 tools/SpecCheck/Program.cs create mode 100644 tools/SpecCheck/README.md create mode 100644 tools/SpecCheck/Report.cs create mode 100644 tools/SpecCheck/Spec.cs create mode 100644 tools/SpecCheck/SpecCheck.csproj create mode 100644 tools/SpecCheck/Wrapper.cs diff --git a/ESI.NET.sln b/ESI.NET.sln index 93d1e2c..2226ae7 100644 --- a/ESI.NET.sln +++ b/ESI.NET.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MintToken", "tools\MintToken\MintToken.csproj", "{591D25A6-7FBB-468E-9020-6C516BCF9C33}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpecCheck", "tools\SpecCheck\SpecCheck.csproj", "{43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,12 +59,25 @@ Global {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x64.Build.0 = Release|Any CPU {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x86.ActiveCfg = Release|Any CPU {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Release|x86.Build.0 = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|x64.Build.0 = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Debug|x86.Build.0 = Debug|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|Any CPU.Build.0 = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x64.ActiveCfg = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x64.Build.0 = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x86.ActiveCfg = Release|Any CPU + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {591D25A6-7FBB-468E-9020-6C516BCF9C33} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0222FDF9-CB59-447A-A88B-C9513544E392} diff --git a/tools/SpecCheck/CoverageCheck.cs b/tools/SpecCheck/CoverageCheck.cs new file mode 100644 index 0000000..eae4291 --- /dev/null +++ b/tools/SpecCheck/CoverageCheck.cs @@ -0,0 +1,103 @@ +namespace ESI.NET.Tools.SpecCheck; + +public sealed record CoverageRow(string Tag, int Covered, int Total) +{ + public IReadOnlyList Missing { get; init; } = Array.Empty(); +} + +public sealed class CoverageResult +{ + public required IReadOnlyList Rows { get; init; } + + /// In the wrapper, but the spec has no such (method, path). Latent 404s. + public required IReadOnlyList Orphaned { get; init; } + + /// Same route shape, different {parameter} name - matches loosely, not exactly. + public required IReadOnlyList<(ImplementedEndpoint Wrapper, SpecOperation Spec)> ParamNameMismatch { get; init; } + + public required int TotalCovered { get; init; } + public required int TotalOperations { get; init; } + public required IReadOnlyList Findings { get; init; } +} + +/// +/// Tier 1. Set-difference between the spec's (method, path) operations and the +/// wrapper's. Orphaned endpoints are an error; missing endpoints and parameter-name +/// drift are warnings. +/// +public static class CoverageCheck +{ + public static CoverageResult Run(Spec spec, Wrapper wrapper, bool strict) + { + var specByKey = spec.Operations + .GroupBy(o => o.Key) + .ToDictionary(g => g.Key, g => g.First()); + + var specByLooseKey = spec.Operations.ToLookup(o => o.LooseKey); + var wrapperKeys = wrapper.Endpoints.Select(e => e.Key).ToHashSet(); + + var findings = new List(); + + // ---- coverage per tag ------------------------------------------------- + var rows = spec.Operations + .GroupBy(o => o.Tag) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => + { + var missing = g.Where(o => !wrapperKeys.Contains(o.Key)).OrderBy(o => o.Key).ToList(); + return new CoverageRow(g.Key, g.Count() - missing.Count, g.Count()) { Missing = missing }; + }) + .ToList(); + + foreach (var op in rows.SelectMany(r => r.Missing)) + { + var looselyCovered = wrapper.Endpoints.Any(e => e.LooseKey == op.LooseKey); + if (looselyCovered) + continue; // handled below as a parameter-name mismatch + findings.Add(new Finding( + strict ? Severity.Error : Severity.Warning, + "missing-endpoint", op.Key, + $"in the spec (tag {op.Tag}, {op.OperationId}) but not implemented")); + } + + // ---- orphaned + parameter-name drift -------------------------------- + var orphaned = new List(); + var paramMismatch = new List<(ImplementedEndpoint, SpecOperation)>(); + + foreach (var endpoint in wrapper.Endpoints.Where(e => !specByKey.ContainsKey(e.Key))) + { + var loose = specByLooseKey[endpoint.LooseKey].ToList(); + if (loose.Count > 0) + { + paramMismatch.Add((endpoint, loose[0])); + findings.Add(new Finding( + strict ? Severity.Error : Severity.Warning, + "parameter-name", endpoint.Key, + $"{endpoint.Class}.{endpoint.Method} - route matches {loose[0].Key} but the parameter name(s) differ")); + } + else + { + orphaned.Add(endpoint); + findings.Add(new Finding( + Severity.Error, + "orphaned-endpoint", endpoint.Key, + $"{endpoint.Class}.{endpoint.Method} - implemented but absent from the spec (renamed or removed upstream)")); + } + } + + foreach (var warning in wrapper.Warnings) + findings.Add(new Finding(Severity.Warning, "scan", "-", warning)); + + var covered = spec.Operations.Count(o => wrapperKeys.Contains(o.Key)); + + return new CoverageResult + { + Rows = rows, + Orphaned = orphaned, + ParamNameMismatch = paramMismatch, + TotalCovered = covered, + TotalOperations = spec.Operations.Count, + Findings = findings, + }; + } +} diff --git a/tools/SpecCheck/Finding.cs b/tools/SpecCheck/Finding.cs new file mode 100644 index 0000000..27ca371 --- /dev/null +++ b/tools/SpecCheck/Finding.cs @@ -0,0 +1,14 @@ +namespace ESI.NET.Tools.SpecCheck; + +public enum Severity +{ + Info, // reported, never fails the build + Warning, // fails the build only under --strict + Error, // always fails the build +} + +/// A single drift observation. is an endpoint key or type name. +public sealed record Finding(Severity Severity, string Category, string Where, string Message) +{ + public override string ToString() => $"[{Severity.ToString().ToUpperInvariant()}] {Category}: {Where} - {Message}"; +} diff --git a/tools/SpecCheck/Program.cs b/tools/SpecCheck/Program.cs new file mode 100644 index 0000000..724b465 --- /dev/null +++ b/tools/SpecCheck/Program.cs @@ -0,0 +1,72 @@ +using ESI.NET; +using ESI.NET.Tools.SpecCheck; + +// spec-check [--spec ] [--source ] [--strict] +// +// --spec OpenAPI document. Default: the live ESI meta spec. +// --source ESI.NET/Logic directory. Default: auto-detected from the repo root. +// --strict Warnings fail the build too (missing endpoints, parameter-name drift). + +var specSource = "https://esi.evetech.net/meta/openapi.json"; +string? sourceDir = null; +var strict = false; + +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--spec" when i + 1 < args.Length: specSource = args[++i]; break; + case "--source" when i + 1 < args.Length: sourceDir = args[++i]; break; + case "--strict": strict = true; break; + case "-h" or "--help": + Console.WriteLine("usage: spec-check [--spec ] [--source ] [--strict]"); + return 0; + default: + Console.Error.WriteLine($"unknown argument: {args[i]}"); + return 2; + } +} + +sourceDir ??= LocateLogicDirectory(); +if (sourceDir is null || !Directory.Exists(sourceDir)) +{ + Console.Error.WriteLine("could not locate ESI.NET/Logic; pass --source "); + return 2; +} + +using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; +http.DefaultRequestHeaders.UserAgent.ParseAdd("ESI.NET-spec-check/1.0"); + +Spec spec; +try +{ + spec = await Spec.LoadAsync(specSource, http); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"could not load the spec from {specSource}: {ex.Message}"); + return 2; +} + +var wrapper = Wrapper.Scan(sourceDir, typeof(EsiClient).Assembly); +var coverage = CoverageCheck.Run(spec, wrapper, strict); +return Report.Render(spec, wrapper, coverage, strict); + +// Walk up from the working directory (and from the binary) until a folder holds +// ESI.NET.sln, then return its ESI.NET/Logic. +static string? LocateLogicDirectory() +{ + foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + for (var dir = new DirectoryInfo(start); dir is not null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "ESI.NET.sln"))) + { + var logic = Path.Combine(dir.FullName, "ESI.NET", "Logic"); + if (Directory.Exists(logic)) + return logic; + } + } + } + return null; +} diff --git a/tools/SpecCheck/README.md b/tools/SpecCheck/README.md new file mode 100644 index 0000000..94bbfe2 --- /dev/null +++ b/tools/SpecCheck/README.md @@ -0,0 +1,57 @@ +# SpecCheck + +Compares the live ESI OpenAPI document against what `ESI.NET/Logic/*.cs` actually +implements, and exits non-zero on drift. Run on a schedule by +`.github/workflows/spec-check.yml`; a failed run emails the maintainer. + +```sh +dotnet run --project tools/SpecCheck # live spec, default source +dotnet run --project tools/SpecCheck -- --strict +dotnet run --project tools/SpecCheck -- --spec ./openapi.json --source ./ESI.NET/Logic +``` + +| Flag | Default | | +| --- | --- | --- | +| `--spec ` | `https://esi.evetech.net/meta/openapi.json` | OpenAPI 3.1 document | +| `--source ` | auto-detected (`ESI.NET/Logic` under the repo root) | Logic sources to scan | +| `--strict` | off | warnings fail the build too | + +## How it reads the wrapper + +The HTTP method, route and security live *inside* each Logic method body, so they +come from a Roslyn syntax walk of every `Execute(_client, _config, +RequestSecurity.x, HttpMethod.y, "/route/", …)` call. The response model `T` +comes from reflecting the built `ESI.NET` assembly (`Task>`) and is +joined back on `(class, method)`. One method (`SearchLogic.Query`) picks its route +from a local at runtime; its routes are read heuristically from the method body +and a scanner warning is emitted. + +## Tier 1 — coverage + +Set-difference on `(METHOD, path)` after normalising trailing slashes. + +- **Orphaned** — implemented but absent from the spec → **error**. A latent 404; + the endpoint was renamed or removed upstream. +- **Missing** — in the spec but not implemented → **warning** (error under + `--strict`). +- **Parameter-name drift** — same route shape, different `{param}` name → + **warning**. + +## Tier 2 — schema + +_(next commit)_ For every covered endpoint, walk the `EsiResponse` model with +reflection and compare it to the resolved 200 schema: properties present in the +spec but not the model, properties in the model the spec no longer has, and type +mismatches (`int` vs `int64`, scalar vs array, enum value drift). + +## Files + +| | | +| --- | --- | +| `Spec.cs` | loads the OpenAPI document, enumerates operations, resolves `$ref` | +| `Wrapper.cs` | Roslyn scan of `Logic/*.cs` + reflection join → implemented endpoints | +| `CoverageCheck.cs` | Tier 1 diff | +| `SchemaCheck.cs` | Tier 2 diff _(next commit)_ | +| `Finding.cs` | severity + message | +| `Report.cs` | stdout + `$GITHUB_STEP_SUMMARY`, exit code | +| `Program.cs` | argument parsing, orchestration | diff --git a/tools/SpecCheck/Report.cs b/tools/SpecCheck/Report.cs new file mode 100644 index 0000000..cff0153 --- /dev/null +++ b/tools/SpecCheck/Report.cs @@ -0,0 +1,127 @@ +using System.Text; + +namespace ESI.NET.Tools.SpecCheck; + +/// +/// Renders the check results to stdout, appends a markdown summary to +/// $GITHUB_STEP_SUMMARY when running in Actions, and returns the process +/// exit code (non-zero if any , or any +/// when ). +/// +public static class Report +{ + public static int Render(Spec spec, Wrapper wrapper, CoverageResult coverage, bool strict) + { + var findings = coverage.Findings; + var text = BuildText(spec, wrapper, coverage); + Console.WriteLine(text); + + var summaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); + if (!string.IsNullOrEmpty(summaryPath)) + { + try { File.AppendAllText(summaryPath, BuildMarkdown(spec, coverage)); } + catch (Exception ex) { Console.Error.WriteLine($"(could not write GITHUB_STEP_SUMMARY: {ex.Message})"); } + } + + var errors = findings.Count(f => f.Severity == Severity.Error); + var warnings = findings.Count(f => f.Severity == Severity.Warning); + + Console.WriteLine(); + Console.WriteLine($"{errors} error(s), {warnings} warning(s). " + + $"Coverage {coverage.TotalCovered}/{coverage.TotalOperations} " + + $"({Percent(coverage.TotalCovered, coverage.TotalOperations)})."); + + var failed = errors > 0 || (strict && warnings > 0); + Console.WriteLine(failed ? "RESULT: drift detected." : "RESULT: clean."); + return failed ? 1 : 0; + } + + private static string BuildText(Spec spec, Wrapper wrapper, CoverageResult coverage) + { + var sb = new StringBuilder(); + sb.AppendLine("ESI.NET spec-check"); + sb.AppendLine($" openapi {spec.OpenApiVersion}, info.version {spec.InfoVersion}"); + sb.AppendLine($" spec operations : {spec.Operations.Count}"); + sb.AppendLine($" wrapper endpoints: {wrapper.Endpoints.Count}"); + sb.AppendLine(); + + sb.AppendLine("COVERAGE BY TAG"); + foreach (var row in coverage.Rows) + { + sb.AppendLine($" {row.Tag,-26} {row.Covered,3}/{row.Total,-3} {Percent(row.Covered, row.Total)}"); + foreach (var op in row.Missing) + sb.AppendLine($" - missing {op.Key}"); + } + sb.AppendLine($" {"TOTAL",-26} {coverage.TotalCovered,3}/{coverage.TotalOperations,-3}"); + sb.AppendLine(); + + sb.AppendLine($"ORPHANED (implemented, not in spec) [{coverage.Orphaned.Count}]"); + foreach (var e in coverage.Orphaned) + sb.AppendLine($" {e.Key,-55} {e.Class}.{e.Method}"); + sb.AppendLine(); + + sb.AppendLine($"PARAMETER-NAME DRIFT [{coverage.ParamNameMismatch.Count}]"); + foreach (var (w, s) in coverage.ParamNameMismatch) + sb.AppendLine($" {w.Key} ~ {s.Key} ({w.Class}.{w.Method})"); + sb.AppendLine(); + + var scanWarnings = coverage.Findings.Where(f => f.Category == "scan").ToList(); + if (scanWarnings.Count > 0) + { + sb.AppendLine($"SCANNER WARNINGS [{scanWarnings.Count}]"); + foreach (var f in scanWarnings) + sb.AppendLine($" {f.Message}"); + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + private static string BuildMarkdown(Spec spec, CoverageResult coverage) + { + var sb = new StringBuilder(); + sb.AppendLine("## ESI.NET spec-check"); + sb.AppendLine(); + sb.AppendLine($"`openapi {spec.OpenApiVersion}` · `info.version {spec.InfoVersion}` · " + + $"coverage **{coverage.TotalCovered}/{coverage.TotalOperations}** " + + $"({Percent(coverage.TotalCovered, coverage.TotalOperations)})"); + sb.AppendLine(); + sb.AppendLine("| Tag | Covered | |"); + sb.AppendLine("| --- | --- | --- |"); + foreach (var row in coverage.Rows) + sb.AppendLine($"| {row.Tag} | {row.Covered}/{row.Total} | {(row.Covered == row.Total ? "✅" : "⚠️")} |"); + sb.AppendLine(); + + if (coverage.Orphaned.Count > 0) + { + sb.AppendLine($"### ❌ Orphaned — implemented, not in spec ({coverage.Orphaned.Count})"); + foreach (var e in coverage.Orphaned) + sb.AppendLine($"- `{e.Key}` — {e.Class}.{e.Method}"); + sb.AppendLine(); + } + + var missing = coverage.Rows.SelectMany(r => r.Missing).ToList(); + if (missing.Count > 0) + { + sb.AppendLine($"
⚠️ Missing — in spec, not implemented ({missing.Count})"); + sb.AppendLine(); + foreach (var op in missing) + sb.AppendLine($"- `{op.Key}` — {op.Tag}"); + sb.AppendLine(); + sb.AppendLine("
"); + sb.AppendLine(); + } + + if (coverage.ParamNameMismatch.Count > 0) + { + sb.AppendLine($"### ⚠️ Parameter-name drift ({coverage.ParamNameMismatch.Count})"); + foreach (var (w, s) in coverage.ParamNameMismatch) + sb.AppendLine($"- `{w.Key}` ~ `{s.Key}` — {w.Class}.{w.Method}"); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string Percent(int n, int d) => d == 0 ? "n/a" : $"{100.0 * n / d:0.#}%"; +} diff --git a/tools/SpecCheck/Spec.cs b/tools/SpecCheck/Spec.cs new file mode 100644 index 0000000..ee2782c --- /dev/null +++ b/tools/SpecCheck/Spec.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ESI.NET.Tools.SpecCheck; + +/// One (method, path) operation lifted from the OpenAPI document. +public sealed record SpecOperation( + string Method, // GET, POST, PUT, DELETE + string Path, // "/alliances/{alliance_id}" - normalized, no trailing slash + string OperationId, + string Tag, + JsonElement? ResponseSchema) // resolved 200/201 application/json schema (Tier 2); null if none +{ + public string Key => $"{Method} {Path}"; + public string LooseKey => $"{Method} {Spec.ParamAgnostic(Path)}"; +} + +/// +/// The parsed ESI OpenAPI 3.1 document. Enumerates operations and resolves local +/// $ref pointers into #/components/schemas. +/// +public sealed class Spec +{ + private readonly JsonDocument _doc; + private JsonElement Root => _doc.RootElement; + + public string OpenApiVersion { get; } + public string InfoVersion { get; } + public IReadOnlyList Operations { get; } + + private Spec(JsonDocument doc) + { + _doc = doc; + OpenApiVersion = Root.TryGetProperty("openapi", out var v) ? v.GetString() ?? "" : ""; + InfoVersion = Root.TryGetProperty("info", out var info) && info.TryGetProperty("version", out var iv) + ? iv.GetString() ?? "" : ""; + Operations = EnumerateOperations().ToList(); + } + + public static async Task LoadAsync(string source, HttpClient http) + { + var json = source.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? await http.GetStringAsync(source) + : await File.ReadAllTextAsync(source); + return new Spec(JsonDocument.Parse(json)); + } + + private static readonly string[] HttpMethods = { "get", "post", "put", "delete", "patch" }; + + private IEnumerable EnumerateOperations() + { + if (!Root.TryGetProperty("paths", out var paths)) + yield break; + + foreach (var path in paths.EnumerateObject()) + { + var normalized = NormalizePath(path.Name); + foreach (var method in HttpMethods) + { + if (!path.Value.TryGetProperty(method, out var op)) + continue; + + var operationId = op.TryGetProperty("operationId", out var oid) ? oid.GetString() ?? "" : ""; + var tag = op.TryGetProperty("tags", out var tags) + && tags.ValueKind == JsonValueKind.Array + && tags.GetArrayLength() > 0 + ? tags[0].GetString() ?? "(untagged)" + : "(untagged)"; + + yield return new SpecOperation( + method.ToUpperInvariant(), + normalized, + operationId, + tag, + ResolveResponseSchema(op)); + } + } + } + + private JsonElement? ResolveResponseSchema(JsonElement op) + { + if (!op.TryGetProperty("responses", out var responses)) + return null; + + foreach (var code in new[] { "200", "201" }) + { + if (responses.TryGetProperty(code, out var response) + && response.TryGetProperty("content", out var content) + && content.TryGetProperty("application/json", out var json) + && json.TryGetProperty("schema", out var schema)) + return Resolve(schema); + } + return null; + } + + /// Follows a local $ref chain to the concrete schema. Inline schemas pass through. + public JsonElement Resolve(JsonElement schema) => Resolve(schema, new HashSet()); + + private JsonElement Resolve(JsonElement schema, HashSet seen) + { + while (schema.ValueKind == JsonValueKind.Object + && schema.TryGetProperty("$ref", out var refElement)) + { + var pointer = refElement.GetString() ?? ""; + if (!seen.Add(pointer)) + break; // cycle + var target = ResolvePointer(pointer); + if (target is null) + break; + schema = target.Value; + } + return schema; + } + + private JsonElement? ResolvePointer(string pointer) + { + if (!pointer.StartsWith("#/")) + return null; // external refs unsupported; ESI's spec is self-contained + + var current = Root; + foreach (var raw in pointer[2..].Split('/')) + { + var segment = raw.Replace("~1", "/").Replace("~0", "~"); + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) + return null; + current = next; + } + return current; + } + + /// Leading slash, no trailing slash. "/alliances/{id}/" -> "/alliances/{id}". + public static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + return "/"; + if (!path.StartsWith('/')) + path = "/" + path; + return path.Length > 1 ? path.TrimEnd('/') : path; + } + + /// Replaces every {param} with {} so paths match regardless of parameter names. + public static string ParamAgnostic(string path) => Regex.Replace(path, "{[^}]+}", "{}"); +} diff --git a/tools/SpecCheck/SpecCheck.csproj b/tools/SpecCheck/SpecCheck.csproj new file mode 100644 index 0000000..d8908e8 --- /dev/null +++ b/tools/SpecCheck/SpecCheck.csproj @@ -0,0 +1,33 @@ + + + + + Exe + net8.0 + latest + enable + enable + false + ESI.NET.Tools.SpecCheck + spec-check + + + + + + + + + + + diff --git a/tools/SpecCheck/Wrapper.cs b/tools/SpecCheck/Wrapper.cs new file mode 100644 index 0000000..0e73b09 --- /dev/null +++ b/tools/SpecCheck/Wrapper.cs @@ -0,0 +1,210 @@ +using System.Reflection; +using ESI.NET; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ESI.NET.Tools.SpecCheck; + +/// One endpoint the wrapper implements, as found in ESI.NET/Logic/*.cs. +public sealed record ImplementedEndpoint( + string Class, // "AllianceLogic" + string Method, // "Information" + string HttpMethod, // "GET" + string Path, // "/alliances/{alliance_id}" - normalized + bool Authenticated, + string ModelTypeText, // "Alliance" / "List" - the Execute arg as written + Type? ModelType) // T resolved by reflection (for Tier 2); null if the join failed +{ + public string Key => $"{HttpMethod} {Path}"; + public string LooseKey => $"{HttpMethod} {Spec.ParamAgnostic(Path)}"; +} + +/// +/// Extracts the implemented endpoint set from source. The HTTP method, route and +/// security live inside each Logic method body (invisible to reflection), so those +/// come from a Roslyn syntax walk; the response model T comes from reflecting +/// the built ESI.NET assembly and is joined back on (class, method). +/// +public sealed class Wrapper +{ + public IReadOnlyList Endpoints { get; } + public IReadOnlyList Warnings { get; } + + private Wrapper(List endpoints, List warnings) + { + Endpoints = endpoints; + Warnings = warnings; + } + + public static Wrapper Scan(string logicDirectory, Assembly esiAssembly) + { + var warnings = new List(); + var models = ReflectResponseModels(esiAssembly); + var endpoints = new List(); + var seen = new HashSet<(string, string, string, string)>(); + + foreach (var file in Directory.EnumerateFiles(logicDirectory, "*.cs").OrderBy(f => f)) + { + var name = Path.GetFileName(file); + var root = CSharpSyntaxTree.ParseText(File.ReadAllText(file), path: file).GetCompilationUnitRoot(); + + foreach (var call in root.DescendantNodes().OfType()) + { + if (!IsExecuteCall(call, out var typeArgs)) + continue; + + var method = call.Ancestors().OfType().FirstOrDefault(); + var type = call.Ancestors().OfType().FirstOrDefault(); + if (method is null || type is null) + continue; + + var className = type.Identifier.Text; + var methodName = method.Identifier.Text; + models.TryGetValue((className, methodName), out var modelType); + if (modelType is null) + warnings.Add($"{name}: {className}.{methodName} - no matching EsiResponse method on the built assembly"); + + // Routes are string literals in almost every Logic method. SearchLogic.Query + // picks its endpoint from a local at runtime, so fall back to reading every + // "/..."-shaped literal in the method body. + List<(string HttpMethod, string Endpoint, bool Authenticated)> found; + if (TryReadArguments(call, out var security, out var httpMethod, out var endpoint)) + { + found = new() { (httpMethod, endpoint, security == "Authenticated") }; + } + else + { + found = FallbackFromBody(method); + if (found.Count == 0) + { + warnings.Add($"{name}: {className}.{methodName} - could not parse Execute(...) arguments"); + continue; + } + warnings.Add($"{name}: {className}.{methodName} - route(s) read heuristically from the method body " + + $"({string.Join(", ", found.Select(f => f.Endpoint))}); security may be approximate"); + } + + foreach (var (m, ep, auth) in found) + { + var path = Spec.NormalizePath(ep); + if (seen.Add((className, methodName, m, path))) + endpoints.Add(new ImplementedEndpoint(className, methodName, m, path, auth, typeArgs, modelType)); + } + } + } + + return new Wrapper(endpoints, warnings); + } + + /// (class, method) -> T for every Logic method returning Task<EsiResponse<T>>. + private static Dictionary<(string, string), Type> ReflectResponseModels(Assembly assembly) + { + var map = new Dictionary<(string, string), Type>(); + + Type?[] types; + try { types = assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types; } + + foreach (var type in types) + { + if (type is null || !type.IsClass || type.Namespace != "ESI.NET.Logic" || !type.Name.EndsWith("Logic")) + continue; + + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + var returnType = method.ReturnType; + if (!returnType.IsGenericType || returnType.GetGenericTypeDefinition() != typeof(Task<>)) + continue; + + var inner = returnType.GetGenericArguments()[0]; + if (!inner.IsGenericType || inner.GetGenericTypeDefinition() != typeof(EsiResponse<>)) + continue; + + map[(type.Name, method.Name)] = inner.GetGenericArguments()[0]; + } + } + return map; + } + + /// + /// Last resort when the Execute call takes its route/security from locals: every + /// string literal in the method that starts with / is treated as an endpoint, + /// the (single) HttpMethod.x in the method as the verb, and the presence of + /// RequestSecurity.Authenticated anywhere in the method as "authenticated". + /// + private static List<(string HttpMethod, string Endpoint, bool Authenticated)> FallbackFromBody(MethodDeclarationSyntax method) + { + var verb = method.DescendantNodes().OfType() + .Where(m => m.Expression.ToString().Split('.').Last() == "HttpMethod") + .Select(m => m.Name.Identifier.Text.ToUpperInvariant()) + .FirstOrDefault() ?? "GET"; + + var authenticated = method.DescendantNodes().OfType() + .Any(m => m.Expression.ToString().Split('.').Last() == "RequestSecurity" + && m.Name.Identifier.Text == "Authenticated"); + + return method.DescendantNodes().OfType() + .Where(l => l.IsKind(SyntaxKind.StringLiteralExpression) && l.Token.ValueText.StartsWith('/')) + .Select(l => l.Token.ValueText) + .Distinct() + .Select(ep => (verb, ep, authenticated)) + .ToList(); + } + + private static bool IsExecuteCall(InvocationExpressionSyntax call, out string typeArgs) + { + typeArgs = ""; + var generic = call.Expression switch + { + GenericNameSyntax g => g, // Execute(...) via using static + MemberAccessExpressionSyntax { Name: GenericNameSyntax g } => g, // EsiRequest.Execute(...) + _ => null, + }; + if (generic is null || generic.Identifier.Text != "Execute") + return false; + + typeArgs = string.Join(", ", generic.TypeArgumentList.Arguments.Select(a => a.ToString())); + return true; + } + + /// + /// Positional args are (client, config, RequestSecurity.x, HttpMethod.y, "endpoint", ...). + /// Falls back to scanning every argument if that exact shape is not present. + /// + private static bool TryReadArguments(InvocationExpressionSyntax call, out string security, out string httpMethod, out string endpoint) + { + security = httpMethod = endpoint = ""; + var positional = call.ArgumentList.Arguments.Where(a => a.NameColon is null).ToList(); + + if (positional.Count >= 5 + && positional[2].Expression is MemberAccessExpressionSyntax s && Receiver(s) == "RequestSecurity" + && positional[3].Expression is MemberAccessExpressionSyntax h && Receiver(h) == "HttpMethod" + && positional[4].Expression is LiteralExpressionSyntax lit && lit.IsKind(SyntaxKind.StringLiteralExpression)) + { + security = s.Name.Identifier.Text; + httpMethod = h.Name.Identifier.Text.ToUpperInvariant(); + endpoint = lit.Token.ValueText; + return true; + } + + foreach (var arg in call.ArgumentList.Arguments) + { + if (arg.Expression is MemberAccessExpressionSyntax member) + { + var receiver = Receiver(member); + if (receiver == "RequestSecurity") security = member.Name.Identifier.Text; + else if (receiver == "HttpMethod") httpMethod = member.Name.Identifier.Text.ToUpperInvariant(); + } + else if (endpoint.Length == 0 + && arg.Expression is LiteralExpressionSyntax l + && l.IsKind(SyntaxKind.StringLiteralExpression)) + { + endpoint = l.Token.ValueText; + } + } + return security.Length > 0 && httpMethod.Length > 0 && endpoint.Length > 0; + + static string Receiver(MemberAccessExpressionSyntax m) => m.Expression.ToString().Split('.').Last(); + } +} From 86455bd443063d748d67cec4d070063322872fb2 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 03:11:33 -0400 Subject: [PATCH 17/67] feat(tools): SpecCheck Tier 2 - EsiResponse vs 200 schema drift Flattens each covered endpoint's model (reflection) and its resolved 200 schema into a shared Node tree and walks them together. - schema-shape / schema-type -> error - schema-int-width, missing/extra prop, enum drift -> warning (error under --strict) - enum-unmodelled, date-as-string, oneOf/anyOf/dictionary -> info First run over 175 endpoints: 6 errors (2 array/object shape mismatches - CorporationLogic.Standings, UniverseLogic.AsteroidBelt; 4 number-typed-as int/string), plus warnings: 492 ids typed int32 vs the spec's int64, 7 enum typos ("loan ", "cleamup", "vulnerable "), 12 unbound (often required) properties, 89 stale properties. --- tools/SpecCheck/Node.cs | 37 ++++ tools/SpecCheck/Program.cs | 16 +- tools/SpecCheck/README.md | 24 ++- tools/SpecCheck/Report.cs | 86 ++++++++- tools/SpecCheck/SchemaCheck.cs | 340 +++++++++++++++++++++++++++++++++ 5 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 tools/SpecCheck/Node.cs create mode 100644 tools/SpecCheck/SchemaCheck.cs diff --git a/tools/SpecCheck/Node.cs b/tools/SpecCheck/Node.cs new file mode 100644 index 0000000..160afca --- /dev/null +++ b/tools/SpecCheck/Node.cs @@ -0,0 +1,37 @@ +namespace ESI.NET.Tools.SpecCheck; + +public enum NodeKind { Object, Array, Scalar, Unknown } + +/// +/// A structural view of a type, built from either an OpenAPI schema +/// () or a CLR type +/// (). The two are then walked together by +/// . +/// +public sealed class Node +{ + public NodeKind Kind { get; private init; } + + // Object + public Dictionary Members { get; } = new(StringComparer.Ordinal); + + // Array + public Node? Items { get; private set; } + + // Scalar + public string? JsonType { get; private set; } // integer | number | string | boolean + public string? Format { get; private set; } // int32 | int64 | date-time | float | double + public IReadOnlyList? EnumValues { get; set; } + public bool Nullable { get; set; } + + // Unknown + public string? Note { get; private set; } + + public static Node Object() => new() { Kind = NodeKind.Object }; + public static Node Array(Node items) => new() { Kind = NodeKind.Array, Items = items }; + public static Node Scalar(string jsonType, string? format = null) => + new() { Kind = NodeKind.Scalar, JsonType = jsonType, Format = format }; + public static Node Unknown(string note) => new() { Kind = NodeKind.Unknown, Note = note }; +} + +public sealed record NodeMember(Node Node, bool Required); diff --git a/tools/SpecCheck/Program.cs b/tools/SpecCheck/Program.cs index 724b465..a9e7234 100644 --- a/tools/SpecCheck/Program.cs +++ b/tools/SpecCheck/Program.cs @@ -1,15 +1,17 @@ using ESI.NET; using ESI.NET.Tools.SpecCheck; -// spec-check [--spec ] [--source ] [--strict] +// spec-check [--spec ] [--source ] [--strict] [--no-schema] // -// --spec OpenAPI document. Default: the live ESI meta spec. -// --source ESI.NET/Logic directory. Default: auto-detected from the repo root. -// --strict Warnings fail the build too (missing endpoints, parameter-name drift). +// --spec OpenAPI document. Default: the live ESI meta spec. +// --source ESI.NET/Logic directory. Default: auto-detected from the repo root. +// --strict Warnings fail the build too. +// --no-schema Tier 1 (coverage) only; skip Tier 2 (schema drift). var specSource = "https://esi.evetech.net/meta/openapi.json"; string? sourceDir = null; var strict = false; +var runSchema = true; for (var i = 0; i < args.Length; i++) { @@ -18,8 +20,9 @@ case "--spec" when i + 1 < args.Length: specSource = args[++i]; break; case "--source" when i + 1 < args.Length: sourceDir = args[++i]; break; case "--strict": strict = true; break; + case "--no-schema": runSchema = false; break; case "-h" or "--help": - Console.WriteLine("usage: spec-check [--spec ] [--source ] [--strict]"); + Console.WriteLine("usage: spec-check [--spec ] [--source ] [--strict] [--no-schema]"); return 0; default: Console.Error.WriteLine($"unknown argument: {args[i]}"); @@ -50,7 +53,8 @@ var wrapper = Wrapper.Scan(sourceDir, typeof(EsiClient).Assembly); var coverage = CoverageCheck.Run(spec, wrapper, strict); -return Report.Render(spec, wrapper, coverage, strict); +var schema = runSchema ? SchemaCheck.Run(spec, wrapper, strict) : null; +return Report.Render(spec, wrapper, coverage, schema, strict); // Walk up from the working directory (and from the binary) until a folder holds // ESI.NET.sln, then return its ESI.NET/Logic. diff --git a/tools/SpecCheck/README.md b/tools/SpecCheck/README.md index 94bbfe2..f054a92 100644 --- a/tools/SpecCheck/README.md +++ b/tools/SpecCheck/README.md @@ -39,10 +39,23 @@ Set-difference on `(METHOD, path)` after normalising trailing slashes. ## Tier 2 — schema -_(next commit)_ For every covered endpoint, walk the `EsiResponse` model with -reflection and compare it to the resolved 200 schema: properties present in the -spec but not the model, properties in the model the spec no longer has, and type -mismatches (`int` vs `int64`, scalar vs array, enum value drift). +For every covered endpoint, `SchemaCheck` flattens the `EsiResponse` model +(reflection) and the resolved 200 schema into a common `Node` tree and walks them +together. + +| Finding | Severity | | +| --- | --- | --- | +| `schema-shape` | error | model is an object where the spec is an array (or vice versa) | +| `schema-type` | error | `string`↔`number`, `integer` where the spec is `number`, … | +| `schema-int-width` | warning¹ | model `int` where the spec is `int64` (ESI types every id as int64) | +| `schema-missing-property` | warning¹ | spec has a property the model does not bind | +| `schema-extra-property` | warning¹ | model has a property the spec no longer returns | +| `schema-enum-drift` | warning¹ | modelled enum values differ from the spec's | +| `schema-enum-unmodelled` | info | spec property is an enum, model types it `string` | +| `schema-date-as-string` | info | spec `date-time`, model `string` | +| `schema-unverified` | info | `oneOf`/`anyOf`, dictionary, or a recursive `$ref` | + +¹ error under `--strict`. ## Files @@ -51,7 +64,8 @@ mismatches (`int` vs `int64`, scalar vs array, enum value drift). | `Spec.cs` | loads the OpenAPI document, enumerates operations, resolves `$ref` | | `Wrapper.cs` | Roslyn scan of `Logic/*.cs` + reflection join → implemented endpoints | | `CoverageCheck.cs` | Tier 1 diff | -| `SchemaCheck.cs` | Tier 2 diff _(next commit)_ | +| `Node.cs` | shared structural view of a type (object / array / scalar / unknown) | +| `SchemaCheck.cs` | Tier 2 — schema→`Node`, CLR→`Node`, and the walk that compares them | | `Finding.cs` | severity + message | | `Report.cs` | stdout + `$GITHUB_STEP_SUMMARY`, exit code | | `Program.cs` | argument parsing, orchestration | diff --git a/tools/SpecCheck/Report.cs b/tools/SpecCheck/Report.cs index cff0153..af6c424 100644 --- a/tools/SpecCheck/Report.cs +++ b/tools/SpecCheck/Report.cs @@ -10,32 +10,104 @@ namespace ESI.NET.Tools.SpecCheck; /// public static class Report { - public static int Render(Spec spec, Wrapper wrapper, CoverageResult coverage, bool strict) + public static int Render(Spec spec, Wrapper wrapper, CoverageResult coverage, SchemaResult? schema, bool strict) { - var findings = coverage.Findings; - var text = BuildText(spec, wrapper, coverage); - Console.WriteLine(text); + var findings = coverage.Findings.Concat(schema?.Findings ?? Enumerable.Empty()).ToList(); + + Console.WriteLine(BuildText(spec, wrapper, coverage)); + if (schema is not null) + Console.WriteLine(BuildSchemaText(schema)); var summaryPath = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY"); if (!string.IsNullOrEmpty(summaryPath)) { - try { File.AppendAllText(summaryPath, BuildMarkdown(spec, coverage)); } + try + { + File.AppendAllText(summaryPath, BuildMarkdown(spec, coverage)); + if (schema is not null) + File.AppendAllText(summaryPath, BuildSchemaMarkdown(schema)); + } catch (Exception ex) { Console.Error.WriteLine($"(could not write GITHUB_STEP_SUMMARY: {ex.Message})"); } } var errors = findings.Count(f => f.Severity == Severity.Error); var warnings = findings.Count(f => f.Severity == Severity.Warning); + var infos = findings.Count(f => f.Severity == Severity.Info); Console.WriteLine(); - Console.WriteLine($"{errors} error(s), {warnings} warning(s). " + Console.WriteLine($"{errors} error(s), {warnings} warning(s), {infos} info. " + $"Coverage {coverage.TotalCovered}/{coverage.TotalOperations} " - + $"({Percent(coverage.TotalCovered, coverage.TotalOperations)})."); + + $"({Percent(coverage.TotalCovered, coverage.TotalOperations)})" + + (schema is not null ? $"; schema-checked {schema.Checked} endpoint(s)." : ".")); var failed = errors > 0 || (strict && warnings > 0); Console.WriteLine(failed ? "RESULT: drift detected." : "RESULT: clean."); return failed ? 1 : 0; } + private static string BuildSchemaText(SchemaResult schema) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("SCHEMA DRIFT (Tier 2)"); + sb.AppendLine($" checked {schema.Checked} endpoint(s); " + + $"skipped {schema.SkippedNoSchema} (no JSON schema), {schema.SkippedNoModel} (model type unresolved)"); + + var byCategory = schema.Findings + .GroupBy(f => (f.Severity, f.Category)) + .OrderByDescending(g => g.Key.Severity) + .ThenByDescending(g => g.Count()); + foreach (var group in byCategory) + sb.AppendLine($" {group.Key.Severity,-7} {group.Key.Category,-26} {group.Count()}"); + + foreach (var endpoint in schema.Findings.GroupBy(f => f.Where).OrderBy(g => g.Key)) + { + sb.AppendLine(); + sb.AppendLine($" {endpoint.Key}"); + foreach (var f in endpoint.OrderByDescending(x => x.Severity)) + sb.AppendLine($" {f.Severity.ToString().ToUpperInvariant(),-7} {f.Message}"); + } + + return sb.ToString().TrimEnd(); + } + + private static string BuildSchemaMarkdown(SchemaResult schema) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("## Schema drift (Tier 2)"); + sb.AppendLine(); + sb.AppendLine($"Checked **{schema.Checked}** endpoints. " + + $"Skipped {schema.SkippedNoSchema} (no schema) + {schema.SkippedNoModel} (unresolved model)."); + sb.AppendLine(); + + if (schema.Findings.Count == 0) + { + sb.AppendLine("No schema drift. ✅"); + return sb.ToString(); + } + + sb.AppendLine("| Severity | Category | Count |"); + sb.AppendLine("| --- | --- | --- |"); + foreach (var group in schema.Findings + .GroupBy(f => (f.Severity, f.Category)) + .OrderByDescending(g => g.Key.Severity).ThenByDescending(g => g.Count())) + sb.AppendLine($"| {group.Key.Severity} | {group.Key.Category} | {group.Count()} |"); + sb.AppendLine(); + + sb.AppendLine($"
{schema.Findings.Count} finding(s) by endpoint"); + sb.AppendLine(); + foreach (var endpoint in schema.Findings.GroupBy(f => f.Where).OrderBy(g => g.Key)) + { + sb.AppendLine($"**`{endpoint.Key}`**"); + foreach (var f in endpoint.OrderByDescending(x => x.Severity)) + sb.AppendLine($"- {f.Severity}: {f.Message}"); + sb.AppendLine(); + } + sb.AppendLine("
"); + return sb.ToString(); + } + private static string BuildText(Spec spec, Wrapper wrapper, CoverageResult coverage) { var sb = new StringBuilder(); diff --git a/tools/SpecCheck/SchemaCheck.cs b/tools/SpecCheck/SchemaCheck.cs new file mode 100644 index 0000000..7c46920 --- /dev/null +++ b/tools/SpecCheck/SchemaCheck.cs @@ -0,0 +1,340 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using Newtonsoft.Json; + +namespace ESI.NET.Tools.SpecCheck; + +public sealed class SchemaResult +{ + public required IReadOnlyList Findings { get; init; } + public required int Checked { get; init; } + public required int SkippedNoSchema { get; init; } + public required int SkippedNoModel { get; init; } +} + +/// +/// Tier 2. For every covered endpoint, flattens the EsiResponse<T> model and +/// the resolved 200 schema into trees and walks them together. +/// +public static class SchemaCheck +{ + private const int MaxDepth = 12; + + public static SchemaResult Run(Spec spec, Wrapper wrapper, bool strict) + { + var specByKey = spec.Operations + .GroupBy(o => o.Key) + .ToDictionary(g => g.Key, g => g.First()); + + var findings = new List(); + int checkedCount = 0, noSchema = 0, noModel = 0; + + foreach (var endpoint in wrapper.Endpoints) + { + if (!specByKey.TryGetValue(endpoint.Key, out var op)) + continue; // orphaned - Tier 1's problem + + if (op.ResponseSchema is not { } schemaElement) + { + noSchema++; + continue; + } + if (endpoint.ModelType is not { } clrType) + { + noModel++; + continue; + } + + var model = FromClr(clrType, 0, new HashSet()); + var schema = FromSchema(schemaElement, spec, 0, new HashSet()); + Compare(model, schema, "Data", endpoint.Key, strict, findings); + checkedCount++; + } + + return new SchemaResult + { + Findings = findings, + Checked = checkedCount, + SkippedNoSchema = noSchema, + SkippedNoModel = noModel, + }; + } + + // ---- CLR type -> Node --------------------------------------------------- + + public static Node FromClr(Type type, int depth, HashSet seen) + { + type = Nullable.GetUnderlyingType(type) is { } underlying ? underlying : type; + + if (TryScalar(type, out var scalar)) // also handles enums + return scalar; + + if (depth >= MaxDepth) + return Node.Unknown("max depth"); + + if (typeof(IDictionary).IsAssignableFrom(type) + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + return Node.Unknown("dictionary / additionalProperties"); + + if (ElementType(type) is { } element) + return Node.Array(FromClr(element, depth + 1, seen)); + + if (type.IsClass && type != typeof(object)) + { + if (!seen.Add(type)) + return Node.Unknown("recursive type " + type.Name); + + var node = Node.Object(); + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + continue; + var name = property.GetCustomAttribute()?.PropertyName ?? property.Name; + node.Members[name] = new NodeMember(FromClr(property.PropertyType, depth + 1, seen), Required: false); + } + seen.Remove(type); + return node; + } + + return Node.Unknown(type.Name); + } + + private static bool TryScalar(Type type, out Node node) + { + if (type.IsEnum) + { + node = Node.Scalar("string"); + node.EnumValues = type.GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(f => f.GetCustomAttribute()?.Value ?? f.Name) + .ToArray(); + return true; + } + + node = Type.GetTypeCode(type) switch + { + TypeCode.Byte or TypeCode.SByte or TypeCode.Int16 or TypeCode.UInt16 + or TypeCode.Int32 or TypeCode.UInt32 => Node.Scalar("integer", "int32"), + TypeCode.Int64 or TypeCode.UInt64 => Node.Scalar("integer", "int64"), + TypeCode.Single => Node.Scalar("number", "float"), + TypeCode.Double => Node.Scalar("number", "double"), + TypeCode.Decimal => Node.Scalar("number"), + TypeCode.Boolean => Node.Scalar("boolean"), + TypeCode.String or TypeCode.Char => Node.Scalar("string"), + TypeCode.DateTime => Node.Scalar("string", "date-time"), + _ when type == typeof(DateTimeOffset) => Node.Scalar("string", "date-time"), + _ when type == typeof(Guid) || type == typeof(TimeSpan) => Node.Scalar("string"), + _ => null!, + }; + return node is not null; + } + + private static Type? ElementType(Type type) + { + if (type == typeof(string)) + return null; + if (type.IsArray) + return type.GetElementType(); + var enumerable = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + return enumerable?.GetGenericArguments()[0]; + } + + // ---- OpenAPI schema -> Node ------------------------------------------ + + public static Node FromSchema(JsonElement element, Spec spec, int depth, HashSet seenRefs) + { + if (depth >= MaxDepth) + return Node.Unknown("max depth"); + + string? refName = element.ValueKind == JsonValueKind.Object + && element.TryGetProperty("$ref", out var r) + ? r.GetString() + : null; + + if (refName is not null && !seenRefs.Add(refName)) + return Node.Unknown("recursive $ref"); + + try + { + element = spec.Resolve(element); + if (element.ValueKind != JsonValueKind.Object) + return Node.Unknown("non-object schema"); + + // allOf: shallow-merge object members + if (element.TryGetProperty("allOf", out var allOf) && allOf.ValueKind == JsonValueKind.Array) + { + var merged = Node.Object(); + foreach (var part in allOf.EnumerateArray()) + { + var partNode = FromSchema(part, spec, depth + 1, seenRefs); + if (partNode.Kind == NodeKind.Object) + foreach (var (k, v) in partNode.Members) + merged.Members[k] = v; + } + return merged.Members.Count > 0 ? merged : Node.Unknown("allOf"); + } + + if (element.TryGetProperty("oneOf", out _) || element.TryGetProperty("anyOf", out _)) + return Node.Unknown("oneOf / anyOf union"); + + var (jsonType, nullable) = ReadType(element); + + if (jsonType == "array" || element.TryGetProperty("items", out _)) + { + var items = element.TryGetProperty("items", out var it) + ? FromSchema(it, spec, depth + 1, seenRefs) + : Node.Unknown("array without items"); + return Node.Array(items); + } + + if (jsonType == "object" || element.TryGetProperty("properties", out _)) + { + var node = Node.Object(); + var required = element.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array + ? req.EnumerateArray().Select(x => x.GetString()).Where(x => x is not null).ToHashSet()! + : new HashSet(); + + if (element.TryGetProperty("properties", out var props)) + foreach (var p in props.EnumerateObject()) + node.Members[p.Name] = new NodeMember( + FromSchema(p.Value, spec, depth + 1, seenRefs), + required.Contains(p.Name)); + return node; + } + + if (jsonType is null) + return Node.Unknown("no type"); + + var scalar = Node.Scalar(jsonType, element.TryGetProperty("format", out var fmt) ? fmt.GetString() : null); + scalar.Nullable = nullable; + if (element.TryGetProperty("enum", out var en) && en.ValueKind == JsonValueKind.Array) + scalar.EnumValues = en.EnumerateArray().Select(x => x.ToString()).ToArray(); + return scalar; + } + finally + { + if (refName is not null) + seenRefs.Remove(refName); + } + } + + /// OpenAPI 3.1 type is a string or an array that may include "null". + private static (string? Type, bool Nullable) ReadType(JsonElement element) + { + if (!element.TryGetProperty("type", out var t)) + return (null, false); + + if (t.ValueKind == JsonValueKind.String) + return (t.GetString(), false); + + if (t.ValueKind == JsonValueKind.Array) + { + var values = t.EnumerateArray().Select(x => x.GetString()).ToList(); + var nullable = values.Remove("null"); + return (values.FirstOrDefault(), nullable); + } + return (null, false); + } + + // ---- compare ---------------------------------------------------------- + + public static void Compare(Node model, Node spec, string crumb, string endpoint, bool strict, List findings) + { + void Add(Severity sev, string category, string message) => + findings.Add(new Finding(sev, category, endpoint, $"{crumb}: {message}")); + + var warn = strict ? Severity.Error : Severity.Warning; + + if (model.Kind == NodeKind.Unknown || spec.Kind == NodeKind.Unknown) + { + Add(Severity.Info, "schema-unverified", + $"not compared ({model.Note ?? spec.Note})"); + return; + } + + if (model.Kind != spec.Kind) + { + Add(Severity.Error, "schema-shape", + $"model is {model.Kind.ToString().ToLowerInvariant()}, spec is {spec.Kind.ToString().ToLowerInvariant()}"); + return; + } + + switch (model.Kind) + { + case NodeKind.Object: + foreach (var (name, member) in spec.Members) + { + if (!model.Members.ContainsKey(name)) + Add(warn, "schema-missing-property", + $"spec has \"{name}\"{(member.Required ? " (required)" : "")}, model does not"); + } + foreach (var name in model.Members.Keys) + { + if (!spec.Members.ContainsKey(name)) + Add(warn, "schema-extra-property", $"model has \"{name}\", spec does not"); + } + foreach (var (name, member) in spec.Members) + { + if (model.Members.TryGetValue(name, out var modelMember)) + Compare(modelMember.Node, member.Node, $"{crumb}.{name}", endpoint, strict, findings); + } + break; + + case NodeKind.Array: + if (model.Items is not null && spec.Items is not null) + Compare(model.Items, spec.Items, $"{crumb}[]", endpoint, strict, findings); + break; + + case NodeKind.Scalar: + CompareScalar(model, spec, Add, warn); + break; + } + } + + private static void CompareScalar(Node model, Node spec, Action add, Severity warn) + { + var m = model.JsonType; + var s = spec.JsonType; + if (s is null || m is null) + return; + + if (m == s) + { + // ESI types every id as int64; ESI.NET overwhelmingly uses int. A real latent + // overflow (structure / item / journal ids already exceed int32) but systemic + // and long-shipping, so it is a warning unless --strict. + if (m == "integer" && model.Format == "int32" && spec.Format == "int64") + add(warn, "schema-int-width", "model is int (int32), spec is int64 - overflow risk"); + + if (m == "string") + { + if (spec.EnumValues is { Count: > 0 } specEnum) + { + if (model.EnumValues is null) + add(Severity.Info, "schema-enum-unmodelled", $"spec is an enum ({specEnum.Count} values), model is a plain string"); + else + { + var extra = model.EnumValues.Except(specEnum).ToList(); + var gone = specEnum.Except(model.EnumValues).ToList(); + if (extra.Count > 0 || gone.Count > 0) + add(warn, "schema-enum-drift", + $"enum differs - model-only [{string.Join(", ", extra)}], spec-only [{string.Join(", ", gone)}]"); + } + } + else if (spec.Format == "date-time" && model.Format != "date-time") + add(Severity.Info, "schema-date-as-string", "spec is date-time, model is a plain string"); + } + return; + } + + // categories differ + if (m == "number" && s == "integer") + add(Severity.Info, "schema-number-widening", "model is floating point, spec is integer"); + else if (m == "integer" && s == "number") + add(Severity.Error, "schema-type", "model is integer, spec is number - non-integral values will throw"); + else + add(Severity.Error, "schema-type", $"model is {m}, spec is {s}"); + } +} From c81f3e3388637f39ce0568a3eab248a27ef7322c Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 03:12:36 -0400 Subject: [PATCH 18/67] docs: reformat README (prettier-style markdown normalization) Bullet and emphasis style, blank lines around headings and fenced blocks. Drops one parenthetical from the Discord line. --- README.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fccc584..a18d7b1 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,29 @@ **ESI.NET** is a .NET wrapper for the [Eve Online ESI API](https://esi.evetech.net/). This wrapper simplifies the process of integrating ESI into your .NET application. ### Resources -* [Discord - E.N](https://discord.gg/SvdN39f) - This channel is where you can contact me (Psianna Archeia) for questions and where automated webhook notifications will be pushed for github and when builds are completed. (If you have Discord, this is the preferred way to contact me concerning ESI.NET. I **DO NOT** monitor Slack anymore for ESI.NET issues.) -* [Tweetfleet - #esi](https://tweetfleet.slack.com/messages/C30KX8UUX/) - This is the official slack channel to speak with CCP devs (and developers) concerning ESI. -* [ESI Application Keys](https://developers.eveonline.com/) -* [ESI OpenAPI Definition](https://esi.evetech.net/meta/openapi.json) -* [ESI-Docs](https://docs.esi.evetech.net/) ([source](https://github.com/esi/esi-docs)) - This is the best documentation concerning ESI and the SSO process. + +- [Discord - E.N](https://discord.gg/SvdN39f) - This channel is where you can contact me (Psianna Archeia) for questions and where automated webhook notifications will be pushed for github and when builds are completed. +- [Tweetfleet - #esi](https://tweetfleet.slack.com/messages/C30KX8UUX/) - This is the official slack channel to speak with CCP devs (and developers) concerning ESI. +- [ESI Application Keys](https://developers.eveonline.com/) +- [ESI OpenAPI Definition](https://esi.evetech.net/meta/openapi.json) +- [ESI-Docs](https://docs.esi.evetech.net/) ([source](https://github.com/esi/esi-docs)) - This is the best documentation concerning ESI and the SSO process. It is extremely important to not solely rely on ESI.NET. You may need to refer to the official specifications to understand what data is expected to be provided. For example, in some instances, ESI.NET will ask for specific values in the endpoint method and construct the JSON object that needs to be sent in the POST request body because it is a simple object that requires a few values. Some of the more complex objects will need to be constructed with anonymous objects by the developer and this can be determined when the endpoint method requires an `object` instead of an `int` or a `string`. Refer to the official documentation and construct the anonymous object to reflect what is expected as Json.NET will be able to convert that anonymous object into the appropriate JSON data. ## ESI.NET on NuGet + https://www.nuget.org/packages/ESI.NET `dotnet add package ESI.NET ` ## Client Instantiation + ESI.NET is Dependency Injection compatible. There are a few parts required to set this up properly in a .NET Standard/Core application: ### .NET Standard (Dependency Injection) + In your appsettings.json, add the following object and fill it in appropriately: + ```json "EsiConfig": { "EsiUrl": "https://esi.evetech.net/", @@ -33,10 +38,12 @@ In your appsettings.json, add the following object and fill it in appropriately: "UserAgent": "" } ``` -*For your protection (and mine), you are required to supply a user_agent value. This can be your character name and/or project name. CCP will be more likely to contact you than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy. Without this property populated, the wrapper will not work.* + +_For your protection (and mine), you are required to supply a user_agent value. This can be your character name and/or project name. CCP will be more likely to contact you than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy. Without this property populated, the wrapper will not work._ Register the client. `AddEsi` binds the config, registers `IEsiClient` as a typed `HttpClient` (via `IHttpClientFactory`), and returns an `IHttpClientBuilder`: + ```cs services.AddEsi(Configuration.GetSection("EsiConfig")); @@ -46,18 +53,21 @@ services.AddEsi(esi => { esi.EsiUrl = "https://esi.evetech.net/"; esi.DataSource Opt in to Polly resilience by adding the `Microsoft.Extensions.Http.Resilience` package and chaining off the returned builder: + ```cs services.AddEsi(Configuration.GetSection("EsiConfig")) .AddStandardResilienceHandler(); ``` Then take `IEsiClient` in your constructor: + ```cs private readonly IEsiClient _client; public ApiTestController(IEsiClient client) { _client = client; } ``` ### .NET Framework + If you are using a .NET Standard-compatible .NET Framework application, you can instantiate the client in this manner: ```cs @@ -73,11 +83,13 @@ IOptions config = Options.Create(new EsiConfig() EsiClient client = new EsiClient(config); ``` -*For your protection (and mine), you are required to supply a user_agent value. This can be your character name and/or project name. CCP will be more likely to contact you than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy. Without this property populated, the wrapper will not work.* + +_For your protection (and mine), you are required to supply a user_agent value. This can be your character name and/or project name. CCP will be more likely to contact you than just cut off access to ESI if you provide something that can identify you within the New Eden galaxy. Without this property populated, the wrapper will not work._ NOTE: You will need to import `Microsoft.Extensions.Options` to accomplish the above. ### Public endpoint + ```cs EsiResponse> response = await _client.Universe.Names(new List { @@ -86,8 +98,10 @@ EsiResponse> response = await _client.Universe.Names(new List ``` ### Per-call options + Every endpoint method takes a trailing `EsiCallOptions`. It is **optional** on public endpoints and **required** on authenticated ones: + ```cs public sealed class EsiCallOptions { @@ -97,6 +111,7 @@ public sealed class EsiCallOptions public int? Page { get; set; } // paginated endpoints } ``` + ```cs var page2 = await _client.Universe.Groups(new() { Page = 2, CancellationToken = ct }); @@ -107,13 +122,17 @@ if (fresh.StatusCode == HttpStatusCode.NotModified) { /* use your cache */ } ## SSO Example ### SSO Login URL generator + ESI.NET has a helper method to generate the URL required to authenticate a character or authorize roles (by providing a `List` of scopes) for the Eve Online SSO. You should also provide a value for "state" that you verify when it is returned (it will be included in the callback). + ```cs var url = _client.SSO.CreateAuthenticationUrl(); ``` ### Initial SSO Token Request + `Verify` throws `InvalidOperationException` if the token fails validation. + ```cs SsoToken token = await _client.SSO.GetToken(GrantType.AuthorizationCode, code); AuthorizedCharacterData authChar = await _client.SSO.Verify(token); @@ -121,17 +140,23 @@ AuthorizedCharacterData authChar = await _client.SSO.Verify(token); // On every re-login, compare the fresh CharacterOwnerHash to your stored one — a // mismatch means the character was transferred and the old data must be discarded. ``` + ### Refresh Token Request + ```cs SsoToken token = await _client.SSO.GetToken(GrantType.RefreshToken, authChar.RefreshToken); ``` + ### Authenticated request + Pass the stored character on the call: + ```cs var wallet = await _client.Wallet.CharacterWallet(new() { Character = authChar }); ``` ### Transparent token refresh + An authenticated call whose access token is within a minute of expiry is refreshed with its refresh token before the request goes out; `authChar` is updated in place. EVE rotates the refresh token, so you must persist the updated @@ -140,6 +165,7 @@ value. **Once, via DI (recommended).** Implement `IEsiTokenRefreshSink` — normal constructor injection works — and register it; it then covers every authenticated call: + ```cs public class DbTokenSink : IEsiTokenRefreshSink { @@ -155,10 +181,12 @@ public class DbTokenSink : IEsiTokenRefreshSink services.AddScoped(); services.AddEsi(Configuration.GetSection("EsiConfig")); ``` + The handler resolves the sink in a fresh scope each time it fires, so a scoped `DbContext` is safe. **Per call**, for one-offs or non-DI use: + ```cs var wallet = await _client.Wallet.CharacterWallet(new() { From 835b6183520c292bf81da08e2c4c2ace9973f95e Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 03:23:11 -0400 Subject: [PATCH 19/67] ci: add spec-check workflow (weekly + on demand) Runs tools/SpecCheck against the live ESI OpenAPI document. Schedule + workflow_dispatch only - not a PR/push gate; a failed run emails the maintainer as the nudge to address drift. workflow_dispatch takes a `strict` input to fail on warnings too. Currently red: 12 orphaned endpoints + 6 schema shape/type mismatches. --- .github/workflows/spec-check.yml | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/spec-check.yml diff --git a/.github/workflows/spec-check.yml b/.github/workflows/spec-check.yml new file mode 100644 index 0000000..d270b06 --- /dev/null +++ b/.github/workflows/spec-check.yml @@ -0,0 +1,41 @@ +name: Spec check + +# Drift check: the live ESI OpenAPI document vs what ESI.NET/Logic implements +# (coverage) and vs each EsiResponse model (schema). Deliberately NOT a PR or +# push gate - it runs weekly and on demand, and a failed run emails the +# maintainer as the nudge to come back to it. See tools/SpecCheck. +# +# A network blip reaching esi.evetech.net also fails the run; for a weekly job +# that is acceptable noise. +on: + schedule: + - cron: '17 6 * * 1' # Mondays 06:17 UTC + workflow_dispatch: + inputs: + strict: + description: 'Fail on warnings too (missing endpoints, int-vs-int64, enum drift, ...)' + type: boolean + default: false + +concurrency: + group: spec-check-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + spec-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Restore + Release-build (+ test) the solution; SpecCheck reflects the + # built ESI.NET assembly, so it has to be compiled first. + - uses: ./.github/actions/ci-dotnet + + - name: Spec check + shell: bash + run: > + dotnet run --project tools/SpecCheck --configuration Release --no-build + ${{ inputs.strict && '-- --strict' || '' }} From fddf50aa697be894c8b29fcf18ba16880f7da621 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 08:48:46 -0400 Subject: [PATCH 20/67] test(integration): add live ESI integration tests (Tier 3) tests/ESI.NET.IntegrationTests - NOT in ESI.NET.sln, so the normal `dotnet test ESI.NET.sln` path never runs it. Runs only from .github/workflows/integration.yml (weekly + workflow_dispatch, the manual pre-release check). - PublicSmokeTests (collection "live"): ~25 unauthenticated GETs across ~12 tags, asserting 200 + bound Data + no exception. LiveFixture resolves entity ids by name and retries on transport errors / 5xx. - AuthProbeTests (collection "live-auth", [SkippableFact]): refresh-token exchange -> Verify (JWKS) -> a bearer call -> transparent refresh against live SSO. Skips unless ESI_CLIENT_ID + ESI_SECRET_KEY + ESI_REFRESH_TOKEN are set. First run flagged two real wrapper bugs (backlog): EsiResponse's JSON sniff doesn't trim, so endpoints whose body has a trailing newline return null Data; Bloodline.ship_type_id is non-nullable but ESI returns null. --- .github/workflows/integration.yml | 52 +++++++ .../AuthProbeTests.cs | 120 ++++++++++++++++ .../ESI.NET.IntegrationTests.csproj | 31 +++++ tests/ESI.NET.IntegrationTests/LiveFixture.cs | 130 ++++++++++++++++++ .../PublicSmokeTests.cs | 127 +++++++++++++++++ tests/ESI.NET.IntegrationTests/README.md | 45 ++++++ 6 files changed, 505 insertions(+) create mode 100644 .github/workflows/integration.yml create mode 100644 tests/ESI.NET.IntegrationTests/AuthProbeTests.cs create mode 100644 tests/ESI.NET.IntegrationTests/ESI.NET.IntegrationTests.csproj create mode 100644 tests/ESI.NET.IntegrationTests/LiveFixture.cs create mode 100644 tests/ESI.NET.IntegrationTests/PublicSmokeTests.cs create mode 100644 tests/ESI.NET.IntegrationTests/README.md diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..edc5849 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,52 @@ +name: Integration (live) + +# Live tests against the real ESI API. Deliberately NOT a PR or push gate - it +# makes ~25 real calls and needs secrets. Runs weekly and on demand; a failed +# run emails the maintainer. Also the manual pre-release check: dispatch it, +# confirm green, then merge dev -> master. +# +# Public smoke tests always run. The auth probe self-skips unless ALL of +# ESI_CLIENT_ID / ESI_SECRET_KEY / ESI_REFRESH_TOKEN are set as repo secrets +# (mint the refresh token with tools/MintToken). Fork PRs never get secrets and +# this never runs on PRs, so that path does not arise. +on: + schedule: + - cron: '41 6 * * 1' # Mondays 06:41 UTC (after spec-check) + workflow_dispatch: + +concurrency: + group: integration-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + integration: + runs-on: ubuntu-latest + env: + ESI_USER_AGENT: 'ESI.NET integration (github.com/seraphx2/ESI.NET)' + ESI_CLIENT_ID: ${{ secrets.ESI_CLIENT_ID }} + ESI_SECRET_KEY: ${{ secrets.ESI_SECRET_KEY }} + ESI_REFRESH_TOKEN: ${{ secrets.ESI_REFRESH_TOKEN }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Integration tests + shell: bash + run: > + dotnet test tests/ESI.NET.IntegrationTests/ESI.NET.IntegrationTests.csproj + --configuration Release --verbosity normal + --logger 'trx;LogFileName=integration.trx' --results-directory ./trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-trx + path: ./trx/*.trx + if-no-files-found: ignore diff --git a/tests/ESI.NET.IntegrationTests/AuthProbeTests.cs b/tests/ESI.NET.IntegrationTests/AuthProbeTests.cs new file mode 100644 index 0000000..7bec218 --- /dev/null +++ b/tests/ESI.NET.IntegrationTests/AuthProbeTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using ESI.NET; +using ESI.NET.Enumerations; +using ESI.NET.Models.SSO; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ESI.NET.IntegrationTests +{ + /// + /// Exchanges the stored refresh token once, then reuses the result. If the + /// credentials are not configured this does nothing and every probe test skips. + /// + public sealed class AuthFixture : IAsyncLifetime + { + public bool Enabled => LiveConfig.HasAuthConfig; + public IEsiClient Client { get; private set; } + public SsoToken Token { get; private set; } + public AuthorizedCharacterData Character { get; private set; } + + public async Task InitializeAsync() + { + if (!Enabled) + return; + + var services = new ServiceCollection(); + services.AddEsi(c => + { + c.EsiUrl = "https://esi.evetech.net/"; + c.DataSource = LiveConfig.DataSource; + c.UserAgent = LiveConfig.UserAgent; + c.ClientId = LiveConfig.ClientId; + c.SecretKey = LiveConfig.SecretKey; + }); + Client = services.BuildServiceProvider().GetRequiredService(); + + Token = await Client.SSO.GetToken(GrantType.RefreshToken, LiveConfig.RefreshToken); + Character = await Client.SSO.Verify(Token); + + if (Token.RefreshToken != LiveConfig.RefreshToken) + Console.WriteLine("::warning::ESI rotated the refresh token; update the ESI_REFRESH_TOKEN secret (re-run tools/MintToken)."); + } + + public Task DisposeAsync() => Task.CompletedTask; + } + + [CollectionDefinition("live-auth")] + public sealed class LiveAuthCollection : ICollectionFixture { } + + /// + /// The SSO / authenticated path end to end: token exchange, JWKS validation + /// (the risky part of the IdentityModel 6->8 upgrade), a bearer call, and the + /// transparent-refresh handler firing against live SSO. Skips unless + /// ESI_CLIENT_ID + ESI_SECRET_KEY + ESI_REFRESH_TOKEN are set. + /// + /// The stored token needs the esi-wallet.read_character_wallet.v1 scope + /// (tools/MintToken's default). + /// + [Collection("live-auth")] + public sealed class AuthProbeTests + { + private const string SkipReason = "ESI_CLIENT_ID / ESI_SECRET_KEY / ESI_REFRESH_TOKEN not set"; + private readonly AuthFixture _f; + + public AuthProbeTests(AuthFixture fixture) => _f = fixture; + + [SkippableFact] + public void Refresh_token_exchange_returns_an_access_token() + { + Skip.IfNot(_f.Enabled, SkipReason); + Assert.False(string.IsNullOrEmpty(_f.Token.AccessToken)); + Assert.False(string.IsNullOrEmpty(_f.Token.RefreshToken)); + Assert.True(_f.Token.ExpiresIn > 0); + } + + [SkippableFact] + public void Verify_projects_a_real_character() + { + Skip.IfNot(_f.Enabled, SkipReason); + Assert.True(_f.Character.CharacterID > 0); + Assert.False(string.IsNullOrEmpty(_f.Character.CharacterName)); + Assert.False(string.IsNullOrEmpty(_f.Character.CharacterOwnerHash)); + } + + [SkippableFact] + public async Task Authenticated_call_succeeds() + { + Skip.IfNot(_f.Enabled, SkipReason); + var r = await LiveFixture.Call(() => _f.Client.Wallet.CharacterWallet(new EsiCallOptions { Character = _f.Character })); + Assert.Null(r.Exception); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + } + + [SkippableFact] + public async Task Transparent_refresh_fires_on_a_stale_token() + { + Skip.IfNot(_f.Enabled, SkipReason); + + var stale = new AuthorizedCharacterData + { + CharacterID = _f.Character.CharacterID, + CharacterName = _f.Character.CharacterName, + CharacterOwnerHash = _f.Character.CharacterOwnerHash, + Scopes = _f.Character.Scopes, + Token = _f.Character.Token, + RefreshToken = _f.Character.RefreshToken, + ExpiresOn = DateTime.UtcNow.AddMinutes(-5), // force the handler to refresh + }; + var tokenBefore = stale.Token; + + var r = await LiveFixture.Call(() => _f.Client.Wallet.CharacterWallet(new EsiCallOptions { Character = stale })); + + Assert.Null(r.Exception); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + Assert.NotEqual(tokenBefore, stale.Token); // EsiTokenRefreshHandler swapped the access token in place + } + } +} diff --git a/tests/ESI.NET.IntegrationTests/ESI.NET.IntegrationTests.csproj b/tests/ESI.NET.IntegrationTests/ESI.NET.IntegrationTests.csproj new file mode 100644 index 0000000..32879d5 --- /dev/null +++ b/tests/ESI.NET.IntegrationTests/ESI.NET.IntegrationTests.csproj @@ -0,0 +1,31 @@ + + + + + net8.0 + false + latest + disable + + + + + + + + + + + + + + + diff --git a/tests/ESI.NET.IntegrationTests/LiveFixture.cs b/tests/ESI.NET.IntegrationTests/LiveFixture.cs new file mode 100644 index 0000000..48fe857 --- /dev/null +++ b/tests/ESI.NET.IntegrationTests/LiveFixture.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using ESI.NET; +using ESI.NET.Enumerations; +using ESI.NET.Models.Universe; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ESI.NET.IntegrationTests +{ + /// Environment-sourced configuration for the live run. + internal static class LiveConfig + { + public static string UserAgent => + Env("ESI_USER_AGENT") ?? "ESI.NET integration tests (github.com/seraphx2/ESI.NET)"; + public static string ClientId => Env("ESI_CLIENT_ID"); + public static string SecretKey => Env("ESI_SECRET_KEY"); + public static string RefreshToken => Env("ESI_REFRESH_TOKEN"); + + public static DataSource DataSource => + Enum.TryParse(Env("ESI_DATASOURCE"), ignoreCase: true, out var ds) ? ds : DataSource.Tranquility; + + /// The auth probe needs client credentials to exchange the token, and the token. + public static bool HasAuthConfig => + !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(SecretKey) && !string.IsNullOrEmpty(RefreshToken); + + private static string Env(string key) => Environment.GetEnvironmentVariable(key)?.Trim(); + } + + /// + /// Builds a real (the full handler pipeline) and pins a + /// handful of entity ids by name so tests never hard-code ids that could drift. + /// + public sealed class LiveFixture : IAsyncLifetime + { + public IEsiClient Client { get; private set; } + + public int JitaSystemId { get; private set; } + public int AmarrSystemId { get; private set; } + public int TritaniumTypeId { get; private set; } + public int TheForgeRegionId { get; private set; } + public int LpCorporationId { get; private set; } // Federal Navy Academy - has an LP store + public int AllianceId { get; private set; } + + public async Task InitializeAsync() + { + var services = new ServiceCollection(); + services.AddEsi(c => + { + c.EsiUrl = "https://esi.evetech.net/"; + c.DataSource = LiveConfig.DataSource; + c.UserAgent = LiveConfig.UserAgent; + c.ClientId = LiveConfig.ClientId; + c.SecretKey = LiveConfig.SecretKey; + }); + Client = services.BuildServiceProvider().GetRequiredService(); + + var status = await Call(() => Client.Status.Retrieve()); + Assert.Equal(HttpStatusCode.OK, status.StatusCode); // ESI is up - fail the whole run fast if not + + var ids = await Call(() => Client.Universe.IDs(new List + { + "Jita", "Amarr", "Tritanium", "The Forge", "Federal Navy Academy", "Pandemic Horde" + })); + Assert.Equal(HttpStatusCode.OK, ids.StatusCode); + + JitaSystemId = Resolve(ids.Data.Systems, "Jita"); + AmarrSystemId = Resolve(ids.Data.Systems, "Amarr"); + TritaniumTypeId = Resolve(ids.Data.InventoryTypes, "Tritanium"); + TheForgeRegionId = Resolve(ids.Data.Regions, "The Forge"); + LpCorporationId = Resolve(ids.Data.Corporations, "Federal Navy Academy"); + AllianceId = Resolve(ids.Data.Alliances, "Pandemic Horde"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + private static int Resolve(List list, string name) + { + var hit = list?.Find(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); + Assert.True(hit != null, $"/universe/ids did not resolve \"{name}\""); + return hit.Id; + } + + /// + /// Retries an ESI call up to three times on a transport exception or a 5xx, so a + /// single blip does not fail the run. + /// + public static async Task> Call(Func>> call) + { + EsiResponse response = null; + for (var attempt = 1; attempt <= 3; attempt++) + { + response = await call(); + if (response.Exception is null && (int)response.StatusCode < 500) + return response; + await Task.Delay(attempt * 750); + } + return response; + } + } + + /// Asserts an came back 200 OK with a body and no exception. + internal static class Ok + { + public static EsiResponse Response(EsiResponse r) + { + Assert.Null(r.Exception); + Assert.Equal(HttpStatusCode.OK, r.StatusCode); + Assert.NotNull(r.Data); + return r; + } + + public static void NonEmpty(EsiResponse> r) + { + Response(r); + Assert.NotEmpty(r.Data); + } + + public static void NonEmpty(EsiResponse r) + { + Response(r); + Assert.NotEmpty(r.Data); + } + } + + [CollectionDefinition("live")] + public sealed class LiveCollection : ICollectionFixture { } +} diff --git a/tests/ESI.NET.IntegrationTests/PublicSmokeTests.cs b/tests/ESI.NET.IntegrationTests/PublicSmokeTests.cs new file mode 100644 index 0000000..964ee98 --- /dev/null +++ b/tests/ESI.NET.IntegrationTests/PublicSmokeTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ESI.NET; +using Xunit; + +namespace ESI.NET.IntegrationTests +{ + /// + /// Unauthenticated GETs against live ESI. Proves the handler pipeline moves a + /// request and that a real payload deserializes into EsiResponse<T>. + /// A representative spread of tags rather than all 195 operations. + /// + [Collection("live")] + public sealed class PublicSmokeTests + { + private readonly LiveFixture _f; + private IEsiClient Esi => _f.Client; + + public PublicSmokeTests(LiveFixture fixture) => _f = fixture; + + [Fact] + public async Task Status_reports_players_online() + { + var r = Ok.Response(await LiveFixture.Call(() => Esi.Status.Retrieve())); + Assert.True(r.Data.Players > 0); + } + + [Fact] + public async Task Universe_reference_lists() + { + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Universe.Bloodlines())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Universe.Races())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Universe.Factions())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Universe.Categories())); + } + + [Fact] + public async Task Universe_system_type_region_by_id() + { + var system = Ok.Response(await LiveFixture.Call(() => Esi.Universe.System(_f.JitaSystemId))); + Assert.Equal("Jita", system.Data.Name); + + var type = Ok.Response(await LiveFixture.Call(() => Esi.Universe.Type(_f.TritaniumTypeId))); + Assert.Equal("Tritanium", type.Data.Name); + + Ok.Response(await LiveFixture.Call(() => Esi.Universe.Region(_f.TheForgeRegionId))); + } + + [Fact] + public async Task Universe_names_round_trip() + { + var r = await LiveFixture.Call(() => Esi.Universe.Names(new List { _f.TritaniumTypeId, _f.JitaSystemId })); + Ok.Response(r); + Assert.Contains(r.Data, x => x.Name == "Tritanium"); + Assert.Contains(r.Data, x => x.Name == "Jita"); + } + + [Fact] + public async Task Dogma_attributes() + { + var ids = await LiveFixture.Call(() => Esi.Dogma.Attributes()); + Ok.NonEmpty(ids); + + var detail = Ok.Response(await LiveFixture.Call(() => Esi.Dogma.Attribute(ids.Data.First()))); + Assert.False(string.IsNullOrEmpty(detail.Data.Name)); + } + + [Fact] + public async Task Market_prices_orders_history() + { + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Market.Prices())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Market.RegionOrders(_f.TheForgeRegionId))); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Market.TypeHistoryInRegion(_f.TheForgeRegionId, _f.TritaniumTypeId))); + } + + [Fact] + public async Task Alliances_list_and_detail() + { + var all = await LiveFixture.Call(() => Esi.Alliance.All()); + Ok.NonEmpty(all); + + var info = Ok.Response(await LiveFixture.Call(() => Esi.Alliance.Information(_f.AllianceId))); + Assert.False(string.IsNullOrEmpty(info.Data.Name)); + } + + [Fact] + public async Task Incursions_ok_possibly_empty() + { + // No incursions is a valid state; just assert the call round-trips. + Ok.Response(await LiveFixture.Call(() => Esi.Incursions.All())); + } + + [Fact] + public async Task Insurance_levels() => + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Insurance.Levels())); + + [Fact] + public async Task Sovereignty_map() => + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Sovereignty.Systems())); + + [Fact] + public async Task Industry_facilities_and_systems() + { + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Industry.Facilities())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Industry.SolarSystemCostIndices())); + } + + [Fact] + public async Task Faction_warfare_systems_and_stats() + { + Ok.NonEmpty(await LiveFixture.Call(() => Esi.FactionWarfare.Systems())); + Ok.NonEmpty(await LiveFixture.Call(() => Esi.FactionWarfare.Stats())); + } + + [Fact] + public async Task Route_between_two_hubs() + { + var r = await LiveFixture.Call(() => Esi.Routes.Map(_f.JitaSystemId, _f.AmarrSystemId)); + Ok.NonEmpty(r); + } + + [Fact] + public async Task Loyalty_store_offers() => + Ok.NonEmpty(await LiveFixture.Call(() => Esi.Loyalty.Offers(_f.LpCorporationId))); + } +} diff --git a/tests/ESI.NET.IntegrationTests/README.md b/tests/ESI.NET.IntegrationTests/README.md new file mode 100644 index 0000000..559c103 --- /dev/null +++ b/tests/ESI.NET.IntegrationTests/README.md @@ -0,0 +1,45 @@ +# ESI.NET.IntegrationTests + +Live tests against the real ESI API. **Not part of `ESI.NET.sln`** — so +`dotnet test ESI.NET.sln` (the normal CI path) never runs them. They run only +from `.github/workflows/integration.yml` (weekly + `workflow_dispatch`), and +`workflow_dispatch` is the manual pre-release check: dispatch, confirm green, +then merge `dev → master`. + +## Two groups + +| Collection | Needs | Covers | +| --- | --- | --- | +| `live` (`PublicSmokeTests`) | nothing | ~25 unauthenticated GETs across ~12 tags — the handler pipeline moves a request and a real payload deserialises into `EsiResponse` | +| `live-auth` (`AuthProbeTests`) | `ESI_CLIENT_ID` + `ESI_SECRET_KEY` + `ESI_REFRESH_TOKEN` | token exchange → `Verify` (JWKS validation) → a bearer call → transparent refresh firing against live SSO. `[SkippableFact]` — skips when the three are unset. | + +`LiveFixture` resolves a few entity ids *by name* via `/universe/ids` at start-up, +so nothing hard-codes an id that could drift. `LiveFixture.Call` retries a call +up to 3× on a transport error or 5xx. + +## Run locally + +```sh +# public smoke only +dotnet test tests/ESI.NET.IntegrationTests + +# + auth probe +ESI_CLIENT_ID=… ESI_SECRET_KEY=… ESI_REFRESH_TOKEN=… dotnet test tests/ESI.NET.IntegrationTests +``` + +Mint the refresh token with `tools/MintToken`; the probe calls +`Wallet.CharacterWallet`, so the token needs `esi-wallet.read_character_wallet.v1`. + +## Environment + +| Variable | Default | | +| --- | --- | --- | +| `ESI_USER_AGENT` | a repo-identifying string | sent as `X-User-Agent` | +| `ESI_DATASOURCE` | `Tranquility` | | +| `ESI_CLIENT_ID` / `ESI_SECRET_KEY` / `ESI_REFRESH_TOKEN` | — | auth probe; all three or none | + +## Caveat + +A real refresh in CI can make EVE rotate the refresh token. If the probe starts +failing with `invalid_grant`, re-mint and update the `ESI_REFRESH_TOKEN` secret. +The probe prints a `::warning::` when it sees a rotation. From b25307f60dd6fced87bfaf3a77666863ba94f9db Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 09:33:20 -0400 Subject: [PATCH 21/67] chore: move ESI.NET.Tests under tests/ Groups it with tests/ESI.NET.IntegrationTests, mirroring the tools/ folder. ProjectReference and the solution entry updated; InternalsVisibleTo is by assembly name so it is unaffected. Build + 45 tests green from the new path. --- ESI.NET.sln | 31 ++++++++++--------- .../ESI.NET.Tests}/DogmaModelTests.cs | 0 .../ESI.NET.Tests}/ESI.NET.Tests.csproj | 2 +- .../ESI.NET.Tests}/EsiHandlerTests.cs | 0 .../ESI.NET.Tests}/EsiRequestTests.cs | 0 .../ESI.NET.Tests}/EsiResponseTests.cs | 0 .../Fixtures/login.eveonline.com-jwks.json | 0 .../ESI.NET.Tests}/SsoTokenValidationTests.cs | 0 .../ESI.NET.Tests}/TokenRefreshTests.cs | 0 9 files changed, 18 insertions(+), 15 deletions(-) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/DogmaModelTests.cs (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/ESI.NET.Tests.csproj (92%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/EsiHandlerTests.cs (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/EsiRequestTests.cs (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/EsiResponseTests.cs (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/Fixtures/login.eveonline.com-jwks.json (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/SsoTokenValidationTests.cs (100%) rename {ESI.NET.Tests => tests/ESI.NET.Tests}/TokenRefreshTests.cs (100%) diff --git a/ESI.NET.sln b/ESI.NET.sln index 2226ae7..b4bdf3f 100644 --- a/ESI.NET.sln +++ b/ESI.NET.sln @@ -5,14 +5,16 @@ VisualStudioVersion = 15.0.27004.2010 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ESI.NET", "ESI.NET\ESI.NET.csproj", "{64F5964F-B659-4EF2-B4ED-45C4F8857012}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ESI.NET.Tests", "ESI.NET.Tests\ESI.NET.Tests.csproj", "{BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MintToken", "tools\MintToken\MintToken.csproj", "{591D25A6-7FBB-468E-9020-6C516BCF9C33}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpecCheck", "tools\SpecCheck\SpecCheck.csproj", "{43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ESI.NET.Tests", "tests\ESI.NET.Tests\ESI.NET.Tests.csproj", "{F11F479D-6662-4654-B18B-E572EFFF0536}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,18 +37,6 @@ Global {64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x64.Build.0 = Release|Any CPU {64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x86.ActiveCfg = Release|Any CPU {64F5964F-B659-4EF2-B4ED-45C4F8857012}.Release|x86.Build.0 = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x64.ActiveCfg = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x64.Build.0 = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x86.ActiveCfg = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Debug|x86.Build.0 = Debug|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|Any CPU.Build.0 = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x64.ActiveCfg = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x64.Build.0 = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.ActiveCfg = Release|Any CPU - {BFCB95A4-DCEF-4885-8042-E02E2C9F71AD}.Release|x86.Build.0 = Release|Any CPU {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|Any CPU.Build.0 = Debug|Any CPU {591D25A6-7FBB-468E-9020-6C516BCF9C33}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -71,6 +61,18 @@ Global {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x64.Build.0 = Release|Any CPU {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x86.ActiveCfg = Release|Any CPU {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A}.Release|x86.Build.0 = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|x64.ActiveCfg = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|x64.Build.0 = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|x86.ActiveCfg = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Debug|x86.Build.0 = Debug|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|Any CPU.Build.0 = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|x64.ActiveCfg = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|x64.Build.0 = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|x86.ActiveCfg = Release|Any CPU + {F11F479D-6662-4654-B18B-E572EFFF0536}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -78,6 +80,7 @@ Global GlobalSection(NestedProjects) = preSolution {591D25A6-7FBB-468E-9020-6C516BCF9C33} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {43B2CC6E-1ACD-4953-B2AA-EEE4F6F7CD9A} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {F11F479D-6662-4654-B18B-E572EFFF0536} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0222FDF9-CB59-447A-A88B-C9513544E392} diff --git a/ESI.NET.Tests/DogmaModelTests.cs b/tests/ESI.NET.Tests/DogmaModelTests.cs similarity index 100% rename from ESI.NET.Tests/DogmaModelTests.cs rename to tests/ESI.NET.Tests/DogmaModelTests.cs diff --git a/ESI.NET.Tests/ESI.NET.Tests.csproj b/tests/ESI.NET.Tests/ESI.NET.Tests.csproj similarity index 92% rename from ESI.NET.Tests/ESI.NET.Tests.csproj rename to tests/ESI.NET.Tests/ESI.NET.Tests.csproj index 3e95d9c..7be1ec2 100644 --- a/ESI.NET.Tests/ESI.NET.Tests.csproj +++ b/tests/ESI.NET.Tests/ESI.NET.Tests.csproj @@ -15,7 +15,7 @@ - + diff --git a/ESI.NET.Tests/EsiHandlerTests.cs b/tests/ESI.NET.Tests/EsiHandlerTests.cs similarity index 100% rename from ESI.NET.Tests/EsiHandlerTests.cs rename to tests/ESI.NET.Tests/EsiHandlerTests.cs diff --git a/ESI.NET.Tests/EsiRequestTests.cs b/tests/ESI.NET.Tests/EsiRequestTests.cs similarity index 100% rename from ESI.NET.Tests/EsiRequestTests.cs rename to tests/ESI.NET.Tests/EsiRequestTests.cs diff --git a/ESI.NET.Tests/EsiResponseTests.cs b/tests/ESI.NET.Tests/EsiResponseTests.cs similarity index 100% rename from ESI.NET.Tests/EsiResponseTests.cs rename to tests/ESI.NET.Tests/EsiResponseTests.cs diff --git a/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json b/tests/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json similarity index 100% rename from ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json rename to tests/ESI.NET.Tests/Fixtures/login.eveonline.com-jwks.json diff --git a/ESI.NET.Tests/SsoTokenValidationTests.cs b/tests/ESI.NET.Tests/SsoTokenValidationTests.cs similarity index 100% rename from ESI.NET.Tests/SsoTokenValidationTests.cs rename to tests/ESI.NET.Tests/SsoTokenValidationTests.cs diff --git a/ESI.NET.Tests/TokenRefreshTests.cs b/tests/ESI.NET.Tests/TokenRefreshTests.cs similarity index 100% rename from ESI.NET.Tests/TokenRefreshTests.cs rename to tests/ESI.NET.Tests/TokenRefreshTests.cs From 5491950fe39f152c775dfac654fae457036e988a Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 09:44:29 -0400 Subject: [PATCH 22/67] ci(release): rich Discord embed on release Replaces the plain one-line webhook with the embed format carried over from the old Azure DevOps pipeline (title links to the release, "Psianna Archeia" author), built with jq + curl so nothing needs hand-escaping. Still last step, still skips drafts, still test-webhook for dispatch / real-webhook for a dev->master release. --- .github/workflows/release.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46895bc..112199c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,7 @@ jobs: --skip-duplicate - name: Create GitHub Release + id: gh_release uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.value }} @@ -103,11 +104,24 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Send Discord webhook + # Rich embed to the project Discord. Test webhook for manual dispatch runs, + # the real one for a dev->master release. jq builds the payload so nothing + # needs hand-escaping. + - name: Announce on Discord if: ${{ success() && !inputs.draft }} + shell: bash env: DISCORD_WEBHOOK: ${{ github.event_name == 'workflow_dispatch' && secrets.DISCORD_TEST_WEBHOOK_URL || secrets.DISCORD_WEBHOOK_URL }} - uses: Ilshidur/action-discord@0.4.0 - with: - args: | - **ESI.NET ${{ steps.version.outputs.value }} NuGet package released** + VERSION: ${{ steps.version.outputs.value }} + RELEASE_URL: ${{ steps.gh_release.outputs.url }} + run: | + jq -n --arg v "$VERSION" --arg url "$RELEASE_URL" '{ + embeds: [{ + title: ("ESI.NET " + $v + " Release"), + url: $url, + description: "The ESI.NET build completed successfully and the NuGet package has been published (indexing may take a few minutes). Click the title for the release changelog.", + color: 3066993, + author: { name: "Psianna Archeia" }, + fields: [{ name: "NuGet", value: ("https://www.nuget.org/packages/ESI.NET/" + $v) }] + }] + }' | curl -sS -f -H "Content-Type: application/json" -d @- "$DISCORD_WEBHOOK" From 9a64fa53639e011f509c29b921a824ff75404d55 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 11:08:38 -0400 Subject: [PATCH 23/67] fix(EsiResponse): trim the body before the JSON sniff; accept bare scalars The 200/201 branch keyed on body.StartsWith("{")/EndsWith("}"), so any endpoint whose body ends in a newline (several do) fell through to Message and left Data null - a 200 with no exception and no data. It also never bound bare-scalar bodies (a wallet balance, a CSPA cost). Now: trim, then deserialize when the first non-space char can start a JSON value ({ [ " - digit t f n). A genuinely non-JSON 200 body still goes to Message with no exception; the outer catch still captures real deserialization failures. +3 tests (trailing newline, surrounding whitespace, bare decimal). 48/48. Fixes 3 of the 4 integration smoke failures. --- ESI.NET/EsiResponse.cs | 10 ++++--- tests/ESI.NET.Tests/EsiResponseTests.cs | 35 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ESI.NET/EsiResponse.cs b/ESI.NET/EsiResponse.cs index cc0ed02..7006c03 100644 --- a/ESI.NET/EsiResponse.cs +++ b/ESI.NET/EsiResponse.cs @@ -70,9 +70,13 @@ private EsiResponse(HttpResponseMessage response, string path, string body) else if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.Created) { - if ((body.StartsWith("{") && body.EndsWith("}")) || - (body.StartsWith("[") && body.EndsWith("]"))) - Data = JsonConvert.DeserializeObject(body); + // ESI returns JSON on 200/201 - an object, an array, or a bare + // scalar (a wallet balance, a CSPA cost). Trim first: some + // endpoints end the body with a newline, which defeated the old + // "{ }" / "[ ]" check and silently left Data null. + var json = body.Trim(); + if (json.Length > 0 && "{[\"-0123456789tfn".IndexOf(json[0]) >= 0) + Data = JsonConvert.DeserializeObject(json); else Message = body; } diff --git a/tests/ESI.NET.Tests/EsiResponseTests.cs b/tests/ESI.NET.Tests/EsiResponseTests.cs index dc3d8be..24f8194 100644 --- a/tests/ESI.NET.Tests/EsiResponseTests.cs +++ b/tests/ESI.NET.Tests/EsiResponseTests.cs @@ -59,6 +59,41 @@ public async Task Ok_with_non_json_body_goes_to_Message() Assert.Equal("just text", r.Message); Assert.Null(r.Data); + Assert.Null(r.Exception); + } + + [Fact] + public async Task Ok_with_a_trailing_newline_still_populates_Data() + { + // Regression: ESI ends some bodies with "\n", which defeated the + // body.EndsWith("}") check and silently left Data null. + var r = await EsiResponse>.CreateAsync( + Message(HttpStatusCode.OK, "{ \"a\": 1 }\n"), "GET|/x/"); + + Assert.Equal(1, r.Data["a"]); + Assert.Null(r.Message); + Assert.Null(r.Exception); + } + + [Fact] + public async Task Ok_with_surrounding_whitespace_still_populates_Data() + { + var r = await EsiResponse.CreateAsync( + Message(HttpStatusCode.OK, " [1, 2, 3]\r\n"), "GET|/x/"); + + Assert.Equal(new[] { 1, 2, 3 }, r.Data); + } + + [Fact] + public async Task Ok_with_a_bare_scalar_body_populates_Data() + { + // e.g. GET /characters/{id}/wallet/ returns just a number. + var r = await EsiResponse.CreateAsync( + Message(HttpStatusCode.OK, "123456.78\n"), "GET|/x/"); + + Assert.Equal(123456.78m, r.Data); + Assert.Null(r.Message); + Assert.Null(r.Exception); } [Fact] From dc042482d344d76d72c2bb07108d676fe0aa0ef8 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Thu, 10 Sep 2026 11:09:48 -0400 Subject: [PATCH 24/67] ci(release): dispatch = NuGet prerelease; add GitHub Packages mirror workflow_dispatch now defaults to a prerelease: packs -