Skip to content

Commit e05a717

Browse files
watmildontsmock
authored andcommitted
See #24875: Take the overture tiles from the STAC catalog instead of a hardcoded bucket
The theme catalogs link to the tiles ("rel": "pmtiles", currently on tiles.overturemaps.org), so follow root -> latest release -> theme and use that link. Themes without a tiles link are skipped with a warning. This costs one small (cached) request per theme, but means the plugin follows Overture if the tiles move again.
1 parent 7ca07ad commit e05a717

3 files changed

Lines changed: 155 additions & 43 deletions

File tree

‎src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/CommonSourceReader.java‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.openstreetmap.josm.tools.Utils;
1111

1212
import jakarta.json.Json;
13+
import jakarta.json.JsonObject;
1314
import jakarta.json.stream.JsonParser;
1415
import jakarta.json.stream.JsonParsingException;
1516

@@ -54,6 +55,31 @@ public Optional<T> parse() throws IOException {
5455
return Optional.empty();
5556
}
5657

58+
/**
59+
* Read a JSON object from another URL, using the same cache settings as the
60+
* main source.
61+
*
62+
* @param url The url to read
63+
* @return The object, or {@code null} if the document is not a JSON object
64+
* @throws IOException if any I/O error occurs
65+
*/
66+
protected JsonObject readObject(String url) throws IOException {
67+
final var file = new CachedFile(url).setMaxAge(CachedFile.DAYS)
68+
.setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince);
69+
if (this.clearCache) {
70+
file.clear();
71+
}
72+
file.setFastFail(this.fastFail);
73+
try (file; JsonParser reader = Json.createParser(file.getContentReader())) {
74+
if (reader.hasNext() && reader.next() == JsonParser.Event.START_OBJECT) {
75+
return reader.getObject();
76+
}
77+
} catch (JsonParsingException jsonParsingException) {
78+
Logging.error(jsonParsingException);
79+
}
80+
return null;
81+
}
82+
5783
/**
5884
* Parses MapWithAI entry sources
5985
*

‎src/main/java/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/OvertureSourceReader.java‎

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import java.util.EnumSet;
1111
import java.util.List;
1212
import java.util.Objects;
13-
import java.util.regex.Pattern;
1413
import java.util.stream.Stream;
1514

1615
import org.openstreetmap.josm.data.Bounds;
@@ -32,17 +31,6 @@
3231
* Read data from overture sources
3332
*/
3433
public class OvertureSourceReader extends CommonSourceReader<List<MapWithAIInfo>> implements Closeable {
35-
/**
36-
* The location of the tiles for an overture release. The STAC catalog only lists
37-
* the geoparquet files, so this comes from
38-
* <a href="https://docs.overturemaps.org/examples/overture-tiles/">the overture
39-
* tiles documentation</a>.
40-
*/
41-
static final String TILES_URL = "https://overturemaps-extras-us-west-2.s3.us-west-2.amazonaws.com/tiles/{release}/{theme}.pmtiles";
42-
/** The themes with tiles. Transportation is not listed since it currently causes crashes. */
43-
private static final List<String> TILE_THEMES = List.of("addresses", "base", "buildings", "divisions", "places");
44-
/** The expected format for release ids (they are used to build URLs) */
45-
private static final Pattern RELEASE_PATTERN = Pattern.compile("[0-9A-Za-z][0-9A-Za-z._-]*");
4634
private final MapWithAIInfo source;
4735

4836
public OvertureSourceReader(MapWithAIInfo source) {
@@ -57,38 +45,87 @@ public List<MapWithAIInfo> parseJson(JsonParser jsonParser) {
5745
return parseRoot(jsonObject);
5846
}
5947
// The STAC catalog (https://stac.overturemaps.org/catalog.json), see #24875
60-
if (jsonObject.get("latest")instanceof JsonString latest) {
61-
return parseStacRoot(latest.getString());
48+
if ("Catalog".equals(jsonObject.getString("type", null)) && jsonObject.containsKey("links")) {
49+
return parseStacRoot(jsonObject);
6250
}
6351
return Collections.emptyList();
6452
}
6553

6654
/**
67-
* Create the sources for the latest release from the STAC catalog. Overture
68-
* only keeps tiles for the most recent releases, so the sources keep the same
69-
* id between releases. This means that user entries are updated to the new
55+
* Create the sources for the latest release from the STAC catalog. The root
56+
* catalog links to the releases, a release links to its themes, and a theme
57+
* links to its tiles ({@code "rel": "pmtiles"}).
58+
* <p>
59+
* Overture only keeps the last two releases, so the sources keep the same id
60+
* between releases. This means that user entries are updated to the new
7061
* release instead of being dropped.
7162
*
72-
* @param releaseId The latest release
73-
* @return The sources for that release
63+
* @param root The root catalog
64+
* @return The sources for the latest release
7465
*/
75-
private List<MapWithAIInfo> parseStacRoot(String releaseId) {
76-
if (!RELEASE_PATTERN.matcher(releaseId).matches()) {
77-
Logging.warn("MapWithAI: Unexpected overture release id: {0}", releaseId);
66+
private List<MapWithAIInfo> parseStacRoot(JsonObject root) {
67+
final var latest = root.getString("latest", null);
68+
final var releases = getLinks(root, "child").toList();
69+
var release = latest == null ? null
70+
: releases.stream().filter(link -> link.getString("href").contains('/' + latest + '/')).findFirst()
71+
.orElse(null);
72+
if (release == null) {
73+
release = releases.stream().filter(link -> link.getBoolean("latest", false)).findFirst().orElse(null);
74+
}
75+
if (release == null) {
76+
Logging.warn("MapWithAI: No overture release found in {0}", this.source.getUrl());
7877
return Collections.emptyList();
7978
}
80-
final var info = new ArrayList<MapWithAIInfo>(TILE_THEMES.size());
81-
for (var theme : TILE_THEMES) {
82-
final var uri = URI.create(TILES_URL.replace("{release}", releaseId).replace("{theme}", theme));
83-
final var themeInfo = buildSource(uri, releaseId, theme);
84-
if (themeInfo != null) {
85-
themeInfo.setId(this.source.getName() + ": " + theme);
86-
info.add(themeInfo);
79+
// The title is "<release> Overture Release"
80+
final var releaseId = latest != null ? latest : release.getString("title", "").split(" ", 2)[0];
81+
final var info = new ArrayList<MapWithAIInfo>(6);
82+
try {
83+
final var releaseCatalog = readObject(release.getString("href"));
84+
if (releaseCatalog == null) {
85+
return info;
86+
}
87+
for (var theme : getLinks(releaseCatalog, "child").toList()) {
88+
final var themeCatalog = readObject(theme.getString("href"));
89+
if (themeCatalog == null) {
90+
continue;
91+
}
92+
final var themeId = themeCatalog.getString("id", theme.getString("title", ""));
93+
final var tiles = getLinks(themeCatalog, "pmtiles").findFirst();
94+
if (tiles.isEmpty()) {
95+
Logging.warn("MapWithAI: Overture theme {0} has no tiles in release {1}", themeId, releaseId);
96+
continue;
97+
}
98+
final var themeInfo = buildSource(URI.create(tiles.get().getString("href")), releaseId, themeId);
99+
if (themeInfo != null) {
100+
themeInfo.setId(this.source.getName() + ": " + themeId);
101+
info.add(themeInfo);
102+
}
87103
}
104+
} catch (IOException | IllegalArgumentException e) {
105+
Logging.warn("MapWithAI: Could not read the overture catalog for release {0}: {1}", releaseId,
106+
e.getMessage());
107+
Logging.trace(e);
88108
}
89109
return info;
90110
}
91111

112+
/**
113+
* Get the links of a STAC object with a specific relation
114+
*
115+
* @param stac The STAC object (catalog or collection)
116+
* @param rel The relation to look for
117+
* @return The links with an {@code href}
118+
*/
119+
private static Stream<JsonObject> getLinks(JsonObject stac, String rel) {
120+
final var links = stac.get("links");
121+
if (links instanceof JsonArray array) {
122+
return array.stream().filter(JsonObject.class::isInstance).map(JsonObject.class::cast)
123+
.filter(link -> rel.equals(link.getString("rel", null)) && link.containsKey("href")
124+
&& link.get("href").getValueType() == JsonValue.ValueType.STRING);
125+
}
126+
return Stream.empty();
127+
}
128+
92129
private List<MapWithAIInfo> parseRoot(JsonObject jsonObject) {
93130
final var info = new ArrayList<MapWithAIInfo>(6 * 4);
94131
final var releases = jsonObject.get("releases");

‎src/test/unit/org/openstreetmap/josm/plugins/mapwithai/io/mapwithai/OvertureSourceReaderTest.java‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.io.StringReader;
99
import java.net.URI;
1010
import java.util.List;
11+
import java.util.Map;
1112

1213
import org.junit.jupiter.api.Test;
1314
import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAICategory;
@@ -16,23 +17,61 @@
1617
import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
1718

1819
import jakarta.json.Json;
20+
import jakarta.json.JsonObject;
1921

2022
/**
2123
* Test class for {@link OvertureSourceReader}
2224
*/
2325
@BasicPreferences
2426
class OvertureSourceReaderTest {
27+
private static final String STAC = "https://stac.overturemaps.org/";
28+
private static final String ROOT = """
29+
{"type":"Catalog","id":"Overture Releases","stac_version":"1.1.0","latest":"2026-09-23.1",
30+
"links":[{"rel":"child","href":"https://stac.overturemaps.org/2026-08-19.0/catalog.json","title":"2026-08-19.0 Overture Release"},
31+
{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/catalog.json","title":"2026-09-23.1 Overture Release","latest":true},
32+
{"rel":"self","href":"https://stac.overturemaps.org/catalog.json"}]}""";
33+
private static final String RELEASE = """
34+
{"type":"Catalog","id":"2026-09-23.1","stac_version":"1.1.0",
35+
"links":[{"rel":"root","href":"https://stac.overturemaps.org/catalog.json"},
36+
{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/addresses/catalog.json","title":"addresses"},
37+
{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/buildings/catalog.json","title":"buildings"},
38+
{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/divisions/catalog.json","title":"divisions"},
39+
{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/transportation/catalog.json","title":"transportation"}]}""";
40+
41+
private static String theme(String theme, boolean tiles) {
42+
return "{\"type\":\"Catalog\",\"id\":\"" + theme + "\",\"links\":[{\"rel\":\"root\",\"href\":\"" + STAC
43+
+ "catalog.json\"}" + (tiles ? ",{\"rel\":\"pmtiles\",\"href\":\"https://tiles.overturemaps.org/2026-09-23.1/"
44+
+ theme + ".pmtiles\",\"type\":\"application/vnd.pmtiles\"}" : "")
45+
+ "]}";
46+
}
47+
48+
private static final Map<String, String> CATALOGS = Map.of(STAC + "2026-09-23.1/catalog.json", RELEASE,
49+
STAC + "2026-09-23.1/addresses/catalog.json", theme("addresses", true),
50+
STAC + "2026-09-23.1/buildings/catalog.json", theme("buildings", true),
51+
STAC + "2026-09-23.1/divisions/catalog.json", theme("divisions", false),
52+
STAC + "2026-09-23.1/transportation/catalog.json", theme("transportation", true));
53+
2554
private static List<MapWithAIInfo> parse(String json) throws IOException {
26-
final var source = new MapWithAIInfo("Overture", "https://stac.overturemaps.org/catalog.json");
55+
final var source = new MapWithAIInfo("Overture", STAC + "catalog.json");
2756
source.setSourceType(MapWithAIType.OVERTURE);
2857
try (var reader = new OvertureSourceReader(source) {
58+
@Override
59+
protected JsonObject readObject(String url) throws IOException {
60+
// Serve the canned catalogs instead of going to the network
61+
if (!CATALOGS.containsKey(url)) {
62+
throw new IOException("Unexpected url: " + url);
63+
}
64+
try (var parser = Json.createParser(new StringReader(CATALOGS.get(url)))) {
65+
parser.next();
66+
return parser.getObject();
67+
}
68+
}
69+
2970
@Override
3071
void readTileInformation(MapWithAIInfo info, URI uri, String releaseId) {
3172
// Don't read the (remote) pmtiles headers in tests
3273
}
33-
};
34-
var sr = new StringReader(json);
35-
var parser = Json.createParser(sr)) {
74+
}; var sr = new StringReader(json); var parser = Json.createParser(sr)) {
3675
parser.next();
3776
return reader.parseJson(parser);
3877
}
@@ -44,29 +83,39 @@ void readTileInformation(MapWithAIInfo info, URI uri, String releaseId) {
4483
*/
4584
@Test
4685
void testStacCatalog() throws IOException {
47-
final var infos = parse("""
48-
{"type":"Catalog","id":"Overture Releases","stac_version":"1.1.0",
49-
"links":[{"rel":"child","href":"https://stac.overturemaps.org/2026-09-23.1/catalog.json","latest":true}],
50-
"latest":"2026-09-23.1"}""");
51-
assertEquals(5, infos.size());
86+
final var infos = parse(ROOT);
87+
// divisions has no tiles, transportation is skipped
88+
assertEquals(2, infos.size(), infos.toString());
5289
for (var info : infos) {
5390
assertEquals(MapWithAIType.PMTILES, info.getSourceType());
54-
assertTrue(info.getUrl().startsWith(
55-
"https://overturemaps-extras-us-west-2.s3.us-west-2.amazonaws.com/tiles/2026-09-23.1/"),
56-
info.getUrl());
57-
assertTrue(info.getUrl().endsWith(".pmtiles"), info.getUrl());
5891
assertTrue(info.hasValidUrl());
92+
assertTrue(info.getName().contains("2026-09-23.1"), info.getName());
5993
}
6094
final var buildings = infos.stream().filter(i -> i.getUrl().endsWith("/buildings.pmtiles")).findFirst()
6195
.orElseThrow();
96+
assertEquals("https://tiles.overturemaps.org/2026-09-23.1/buildings.pmtiles", buildings.getUrl());
6297
assertEquals(MapWithAICategory.BUILDING, buildings.getCategory());
6398
// The id must not change between releases
6499
assertEquals("Overture: buildings", buildings.getId());
100+
final var addresses = infos.stream().filter(i -> i.getUrl().endsWith("/addresses.pmtiles")).findFirst()
101+
.orElseThrow();
102+
assertEquals(MapWithAICategory.ADDRESS, addresses.getCategory());
103+
}
104+
105+
/**
106+
* The {@code latest} field is optional in STAC; fall back to the link marked as
107+
* latest.
108+
*/
109+
@Test
110+
void testStacCatalogWithoutLatest() throws IOException {
111+
assertEquals(2, parse(ROOT.replace("\"latest\":\"2026-09-23.1\",", "")).size());
65112
}
66113

67114
@Test
68115
void testBadCatalog() throws IOException {
69116
assertTrue(parse("{\"type\":\"Catalog\"}").isEmpty());
70-
assertTrue(parse("{\"latest\":\"../../something else\"}").isEmpty());
117+
assertTrue(parse("{\"type\":\"Catalog\",\"links\":[]}").isEmpty());
118+
assertTrue(parse("{\"type\":\"Catalog\",\"latest\":\"2000-01-01.0\",\"links\":[{\"rel\":\"child\","
119+
+ "\"href\":\"https://stac.overturemaps.org/2000-01-01.0/catalog.json\"}]}").isEmpty());
71120
}
72121
}

0 commit comments

Comments
 (0)