Manejo de la respuesta de error personalizada en la biblioteca de cliente JAX-RS 2.0


Estoy empezando a usar la nueva biblioteca API de cliente en JAX-RS y realmente me encanta hasta ahora. He encontrado una cosa que no puedo entender sin embargo. La API que estoy usando tiene un formato de mensaje de error personalizado que se ve así, por ejemplo:

{
    "code": 400,
    "message": "This is a message which describes why there was a code 400."
} 

Devuelve 400 como el código de estado, pero también incluye un mensaje de error descriptivo para decirle lo que hizo mal.

Sin embargo, el cliente JAX-RS 2.0 está re-mapeando el estado 400 en algo genérico y pierdo el mensaje de error bueno. Se lo asigna correctamente a una BadRequestException, pero con un mensaje genérico "HTTP 400 Bad Request".

javax.ws.rs.BadRequestException: HTTP 400 Bad Request
    at org.glassfish.jersey.client.JerseyInvocation.convertToException(JerseyInvocation.java:908)
    at org.glassfish.jersey.client.JerseyInvocation.translate(JerseyInvocation.java:770)
    at org.glassfish.jersey.client.JerseyInvocation.access$500(JerseyInvocation.java:90)
    at org.glassfish.jersey.client.JerseyInvocation$2.call(JerseyInvocation.java:671)
    at org.glassfish.jersey.internal.Errors.process(Errors.java:315)
    at org.glassfish.jersey.internal.Errors.process(Errors.java:297)
    at org.glassfish.jersey.internal.Errors.process(Errors.java:228)
    at org.glassfish.jersey.process.internal.RequestScope.runInScope(RequestScope.java:424)
    at org.glassfish.jersey.client.JerseyInvocation.invoke(JerseyInvocation.java:667)
    at org.glassfish.jersey.client.JerseyInvocation$Builder.method(JerseyInvocation.java:396)
    at org.glassfish.jersey.client.JerseyInvocation$Builder.get(JerseyInvocation.java:296)

¿Hay algún tipo de interceptor o controlador de errores personalizado que se puede inyectar para que tenga acceso al mensaje de error real. He estado revisando la documentación pero no veo ninguna manera de hacerlo.

Estoy usando Jersey en este momento, pero lo intenté usando CXF y obtuve el mismo resultado. Así es como se ve el código.

Client client = ClientBuilder.newClient().register(JacksonFeature.class).register(GzipInterceptor.class);
WebTarget target = client.target("https://somesite.com").path("/api/test");
Invocation.Builder builder = target.request()
                                   .header("some_header", value)
                                   .accept(MediaType.APPLICATION_JSON_TYPE)
                                   .acceptEncoding("gzip");
MyEntity entity = builder.get(MyEntity.class);

ACTUALIZACIÓN:

I implementado la solución que se muestra en el comentario a continuación. Es ligeramente diferente ya que las clases han cambiado un poco en la API de cliente JAX-RS 2.0. Todavía creo que está mal que el comportamiento predeterminado sea dar un mensaje de error genérico y descartar el real. Entiendo por qué no analizaría mi objeto de error, pero la versión no analizada debería haber sido devuelta. Termino teniendo la asignación de excepción replicada que la biblioteca ya tiene.

Gracias por la ayuda.

Aquí está mi clase de filtro:

@Provider
public class ErrorResponseFilter implements ClientResponseFilter {

    private static ObjectMapper _MAPPER = new ObjectMapper();

