Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit ed0d91a

Browse files
Split fetch bboxes when area contains too much data
1 parent 7aafd17 commit ed0d91a

16 files changed

Lines changed: 283 additions & 101 deletions

File tree

Alidade.Core/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
global using Alidade.Core.Consts;
2+
global using Alidade.Core.Models;
23
global using Alidade.Core.Enums;
34
global using Alidade.Core.Models.CQRS.Request;
45
global using Alidade.Core.Models.CQRS.Response;

Alidade.Core/Models/Bbox.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using NetTopologySuite.Geometries;
2+
3+
namespace Alidade.Core.Models;
4+
5+
/// <summary>
6+
/// An axis-aligned geographic bounding box defined by two corner coordinates.
7+
/// </summary>
8+
/// <param name="NorthWest">The north-west corner (max latitude, min longitude).</param>
9+
/// <param name="SouthEast">The south-east corner (min latitude, max longitude).</param>
10+
public record Bbox(Coordinate NorthWest, Coordinate SouthEast);

Alidade.Core/ServiceInterface/IOsmEditingService.cs

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,56 +10,75 @@ public interface IOsmEditingService
1010
/// Fetches the OSM XML for all elements within the specified bounding box as a stream.
1111
/// The caller is responsible for disposing the returned stream.
1212
/// </summary>
13-
/// <param name="west">Western longitude bound.</param>
14-
/// <param name="south">Southern latitude bound.</param>
15-
/// <param name="east">Eastern longitude bound.</param>
16-
/// <param name="north">Northern latitude bound.</param>
17-
/// <param name="ct">Optional cancellation token.</param>
13+
/// <param name="bbox">The geographic bounding box to fetch.</param>
14+
/// <param name="cancellationToken">Cancellation token.</param>
1815
/// <returns>A stream over the OSM XML response body.</returns>
19-
public Task<Stream> FetchBboxAsync(double west, double south, double east, double north,
20-
CancellationToken ct = default);
16+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
17+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
18+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
19+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
20+
public Task<Stream> FetchBboxAsync(Bbox bbox, CancellationToken cancellationToken);
2121

2222
/// <summary>
2323
/// Creates a new OSM changeset with the given tags and returns the assigned changeset ID.
2424
/// </summary>
2525
/// <param name="tags">The changeset tags (e.g. <c>comment</c>, <c>created_by</c>).</param>
26-
/// <param name="ct">Optional cancellation token.</param>
26+
/// <param name="cancellationToken">Cancellation token.</param>
2727
/// <returns>The numeric changeset ID assigned by the OSM API.</returns>
28-
public Task<int> CreateChangesetAsync(Dictionary<string, string> tags,
29-
CancellationToken ct = default);
28+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
29+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
30+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
31+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
32+
/// <exception cref="FormatException">The response body could not be parsed as a changeset ID integer.</exception>
33+
public Task<int> CreateChangesetAsync(Dictionary<string, string> tags, CancellationToken cancellationToken);
3034

3135
/// <summary>
3236
/// Uploads a pre-built osmChange XML document to an open changeset and returns the raw
3337
/// diffResult XML string.
3438
/// </summary>
3539
/// <param name="changesetId">The open changeset to upload to.</param>
3640
/// <param name="osmChangeXml">The osmChange XML body to POST.</param>
37-
/// <param name="ct">Optional cancellation token.</param>
41+
/// <param name="cancellationToken">Cancellation token.</param>
3842
/// <returns>The raw diffResult XML string returned by the OSM API.</returns>
39-
public Task<string> UploadChangesetAsync(int changesetId, string osmChangeXml,
40-
CancellationToken ct = default);
43+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
44+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
45+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
46+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
47+
public Task<string> UploadChangesetAsync(int changesetId, string osmChangeXml, CancellationToken cancellationToken);
4148

4249
/// <summary>
4350
/// Closes an open changeset, preventing further uploads to it.
4451
/// </summary>
4552
/// <param name="changesetId">The changeset ID to close.</param>
46-
/// <param name="ct">Optional cancellation token.</param>
47-
public Task CloseChangesetAsync(int changesetId, CancellationToken ct = default);
53+
/// <param name="cancellationToken">Cancellation token.</param>
54+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
55+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
56+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
57+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
58+
public Task CloseChangesetAsync(int changesetId, CancellationToken cancellationToken);
4859

