JVM Advent

The JVM Programming Advent Calendar

Wrapping up the year with powerful Java language features

Whether you are a beginner or senior Java developer, you strive to accomplish ambitious goals through your code while enjoying incremental progress. Along with many performance, stability, and security updates, Java 21 delivers new features and enhancements aiming to boost Java development productivity.  And the best way to learn these language features is by using them in a Java project.

Setup

As the winter festivities approach, let’s build a Java application where you can order a wrapped gift for someone. Project wrapup is a simple http handler implementation that returns a gift as JSON from a sender to a receiver via HTTP POST method.

Before jumping into action, you should know that you need an IDE, at least  JDK 21 and maven installed on your local machine to reproduce the examples. I generated my project with Oracle Java Platform Extension for Visual Studio Code via View > Command Palette > Java: New Project > Java with Maven, named the project wrapup and chose the package name org.ammbra.advent.

So, let’s check out how we can use Java 21 language constructs to package gifts as JSONs.

Towards a simplified beginning with Java

The IDE generated project contains a starter class Wrappup.java in the package org.ammbra.advent.

package org.ammbra.advent;

public class Wrapup {

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Although the main method declares arguments, those are not later processed within its scope. In JDK 21,  JEP 445  introduced unnamed classes and instance main methods as a preview feature to reduce the verbosity when writing simple programs. In consequence, you can refactor the previous code to:

package org.ammbra.advent; 

class Wrapup {

    void main() { System.out.println("Hello, World!");}
}

To run the previous snippet, go to a terminal window and type the following command:

java --enable-preview --source 21 \         src/main/java/org/ammbra/advent/Wrapup.java

For the moment, let’s evolve the Wrapup class to process only HTTP POST requests and produce a JSON output, by implementing com.sun.net.httpserver.HttpHandler.

record Wrapup() implements HttpHandler {

    void main() throws IOException {
       var server = HttpServer.create(
             new InetSocketAddress("", 8081), 0);
       var address = server.getAddress();
       server.createContext("/", new Wrapup());
       server.setExecutor(
             Executors.newVirtualThreadPerTaskExecutor()
       );
       server.start();
       System.out.printf("http://%s:%d%n",
             address.getHostString(), address.getPort());
    }

    @Override
    public void handle(HttpExchange exchange)
          throws IOException {

       int statusCode = 200;

       String requestMethod = exchange.getRequestMethod();
       if (!"POST".equalsIgnoreCase(requestMethod)) {
          statusCode = 400;
       }

       // Get the request body input stream
       InputStream reqBody = exchange.getRequestBody();

       // Read JSON from the input stream
       JSONObject req = RequestConverter.asJSONObject(reqBody);

       String sender = req.optString("sender");
       String receiver = req.optString("receiver");
       String message = req.optString("celebration");
       String json = "{'receiver':'" + receiver
             + "', 'sender':'" + sender
             + "','message':'" + message + "'}";

       exchange.sendResponseHeaders(statusCode, 0);
       try (var stream = exchange.getResponseBody()) {
          stream.write(json.getBytes());
       }
    }
}

To launch the program, go to the terminal window and run the following commands:

#export path to .m2 json library
export $JSON_PATH=/<YOU>/.m2/repository/org/json/json/20231013

#launch the app
java -classpath target/classes:$JSON_PATH/json-20231013.jar \
  --enable-preview --source 21 \
  src/main/java/org/ammbra/advent/Wrapup.java

Let’s try a simple curl request to check the output:

curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"Happy New Year!"}'

You should receive the following response:

 {'receiver':'Duke', 'sender':'Ana','message':'Happy New Year!'}

Greeting someone is a nice gesture, but the application should also serve more complex responses when the sender wishes to send a more substantial gift. To address that, let’s model the application domain.

Data modelling with records and sealed types

Gifting on a special occasion can vary from a postcard to a more substantial gift.

For the wrapup project let’s consider the following requirements:

  • A sender can do a nice gesture and offer a gift.
  • A gift can be either a postcard or add to it one of the following:  an online coupon, buy an experience or a material present.
  • A postcard does not have an associated cost, all the other 3 types of gifts have a price.
  • An online coupon has an expiry date.
  • A present can be placed inside a box, which has an extra cost.
  • A sender can give a different postcard or surprise depending on celebration, but never send 2 postcards as a gift.
System Class Diagram

Fig.1 System Class Diagram

The diagram above shows a possible way to model previously described scenario. Postcard,Coupon, Experience and Presentare records because they should be carriers of immutable data representing possible surprise options.  They also share a common formatting process to JSON through the sealed interface Intention.

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public sealed interface Intention
       permits Coupon, Experience, Present, Postcard {

    JSONObject asJSON();
}

A Gift is another record type containing a Postcard and an Intention.

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public record Gift(Postcard postcard, Intention intention) {

    public JSONObject merge(String option) {
       JSONObject intentionJSON = intention.asJSON();
       JSONObject postcardJSON = postcard.asJSON();
       return postcardJSON.put(option, intentionJSON);
    }
}

