Functional Programming in Java, Second Edition: Chapter 10, p.168: Retrieving Airport Info, new code!

As for FinanceData on page 91, the code for AirportInfo uses the suspect and soon-to-be-deprecated java.net.URL. Let’s use java.net.URI instead.

Some additional info on the (apparently RESTful web service) we are interrogating here:

This is the Federal Aviation Administration (FAA) Airport Status Web Service"

https://catalog.data.faa.gov/about

I have not found documentation at the FAA but the service is described at “swaggerhub” ( “SwaggerHub is a collaborative platform for defining your APIs using the [OpenAPI Specification], and managing them throughout their lifecycle.”)

More precisely the API element we use is described as follows:

Get airport status based on path parameter provided on the API call. The path parameter is an IATA airport code.

Experimentally, it returns JSON rather than XML.

Apparently this is only for a (restricted?) set of US airports. As lack would have it, I tried IATA code LUX for Findel airport, Luxembourg, and it gives me back information for US airport identified by the FAA identifier (not a IATA code) LUX, so there is at least something not according to specification in there.

If one accesses the webservice, which can only be done via https, the handshake may fail with an IOException (more precisely an SSLHandshakeException). There may be an anti-DOS device at the webserver. But the Java truststore contains the needed trusted root certificate by default, which is:

  s:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root CA
   i:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root CA
   a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA1
   v:NotBefore: Nov 10 00:00:00 2006 GMT; NotAfter: Nov 10 00:00:00 2031 GMT

To test the TLS handshake on Linux:

echo "Q" | openssl s_client -no_ssl3 -connect "soa.smext.faa.gov:443" -prexit -state -showcerts > OUT.txt 2>&1

See man openssl s_client for more.

Instead of a simple string search, we shall also perform a proper JSON parse using com.fasterxml.jackson which is easy to implement.

We need to add the following to the Maven POM and we are good to go:

        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml -->
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-yaml</artifactId>
            <version>2.15.0</version>
        </dependency>

This allows us to use

https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/2.9.8/com/fasterxml/jackson/databind/ObjectMapper.html

etc. One may note that this library seems to have traded elegance through proper subclassing for speed.

Chapter10_Airport.java

Here we go. This comes with several test cases and a “simple” monadic approach at handling exceptions in a stream, similar to the one in the book, but the latter is more general.

package chapter10;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;

import static chapter10.Exceptional.*;
import static java.util.stream.Collectors.*;

public class Chapter10_Airport {

    // A specific exception thrown by getNameOfAirport()
    // "exceptionhandling/fpij/AirportInfoException.java" p.169

    public static class AirportInfoException extends Exception {
        public AirportInfoException(String message) {
            super(message);
        }
    }

    // The method to perform a web retrieval
    // "exceptionhandling/fpij/AirportInfo.java" p.168 (but unpacked from the surrounding "AirportInfo" class)

