diff --git a/docs/sandbox/MapboxVectorTilesApi.md b/docs/sandbox/MapboxVectorTilesApi.md
index da9fd1120e1..7183bdf8b7f 100644
--- a/docs/sandbox/MapboxVectorTilesApi.md
+++ b/docs/sandbox/MapboxVectorTilesApi.md
@@ -148,6 +148,7 @@ For each layer, the configuration includes:
| Config Parameter | Type | Summary | Req./Opt. | Default Value | Since |
|----------------------------------------------------------------|:----------:|--------------------------------------------------------------------------------------------|:----------:|---------------|:-----:|
+| [attribution](#vectorTiles_attribution) | `string` | Custom attribution to be returned in `tilejson.json` | *Optional* | | 2.5 |
| [basePath](#vectorTiles_basePath) | `string` | The path of the vector tile source URLs in `tilejson.json`. | *Optional* | | 2.5 |
| [layers](#vectorTiles_layers) | `object[]` | Configuration of the individual layers for the Mapbox vector tiles. | *Optional* | | 2.0 |
| type = "stop" | `enum` | Type of the layer. | *Required* | | 2.0 |
@@ -161,6 +162,22 @@ For each layer, the configuration includes:
#### Details
+
attribution
+
+**Since version:** `2.5` ∙ **Type:** `string` ∙ **Cardinality:** `Optional`
+**Path:** /vectorTiles
+
+Custom attribution to be returned in `tilejson.json`
+
+By default the, `attribution` property in `tilejson.json` is computed from the names and
+URLs of the feed publishers.
+If the OTP deployment contains many fields, this can become very unwieldy.
+
+This configuration parameter allows you to set the `attribution` to any string you wish
+including HTML tags,
+for example `Regional Partners`.
+
+
basePath
**Since version:** `2.5` ∙ **Type:** `string` ∙ **Cardinality:** `Optional`
diff --git a/src/ext/java/org/opentripplanner/ext/vectortiles/VectorTilesResource.java b/src/ext/java/org/opentripplanner/ext/vectortiles/VectorTilesResource.java
index 772db7394f3..a1e9a83c85a 100644
--- a/src/ext/java/org/opentripplanner/ext/vectortiles/VectorTilesResource.java
+++ b/src/ext/java/org/opentripplanner/ext/vectortiles/VectorTilesResource.java
@@ -28,6 +28,7 @@
import org.opentripplanner.inspector.vector.LayerBuilder;
import org.opentripplanner.inspector.vector.LayerParameters;
import org.opentripplanner.inspector.vector.VectorTileResponseFactory;
+import org.opentripplanner.model.FeedInfo;
import org.opentripplanner.standalone.api.OtpServerRequestContext;
@Path("/routers/{ignoreRouterId}/vectorTiles")
@@ -81,13 +82,6 @@ public TileJson getTileJson(
@PathParam("layers") String requestedLayers
) {
var envelope = serverContext.worldEnvelopeService().envelope().orElseThrow();
- var feedInfos = serverContext
- .transitService()
- .getFeedIds()
- .stream()
- .map(serverContext.transitService()::getFeedInfo)
- .filter(Predicate.not(Objects::isNull))
- .toList();
List rLayers = Arrays.asList(requestedLayers.split(","));
@@ -101,7 +95,24 @@ public TileJson getTileJson(
TileJson.urlWithDefaultPath(uri, headers, rLayers, ignoreRouterId, "vectorTiles")
);
- return new TileJson(url, envelope, feedInfos);
+ return serverContext
+ .vectorTileConfig()
+ .attribution()
+ .map(attr -> new TileJson(url, envelope, attr))
+ .orElseGet(() -> {
+ var feedInfos = getFeedInfos();
+ return new TileJson(url, envelope, feedInfos);
+ });
+ }
+
+ private List getFeedInfos() {
+ return serverContext
+ .transitService()
+ .getFeedIds()
+ .stream()
+ .map(serverContext.transitService()::getFeedInfo)
+ .filter(Predicate.not(Objects::isNull))
+ .toList();
}
private static LayerBuilder> crateLayerBuilder(
diff --git a/src/main/java/org/opentripplanner/apis/support/TileJson.java b/src/main/java/org/opentripplanner/apis/support/TileJson.java
index 75aabb2b6c6..1f5c090c16d 100644
--- a/src/main/java/org/opentripplanner/apis/support/TileJson.java
+++ b/src/main/java/org/opentripplanner/apis/support/TileJson.java
@@ -36,15 +36,8 @@ public class TileJson implements Serializable {
public final double[] bounds;
public final double[] center;
- public TileJson(String tileUrl, WorldEnvelope envelope, Collection feedInfos) {
- attribution =
- feedInfos
- .stream()
- .map(feedInfo ->
- "" + feedInfo.getPublisherName() + ""
- )
- .collect(Collectors.joining(", "));
-
+ public TileJson(String tileUrl, WorldEnvelope envelope, String attribution) {
+ this.attribution = attribution;
tiles = new String[] { tileUrl };
bounds =
@@ -59,6 +52,10 @@ public TileJson(String tileUrl, WorldEnvelope envelope, Collection fee
center = new double[] { c.longitude(), c.latitude(), 9 };
}
+ public TileJson(String tileUrl, WorldEnvelope envelope, Collection feedInfos) {
+ this(tileUrl, envelope, attributionFromFeedInfo(feedInfos));
+ }
+
/**
* Creates a vector source layer URL from a hard-coded path plus information from the incoming
* HTTP request.
@@ -96,4 +93,14 @@ public static String urlFromOverriddenBasePath(
String.join(",", layers)
);
}
+
+ private static String attributionFromFeedInfo(Collection feedInfos) {
+ return feedInfos
+ .stream()
+ .map(feedInfo ->
+ "%s".formatted(feedInfo.getPublisherUrl(), feedInfo.getPublisherName())
+ )
+ .distinct()
+ .collect(Collectors.joining(", "));
+ }
}
diff --git a/src/main/java/org/opentripplanner/framework/io/HttpUtils.java b/src/main/java/org/opentripplanner/framework/io/HttpUtils.java
index 4981a8ab91b..672c9b9c481 100644
--- a/src/main/java/org/opentripplanner/framework/io/HttpUtils.java
+++ b/src/main/java/org/opentripplanner/framework/io/HttpUtils.java
@@ -3,6 +3,7 @@
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import org.apache.hc.core5.http.ContentType;
public final class HttpUtils {
@@ -31,7 +32,7 @@ public static String getBaseAddress(UriInfo uri, HttpHeaders headers) {
String host;
if (headers.getRequestHeader(HEADER_X_FORWARDED_HOST) != null) {
- host = headers.getRequestHeader(HEADER_X_FORWARDED_HOST).getFirst();
+ host = extractHost(headers.getRequestHeader(HEADER_X_FORWARDED_HOST).getFirst());
} else if (headers.getRequestHeader(HEADER_HOST) != null) {
host = headers.getRequestHeader(HEADER_HOST).getFirst();
} else {
@@ -40,4 +41,12 @@ public static String getBaseAddress(UriInfo uri, HttpHeaders headers) {
return protocol + "://" + host;
}
+
+ /**
+ * The X-Forwarded-Host header can contain a comma-separated list so we account for that.
+ * https://stackoverflow.com/questions/66042952/http-proxy-behavior-for-x-forwarded-host-header
+ */
+ private static String extractHost(String xForwardedFor) {
+ return Arrays.stream(xForwardedFor.split(",")).map(String::strip).findFirst().get();
+ }
}
diff --git a/src/main/java/org/opentripplanner/standalone/config/routerconfig/VectorTileConfig.java b/src/main/java/org/opentripplanner/standalone/config/routerconfig/VectorTileConfig.java
index 6f7d6967ce8..d6beac6d256 100644
--- a/src/main/java/org/opentripplanner/standalone/config/routerconfig/VectorTileConfig.java
+++ b/src/main/java/org/opentripplanner/standalone/config/routerconfig/VectorTileConfig.java
@@ -18,18 +18,23 @@
public class VectorTileConfig
implements VectorTilesResource.LayersParameters {
- public static final VectorTileConfig DEFAULT = new VectorTileConfig(List.of(), null);
+ public static final VectorTileConfig DEFAULT = new VectorTileConfig(List.of(), null, null);
private final List> layers;
@Nullable
private final String basePath;
+ @Nullable
+ private final String attribution;
+
VectorTileConfig(
Collection extends LayerParameters> layers,
- @Nullable String basePath
+ @Nullable String basePath,
+ @Nullable String attribution
) {
this.layers = List.copyOf(layers);
this.basePath = basePath;
+ this.attribution = attribution;
}
@Override
@@ -41,6 +46,10 @@ public Optional basePath() {
return Optional.ofNullable(basePath);
}
+ public Optional attribution() {
+ return Optional.ofNullable(attribution);
+ }
+
public static VectorTileConfig mapVectorTilesParameters(NodeAdapter node, String paramName) {
var root = node.of(paramName).summary("Vector tile configuration").asObject();
return new VectorTileConfig(
@@ -71,7 +80,23 @@ public static VectorTileConfig mapVectorTilesParameters(NodeAdapter node, String
is expected to be handled by a proxy.
"""
)
- .asString(DEFAULT.basePath)
+ .asString(DEFAULT.basePath),
+ root
+ .of("attribution")
+ .since(V2_5)
+ .summary("Custom attribution to be returned in `tilejson.json`")
+ .description(
+ """
+ By default the, `attribution` property in `tilejson.json` is computed from the names and
+ URLs of the feed publishers.
+ If the OTP deployment contains many fields, this can become very unwieldy.
+
+ This configuration parameter allows you to set the `attribution` to any string you wish
+ including HTML tags,
+ for example `Regional Partners`.
+ """
+ )
+ .asString(DEFAULT.attribution)
);
}
diff --git a/src/test/java/org/opentripplanner/apis/support/TileJsonTest.java b/src/test/java/org/opentripplanner/apis/support/TileJsonTest.java
index ac3b7bca522..73c8cd1369e 100644
--- a/src/test/java/org/opentripplanner/apis/support/TileJsonTest.java
+++ b/src/test/java/org/opentripplanner/apis/support/TileJsonTest.java
@@ -2,16 +2,33 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.time.LocalDate;
import java.util.List;
import org.glassfish.jersey.server.internal.routing.UriRoutingContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.opentripplanner.model.FeedInfo;
+import org.opentripplanner.service.worldenvelope.model.WorldEnvelope;
import org.opentripplanner.test.support.HttpForTest;
class TileJsonTest {
private static final List LAYERS = List.of("stops", "rentalVehicles");
+ private static final WorldEnvelope ENVELOPE = WorldEnvelope
+ .of()
+ .expandToIncludeStreetEntities(1, 1)
+ .expandToIncludeStreetEntities(2, 2)
+ .build();
+ private static final FeedInfo FEED_INFO = new FeedInfo(
+ "1",
+ "Trimet",
+ "https://trimet.org",
+ "en",
+ LocalDate.MIN,
+ LocalDate.MIN,
+ "1"
+ );
@ParameterizedTest
@ValueSource(
@@ -40,4 +57,23 @@ void defaultPath() {
TileJson.urlWithDefaultPath(uriInfo, req, LAYERS, "default", "vectorTiles")
);
}
+
+ @Test
+ void attributionFromFeedInfo() {
+ var tileJson = new TileJson("http://example.com", ENVELOPE, List.of(FEED_INFO));
+ assertEquals("Trimet", tileJson.attribution);
+ }
+
+ @Test
+ void duplicateAttribution() {
+ var tileJson = new TileJson("http://example.com", ENVELOPE, List.of(FEED_INFO, FEED_INFO));
+ assertEquals("Trimet", tileJson.attribution);
+ }
+
+ @Test
+ void attributionFromOverride() {
+ var override = "OVERRIDE";
+ var tileJson = new TileJson("http://example.com", ENVELOPE, override);
+ assertEquals(override, tileJson.attribution);
+ }
}
diff --git a/src/test/java/org/opentripplanner/framework/io/HttpUtilsTest.java b/src/test/java/org/opentripplanner/framework/io/HttpUtilsTest.java
new file mode 100644
index 00000000000..ac8ae52848c
--- /dev/null
+++ b/src/test/java/org/opentripplanner/framework/io/HttpUtilsTest.java
@@ -0,0 +1,38 @@
+package org.opentripplanner.framework.io;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Map;
+import org.glassfish.jersey.server.internal.routing.UriRoutingContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.opentripplanner.test.support.HttpForTest;
+
+class HttpUtilsTest {
+
+ private static final String HOSTNAME = "example.com";
+
+ static List testCases() {
+ return List.of(
+ HOSTNAME,
+ "example.com,",
+ " example.com ,",
+ "example.com,example.com",
+ "example.com, example.com",
+ "example.com, example.net",
+ "example.com, example.net, example.com",
+ " example.com, example.net, example.com"
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("testCases")
+ void extractHost(String headerValue) {
+ var req = HttpForTest.containerRequest();
+ req.headers(Map.of("X-Forwarded-Host", List.of(headerValue)));
+ var uriInfo = new UriRoutingContext(req);
+ var baseAddress = HttpUtils.getBaseAddress(uriInfo, req);
+ assertEquals("https://" + HOSTNAME, baseAddress);
+ }
+}