Skip to content

Commit 9d5c172

Browse files
authored
Add Valhalla offline files download support. (#2635)
1 parent 14a2765 commit 9d5c172

7 files changed

Lines changed: 134 additions & 22 deletions

File tree

IsraelHiking.API/Controllers/FilesController.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,20 @@ private async Task<DataContainerPoco> ConvertToDataContainer(byte[] data, string
113113
/// <param name="lastModified">The last time this tile was downloaded</param>
114114
/// <param name="tileX">The tile's X coordinates, null for root</param>
115115
/// <param name="tileY">The tile's Y coordinates, null for root</param>
116+
/// <param name="routingTile">Whether to also list the offline routing file of the tile</param>
116117
/// <returns></returns>
117118
[HttpGet]
118119
[Route("offline")]
119120
[Authorize]
120-
public async Task<IActionResult> GetOfflineFiles([FromQuery] DateTime lastModified, [FromQuery] long? tileX, [FromQuery] long? tileY)
121+
public async Task<IActionResult> GetOfflineFiles([FromQuery] DateTime lastModified, [FromQuery] long? tileX, [FromQuery] long? tileY, [FromQuery] bool routingTile = false)
121122
{
122123
if (!await _receiptValidationGateway.IsEntitled(User.Identity?.Name))
123124
{
124125
_logger.LogInformation($"Unable to get the list of offline files for user: {User.Identity?.Name} since the user is not entitled, date: {lastModified}");
125126
return Forbid();
126127
}
127128
_logger.LogInformation($"Getting the list of offline files for user: {User.Identity?.Name}, date: {lastModified}");
128-
return Ok(await _offlineFilesService.GetUpdatedFilesList(lastModified, tileX, tileY));
129+
return Ok(await _offlineFilesService.GetUpdatedFilesList(lastModified, tileX, tileY, routingTile));
129130
}
130131

131132
/// <summary>

IsraelHiking.API/Services/IOfflineFilesService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public interface IOfflineFilesService
2525
/// <param name="lastModifiedDate">The date to check against</param>
2626
/// <param name="tileX">The tile's X coordinates, null for root</param>
2727
/// <param name="tileY">The tile's Y coordinates, null for root</param>
28+
/// <param name="routingTile">Whether to also list the offline routing file of the tile</param>
2829
/// <returns>A list of file names</returns>
29-
Task<Dictionary<string, DateTime>> GetUpdatedFilesList(DateTime lastModifiedDate, long? tileX, long? tileY);
30+
Task<Dictionary<string, DateTime>> GetUpdatedFilesList(DateTime lastModifiedDate, long? tileX, long? tileY, bool routingTile);
3031
}

IsraelHiking.API/Services/OfflineFilesService.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ public class OfflineFilesService : IOfflineFilesService
5858
/// <inheritdoc cref="DEM_FILE_NAME"/>
5959
private const string DEM_ALIAS_FILE_NAME = "raster-dem";
6060

61+
/// <summary>
62+
/// The offline routing (valhalla) tiles of a slice, a tar file that is sliced on the fly like the other
63+
/// non-DEM files. It is not a part of the style, and it is only listed for clients that ask for it since
64+
/// older clients do not know how to handle it.
65+
/// </summary>
66+
private const string VALHALLA_FILE_NAME = "valhalla";
67+
68+
/// <inheritdoc cref="VALHALLA_FILE_NAME"/>
69+
private const string VALHALLA_FILE_EXTENSION = ".tar";
70+
6171
/// <summary>
6272
/// The fixed last modified date reported for the DEM, bumped whenever the underlying data changes.
6373
/// </summary>
@@ -100,7 +110,7 @@ public OfflineFilesService(IFileSystemHelper fileSystemHelper,
100110
/// The on-the-fly files are generated on demand, so they are always reported with today's date,
101111
/// while the DEM file has a fixed date and only exists at the tile level.
102112
/// </remarks>
103-
public async Task<Dictionary<string, DateTime>> GetUpdatedFilesList(DateTime lastModifiedDate, long? tileX, long? tileY)
113+
public async Task<Dictionary<string, DateTime>> GetUpdatedFilesList(DateTime lastModifiedDate, long? tileX, long? tileY, bool routingTile)
104114
{
105115
var filesDictionary = new Dictionary<string, DateTime>();
106116
var today = DateTime.UtcNow.Date;
@@ -117,16 +127,25 @@ public async Task<Dictionary<string, DateTime>> GetUpdatedFilesList(DateTime las
117127
AddIfUpdated(filesDictionary, SourceNameToFileName(name, tileX, tileY), DEM_MODIFIED_DATE, lastModifiedDate);
118128
}
119129
}
130+
if (routingTile && tileX.HasValue && tileY.HasValue)
131+
{
132+
AddIfUpdated(filesDictionary, SourceNameToFileName(VALHALLA_FILE_NAME, tileX, tileY, VALHALLA_FILE_EXTENSION), today, lastModifiedDate);
133+
}
120134
return filesDictionary;
121135
}
122136

123137
/// <inheritdoc/>
124138
/// <remarks>
125139
/// The DEM might be requested by its alias name, but it is stored on disk under its original name.
140+
/// The routing tiles are served by their own service, everything else by the on-the-fly one.
126141
/// </remarks>
127142
public async Task<(Stream Content, long? Length)> GetFileContent(string fileName, long? tileX, long? tileY)
128143
{
129144
var sourceName = FileNameToSourceName(fileName);
145+
if (sourceName == VALHALLA_FILE_NAME)
146+
{
147+
return await _remoteFileFetcherGateway.GetFileStream(_options.RoutingTilesAddress + fileName);
148+
}
130149
if (!IsDem(sourceName))
131150
{
132151
return await _remoteFileFetcherGateway.GetFileStream(_options.OnTheFlyFilesAddress + fileName);
@@ -244,10 +263,10 @@ private static string FileNameToSourceName(string fileName)
244263
return lastDashIndex >= 0 ? name[..lastDashIndex] : name;
245264
}
246265

247-
private static string SourceNameToFileName(string sourceName, long? tileX, long? tileY)
266+
private static string SourceNameToFileName(string sourceName, long? tileX, long? tileY, string extension = ".pmtiles")
248267
{
249268
return tileX.HasValue && tileY.HasValue
250-
? $"{sourceName}+{SLICE_TILE_ZOOM}-{tileX}-{tileY}.pmtiles"
251-
: $"{sourceName}-{ROOT_ZOOM}.pmtiles";
269+
? $"{sourceName}+{SLICE_TILE_ZOOM}-{tileX}-{tileY}{extension}"
270+
: $"{sourceName}-{ROOT_ZOOM}{extension}";
252271
}
253272
}

IsraelHiking.Common/Configuration/ConfigurationData.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ public class ConfigurationData
114114
/// </summary>
115115
public string OnTheFlyFilesAddress { get; set; }
116116
/// <summary>
117+
/// The base address of the service that slices and serves the offline routing (valhalla) tiles
118+
/// </summary>
119+
public string RoutingTilesAddress { get; set; }
120+
/// <summary>
117121
/// OSM server base address
118122
/// </summary>
119123
public string OsmBaseAddress { get; set; }
@@ -159,6 +163,7 @@ public ConfigurationData()
159163
ImageCreatorServerAddress = "http://localhost:11223/";
160164
OfflineFilesFolder = "./";
161165
OnTheFlyFilesAddress = "https://mapeak.com/serve-extract/";
166+
RoutingTilesAddress = "https://mapeak.com/routing-tiles/";
162167
OsmBaseAddress = "https://www.openstreetmap.org";
163168
ShareUrlApiAddress = "https://israelhiking.osm.org.il/api/urls/";
164169
ImageUrlsAllowList =

IsraelHiking.Web/appsettings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"offlineFilesFolder": "/tmp/offline",
33
"onTheFlyFilesAddress": "https://mapeak.com/serve-extract/",
4+
"routingTilesAddress": "https://mapeak.com/routing-tiles/",
45
"logging": {
56
"LogLevel": {
67
"Default": "Debug",

Tests/IsraelHiking.API.Tests/Controllers/FilesControllerTests.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ public void GetOfflineFiles_ShouldGetTheList()
183183
{
184184
_controller.SetupIdentity();
185185
var dict = new Dictionary<string, DateTime>();
186-
_offlineFilesService.GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2)
186+
_offlineFilesService.GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2, Arg.Any<bool>())
187187
.Returns(dict);
188188
_receiptValidationGateway.IsEntitled(Arg.Any<string>()).Returns(true);
189189

@@ -195,6 +195,32 @@ public void GetOfflineFiles_ShouldGetTheList()
195195
Assert.AreEqual(dict.Count, resultDict.Count);
196196
}
197197

198+
[TestMethod]
199+
public void GetOfflineFiles_OldClient_ShouldNotAskForTheRoutingTile()
200+
{
201+
_controller.SetupIdentity();
202+
_offlineFilesService.GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2, Arg.Any<bool>())
203+
.Returns(new Dictionary<string, DateTime>());
204+
_receiptValidationGateway.IsEntitled(Arg.Any<string>()).Returns(true);
205+
206+
_ = _controller.GetOfflineFiles(DateTime.Now, 1, 2).Result;
207+
208+
_offlineFilesService.Received(1).GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2, false);
209+
}
210+
211+
[TestMethod]
212+
public void GetOfflineFiles_ClientAsksForTheRoutingTile_ShouldPassItOn()
213+
{
214+
_controller.SetupIdentity();
215+
_offlineFilesService.GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2, Arg.Any<bool>())
216+
.Returns(new Dictionary<string, DateTime>());
217+
_receiptValidationGateway.IsEntitled(Arg.Any<string>()).Returns(true);
218+
219+
_ = _controller.GetOfflineFiles(DateTime.Now, 1, 2, true).Result;
220+
221+
_offlineFilesService.Received(1).GetUpdatedFilesList(Arg.Any<DateTime>(), 1, 2, true);
222+
}
223+
198224
[TestMethod]
199225
public void GetOfflineFile_NotEntitled_ShouldNotGetIt()
200226
{

Tests/IsraelHiking.API.Tests/Services/OfflineFilesServiceTests.cs

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.IO;
3+
using System.Linq;
34
using System.Text;
45
using System.Threading.Tasks;
56
using IsraelHiking.API.Services;
@@ -18,6 +19,7 @@ namespace IsraelHiking.API.Tests.Services;
1819
public class OfflineFilesServiceTests
1920
{
2021
private const string OnTheFlyAddress = "https://mapeak.com/serve-extract/";
22+
private const string RoutingTilesAddress = "https://mapeak.com/routing-tiles/";
2123
private const string StyleAddress = "https://raw.githubusercontent.com/IsraelHikingMap/VectorMap/master/Styles/mapeak-hike.json";
2224
private const string Style = """
2325
{
@@ -58,7 +60,11 @@ public void TestInitialize()
5860
_fileSystemHelper.CreateFileProvider(Arg.Any<string>()).Returns(_fileProvider);
5961
SetupStyleResponse(Style);
6062
var options = Substitute.For<IOptions<ConfigurationData>>();
61-
options.Value.Returns(new ConfigurationData { OnTheFlyFilesAddress = OnTheFlyAddress });
63+
options.Value.Returns(new ConfigurationData
64+
{
65+
OnTheFlyFilesAddress = OnTheFlyAddress,
66+
RoutingTilesAddress = RoutingTilesAddress
67+
});
6268
_service = new OfflineFilesService(_fileSystemHelper, _remoteFileFetcherGateway, options, Substitute.For<ILogger>());
6369
}
6470

@@ -85,7 +91,7 @@ public void ConstructWithoutFolder_ShouldNotCreateFileProviderAgain()
8591
[TestMethod]
8692
public async Task GetUpdatedFilesList_Root_ShouldReturnOnlyOnTheFlyFilesWithTodaysDate()
8793
{
88-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null);
94+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null, false);
8995

9096
Assert.HasCount(3, results);
9197
Assert.AreEqual(DateTime.UtcNow.Date, results["IHM-schema-6.pmtiles"]);
@@ -96,7 +102,7 @@ public async Task GetUpdatedFilesList_Root_ShouldReturnOnlyOnTheFlyFilesWithToda
96102
[TestMethod]
97103
public async Task GetUpdatedFilesList_Tile_ShouldReturnOnTheFlyAndJaxaFiles()
98104
{
99-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
105+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
100106

101107
Assert.HasCount(4, results);
102108
Assert.AreEqual(DateTime.UtcNow.Date, results["IHM-schema+7-52-75.pmtiles"]);
@@ -105,18 +111,71 @@ public async Task GetUpdatedFilesList_Tile_ShouldReturnOnTheFlyAndJaxaFiles()
105111
Assert.IsTrue(results.ContainsKey("jaxa_terrarium0-11_v2+7-52-75.pmtiles"));
106112
}
107113

114+
[TestMethod]
115+
public async Task GetUpdatedFilesList_TileWithValhalla_ShouldAlsoReturnTheValhallaFile()
116+
{
117+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, true);
118+
119+
Assert.HasCount(5, results);
120+
Assert.AreEqual(DateTime.UtcNow.Date, results["valhalla+7-52-75.tar"]);
121+
}
122+
123+
[TestMethod]
124+
public async Task GetUpdatedFilesList_TileWithoutValhalla_ShouldNotReturnTheValhallaFile()
125+
{
126+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
127+
128+
Assert.IsFalse(results.Keys.Any(k => k.StartsWith("valhalla")));
129+
}
130+
131+
[TestMethod]
132+
public async Task GetUpdatedFilesList_RootWithValhalla_ShouldNotReturnTheValhallaFile()
133+
{
134+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null, true);
135+
136+
Assert.IsFalse(results.Keys.Any(k => k.StartsWith("valhalla")));
137+
}
138+
139+
[TestMethod]
140+
public async Task GetUpdatedFilesList_ValhallaUpToDate_ShouldNotReturnTheValhallaFile()
141+
{
142+
var results = await _service.GetUpdatedFilesList(DateTime.UtcNow.AddDays(1), 52, 75, true);
143+
144+
Assert.IsFalse(results.Keys.Any(k => k.StartsWith("valhalla")));
145+
}
146+
147+
[TestMethod]
148+
public async Task GetFileContent_ValhallaFile_ShouldBeFetchedFromTheRoutingTilesServer()
149+
{
150+
_remoteFileFetcherGateway.GetFileStream(Arg.Any<string>()).Returns((new MemoryStream() as Stream, (long?)0));
151+
152+
await _service.GetFileContent("valhalla+7-52-75.tar", 52, 75);
153+
154+
await _remoteFileFetcherGateway.Received(1).GetFileStream(RoutingTilesAddress + "valhalla+7-52-75.tar");
155+
}
156+
157+
[TestMethod]
158+
public async Task GetFileContent_NonValhallaFile_ShouldBeFetchedFromTheOnTheFlyServer()
159+
{
160+
_remoteFileFetcherGateway.GetFileStream(Arg.Any<string>()).Returns((new MemoryStream() as Stream, (long?)0));
161+
162+
await _service.GetFileContent("IHM-schema+7-52-75.pmtiles", 52, 75);
163+
164+
await _remoteFileFetcherGateway.Received(1).GetFileStream(OnTheFlyAddress + "IHM-schema+7-52-75.pmtiles");
165+
}
166+
108167
[TestMethod]
109168
public async Task GetUpdatedFilesList_ShouldNotReturnContourFiles()
110169
{
111-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
170+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
112171

113172
Assert.IsFalse(results.ContainsKey("JAXA_AW3D30_2024_contour_z5-Z12_vector+7-52-75.pmtiles"));
114173
}
115174

116175
[TestMethod]
117176
public async Task GetUpdatedFilesList_JaxaFiles_ShouldUseFixedDates()
118177
{
119-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
178+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
120179

121180
Assert.AreEqual(DateTimeOffset.Parse("2026-04-09T10:36:08.8024764Z").UtcDateTime,
122181
results["jaxa_terrarium0-11_v2+7-52-75.pmtiles"].ToUniversalTime());
@@ -125,7 +184,7 @@ public async Task GetUpdatedFilesList_JaxaFiles_ShouldUseFixedDates()
125184
[TestMethod]
126185
public async Task GetUpdatedFilesList_EverythingUpToDate_ShouldReturnEmptyList()
127186
{
128-
var results = await _service.GetUpdatedFilesList(DateTime.UtcNow.AddDays(1), 52, 75);
187+
var results = await _service.GetUpdatedFilesList(DateTime.UtcNow.AddDays(1), 52, 75, false);
129188

130189
Assert.IsEmpty(results);
131190
}
@@ -134,7 +193,7 @@ public async Task GetUpdatedFilesList_EverythingUpToDate_ShouldReturnEmptyList()
134193
public async Task GetUpdatedFilesList_OnlyJaxaUpToDate_ShouldReturnOnlyOnTheFlyFiles()
135194
{
136195
// A last-modified between the fixed jaxa dates and today filters out jaxa but keeps the on-the-fly files.
137-
var results = await _service.GetUpdatedFilesList(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), 52, 75);
196+
var results = await _service.GetUpdatedFilesList(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), 52, 75, false);
138197

139198
Assert.HasCount(3, results);
140199
Assert.IsTrue(results.ContainsKey("IHM-schema+7-52-75.pmtiles"));
@@ -144,16 +203,16 @@ public async Task GetUpdatedFilesList_OnlyJaxaUpToDate_ShouldReturnOnlyOnTheFlyF
144203
[TestMethod]
145204
public async Task GetUpdatedFilesList_ShouldNotReadFileSystem()
146205
{
147-
await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
206+
await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
148207

149208
_fileProvider.DidNotReceive().GetDirectoryContents(Arg.Any<string>());
150209
}
151210

152211
[TestMethod]
153212
public async Task GetUpdatedFilesList_Twice_ShouldFetchTheStyleOnlyOnce()
154213
{
155-
await _service.GetUpdatedFilesList(DateTime.MinValue, null, null);
156-
await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
214+
await _service.GetUpdatedFilesList(DateTime.MinValue, null, null, false);
215+
await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
157216

158217
await _remoteFileFetcherGateway.Received(1).GetFileContent(StyleAddress);
159218
}
@@ -165,7 +224,7 @@ public async Task GetUpdatedFilesList_StyleFetchFailsOnce_ShouldRetryAndSucceed(
165224
_ => throw new Exception("some error"),
166225
_ => new RemoteFileFetcherGatewayResponse { Content = Encoding.UTF8.GetBytes(Style) });
167226

168-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null);
227+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null, false);
169228

170229
Assert.HasCount(3, results);
171230
await _remoteFileFetcherGateway.Received(2).GetFileContent(StyleAddress);
@@ -177,7 +236,7 @@ public async Task GetUpdatedFilesList_StyleFetchReturnsEmptyContent_ShouldRetryA
177236
_remoteFileFetcherGateway.GetFileContent(StyleAddress)
178237
.Returns(new RemoteFileFetcherGatewayResponse { Content = [] });
179238

180-
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => _service.GetUpdatedFilesList(DateTime.MinValue, null, null));
239+
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => _service.GetUpdatedFilesList(DateTime.MinValue, null, null, false));
181240
await _remoteFileFetcherGateway.Received(3).GetFileContent(StyleAddress);
182241
}
183242

@@ -186,7 +245,7 @@ public async Task GetUpdatedFilesList_AliasedDemSource_ShouldReturnTheAliasNameW
186245
{
187246
SetupStyleResponse(StyleWithAliasedDem);
188247

189-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75);
248+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, 52, 75, false);
190249

191250
Assert.HasCount(4, results);
192251
Assert.IsFalse(results.ContainsKey("jaxa_terrarium0-11_v2+7-52-75.pmtiles"));
@@ -199,7 +258,7 @@ public async Task GetUpdatedFilesList_AliasedDemSourceForRoot_ShouldNotReturnIt(
199258
{
200259
SetupStyleResponse(StyleWithAliasedDem);
201260

202-
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null);
261+
var results = await _service.GetUpdatedFilesList(DateTime.MinValue, null, null, false);
203262

204263
Assert.HasCount(3, results);
205264
Assert.IsFalse(results.ContainsKey("raster-dem-6.pmtiles"));

0 commit comments

Comments
 (0)