4960
/// <summary>
5061
/// Fetches the raw OSM XML for a single node from <c>GET /api/0.6/node/{id}</c>.
5162
/// </summary>
5263
/// <param name="nodeId">The node ID to fetch.</param>
53-
/// <param name="ct">Optional cancellation token.</param>
64+
/// <param name="cancellationToken">Cancellation token.</param>
5465
/// <returns>The raw OSM XML string returned by the API.</returns>
55-
public Task<string> FetchNodeAsync(long nodeId, CancellationToken ct = default);
66+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
67+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
68+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
69+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
70+
public Task<string> FetchNodeAsync(long nodeId, CancellationToken cancellationToken);
5671

5772
/// <summary>
5873
/// Fetches the raw OSM XML for a way and all its constituent nodes from
5974
/// <c>GET /api/0.6/way/{id}/full</c>.
6075
/// </summary>
6176
/// <param name="wayId">The way ID to fetch.</param>
62-
/// <param name="ct">Optional cancellation token.</param>
77+
/// <param name="cancellationToken">Cancellation token.</param>
6378
/// <returns>The raw OSM XML string returned by the API.</returns>
64-
public Task<string> FetchWayFullAsync(long wayId, CancellationToken ct = default);
79+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
80+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
81+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
82+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
83+
public Task<string> FetchWayFullAsync(long wayId, CancellationToken cancellationToken);
6584
}

Alidade.Core/ServiceInterface/IOsmNotesService.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ public interface IOsmNotesService
1010
/// Fetches the GeoJSON for notes within the specified bounding box as a stream.
1111
/// The caller is responsible for disposing the returned stream.
1212
/// </summary>
13-
/// <param name="west">Western longitude bound.</param>
14-
/// <param name="south">Southern latitude bound.</param>
15-
/// <param name="east">Eastern longitude bound.</param>
16-
/// <param name="north">Northern latitude bound.</param>
17-
/// <param name="ct">Optional cancellation token.</param>
13+
/// <param name="bbox">The geographic bounding box to fetch.</param>
14+
/// <param name="cancellationToken">Cancellation token.</param>
1815
/// <returns>A stream over the GeoJSON response body.</returns>
19-
public Task<Stream> FetchNotesAsync(double west, double south, double east, double north,
20-
CancellationToken ct = default);
16+
/// <exception cref="OperationCanceledException">The request was cancelled via <paramref name="cancellationToken"/>.</exception>
17+
/// <exception cref="ArgumentNullException">The resolved API base URL is null.</exception>
18+
/// <exception cref="HttpRequestException">The HTTP request failed or the server returned a non-success status code.</exception>
19+
/// <exception cref="UriFormatException">The resolved API base URL is not a valid URI.</exception>
20+
public Task<Stream> FetchNotesAsync(Bbox bbox, CancellationToken cancellationToken);
2121
}

