de.schildbach.pte.NegentweeProvider.java Source code

Java tutorial

Introduction

Here is the source code for de.schildbach.pte.NegentweeProvider.java

Source

/*
 * Copyright 2017 the original author or authors.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package de.schildbach.pte;

import java.io.IOException;
import java.io.Serializable;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Currency;
import java.util.Date;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Nullable;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import de.schildbach.pte.dto.Departure;
import de.schildbach.pte.dto.Fare;
import de.schildbach.pte.dto.Line;
import de.schildbach.pte.dto.LineDestination;
import de.schildbach.pte.dto.Location;
import de.schildbach.pte.dto.LocationType;
import de.schildbach.pte.dto.NearbyLocationsResult;
import de.schildbach.pte.dto.Point;
import de.schildbach.pte.dto.Position;
import de.schildbach.pte.dto.Product;
import de.schildbach.pte.dto.QueryDeparturesResult;
import de.schildbach.pte.dto.QueryTripsContext;
import de.schildbach.pte.dto.QueryTripsResult;
import de.schildbach.pte.dto.ResultHeader;
import de.schildbach.pte.dto.StationDepartures;
import de.schildbach.pte.dto.Stop;
import de.schildbach.pte.dto.SuggestLocationsResult;
import de.schildbach.pte.dto.SuggestedLocation;
import de.schildbach.pte.dto.Trip;
import de.schildbach.pte.exception.InternalErrorException;
import de.schildbach.pte.exception.NotFoundException;
import de.schildbach.pte.util.ParserUtils;
import de.schildbach.pte.util.WordUtils;

import okhttp3.HttpUrl;

/**
 * @author full-duplex
 */
public class NegentweeProvider extends AbstractNetworkProvider {

    private static final String API_BASE = "https://api.9292.nl/0.1/";
    private static final String SERVER_PRODUCT = "negentwee";

    private static final Language DEFAULT_API_LANG = Language.NL_NL;
    private static final int DEFAULT_MAX_LOCATIONS = 50;

    private static final SimpleDateFormat dateTimeParser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm");
    private static final SimpleDateFormat timeParser = new SimpleDateFormat("HH:mm");

    private static final EnumSet<Product> trainProducts = EnumSet.of(Product.HIGH_SPEED_TRAIN,
            Product.REGIONAL_TRAIN, Product.SUBURBAN_TRAIN);

    private final Language language;
    private final ResultHeader resultHeader;

    public enum Language {
        NL_NL("nl-NL"), EN_GB("en-GB");

        private final String lang;

        private Language(String lang) {
            this.lang = lang;
        }

        @Override
        public String toString() {
            return this.lang;
        }
    }

    private enum InterchangeTime {
        STANDARD, EXTRA;

        @Override
        public String toString() {
            return name().toLowerCase();
        }
    }

    @SuppressWarnings("serial")
    private static class QueryParameter implements Serializable {
        public String name, value;

        private QueryParameter(String name, String value) {
            this.name = name;
            this.value = value;
        }

        @Override
        public String toString() {
            return this.name + "=" + this.value;
        }
    }

    @SuppressWarnings("serial")
    private static class TripsContext implements QueryTripsContext {
        private String url, earlier, later;
        public Location from, to, via;

        private TripsContext(HttpUrl url, @Nullable String earlier, @Nullable String later, Location from,
                @Nullable Location via, Location to) {
            this.url = url.toString();
            this.earlier = earlier;
            this.later = later;
            this.from = from;
            this.via = via;
            this.to = to;
        }

        private HttpUrl getQueryEarlier() {
            return HttpUrl.parse(this.url).newBuilder(this.earlier).addQueryParameter("before", "4").build();
        }

        private HttpUrl getQueryLater() {
            return HttpUrl.parse(this.url).newBuilder(this.later).addQueryParameter("after", "4").build();
        }

        @Override
        public boolean canQueryEarlier() {
            return (earlier != null);
        }

