Monday, February 18, 2019

Java date-time timezone formats

Java has excellent date-time formatting with the arrival of java.time (JSR310) in Java 8. I point out that release as it came with a usable, safe API. (Let us never speak of Calendar again).

However, I never recall how to format timezone. There are so many options, and it is easy to get is "almost right", but not exactly right.

Problem

I'd like to append a "Z" character on the end of a UTC timestamp. OK, let's look at the options, showing only those for timezone/offset:

Symbol Meaning Presentation Examples
V time-zone ID zone-id America/Los_Angeles; Z; -08:30
v generic time-zone name zone-name Pacific Time; PT
z time-zone name zone-name Pacific Standard Time; PST
O localized zone-offset offset-O GMT+8; GMT+08:00; UTC-08:00
X zone-offset 'Z' for zero offset-X Z; -08; -0830; -08:30; -083015; -08:30:15
x zone-offset offset-x +0000; -08; -0830; -08:30; -083015; -08:30:15
Z zone-offset offset-Z +0000; -0800; -08:00

One thing to be wary of: formatting characters can be doubled, tripled, or quadrupled, and it changes the result. Further, some characters have special rules on repeating (eg, "VV", and "O" vs "OOOO").

The best way to understand what to use is to try them all:

final var when = ZonedDateTime.of(
        LocalDate.of(2011, 2, 3),
        LocalTime.of(14, 5, 6, 7_000_000),
        ZoneId.of("UTC"))
        .toInstant();
for (final String tzFormat
        : List.of("VV", "v", "z", "zz", "zzz", "zzzz", "O", "OOOO", "X", "XX", "XXX",
        "XXXX", "x", "xx", "xxx", "xxxx", "Z", "ZZ", "ZZZ", "ZZZZ")) {
    System.out.println(
            tzFormat + " - " + DateTimeFormatter
                    .ofPattern("yyyy-MM-dd'T'HH:mm:ss" + tzFormat)
                    .withZone(ZoneId.of("UTC"))
                    .format(when));
}

Producing:

VV - 2011-02-03T14:05:06UTC
v - 2011-02-03T14:05:06UTC
z - 2011-02-03T14:05:06UTC
zz - 2011-02-03T14:05:06UTC
zzz - 2011-02-03T14:05:06UTC
zzzz - 2011-02-03T14:05:06Coordinated Universal Time
O - 2011-02-03T14:05:06GMT
OOOO - 2011-02-03T14:05:06GMT
X - 2011-02-03T14:05:06Z
XX - 2011-02-03T14:05:06Z
XXX - 2011-02-03T14:05:06Z
XXXX - 2011-02-03T14:05:06Z
x - 2011-02-03T14:05:06+00
xx - 2011-02-03T14:05:06+0000
xxx - 2011-02-03T14:05:06+00:00
xxxx - 2011-02-03T14:05:06+0000
Z - 2011-02-03T14:05:06+0000
ZZ - 2011-02-03T14:05:06+0000
ZZZ - 2011-02-03T14:05:06+0000
ZZZZ - 2011-02-03T14:05:06GMT

What an exciting list! "zzzz" is rather wordy, and it's unclear what "ZZZZ" is doing. Actually, the whole list is even more iteresting for timezones other than UTC.

Solution

Since the goal is to append a "Z", the simplest choice is: yyyy-MM-dd'T'HH:mm:ssX.

Addendum

Why didn't I just use DateTimeFormatter.ISO_INSTANT, which is documented to produce the "Z"? I want a timestamp that is to only seconds-precision, and the format for "ISO_INSTANT" includes milliseconds.

Friday, January 18, 2019

Spring REST testing

After too much Internet searching, I was unable to find an easy solution to repeated duplication in my Spring MockMVC tests of REST controller endpoints. For years now, the endpoints we write have typically sent or received JSON. This is what I mean:

mockMvc.perform(post("/some/endpoint")
        .contentType(APPLICATION_JSON_UTF8)
        .accept(APPLICATION_JSON_UTF8)
        .content(someRequestJson))
        .andExpect(status().isCreated())
        .andExpect(header().string(CONTENT_TYPE, APPLICATION_JSON_UTF8_VALUE))
        .andExpect(header().string(LOCATION, "/some/endpoint/name-or-id"))
        .andExpect(content().json(someResponseJson));

All the repeated "APPLICATION_JSON_UTF8"s, in every controller test!

If there is an existing Spring testing solution, I'd love to hear about it. Rather than wait, I wrote up a small extension of @WebMvcTest to default these values.

First, an annotation for Spring to use in setting up a MockMvc (javadoc elided):

@Documented
@Import(JsonMockMvcConfiguration.class)
@Retention(RUNTIME)
@Target(TYPE)
@WebMvcTest
public @interface JsonWebMvcTest {
    @AliasFor(annotation = WebMvcTest.class)
    String[] properties() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] value() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] controllers() default {};

    @AliasFor(annotation = WebMvcTest.class)
    boolean useDefaultFilters() default true;

    @AliasFor(annotation = WebMvcTest.class)
    ComponentScan.Filter[] includeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    ComponentScan.Filter[] excludeFilters() default {};

    @AliasFor(annotation = WebMvcTest.class)
    Class[] excludeAutoConfiguration() default {};
}