Alidade.Osm/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
global using System.Collections.Immutable;
22
global using Alidade.Core.Enums;
3+
global using Alidade.Core.Models;
34
global using Alidade.Core.Models.CQRS;
45
global using Alidade.Core.Models.CQRS.Response;
56
global using Alidade.Core.ServiceInterface;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Net;
2+
using System.Net.Http;
3+
using NetTopologySuite.Geometries;
4+
using Alidade.Osm.Handlers.Parsing;
5+
using Alidade.Osm.Models.Editing;
6+
using Alidade.Osm.Models.Parsing;
7+
8+
namespace Alidade.Osm.Handlers.Api.Editing;
9+
10+
/// <inheritdoc />
11+
public sealed class FetchBbox(IOsmEditingService osm, ISender sender)
12+
: IRequestHandler<FetchBbox.Query, QueryResult<FetchBboxResult>>
13+
{
14+
/// <summary>
15+
/// Fetches OSM nodes, ways, and relations for the given bounding box,
16+
/// splitting into sub-requests if the OSM API rejects the area as too large.
17+
/// </summary>
18+
/// <param name="Bbox">The geographic bounding box to fetch.</param>
19+
public record Query(Bbox Bbox) : IRequest<QueryResult<FetchBboxResult>>;
20+
21+
private const int MaxSplitDepth = 3;
22+
23+
/// <inheritdoc />
24+
public Task<QueryResult<FetchBboxResult>> Handle(Query request, CancellationToken cancellationToken)
25+
=> FetchWithSplitAsync(request.Bbox, depth: 0, cancellationToken);
26+
27+
private async Task<QueryResult<FetchBboxResult>> FetchWithSplitAsync(
28+
Bbox bbox, int depth, CancellationToken cancellationToken)
29+
{
30+
Stream? stream = await FetchBboxStreamAsync(bbox, cancellationToken);
31+
32+
if (stream is null)
33+
{
34+
if (depth >= MaxSplitDepth)
35+
{
36+
return QueryResult<FetchBboxResult>.Fail("OSM bbox area too large to fetch.");
37+
}
38+
39+
double west = bbox.NorthWest.X;
40+
double north = bbox.NorthWest.Y;
41+
double east = bbox.SouthEast.X;
42+
double south = bbox.SouthEast.Y;
43+
bool splitOnLongitude = (east - west) >= (north - south);
44+
45+
Bbox half1 = splitOnLongitude
46+
? new Bbox(bbox.NorthWest, new Coordinate((west + east) / 2, south))
47+
: new Bbox(bbox.NorthWest, new Coordinate(east, (south + north) / 2));
48+
49+
Bbox half2 = splitOnLongitude
50+
? new Bbox(new Coordinate((west + east) / 2, north), bbox.SouthEast)
51+
: new Bbox(new Coordinate(west, (south + north) / 2), bbox.SouthEast);
52+
53+
Task<QueryResult<FetchBboxResult>> t1 = FetchWithSplitAsync(half1, depth + 1, cancellationToken);
54+
Task<QueryResult<FetchBboxResult>> t2 = FetchWithSplitAsync(half2, depth + 1, cancellationToken);
55+
await Task.WhenAll(t1, t2);
56+
57+
QueryResult<FetchBboxResult> r1 = t1.Result;
58+
QueryResult<FetchBboxResult> r2 = t2.Result;
59+
60+
if (!r1.Success || r1.Result is null || !r2.Success || r2.Result is null)
61+
{
62+
return QueryResult<FetchBboxResult>.Fail(r1.FailReason ?? r2.FailReason);
63+
}
64+
65+
return new FetchBboxResult(
66+
[.. r1.Result.Nodes, .. r2.Result.Nodes],
67+
[.. r1.Result.Ways, .. r2.Result.Ways],
68+
[.. r1.Result.Relations, .. r2.Result.Relations]);
69+
}
70+
71+
await using Stream s = stream;
72+
QueryResult<ParseOsmXmlResult> parseResult = await sender.Send(new ParseOsmXml.Query(s), cancellationToken);
73+
if (!parseResult.Success || parseResult.Result is null)
74+
{
75+
return QueryResult<FetchBboxResult>.Fail(parseResult.FailReason);
76+
}
77+
return new FetchBboxResult(parseResult.Result.Nodes, parseResult.Result.Ways, parseResult.Result.Relations);
78+
}
79+
80+
// Returns null when the OSM API returns HTTP 400 (area or node limit exceeded).
81+
private async Task<Stream?> FetchBboxStreamAsync(Bbox bbox, CancellationToken cancellationToken)
82+
{
83+
try
84+
{
85+
return await osm.FetchBboxAsync(bbox, cancellationToken);
86+
}
87+
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.BadRequest)
88+
{
89+
return null;
90+
}
91+
}
92+
}

Alidade.Osm/Handlers/Editing/FetchBbox.cs

Lines changed: 0 additions & 33 deletions
This file was deleted.

Alidade.Osm/Handlers/Editing/FetchNotes.cs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,13 @@ public class FetchNotes(IOsmNotesService osmNotes, ISender sender) : IRequestHan
88
/// <summary>
99
/// Fetches OSM notes for the given bounding box.
1010
/// </summary>
11-
/// <param name="West">Western longitude bound.</param>
12-
/// <param name="South">Southern latitude bound.</param>
13-
/// <param name="East">Eastern longitude bound.</param>
14-
/// <param name="North">Northern latitude bound.</param>
15-
public record Query(double West, double South, double East, double North)
16-
: IRequest<QueryResult<OsmNote[]>>;
11+
/// <param name="Bbox">The geographic bounding box to fetch.</param>
12+
public record Query(Bbox Bbox) : IRequest<QueryResult<OsmNote[]>>;
1713