        @Override
        public boolean canQueryLater() {
            return (later != null);
        }
    }

    public NegentweeProvider() {
        this(DEFAULT_API_LANG);
    }

    public NegentweeProvider(Language language) {
        super(NetworkId.NEGENTWEE);

        this.language = language;
        this.resultHeader = new ResultHeader(network, SERVER_PRODUCT);
    }

    private HttpUrl buildApiUrl(String action, List<QueryParameter> queries) {
        HttpUrl.Builder url = HttpUrl.parse(API_BASE).newBuilder().addPathSegments(action).addQueryParameter("lang",
                this.language.toString());

        for (QueryParameter q : queries) {
            url.addQueryParameter(q.name, q.value);
        }

        return url.build();
    }

    private Location queryLocationById(String stationId) throws IOException {
        HttpUrl url = buildApiUrl("locations/" + stationId, new ArrayList<QueryParameter>());
        final CharSequence page = httpClient.get(url);

        try {
            JSONObject head = new JSONObject(page.toString());
            JSONObject location = head.getJSONObject("location");

            return locationFromJSONObject(location);
        } catch (final JSONException x) {
            throw new IOException("cannot parse: '" + page + "' on " + url, x);
        }
    }

    private Location queryLocationByName(String locationName, EnumSet<LocationType> types) throws IOException {
        for (Location location : queryLocationsByName(locationName, types)) {
            if (location.name != null && location.name.equals(locationName)) {
                return location;
            }
        }

        throw new RuntimeException("Cannot find station with name " + locationName);
    }

    private List<Location> queryLocationsByName(String locationName, EnumSet<LocationType> types)
            throws IOException {
        List<QueryParameter> queryParameters = new ArrayList<>();
        queryParameters.add(new QueryParameter("q", locationName));

        if (!types.contains(LocationType.ANY) && types.size() > 0) {
            StringBuilder typeValue = new StringBuilder();
            for (LocationType type : types) {
                for (String addition : locationStringsFromLocationType(type)) {
                    if (typeValue.length() > 0)
                        typeValue.append(",");
                    typeValue.append(addition);
                }
            }
            queryParameters.add(new QueryParameter("type", typeValue.toString()));
        }

        HttpUrl url = buildApiUrl("locations", queryParameters);
        final CharSequence page = httpClient.get(url);

        try {
            JSONObject head = new JSONObject(page.toString());
            JSONArray locations = head.getJSONArray("locations");

            Location[] foundLocations = new Location[locations.length()];
            for (int i = 0; i < locations.length(); i++) {
                foundLocations[i] = locationFromJSONObject(locations.getJSONObject(i));
            }

            return Arrays.asList(foundLocations);
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }
    }

    private Point pointFromLocation(Location location) throws JSONException {
        return new Point(location.lat, location.lon);
    }

    private LocationType locationTypeFromTypeString(String type) throws JSONException {
        switch (type) {
        case "station":
        case "stop":
            return LocationType.STATION;
        case "address":
        case "street":
        case "streetrange":
        case "place":
        case "postcode":
            return LocationType.ADDRESS;
        case "poi":
            return LocationType.POI;
        case "latlong":
            return LocationType.COORD;
        default:
            throw new JSONException("Unsupported location type: " + type);
        }
    }

    private List<String> locationStringsFromLocationType(LocationType type) {
        switch (type) {
        case STATION:
            return Arrays.asList("station", "stop");
        case POI:
            return Arrays.asList("poi");
        case ADDRESS:
            return Arrays.asList("address", "street", "streetrange", "place", "postcode");
        case COORD:
            return Arrays.asList("latlong");
        default:
            return Arrays.asList();
        }
    }

