Skip to content

Commit

Permalink
Download shape file dynamically from NDW, with if-none-match header.
Browse files Browse the repository at this point in the history
  • Loading branch information
bertrik committed Sep 19, 2024
1 parent 29708ef commit c3d5045
Show file tree
Hide file tree
Showing 12 changed files with 268 additions and 71 deletions.
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
package nl.bertriksikken.verkeersdrukte.ndw;

import com.google.common.collect.Iterables;
import jakarta.ws.rs.core.HttpHeaders;

import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;

public final class FileResponse {

private final int code;
private final byte[] contents;
private final Instant lastModified;
private final Map<String, List<String>> headers;

FileResponse(byte[] contents, Instant lastModified) {
this.contents = contents;
this.lastModified = lastModified;
FileResponse(int code, Map<String, List<String>> headers, byte[] contents) {
this.code = code;
this.headers = headers; // do not copy, original map has special case-insensitive properties
this.contents = contents.clone();
}

public static FileResponse create(byte[] contents, String lastModified) {
Instant date = DateTimeFormatter.RFC_1123_DATE_TIME.parse(lastModified, Instant::from);
return new FileResponse(contents, date);
public static FileResponse create(int code, Map<String, List<String>> headers, byte[] contents) {
return new FileResponse(code, headers, contents);
}

public static FileResponse empty() {
return new FileResponse(new byte[0], Instant.now());
public int getCode() {
return code;
}

public byte[] getContents() {
return contents;
}

public Instant getLastModified() {
return lastModified;
String lastModified = Iterables.getFirst(headers.get(HttpHeaders.LAST_MODIFIED), "");
return DateTimeFormatter.RFC_1123_DATE_TIME.parse(lastModified, Instant::from);
}

public String getEtag() {
List<String> values = headers.getOrDefault(HttpHeaders.ETAG, List.of());
return Iterables.getFirst(values, "");
}

@Override
public String toString() {
return String.format(Locale.ROOT, "{code=%s,contents=%d bytes,headers=%s}", code, contents.length, headers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Headers;
import retrofit2.http.HeaderMap;
import retrofit2.http.Path;

import java.util.Map;

public interface INdwApi {

String TRAFFIC_SPEED_XML_GZ = "trafficspeed.xml.gz";
String TRAFFIC_SPEED_SHAPEFILE = "NDW_AVG_Meetlocaties_Shapefile.zip";

/**
* Downloads a file from <a href="https://opendata.ndw.nu/">NDW open data portaal</a>
*/
@GET("/{filename}")
@Headers({"Accept-Encoding: gzip"})
Call<ResponseBody> downloadFile(@Path("filename") String filename);
Call<ResponseBody> downloadFile(@Path("filename") String filename, @HeaderMap Map<String, String> headers);

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import okhttp3.Request;
import okhttp3.ResponseBody;
import org.eclipse.jetty.http.HttpHeader;
import org.glassfish.jersey.http.HttpHeaders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.scalars.ScalarsConverterFactory;

import java.io.IOException;
import java.util.Map;
import java.util.Objects;

public final class NdwClient {
Expand Down Expand Up @@ -41,12 +43,22 @@ private static okhttp3.Response addUserAgent(Interceptor.Chain chain) throws IOE
}

public FileResponse getTrafficSpeed() throws IOException {
Response<ResponseBody> response = restApi.downloadFile(INdwApi.TRAFFIC_SPEED_XML_GZ).execute();
Map<String, String> headers = Map.of(HttpHeaders.ACCEPT_ENCODING, "gzip");
return getFile(INdwApi.TRAFFIC_SPEED_XML_GZ, headers);
}

public FileResponse getShapeFile(String etag) throws IOException {
Map<String, String> headers = Map.of(HttpHeaders.IF_NONE_MATCH, etag);
return getFile(INdwApi.TRAFFIC_SPEED_SHAPEFILE, headers);
}

FileResponse getFile(String name, Map<String, String> headers) throws IOException {
Response<ResponseBody> response = restApi.downloadFile(name, headers).execute();
if (response.isSuccessful()) {
return FileResponse.create(response.body().bytes(), response.headers().get("Last-Modified"));
return FileResponse.create(response.code(), response.headers().toMultimap(), response.body().bytes());
} else {
LOG.warn("getTrafficSpeed failed, code {}, message {}", response.code(), response.message());
return FileResponse.empty();
LOG.warn("getFile('{}') failed, code {}: '{}'", name, response.code(), response.message());
return FileResponse.create(response.code(), response.headers().toMultimap(), new byte[0]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package nl.bertriksikken.verkeersdrukte.traffic;

import nl.bertriksikken.geojson.FeatureCollection;
import nl.bertriksikken.shapefile.EShapeType;
import nl.bertriksikken.shapefile.ShapeFile;
import nl.bertriksikken.shapefile.ShapeRecord;
import nl.bertriksikken.verkeersdrukte.ndw.FileResponse;
import nl.bertriksikken.verkeersdrukte.ndw.NdwClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

public final class ShapeFileDownloader {

private static final Logger LOG = LoggerFactory.getLogger(ShapeFileDownloader.class);

private final File folder;
private final NdwClient client;

private String etag = "";
private ShapeFile shapeFile;

ShapeFileDownloader(File folder, NdwClient client) {
this.folder = folder;
this.client = client;
}

public boolean download() throws IOException {
FileResponse response = client.getShapeFile(etag);
if (response.getCode() != 200) {
LOG.info("Shapefile not downloaded, code {}", response.getCode());
return false;
}

// remember etag for next time
etag = response.getEtag();

// unzip
folder.mkdirs();
deleteFiles(folder);
unzip(response.getContents(), folder);

// read shape file
try (FileInputStream shpStream = new FileInputStream(new File(folder, "Telpunten_WGS84.shp"))) {
try (FileInputStream dbfStream = new FileInputStream(new File(folder, "Telpunten_WGS84.dbf"))) {
shapeFile = ShapeFile.read(shpStream, dbfStream);
}
}
return true;
}

private void deleteFiles(File folder) {
for (File file : folder.listFiles(this::isTelpunt)) {
LOG.info("Deleting '{}'", file.getName());
file.delete();
}
}

private void unzip(byte[] contents, File folder) throws IOException {
ByteArrayInputStream bais = new ByteArrayInputStream(contents);
try (ZipInputStream zis = new ZipInputStream(bais)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
if (!entry.isDirectory() && isTelpunt(folder, name)) {
File file = new File(folder, name);
LOG.info("Unzipping {}", file.getName());
unzipFile(zis, file);
}
}
}
}

private void unzipFile(ZipInputStream zis, File outputFile) throws IOException {
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
byte[] data = zis.readAllBytes();
fos.write(data);
}
}

public FeatureCollection getFeatureCollection() throws IOException {
FeatureCollection collection = new FeatureCollection();
for (ShapeRecord record : shapeFile.getRecords()) {
if (record.getType() == EShapeType.Point) {
ShapeRecord.Point point = (ShapeRecord.Point) record;
FeatureCollection.GeoJsonGeometry geometry = new FeatureCollection.PointGeometry(point.y, point.x);
FeatureCollection.Feature feature = new FeatureCollection.Feature(geometry);
record.getProperties().forEach(feature::addProperty);
collection.add(feature);
}
}
return collection;
}

private boolean isTelpunt(File dir, String name) {
return name.startsWith("Telpunten_WGS84");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonProperty;

import java.io.File;
import java.time.Duration;
import java.time.ZoneId;

Expand All @@ -15,6 +16,8 @@ public final class TrafficConfig {
private String baseUrl = "http://stofradar.nl:9002";
@JsonProperty("expiryDurationMinutes")
private int expiryDurationMinutes = 1440;
@JsonProperty("shapeFileFolder")
private String shapeFileFolder = ".shapefile";

public ZoneId getTimeZone() {
return ZoneId.of(timeZone);
Expand All @@ -27,4 +30,8 @@ public String getBaseUrl() {
public Duration getExpiryDuration() {
return Duration.ofMinutes(expiryDurationMinutes);
}

public File getShapeFileFolder() {
return new File(shapeFileFolder);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@
import nl.bertriksikken.datex2.MeasuredValue;
import nl.bertriksikken.datex2.SiteMeasurements;
import nl.bertriksikken.geojson.FeatureCollection;
import nl.bertriksikken.shapefile.EShapeType;
import nl.bertriksikken.shapefile.ShapeFile;
import nl.bertriksikken.shapefile.ShapeRecord;
import nl.bertriksikken.verkeersdrukte.app.VerkeersDrukteAppConfig;
import nl.bertriksikken.verkeersdrukte.ndw.FileResponse;
import nl.bertriksikken.verkeersdrukte.ndw.NdwClient;
Expand All @@ -17,7 +14,6 @@

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
Expand All @@ -41,36 +37,36 @@ public final class TrafficHandler implements ITrafficHandler, Managed {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private final NdwClient ndwClient;
private final MeasurementCache measurementCache;
private final ShapeFileDownloader shapeFileDownloader;
private FeatureCollection shapeFile;

public TrafficHandler(VerkeersDrukteAppConfig config) {
ndwClient = NdwClient.create(config.getNdwConfig());
measurementCache = new MeasurementCache(config.getTrafficConfig().getExpiryDuration());
shapeFileDownloader = new ShapeFileDownloader(config.getTrafficConfig().getShapeFileFolder(), ndwClient);
}

@Override
public void start() throws IOException {
// read the shape file
LOG.info("Reading shape file ...");
InputStream shpStream = getClass().getClassLoader().getResourceAsStream("shapefile/Telpunten_WGS84.shp");
InputStream dbfStream = getClass().getClassLoader().getResourceAsStream("shapefile/Telpunten_WGS84.dbf");
shapeFile = readShapeFile(shpStream, dbfStream);
public void start() {
// schedule shape file download
LOG.info("Schedule shape file download ...");
schedule(this::downloadShapeFile, Duration.ZERO);

// schedule regular fetches, starting immediately
LOG.info("Schedule download ...");
LOG.info("Schedule traffic speed download ...");
schedule(this::downloadTrafficSpeed, Duration.ZERO);
}

@SuppressWarnings("FutureReturnValueIgnored")
private void schedule(Runnable action, Duration interval) {
private void schedule(Runnable action, Duration delay) {
Runnable runnable = () -> {
try {
action.run();
} catch (Throwable e) {
LOG.warn("Caught throwable in scheduled task", e);
}
};
executor.schedule(runnable, interval.toMillis(), TimeUnit.MILLISECONDS);
executor.schedule(runnable, delay.toMillis(), TimeUnit.MILLISECONDS);
}

@Override
Expand Down Expand Up @@ -103,6 +99,19 @@ private void downloadTrafficSpeed() {
notifyClients();
}

private void downloadShapeFile() {
try {
LOG.info("Downloading shapefile ...");
if (shapeFileDownloader.download()) {
shapeFile = shapeFileDownloader.getFeatureCollection();
}
} catch (IOException e) {
LOG.warn("Shapefile download failed: {}", e.getMessage());
}
// reschedule
schedule(this::downloadShapeFile, Duration.ofDays(1));
}

private void decode(ByteArrayInputStream inputStream) throws IOException {
try (GZIPInputStream gzis = new GZIPInputStream(inputStream)) {
MeasuredDataPublication publication = MeasuredDataPublication.parse(gzis);
Expand Down Expand Up @@ -198,18 +207,4 @@ private void notifyClients() {
List.copyOf(subscriptions.values()).forEach(INotifyData::notifyUpdate);
}

private FeatureCollection readShapeFile(InputStream shpStream, InputStream dbfStream) throws IOException {
ShapeFile shapeFile = ShapeFile.read(shpStream, dbfStream);
FeatureCollection collection = new FeatureCollection();
for (ShapeRecord record : shapeFile.getRecords()) {
if (record.getType() == EShapeType.Point) {
ShapeRecord.Point point = (ShapeRecord.Point) record;
FeatureCollection.GeoJsonGeometry geometry = new FeatureCollection.PointGeometry(point.y, point.x);
FeatureCollection.Feature feature = new FeatureCollection.Feature(geometry);
record.getProperties().forEach(feature::addProperty);
collection.add(feature);
}
}
return collection;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package nl.bertriksikken.verkeersdrukte.ndw;

import org.junit.Assert;
import org.junit.Test;

import java.time.Instant;
import java.util.List;
import java.util.Map;

public final class FileResponseTest {

@Test
public void test() {
byte[] contents = new byte[]{1, 2, 3};
Map<String, List<String>> headers = Map.of("Last-Modified", List.of("Tue, 3 Jun 2008 11:05:30 GMT"));
FileResponse response = FileResponse.create(200, headers, contents);

Assert.assertArrayEquals(contents, response.getContents());
Instant lastModified = response.getLastModified();
Assert.assertNotNull(lastModified);
}

}
Loading

0 comments on commit c3d5045

Please sign in to comment.