Skip to content

Commit 1f689ed

Browse files
mikemcdougallMike McDougall
andauthored
feat: tilejson metadata endpoints and tests (#20) (#255)
* feat: tilejson metadata endpoints and tests include ongoing refactor adjustments and test fixes * chore: format code --------- Co-authored-by: Mike McDougall <[email protected]>
1 parent 758eddd commit 1f689ed

File tree

88 files changed

+3376
-1897
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+3376
-1897
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Current entrypoints:
2828
- `/ogc/tiles`
2929
- `/odata`
3030
- `/tiles/{layerId}/{z}/{x}/{y}.mvt`
31+
- `/tiles/{layerId}/tile.json`
32+
- `/api/styles/{layerId}.json`
3133
- `/openapi.json`
3234

3335
## Quick Start
@@ -61,14 +63,15 @@ Implemented (server + admin API):
6163
- OGC API Tiles: tilesets metadata + vector tiles.
6264
- OData v4: CRUD with spatial functions (`geo.distance`, `geo.intersects`); $batch/$apply/$search endpoints exist with limited coverage.
6365
- Vector tiles (MVT): PostGIS `ST_AsMVT` via `/tiles/{layerId}/{z}/{x}/{y}.mvt`.
66+
- TileJSON metadata: `/tiles/{layerId}/tile.json` with MapLibre style discovery.
67+
- Public MapLibre styles: `/api/styles/{layerId}.json`.
6468
- File import: GeoJSON, Shapefile, GeoPackage, CSV (lat/lon or WKT), KML/KMZ — no GDAL required.
6569
- CRS support: PostGIS-based reprojection, EPSG via `spatial_ref_sys`, auto-detect from source files.
6670
- Admin APIs: connections, services/layers/relationships/styles, import jobs, operations progress.
6771
- OIDC authentication (server-side plumbing) and optional Redis metadata cache.
6872
- .NET Aspire local dev orchestration with dashboard (traces, logs, metrics, health).
6973

7074
Pending MVP items (open issues):
71-
- TileJSON metadata endpoint (#20).
7275
- Service enable/disable controls (#58).
7376
- Admin UI (project setup, connections, publishing, health dashboard, map preview) (#25, #26, #27, #42, #43).
7477
- Embedded Maputnik style editor (#30).

docs/API_EXAMPLES.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,11 @@ curl "http://localhost:8080/rest/services/1/FeatureServer/0/tiles/10/163/395.pbf
389389

390390
```bash
391391
# Get TileJSON metadata for the layer
392-
curl "http://localhost:8080/rest/services/1/FeatureServer/0/tiles/metadata" \
392+
curl "http://localhost:8080/tiles/0/tile.json" \
393+
-H "Accept: application/json"
394+
395+
# Get MapLibre style JSON for the layer
396+
curl "http://localhost:8080/api/styles/0.json" \
393397
-H "Accept: application/json"
394398
```
395399

src/Honua.Core/Features/Import/Domain/EsriImportRequest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public sealed record EsriDiscoveryRequest
2727
/// </summary>
2828
public sealed record EsriImportRequest
2929
{
30+
/// <summary>
31+
/// Optional job identifier for progress tracking.
32+
/// </summary>
33+
public string? JobId { get; init; }
34+
3035
/// <summary>
3136
/// The base URL of the ArcGIS Server service.
3237
/// </summary>

src/Honua.Core/Features/Import/Domain/ImportRequest.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,14 @@ public sealed record ImportRequest
4949
/// <exception cref="InvalidOperationException">Thrown when neither FileStream nor CloudFileId is provided</exception>
5050
public void Validate()
5151
{
52-
if (FileStream == null && string.IsNullOrEmpty(CloudFileId))
52+
var cloudFileId = string.IsNullOrWhiteSpace(CloudFileId) ? null : CloudFileId;
53+
54+
if (FileStream == null && cloudFileId == null)
5355
{
5456
throw new InvalidOperationException("Either FileStream or CloudFileId must be provided.");
5557
}
5658

57-
if (FileStream != null && !string.IsNullOrEmpty(CloudFileId))
59+
if (FileStream != null && cloudFileId != null)
5860
{
5961
throw new InvalidOperationException("Only one of FileStream or CloudFileId should be provided, not both.");
6062
}
@@ -63,5 +65,5 @@ public void Validate()
6365
/// <summary>
6466
/// Gets a value indicating whether this request uses cloud storage
6567
/// </summary>
66-
public bool UsesCloudStorage => !string.IsNullOrEmpty(CloudFileId);
68+
public bool UsesCloudStorage => !string.IsNullOrWhiteSpace(CloudFileId);
6769
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
namespace Honua.Core.Features.Infrastructure.IO;
5+
6+
/// <summary>
7+
/// Stream wrapper that forwards operations to an inner stream.
8+
/// </summary>
9+
public abstract class DelegatingStream : Stream
10+
{
11+
/// <inheritdoc/>
12+
protected DelegatingStream(Stream inner)
13+
{
14+
Inner = inner ?? throw new ArgumentNullException(nameof(inner));
15+
}
16+
17+
/// <summary>
18+
/// The wrapped stream.
19+
/// </summary>
20+
protected Stream Inner { get; }
21+
22+
/// <inheritdoc/>
23+
public override bool CanRead => Inner.CanRead;
24+
25+
/// <inheritdoc/>
26+
public override bool CanSeek => Inner.CanSeek;
27+
28+
/// <inheritdoc/>
29+
public override bool CanWrite => Inner.CanWrite;
30+
31+
/// <inheritdoc/>
32+
public override long Length => Inner.Length;
33+
34+
/// <inheritdoc/>
35+
public override long Position
36+
{
37+
get => Inner.Position;
38+
set => Inner.Position = value;
39+
}
40+
41+
/// <inheritdoc/>
42+
public override void Flush() => Inner.Flush();
43+
44+
/// <inheritdoc/>
45+
public override Task FlushAsync(CancellationToken cancellationToken) => Inner.FlushAsync(cancellationToken);
46+
47+
/// <inheritdoc/>
48+
public override int Read(byte[] buffer, int offset, int count) => Inner.Read(buffer, offset, count);
49+
50+
/// <inheritdoc/>
51+
public override int Read(Span<byte> buffer) => Inner.Read(buffer);
52+
53+
/// <inheritdoc/>
54+
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
55+
Inner.ReadAsync(buffer, offset, count, cancellationToken);
56+
57+
/// <inheritdoc/>
58+
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
59+
Inner.ReadAsync(buffer, cancellationToken);
60+
61+
/// <inheritdoc/>
62+
public override long Seek(long offset, SeekOrigin origin) => Inner.Seek(offset, origin);
63+
64+
/// <inheritdoc/>
65+
public override void SetLength(long value) => Inner.SetLength(value);
66+
67+
/// <inheritdoc/>
68+
public override void Write(byte[] buffer, int offset, int count) => Inner.Write(buffer, offset, count);
69+
70+
/// <inheritdoc/>
71+
public override void Write(ReadOnlySpan<byte> buffer) => Inner.Write(buffer);
72+
73+
/// <inheritdoc/>
74+
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
75+
Inner.WriteAsync(buffer, offset, count, cancellationToken);
76+
77+
/// <inheritdoc/>
78+
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) =>
79+
Inner.WriteAsync(buffer, cancellationToken);
80+
}

src/Honua.Core/Features/Infrastructure/Memory/PooledGeometryProcessor.cs

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Honua. All rights reserved.
22
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
33

4+
using Honua.Core.Features.Infrastructure.IO;
45
using NetTopologySuite.Geometries;
56
using NetTopologySuite.IO;
67

@@ -113,40 +114,13 @@ public static byte[] WriteWkbWithPooling(NetTopologySuite.Geometries.Geometry ge
113114
return new WKBWriter().Write(geometry);
114115
}
115116

116-
private sealed class NonClosingStream : Stream
117+
private sealed class NonClosingStream : DelegatingStream
117118
{
118-
private readonly Stream _inner;
119-
120119
public NonClosingStream(Stream inner)
120+
: base(inner)
121121
{
122-
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
123-
}
124-
125-
public override bool CanRead => _inner.CanRead;
126-
public override bool CanSeek => _inner.CanSeek;
127-
public override bool CanWrite => _inner.CanWrite;
128-
public override long Length => _inner.Length;
129-
130-
public override long Position
131-
{
132-
get => _inner.Position;
133-
set => _inner.Position = value;
134122
}
135123

136-
public override void Flush() => _inner.Flush();
137-
138-
public override int Read(byte[] buffer, int offset, int count)
139-
=> _inner.Read(buffer, offset, count);
140-
141-
public override long Seek(long offset, SeekOrigin origin)
142-
=> _inner.Seek(offset, origin);
143-
144-
public override void SetLength(long value)
145-
=> _inner.SetLength(value);
146-
147-
public override void Write(byte[] buffer, int offset, int count)
148-
=> _inner.Write(buffer, offset, count);
149-
150124
protected override void Dispose(bool disposing)
151125
{
152126
// Suppress disposing the inner stream; base.Dispose is a no-op.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
using System.Text.Json;
5+
6+
namespace Honua.Core.Features.Shared.Models;
7+
8+
/// <summary>
9+
/// Helpers for converting <see cref="JsonElement"/> values into CLR objects.
10+
/// </summary>
11+
public static class JsonElementConverter
12+
{
13+
/// <summary>
14+
/// Converts a <see cref="JsonElement"/> into a CLR object, expanding arrays and objects recursively.
15+
/// </summary>
16+
public static object? ConvertToObject(JsonElement element)
17+
{
18+
return element.ValueKind switch
19+
{
20+
JsonValueKind.String => element.GetString(),
21+
JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal :
22+
element.TryGetDouble(out var doubleVal) ? doubleVal :
23+
element.GetDecimal(),
24+
JsonValueKind.True => true,
25+
JsonValueKind.False => false,
26+
JsonValueKind.Null => null,
27+
JsonValueKind.Object => element.EnumerateObject()
28+
.ToDictionary(prop => prop.Name, prop => ConvertToObject(prop.Value)),
29+
JsonValueKind.Array => element.EnumerateArray().Select(ConvertToObject).ToArray(),
30+
_ => element.GetRawText()
31+
};
32+
}
33+
34+
/// <summary>
35+
/// Converts a <see cref="JsonElement"/> into a CLR scalar when possible.
36+
/// Non-scalar values are returned as the original element.
37+
/// </summary>
38+
public static object? ConvertToScalar(JsonElement element)
39+
{
40+
return element.ValueKind switch
41+
{
42+
JsonValueKind.String => element.GetString(),
43+
JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal :
44+
element.TryGetDouble(out var doubleVal) ? doubleVal :
45+
element.GetDecimal(),
46+
JsonValueKind.True => true,
47+
JsonValueKind.False => false,
48+
JsonValueKind.Null => null,
49+
_ => element
50+
};
51+
}
52+
}

src/Honua.Core/Queries/Filters/Cql2/Cql2JsonParser.cs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ private FilterExpression ParseOperation(string opValue, JsonElement argsElement)
190190
}
191191

192192
var negate = normalizedLower.StartsWith("not", StringComparison.Ordinal);
193-
return BuildBetweenExpression(expressions[0], expressions[1], expressions[2], negate);
193+
return FilterExpressionHelpers.BuildBetweenExpression(expressions[0], expressions[1], expressions[2], negate);
194194
}
195195

196196
if (normalizedLower is "=" or "==" or "!=" or "<>" or "<" or "<=" or ">" or ">=")
@@ -354,22 +354,6 @@ private List<FilterExpression> ParseArguments(JsonElement argsElement)
354354
return expressions;
355355
}
356356

357-
private static BinaryExpression BuildBetweenExpression(FilterExpression target, FilterExpression lower, FilterExpression upper, bool negate)
358-
{
359-
var lowerExpr = new BinaryExpression(target, BinaryOperator.GreaterThanOrEqual, lower);
360-
var upperExpr = new BinaryExpression(target, BinaryOperator.LessThanOrEqual, upper);
361-
var combined = new BinaryExpression(lowerExpr, BinaryOperator.And, upperExpr);
362-
363-
if (!negate)
364-
{
365-
return combined;
366-
}
367-
368-
var lowerNot = new BinaryExpression(target, BinaryOperator.LessThan, lower);
369-
var upperNot = new BinaryExpression(target, BinaryOperator.GreaterThan, upper);
370-
return new BinaryExpression(lowerNot, BinaryOperator.Or, upperNot);
371-
}
372-
373357
private ArrayLiteral ParseArrayLiteral(JsonElement element)
374358
{
375359
var values = new List<FilterExpression>();

src/Honua.Core/Queries/Filters/Cql2/Cql2Parser.cs

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private FilterExpression ParseComparisonPredicate()
180180
var lower = ParseScalarExpression();
181181
Consume(Cql2TokenType.And, "Expected AND in BETWEEN predicate");
182182
var upper = ParseScalarExpression();
183-
return BuildBetweenExpression(left, lower, upper, negate: true);
183+
return FilterExpressionHelpers.BuildBetweenExpression(left, lower, upper, negate: true);
184184
}
185185

186186
// BETWEEN
@@ -189,7 +189,7 @@ private FilterExpression ParseComparisonPredicate()
189189
var lower = ParseScalarExpression();
190190
Consume(Cql2TokenType.And, "Expected AND in BETWEEN predicate");
191191
var upper = ParseScalarExpression();
192-
return BuildBetweenExpression(left, lower, upper, negate: false);
192+
return FilterExpressionHelpers.BuildBetweenExpression(left, lower, upper, negate: false);
193193
}
194194

195195
// NOT IN
@@ -237,22 +237,6 @@ private ValueList ParseInList()
237237
return new ValueList(values);
238238
}
239239

240-
private BinaryExpression BuildBetweenExpression(FilterExpression target, FilterExpression lower, FilterExpression upper, bool negate)
241-
{
242-
var lowerExpr = new BinaryExpression(target, BinaryOperator.GreaterThanOrEqual, lower);
243-
var upperExpr = new BinaryExpression(target, BinaryOperator.LessThanOrEqual, upper);
244-
var combined = new BinaryExpression(lowerExpr, BinaryOperator.And, upperExpr);
245-
246-
if (!negate)
247-
{
248-
return combined;
249-
}
250-
251-
var lowerNot = new BinaryExpression(target, BinaryOperator.LessThan, lower);
252-
var upperNot = new BinaryExpression(target, BinaryOperator.GreaterThan, upper);
253-
return new BinaryExpression(lowerNot, BinaryOperator.Or, upperNot);
254-
}
255-
256240
private FilterExpression ParseScalarExpression()
257241
{
258242
if (IsTemporalLiteralStart())
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Honua. All rights reserved.
2+
// Licensed under the Elastic License 2.0. See LICENSE in the project root.
3+
4+
namespace Honua.Core.Queries.Filters;
5+
6+
internal static class FilterExpressionHelpers
7+
{
8+
internal static BinaryExpression BuildBetweenExpression(
9+
FilterExpression target,
10+
FilterExpression lower,
11+
FilterExpression upper,
12+
bool negate)
13+
{
14+
var lowerExpr = new BinaryExpression(target, BinaryOperator.GreaterThanOrEqual, lower);
15+
var upperExpr = new BinaryExpression(target, BinaryOperator.LessThanOrEqual, upper);
16+
var combined = new BinaryExpression(lowerExpr, BinaryOperator.And, upperExpr);
17+
18+
if (!negate)
19+
{
20+
return combined;
21+
}
22+
23+
var lowerNot = new BinaryExpression(target, BinaryOperator.LessThan, lower);
24+
var upperNot = new BinaryExpression(target, BinaryOperator.GreaterThan, upper);
25+
return new BinaryExpression(lowerNot, BinaryOperator.Or, upperNot);
26+
}
27+
}

0 commit comments

Comments
 (0)