<NME> JobSearchResult.java
<BEF> /*
 *
 *  Copyright 2016 Netflix, Inc.
 *
 *     Licensed under the Apache License, Version 2.0 (the "License");
 *     you may not use this file except in compliance with the License.
 *     You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 *     Unless required by applicable law or agreed to in writing, software
 *     distributed under the License is distributed on an "AS IS" BASIS,
 *     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *     See the License for the specific language governing permissions and
 *     limitations under the License.
 *
 */
package com.netflix.genie.common.dto.search;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer;
import com.netflix.genie.common.dto.JobStatus;
import com.netflix.genie.common.util.JsonUtils;
import com.netflix.genie.common.util.TimeUtils;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import javax.annotation.Nullable;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;

/**
 * This class represents the subset of data returned from a Job when a search for Jobs is conducted.
 *
 * @author tgianos
 * @since 3.0.0
 */
@Getter
@EqualsAndHashCode(callSuper = true)
public class JobSearchResult extends BaseSearchResult {

    private static final long serialVersionUID = -3886685874572773514L;

    private final JobStatus status;
    @JsonSerialize(using = JsonUtils.OptionalInstantMillisecondSerializer.class)
    private final Instant started;
    @JsonSerialize(using = JsonUtils.OptionalInstantMillisecondSerializer.class)
    private final Instant finished;
    private final String clusterName;
    private final String commandName;
    @JsonSerialize(using = ToStringSerializer.class)
    @JsonDeserialize(using = DurationDeserializer.class)
    private final Duration runtime;

    /**
     * Constructor.
     *
     * @param id          The id of the job
     * @param name        The name of the job
     * @param user        The user of the job
     * @param status      The current status of the job
     * @param started     The start time of the job
     */
    @JsonCreator
    public JobSearchResult(
        @JsonProperty("id") @NotBlank final String id,
        @JsonProperty("name") @NotBlank final String name,
        @JsonProperty("user") @NotBlank final String user,
        @JsonProperty("status") @NotNull final JobStatus status,
        @JsonProperty("started") @Nullable final Instant started,
        @JsonProperty("finished") @Nullable final Instant finished,
        @JsonProperty("clusterName") @Nullable final String clusterName,
        @JsonProperty("started") @Nullable final Instant started,
        @JsonProperty("finished") @Nullable final Instant finished,
        @JsonProperty("clusterName") @Nullable final String clusterName,
        @JsonProperty("commandName") @Nullable final String commandName
    ) {
        super(id, name, user);
        this.status = status;
        this.started = started;
        this.finished = finished;
        this.clusterName = clusterName;
        this.commandName = commandName;

        this.runtime = TimeUtils.getDuration(this.started, this.finished);
    }

    /**
     * Constructor.
     *
     * @param id          The id of the job
     * @param name        The name of the job
     * @param user        The user of the job
     * @param status      The current status of the job
     * @param started     The start time of the job
     * @param finished    The finish time of the job
     * @param clusterName The name of the cluster this job is or was run on
     * @param commandName The name fo the command this job is or was run with
     * @throws IllegalArgumentException If the status string can't be parsed into a {@link JobStatus}
     */
    public JobSearchResult(
        @NotBlank final String id,
        @NotBlank final String name,
        @NotBlank final String user,
        @NotBlank final String status,
        @Nullable final Instant started,
        @Nullable final Instant finished,
        @Nullable final String clusterName,
        @Nullable final String commandName
    ) throws IllegalArgumentException {
        super(id, name, user);
        this.status = JobStatus.valueOf(status);
        this.started = started;
        this.finished = finished;
        this.clusterName = clusterName;
        this.commandName = commandName;

        this.runtime = TimeUtils.getDuration(this.started, this.finished);
    }

    /**
     * Get the time the job started.
     *
     * @return The started time or null if not set
     */
    public Optional<Instant> getStarted() {
        return Optional.ofNullable(this.started);
    }

    /**
     * Get the time the job finished.
     *
     * @return The finished time or null if not set
     */
    public Optional<Instant> getFinished() {
        return Optional.ofNullable(this.finished);
    }

    /**
     * Get the name of the cluster running the job if there currently is one.
     *
     * @return The name of the cluster where the job is running
     */
    public Optional<String> getClusterName() {
        return Optional.ofNullable(this.clusterName);
    }

    /**
     * Get the name of the command running this job if there currently is one.
     *
     * @return The name of the command
     */
    public Optional<String> getCommandName() {
        return Optional.ofNullable(this.commandName);
    }
}

<MSG> Explicitly mark API required fields to aid JSON deserialization

Fields marked JsonProperty are implicitly optional, so missing required fields are not caught at the API layer.
Genie detects the bad request later in the lifecycle, when checking the validity of the materialized DTO.

This results in error 412 with a plaintext response error message.
By marking the required fields as such, the request is caught earlier and produces a more correct response code (400) accompanied by a JSON message.

Example, submitting a job that is missing the required 'user' field:

 ~ Before this change ~

< HTTP/1.1 412
< Content-Type: text/plain;charset=UTF-8

Validation failed for argument [0] in public org.springframework.http.ResponseEntity<java.lang.Void> com.netflix.genie.web.apis.rest.v3.controllers.JobRestController.submitJob(com.netflix.genie.common.dto.JobRequest,java.lang.String,java.lang.String,javax.servlet.http.HttpServletRequest) throws com.netflix.genie.common.exceptions.GenieException: [Field error in object 'jobRequest' on field 'user': rejected value [null]; codes [NotEmpty.jobRequest.user,NotEmpty.user,NotEmpty.java.lang.String,NotEmpty]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [jobRequest.user,user]; arguments []; default message [user]]; default message [A user is required and must be at most 255 characters]]

 ~ After this change ~

< HTTP/1.1 400
< Content-Type: application/json;charset=UTF-8

{"timestamp":"2019-09-20T19:12:49.367+0000","status":400,"error":"Bad Request","message":"JSON parse error: Missing required creator property 'user' (index 1); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Missing required creator property 'user' (index 1)\n at [Source: (PushbackInputStream); line: 29, column: 1]","path":"/api/v3/jobs"}

<DFF> @@ -70,10 +70,10 @@ public class JobSearchResult extends BaseSearchResult {
      */
     @JsonCreator
     public JobSearchResult(
-        @JsonProperty("id") @NotBlank final String id,
-        @JsonProperty("name") @NotBlank final String name,
-        @JsonProperty("user") @NotBlank final String user,
-        @JsonProperty("status") @NotNull final JobStatus status,
+        @JsonProperty(value = "id", required = true) @NotBlank final String id,
+        @JsonProperty(value = "name", required = true) @NotBlank final String name,
+        @JsonProperty(value = "user", required = true) @NotBlank final String user,
+        @JsonProperty(value = "status", required = true) @NotNull final JobStatus status,
         @JsonProperty("started") @Nullable final Instant started,
         @JsonProperty("finished") @Nullable final Instant finished,
         @JsonProperty("clusterName") @Nullable final String clusterName,

Explicitly mark API required fields to aid JSON deserialization