Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
"commands": [
"dotnet-gitversion"
],
"rollForward": false
"rollForward": true
},
"fallout.globaltool": {
"version": "10.3.49",
"commands": [
"fallout"
],
"rollForward": true
}
}
}
7 changes: 0 additions & 7 deletions .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,6 @@
"Verbosity": {
"description": "Logging verbosity during build execution. Default is 'Normal'",
"$ref": "#/definitions/Verbosity"
},
"BuildProjectFile": {
"type": [
"null",
"string"
],
"description": "Path to the build project (.csproj) relative to the repository root. Defaults to 'build/_build.csproj' when unset. Read by the Fallout global tool's in-tool runner."
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Categorises the auto-generated GitHub Release notes by PR label
# (used by `generate_release_notes` in publish.yml).
changelog:
exclude:
labels:
- duplicate
- invalid
- wontfix
categories:
- title: ✨ Features & Enhancements
labels: [enhancement, feature]
- title: 🐛 Fixes
labels: [bug, fix]
- title: 📝 Documentation
labels: [documentation]
- title: 🔧 Maintenance & CI
labels: [chore, ci, cleanup, dependencies, iac]
- title: Other Changes
labels: ["*"]
18 changes: 1 addition & 17 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,5 @@ jobs:
.fallout/temp
~/.nuget/packages
key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- name: 'Setup: .NET SDK'
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- name: 'Restore: dotnet tools'
run: dotnet tool restore
- name: 'Run: Test, Pack'
run: dotnet run --project build/_build.csproj -- Test Pack
- name: 'Publish: test-results'
uses: actions/upload-artifact@v5
with:
name: test-results
path: artifacts/test-results
- name: 'Publish: packages'
uses: actions/upload-artifact@v5
with:
name: packages
path: artifacts/packages
run: ./build.cmd Test Pack
37 changes: 28 additions & 9 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# Publish to NuGet.org via Trusted Publishing (OIDC) — no stored API key.
# Publish on a version tag:
# 1. NuGet.org via Trusted Publishing (OIDC) — no stored API key.
# 2. GitHub Packages (uses the built-in GITHUB_TOKEN).
# 3. A GitHub Release with the .nupkg files attached and auto-generated,
# label-categorized release notes (see .github/release.yml).
#
# Requires a one-time Trusted Publisher policy on nuget.org for each package
# One-time nuget.org setup: a Trusted Publisher policy for each package
# (TvdbClient, TvdbClient.Models, TvdbClient.Abstractions): owner Chrison-dev,
# repo TvdbApi, workflow publish.yml, environment nuget.org. Set the repo variable
# NUGET_USER to your nuget.org username.
# repo TvdbApi, workflow publish.yml, environment nuget.org. Repo variable
# NUGET_USER = your nuget.org username.
#
# Triggered by pushing a version tag, e.g. `git tag v4.7.11 && git push origin v4.7.11`.
# Triggered by pushing a version tag, e.g. `git tag v4.7.12 && git push origin v4.7.12`.
name: publish

on:
Expand All @@ -14,8 +18,9 @@ on:
workflow_dispatch:

permissions:
id-token: write # required for OIDC trusted publishing
contents: read
id-token: write # OIDC trusted publishing (nuget.org)
contents: write # create the GitHub Release
packages: write # push to GitHub Packages

jobs:
publish:
Expand All @@ -36,15 +41,29 @@ jobs:
- name: 'Pack'
run: dotnet run --project build/_build.csproj -- Pack

- name: 'NuGet: trusted-publishing login (OIDC → short-lived key)'
- name: 'NuGet.org: trusted-publishing login (OIDC → short-lived key)'
uses: NuGet/login@v1
id: login
with:
user: ${{ vars.NUGET_USER }}

- name: 'NuGet: push'
- name: 'NuGet.org: push'
run: >
dotnet nuget push "artifacts/packages/*.nupkg"
--source https://api.nuget.org/v3/index.json
--api-key ${{ steps.login.outputs.NUGET_API_KEY }}
--skip-duplicate

- name: 'GitHub Packages: push'
run: >
dotnet nuget push "artifacts/packages/*.nupkg"
--source https://nuget.pkg.github.com/Chrison-dev/index.json
--api-key ${{ secrets.GITHUB_TOKEN }}
--skip-duplicate

- name: 'GitHub Release: create with packages + generated notes'
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: artifacts/packages/*.nupkg
generate_release_notes: true
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# CLAUDE.md — TvdbApi

C# client for **TheTVDB v4 API**, published as three NuGet packages. Read this before changing build, CI, codegen, or the public surface.

## ⚠️ CI/CD: Fallout owns the workflows — NEVER hand-edit `.github/workflows/*.yml`

This repo builds with **[Fallout](https://github.com/Fallout-build/Fallout)** (a NUKE fork). **All GitHub Actions workflows are GENERATED** from `[GitHubActions(...)]` attributes on `build/Build.cs`.

- **Do not edit generated `.github/workflows/*.yml` by hand.** `build.yml` is generated from the `[GitHubActions]` attribute on `build/Build.cs`.
- **One documented exception: `publish.yml` is hand-written.** The publish lane's OIDC login + GitHub Release are `uses:` actions that only Fallout's 11.x `IConfigureGitHubActions` custom-step API can model — and the **published** `Fallout.GlobalTool` (10.3.49) doesn't have it. So `publish.yml` is maintained by hand (exactly as Fallout's *own* repo keeps a hand-written publish workflow). If a Fallout 11.x tool is ever published, move publishing into `[GitHubActions("publish")]` + `IConfigureGitHubActions` and regenerate.
- To change the build/CI lane: edit the `[GitHubActions]` attribute + targets in `build/Build.cs`, then **regenerate**:
```sh
./build.ps1 # regenerates workflows as part of a build
# or explicitly:
dotnet run --project build/_build.csproj -- --generate-configuration GitHubActions_<name> --host GitHubActions
```
- Same rule for `.github/release.yml` if it can be driven from the build; otherwise treat generated files as generated.

## Build & tooling

- Run the build via `./build.ps1 <Target>` (bootstraps `dotnet run --project build/_build.csproj`). Targets: `Generate`, `Test`, `Pack`.
- **In-stack tooling only**: Fallout targets (C#) or PowerShell. **No Python/Bash** scripts for repo tooling.

## Codegen — don't hand-edit generated code

- Clients + models are generated from TheTVDB's OpenAPI spec via the Fallout **`Generate`** target (NSwag's C# API, in `build/Build.cs`). Run `./build.ps1 Generate`.
- The overlay (in `Build.cs`) fixes spec quirks in-memory: coerce integer params `number`→`long`, hoist inline param enums, and **strip the `{ data, status, links }` envelope** from responses.
- `src/TvdbClient.Models/TvdbModels.cs` and `src/TvdbClient/Clients/TvdbClient.cs` are **generated** — never hand-edit; change the overlay/settings and regenerate.

## Architecture — 3 projects (enforced by `ArchitectureSpecs`)

| Project | Namespace | Role | Versioning |
|---|---|---|---|
| `TvdbClient.Models` | `Tvdb.Models` | generated DTOs (the leaf) | tracks TheTVDB API version |
| `TvdbClient.Abstractions` | `Tvdb.Abstractions` | generic contracts/config/envelope (`Page<T>`) | generic |
| `TvdbClient` | `Tvdb.Clients` / `Tvdb.Paging` / … | generated clients + auth/DI/facade | generic |

`Models` must depend on nothing else; `Abstractions` must not depend on the core. `NetArchTest` specs enforce this — keep them green.

**Response shape:** clients return the entity `T` (single) / `ICollection<T>` (list); the `{data}` envelope is peeled at runtime by `EnvelopeUnwrappingHandler`. Paginated endpoints expose `Page<T>` via `GetPageAsync` extensions.

## Testing — Fallout spec convention

- **xUnit + FluentAssertions + Verify + NetArchTest + Mockly** (HTTP mocking). Public-API stability via **PublicApiGenerator + Verify** snapshots.
- Test packages are injected for `*.Specs` projects via `tests/Directory.Build.props`. **No Central Package Management** (per-project versions) — deliberate.
- One `<ClassUnderTest>Specs` per class; snake_case behavioural test names.

## Versioning & publishing

- **GitVersion**: `Major.Minor` = TheTVDB API version, **patch = our release counter**. Cut a release by tagging `v<Major>.<Minor>.<patch>` (e.g. `v4.7.12`).
- Publish (on tag, via the generated publish workflow): **NuGet.org Trusted Publishing (OIDC)** + **GitHub Packages** + a **GitHub Release** with the `.nupkg` files attached and label-categorized notes.
- Shared external accounts (nuget.org, GitHub org): add-only; don't disturb existing packages/policies.
8 changes: 4 additions & 4 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
<!-- Enable GitVersion integration for local builds -->
<EnableGitVersionTask Condition="'$(GITHUB_ACTIONS)' != 'true'">true</EnableGitVersionTask>
<!-- Use static fallback version when GitVersion is not available -->
<Version Condition="'$(GitVersion_FullSemVer)' == ''">4.7.11</Version>
<AssemblyVersion Condition="'$(GitVersion_AssemblySemVer)' == ''">4.7.11.0</AssemblyVersion>
<FileVersion Condition="'$(GitVersion_AssemblySemVer)' == ''">4.7.11.0</FileVersion>
<InformationalVersion Condition="'$(GitVersion_InformationalVersion)' == ''">4.7.11</InformationalVersion>
<Version Condition="'$(GitVersion_FullSemVer)' == ''">4.7.12</Version>
<AssemblyVersion Condition="'$(GitVersion_AssemblySemVer)' == ''">4.7.12.0</AssemblyVersion>
<FileVersion Condition="'$(GitVersion_AssemblySemVer)' == ''">4.7.12.0</FileVersion>
<InformationalVersion Condition="'$(GitVersion_InformationalVersion)' == ''">4.7.12</InformationalVersion>
<!-- Use GitVersion values when available -->
<Version Condition="'$(GitVersion_FullSemVer)' != ''">$(GitVersion_FullSemVer)</Version>
<AssemblyVersion Condition="'$(GitVersion_AssemblySemVer)' != ''">$(GitVersion_AssemblySemVer)</AssemblyVersion>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ var record = response.Data; // { data, status } envel

## Building

This project builds with **[Fallout](https://github.com/ChrisonSimtian/Fallout)**
This project builds with **[Fallout](https://github.com/Fallout-build/Fallout)**
(a NUKE fork) — the build lives in `build/Build.cs` and runs through the `./build.ps1`
bootstrapper (no global tool required). CI (`build.yml`) invokes the same targets.

Expand Down
8 changes: 5 additions & 3 deletions build.cmd
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
:; set -eo pipefail
:; SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
:; exec "$SCRIPT_DIR/build.sh" "$@"

@echo off
:: Fallout build bootstrapper (Windows). Runs the build project directly.
:: build.cmd Generate
dotnet run --project "%~dp0build\_build.csproj" -- %*
powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %*
3 changes: 3 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -eo pipefail
dotnet run --project "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build/_build.csproj" -- "$@"
62 changes: 46 additions & 16 deletions build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,14 @@
/// CI (build.yml, auto-generated from the [GitHubActions] attribute) runs Test + Pack
/// on pushes/PRs to main. The <c>Generate</c> target owns codegen in-stack (NSwag's
/// C# API — no CLI, no .nswag config, no external scripts) and is deliberately NOT in
/// CI — run it locally via <c>./build.ps1 Generate</c>. Publishing to NuGet uses
/// trusted publishing (OIDC) via a dedicated workflow (Fallout has no built-in OIDC).
/// CI — run it locally via <c>./build.ps1 Generate</c>.
/// </summary>
// AutoGenerate=false: the workflow was generated from this attribute, but its run
// step is bootstrapped via `dotnet run --project build/_build.csproj` instead of the
// `fallout` global tool (Fallout.GlobalTools isn't on nuget.org). The attribute stays
// as the source-of-truth description of the build lane.
// Workflows are GENERATED from this attribute (see CLAUDE.md: never hand-edit
// .github/workflows/*.yml). Regenerate with `./build.cmd` or:
// dotnet fallout --generate-configuration GitHubActions_build --host GitHubActions
[GitHubActions(
"build",
GitHubActionsImage.UbuntuLatest,
AutoGenerate = false,
FetchDepth = 0,
OnPushBranches = new[] { "main" },
OnPullRequestBranches = new[] { "main" },
Expand Down Expand Up @@ -82,12 +79,15 @@ partial class Build : FalloutBuild
Log.Information("Downloading spec: {Url}", SpecUrl);
var document = await OpenApiYamlDocument.FromUrlAsync(SpecUrl);

var coerced = CoerceIntegerIdPathParameters(document);
Log.Information("Overlay: coerced {Count} id path param(s) number → int64", coerced);
var coerced = CoerceIntegerParameters(document);
Log.Information("Overlay: coerced {Count} integer param(s) number → int64", coerced);

var hoisted = HoistInlineParameterEnums(document);
Log.Information("Overlay: hoisted {Count} inline parameter enum(s) to named schemas", hoisted);

var unwrapped = StripDataEnvelope(document);
Log.Information("Overlay: stripped the data envelope from {Count} response(s)", unwrapped);

// DTOs only → Tvdb.Models (TvdbClient.Models project). No client interfaces/exception
// classes here — those belong with the generic client core.
var contracts = new CSharpClientGenerator(document,
Expand Down Expand Up @@ -142,18 +142,17 @@ static CSharpClientGeneratorSettings CreateSettings(
}

/// <summary>
/// TheTVDB types integer resource ids as <c>number</c>, which NSwag maps to
/// <c>double</c>. Coerce the path-id parameters to integer/int64. Narrow by
/// design: the only other <c>number</c> fields are the genuinely-float
/// <c>score</c> properties, which must stay <c>double</c>.
/// TheTVDB types every integer parameter (ids, <c>page</c>, <c>year</c>, <c>since</c>, …)
/// as <c>number</c>, which NSwag maps to <c>double</c>. Coerce all <c>number</c>
/// parameters (path and query) to integer/int64 → C# <c>long</c>. Safe: the only genuine
/// floats in the API are the <c>score</c> response properties, which are not parameters.
/// </summary>
static int CoerceIntegerIdPathParameters(OpenApiDocument document)
static int CoerceIntegerParameters(OpenApiDocument document)
{
var count = 0;
foreach (var pathItem in document.Paths.Values)
foreach (var operation in pathItem.Values)
foreach (var parameter in operation.Parameters
.Where(p => p.Kind == OpenApiParameterKind.Path))
foreach (var parameter in operation.Parameters)
{
var schema = parameter.Schema;
if (schema is { Type: JsonObjectType.Number })
Expand Down Expand Up @@ -201,6 +200,37 @@ static int HoistInlineParameterEnums(OpenApiDocument document)
return count;
}

/// <summary>
/// TheTVDB wraps every payload in a <c>{ data, status, links? }</c> envelope. Replace each
/// JSON response schema with its inner <c>data</c> schema so the generated clients return the
/// entity (<c>T</c>) or collection (<c>ICollection&lt;T&gt;</c>) directly. The envelope is
/// peeled off the wire at runtime by EnvelopeUnwrappingHandler; pagination <c>links</c> are
/// surfaced separately for the list endpoints.
/// </summary>
static int StripDataEnvelope(OpenApiDocument document)
{
var count = 0;
foreach (var pathItem in document.Paths.Values)
foreach (var operation in pathItem.Values)
{
if (operation.Responses is null)
continue;

foreach (var response in operation.Responses.Values)
{
if (!response.Content.TryGetValue("application/json", out var media) || media.Schema is null)
continue;
if (media.Schema.Properties.TryGetValue("data", out var dataSchema))
{
media.Schema = dataSchema;
count++;
}
}
}

return count;
}

/// <summary>PascalCase an identifier, splitting on non-alphanumeric separators.</summary>
static string Pascalize(string value)
{
Expand Down
4 changes: 2 additions & 2 deletions build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Fallout.Common" Version="11.0.18" />
<PackageReference Include="Fallout.Components" Version="11.0.18" />
<PackageReference Include="Fallout.Common" Version="10.3.49" />
<PackageReference Include="Fallout.Components" Version="10.3.49" />
<!-- Codegen uses NSwag's C# API directly (no CLI, no .nswag config). -->
<PackageReference Include="NSwag.CodeGeneration.CSharp" Version="14.2.0" />
<PackageReference Include="NSwag.Core.Yaml" Version="14.2.0" />
Expand Down
39 changes: 39 additions & 0 deletions src/TvdbClient.Abstractions/Page.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Tvdb.Models;

namespace Tvdb.Abstractions;

/// <summary>
/// A page of results from a paginated TheTVDB list endpoint: the items plus the
/// pagination <see cref="Links"/> and status peeled from the response envelope.
/// </summary>
public sealed class Page<T>
{
public Page(IReadOnlyList<T> items, Links? links, string? status)
{
Items = items;
Links = links;
Status = status;
}

/// <summary>The items on this page.</summary>
public IReadOnlyList<T> Items { get; }

/// <summary>Pagination links (prev/self/next) and totals, or <c>null</c> if absent.</summary>
public Links? Links { get; }

/// <summary>The envelope status (e.g. <c>"success"</c>).</summary>
public string? Status { get; }

/// <summary>True if the API reported a next page.</summary>
public bool HasNext => !string.IsNullOrWhiteSpace(Links?.Next);

/// <summary>True if the API reported a previous page.</summary>
public bool HasPrevious => !string.IsNullOrWhiteSpace(Links?.Prev);

/// <summary>Total item count across all pages, when the API provides it.</summary>
public int? TotalItems => Links?.Total_items;

/// <summary>Page size, when the API provides it.</summary>
public int? PageSize => Links?.Page_size;
}
Loading
Loading