    private EnumSet<Product> productSetFromTypeString(String type) {
        switch (type.toLowerCase()) {
        case "train":
            return EnumSet.of(Product.HIGH_SPEED_TRAIN, Product.REGIONAL_TRAIN, Product.SUBURBAN_TRAIN);
        case "subway":
            return EnumSet.of(Product.SUBWAY);
        case "tram":
            return EnumSet.of(Product.TRAM);
        case "bus":
            return EnumSet.of(Product.BUS);
        case "ferry":
            return EnumSet.of(Product.FERRY);
        case "walk":
            return EnumSet.of(Product.ON_DEMAND);
        default:
            return EnumSet.noneOf(Product.class);
        }
    }

    private Product productFromMode(String type, String name) {
        switch (type.toLowerCase()) {
        case "train":
            switch (name.toLowerCase()) {
            // TODO: Likely not all possible train names, add here if trains are classified incorrectly.
            case "thalys":
            case "ice":
            case "intercity direct":
            case "intercity":
                return Product.HIGH_SPEED_TRAIN;
            case "sprinter":
            default:
                return Product.REGIONAL_TRAIN;
            }
        case "tram":
            return Product.TRAM;
        case "subway":
            return Product.SUBWAY;
        case "bus":
            return Product.BUS;
        case "ferry":
            return Product.FERRY;
        case "walk":
            return Product.ON_DEMAND;
        }

        return null;
    }

    private Date dateFromJSONObject(JSONObject obj, String key) throws JSONException {
        try {
            return dateTimeParser.parse(obj.getString(key));
        } catch (ParseException e) {
            return null;
        }
    }

    private Date timeFromJSONObject(JSONObject obj, String key) throws JSONException {
        try {
            return timeParser.parse(obj.getString(key));
        } catch (ParseException e) {
            return null;
        }
    }

    private Date realtimeDateFromJSONObject(JSONObject obj, String key, String realtimeKey) throws JSONException {
        return dateFromJSONObject(obj, (!obj.isNull(realtimeKey)) ? realtimeKey : key);
    }

