AutoRepo 0.5.1
dotnet add package AutoRepo --version 0.5.1
NuGet\Install-Package AutoRepo -Version 0.5.1
<PackageReference Include="AutoRepo" Version="0.5.1"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="AutoRepo" Version="0.5.1" />
<PackageReference Include="AutoRepo"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add AutoRepo --version 0.5.1
#r "nuget: AutoRepo, 0.5.1"
#:package AutoRepo@0.5.1
#addin nuget:?package=AutoRepo&version=0.5.1
#tool nuget:?package=AutoRepo&version=0.5.1
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:
IApi{Name}- Refit-compatible interface with HTTP method attributesApi{Name}Client- HttpClient-based implementationFallback{Name}- Local-first wrapper with API fallbackCombined{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
idor ending withIdin 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
Sourceproperty of typeDataSource, generated wrappers set it toLocal,Remote, orBoth - 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
GetStoredorGetLibrarystay 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)
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.