Skip to content

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.WebApiCore Cross-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 FullName URL 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.WebApi A tiny controller that exposes registered service connections:

    • GET /api/services/ids – list of connectionIds available via ServiceLocator
    • GET /api/services/types – map of connectionId -> 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 set DataDirectory as shown. Otherwise string.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 from Environment.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.Any requires Values (array).
  • All other operators require Value.
  • Types are parsed from strings: int, double, DateTime, bool, LogLevel.*, or leave as string.

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:

  1. Validates input with Guard.Against.
  2. Creates a Filter (type + optional connection + optional conditions).
  3. Adds the current transport connection to a SignalR group named by the filter’s ID.
  4. 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/ids Returns all registered connection IDs (keys in ServiceLocator). Example:
["docs-file", "docs-cloud", "jobs-json", "timeseries-pg"]
  • GET /api/services/types Returns { 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 connectionId for 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) so ServiceLocator is 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. Call AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(env.ContentRootPath, "App_Data"));

  • “Could not parse query operator …” The QueryOperator in QueryConditionDTO must be one of the enum names: Equal, GreaterThan, Any, Contains, etc. Any requires Values; others require Value.

  • IDs with slashes breaking routes Use FullNameString.ToUrl() when generating links and FromUrl() in controller to decode.

  • SignalR not authenticating If you pass the token via query string (access_token) for WebSockets, set OnMessageReceived as 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 your connections.json is reachable. If lazyCreation: true, the service is registered on first use; ids will 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 via string.Resolve() keep paths and secrets out of source code.
  • QueryDTO<T> lets clients POST structured filter conditions that translate directly to domain Query<T> expressions.
  • FullNameString encodes/decodes slash-separated IDs (group/name) for safe use in URL path segments.
  • NotificationHub is a ready-made SignalR hub that manages per-client filters and broadcasts server-side events to grouped clients.
  • AllowAnonymousHandler bypasses authorization for local testing — never enable in production.

Next: TimeSeries