    @Override
    public void filter(ClientRequestContext requestContext, ClientResponseContext responseContext) throws IOException {
        // for non-200 response, deal with the custom error messages
        if (responseContext.getStatus() != Response.Status.OK.getStatusCode()) {
            if (responseContext.hasEntity()) {
                // get the "real" error message
                ErrorResponse error = _MAPPER.readValue(responseContext.getEntityStream(), ErrorResponse.class);
                String message = error.getMessage();

                Response.Status status = Response.Status.fromStatusCode(responseContext.getStatus());
                WebApplicationException webAppException;
                switch (status) {
                    case BAD_REQUEST:
                        webAppException = new BadRequestException(message);
                        break;
                    case UNAUTHORIZED:
                        webAppException = new NotAuthorizedException(message);
                        break;
                    case FORBIDDEN:
                        webAppException = new ForbiddenException(message);
                        break;
                    case NOT_FOUND:
                        webAppException = new NotFoundException(message);
                        break;
                    case METHOD_NOT_ALLOWED:
                        webAppException = new NotAllowedException(message);
                        break;
                    case NOT_ACCEPTABLE:
                        webAppException = new NotAcceptableException(message);
                        break;
                    case UNSUPPORTED_MEDIA_TYPE:
                        webAppException = new NotSupportedException(message);
                        break;
                    case INTERNAL_SERVER_ERROR:
                        webAppException = new InternalServerErrorException(message);
                        break;
                    case SERVICE_UNAVAILABLE:
                        webAppException = new ServiceUnavailableException(message);
                        break;
                    default:
                        webAppException = new WebApplicationException(message);
                }

                throw webAppException;
            }
        }
    }
}
Author: Chuck M, 2014-03-21

5 answers

Creo que quieres hacer algo como esto:

Response response = builder.get( Response.class );
if ( response.getStatusCode() != Response.Status.OK.getStatusCode() ) {
    System.out.println( response.getStatusType() );
    return null;
}

return response.readEntity( MyEntity.class );

Otra cosa que puedes probar (ya que no se donde esta API pone cosas in es decir, en el encabezado o entidad o que) es:

Response response = builder.get( Response.class );
if ( response.getStatusCode() != Response.Status.OK.getStatusCode() ) {
    // if they put the custom error stuff in the entity
    System.out.println( response.readEntity( String.class ) );
    return null;
}

return response.readEntity( MyEntity.class );

Si desea asignar generalmente los códigos de respuesta REST a la excepción de Java, puede agregar un filtro de cliente para hacerlo:

class ClientResponseLoggingFilter implements ClientResponseFilter {

    @Override
    public void filter(final ClientRequestContext reqCtx,
                       final ClientResponseContext resCtx) throws IOException {

        if ( resCtx.getStatus() == Response.Status.BAD_REQUEST.getStatusCode() ) {
            throw new MyClientException( resCtx.getStatusInfo() );
        }

        ...

En el filtro anterior puede crear excepciones específicas para cada código o crear un tipo de excepción genérico que envuelva el código de respuesta y la entidad.

 19
Author: robert_difalco,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2015-09-10 13:50:24

Hay otras formas de obtener un mensaje de error personalizado para el cliente Jersey además de escribir un filtro personalizado. (aunque el filtro es una solución excelente)

1) Pase el mensaje de error en un campo de encabezado HTTP. El mensaje de error de detalle podría estar en la respuesta JSON y en un campo de encabezado adicional, como "x-error-message".

El servidor agrega el encabezado de error HTTP.

ResponseBuilder rb = Response.status(respCode.getCode()).entity(resp);
if (!StringUtils.isEmpty(errMsg)){
    rb.header("x-error-message", errMsg);
}
return rb.build();

El Cliente captura la excepción, NotFoundException en mi case, y lee el encabezado de la respuesta.

try {
    Integer accountId = 2222;
    Client client = ClientBuilder.newClient();
    WebTarget webTarget = client.target("http://localhost:8080/rest-jersey/rest");
    webTarget = webTarget.path("/accounts/"+ accountId);
    Invocation.Builder ib = webTarget.request(MediaType.APPLICATION_JSON);
    Account resp = ib.get(new GenericType<Account>() {
    });
} catch (NotFoundException e) {
    String errorMsg = e.getResponse().getHeaderString("x-error-message");
    // do whatever ...
    return;
}

2) Otra solución es capturar la excepción y leer el contenido de la respuesta.

try {
    // same as above ...
} catch (NotFoundException e) {
    String respString = e.getResponse().readEntity(String.class);
    // you can convert to JSON or search for error message in String ...
    return;
} 
 4
Author: Peter Tarlos,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-09-11 20:25:04

