Benevia.Core.API
Introduction
Benevia.Core.API provides RESTful OData API infrastructure for exposing entities through HTTP endpoints. It automatically generates CRUD endpoints for entities decorated with [ApiEntity], handles database access via Entity Framework, and supports rich OData querying capabilities.
Use this library when you need:
- RESTful API with minimal boilerplate
- OData query support (filtering, sorting, expansion, pagination)
- Multi-tenant database architecture
- Integration with Entity Framework Core
Note: This library works standalone or integrates with Benevia.Core.Events for business logic on entities.
Getting Started
1. Install the NuGet package
dotnet add package Benevia.Core.API
2. Create your model
Entities can be simple POCOs only decorated with [ApiEntity]. Use Data Annotations to provide Entity Framework with the needed db info.
namespace MyApp.Model;
[ApiEntity]
[NaturalKey(nameof(Product.Sku))]
public partial class Product : EntityBase
{
[Required]
[MaxLength(20)]
public partial string Sku { get; set; }
public partial string Description { get; set; }
public partial decimal Price { get; set; }
public partial decimal StockQuantity { get; set; }
}
3. Add & use the model & endpoints
In your Program.cs:
// ...
builder.Services
.AddMyAppModel() //generated extension from your model
.AddMyAppEndpoints() //generated extension from your model
.AddCoreApi((o, t) => o.UseNpgsql(t?.ConnectionString));
// ...
app.UseCoreApi();
app.Run();
// ...
4. Set up configuration
Add tenant and authentication configuration to your appsettings.Development.json:
{
"Tenants": {
"Demo": {
"ConnectionString": "Host=localhost;Database=demo_benevia_erp;Username=postgres;Password=postgres",
"EncryptionKey": "e8a916b18c496995374f11beb0922b5231093e1c9ca0f31b34d63edafb25b10c"
}
},
"JWT": {
"Issuer": "yourcompany",
"Audience": "yourcompany",
"AccessTokenExpirationHours": 0.5,
"RefreshTokenExpirationDays": 14
}
}
JWT settings provided in API by default, but If you add your own JWT configuration section, make sure you pass it in Program.cs so it overwrites defaults:
.AddCoreApiEvents((o, t) => o.UseNpgsql(t?.ConnectionString),
jwtOptions: _ => builder.Configuration.GetSection("JWT").Get<JwtOptions>())
Production deployment:
For production environments, use environment variables instead of storing secrets in configuration files. The application uses .NET's IConfiguration, which automatically reads from environment variables and more. Example:
Tenants__Demo__ConnectionString="Host=prod-db;Database=erp;Username=app;Password=***"
Tenants__Demo__EncryptionKey="***"
Feel free to use a different Entity Framework database provider (such as Microsoft SQL, SQLite, etc).
5. Configure API Options (Optional)
You can customize API behavior through the ApiOptions section in your configuration:
{
"ApiOptions": {
"EnableLowerCamelCase": true,
"MaxTop": 500,
"MaxNodeCount": 100,
"MaxExpansionDepth": 5
}
}
| Option | Default | Description |
|---|---|---|
EnableLowerCamelCase |
false |
When true, OData JSON responses use camelCase property names (e.g., unitPrice). When false, uses PascalCase (e.g., UnitPrice). |
MaxTop |
500 |
Maximum number of items returned by a single query using $top. |
MaxNodeCount |
100 |
Maximum number of nodes allowed in a query. |
MaxExpansionDepth |
5 |
Maximum depth for $expand operations. |
Note: Unlike standard ASP.NET Core JSON options, OData uses its own Entity Data Model (EDM) for serialization. The EnableLowerCamelCase option configures the EDM builder directly to ensure consistent casing across all OData responses.
Using the API
The Benevia.Core.API package automatically exposes your entities as OData RESTful endpoints. OData (Open Data Protocol) is a standardized protocol for building and consuming RESTful APIs, providing rich querying capabilities.
What is OData?
OData allows clients to:
- Filter data with
$filter(e.g.,?$filter=Price gt 100) - Sort results with
$orderby(e.g.,?$orderby=Name desc) - Select specific fields with
$select(e.g.,?$select=Sku,Description) - Expand related entities with
$expand(e.g.,?$expand=Category) - Paginate with
$topand$skip(e.g.,?$top=10&$skip=20) - Count results with
$count(e.g.,?$count=true)
For full OData syntax, see the OData documentation.
CRUD Operations
Each entity decorated with [ApiEntity] gets a full CRUD API:
Create (POST)
POST /api/Products
Content-Type: application/json
{
"Sku": "PROD-001",
"Description": "Premium Widget",
"Price": 29.99,
"StockQuantity": 100
}
Read All (GET)
GET /api/Products
Read One (GET by Key)
GET /api/Products(123e4567-e89b-12d3-a456-426614174000)
Update (PATCH)
PATCH /api/Products(123e4567-e89b-12d3-a456-426614174000)
Content-Type: application/json
{
"Price": 24.99,
"StockQuantity": 85
}
Delete (DELETE)
DELETE /api/Products(123e4567-e89b-12d3-a456-426614174000)
Calling c# methods (Functions and Actions)
Methods can be created in c# and automatically be exposed to the OData API. Methods are defined in your c# class in this way:
See Core.Events documentation
Query Examples
Filter by price:
GET /api/Products?$filter=Price gt 20 and Price lt 50
Sort by SKU descending:
GET /api/Products?$orderby=Sku desc
Get only specific fields:
GET /api/Products?$select=Sku,Description,Price
Combine multiple queries:
GET /api/Products?$filter=StockQuantity gt 0&$orderby=Price&$top=10
Expand related entities (if you have navigation properties):
GET /api/Products?$expand=Category,Supplier
Count results:
GET /api/Products?$count=true&$filter=Price lt 100
Authentication
The API uses JWT (JSON Web Token) authentication for securing endpoints.
Sign In
POST /api/auth/signin
Content-Type: application/json
{
"TenantId": "Demo",
"Username": "myuser",
"Password": "MyPassword@123"
}
Returns an access token and refresh token. Include the access token in the Authorization header for all API requests:
GET /api/Products
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Token Refresh
When the access token expires (default: 30 minutes), use the refresh token to get a new one:
POST /api/auth/refresh
Content-Type: application/json
{
"RefreshToken": "your-refresh-token-here"
}
Note: Refresh tokens are one-time use only and expire after 14 days. Always use the new refresh token returned in each response.
Roles and Hierarchy
Roles support a tree structure through an optional supervisor (parent) role.
Rules:
- A role can have at most one parent.
- Role inheritance is transitive: assigning a parent role grants effective access from all descendant roles.
- Cycles are rejected (
A -> B -> Ais invalid). - A role with child roles cannot be deleted until children are re-parented or removed.
Role endpoints:
- Existing CRUD routes are unchanged.
GET /api/roles/treereturns the role hierarchy for role-management UI clients.
Personal Access Tokens (PAT)
For third-party integrations (for example Power BI, import jobs, CRM sync, or external AI tools) that cannot perform the interactive login flow, you can create a Personal Access Token (PAT).
Create:
POST /api/user/pats
Authorization: Bearer <interactive-user-token>
Content-Type: application/json
{
"name": "Sales Integration",
"expirationDays": 90,
"responsibilityIds": [
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333"
]
}
Rules:
expirationDays:1..365responsibilityIds: required explicit scope for normal PATs; may be empty only for the full-access fallback case described below- PAT scope is fixed to selected responsibilities
- PAT owner-scope checks use effective inherited access from role hierarchy
- PAT secret is shown only at create/regenerate response time (not retrievable later)
Emergency fallback mode (for apps that have not set up roles/responsibilities yet):
- If
responsibilityIdsis empty, Core can create a full-access fallback PAT only when:- owner has
HasFullAccess = true, and - owner has zero effective roles.
- owner has
- Fallback PATs return
isFullAccessFallback: true. - Fallback PATs auto-disable when owner later gets any role (or loses full access).
- Legacy PAT rows with no responsibilities and
isFullAccessFallback = falsedo not get implicit access.
PAT endpoints:
POST /api/user/patsGET /api/user/patsPOST /api/user/pats/{patId}/revokePOST /api/user/pats/{patId}/regenerate
Basic Authentication with PAT
Some OData clients (like Excel or PowerBI) may not support Bearer token authentication easily. For these scenarios, the API supports Basic Authentication using your PAT.
Username: pat
Password: <your-pat-token>
Example header:
Authorization: Basic <base64-encoded-credentials>
Where credentials are pat:your-long-jwt-token.
Request flow
- Token is validated (JWT or PAT).
- Snapshot resolver loads current user state from the database.
- For user-session JWTs: roles/responsibilities are resolved from current assignments (cross-request role cache uses
RoleCacheExpirationMinutes). - For PATs: selected PAT responsibilities are resolved and intersected with the owner's current responsibilities.
- Permission checks use resolved
UserAccessContextand responsibility permissions (ResponsibilityAccessCache).
PAT cache behavior:
- PAT scope cache is keyed by
patId + tokenHash. - PAT scope cache TTL uses
ResponsibilityCacheExpirationMinutes(0disables it). - revoke/regenerate invalidates PAT cache immediately.
PAT onboarding checklist (non-ERP teams)
- Define responsibilities in BL.
- Ensure responsibility synchronization runs at startup.
- Create roles and assign responsibilities.
- Assign roles to users.
- Create scoped PATs by selecting explicit responsibilities.
Integration Testing
Benevia.Core.API works seamlessly with ASP.NET Core's WebApplicationFactory for integration testing. This allows you to test your API endpoints with a real HTTP client and database.
What's happening:
WebApplicationFactory<Program>creates a test server hosting your applicationCreateClient()provides an HTTP client configured to send requests to the test server- Requests are processed through the full API pipeline (routing, controllers, OData, database)
- You can use an in-memory database or test database for isolation
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net.Http.Json;
using Xunit;
public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
await AuthenticateClient(_client);
}
[Fact]
public async Task CreateProduct_ReturnsCreatedProduct()
{
// Arrange
var product = new { Name = "Test Product", Price = 19.99 };
// Act
var response = await _client.PostAsJsonAsync("/api/Products", product);
// Assert
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<Product>();
Assert.Equal("Test Product", created.Name);
}
}
More Info
For more information about the architecture of Benevia.Core.API, see ./ARCHITECTURE.md.