Celebration is an enum storing the defined occasions for sending a gift. Depending on the value of the Choice enum, wrapup will return the appropriate gift in JSON format. Next, let’s define Coupon, Experience, Postcard and Present records and format their data using String templates.

Syntax flexibility with expressive String templates

The sealed interface Intention limits inheritance, by only allowing specific subtypes, but is also a useful language construct to communicate the purpose of Coupon, Experience, Postcard and Present records.

For example, the characteristics of a Coupon object are its price, the date when it expires and the currency of its cost. As a gift representation should follow a JSON format, let’s leverage string templates to achieve that.

String templates became available as a preview feature in Java 21 and mix literal text with embedded expressions and template processors to produce specialized results, like JSONObject.  To return a JSONObject, a template expression would need:

  • A template processor (JSON)
  • A dot character (U+002E) and
  • A template which contains an embedded expression (Coupon record fields).

The Coupon, Experience, Postcard and Present records can share the same template processor from String to JSON:

package org.ammbra.advent.surprise;

import org.json.JSONObject;

public sealed interface Intention
       permits Coupon, Experience, Present, Postcard {

    StringTemplate.Processor<JSONObject, RuntimeException> JSON = StringTemplate.Processor.of(
          (StringTemplate st) -> new JSONObject(st.interpolate())
    );

    JSONObject asJSON();
}

And with this template processor, the Coupon record becomes:

package org.ammbra.advent.surprise;

import org.json.JSONObject;
import java.time.LocalDate;
import java.util.Currency;

public record Coupon(double price, LocalDate expiringOn, Currency currency)
       implements Intention {

    @Override
    public JSONObject asJSON() {
       return JSON. """
             {
                 "currency": "\{currency}",
                 "expiresOn" : "\{ expiringOn}",
                 "cost": "\{price}"
             }
             """ ;
    }
}

Experience and Postcard records share a similar template formatting logic. As the cost of a Present varies depending on the gift-wrapping cost, the asJSON method implementation looks as follows:

package org.ammbra.advent.surprise;

import org.json.JSONObject;
import java.util.Currency;

public record Present(double itemPrice, double boxPrice,
                  Currency currency) implements Intention {

    @Override
    public JSONObject asJSON() {

       return JSON. """
             {
                 "currency": "\{currency}",
                 "boxPrice": "\{boxPrice}",
                 "packaged" : "\{ boxPrice > 0.0}",
                 "cost": "\{(boxPrice > 0.0) ? itemPrice + boxPrice : itemPrice}"
             }
             """ ;
    }
}

Now that the project has each element of the data model, let’s investigate how to prototype the HTTP response containing the gift as JSON.

A clear control flow with pattern matching in switch expressions

An user of the wrapup application should be able to emit different requests to send a personalized gift to someone:

#send a postcard with a greeting for current year
curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"CURRENT_YEAR", "type":"NONE"}' 

#send a coupon and a postcard with a greeting for current year curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \ 
  -d '{"receiver":"Duke","sender":"Ana","celebration":"CURRENT_YEAR", "option":"COUPON", "itemPrice": "24.2"}' 

#send a birthday present and postcard
curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"BIRTHDAY", "option ":"PRESENT", "itemPrice": "27.8", "boxPrice": "2.0"}'  

#send a happy new year postcard and an experience 
curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"NEW_YEAR", "option ":"EXPERIENCE", "itemPrice": "47.5"}'

To support all these operations, the behaviour of HTTPHandler should be capable to process each of these request bodies and return an appropriate gift as JSON. Given the complexity of the POST request body,  let’s represent it as a record which builds based on potential data:

package org.ammbra.advent.request;

import org.ammbra.advent.surprise.Celebration;

public record RequestData(String sender, String receiver,
                     Celebration celebration, Choice choice, 
                     double itemPrice, double boxPrice) {

    private RequestData(Builder builder) {
       this(builder.sender, builder.receiver,
             builder.celebration, builder.choice,
             builder.itemPrice, builder.boxPrice);
    }

    public static class Builder {
       private String sender;
       private String receiver;
       private Celebration celebration;
       private Choice choice;
       private double itemPrice;
       private double boxPrice;

       public Builder sender(String sender) {
          this.sender = sender;
          return this;
       }

       public Builder receiver(String receiver) {
          this.receiver = receiver;
          return this;
       }

       public Builder celebration(Celebration celebration) {
          this.celebration = celebration;
          return this;
       }

       public Builder choice(Choice choice) {
          this.choice = choice;
          return this;
       }

       public Builder itemPrice(double itemPrice) {
          this.itemPrice = itemPrice;
          return this;
       }

       public Builder boxPrice(double boxPrice) {
          this.boxPrice = boxPrice;
          return this;
       }

       public RequestData build() throws IllegalStateException {
          return new RequestData(this);
       }
    }
}

RequestData uses an alternative constructor to pass the Builder instance to the record constructor. With this record definition, the logic inside handle(HttpExchange exchange) method refactors to:

@Override
public void handle(HttpExchange exchange) throws IOException {
    // ...

    // Get the request body input stream
    InputStream reqBody = exchange.getRequestBody();

    // Read JSON from the input stream
    JSONObject req = RequestConverter.asJSONObject(reqBody);
    RequestData data = RequestConverter.fromJSON(req);

// ... }

Next, let’s evaluate the surprise content based on the gift option present in the request and make sure each case is treated accordingly using an exhaustive switch expression:

double price = data.itemPrice();
double boxPrice = data.boxPrice();
Choice choice = data.choice();
Intention intention = switch (choice) {
    case NONE -> {
       Currency usd = Currency.getInstance("USD");
       yield new Coupon(0.0, null, usd);
    }
    case COUPON -> {
       Currency usd = Currency.getInstance("USD");
       LocalDate localDate = LocalDateTime.now()
            .plusYears(1).toLocalDate();
       yield new Coupon(itemPrice, localDate, usd);
    }
    case EXPERIENCE -> {
       Currency eur = Currency.getInstance("EUR");
       yield new Experience(itemPrice, eur);
    }
    case PRESENT -> {
       Currency ron = Currency.getInstance("RON");
       yield new Present(itemPrice, boxPrice, ron);
    }
};

Without a default branch,  adding new Choice values will lead to compilation errors, which will make us consider how to handle those new cases.

As the gift intention is now clear, let’s process the final JSONObject response by using pattern matching for switch.

Postcard postcard = new Postcard(data.sender(), data.receiver(), data.celebration());
Gift gift = new Gift(postcard, intention);

JSONObject json = switch (gift) {
    case Gift(Postcard p1, Postcard p2) -> {
       String message = "You cannot send two postcards!";
       throw new UnsupportedOperationException(message);
    }
    case Gift(Postcard p, Coupon c)
          when (c.price() == 0.0) -> p.asJSON();
    case Gift(Postcard p, Coupon c) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option); 
    }
    case Gift(Postcard p, Experience e) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option); 
    }
    case Gift(Postcard p, Present pr) -> {
       String option = choice.name().toLowerCase(); 
       yield gift.merge(option);
    }
};

In this scenario, the switch expression uses the nested record pattern of Gift to determine the final JSON. As mentioned in the initial requirements, a sender cannot send two postcards as a gift so that operation is not supported. Another special scenario is when the sender offers only a complimentary postcard and the final gift has no associated cost. Hence, the switch expression first treats this situation in a guarded case label – case Gift(Postcard p, Coupon c) when (c.price() == 0.0) –  because an unguarded pattern case label –case Gift(Postcard p, Coupon c)– dominates the guarded pattern case label with the same pattern.

Records and record patterns are great to streamline data processing, but Wrapup  program needed only some of the components for further processing.

Concise code with unnamed patterns and variables

When a switch executes the same action for multiple cases, you can improve its readability by using unnamed pattern variables.  The unnamed patterns and variables became a preview feature in JDK 21 and target to be finalized in JDK 22 (see JEP 456).

Some cases from the previous switch expression required Postcard, Coupon, Experience and Present, but never used further values from these records. After refactoring the switch with unnamed pattern variables, it becomes:

Gift gift = new Gift(postcard, intention);

JSONObject json = switch (gift) {
    case Gift(Postcard _, Postcard _) -> {
       String message = "You cannot send two postcards!";
       throw new UnsupportedOperationException(message);
    }

    case Gift(Postcard p, Coupon c)
          when (c.price() == 0.0) -> p.asJSON();

    case Gift(_, Coupon _), Gift(_, Experience _), 
          Gift(_, Present _) -> {
       String option = choice.name().toLowerCase();
       yield gift.merge(option);
    }
};

Now that the Wrapup implementation reached its final state, build the project and launch it again from a terminal window:

#build the project
mvn clean verify

#launch the app 
java -classpath target/classes:$JSON_PATH/json-20231013.jar \ 
  --enable-preview --source 21 \  src/main/java/org/ammbra/advent/Wrapup.java

and issue a POST request via curl:

curl -X POST http://127.0.0.1:8081 \
  -H 'Content-Type: application/json' \
  -d '{"receiver":"Duke","sender":"Ana","celebration":"NEW_YEAR", "option ":"EXPERIENCE", "itemPrice": "47.5"}'

If you would like to further try the code used in this article, go to the  wrapup repository.

Final thoughts

JDK 21’s preview features like string templates, unnamed patterns, variables, unnamed classes and instance main methods help you minimize the amount of repetitive and verbose code, enabling you to express intent more clearly and concisely.  Use records and sealed types to model your domain and enable a powerful form of data navigation and processing with record patterns and pattern matching for switch. As the year comes to a close, I encourage to try these features to boost your productivity with Java.

Author: Ana-Maria Mihalceanu

Ana is a Java Champion Alumni, Developer Advocate, guest author of the book “DevOps tools for Java Developers”, and a constant adopter of challenging technical scenarios involving Java-based frameworks and multiple cloud providers. She actively supports technical communities’ growth through knowledge sharing and enjoys curating content for conferences as a program committee member. To learn more about/from her, follow her on Twitter @ammbra1508 or on Mastodon @ammbra1508.mastodon.social.

Next Post

Previous Post

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

© 2024 JVM Advent | Powered by steinhauer.software Logosteinhauer.software

Theme by Anders Norén