    private static String getNameOfAirport(final String iata) throws IOException, AirportInfoException {
        HttpResponse<String> response;
        try {
            final String scheme = "https";
            final String authority = "soa.smext.faa.gov";
            final String path = "/asws/api/airport/status/" + iata;
            final String query = null;
            final String fragment = null;
            final URI uri = new URI(scheme, authority, path, query, fragment);
            System.out.println("Connecting to URI: " + uri);
            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder().uri(uri).build();
            response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        } catch (IOException ioex) {
            throw ioex;
        } catch (Exception other) {
            throw new RuntimeException(other);
        }
        System.out.println("Received status code: " + response.statusCode());
        if (response.statusCode() == 200) {
            // The response is JSON, let's parse it with the Jackson library
            ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());
            JsonNode jnode = objectMapper.readTree(response.body());
            // Printout of the whole JSON tree (not really needed)
            {
                System.out.println(JsonHelpers.describeNode(jnode));
            }
            // Collect
            if (jnode.getNodeType() != JsonNodeType.OBJECT) {
                throw new AirportInfoException("JSON decode: Topmost node is not an 'object'");
            }
            if (jnode.get("Name") == null) {
                throw new AirportInfoException("JSON decode: No 'name' found at expected place");
            }
            if (jnode.get("Name").getNodeType() != JsonNodeType.STRING) {
                throw new AirportInfoException("JSON decode: The 'name' node is not a string value");
            }
            return jnode.get("Name").asText();
        } else {
            throw new IllegalStateException("Status code was: " + response.statusCode());
        }
    }

    // ---
    // A record carrying the result of a web request.
    // We discriminate using an enum instead of just a boolean for readability.
    // ---

    record ResultOrFailure(String iataCode, String name, Exception ex) {

        public enum What {Result, Failure}

        public ResultOrFailure(String iataCode, String name) {
            this(iataCode, name, null);
        }

        public ResultOrFailure(String iataCode, Exception ex) {
            this(iataCode, null, ex);
        }

        public ResultOrFailure(String iataCode, String name, Exception ex) {
            if (iataCode == null || ((name == null) && (ex == null))) {
                throw new IllegalArgumentException();
            }
            this.iataCode = iataCode;
            this.name = name;
            this.ex = ex;
        }

        public What getWhat() {
            return (this.ex() == null) ? What.Result : What.Failure;
        }

        public boolean isResult() {
            return getWhat() == What.Result;
        }

        public boolean isFailure() {
            return getWhat() == What.Failure;
        }

        public String toString() {
            if (getWhat() == What.Result) {
                return iataCode + " = '" + name + "'";
            } else {
                return iataCode + " =>> [" + ex.getMessage() + "]";
            }
        }

        public ResultOrFailure transform(Function<String, String> transformer) {
            if (getWhat() == What.Result) {
                try {
                    return new ResultOrFailure(this.iataCode, transformer.apply(this.name));
                } catch (Exception ex) {
                    return new ResultOrFailure(this.iataCode, ex);
                }
            } else {
                return this;
            }
        }

    }

    // Printout with some dexterous stream processing!
    // whereby the result is partitioned into "error" and "success" lists.

    private static void printResults(final List<ResultOrFailure> results) {
        final Map<ResultOrFailure.What, List<String>> printy = results.stream()
                .collect(groupingBy(
                        ResultOrFailure::getWhat,
                        mapping(ResultOrFailure::toString,
                                collectingAndThen(Collectors.toList(), Collections::unmodifiableList))));
        final ResultOrFailure.What succ = ResultOrFailure.What.Result;
        final ResultOrFailure.What fail = ResultOrFailure.What.Failure;
        if (printy.containsKey(succ)) {
            System.out.println("Obtained names:\n" + String.join("\n", printy.get(succ)));
        }
        if (printy.containsKey(fail)) {
            System.out.println("Errors:\n" + String.join("\n", printy.get(fail)));
        }
    }

    // "exceptionhandling/fpij/AirportNames.java" p.169

    @Test
    void retrieveSingleAirportName() throws AirportInfoException, IOException {
        final String iataCode = "AUS";
        final String name = getNameOfAirport(iataCode);
        System.out.println("Obtained name of airport with IATA code " + iataCode + ": '" + name + "'");
    }

    // Airports to retrieve. "IHA" is an invalid code, so gives rise to an error during fetch.

    private final List<String> iataCodes = List.of("AUS", "DFW", "HOU", "IHA", "SAT");

    // "exceptionhandling/fpij/AirportNames.java" p.169, slightly modified
    // This version commingles successfully retrieved data and error messages in a single result list.
    // Output:
    // AUSTIN-BERGSTROM INTL
    // DALLAS-FORT WORTH INTL
    // WILLIAM P HOBBY
    // Error: JSON decode: No 'name' found at expected place
    // SAN ANTONIO INTL

    @Test
    void retrieveMultipleAirportNames_imperativeLousy() {
        List<String> names = new ArrayList<>();
        for (var iataCode : iataCodes) {
            try {
                names.add(getNameOfAirport(iataCode).toUpperCase());
            } catch (IOException | AirportInfoException ex) {
                names.add("Error: " + ex.getMessage());
            }
        }
        System.out.println(String.join("\n", names));
    }

    // As above, but we have a more complex result than just "String",
    // namely instances of "ResultOrError", which can be processed for much better output.
    // Output:
    // Obtained names:
    // AUS = 'AUSTIN-BERGSTROM INTL'
    // DFW = 'DALLAS-FORT WORTH INTL'
    // HOU = 'WILLIAM P HOBBY'
    // SAT = 'SAN ANTONIO INTL'
    // Errors:
    // IHA =>> [JSON decode: No 'name' found at expected place]

    @Test
    void retrieveMultipleAirportNames_imperativeBetter() {
        final List<ResultOrFailure> results = new ArrayList<>();
        for (var iataCode : iataCodes) {
            try {
                results.add(new ResultOrFailure(iataCode, getNameOfAirport(iataCode).toUpperCase()));
            } catch (IOException | AirportInfoException ex) {
                results.add(new ResultOrFailure(iataCode, ex));
            }
        }
        printResults(results);
    }

    // Code on p.171
    // Note that unlike retrieveMultipleAirportNames_imperative()
    // this code raises an Exception on first error (instead of
    // printing an error and continuing). So there may be no output at all.

    @Test
    void retrieveMultipleAirportNames_streamPipelineBad() {
        final List<String> names =
                iataCodes.stream()
                        .map(iataCode -> {
                            try {
                                return getNameOfAirport(iataCode);
                            } catch (Exception ex) {
                                // Ripping down everything like this is at the least inelegant
                                throw new RuntimeException(ex);
                            }
                        })
                        .map(String::toUpperCase)
                        .toList();
        System.out.println(String.join("\n", names));
    }

    // "Dealing with it downstream"
    // My own take, with no generality at all.
    // The "ResultOrError" structure is carried along the stream.
    // It can even support additional exceptions inside the "transform()"
    // call, becoming an "error" object with the latest exception
    // carried along.

    @Test
    void retrieveMultipleAirportNames_streamPipelineMonadic() {
        final List<ResultOrFailure> results =
                iataCodes.stream()
                        .map(iataCode -> {
                            try {
                                String name = getNameOfAirport(iataCode);
                                return new ResultOrFailure(iataCode, name);
                            } catch (Exception ex) {
                                // this will still let any "Throwable" up the stack
                                return new ResultOrFailure(iataCode, ex);
                            }
                        })
                        .map(it -> it.transform(String::toUpperCase))
                        .toList();
        printResults(results);
    }

    // If we want to "break" the pipeline at the first exception (but the information
    // about the exception is lost)
    // Output:
    // Obtained names:
    // AUS = 'AUSTIN-BERGSTROM INTL'
    // DFW = 'DALLAS-FORT WORTH INTL'
    // HOU = 'WILLIAM P HOBBY'

    @Test
    void retrieveMultipleAirportNames_streamPipelineMonadicTerminateAsap() {
        final List<ResultOrFailure> results =
                iataCodes.stream()
                        .map(iataCode -> {
                            try {
                                String name = getNameOfAirport(iataCode);
                                return new ResultOrFailure(iataCode, name);
                            } catch (Exception ex) {
                                // this will still let any "Throwable" up the stack
                                return new ResultOrFailure(iataCode, ex);
                            }
                        })
                        .map(it -> it.transform(String::toUpperCase))
                        .takeWhile(ResultOrFailure::isResult)
                        .toList();
        printResults(results);
    }

    // If we want to "break" the pipeline at the first exception (but the information
    // about the exception is kept; if this is run in parallel, several exceptions in
    // sequence may be left through .. I think.
    // Output:
    // Obtained names:
    // AUS = 'AUSTIN-BERGSTROM INTL'
    // DFW = 'DALLAS-FORT WORTH INTL'
    // HOU = 'WILLIAM P HOBBY'
    // SAT = 'SAN ANTONIO INTL'
    // Errors:
    // IHA =>> [JSON decode: No 'name' found at expected place]

    @Test
    void retrieveMultipleAirportNames_streamPipelineMonadicTerminateAsapButKeepFirstException() {
        final AtomicBoolean goodStream = new AtomicBoolean(true);
        final List<ResultOrFailure> results =
                iataCodes.stream()
                        .map(iataCode -> {
                            try {
                                String name = getNameOfAirport(iataCode);
                                return new ResultOrFailure(iataCode, name);
                            } catch (Exception ex) {
                                return new ResultOrFailure(iataCode, ex);
                            }
                        })
                        .map(it -> it.transform(String::toUpperCase))
                        .takeWhile(it -> goodStream.get())
                        .peek(it -> {
                            // interrogate & modify "global state"
                            if (it.isFailure()) {
                                goodStream.set(false);
                            }
                        })
                        .toList();
        printResults(results);
    }
}

