Write a plain C# class. Get a full REST API. No controllers to write.
EZRestAPI is a source generator. It reads your classes at compile time and writes the code for a working REST API. You only add attributes to your own classes.
What you get:
- CRUD routes for each class (create, read, list, update, delete) backed by a database.
- Entity Framework Core storage and ASP.NET Core minimal API routes, generated for you.
- OpenAPI docs and clean error responses (RFC 9457
problem+json) out of the box.
- Add it to your project
- Quick start
- Your models
- Endpoints
- Links between models
- Owned data
- Errors
- API docs (OpenAPI)
- Build warnings
- The Example project & tests
- Design docs
Reference the generator as an analyzer (it runs during build; there is no NuGet package yet):
<ItemGroup>
<ProjectReference Include="..\EZRestAPI\EZRestAPI.csproj" OutputItemType="Analyzer" />
</ItemGroup>Wire it up in Program.cs. Give your DbContext a connection string named after it
(here the context is CustomDbContext, so the string is named CustomDbContext):
builder.Services.AddDbContextFactory<CustomDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("CustomDbContext")));
builder.Services.AddEZRestAPI(); // register the generated services
builder.Services.AddOpenApi(); // needed for MapOpenApi below
var app = builder.Build();
app.MapEZRestAPI(); // add the generated routes
app.MapOpenApi(); // serve the OpenAPI document
app.Run();Mark a class with [EZRestAPI.Model(...)]. The first name is singular, the second
is plural. Make the class partial so the generator can add to it (it adds an int Id
key for you):
[EZRestAPI.Model("Book", "Books", Endpoints = EZRestAPI.Endpoints.All)]
public partial class BookModel
{
public required string Title { get; set; }
}Endpoints says which routes to expose; here All turns on every one. See
Endpoints for the other options. That gives you these routes.
The path is the plural name, lowercased (/books):
POST /books— create one. Returns201 Created.GET /books/{id}— read one. Returns200 OK, or404if not found.GET /books— list them, paginated (?pageand?pageSize). Returns200 OK.PUT /books/{id}— replace one. Returns204 No Content, or404if not found.DELETE /books/{id}— delete one. Returns204 No Content, or404if not found.
A [Model] is an Entity Framework Core model: a table with columns, plain
data. It holds no rules, so the generator can create, replace and delete the
whole object. A class with rules about its own state is a poor fit — write that
API by hand.
A model is a plain C# class. Mark it partial and add [EZRestAPI.Model] with a
singular and a plural name. At compile time, EZRestAPI reads the class and always writes:
- An
Idfield (int, the primary key). You don't write it. If you want your ownId, add one — it must be anint. - A
DbSeton your database context, named after the plural name (soBooks). This is the table.
Everything else — the repository, the DTOs, and the REST endpoints — depends on the
Endpoints flags you set on [Model]. See Endpoints for what those
generate and how to choose them.
Your model's public properties become table columns. Common types work as you'd expect:
stringint,longbooldecimalDateTime,DateTimeOffsetGuidbyte[]
More are supported too: byte, short, float, double, and TimeSpan. Add ? to make a field nullable (string?).
For the full list of every supported type, see Example/Models/AuthorModel.cs. It uses each one and shows the database column it maps to.
Put standard .NET validation attributes on your fields. EZRestAPI copies them onto the generated request types and checks them on every POST and PUT. Common ones:
[Required]— the field must be sent.[MaxLength(n)]/[StringLength(min, max)]— length limits.[Range(min, max)]— number range.[EmailAddress]— must look like an email.[RegularExpression(pattern)]— must match a pattern.
[EZRestAPI.Model("Registration", "Registrations", Endpoints = EZRestAPI.Endpoints.All)]
public partial class RegistrationModel
{
[Required]
[StringLength(32, MinimumLength = 3)]
public required string Username { get; set; }
[Required]
[EmailAddress]
public required string Email { get; set; }
[Range(18, 120)]
public int Age { get; set; }
}If the input is bad, the API returns 422 with an application/problem+json body (RFC 9457). It includes an errors map: each field name points to a list of what went wrong. So the caller sees exactly which fields failed and why.
{
"type": "...",
"title": "One or more validation errors occurred.",
"status": 422,
"detail": "One or more fields failed validation; see 'errors' for details.",
"code": "unprocessableEntity",
"errors": {
"Username": ["The field Username must be a string with a minimum length of 3 and a maximum length of 32."],
"Email": ["The Email field is not a valid e-mail address."]
}
}One extra check happens for free: a non-nullable string (like string Title with no ?) is treated as [Required], so a missing value is caught as a 422 instead of failing later.
See Example/Models/RegistrationModel.cs for a full validation example.
[Model] takes an Endpoints flag saying which routes to expose:
[Flags]
public enum Endpoints
{
None = 0,
List = 1, Create = 2, Read = 4, Update = 8, Delete = 16,
Nested = 32,
Crud = List | Create | Read | Update | Delete, // flat routes only
ReadOnly = List | Read | Nested,
All = Crud | Nested,
}| Flag | Route it turns on |
|---|---|
List |
GET /{plural} |
Create |
POST /{plural} |
Read |
GET /{plural}/{id} |
Update |
PUT /{plural}/{id} |
Delete |
DELETE /{plural}/{id} |
Nested |
the nested form of whichever verbs above are also set (see Links between models) |
Combine flags with |, e.g. Endpoints = EZRestAPI.Endpoints.List | EZRestAPI.Endpoints.Read.
Three presets cover the common cases: Crud (every flat route, no nested routes),
ReadOnly (read-only, flat and nested), and All (everything).
Whatever you set, you always get the same building blocks: the Id, the
DbSet, the DTOs (CreateBookRequest, ReadBookResponse, and the rest), and
the full repository — CreateAsync, ReadAsync, ListAsync, UpdateAsync,
DeleteAsync. The flags decide which of those get an HTTP route.
So Endpoints.List gives you one route and the whole repository. That is on
purpose: the usual reason to leave out Create is that you want to hand-write
POST /books yourself, and your version needs CreateAsync and
CreateBookRequest to build on.
Leave Endpoints off [Model] and you get None. You get the table and the
repository, but no routes, and the model is left out of MapEZRestAPI. Use it
when you want to store and use a type without publishing it — see
AuditLogModel in the Example project.
Nested has no route of its own. It switches on the nested form of whichever
verbs are also set. Endpoints.List | Endpoints.Nested gives you both
GET /books and GET /authors/{authorId}/books. Endpoints.Crud deliberately
leaves Nested out, so it gives flat routes only, even for a model with a
foreign key that would otherwise make it nestable.
EZR012(Warning) —Createis set withoutRead. TheCreateresponse'sLocationheader points at theReadroute, which doesn't exist without it, so the header would point nowhere.EZR013(Info) — the model isEndpoints.None, so it publishes no routes. The table, the DTOs and the repository are still there; only the endpoint class is not.EZR014(Info) — the model'sEndpointsis non-zero but selects no verb, so again no route is generated.Endpoints.Nestedalone is the common way to hit this: it only switches the nested form of whichever verbs are also set, so on its own it selects nothing.
EZR013 and EZR014 are both informational, not mistakes, but easy to miss:
MSBuild's console logger hides Info diagnostics at the default verbosity, so
if a model you expected to have an API doesn't, rebuild with -v detailed to
see them (an IDE shows them as normal).
To link two models, add a property named {Singular}Id. If its name (minus Id)
matches another model's singular name, and its type is int or int?, it becomes
a foreign key. Use int? if the link is optional.
Book points to Author:
[EZRestAPI.Model("Book", "Books", Endpoints = EZRestAPI.Endpoints.All)]
public partial class BookModel
{
[MaxLength(255)]
public required string Title { get; set; }
public required int AuthorId { get; set; } // -> Author
}You get two ways to reach the same books:
- Flat:
/books,/books/{id}(all the usual CRUD). - Nested under the parent:
/authors/{authorId}/books,/authors/{authorId}/books/{id}.
The nested routes only show books that belong to that author. Creating a book under
/authors/5/books sets AuthorId to 5 for you.
The two list routes (GET /books and GET /authors/{authorId}/books) return one
page at a time. Two query values control it:
?page— which page, starts at1(default1).?pageSize— how many per page (default20, max100).
If you ask for more than 100, it quietly gives you 100. If page or pageSize is
less than 1, you get 422 with a message.
The response wraps the list:
{
"items": [ ... ],
"totalCount": 42,
"page": 1,
"pageSize": 20
}- Reading
/authors/5/books/9but book 9 belongs to another author ->404. - Using a parent id that does not exist (
/authors/999/books) ->404. - Creating or updating a book with an
AuthorIdthat does not exist ->422. - Deleting an author that still has books ->
409(the books block it).
Use [EZRestAPI.Nested] for parts that belong to one parent and have no life of
their own. They are stored with the parent (EF owned types). They get no routes.
You save the whole tree in one call, and deleting the parent deletes them too.
A post has comments, and each comment has reactions:
[EZRestAPI.Model("Post", "Posts", Endpoints = EZRestAPI.Endpoints.All)]
public partial class PostModel
{
[MaxLength(255)]
public required string Title { get; set; }
public required List<CommentModel> Comments { get; set; }
}
[EZRestAPI.Nested("Comment")]
public class CommentModel
{
[MaxLength(1024)]
public required string Text { get; set; }
public required List<ReactionModel> Reactions { get; set; }
}
[EZRestAPI.Nested("Reaction")]
public class ReactionModel
{
[MaxLength(16)]
public required string Emoji { get; set; }
}There is no /comments route. You send the comments and reactions inside the post,
and read them back inside the post.
Sometimes a property is named like a foreign key but is not one. If you have an
int property ending in Id and no matching model exists, the generator warns you
with EZR011. Add [EZRestAPI.Scalar] to say "this is just a plain number, leave
it alone." The warning goes away and no nested route is made.
[EZRestAPI.Model("SensorReading", "SensorReadings", Endpoints = EZRestAPI.Endpoints.All)]
public partial class SensorReadingModel
{
[EZRestAPI.Scalar]
public required int ExternalId { get; set; } // not a link, just a value
public required double Value { get; set; }
public required DateTimeOffset TakenAt { get; set; }
}Every error comes back as application/problem+json (RFC 9457). The body has the same fields each time:
type— a link that names the error kind.title— a short label, likeNot Found.status— the HTTP status code.detail— a plain sentence about what went wrong.code— a short machine string:notFound,conflict, orunprocessableEntity.
When you send bad input you get 422 and one extra field: errors — a map from each bad field name to a list of messages (see the Validation example).
Common status codes and when you get them:
| Status | Meaning | When |
|---|---|---|
| 200 OK | Success with a body | Read one, or list |
| 201 Created | Made a new thing | POST create |
| 204 No Content | Success, empty body | PUT update, DELETE |
| 404 Not Found | The thing is not there | Missing id, missing nested parent, or a scoped id that does not match |
| 409 Conflict | The action clashes with the current state | Delete a parent that still has children |
| 422 Unprocessable Entity | The request was understood but not valid | Failed validation, a bad foreign key in the body, or page/pageSize below 1 |
The Example app turns on OpenAPI in two lines:
builder.Services.AddOpenApi(); // in service setup
app.MapOpenApi(); // only when in DevelopmentIn Development you can open /openapi/v1.json to get the full document. Each route is described with:
- a tag — the model's plural name, so routes group by resource.
- an operation id — a stable name like
CreateBookorListBooks, used by client generators. - its error responses — every route lists the
application/problem+jsonerrors it can return. A422is documented as a validation problem so theerrorsmap shows up in the schema.
The generator checks your models at compile time. Most problems stop the build (Error); some are just a heads-up (Warning, Info). Fix the code and rebuild.
| Code | Level | Meaning |
|---|---|---|
| EZR001 | Error | A [Model] class is not partial. |
| EZR002 | Error | Two models share the same singular name. |
| EZR003 | Error | Two models share the same plural name. |
| EZR004 | Error | A property points at another [Model] type; use its id, or mark it [Nested]. |
| EZR005 | Error | [Nested] classes contain each other in a loop; nesting must be a tree. |
| EZR006 | Error | Two [Nested] classes share the same singular name. |
| EZR007 | Error | An Id property is not an int; only int keys work. |
| EZR008 | Error | A name you gave is not a valid C# identifier. |
| EZR009 | Error | Nested items sit in an unsupported collection; use List<T>, IList<T>, ICollection<T>, IReadOnlyList<T>, or IReadOnlyCollection<T>. |
| EZR010 | Error | A class has [Model] plus [Nested]; pick one. |
| EZR011 | Warning | A property looks like a foreign key (XId) but no [Model] has singular name X; add that model, or mark it [Scalar]. |
| EZR012 | Warning | Endpoints.Create is set without Endpoints.Read, so the Create response's Location header points at a GET route that does not exist. |
| EZR013 | Info | The model has Endpoints.None (the default), so it publishes no routes. The table, DTOs and repository are still generated. If this is a surprise, add an Endpoints value to [Model]. Console builds hide Info diagnostics at the default verbosity; run with -v detailed to see it (an IDE shows it as usual). |
| EZR014 | Info | The model's Endpoints selects no verb (List/Create/Read/Update/Delete) — for example Endpoints.Nested alone. Everything else is still generated, but no route is, since Nested only switches the form of a verb that must also be set. Add at least one verb. |
Example/ is a small runnable app that uses every feature. Look at Example/Models/ to see each one:
SimpleDataModel— the smallest model.AuthorModel— many property types; andBookModelshows a foreign-key relationship.PostModel/CommentModel/ReactionModel—[Nested]owned types.RegistrationModel— validation.SensorReadingModel—[Scalar]to opt an id-shaped field out.ReviewModel— more than one foreign key.AuditLogModel—Endpoints.None: a table and a repository, but no routes.ExchangeRateModel—Endpoints.ReadOnly: only theGETroutes.AuditNoteModel—Endpoints.Crud: a child ofAuditLogModelwhose nested routes are deliberately left off, so it only appears at its flat, top-level path.
Example/Program.cs is the full wiring (AddEZRestAPI, MapEZRestAPI, OpenAPI).
Example.Tests runs the generated API for real. Most tests boot the app against a live SQL Server, so they need Docker — a container starts automatically through Testcontainers (MsSqlContainerFixture). Run them with:
dotnet testOne test class, OpenApiDocumentTests, only reads the OpenAPI document and needs no database or Docker. The database-backed classes are marked [Collection("MsSql")] and share one container.
This README is the user guide. docs/ holds the specs — what the
generator guarantees, and why — plus how it is built and tested.
ROADMAP.md tracks what is done and what is next.
Status: work in progress.