DHI.Services.Meshes.WebApi – Internal Guide¶
1) What this API does¶
The Meshes Web API is a read-only HTTP facade over DHI.Services.Meshes. It lets clients:
- Discover available meshes and metadata (
MeshInfo) - Query time series at a point (for one item or all items)
- Compute spatial aggregations (whole mesh or within polygons), optionally grouped by a temporal period
- Get instantaneous aggregated values at a specific timestamp
- Generate contour lines (as GeoJSON features) for a specific timestamp or hourly over a time range
This layer is storage-agnostic. Which repository/provider is used is decided by the {connectionId} part of the route and the service registration in the host (see §6).
2) High-level architecture¶
HTTP Controller (MeshesController)
│
▼
Services.Get<IMeshService>(connectionId)
│ (or IGroupedMeshService for grouped operations)
▼
MeshService / GroupedMeshService<TId>
│
▼
IMeshRepository<TId> (provider-specific)
- Authentication:
[Authorize]on the controller (JWT Bearer). - Versioning:
[ApiVersion("1")]→ all routes are v1 of this controller. - Serialization: custom
JsonSerializerOptionswith Mesh/Spatial/TimeSeries converters (SerializerOptionsDefault). - Swagger/OpenAPI: configured in the host with annotations on actions.
3) Routing, shapes & examples¶
Base path¶
/api/meshes/{connectionId}
{connectionId} selects a registered mesh service (e.g., "dfsu" in the host example). See §6 for registering more.
IDs & Groups using “FullName” Many endpoints accept mesh
idorgroupthat represent a FullName (Group/Name). Use standard URL encoding (e.g.,Coastal%2FWestBay) — the controller usesFullNameString.FromUrl(...)to decode.
Conventions¶
- Times are UTC; pass ISO-8601 (
YYYY-MM-DDTHH:mm:ssZ) unless you’re sure the client attachesZ. - GeoJSON bodies:
Point→{"type":"Point","coordinates":[x,y]}Polygon→{"type":"Polygon","coordinates":[ [[x1,y1],[x2,y2],...,[x1,y1]] ]}(first ring only shown; use RFC 7946 format)
- AggregationType path segment: one of
Minimum,Maximum,Average,Sum(case-insensitive display names parsed viaEnumeration.FromDisplayName<AggregationType>). - Period path segment: any supported
Periodvalue (e.g.,Hour,Day,Month,Year).
3.1 Catalog & discovery¶
GET /api/meshes/{connectionId}¶
- Optional
groupquery string (?group=Project%2FA). - Returns
IEnumerable<MeshInfo>(group-aware entries withid,name,group,items[],dateRange, optionalprojection,boundingBox).
Example
GET /api/meshes/dfsu?group=Scenarios%2FRiverMouth HTTP/1.1
Authorization: Bearer <JWT>
Accept: application/json
GET /api/meshes/{connectionId}/count¶
Returns the number of meshes.
GET /api/meshes/{connectionId}/{id}¶
Returns one MeshInfo. id may be a “FullName”, e.g. Scenarios%2FRiverMouth%2Fmesh-001.
GET /api/meshes/{connectionId}/ids¶
Returns IEnumerable<string> of mesh IDs.
GET /api/meshes/{connectionId}/fullnames¶
Optional group query. These are convenience strings "{group}/{name}".
Only meaningful for grouped providers; it’s fine to call on non-grouped providers (you’ll just get
name).
3.2 Availability¶
GET /api/meshes/{connectionId}/{id}/datetimes¶
Returns IEnumerable<DateTime> available for queries (sorted). Use this to validate timestamps for contours/instant aggregates.
3.3 Values at a point¶
POST /api/meshes/{connectionId}/{id}/{item}/values¶
Body: GeoJSON Point. Query: optional from, to.
- Returns
ITimeSeriesData<double>for oneitemandPointin aDateRange(from,to).
Example
POST /api/meshes/dfsu/Scenarios%2FRiverMouth%2Fmesh-001/WaterLevel/values?from=2025-05-01T00:00:00Z&to=2025-05-02T00:00:00Z
Content-Type: application/json
Authorization: Bearer <JWT>
{"type":"Point","coordinates":[12.345,55.678]}
Response (shape via TimeSeriesDataConverter<double>, camelCase)
[
{"time":"2025-05-01T00:00:00Z","value":0.23},
{"time":"2025-05-01T01:00:00Z","value":0.25}
]
POST /api/meshes/{connectionId}/{id}/values¶
Body: GeoJSON Point. Query: optional from, to.
- Returns
Dictionary<string, ITimeSeriesData<double>>keyed byitemfor all items at the point.
{
"WaterLevel":[{"time":"...","value":0.23}, ...],
"Salinity":[{"time":"...","value":28.1}, ...]
}
3.4 Spatial aggregation over a range¶
GET /api/meshes/{connectionId}/{id}/{item}/{aggregation}¶
Query: optional from, to.
Returns aggregated time series over the whole mesh.
POST /api/meshes/{connectionId}/{id}/{item}/{aggregation}¶
Body: GeoJSON Polygon. Query: optional from, to.
Returns aggregated time series within polygon.
Grouped by period
- Whole mesh:
GET /api/meshes/{connectionId}/{id}/{item}/{aggregation}/period/{period}?from=...&to=... - Within polygon:
POST /api/meshes/{connectionId}/{id}/{item}/{aggregation}/period/{period}?from=...&to=...(GeoJSONPolygonin body)
Response shape is ITimeSeriesData<double> as in §3.3.
3.5 Instantaneous aggregation¶
GET /api/meshes/{connectionId}/{id}/{item}/{aggregation}/{dateTime}¶
Returns a single aggregated value (whole mesh) at the timestamp. May be null if no data exactly at that instant.
POST /api/meshes/{connectionId}/{id}/{item}/{aggregation}/{dateTime}¶
Body: GeoJSON Polygon.
Returns a single aggregated value within polygon.
Note: The controller action returns ActionResult<double> but the underlying service returns double?. If you expect null (no data) in your provider, prefer changing the signature to ActionResult<double?> in the controller (see §7).
3.6 Contours¶
POST /api/meshes/{connectionId}/contours/{id}/{item}/{dateTime}¶
Body: double[] threshold values.
- Returns a GeoJSON-like
IFeatureCollectionwith contour polygons. Geometry/feature serialization is handled by the custom converters so it looks like standard GeoJSON:
{
"type": "FeatureCollection",
"features": [
{
"type":"Feature",
"geometry":{"type":"Polygon","coordinates":[[ [x,y], ... ]]},
"properties":{"value": 0.5}
}
]
}
POST /api/meshes/{connectionId}/contours/{id}/{item}?from=...&to=...¶
Body: double[] threshold values.
Returns a list of IFeatureCollection — one per hour between from and to (inclusive).
Time stepping is hourly. If you need a different cadence, adjust the range enumeration in this action.
4) Error handling & status codes¶
- The controller is decorated with
ProducesResponseType(404)on endpoints that may raise “not found”. Internally, services throwKeyNotFoundExceptionwhen a mesh id does not exist and the global exception handler (app.UseExceptionHandling()) maps this to404. - Invalid arguments (e.g., missing body for POST, invalid GeoJSON, unknown aggregation token) will bubble up to the exception handler; how these are mapped depends on
WebApiCore’s middleware. Aim to return400for client errors; throwArgumentException/ArgumentNullExceptionin services/providers to help mapping. - Authentication failures yield
401; missing/invalid bearer token, or403if user not allowed depending on repository authorization.
Developer tip: If your provider can’t produce a value at an instant, return Maybe.Empty<double>() from the repository. The service will convert that to null safely.
5) Serialization (JSON)¶
SerializerOptionsDefault.Options configures:
DefaultIgnoreCondition = WhenWritingNullPropertyNamingPolicy = CamelCasePropertyNameCaseInsensitive = trueReferenceHandler = IgnoreCyclesJsonNumberHandling = AllowReadingFromString- Converters added:
- Spatial:
PositionConverter,GeometryConverter,FeatureCollectionConverter - Time series:
TimeSeriesDataConverter<double> - Utilities:
ObjectToInferredTypeConverter,DateRangeConverter
- Spatial:
These are injected in Startup:
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.DefaultIgnoreCondition = SerializerOptionsDefault.Options.DefaultIgnoreCondition;
options.JsonSerializerOptions.WriteIndented = true;
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
options.JsonSerializerOptions.AddConverters(SerializerOptionsDefault.Options.Converters);
});
Implication for clients: time series serialize as arrays of { time, value }, and features serialize as GeoJSON, using camelCase everywhere.
6) Hosting & wiring providers¶
The Host project shows the minimal wiring:
// 1) JWT Bearer
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ... }; });
// 2) Controllers + JSON (see §5)
// 3) Swagger (security scheme "Bearer" added)
// 4) Pipeline
app.UseAuthentication();
app.UseSwagger();
app.UseSwaggerUI(...);
app.UseExceptionHandling();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => endpoints.MapControllers());
// 5) Register a mesh service under a connection id
var meshRepository = new DfsuMeshRepository(new FileSource("[AppData]\\dfsu".Resolve()));
var meshService = new GroupedMeshService(meshRepository);
ServiceLocator.Register(meshService, "dfsu"); // ← connectionId
Adding another source (e.g., NetCDF):
var ncRepo = new NetCdfMeshRepository(new FileSource("[AppData]\\netcdf".Resolve()));
var ncService = new MeshService(ncRepo); // or GroupedMeshService if grouped
ServiceLocator.Register(ncService, "netcdf");
Consumers then hit /api/meshes/netcdf/....
JWT settings expected in configuration:
"Tokens": {
"Issuer": "dhigroup.com",
"Audience": "dhigroup.com",
"PublicRSAKey": "[env:PublicRSAKey]"
},
7) Notes¶
7.1 Concurrent dictionary bug in GetContoursInRange¶
This action fills results from a Parallel.ForEach but uses a plain Dictionary<long, IFeatureCollection>, which is not thread-safe.
Fix:
var results = new ConcurrentDictionary<long, IFeatureCollection>();
Parallel.ForEach(
Partitioner.Create(dates, EnumerablePartitionerOptions.NoBuffering),
kvp => results.TryAdd(kvp.Key, meshService.GetContours(id, item, thresholdValues, kvp.Value, user))
);
var returnObj = results.OrderBy(x => x.Key).Select(x => x.Value).ToList();
7.2 Instant aggregate return type¶
If providers may return null for “no value at this instant”, the controller signatures should be ActionResult<double?>:
[HttpGet("{id}/{item}/{aggregation}/{dateTime}")]
public ActionResult<double?> GetAggregatedValue(...) =>
Ok(meshService.GetAggregatedValue(id, aggregationType, item, dateTime, user));
[HttpPost("{id}/{item}/{aggregation}/{dateTime}")]
public ActionResult<double?> GetAggregatedValueWithinPolygon(...) =>
Ok(meshService.GetAggregatedValue(id, aggregationType, item, polygon, dateTime, user));
7.3 Consistent UTC¶
Services and repositories generally operate in UTC. Encourage clients to pass Z or convert to UTC server-side before calling repositories.
7.4 Aggregation tokens¶
Enumeration.FromDisplayName<AggregationType>(aggregation) will throw if the string doesn’t match a known display name. If you anticipate typos, add a guard translating common aliases (e.g., avg, max, min, sum) before calling it.
7.5 Group endpoints on non-grouped repos¶
GetFullNames and the group query parameter assume a grouped provider. We already call the grouped interface explicitly (Services.Get<IGroupedMeshService<string>>), so for non-grouped providers don’t register an IGroupedMeshService (or register a thin adapter that returns just Name as full name).
7.6 Large results¶
- Time series over long ranges or many items can be big. Consider adding paging or a
maxPointsdownsampling query in the future. - Contours over long hourly ranges can be heavy. For bulk generation, prefer a streaming endpoint or server-side archiving.
8) End-to-end examples¶
All examples assume you registered
"dfsu"and are holding a valid JWT.
8.1 List meshes in a group¶
curl -H "Authorization: Bearer $TOKEN" \
"https://localhost:5001/api/meshes/dfsu?group=Scenarios%2FRiverMouth"
8.2 Inspect one mesh¶
curl -H "Authorization: Bearer $TOKEN" \
"https://localhost:5001/api/meshes/dfsu/Scenarios%2FRiverMouth%2Fmesh-001"
8.3 Values at a point for one item¶
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"https://localhost:5001/api/meshes/dfsu/Scenarios%2FRiverMouth%2Fmesh-001/WaterLevel/values?from=2025-05-01T00:00:00Z&to=2025-05-01T12:00:00Z" \
-d '{"type":"Point","coordinates":[12.345,55.678]}'
8.4 Average in a polygon, grouped monthly¶
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"https://localhost:5001/api/meshes/dfsu/mesh-001/WaterLevel/Average/period/Month?from=2025-01-01T00:00:00Z&to=2025-12-31T23:59:59Z" \
-d '{ "type":"Polygon", "coordinates":[ [ [12.0,55.0],[12.5,55.0],[12.5,55.5],[12.0,55.5],[12.0,55.0] ] ] }'
8.5 Instantaneous max over whole mesh¶
curl -H "Authorization: Bearer $TOKEN" \
"https://localhost:5001/api/meshes/dfsu/mesh-001/WaterLevel/Maximum/2025-05-15T06:00:00Z"
8.6 Contours at a timestamp¶
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"https://localhost:5001/api/meshes/dfsu/contours/mesh-001/WaterLevel/2025-05-15T06:00:00Z" \
-d '[ -1.0, 0.0, 1.0 ]'
8.7 Hourly contours in a range¶
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"https://localhost:5001/api/meshes/dfsu/contours/mesh-001/WaterLevel?from=2025-05-01T00:00:00Z&to=2025-05-01T05:00:00Z" \
-d '[ 0.2, 0.5, 0.8 ]'
9) Extending the API¶
- Add cadence parameter to
GetContoursInRange(e.g.,&stepHours=3) and change the date enumeration. - Add multi-polygon support for aggregations: accept a
FeatureCollectionof polygons in the body and return a dictionary of results keyed by feature id. - Add downsampling for time series (e.g.,
maxPoints+ LTTB algorithm server-side). - Add CRS transform: allow
crsquery to transform input point/polygon to mesh projection server-side usingProjNet.
10) Quick checklist when adding a new provider & exposing it¶
- Implement
IMeshRepository<TId>(orIGroupedMeshRepository<TId>) in your provider (DFS(U), NetCDF, DB, …). - Choose grouped or plain
MeshService<TId>and register it with a connection id:ServiceLocator.Register(new MeshService(myRepo), "my-connection"); - Hit the API with
/api/meshes/my-connection/.... - Validate:
/ids,/count, and one/{id}/{id}/datetimes- One point time series & one polygon aggregation
- One contour response
- If your provider may return “no value at instant”, update controller signatures to
ActionResult<double?>(§7.2). - (Optional) If grouped: wire
IGroupedMeshServiceand test/fullnamesand?group=queries.