DHI Web API Core & Web API – Developer Guide¶
This guide shows how to use DHI.Services.WebApiCore and DHI.Services.WebApi in your own ASP.NET Core apps. It assumes you consume the NuGet packages. You’ll learn what each piece does, how to wire it up, and how the DTOs/utilities fit into controllers, hubs, and middleware.
Package roles (what they do)¶
-
DHI.Services.WebApiCoreCross-cutting building blocks for REST/SignalR endpoints:- Exception -> HTTP mapping middleware (+ logging variant)
- Authorization helper (dev bypass handler)
- Query/Filter DTOs -> Domain
Query<T>translation - Helpers for
FullNameURL encoding/decoding - Utilities for configuration and connection strings (
[AppData],[env:VAR]) - SignalR NotificationHub for server -> client push (filters & groups)
- RSA key helper for JWT signing
-
DHI.Services.WebApiA tiny controller that exposes registered service connections:GET /api/services/ids– list ofconnectionIds available viaServiceLocatorGET /api/services/types– map ofconnectionId-> concrete service type This is very useful for frontends to know which connectionId to pass when calling domain-specific APIs.
Quick start: minimal Program.cs¶
using DHI.Services;
using DHI.Services.WebApiCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IO;
var builder = WebApplication.CreateBuilder(args);
// 1) Set [AppData] base if you plan to use .Resolve()
AppDomain.CurrentDomain.SetData("DataDirectory",
Path.Combine(builder.Environment.ContentRootPath, "App_Data"));
// 2) Add MVC + API versioning + Swagger
builder.Services.AddControllers();
builder.Services.AddApiVersioning();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// 3) SignalR (for NotificationHub)
builder.Services.AddSignalR();
// 4) AuthN/AuthZ (example with JWT)
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
// configure Issuer, Audience, and SigningKey appropriately
};
o.Events = new JwtBearerEvents
{
// Optional: SignalR support for access_token in query
OnMessageReceived = ctx =>
{
var accessToken = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub/notifications"))
ctx.Token = accessToken;
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// 5) (Optional) DEV ONLY – allow all authorized requirements to succeed
// builder.Services.AddSingleton<IAuthorizationHandler, AllowAnonymousHandler>();
// 6) Domain connections (example)
// Services.Configure(new ConnectionRepository("[AppData]connections.json".Resolve()), lazyCreation: true);
var app = builder.Build();
// 7) Pipeline: DHI exception handling (choose one)
app.UseExceptionHandlingWithLogging();
// app.UseExceptionHandling();
app.UseSwagger();
app.UseSwaggerUI();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); // Includes ServicesController
app.MapHub<NotificationHub>("/hub/notifications"); // SignalR hub
app.Run();
Heads-up: If you use
[AppData]or[env:VAR]in connection strings, you must setDataDirectoryas shown. Otherwisestring.Resolve()will throw with a helpful message.
Exception handling middleware¶
Two middlewares convert exceptions into consistent JSON error responses:
app.UseExceptionHandling()Minimal, returns{ "error": "<.ToString() of exception>" }with HTTP status chosen by exception type.app.UseExceptionHandlingWithLogging()Same mapping + logs the exception. Logging behavior reads optional config:
{
"Logger": {
"Verbose": false,
"LogLevel": "Warning" // Any Microsoft.Extensions.Logging.LogLevel value
}
}
Status code mapping¶
| Exception type | HTTP status |
|---|---|
KeyNotFoundException, ArgumentOutOfRangeException |
404 Not Found |
ArgumentException |
400 Bad Request |
NotImplementedException, NotSupportedException |
501 Not Implemented |
| Any other | 500 Internal Server Error |
Connection string resolver: [AppData] & [env:VAR]¶
string Resolve(this string connectionString) replaces:
"[env:MY_VAR]"-> value fromEnvironment.GetEnvironmentVariable("MY_VAR")"[AppData]"->AppDomain.CurrentDomain.GetData("DataDirectory") + Path.DirectorySeparatorChar
Example:
"ConnectionString": "[AppData]documents"
var physicalPath = configuration["ConnectionString"].Resolve();
If DataDirectory is not set, Resolve() throws with setup instructions.
Query utilities (DTOs ↔ domain queries)¶
When designing controllers, you can let clients post structured filter objects that become domain Query<T> instances. The DTOs live in WebApiCore:
DTOs¶
public class QueryConditionDTO
{
public string Item { get; set; } // Property name on T
public string QueryOperator { get; set; } // Enum name (e.g., "Equal", "Any", "GreaterThan")
public string Value { get; set; } // Single value
public IEnumerable<string> Values { get; set; } // For "Any"
}
public class QueryDTO<T> : List<QueryConditionDTO>
{
public Query<T> ToQuery() { ... }
}
public class QueryDTO : List<QueryConditionDTO>
{
public IEnumerable<QueryCondition> ToQueryConditions() { ... }
}
QueryOperator.AnyrequiresValues(array).- All other operators require
Value. - Types are parsed from strings:
int,double,DateTime,bool,LogLevel.*, or leave asstring.
Example request body:
{
"conditions": [
{ "Item": "Status", "QueryOperator": "Equal", "Value": "Completed" },
{ "Item": "Requested", "QueryOperator": "GreaterThanOrEqual", "Value": "2024-06-01" },
{ "Item": "Tag", "QueryOperator": "Any", "Values": ["report", "export"] }
]
}
If you use QueryDTO<T>, you typically serialize as a bare array (the DTO inherits List<>). In a controller action you can accept QueryDTO<MyType> directly.
Query string -> Query<T>¶
For simple filter-by-URL scenarios:
[HttpGet("search")]
public IActionResult Search()
{
// /api/foo/search?Status=Completed&Requested=2024-06-01
Query<MyDto> query = Request.Query.ToQuery<MyDto>();
...
}
Each query string key/value turns into a QueryCondition(Item, Equal, value). Types auto-convert as above.
Encoding FullName in route segments¶
IDs that contain slashes (group/name) are awkward in route parameters. Use FullNameString helpers to safely pass IDs in a URL segment:
- Encode when writing URLs:
FullNameString.ToUrl("group/name")->"group|name"(and escapes|as||). - Decode when reading route values:
FullNameString.FromUrl(routeValue).
Example
GET /api/docs/{id}
where id = FullNameString.ToUrl(documentId)
[HttpGet("{id}")]
public IActionResult GetById(string id)
{
var fullName = FullNameString.FromUrl(id); // "group/name"
...
}
SignalR NotificationHub (server -> client notifications)¶
NotificationHub (in WebApiCore) is an [Authorize] SignalR hub that manages filters per client connection. Clients call Hub methods to register filters; the hub groups clients by a generated filterId. Your server code can later broadcast to those groups.
Hub methods¶
Task AddJobFilter(string dataConnectionId, QueryDTO queryDTO)
Task AddJsonDocumentFilter(string dataConnectionId, QueryDTO queryDTO)
Task AddTimeSeriesFilter(string dataConnectionId, QueryDTO queryDTO)
Task AddUserGroupFilter(QueryDTO queryDTO)
dataConnectionId(optional for user group): which domain service connection the filter targets (e.g., a specific repository).queryDTO: optional filter conditions.
Behind the scenes the hub:
- Validates input with
Guard.Against. - Creates a
Filter(type + optional connection + optional conditions). - Adds the current transport connection to a SignalR group named by the filter’s ID.
- On disconnect, automatically cleans up; when the last transport leaves a filter, the filter is deleted.
The hub relies on
IFilterRepository/FilterService(shipped in the broader DHI Services stack). Make sure you’ve wired a provider for filters in DI, the same way you wire other repositories.
Server broadcasting (example)¶
Elsewhere in your server (when a job changes), you can send to all clients that registered a compatible job filter:
public class JobNotifier
{
private readonly IHubContext<NotificationHub> _hub;
public JobNotifier(IHubContext<NotificationHub> hub) { _hub = hub; }
public Task NotifyJobUpdated(string filterId, object payload)
=> _hub.Clients.Group(filterId).SendAsync("jobUpdated", payload);
}
The mapping from “filters” to “filterId” is managed by FilterService. Your domain service (e.g., Jobs) should publish the right message to the filter’s group.
JS client snippet¶
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hub/notifications", { accessTokenFactory: () => authToken })
.withAutomaticReconnect()
.build();
await connection.start();
// Example: subscribe to completed jobs in connection "jobs-json"
await connection.invoke("AddJobFilter", "jobs-json", [
{ item: "Status", queryOperator: "Equal", value: "Completed" }
]);
connection.on("jobUpdated", payload => {
console.log("Job updated:", payload);
});
Permissions DTO¶
PermissionDTO mirrors the domain Permission (principals + operation + type). Controllers can accept/return this DTO and convert:
var dto = new PermissionDTO(permission);
var permission = dto.ToPermission(); // Type string is parsed to enum
RSA helper (JWT signing)¶
If you store RSA keys in XML form, RSA.BuildSigningKey(xml) returns a RsaSecurityKey suitable for TokenValidationParameters or for issuing JWTs.
var signingKey = DHI.Services.WebApiCore.RSA.BuildSigningKey(xmlString);
Services catalog endpoints (DHI.Services.WebApi)¶
Route base: /api/services (v1), [Authorize].
GET /api/services/idsReturns all registered connection IDs (keys inServiceLocator). Example:
["docs-file", "docs-cloud", "jobs-json", "timeseries-pg"]
GET /api/services/typesReturns{ connectionId: "Concrete.Type, Assembly" }. Example:
{
"docs-file": "DHI.Services.Documents.GroupedDocumentService`1[System.String]",
"jobs-json": "DHI.Services.Jobs.JobService"
}
These endpoints are primarily for front-ends and ops:
- Pick the right
connectionIdfor subsequent API calls. - Show friendly names (you can join with your own metadata).
Make sure your application registers connections at startup (for example by reading
connections.json) soServiceLocatoris populated:
Services.Configure(new ConnectionRepository("[AppData]connections.json".Resolve()), lazyCreation: true);
// or eagerly resolve by iterating Connections.GetAll()
Controller patterns (using WebApiCore helpers)¶
Here’s a minimal, realistic controller that:
- Accepts a full name in the route using
|encoding, - Uses
QueryDTO<T>in POST to filter data, - Relies on exception middleware for consistent errors.
using DHI.Services.WebApiCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/mydocs")]
[Authorize]
public class MyDocsController : ControllerBase
{
private readonly GroupedDocumentService<string> _docs;
public MyDocsController(GroupedDocumentService<string> docs)
{
_docs = docs;
}
// GET api/mydocs/{id}
[HttpGet("{id}")]
public IActionResult Get(string id)
{
var fullName = FullNameString.FromUrl(id); // group/name
var (stream, fileType, fileName) = _docs.Get(fullName, User);
return File(stream, "application/octet-stream", fileName);
}
// POST api/mydocs/search
[HttpPost("search")]
public IActionResult Search([FromBody] QueryDTO<Document<string>> queryDto)
{
var query = queryDto?.ToQuery();
var docs = query == null ? _docs.GetAll(User) : _docs.GetAll(User).Where(query.ToExpression().Compile());
return Ok(docs.Select(d => d.FullName));
}
}
No try/catch is needed; exceptions are handled by UseExceptionHandling*() and serialized with the right HTTP code.
Dev-only authorization bypass¶
AllowAnonymousHandler marks all requirements as succeeded. Registering it will effectively turn off policy enforcement. Use sparingly for local testing:
builder.Services.AddSingleton<IAuthorizationHandler, AllowAnonymousHandler>();
Do not enable this in production.
Configuration safety helper¶
configuration.GetMandatoryValue<T>("Some:Key")
Throws an ArgumentException if the key is absent. Use it at startup to fail fast with a clear message.
Troubleshooting & best practices¶
-
500 with “DataDirectory is not set” You’re using
[AppData]in a connection string. CallAppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(env.ContentRootPath, "App_Data")); -
“Could not parse query operator …” The
QueryOperatorinQueryConditionDTOmust be one of the enum names:Equal,GreaterThan,Any,Contains, etc.AnyrequiresValues; others requireValue. -
IDs with slashes breaking routes Use
FullNameString.ToUrl()when generating links andFromUrl()in controller to decode. -
SignalR not authenticating If you pass the token via query string (
access_token) for WebSockets, setOnMessageReceivedas shown in §2 so the JWT middleware picks it up for the hub path. -
Service list is empty Ensure you call
Services.Configure(...)at startup and yourconnections.jsonis reachable. IflazyCreation: true, the service is registered on first use;idswill still be present when the connection objects are loaded.
Summary¶
- Exception middleware (
UseExceptionHandling*) maps common exception types to the correct HTTP status codes — no try/catch in controllers needed. [AppData]/[env:VAR]placeholders viastring.Resolve()keep paths and secrets out of source code.QueryDTO<T>lets clients POST structured filter conditions that translate directly to domainQuery<T>expressions.FullNameStringencodes/decodes slash-separated IDs (group/name) for safe use in URL path segments.NotificationHubis a ready-made SignalR hub that manages per-client filters and broadcasts server-side events to grouped clients.AllowAnonymousHandlerbypasses authorization for local testing — never enable in production.
Next: TimeSeries