-
Notifications
You must be signed in to change notification settings - Fork 0
12 08 Customizing Endpoints
Practical guide for creating consumer endpoints using the per-domain .Endpoints packages and the thin closure pattern.
Each .Endpoints package provides generic base classes with open type parameters. Consumers create thin sealed classes that:
- Close the generic type parameters with concrete types
- Implement required abstract methods (typically mapping)
- Optionally override virtual hooks for logging or customization
<ItemGroup>
<PackageReference Include="FractalDataWorks.Services.Connections.Endpoints" />
<PackageReference Include="FractalDataWorks.Services.Data.Endpoints" />
<!-- Add only the domains you need -->
</ItemGroup>Minimal closure (no logic needed):
// ListConnectionsEndpointBase has no type parameters -- just inherit
public sealed class ListConnectionsEndpoint : ListConnectionsEndpointBase
{
public ListConnectionsEndpoint(
IOptionsMonitor<List<ConnectionConfiguration>> configurations)
: base(configurations) { }
}Closure with type parameter + abstract method implementation:
// GetConnectionEndpointBase<TConfig> requires a concrete config type
public sealed class GetConnectionEndpoint : GetConnectionEndpointBase<MsSqlConnectionConfiguration>
{
public GetConnectionEndpoint(IOptionsMonitor<List<MsSqlConnectionConfiguration>> configurations)
: base(configurations) { }
// Implement the abstract mapping for your concrete config type
protected override ConnectionDetailDto MapToDetail(MsSqlConnectionConfiguration config)
{
return new ConnectionDetailDto
{
Id = config.Id,
Name = config.Name,
ServiceType = config.ServiceOptionType ?? "MsSql",
Server = config.Server,
Port = config.Port,
Database = config.Database,
AuthenticationType = config.Authentication.Type,
// ... implementation-specific fields
};
}
}Closure with create/update logic:
public sealed class CreateConnectionEndpoint : CreateConnectionEndpointBase<MsSqlConnectionConfiguration>
{
public CreateConnectionEndpoint(
IOptionsMonitor<List<MsSqlConnectionConfiguration>> configurations,
IConfigurationWriterFactory writerFactory)
: base(configurations, writerFactory) { }
protected override MsSqlConnectionConfiguration CreateConfiguration(
CreateConnectionRequest request, Guid connectionId)
{
return new MsSqlConnectionConfiguration
{
Id = connectionId,
Name = request.Name,
Server = request.Server,
Port = request.Port,
Database = request.Database,
// ... map request to concrete config
};
}
protected override ConnectionDetailDto MapToDetail(
MsSqlConnectionConfiguration savedConfig,
CreateConnectionRequest request, Guid connectionId) { ... }
}Each domain follows the same pattern. Create one sealed class per endpoint operation. Each endpoint gets its own file:
Reference.Api/Endpoints/
├── ConnectionsEndpoint.cs ← List + Get + Create + Update + Delete closures
├── CreateDataStoreEndpoint.cs ← Create DataStore closure
├── DeleteDataStoreEndpoint.cs ← Delete DataStore closure
├── GetDataStoreEndpoint.cs ← Get DataStore closure
├── ListDataStoresEndpoint.cs ← List DataStores closure
├── UpdateDataStoreEndpoint.cs ← Update DataStore closure
├── CreateUserEndpoint.cs ← Create User closure
├── GetUserEndpoint.cs ← Get User closure
├── ListUsersEndpoint.cs ← List Users closure
└── ... ← ~70 endpoint files total
Override the virtual On*() hooks to add domain-specific structured logging:
public sealed class GetConnectionEndpoint : GetConnectionEndpointBase<MsSqlConnectionConfiguration>
{
private readonly ILogger<GetConnectionEndpoint> _logger;
public GetConnectionEndpoint(
IOptionsMonitor<List<MsSqlConnectionConfiguration>> configurations,
ILogger<GetConnectionEndpoint> logger) : base(configurations)
{
_logger = logger;
}
protected override void OnBeforeGet(string identifier)
=> ConnectionLog.GettingConnection(_logger, identifier);
protected override void OnNotFound(string identifier)
=> ConnectionLog.ConnectionNotFound(_logger, identifier);
protected override ConnectionDetailDto MapToDetail(MsSqlConnectionConfiguration config) { ... }
}The base classes use RESTful verbs. Override Configure() to change routes:
public sealed class GetConnectionEndpoint : GetConnectionEndpointBase<MsSqlConnectionConfiguration>
{
public override void Configure()
{
Post("/connections/get"); // POST instead of GET
#if DEVELOP
AllowAnonymous();
#else
Policies("fdw:connections:read");
#endif
}
// ...
}When overriding Configure(), you must re-apply the authorization guard because the base Configure() is fully replaced.
Not all endpoints fit the CRUD pattern. Custom actions inherit directly from FastEndpoints Endpoint<TRequest, TResponse>.
From TestConnectionEndpoint.cs:
public class TestConnectionEndpoint : Endpoint<TestConnectionRequest, TestConnectionResponse>
{
private readonly IConnectionProvider _connectionProvider;
private readonly ILogger<TestConnectionEndpoint> _logger;
public TestConnectionEndpoint(
IConnectionProvider connectionProvider,
ILogger<TestConnectionEndpoint> logger)
{
_connectionProvider = connectionProvider;
_logger = logger;
}
public override void Configure()
{
Post("/connections/{Name}/test");
#if DEVELOP
AllowAnonymous();
#else
Policies("fdw:connections:read");
#endif
Summary(s =>
{
s.Summary = "Test a connection";
s.Description = "Tests connectivity to a configured connection by name.";
});
}
public override async Task HandleAsync(TestConnectionRequest req, CancellationToken ct)
{
var result = _connectionProvider.Get(req.Name);
if (!result.IsSuccess)
{
await Send.OkAsync(new TestConnectionResponse
{
Name = req.Name,
Success = false,
Message = result.CurrentMessage ?? "Unknown error"
}, ct);
return;
}
await Send.OkAsync(new TestConnectionResponse
{
Name = req.Name,
Success = true,
Message = "Connection successful"
}, ct);
}
}| Domain | Action | Route | Verb |
|---|---|---|---|
| Connections | Test connectivity | /connections/{Name}/test |
POST |
| DataStores | Introspect schema | /datastores/{Name}/introspect |
POST |
| Executions | Trigger workflow | /executions/trigger |
POST |
| Executions | Cancel execution | /executions/{Id}/cancel |
POST |
| Executions | Pause execution | /executions/{Id}/pause |
POST |
| Executions | Resume execution | /executions/{Id}/resume |
POST |
| Schedules | Toggle schedule | /schedules/{Name}/toggle |
POST |
| Themes | Set default | /themes/{Name}/default |
POST |
In your consumer project (e.g., Reference.Api/Logging/):
public static partial class ConnectionLog
{
[MessageLogging(EventId = 5200, Level = LogLevel.Information,
Message = "Getting connection '{name}'")]
public static partial IGenericMessage GettingConnection(ILogger logger, string name);
[MessageLogging(EventId = 5201, Level = LogLevel.Warning,
Message = "Connection '{name}' not found")]
public static partial IGenericMessage ConnectionNotFound(ILogger logger, string name);
}protected override void OnBeforeGet(string identifier)
=> ConnectionLog.GettingConnection(_logger, identifier);
protected override void OnNotFound(string identifier)
=> ConnectionLog.ConnectionNotFound(_logger, identifier);While developing alongside FractalDataWorks:
<ItemGroup>
<ProjectReference Include="..\..\src\FractalDataWorks.Services.Connections.Endpoints\..." />
<ProjectReference Include="..\..\src\FractalDataWorks.Services.Data.Endpoints\..." />
</ItemGroup>When FractalDataWorks packages are published to NuGet:
<ItemGroup>
<PackageReference Include="FractalDataWorks.Services.Connections.Endpoints" />
<PackageReference Include="FractalDataWorks.Services.Data.Endpoints" />
<!-- Each package transitively includes Web.RestEndpoints and Web.Endpoints -->
</ItemGroup>If you only want the CRUD base classes without any domain-specific logic:
<ItemGroup>
<PackageReference Include="FractalDataWorks.Web.RestEndpoints" />
<PackageReference Include="FractalDataWorks.Web.Endpoints" />
</ItemGroup>- Add
PackageReferencefor each domain endpoint package you need - For each domain, create thin sealed closures inheriting the
*EndpointBaseclasses - Close generic type parameters with your concrete configuration types
- Implement abstract mapping methods (
MapToDetail(),CreateConfiguration(), etc.) - Optionally override
On*()hooks for domain-specific logging - Optionally override
Configure()for custom routes or authorization
The EtlServer and SchedulerServer use standalone FastEndpoints that inject domain services directly, without the three-tier CRUD base classes. This pattern is appropriate for internal service endpoints with domain-specific behavior.
From Reference.Etl.Server/Endpoints/TriggerJobEndpoint.cs:
public sealed class TriggerJobEndpoint : Endpoint<TriggerJobRequest, TriggerJobResponse>
{
private readonly IJobExecutionService _jobService;
public TriggerJobEndpoint(IJobExecutionService jobService)
{
_jobService = jobService;
}
public override void Configure()
{
Post("etl/trigger");
AllowAnonymous(); // Protected by InternalApiKeyMiddleware instead
Summary(s =>
{
s.Summary = "Trigger an ETL job";
s.Description = "Triggers a pipeline execution and returns the execution ID.";
});
}
public override async Task HandleAsync(TriggerJobRequest req, CancellationToken ct)
{
var result = await _jobService.TriggerJob(
req.PipelineName, req.TriggerSource ?? "Api", req.ScheduleName, ct)
.ConfigureAwait(false);
if (!result.IsSuccess)
{
AddError(result.CurrentMessage ?? "Failed to trigger job");
await Send.ErrorsAsync(400, ct).ConfigureAwait(false);
return;
}
await Send.ResponseAsync(new TriggerJobResponse
{
ExecutionId = result.Value,
Status = "Triggered"
}, 202, ct).ConfigureAwait(false);
}
}Key differences from consumer CRUD endpoints:
| Aspect | Consumer CRUD Endpoints | Internal Server Endpoints |
|---|---|---|
| Base class |
CrudListEndpoint<T>, CrudGetEndpoint<TReq,T>, etc. |
Endpoint<TRequest, TResponse> directly |
| Auth |
Policies("fdw:resource:action") or AllowAnonymous() in DEVELOP |
AllowAnonymous() (protected by InternalApiKeyMiddleware) |
| Route prefix |
api/v1 (consumer) |
api/v1 (same prefix, configured in UseFastEndpoints) |
| Dependencies |
IOptionsMonitor<T>, IDataGateway
|
Domain services (IJobExecutionService, ISchedulerService) |
| Response | Standardized CRUD responses | Domain-specific response types |
All three servers (ApiSolution, EtlServer, SchedulerServer) configure FastEndpoints with the same api/v1 prefix:
app.UseFastEndpoints(config =>
{
config.Endpoints.RoutePrefix = "api/v1";
});Endpoint routes in Configure() are relative to this prefix. For example, Post("etl/trigger") becomes POST /api/v1/etl/trigger.
- API Endpoints - Architecture overview
- Authorization - RBAC policy system
- MessageLogging Attribute - Creating log methods
- Logger Classes - Organizing log methods into classes
- ResultCodes - Structured error codes for validation