1814
/// <inheritdoc />
1915
public async Task<QueryResult<OsmNote[]>> Handle(Query request, CancellationToken cancellationToken)
2016
{
21-
await using Stream stream = await osmNotes.FetchNotesAsync(
22-
request.West, request.South, request.East, request.North, cancellationToken);
17+
await using Stream stream = await osmNotes.FetchNotesAsync(request.Bbox, cancellationToken);
2318

2419
QueryResult<IList<OsmNote>> parseResult =
2520
await sender.Send(new ParseNotesJson.Query(stream), cancellationToken);

Alidade.Osm/Services/OsmEditingService.cs

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,31 @@ internal sealed class OsmEditingService(HttpClient http, IOsmApiContext context)
2020
#region Bounding box
2121

2222
/// <inheritdoc />
23-
public Task<Stream> FetchBboxAsync(double west, double south, double east, double north,
24-
CancellationToken ct = default)
23+
public Task<Stream> FetchBboxAsync(Bbox bbox, CancellationToken cancellationToken)
2524
{
26-
string url = $"{ApiBase}/map?bbox={west:F7},{south:F7},{east:F7},{north:F7}";
27-
return http.GetStreamAsync(url, ct);
25+
string url = $"{ApiBase}/map?bbox={bbox.NorthWest.X:F7},{bbox.SouthEast.Y:F7},{bbox.SouthEast.X:F7},{bbox.NorthWest.Y:F7}";
26+
return http.GetStreamAsync(url, cancellationToken);
2827
}
2928

3029
#endregion
3130

3231
#region Single element fetch
3332

3433
/// <inheritdoc />
35-
public Task<string> FetchNodeAsync(long nodeId, CancellationToken ct = default)
36-
=> http.GetStringAsync($"{ApiBase}/node/{nodeId}", ct);
34+
public Task<string> FetchNodeAsync(long nodeId, CancellationToken cancellationToken)
35+
=> http.GetStringAsync($"{ApiBase}/node/{nodeId}", cancellationToken);
3736

3837
/// <inheritdoc />
39-
public Task<string> FetchWayFullAsync(long wayId, CancellationToken ct = default)
40-
=> http.GetStringAsync($"{ApiBase}/way/{wayId}/full", ct);
38+
public Task<string> FetchWayFullAsync(long wayId, CancellationToken cancellationToken)
39+
=> http.GetStringAsync($"{ApiBase}/way/{wayId}/full", cancellationToken);
4140

4241
#endregion
4342

4443
#region Changesets
4544

4645
/// <inheritdoc />
4746
public async Task<int> CreateChangesetAsync(Dictionary<string, string> tags,
48-
CancellationToken ct = default)
47+
CancellationToken cancellationToken)
4948
{
5049
XElement body = new("osm",
5150
new XElement("changeset",
@@ -58,33 +57,33 @@ public async Task<int> CreateChangesetAsync(Dictionary<string, string> tags,
5857
Content = new StringContent(body.ToString(), Encoding.UTF8, "application/xml")
5958
};
6059
await InjectAuthHeaderAsync(req);
61-
HttpResponseMessage resp = await http.SendAsync(req, ct);
60+
HttpResponseMessage resp = await http.SendAsync(req, cancellationToken);
6261
resp.EnsureSuccessStatusCode();
63-
return int.Parse(await resp.Content.ReadAsStringAsync(ct));
62+
return int.Parse(await resp.Content.ReadAsStringAsync(cancellationToken));
6463
}
6564

6665
/// <inheritdoc />
6766
public async Task<string> UploadChangesetAsync(int changesetId, string osmChangeXml,
68-
CancellationToken ct = default)
67+
CancellationToken cancellationToken)
6968
{
7069
HttpRequestMessage req = new(HttpMethod.Post,
7170
$"{ApiBase}/changeset/{changesetId}/upload")
7271
{
7372
Content = new StringContent(osmChangeXml, Encoding.UTF8, "application/xml")
7473
};
7574
await InjectAuthHeaderAsync(req);
76-
HttpResponseMessage resp = await http.SendAsync(req, ct);
75+
HttpResponseMessage resp = await http.SendAsync(req, cancellationToken);
7776
resp.EnsureSuccessStatusCode();
78-
return await resp.Content.ReadAsStringAsync(ct);
77+
return await resp.Content.ReadAsStringAsync(cancellationToken);
7978
}
8079

8180
/// <inheritdoc />
82-
public async Task CloseChangesetAsync(int changesetId, CancellationToken ct = default)
81+
public async Task CloseChangesetAsync(int changesetId, CancellationToken cancellationToken)
8382
{
8483
HttpRequestMessage req = new(HttpMethod.Put,
8584
$"{ApiBase}/changeset/{changesetId}/close");
8685
await InjectAuthHeaderAsync(req);
87-
HttpResponseMessage resp = await http.SendAsync(req, ct);
86+
HttpResponseMessage resp = await http.SendAsync(req, cancellationToken);
8887
resp.EnsureSuccessStatusCode();
8988
}
9089

0 commit comments

Comments
 (0)