AutoRepo 0.5.1

dotnet add package AutoRepo --version 0.5.1
                    
NuGet\Install-Package AutoRepo -Version 0.5.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AutoRepo" Version="0.5.1">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AutoRepo" Version="0.5.1" />
                    
Directory.Packages.props
<PackageReference Include="AutoRepo">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AutoRepo --version 0.5.1
                    
#r "nuget: AutoRepo, 0.5.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AutoRepo@0.5.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AutoRepo&version=0.5.1
                    
Install as a Cake Addin
#tool nuget:?package=AutoRepo&version=0.5.1
                    
Install as a Cake Tool

AutoRepo

A C# source generator that automatically creates API client code from repository interfaces.

What it generates

Given a repository interface annotated with [GenerateApi], AutoRepo generates:

  1. IApi{Name} - Refit-compatible interface with HTTP method attributes
  2. Api{Name}Client - HttpClient-based implementation
  3. Fallback{Name} - Local-first wrapper with API fallback
  4. Combined{Name} - Merged wrapper that queries both local and remote sources in parallel

Usage

Add AutoRepo as an analyzer reference:

<ProjectReference Include="path/to/AutoRepo.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

Annotate your repository interfaces:

using AutoRepo;

[GenerateApi(RoutePrefix = "/api/products")]
public interface IProductRepository
{
    Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken ct = default);
    Task<Product?> GetAsync(int id, CancellationToken ct = default);
    Task UpsertAsync(Product product, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}

Custom JSON serialization

If your entities need custom JSON converters (e.g., for strongly-typed IDs), specify a JsonOptionsType:

[GenerateApi(RoutePrefix = "/api/products", JsonOptionsType = "MyApp.MyJsonOptions")]
public interface IProductRepository { ... }

The type must have a static Default property returning JsonSerializerOptions.

Conventions

  • HTTP method inference: Get*/Search*/Find* → GET, Delete*/Remove* → DELETE, Create*/Add* → POST, Update* → PUT, Upsert* → POST (with body) or PUT
  • Route parameters: Parameters named id or ending with Id in Get/Delete methods

Overriding the route

The route heuristic above only has an id-shaped parameter to key a path segment on. It breaks down for a family of sibling read methods distinguished by intent rather than by id — e.g. GetTrendingAsync(), GetPopularAsync(), GetTopRatedAsync() all have no route parameter at all, so without an override they'd silently collapse onto the same bare route prefix. Use [ApiRoute("...")] on the method to say exactly what the path should be instead of guessing:

[GenerateApi(RoutePrefix = "/api/discover")]
public interface IDiscoveryRepository
{
    [ApiRoute("tvshows/trending")]
    Task<DiscoveryResult> GetTrendingTVShowsAsync(int? page = null, CancellationToken ct = default);

    [ApiRoute("tvshows/popular")]
    Task<DiscoveryResult> GetPopularTVShowsAsync(int? page = null, CancellationToken ct = default);

    // "{tmdbId}" binds the parameter into the path, overriding the default id-suffix heuristic too
    [ApiRoute("tvshows/{tmdbId}/details")]
    Task<ShowDetails> GetDiscoverTVShowDetailsAsync(int tmdbId, CancellationToken ct = default);
}

[ApiRoute] always wins outright for that method — the name-based heuristic (including the automatic /search suffix for Search* methods) is skipped entirely once it's present. [ApiRoute("")] means "the bare route prefix, on purpose" — useful when one method in the interface genuinely belongs at the resource root while its siblings each need their own explicit path (e.g. a plain GetAllAsync() alongside GetContinueWatchingAsync()/GetRecentAsync(), all on the same [GenerateApi] interface).

  • Body parameters: Complex types in POST/PUT methods
  • Query parameters: Primitive types that aren't route parameters
  • Source tracking: If entities have a Source property of type DataSource, generated wrappers set it to Local, Remote, or Both
  • Fallback strategy: Single items → local-first; Collections → API-first; Mutations → local-only unless [MutationOperation(Target = ...)] says otherwise (see below)
  • Combined strategy: All reads query both sources in parallel and merge; Mutations → local-only unless [MutationOperation(Target = ...)] says otherwise (see below)
  • Local-only methods: Methods starting with GetStored or GetLibrary stay local-only in Combined wrapper

Where mutations go

By default every mutation (Add*/Create*/Update*/Upsert*/Delete*/Remove*, or anything marked [MutationOperation]) is applied to the local store only by the Fallback and Combined wrappers. That's right for client-owned data (a user's library), and wrong for server-owned per-user data (watch history, preferences) where the local copy is just a cache — a Combined client would happily "save" forever without the server ever hearing about it.

Set the target per method:

[GenerateApi(RoutePrefix = "/api/watchhistory")]
public interface IWatchHistoryRepository
{
    // API first, then mirrored locally so the next Combined read already sees it.
    // If the API call throws, it's logged as a warning and the local write still happens.
    [MutationOperation(Target = MutationTarget.Both)]
    Task UpsertEpisodeProgressAsync(string contentId, TimeSpan position, CancellationToken ct = default);

    // API only — the local store is never touched; exceptions propagate.
    [MutationOperation(Target = MutationTarget.Api)]
    Task PurgeAsync(CancellationToken ct = default);

    // Default (Local) — same as no attribute.
    Task AddToLibraryAsync(int showId, CancellationToken ct = default);
}
Target Fallback / Combined behaviour Failure handling
Local (default) _local only n/a
Api _api only exception propagates
Both _api then _local; a non-void method returns the API's result when it succeeded, else the local one API exception → ILogger.LogWarning, local write still runs

Which source wins on read

When a Combined read finds data in both the local store and the API, the default is local wins: a single-item read returns the local row, and a collection read merges by Id keeping the local item for duplicates. A Fallback single-item read tries local first. That's right for client-owned data.

For server-owned data written with [MutationOperation(Target = MutationTarget.Both)] the local copy is a mirror that goes stale the moment another device writes, so mark the interface (or individual methods) API-first:

[GenerateApi(RoutePrefix = "/api/watchhistory")]
[ReadPreference(ReadSource.ApiFirst)]
public interface IWatchHistoryRepository
{
    Task<WatchHistoryEntity?> GetEpisodeProgressAsync(string contentId, CancellationToken ct = default);

    // A method-level attribute overrides the interface's.
    [ReadPreference(ReadSource.LocalFirst)]
    Task<WatchHistoryEntity?> GetLastLocalSessionAsync(string contentId, CancellationToken ct = default);
}
Read LocalFirst (default) ApiFirst
Combined, single item both in parallel; local row if present, else API row both in parallel; API row if non-null, else local row
Combined, collection both in parallel; merge by Id, local wins duplicates both in parallel; API list as-is (order kept, no merge); local list only if the API call threw
Fallback, single item local, then API if null API, then local if null or the API threw
Fallback, collection API, then local if the API threw (unchanged) same

API exceptions are always logged and swallowed, so an offline client still gets its local data. The local mirror is not rewritten from the API row on read; it is refreshed by the next write-through. An explicit [ReadOperation(Mode = ReadMode.Fallback)] keeps meaning local-first, and mutations and GetStored*/GetLibrary* local-only reads are unaffected.

Requirements

  • .NET SDK with Roslyn source generators support
  • Refit (for the generated Refit interface)
  • Microsoft.Extensions.Logging (for Fallback/Combined wrappers)
There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.1 167 10/10/2026
0.4.0 102 9/7/2026
0.3.1 96 9/6/2026
0.3.0 117 9/6/2026
0.2.0 125 4/19/2026
0.1.0 129 3/2/2026