    private Trip tripFromJSONObject(JSONObject trip, @Nullable Location from, @Nullable Location to,
            @Nullable Map<String, JSONObject> disturbances) throws JSONException {
        JSONArray legs = trip.getJSONArray("legs");

        Date tripDeparture = realtimeDateFromJSONObject(trip, "departure", "realtimeDeparture");
        /* Date tripArrival = */ realtimeDateFromJSONObject(trip, "arrival", "realtimeArrival");

        // Get journey legs
        LinkedList<Trip.Leg> foundLegs = new LinkedList<>();
        for (int i = 0; i < legs.length(); i++) {
            JSONObject leg = legs.getJSONObject(i);

            JSONArray stops = leg.getJSONArray("stops");
            JSONObject mode = leg.getJSONObject("mode");
            JSONObject operator = leg.optJSONObject("operator");

            LinkedList<Point> foundPoints = new LinkedList<>();

            // First stop
            Stop firstStop = stopFromJSONObject(stops.getJSONObject(0));
            foundPoints.add(pointFromLocation(firstStop.location));

            // Intermediate stops
            LinkedList<Stop> foundStops = new LinkedList<>();
            for (int j = 1; j < stops.length() - 1; j++) {
                foundStops.add(stopFromJSONObject(stops.getJSONObject(j)));
                foundPoints.add(pointFromLocation(foundStops.getLast().location));
            }

            // Last stop
            Stop lastStop = stopFromJSONObject(stops.getJSONObject(stops.length() - 1));
            foundPoints.add(pointFromLocation(lastStop.location));

            switch (leg.getString("type").toLowerCase()) {
            case "scheduled":
                Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name"));

                StringBuilder legMessage = new StringBuilder();

                // Add attributes to leg message
                JSONArray legAttributes = leg.getJSONArray("attributes");
                for (int k = 0; k < legAttributes.length(); k++) {
                    JSONObject legAttribute = legAttributes.getJSONObject(k);

                    if (legMessage.length() > 0)
                        legMessage.append(", ");
                    legMessage.append(WordUtils.capitalizeFirst(legAttribute.getString("title")));
                }

                // Add disturbances to leg message
                if (disturbances != null) {
                    JSONArray legDisturbances = leg.getJSONArray("disturbancePlannerIds");
                    for (int k = 0; k < legDisturbances.length(); k++) {
                        String legDisturbanceId = legDisturbances.optString(k);

                        if (legDisturbanceId != null && disturbances.containsKey(legDisturbanceId)) {
                            JSONObject legDisturbance = disturbances.get(legDisturbanceId);

                            if (legMessage.length() > 0)
                                legMessage.append("<br>\n<br>\n");
                            legMessage.append(legDisturbance.getString("title"));
                            legMessage.append(":<br>\n");
                            legMessage.append(legDisturbance.getString("effect"));
                            legMessage.append(" ");
                            legMessage.append(legDisturbance.getString("measure"));
                        }
                    }
                }

                StringBuilder lineName = new StringBuilder();
                lineName.append(mode.getString("name"));

                // Service codes have no relevant meaning for trains
                if (!leg.isNull("service") && !trainProducts.contains(lineProduct)) {
                    lineName.append(" ");
                    lineName.append(leg.getString("service"));
                }

                foundLegs.add(new Trip.Public(
                        new Line(leg.getString("service"), (operator != null) ? operator.getString("name") : null,
                                lineProduct, lineName.toString(), leg.optString("service"),
                                Standard.STYLES.get(lineProduct), null, null),
                        new Location(LocationType.STATION, null, null, leg.getString("destination")), firstStop,
                        lastStop, foundStops, foundPoints, legMessage.length() > 0 ? legMessage.toString() : null));
                break;
            case "continuous":
                // Get leg time from trip or previous leg
                Date legDeparture = (i == 0) ? tripDeparture : foundLegs.getLast().getArrivalTime();
                Date legArrival = ParserUtils.addMinutes(legDeparture,
                        ParserUtils.parseMinutesFromTimeString(leg.getString("duration")));

                foundLegs.add(new Trip.Individual(Trip.Individual.Type.WALK, firstStop.location, legDeparture,
                        lastStop.location, legArrival, foundPoints, -1));
                break;
            default:
                throw new JSONException("Unknown leg type: " + leg.getString("type"));
            }
        }

        // Get journey fares
        JSONObject fareInfo = trip.getJSONObject("fareInfo");
        JSONArray fareLegs = fareInfo.getJSONArray("legs");

        Fare[] foundFares = new Fare[fareLegs.length()];
        for (int i = 0; i < fareLegs.length(); i++) {
            foundFares[i] = fareFromJSONObject(fareLegs.getJSONObject(i));
        }

        return new Trip(trip.getString("id"), from, to, foundLegs, Arrays.asList(foundFares), null,
                trip.getInt("numberOfChanges"));
    }

    private Stop stopFromJSONObject(JSONObject stop) throws JSONException {
        Position plannedPlatform = positionFromJSONObject(stop, "platform");
        Position changedPlatform = positionFromJSONObject(stop, "platformChange");

        return new Stop(locationFromJSONObject(stop.getJSONObject("location")), dateFromJSONObject(stop, "arrival"),
                dateFromJSONObject(stop, "realtimeArrival"), plannedPlatform, changedPlatform, false,
                dateFromJSONObject(stop, "departure"), dateFromJSONObject(stop, "realtimeDeparture"),
                plannedPlatform, changedPlatform, false);
    }

    private Fare fareFromJSONObject(JSONObject fareLeg) throws JSONException {
        JSONArray fares = fareLeg.getJSONArray("fares");

        float farePrice = 0;
        for (int j = 0; j < fares.length(); j++) {
            JSONObject fare = fares.getJSONObject(j);

            // Always get the full non-reduced 2nd class fare price
            String fareClass = fare.getString("class");
            if (!fare.getBoolean("reduced") && (fareClass.equals("none") || fareClass.equals("second"))) {
                farePrice = (fare.getInt("eurocents") / 100);
                break;
            }
        }

        return new Fare(fareLeg.getString("operatorString"), Fare.Type.ADULT, Currency.getInstance("EUR"),
                farePrice, null, null);
    }