Note it is a near exact lookalike of @WebMvcTest (minus the deprecated parameter). The important bits are:

  1. Marking this annotation with @WebMvcTest, a kind of extension through composition.
  2. Adding @Import to bind custom configuration to this annotation.
  3. Tying the same-named annotation parameters to @WebMvcTest, so this annotation is a drop-in replacement of that one.

Next a configuration class, imported by the annotation, to customize MockMvc:

@Configuration
public class JsonMockMvcConfiguration {
    @Bean
    @Primary
    public MockMvc jsonMockMvc(final WebApplicationContext ctx) {
        return webAppContextSetup(ctx)
                .defaultRequest(post("/")
                        .contentType(APPLICATION_JSON_UTF8)
                        .accept(APPLICATION_JSON_UTF8_VALUE))
                .alwaysExpect(header().string(
                        CONTENT_TYPE, APPLICATION_JSON_UTF8_VALUE))
                .build();
    }
}

Some points about this class:

  • @Primary is not necessary for Spring, but helped IntelliJ — perhaps I got lucky with Spring without @Primary, and IntelliJ highlighted a real problem.
  • It took quite a while to get defaultRequest(...) working. I was unable to (re)implement the relevant interfaces, and eventually found that passing any MockHttpServletRequestBuilder sufficed. Spring "merges" (overlays) the actual request builder from the test over this default, replacing POST and "/" with whichever HTTP method and path the test uses (eg, GET "/bob"). Only the header customization is used.

Example usage:

@JsonWebMvcTest(SomeController.class)
class SomeControllerTest {
    @Autowired
    private MockMvc jsonMockMvc;

    @Test
    void shouldCheckSomething()
            throws Exception {
        jsonMockMvc.perform(post("/some/endpoint")
                .content(someRequestJson))
                .andExpect(status().isCreated())
                .andExpect(header()
                        .string(LOCATION, "/some/endpoint/new-name"))
                .andExpect(content().json(someResponseJson));
    }
}

See the Basilisk project for source code and sample usage. (Basilisk is a demonstration project for my team illustrating Spring usage and conventions.)

Wednesday, January 09, 2019

Magic Bus returns

During my first stint at ThoughtWorks, I paired with Gregor Hohpe on implementing messaging patterns while he worked with Bobby Woolf on Enterprise Integration Patterns (EIP). To this day, this remains one of my favorite technical books. In conversation I was always struck by Gregor's meticulous "napkin diagrams" as he illustrated the point he was making.

One output from that pairing was to experiment with using messaging patterns within a single program, not just between programs. So I wrote the "Magic Bus" library in Java, using reflection, to connect publishing and subscribing components within a web services backend.

While working a new project, I find myself diagramming one of our backend services using EIP's notations for messaging patterns. And I recalled "Magic Bus".

I thought I had long ago lost the source code, but found some JVM .class files in a forgotten directory. IntelliJ to the rescue! Using JetBrain's excellent Fernflower decompiler, I recovered a later stage of "Magic Bus" after I had converted it to typesafer generics and dropped reflection.

That code is now in public GitHub, brought up to Java 11, and cleaned up.

If I recall correctly, I originally dropped "Magic Bus" after Guava's Event Bus came along. What makes "Magic Bus" different from Event Bus? Not too much, actually. The main feature in "Magic Bus" lacking in Event Bus is subscribing to message handler exceptions: in Guava one instead registers a global callback to handle exceptions.

Monday, January 07, 2019

Hard-won JDK offset knowedge

It took far more research time than I expected. The goal: Output an OffsetDateTime with offset for Zulu (OTC) timezone as +00:00.

I have a project where a 3rd-party JSON exchange expected timestamps in the format 01-02-03T04:05:06+00:00. We're using Jackson in a Java project. All the default configuration I could find, and trying all the "knobs" on Jackson I could find, led to: 01-02-03T04:05:06Z. Interesting, as any non-0 offset for timezone produced: 01-02-03T04:05:06+07:00 rather than a timezone abbreviation: Zero offset is special.

Finally, circling back to the JDK javadocs yet again, I spotted what I had overlooked many times before:

Offset X and x: This formats the offset based on the number of pattern letters. One letter outputs just the hour, such as '+01', unless the minute is non-zero in which case the minute is also output, such as '+0130'. Two letters outputs the hour and minute, without a colon, such as '+0130'. Three letters outputs the hour and minute, with a colon, such as '+01:30'. Four letters outputs the hour and minute and optional second, without a colon, such as '+013015'. Five letters outputs the hour and minute and optional second, with a colon, such as '+01:30:15'. Six or more letters throws IllegalArgumentException. Pattern letter 'X' (upper case) will output 'Z' when the offset to be output would be zero, whereas pattern letter 'x' (lower case) will output '+00', '+0000', or '+00:00'.

The key is to use lowercase 'x' in the format specification. So my problem with Jackson became:

    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssxxx")
    private final OffsetDateTime someOffsetDateTime;

And the result is the desired, 01-02-03T04:05:06+00:00.

Now I can return to more interesting problems.