La clase WebApplicationException fue diseñada para eso, pero por alguna razón ignora y sobrescribe lo que especifique como parámetro para el mensaje.

Por esa razón he creado mi propia extensión WebAppException que respeta los parámetros. Es una sola clase y no requiere ningún filtro de respuesta o un mapeador.

Prefiero excepciones que crear un Response ya que se puede lanzar desde cualquier lugar mientras se procesa.

Uso simple:

throw new WebAppException(Status.BAD_REQUEST, "Field 'name' is missing.");

El clase:

import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.Response.Status.Family;
import javax.ws.rs.core.Response.StatusType;

public class WebAppException extends WebApplicationException {
    private static final long serialVersionUID = -9079411854450419091L;

    public static class MyStatus implements StatusType {
        final int statusCode;
        final String reasonPhrase;

        public MyStatus(int statusCode, String reasonPhrase) {
            this.statusCode = statusCode;
            this.reasonPhrase = reasonPhrase;
        }

        @Override
        public int getStatusCode() {
            return statusCode;
        }
        @Override
        public Family getFamily() {
            return Family.familyOf(statusCode);
        }
        @Override
        public String getReasonPhrase() {
            return reasonPhrase;
        }
    }

    public WebAppException() {
    }

    public WebAppException(int status) {
        super(status);
    }

    public WebAppException(Response response) {
        super(response);
    }

    public WebAppException(Status status) {
        super(status);
    }

    public WebAppException(String message, Response response) {
        super(message, response);
    }

    public WebAppException(int status, String message) {
        super(message, Response.status(new MyStatus(status, message)). build());
    }

    public WebAppException(Status status, String message) {
        this(status.getStatusCode(), message);
    }

    public WebAppException(String message) {
        this(500, message);
    }

}
 3
Author: Pla,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-09-27 10:50:14

Las siguientes obras para mí

Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
 0
Author: Shivoam,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-02-03 09:00:00

Una solución mucho más concisa para cualquiera que tropiece con esto:

Llamando a .get(Class<T> responseType) o cualquiera de los otros métodos que toman el tipo resultado como un argumento Invocation.Builder devolverá un valor del tipo deseado en lugar de un Response. Como efecto secundario, estos métodos verificarán si el código de estado recibido está en el rango 2xx y lanzarán un WebApplicationException apropiado de lo contrario.

De la documentación :

Lanza: WebApplicationException en caso de que la respuesta código de estado de la respuesta devuelta por el servidor no es exitosa y el el tipo de respuesta especificado no es Respuesta.

Esto permite capturar el WebApplicationException, recuperar el Response real, procesar la entidad contenida como detalles de excepción (ApiExceptionInfo) y lanzar una excepción apropiada (ApiException).

public <Result> Result get(String path, Class<Result> resultType) {
    return perform("GET", path, null, resultType);
}

public <Result> Result post(String path, Object content, Class<Result> resultType) {
    return perform("POST", path, content, resultType);
}

private <Result> Result perform(String method, String path, Object content, Class<Result> resultType) {
    try {
        Entity<Object> entity = null == content ? null : Entity.entity(content, MediaType.APPLICATION_JSON);
        return client.target(uri).path(path).request(MediaType.APPLICATION_JSON).method(method, entity, resultType);
    } catch (WebApplicationException webApplicationException) {
        Response response = webApplicationException.getResponse();
        if (response.getMediaType().equals(MediaType.APPLICATION_JSON_TYPE)) {
            throw new ApiException(response.readEntity(ApiExceptionInfo.class), webApplicationException);
        } else {
            throw webApplicationException;
        }
    }
}

ApiExceptionInfo es un tipo de datos personalizado en mi aplicación:

import lombok.Data;

@Data
public class ApiExceptionInfo {

    private int code;

    private String message;

}

ApiException es el tipo de excepción personalizado en mi aplicación:

import lombok.Getter;

public class ApiException extends RuntimeException {

    @Getter
    private final ApiExceptionInfo info;

    public ApiException(ApiExceptionInfo info, Exception cause) {
        super(info.toString(), cause);
        this.info = info;
    }

}
 0
Author: toKrause,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-08-09 13:13:46