    private Departure departureFromJSONObject(JSONObject departure) throws JSONException {
        JSONObject mode = departure.getJSONObject("mode");

        /* String lineName = */ departure.optString("service");
        Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name"));
        return new Departure(timeFromJSONObject(departure, "time"), timeFromJSONObject(departure, "time"),
                new Line(null, departure.getString("operatorName"), lineProduct,
                        !departure.isNull("service") ? departure.getString("service") : mode.getString("name"),
                        null, Standard.STYLES.get(lineProduct), null, null),
                !departure.isNull("platform") ? new Position(departure.getString("platform")) : null,
                new Location(LocationType.STATION, null, null, departure.getString("destinationName")), null,
                !departure.isNull("realtimeText") ? departure.optString("realtimeText") : null);
    }

    private Position positionFromJSONObject(JSONObject obj, String key) throws JSONException {
        String position = obj.getString(key);
        if (position != null && !position.equals("null")) {
            return new Position(position);
        } else {
            return null;
        }
    }

    private Location locationFromJSONObject(JSONObject location) throws JSONException {
        return locationFromJSONObject(location, true);
    }

    private Location locationFromJSONObject(JSONObject location, boolean addTypePrefix) throws JSONException {
        JSONObject latlon = location.getJSONObject("latLong");
        JSONObject place = location.optJSONObject("place");

        String locationType = location.getString("type");
        String locationName = location.getString("name");
        if (addTypePrefix && !location.isNull(locationType + "Type") && !locationType.equals("poi")) {
            locationName = location.getString(locationType + "Type") + " " + locationName;
        }

        Point locationPoint = Point.fromDouble(latlon.getDouble("lat"), latlon.getDouble("long"));

        return new Location(locationTypeFromTypeString(locationType), location.getString("id"), locationPoint.lat,
                locationPoint.lon, !(place == null) ? place.getString("name") : null, locationName, null);
    }

    private List<Location> solveAmbiguousLocation(Location location) throws IOException {
        if (location.hasId()) {
            return Arrays.asList(location);
        } else if (location.hasLocation()) {
            return queryNearbyLocations(EnumSet.of(location.type), location, -1, -1).locations;
        } else if (location.hasName()) {
            return queryLocationsByName(location.name, EnumSet.of(location.type));
        } else {
            return null;
        }
    }

    private QueryTripsResult ambiguousQueryTrips(Location from, @Nullable Location via, Location to)
            throws IOException {
        List<Location> ambiguousFrom = solveAmbiguousLocation(from);
        if (ambiguousFrom == null || ambiguousFrom.size() <= 0)
            return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_FROM);

