Syntrony.Web
1.3.0
dotnet add package Syntrony.Web --version 1.3.0
NuGet\Install-Package Syntrony.Web -Version 1.3.0
<PackageReference Include="Syntrony.Web" Version="1.3.0" />
<PackageVersion Include="Syntrony.Web" Version="1.3.0" />
<PackageReference Include="Syntrony.Web" />
paket add Syntrony.Web --version 1.3.0
#r "nuget: Syntrony.Web, 1.3.0"
#:package Syntrony.Web@1.3.0
#addin nuget:?package=Syntrony.Web&version=1.3.0
#tool nuget:?package=Syntrony.Web&version=1.3.0
Syntrony.Web
Exposes application services as MVC controllers without writing a controller by hand — reproducing
ABP's CreateControllersForAppServices on top of plain ASP.NET Core, with the same route format and
HTTP verb convention already in use today. AddSyntronyWeb() composes the full framework
(Syntrony.Wrapper + Syntrony.Logging + Syntrony.Web) in one call, a 1:1 replacement for
services.AddAbp<WebModule>(...).
Installation
dotnet add package Syntrony.Web
Basic usage
using Syntrony.Web;
builder.Services.AddControllers();
builder.Services.AddSyntronyWeb(web => web.ModuleName = "app"); // default already "app"
// Tell the framework which assembly holds your application services. Without this line nothing is exposed.
builder.Services.AddSyntrony(opt => opt.AddAssemblyContaining<UserAppService>());
var app = builder.Build();
app.UseExceptionHandler();
app.UseRouting();
app.MapControllers();
app.Run();
Syntrony.Web only looks at the assemblies registered this way — the same list drives convention-based DI
registration. Calling AddSyntrony again composes with AddSyntronyWeb's own call. If every application
service route answers 404 and nothing fails at startup, this registration is what's missing.
Only the methods of a type implementing Syntrony.Application.IApplicationService are affected —
a controller you wrote by hand keeps working exactly as before, with or without [RemoteService].
public class UserAppService(IRepository<User> users) : ApplicationService
{
public Task<UserOutput> GetAsync(Guid id) => ...;
public Task<UserOutput> CreateAsync(CreateUserInput input) => ...;
}
produces GET api/services/app/User/Get and POST api/services/app/User/Create.
Current user
UseWeb() registers the accessor that connects ICurrentUser (from the Syntrony package) to the
request's ClaimsPrincipal. Nothing to configure:
public class UserAppService(ICurrentUser currentUser) : ApplicationService
{
public Task<Guid?> GetMyId() => Task.FromResult(currentUser.Id);
}
The claims queried are CurrentUserClaimTypes's: NameIdentifier/sub for the id, Name/UserData
for the name. Register your own CurrentUserClaimTypes instance before AddSyntrony to change
them. To replace the accessor entirely, register your own ICurrentPrincipalAccessor after
AddSyntrony — the last registration wins.
Route format
api/services/{module}/{controller}/{action}
{module}—SyntronyWebOptions.ModuleName, default"app".{controller}— the type name withAppService/ApplicationServicestripped (UserAppService→User).{action}— the method name with theAsyncsuffix stripped by MVC's own convention.
HTTP verb convention
The verb is derived from the action name's prefix, case-insensitively, checked in this order:
| Prefix | Verb |
|---|---|
Get |
GET |
Put, Update |
PUT |
Delete, Remove |
DELETE |
Patch |
PATCH |
Post, Create, Insert, or anything else |
POST |
Set UseConventionalHttpVerbs = false to make every action POST regardless of its name.
Parameter binding
Primitive types (and their nullable form) are bound from the route/query string; everything else is
bound from the request body on verbs that have one (not GET/DELETE/TRACE/HEAD). Already-declared
bindings ([FromQuery], [FromForm], etc.) are never overridden.
"Primitive" here means string, decimal, DateTime, DateTimeOffset, TimeSpan, Guid,
DateOnly, TimeOnly, Uri, any CLR primitive (int, bool, ...), and the Nullable<T> form of
all of the above. enum is intentionally not included, matching the exact behavior being
reproduced. IFormFile, IFormFileCollection and CancellationToken are never bound from the body
even though they're complex types — extend the list via SyntronyWebOptions.BodyBindingIgnoredTypes.
An action with two parameters eligible for [FromBody] throws at startup, naming both — MVC only
allows one body parameter per action, and finding out from a confusing runtime error is worse than
finding out immediately when the app starts.
Model validation
Application services are MVC controllers without [ApiController], so MVC binds the request and evaluates
DataAnnotations but nothing rejects an invalid model: the action would run with a null or half-bound input.
UseWeb() rejects it first. Before the action runs, any request whose model is invalid answers 400 in the
standard Syntrony.Wrapper envelope:
{ "value": null, "error": "Email: The Email field is not a valid e-mail address.",
"statusCode": 400, "isSuccess": false, "isFailure": true }
DataAnnotations on your inputs are enforced with no further wiring:
public class CreateUserInput
{
public required string Name { get; set; }
[EmailAddress]
public string? Email { get; set; }
}
What gets rejected, and what the caller reads:
| Request | error |
|---|---|
A required property is absent from the JSON |
name: The name field is required. |
| A DataAnnotations rule fails | Email: The Email field is not a valid e-mail address. |
A query/route value of the wrong type (?id=x for a Guid) |
id: The value 'x' is not valid. |
| An empty body on an action that needs one | A non-empty request body is required. |
| Malformed JSON, or a value of the wrong JSON type | $.age: The value is invalid. |
Several errors are joined with |, ordered by property name. The text of the JSON deserializer is never forwarded — it names CLR types and
byte positions — so a deserialization failure always reads The value is invalid., except for the missing
required properties, which are listed by name.
- Order. The filter runs before the default-order filters of
Syntrony.UoWandSyntrony.Wrapper, so a rejected request never opens a unit of work. The one trade-off: it also runs before the Wrapper records the action, so a rejection on a[DontWrap]action is reported in the standard envelope instead of as raw text. - Scope. Only application services. A hand-written controller keeps MVC's behavior, and where
[ApiController]is present its own filter (RFC 7807) still goes first. - Business rules (
email already exists) are not model state; they stay in your service and throwUserFriendlyException. - Opt out with
UseWeb(web => web.ValidateModelState = false). The action then receives the model as MVC leaves it, exactly as before this option existed.
Every rejection is a UserFriendlyException, so it is logged exactly like any other one.
Audit log
Every application service call writes one log entry when it finishes — what ABP's AUDIT LOG used to write:
AUDIT LOG: Shop.Application.Orders.OrderAppService.CreateAsync is executed by user 3f2a9c1e-… in 142 ms from 10.42.0.17 IP address with succeed.
AUDIT LOG: Shop.Application.Orders.OrderAppService.GetAsync is executed by an anonymous user in 38 ms from 10.42.0.17 IP address with exception: Not enough permissions.
- Level.
Informationwhen the call succeeds,Warningwhen it ends in an exception. The exception itself (and its stack trace) is written once, by the Wrapper's exception handler; the audit entry only carries the message. - Category.
Syntrony.Web.Audit. Raise or lower it fromLogging:LogLevellike any other category. - Caller. The user id, or
an anonymous user. A token without an id or a user name (client credentials) isan authenticated caller. E-mail addresses and user names are not written. - Client IP.
Connection.RemoteIpAddress. Behind a reverse proxy or an ingress that is the proxy's address, unless the service usesForwardedHeadersmiddleware.X-Forwarded-Foris deliberately not read: anyone can send it. - Duration. Milliseconds around the rest of the filter pipeline and the action, so a request rejected by validation is logged too — as a warning carrying the validation message.
- What is never written: the input parameters and the returned value.
LoginandCreateUserreceive passwords;LoginandTokenreturn tokens. - Scope. Only application services. A hand-written controller is not logged.
- Opt out for the whole service with
UseWeb(web => web.AuditLog = false), or for a service or a single method with[DisableAuditLog]:
public class HealthAppService : ApplicationService
{
[DisableAuditLog] // polled every few seconds; the entries would only add noise
public Task<string> PingAsync() => Task.FromResult("pong");
}
Opting out: [RemoteService]
// Still registered in DI, still callable by other app services — just not exposed over HTTP.
[RemoteService(false)]
public class TokenIssuerAppService : ApplicationService { ... }
public class UserAppService : ApplicationService
{
public Task<UserOutput> GetAsync(Guid id) => ...;
// Exposed over HTTP, but hidden from Swagger/ApiExplorer.
[RemoteService(IsMetadataEnabled = false)]
public Task RebuildIndexAsync() => ...;
}
IsEnabled and IsMetadataEnabled are independent: the first controls whether the endpoint exists
at all, the second only whether it shows up in API metadata.
A type can't be both an application service and a hand-written controller
A type implementing IApplicationService that also inherits ControllerBase is ambiguous — MVC's
default controller discovery and this package's would both claim it. It throws at startup, naming
the type, instead of producing duplicate routes.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Syntrony (>= 1.2.0 && < 2.0.0)
- Syntrony.Logging (>= 1.0.1 && < 2.0.0)
- Syntrony.Wrapper (>= 1.0.1 && < 2.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.