JsonHelpers.java

This is going too far for the purpose of the book, but if one wants to print the whole JSON tree received from the FAA, this code is handy:

package chapter10;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeType;

import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

class JsonHelpers {

    private static String buildIndentString(int indent) {
        return IntStream.range(0, indent).mapToObj(it -> " ").collect(Collectors.joining());
    }

    private static void addSubDescToBuf(StringBuilder buf, String subDesc, String heading) {
        String[] lines = subDesc.split("\n");
        assert lines.length > 0;
        buf.append(heading);
        buf.append(lines[0]);
        for (int j = 1; j < lines.length; j++) {
            buf.append("\n");
            buf.append(buildIndentString(heading.length()));
            buf.append(lines[j]);
        }
    }

    private static String getSuffix(int counter) {
        return (counter == 1) ? "" : "s";
    }

    private static int countElements(JsonNode node) {
        int counter = 0;
        for (Iterator<JsonNode> it = node.elements(); it.hasNext(); it.next()) {
            counter++;
        }
        assert counter == node.size();
        return counter;
    }

    private static List<String> collectFieldNames(JsonNode node) {
        assert node.isObject();
        List<String> res = new LinkedList<>();
        for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
            res.add(it.next().getKey());
        }
        return res;
    }

    private static String describeArray(JsonNode node) {
        StringBuilder buf = new StringBuilder();
        int count = countElements(node);
        buf.append("== Array of size " + count + " ==");
        for (int i = 0; i < count; i++) {
            buf.append("\n");
            String heading = "[" + i + "] ";
            String subDesc = describeNode(node.get(i));
            addSubDescToBuf(buf, subDesc, heading);
        }
        return buf.toString();
    }

    private static String describeObject(JsonNode node) {
        StringBuilder buf = new StringBuilder();
        List<String> fieldNames = collectFieldNames(node);
        int count = fieldNames.size();
        buf.append("== Object of size " + count + " ==");
        for (String fieldName : fieldNames) {
            buf.append("\n");
            String heading = "[" + fieldName + "] ";
            String subDesc = describeNode(node.get(fieldName));
            addSubDescToBuf(buf, subDesc, heading);
        }
        return buf.toString();
    }

    public static String describeNode(JsonNode node) {
        StringBuilder buf = new StringBuilder();
        if (node == null) {
            buf.append("(null)");
        } else if (node.isValueNode()) {
            assert !node.isContainerNode();
            assert !node.isArray();
            assert !node.isObject();
            if (node.getNodeType() == JsonNodeType.STRING) {
                buf.append("'");
                buf.append(node.asText());
                buf.append("'");
            } else {
                buf.append(node.asText());
            }
            buf.append(" (" + node.getNodeType().toString().toLowerCase() + ")");
        } else {
            assert node.isContainerNode();
            assert !node.isValueNode();
            assert node.isArray() == !node.isObject(); // XOR
            if (node.isArray()) {
                buf.append(describeArray(node));
            } else {
                buf.append(describeObject(node));
            }
        }
        return buf.toString();
    }

}