        List<Location> ambiguousTo = solveAmbiguousLocation(to);
        if (ambiguousTo == null || ambiguousTo.size() <= 0)
            return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_TO);

        List<Location> ambiguousVia = null;
        if (via != null) {
            ambiguousVia = solveAmbiguousLocation(via);
            if (ambiguousVia == null || ambiguousVia.size() <= 0)
                return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_VIA);
        }

        return new QueryTripsResult(this.resultHeader, ambiguousFrom, ambiguousVia, ambiguousTo);
    }

    private QueryTripsResult queryTrips(HttpUrl url, Location from, @Nullable Location via, Location to)
            throws IOException {
        final CharSequence page;
        try {
            page = httpClient.get(url);
        } catch (InternalErrorException e) {
            return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.SERVICE_DOWN);
        }

        List<Trip> foundTrips = new ArrayList<>();
        String tripsEarlier, tripsLater;
        try {
            final JSONObject head = new JSONObject(page.toString());

            if (head.has("error")) {
                switch (head.getString("error")) {
                case "WithinWalkingDistance":
                    return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.TOO_CLOSE);
                case "DateOutOfRange":
                    return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.INVALID_DATE);
                case "UnknownLocations":
                    String errorDetails = head.getString("details");
                    if (errorDetails.startsWith("From:")) {
                        return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_FROM);
                    } else if (errorDetails.startsWith("Via:")) {
                        return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_VIA);
                    } else if (errorDetails.startsWith("To:")) {
                        return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.UNKNOWN_TO);
                    } else {
                        return new QueryTripsResult(this.resultHeader,
                                QueryTripsResult.Status.UNRESOLVABLE_ADDRESS);
                    }
                default:
                    return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.NO_TRIPS);
                }
            }

            if (head.has("exception")) {
                return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.NO_TRIPS);
            }

            final JSONArray trips = head.optJSONArray("journeys");

            final JSONArray disturbances = head.optJSONArray("disturbances");

            // Prepare disturbances mapping for leg messages
            Map<String, JSONObject> disturbancesMap;
            if (disturbances != null && disturbances.length() > 0) {
                disturbancesMap = new HashMap<>();
                for (int i = 0; i < disturbances.length(); i++) {
                    JSONObject disturbance = disturbances.getJSONObject(i);
                    disturbancesMap.put(disturbance.getString("plannerDisturbanceId"), disturbance);
                }
            } else {
                disturbancesMap = null;
            }

            tripsEarlier = head.optString("earlier");
            tripsLater = head.optString("later");

            for (int i = 0; i < trips.length(); i++) {
                JSONObject trip = trips.getJSONObject(i);

                // Skip impossible or cancelled trips
                JSONObject realtimeInfo = trip.optJSONObject("realtimeInfo");
                if (realtimeInfo != null && ("fatal".equals(realtimeInfo.optString("delays"))
                        || "cancellations".equals(realtimeInfo.optString("cancellations"))))
                    continue;

                foundTrips.add(tripFromJSONObject(trip, from, to, disturbancesMap));
            }
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }

        return new QueryTripsResult(null, url.toString(), from, via, to,
                new TripsContext(url, tripsEarlier, tripsLater, from, via, to), foundTrips);
    }

    @Override
    public Set<Product> defaultProducts() {
        return EnumSet.of(Product.HIGH_SPEED_TRAIN, Product.REGIONAL_TRAIN, Product.SUBURBAN_TRAIN, Product.SUBWAY,
                Product.TRAM, Product.BUS, Product.FERRY);
    }

    @Override
    protected boolean hasCapability(Capability capability) {
        switch (capability) {
        case SUGGEST_LOCATIONS:
        case NEARBY_LOCATIONS:
        case DEPARTURES:
        case TRIPS:
            return true;
        default:
            return false;
        }
    }

    @Override
    public NearbyLocationsResult queryNearbyLocations(EnumSet<LocationType> types, Location location,
            int maxDistance, int maxLocations) throws IOException {
        // Coordinates are required
        if (!location.hasLocation()) {
            try {
                if (location.hasId()) {
                    location = queryLocationById(location.id);
                } else if (location.hasName()) {
                    location = queryLocationByName(location.name, EnumSet.of(location.type));
                }
            } catch (InternalErrorException | NotFoundException | RuntimeException e) {
                return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.INVALID_ID);
            } catch (IOException e) {
                return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.SERVICE_DOWN);
            }

            if (location == null || !location.hasLocation()) {
                return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.INVALID_ID);
            }
        }

        // Default query options
        List<QueryParameter> queryParameters = new ArrayList<>();
        queryParameters
                .add(new QueryParameter("latlong", location.getLatAsDouble() + "," + location.getLonAsDouble()));
        queryParameters.add(new QueryParameter("rows",
                String.valueOf(Math.min((maxLocations <= 0) ? DEFAULT_MAX_LOCATIONS : maxLocations, 100))));

        // Add type if specified
        if (!types.contains(LocationType.ANY) && types.size() > 0) {
            StringBuilder typeValue = new StringBuilder();
            for (LocationType type : types) {
                for (String addition : locationStringsFromLocationType(type)) {
                    if (typeValue.length() > 0)
                        typeValue.append(",");
                    typeValue.append(addition);
                }
            }
            queryParameters.add(new QueryParameter("type", typeValue.toString()));
        }
        HttpUrl url = buildApiUrl("locations", queryParameters);

        CharSequence page;
        try {
            page = httpClient.get(url);
        } catch (InternalErrorException e) {
            return new NearbyLocationsResult(this.resultHeader, NearbyLocationsResult.Status.SERVICE_DOWN);
        }

        // Parse result into location list
        final List<Location> foundLocations = new ArrayList<>();
        try {
            final JSONObject head = new JSONObject(page.toString());
            final JSONArray locations = head.optJSONArray("locations");

            for (int i = 0; i < locations.length(); i++) {
                foundLocations.add(locationFromJSONObject(locations.getJSONObject(i)));
            }
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }

        return new NearbyLocationsResult(new ResultHeader(network, SERVER_PRODUCT), foundLocations);
    }

    @Override
    public QueryDeparturesResult queryDepartures(String stationId, @Nullable Date time, int maxDepartures,
            boolean equivs) throws IOException {
        // The stationId does not need the / character escaped
        HttpUrl url = buildApiUrl("locations/" + stationId + "/departure-times", new ArrayList<QueryParameter>());
        final CharSequence page;
        try {
            page = httpClient.get(url);
        } catch (InternalErrorException | NotFoundException e) {
            return new QueryDeparturesResult(this.resultHeader, QueryDeparturesResult.Status.INVALID_STATION);
        } catch (Exception e) {
            return new QueryDeparturesResult(this.resultHeader, QueryDeparturesResult.Status.SERVICE_DOWN);
        }

        QueryDeparturesResult queryDeparturesResult = new QueryDeparturesResult(this.resultHeader);
        try {
            JSONObject head = new JSONObject(page.toString());
            JSONArray tabs = head.getJSONArray("tabs");
            for (int t = 0; t < tabs.length(); t++) {
                JSONObject tab = tabs.getJSONObject(t);

                JSONArray locations = tab.getJSONArray("locations");
                for (int l = 0; l < locations.length(); l++) {
                    JSONObject location = locations.getJSONObject(l);

                    // Ignore if equivs is false and stationId is not a strict match
                    if (!equivs && !location.getString("id").equals(stationId)) {
                        continue;
                    }

                    // Get list of departures
                    List<Departure> departuresResult = new ArrayList<>();
                    List<LineDestination> lineDestinationResult = new ArrayList<>();

                    JSONArray departures = tab.getJSONArray("departures");
                    for (int i = 0; i < departures.length(); i++) {
                        JSONObject departure = departures.getJSONObject(i);
                        JSONObject mode = departure.getJSONObject("mode");

                        departuresResult.add(departureFromJSONObject(departure));

                        Product lineProduct = productFromMode(mode.getString("type"), mode.getString("name"));
                        lineDestinationResult.add(new LineDestination(
                                new Line(null, departure.getString("operatorName"), lineProduct,
                                        mode.getString("name"), null, Standard.STYLES.get(lineProduct), null, null),
                                new Location(LocationType.STATION, null, 0, 0, null,
                                        departure.getString("destinationName"), EnumSet.of(lineProduct))));
                    }

                    // Add to result object
                    queryDeparturesResult.stationDepartures.add(new StationDepartures(
                            locationFromJSONObject(location), departuresResult, lineDestinationResult));
                }
            }

            return queryDeparturesResult;
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }
    }

    @Override
    public SuggestLocationsResult suggestLocations(CharSequence constraint) throws IOException {
        HttpUrl url = buildApiUrl("locations", Arrays.asList(new QueryParameter("q", constraint.toString())));
        final CharSequence page;
        try {
            page = httpClient.get(url);
        } catch (InternalErrorException e) {
            return new SuggestLocationsResult(this.resultHeader, SuggestLocationsResult.Status.SERVICE_DOWN);
        }

        final List<SuggestedLocation> foundLocations = new ArrayList<>();
        try {
            final JSONObject head = new JSONObject(page.toString());
            final JSONArray locations = head.optJSONArray("locations");

            if (head.has("error")) {
                return new SuggestLocationsResult(this.resultHeader, SuggestLocationsResult.Status.SERVICE_DOWN);
            }

            for (int i = 0; i < locations.length(); i++) {
                JSONObject location = locations.getJSONObject(i);

                foundLocations.add(new SuggestedLocation(locationFromJSONObject(location)));
            }
        } catch (final JSONException x) {
            throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
        }

        return new SuggestLocationsResult(this.resultHeader, foundLocations);
    }

    @Override
    public QueryTripsResult queryTrips(Location from, @Nullable Location via, Location to, Date date, boolean dep,
            @Nullable Set<Product> products, @Nullable Optimize optimize, @Nullable WalkSpeed walkSpeed,
            @Nullable Accessibility accessibility, @Nullable Set<Option> options) throws IOException {
        if (!from.hasId())
            return ambiguousQueryTrips(from, via, to);

        if (!to.hasId())
            return ambiguousQueryTrips(from, via, to);

        // Default query options
        List<QueryParameter> queryParameters = new ArrayList<>(Arrays.asList(new QueryParameter("from", from.id),
                new QueryParameter("to", to.id), new QueryParameter("searchType", dep ? "departure" : "arrival"),
                new QueryParameter("dateTime", new SimpleDateFormat("yyyy-MM-dd'T'HHmm").format(date.getTime())),
                new QueryParameter("sequence", "1"), new QueryParameter("realtime", "true"),
                new QueryParameter("before", "1"), new QueryParameter("after", "5")));

        if (via != null) {
            if (!via.hasId())
                return ambiguousQueryTrips(from, via, to);

            queryParameters.add(new QueryParameter("via", via.id));
        }

        if (walkSpeed != null && walkSpeed == WalkSpeed.SLOW) {
            queryParameters.add(new QueryParameter("interchangeTime", InterchangeTime.EXTRA.toString()));
        } else {
            queryParameters.add(new QueryParameter("interchangeTime", InterchangeTime.STANDARD.toString()));
        }

        // Add trip product options to query
        if (products == null || products.size() == 0) {
            products = defaultProducts();
        }

        queryParameters.add(new QueryParameter("byBus", String.valueOf(products.contains(Product.BUS))));
        queryParameters.add(new QueryParameter("byTrain", String.valueOf(products.contains(Product.HIGH_SPEED_TRAIN)
                || products.contains(Product.REGIONAL_TRAIN) || products.contains(Product.SUBURBAN_TRAIN))));
        queryParameters.add(new QueryParameter("bySubway", String.valueOf(products.contains(Product.SUBWAY))));
        queryParameters.add(new QueryParameter("byTram", String.valueOf(products.contains(Product.TRAM))));
        queryParameters.add(new QueryParameter("byFerry", String.valueOf(products.contains(Product.FERRY))));

        return queryTrips(buildApiUrl("journeys", queryParameters), from, via, to);
    }

    @Override
    public QueryTripsResult queryMoreTrips(QueryTripsContext context, boolean later) throws IOException {
        TripsContext tripContext = (TripsContext) context;

        HttpUrl url;
        if (later && context.canQueryLater()) {
            url = tripContext.getQueryLater();
        } else if (!later && context.canQueryEarlier()) {
            url = tripContext.getQueryEarlier();
        } else {
            return new QueryTripsResult(this.resultHeader, QueryTripsResult.Status.NO_TRIPS);
        }

        return queryTrips(url, tripContext.from, tripContext.via, tripContext.to);
    }
}