Running it on “AUS”

Connecting to URI: https://soa.smext.faa.gov/asws/api/airport/status/AUS
Received status code: 200
== Object of size 10 ==
[Name] 'Austin-bergstrom Intl' (string)
[City] 'Austin' (string)
[State] 'TX' (string)
[ICAO] 'KAUS' (string)
[IATA] 'AUS' (string)
[SupportedAirport] false (boolean)
[Delay] false (boolean)
[DelayCount] 0 (number)
[Status] == Array of size 1 ==
         [0] == Object of size 1 ==
             [Reason] 'No known delays for this airport' (string)
[Weather] == Object of size 5 ==
          [Weather] == Array of size 1 ==
                    [0] == Object of size 1 ==
                        [Temp] == Array of size 1 ==
                               [0] 'A Few Clouds' (string)
          [Visibility] == Array of size 1 ==
                       [0] 10.0 (number)
          [Meta] == Array of size 1 ==
                 [0] == Object of size 3 ==
                     [Credit] 'NOAA's National Weather Service' (string)
                     [Url] 'https://weather.gov/' (string)
                     [Updated] 'Last Updated on May 18 2023, 5:53 am CDT' (string)
          [Temp] == Array of size 1 ==
                 [0] '62.0 F (16.7 C)' (string)
          [Wind] == Array of size 1 ==
                 [0] 'North at 0.0' (string)
Obtained name of airport with IATA code AUS: 'Austin-bergstrom Intl'