diff --git a/eng/versioning/version_client.txt b/eng/versioning/version_client.txt index 057fe35000c42..b98759d3f7446 100644 --- a/eng/versioning/version_client.txt +++ b/eng/versioning/version_client.txt @@ -400,6 +400,7 @@ com.azure.tools:azure-sdk-build-tool;1.0.0-beta.1;1.0.0-beta.2 # note: The unreleased dependencies will not be manipulated with the automatic PR creation code. # In the pom, the version update tag after the version should name the unreleased package and the dependency version: # +unreleased_com.azure:azure-core;1.36.0-beta.1 # Released Beta dependencies: Copy the entry from above, prepend "beta_", remove the current diff --git a/sdk/containerregistry/azure-containers-containerregistry/pom.xml b/sdk/containerregistry/azure-containers-containerregistry/pom.xml index 82b8f3bee423c..c938a3d1836b6 100644 --- a/sdk/containerregistry/azure-containers-containerregistry/pom.xml +++ b/sdk/containerregistry/azure-containers-containerregistry/pom.xml @@ -48,7 +48,7 @@ com.azure azure-core - 1.35.0 + 1.36.0-beta.1 com.azure diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/RegistryArtifact.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/RegistryArtifact.java index a5bcdccaf8da1..9c4706b3f89dc 100644 --- a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/RegistryArtifact.java +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/RegistryArtifact.java @@ -221,7 +221,7 @@ public Response getManifestPropertiesWithResponse(Co String res = this.getDigest(); try { return this.serviceClient.getManifestPropertiesWithResponse(getRepositoryName(), res, enableSync(getTracingContext(context))); - } catch (AcrErrorsException exception) { + } catch (HttpResponseException exception) { throw LOGGER.logExceptionAsError(mapAcrErrorsException(exception)); } } diff --git a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/UtilsImpl.java b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/UtilsImpl.java index 4aa1ce9321070..ee937eb170116 100644 --- a/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/UtilsImpl.java +++ b/sdk/containerregistry/azure-containers-containerregistry/src/main/java/com/azure/containers/containerregistry/implementation/UtilsImpl.java @@ -6,7 +6,6 @@ import com.azure.containers.containerregistry.ContainerRegistryServiceVersion; import com.azure.containers.containerregistry.implementation.authentication.ContainerRegistryCredentialsPolicy; import com.azure.containers.containerregistry.implementation.authentication.ContainerRegistryTokenService; -import com.azure.containers.containerregistry.implementation.models.AcrErrorsException; import com.azure.containers.containerregistry.implementation.models.ManifestAttributesBase; import com.azure.containers.containerregistry.implementation.models.TagAttributesBase; import com.azure.containers.containerregistry.models.ArtifactManifestProperties; @@ -178,13 +177,9 @@ public static HttpPipeline buildHttpPipeline( return httpPipeline; } + @SuppressWarnings("unchecked") private static ArrayList clone(ArrayList policies) { - ArrayList clonedPolicy = new ArrayList<>(); - for (HttpPipelinePolicy policy:policies) { - clonedPolicy.add(policy); - } - - return clonedPolicy; + return (ArrayList) policies.clone(); } /** @@ -249,15 +244,15 @@ private static Response getAcceptedDeleteResponse(Response response * @return The exception returned by the public methods. */ public static Throwable mapException(Throwable exception) { - AcrErrorsException acrException = null; + HttpResponseException acrException = null; - if (exception instanceof AcrErrorsException) { - acrException = ((AcrErrorsException) exception); + if (exception instanceof HttpResponseException) { + acrException = ((HttpResponseException) exception); } else if (exception instanceof RuntimeException) { RuntimeException runtimeException = (RuntimeException) exception; Throwable throwable = runtimeException.getCause(); - if (throwable instanceof AcrErrorsException) { - acrException = (AcrErrorsException) throwable; + if (throwable instanceof HttpResponseException) { + acrException = (HttpResponseException) throwable; } } @@ -265,25 +260,10 @@ public static Throwable mapException(Throwable exception) { return exception; } - final HttpResponse errorHttpResponse = acrException.getResponse(); - final int statusCode = errorHttpResponse.getStatusCode(); - final String errorDetail = acrException.getMessage(); - - switch (statusCode) { - case 401: - return new ClientAuthenticationException(errorDetail, acrException.getResponse(), exception); - case 404: - return new ResourceNotFoundException(errorDetail, acrException.getResponse(), exception); - case 409: - return new ResourceExistsException(errorDetail, acrException.getResponse(), exception); - case 412: - return new ResourceModifiedException(errorDetail, acrException.getResponse(), exception); - default: - return new HttpResponseException(errorDetail, acrException.getResponse(), exception); - } + return mapAcrErrorsException(acrException); } - public static HttpResponseException mapAcrErrorsException(AcrErrorsException acrException) { + public static HttpResponseException mapAcrErrorsException(HttpResponseException acrException) { final HttpResponse errorHttpResponse = acrException.getResponse(); final int statusCode = errorHttpResponse.getStatusCode(); final String errorDetail = acrException.getMessage(); diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessibleByteArrayOutputStream.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessibleByteArrayOutputStream.java index 40e0d2823592e..7131faff85659 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessibleByteArrayOutputStream.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/AccessibleByteArrayOutputStream.java @@ -4,12 +4,17 @@ package com.azure.core.implementation; import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; import java.nio.charset.Charset; +import java.util.Arrays; /** * This class is an extension of {@link ByteArrayOutputStream} which allows access to the backing {@code byte[]} without * requiring a copying of the data. The only use of this class is for internal purposes where we know it is safe to * directly access the {@code byte[]} without copying. + *

+ * This class isn't meant to be thread-safe as usage should be internal to azure-core and should be guarded + * appropriately when used. */ public class AccessibleByteArrayOutputStream extends ByteArrayOutputStream { /** @@ -30,8 +35,38 @@ public AccessibleByteArrayOutputStream(int initialCapacity) { } @Override - public synchronized byte[] toByteArray() { - return buf; + public byte[] toByteArray() { + return Arrays.copyOf(buf, count); + } + + // Commenting out as this isn't used but may be added back in the future. +// /** +// * Returns the internal {@code byte[]} without copying. +// *

+// * This will be the full {@code byte[]}, so if writing required it to be resized to 8192 bytes but only 6000 bytes +// * were written the final 2192 bytes will be undefined data. If this is used in an API where a {@code byte[]} is +// * accepted you must use the range based overload with {@link #count()}, if a range based overload isn't available +// * use {@link #toByteArray()} which will copy the range of bytes written. +// * +// * @return A direct reference to the internal {@code byte[]} where data is being written. +// */ +// public byte[] toByteArrayUnsafe() { +// return buf; +// } + + /** + * Returns a {@link ByteBuffer} representation of the content written to this stream. + *

+ * The {@link ByteBuffer} will use a direct reference to the internal {@code byte[]} being written, so any + * modifications to the content already written will be reflected in the {@link ByteBuffer}. Given the direct + * reference to the internal {@code byte[]} the {@link ByteBuffer} returned by the API will be read-only. Further + * writing to this stream won't be reflected in the {@link ByteBuffer} as the ByteBuffer will be created using + * {@code ByteBuffer.wrap(bytes, 0, count())}. + * + * @return A read-only {@link ByteBuffer} represented by the internal buffer being written to. + */ + public ByteBuffer toByteBuffer() { + return ByteBuffer.wrap(buf, 0, count).asReadOnlyBuffer(); } /** @@ -52,4 +87,17 @@ public int count() { public String toString(Charset charset) { return new String(buf, 0, count, charset); } + + /** + * Gets a BOM aware string representation of the stream. + *

+ * This method is the equivalent of calling + * {@code ImplUtils.bomAwareToString(toByteBufferUnsafe(), 0, count(), contentType)}. + * + * @param contentType The {@code Content-Type} header value. + * @return A string representation of the stream encoded to the found encoding. + */ + public String bomAwareToString(String contentType) { + return ImplUtils.bomAwareToString(buf, 0, count, contentType); + } } diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/ImplUtils.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/ImplUtils.java index 658e153b98d7e..66cb915b23fa7 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/ImplUtils.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/ImplUtils.java @@ -15,6 +15,10 @@ import java.io.OutputStream; import java.net.URL; import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; import java.time.DateTimeException; import java.time.Duration; import java.time.OffsetDateTime; @@ -26,6 +30,8 @@ import java.util.NoSuchElementException; import java.util.function.Function; import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Utility class containing implementation specific methods. @@ -37,6 +43,16 @@ public final class ImplUtils { // future improvement - make this configurable public static final int MAX_CACHE_SIZE = 10000; + private static final Charset UTF_32BE = Charset.forName("UTF-32BE"); + private static final Charset UTF_32LE = Charset.forName("UTF-32LE"); + private static final byte ZERO = (byte) 0x00; + private static final byte BB = (byte) 0xBB; + private static final byte BF = (byte) 0xBF; + private static final byte EF = (byte) 0xEF; + private static final byte FE = (byte) 0xFE; + private static final byte FF = (byte) 0xFF; + private static final Pattern CHARSET_PATTERN = Pattern.compile("charset=(\\S+)\\b", Pattern.CASE_INSENSITIVE); + /** * Attempts to extract a retry after duration from a given set of {@link HttpHeaders}. *

@@ -247,6 +263,61 @@ public Map.Entry next() { } } + /** + * Attempts to convert a byte stream into the properly encoded String. + *

+ * This utility method will attempt to find the encoding for the String in this order. + *

    + *
  1. Find the byte order mark in the byte array.
  2. + *
  3. Find the charset in the {@code contentType} header.
  4. + *
  5. Default to {@code UTF-8}.
  6. + *
+ * + * @param bytes The byte array. + * @param offset The starting offset in the byte array. + * @param count The number of bytes to process in the byte array. + * @param contentType The {@code Content-Type} header value. + * @return A string representation of the byte encoded to the found encoding, or null if {@code bytes} is null. + */ + public static String bomAwareToString(byte[] bytes, int offset, int count, String contentType) { + if (bytes == null) { + return null; + } + + if (count >= 3 && bytes[offset] == EF && bytes[offset + 1] == BB && bytes[offset + 2] == BF) { + return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); + } else if (count >= 4 && bytes[offset] == ZERO && bytes[offset + 1] == ZERO + && bytes[offset + 2] == FE && bytes[offset + 3] == FF) { + return new String(bytes, 4, bytes.length - 4, UTF_32BE); + } else if (count >= 4 && bytes[offset] == FF && bytes[offset + 1] == FE + && bytes[offset + 2] == ZERO && bytes[offset + 3] == ZERO) { + return new String(bytes, 4, bytes.length - 4, UTF_32LE); + } else if (count >= 2 && bytes[offset] == FE && bytes[offset + 1] == FF) { + return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16BE); + } else if (count >= 2 && bytes[offset] == FF && bytes[offset + 1] == FE) { + return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16LE); + } else { + /* + * Attempt to retrieve the default charset from the 'Content-Encoding' header, if the value isn't + * present or invalid fallback to 'UTF-8' for the default charset. + */ + if (!CoreUtils.isNullOrEmpty(contentType)) { + try { + Matcher charsetMatcher = CHARSET_PATTERN.matcher(contentType); + if (charsetMatcher.find()) { + return new String(bytes, offset, count, Charset.forName(charsetMatcher.group(1))); + } else { + return new String(bytes, offset, count, StandardCharsets.UTF_8); + } + } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) { + return new String(bytes, offset, count, StandardCharsets.UTF_8); + } + } else { + return new String(bytes, offset, count, StandardCharsets.UTF_8); + } + } + } + private ImplUtils() { } } diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/ReflectionSerializable.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/ReflectionSerializable.java index f144fb623d8bb..371da8ad140bf 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/ReflectionSerializable.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/ReflectionSerializable.java @@ -202,7 +202,7 @@ static ByteBuffer serializeAsJsonSerializable(Object jsonSerializable) throws IO JSON_WRITER_WRITE_JSON_SERIALIZABLE.writeJson(jsonWriter, jsonSerializable); JSON_WRITER_FLUSH.flush(jsonWriter); - return ByteBuffer.wrap(outputStream.toByteArray(), 0, outputStream.count()); + return outputStream.toByteBuffer(); } } @@ -289,7 +289,7 @@ static ByteBuffer serializeAsXmlSerializable(Object bodyContent) throws IOExcept XML_WRITER_WRITE_XML_SERIALIZABLE.writeXml(xmlWriter, bodyContent); XML_WRITER_FLUSH.flush(xmlWriter); - return ByteBuffer.wrap(outputStream.toByteArray(), 0, outputStream.count()); + return outputStream.toByteBuffer(); } catch (IOException ex) { throw ex; } catch (Exception ex) { diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/RestProxyBase.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/RestProxyBase.java index fa8d715f55471..1251e812deab0 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/RestProxyBase.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/http/rest/RestProxyBase.java @@ -25,6 +25,7 @@ import com.azure.core.implementation.TypeUtil; import com.azure.core.implementation.http.UnexpectedExceptionInformation; import com.azure.core.implementation.serializer.HttpResponseDecoder; +import com.azure.core.implementation.serializer.MalformedValueException; import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.UrlBuilder; @@ -352,6 +353,16 @@ public Exception instantiateUnexpectedException(final UnexpectedExceptionInforma exceptionMessage.append("\"").append(new String(responseContent, StandardCharsets.UTF_8)).append("\""); } + // If the decoded response content is on of these exception types there was a failure in creating the actual + // exception body type. In this case return an HttpResponseException to maintain the exception having a + // reference to the HttpResponse and information about what caused the deserialization failure. + if (responseDecodedContent instanceof IOException + || responseDecodedContent instanceof MalformedValueException + || responseDecodedContent instanceof IllegalStateException) { + return new HttpResponseException(exceptionMessage.toString(), httpResponse, + (Throwable) responseDecodedContent); + } + // For HttpResponseException types that exist in azure-core, call the constructor directly. Class exceptionType = exception.getExceptionType(); if (exceptionType == HttpResponseException.class) { diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/serializer/HttpResponseBodyDecoder.java b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/serializer/HttpResponseBodyDecoder.java index b4a75c54ba0b3..9eca628ff7aa5 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/implementation/serializer/HttpResponseBodyDecoder.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/implementation/serializer/HttpResponseBodyDecoder.java @@ -61,11 +61,16 @@ static Object decodeByteArray(byte[] body, HttpResponse httpResponse, Serializer return deserializeBody(body, decodeData.getUnexpectedException(httpResponse.getStatusCode()).getExceptionBodyType(), null, serializer, SerializerEncoding.fromHeaders(httpResponse.getHeaders())); - } catch (IOException | MalformedValueException ex) { - // This translates in RestProxy as a RestException with no deserialized body. - // The response content will still be accessible via the .response() member. + } catch (IOException | MalformedValueException | IllegalStateException ex) { + // MalformedValueException is thrown by Jackson, IllegalStateException is thrown by the TEXT + // serialization encoding handler, and IOException can be thrown by both Jackson and TEXT. + // + // There has been an issue deserializing the error response body. This may be an error in the service + // return. + // + // Return the exception as the body type, RestProxyBase will handle this later. LOGGER.warning("Failed to deserialize the error entity.", ex); - return null; + return ex; } } else { if (!decodeData.isReturnTypeDecodeable()) { diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java b/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java index a53ede2ea7639..7229c529cfa0c 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/util/CoreUtils.java @@ -6,16 +6,13 @@ import com.azure.core.http.HttpHeaders; import com.azure.core.http.policy.HttpLogOptions; import com.azure.core.http.rest.PagedResponse; +import com.azure.core.implementation.ImplUtils; import com.azure.core.util.logging.ClientLogger; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.Charset; -import java.nio.charset.IllegalCharsetNameException; -import java.nio.charset.StandardCharsets; -import java.nio.charset.UnsupportedCharsetException; import java.time.Duration; import java.util.Arrays; import java.util.Collection; @@ -27,8 +24,6 @@ import java.util.Properties; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -37,15 +32,6 @@ public final class CoreUtils { // CoreUtils is a commonly used utility, use a static logger. private static final ClientLogger LOGGER = new ClientLogger(CoreUtils.class); - private static final Charset UTF_32BE = Charset.forName("UTF-32BE"); - private static final Charset UTF_32LE = Charset.forName("UTF-32LE"); - private static final byte ZERO = (byte) 0x00; - private static final byte BB = (byte) 0xBB; - private static final byte BF = (byte) 0xBF; - private static final byte EF = (byte) 0xEF; - private static final byte FE = (byte) 0xFE; - private static final byte FF = (byte) 0xFF; - private static final Pattern CHARSET_PATTERN = Pattern.compile("charset=([\\S]+)\\b", Pattern.CASE_INSENSITIVE); private CoreUtils() { // Exists only to defeat instantiation. @@ -235,36 +221,7 @@ public static String bomAwareToString(byte[] bytes, String contentType) { return null; } - if (bytes.length >= 3 && bytes[0] == EF && bytes[1] == BB && bytes[2] == BF) { - return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); - } else if (bytes.length >= 4 && bytes[0] == ZERO && bytes[1] == ZERO && bytes[2] == FE && bytes[3] == FF) { - return new String(bytes, 4, bytes.length - 4, UTF_32BE); - } else if (bytes.length >= 4 && bytes[0] == FF && bytes[1] == FE && bytes[2] == ZERO && bytes[3] == ZERO) { - return new String(bytes, 4, bytes.length - 4, UTF_32LE); - } else if (bytes.length >= 2 && bytes[0] == FE && bytes[1] == FF) { - return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16BE); - } else if (bytes.length >= 2 && bytes[0] == FF && bytes[1] == FE) { - return new String(bytes, 2, bytes.length - 2, StandardCharsets.UTF_16LE); - } else { - /* - * Attempt to retrieve the default charset from the 'Content-Encoding' header, if the value isn't - * present or invalid fallback to 'UTF-8' for the default charset. - */ - if (!isNullOrEmpty(contentType)) { - try { - Matcher charsetMatcher = CHARSET_PATTERN.matcher(contentType); - if (charsetMatcher.find()) { - return new String(bytes, Charset.forName(charsetMatcher.group(1))); - } else { - return new String(bytes, StandardCharsets.UTF_8); - } - } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) { - return new String(bytes, StandardCharsets.UTF_8); - } - } else { - return new String(bytes, StandardCharsets.UTF_8); - } - } + return ImplUtils.bomAwareToString(bytes, 0, bytes.length, contentType); } /** diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/JacksonAdapter.java b/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/JacksonAdapter.java index 413606a7c57a4..cb248cb420a09 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/JacksonAdapter.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/JacksonAdapter.java @@ -4,9 +4,12 @@ package com.azure.core.util.serializer; import com.azure.core.http.HttpHeaders; +import com.azure.core.implementation.AccessibleByteArrayOutputStream; import com.azure.core.implementation.jackson.ObjectMapperShim; import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.core.util.ExpandableStringEnum; import com.azure.core.util.Header; import com.azure.core.util.logging.ClientLogger; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,10 +19,17 @@ import java.io.OutputStream; import java.io.UncheckedIOException; import java.lang.reflect.Type; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.security.PrivilegedActionException; import java.security.PrivilegedExceptionAction; +import java.time.LocalDate; +import java.time.OffsetDateTime; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.function.BiConsumer; /** @@ -156,9 +166,15 @@ public String serialize(Object object, SerializerEncoding encoding) throws IOExc return null; } - return (String) useAccessHelper(() -> (encoding == SerializerEncoding.XML) - ? getXmlMapper().writeValueAsString(object) - : mapper.writeValueAsString(object)); + return (String) useAccessHelper(() -> { + if (encoding == SerializerEncoding.XML) { + return getXmlMapper().writeValueAsString(object); + } else if (encoding == SerializerEncoding.TEXT) { + return object.toString(); + } else { + return mapper.writeValueAsString(object); + } + }); } @Override @@ -167,9 +183,15 @@ public byte[] serializeToBytes(Object object, SerializerEncoding encoding) throw return null; } - return (byte[]) useAccessHelper(() -> (encoding == SerializerEncoding.XML) - ? getXmlMapper().writeValueAsBytes(object) - : mapper.writeValueAsBytes(object)); + return (byte[]) useAccessHelper(() -> { + if (encoding == SerializerEncoding.XML) { + return getXmlMapper().writeValueAsBytes(object); + } else if (encoding == SerializerEncoding.TEXT) { + return object.toString().getBytes(StandardCharsets.UTF_8); + } else { + return mapper.writeValueAsBytes(object); + } + }); } @Override @@ -181,6 +203,8 @@ public void serialize(Object object, SerializerEncoding encoding, OutputStream o useAccessHelper(() -> { if (encoding == SerializerEncoding.XML) { getXmlMapper().writeValue(outputStream, object); + } else if (encoding == SerializerEncoding.TEXT) { + outputStream.write(object.toString().getBytes(StandardCharsets.UTF_8)); } else { mapper.writeValue(outputStream, object); } @@ -261,9 +285,15 @@ public T deserialize(String value, Type type, SerializerEncoding encoding) t return null; } - return (T) useAccessHelper(() -> (encoding == SerializerEncoding.XML) - ? getXmlMapper().readValue(value, type) - : mapper.readValue(value, type)); + return (T) useAccessHelper(() -> { + if (encoding == SerializerEncoding.XML) { + return getXmlMapper().readValue(value, type); + } else if (encoding == SerializerEncoding.TEXT) { + return deserializeText(value, type); + } else { + return mapper.readValue(value, type); + } + }); } @SuppressWarnings("unchecked") @@ -273,9 +303,15 @@ public T deserialize(byte[] bytes, Type type, SerializerEncoding encoding) t return null; } - return (T) useAccessHelper(() -> (encoding == SerializerEncoding.XML) - ? getXmlMapper().readValue(bytes, type) - : mapper.readValue(bytes, type)); + return (T) useAccessHelper(() -> { + if (encoding == SerializerEncoding.XML) { + return getXmlMapper().readValue(bytes, type); + } else if (encoding == SerializerEncoding.TEXT) { + return deserializeText(CoreUtils.bomAwareToString(bytes, null), type); + } else { + return mapper.readValue(bytes, type); + } + }); } @SuppressWarnings("unchecked") @@ -286,9 +322,73 @@ public T deserialize(InputStream inputStream, final Type type, SerializerEnc return null; } - return (T) useAccessHelper(() -> (encoding == SerializerEncoding.XML) - ? getXmlMapper().readValue(inputStream, type) - : mapper.readValue(inputStream, type)); + return (T) useAccessHelper(() -> { + if (encoding == SerializerEncoding.XML) { + return getXmlMapper().readValue(inputStream, type); + } else if (encoding == SerializerEncoding.TEXT) { + AccessibleByteArrayOutputStream outputStream = new AccessibleByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int readCount; + while ((readCount = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, readCount); + } + + return deserializeText(outputStream.bomAwareToString(null), type); + } else { + return mapper.readValue(inputStream, type); + } + }); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object deserializeText(String value, Type type) throws IOException { + if (type == String.class || type == CharSequence.class) { + return value; + } else if (type == int.class || type == Integer.class) { + return Integer.parseInt(value); + } else if (type == char.class || type == Character.class) { + return CoreUtils.isNullOrEmpty(value) ? null : value.charAt(0); + } else if (type == byte.class || type == Byte.class) { + return CoreUtils.isNullOrEmpty(value) ? null : (byte) value.charAt(0); + } else if (type == byte[].class) { + return CoreUtils.isNullOrEmpty(value) ? null : value.getBytes(StandardCharsets.UTF_8); + } else if (type == long.class || type == Long.class) { + return Long.parseLong(value); + } else if (type == short.class || type == Short.class) { + return Short.parseShort(value); + } else if (type == float.class || type == Float.class) { + return Float.parseFloat(value); + } else if (type == double.class || type == Double.class) { + return Double.parseDouble(value); + } else if (type == boolean.class || type == Boolean.class) { + return Boolean.parseBoolean(value); + } else if (type == OffsetDateTime.class) { + return OffsetDateTime.parse(value); + } else if (type == DateTimeRfc1123.class) { + return new DateTimeRfc1123(value); + } else if (type == URL.class) { + try { + return new URL(value); + } catch (MalformedURLException ex) { + throw new IOException(ex); + } + } else if (type == URI.class) { + return URI.create(value); + } else if (type == UUID.class) { + return UUID.fromString(value); + } else if (type == LocalDate.class) { + return LocalDate.parse(value); + } else if (Enum.class.isAssignableFrom((Class) type)) { + return Enum.valueOf((Class) type, value); + } else if (ExpandableStringEnum.class.isAssignableFrom((Class) type)) { + try { + return ((Class) type).getDeclaredMethod("fromString", String.class).invoke(null, value); + } catch (ReflectiveOperationException ex) { + throw new IOException(ex); + } + } else { + throw new IllegalStateException("Unsupported text Content-Type Type: " + type); + } } @SuppressWarnings("unchecked") diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/SerializerEncoding.java b/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/SerializerEncoding.java index f916db0eac6af..efddb7e2bd41a 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/SerializerEncoding.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/util/serializer/SerializerEncoding.java @@ -5,6 +5,7 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; import java.util.Map; @@ -22,12 +23,16 @@ public enum SerializerEncoding { /** * Extensible Markup Language. */ - XML; + XML, + + /** + * Text. + */ + TEXT; private static final ClientLogger LOGGER = new ClientLogger(SerializerEncoding.class); private static final String CONTENT_TYPE = "Content-Type"; private static final Map SUPPORTED_MIME_TYPES; - private static final TreeMap SUPPORTED_SUFFIXES; private static final SerializerEncoding DEFAULT_ENCODING = JSON; @@ -37,10 +42,11 @@ public enum SerializerEncoding { SUPPORTED_MIME_TYPES.put("text/xml", XML); SUPPORTED_MIME_TYPES.put("application/xml", XML); SUPPORTED_MIME_TYPES.put("application/json", JSON); - - SUPPORTED_SUFFIXES = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - SUPPORTED_SUFFIXES.put("xml", XML); - SUPPORTED_SUFFIXES.put("json", JSON); + SUPPORTED_MIME_TYPES.put("text/css", TEXT); + SUPPORTED_MIME_TYPES.put("text/csv", TEXT); + SUPPORTED_MIME_TYPES.put("text/html", TEXT); + SUPPORTED_MIME_TYPES.put("text/javascript", TEXT); + SUPPORTED_MIME_TYPES.put("text/plain", TEXT); } /** @@ -52,35 +58,40 @@ public enum SerializerEncoding { */ public static SerializerEncoding fromHeaders(HttpHeaders headers) { final String mimeContentType = headers.getValue(HttpHeaderName.CONTENT_TYPE); - if (mimeContentType == null || mimeContentType.isEmpty()) { + if (CoreUtils.isNullOrEmpty(mimeContentType)) { LOGGER.warning("'{}' not found. Returning default encoding: {}", CONTENT_TYPE, DEFAULT_ENCODING); return DEFAULT_ENCODING; } - final String[] parts = mimeContentType.split(";"); - final SerializerEncoding encoding = SUPPORTED_MIME_TYPES.get(parts[0]); + int contentTypeEnd = mimeContentType.indexOf(';'); + String contentType = (contentTypeEnd == -1) ? mimeContentType : mimeContentType.substring(0, contentTypeEnd); + final SerializerEncoding encoding = SUPPORTED_MIME_TYPES.get(contentType); if (encoding != null) { return encoding; } - final String[] mimeTypeParts = parts[0].split("/"); - if (mimeTypeParts.length != 2) { + int contentTypeTypeSplit = contentType.indexOf('/'); + if (contentTypeTypeSplit == -1) { LOGGER.warning("Content-Type '{}' does not match mime-type formatting 'type'/'subtype'. " - + "Returning default: {}", parts[0], DEFAULT_ENCODING); + + "Returning default: {}", contentType, DEFAULT_ENCODING); return DEFAULT_ENCODING; } // Check the suffix if it does not match the full types. - final String subtype = mimeTypeParts[1]; - final int lastIndex = subtype.lastIndexOf("+"); + // Suffixes are defined by the Structured Syntax Suffix Registry + // https://www.rfc-editor.org/rfc/rfc6839 + final String subtype = contentType.substring(contentTypeTypeSplit + 1); + final int lastIndex = subtype.lastIndexOf('+'); if (lastIndex == -1) { return DEFAULT_ENCODING; } + // Only XML and JSON are supported suffixes, there is no suffix for TEXT. final String mimeTypeSuffix = subtype.substring(lastIndex + 1); - final SerializerEncoding serializerEncoding = SUPPORTED_SUFFIXES.get(mimeTypeSuffix); - if (serializerEncoding != null) { - return serializerEncoding; + if ("xml".equalsIgnoreCase(mimeTypeSuffix)) { + return XML; + } else if ("json".equalsIgnoreCase(mimeTypeSuffix)) { + return JSON; } LOGGER.warning("Content-Type '{}' does not match any supported one. Returning default: {}", diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/JacksonAdapterTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/JacksonAdapterTests.java index 608311db7f61a..10de78cd16400 100644 --- a/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/JacksonAdapterTests.java +++ b/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/JacksonAdapterTests.java @@ -4,6 +4,10 @@ package com.azure.core.util.serializer; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.implementation.AccessibleByteArrayOutputStream; +import com.azure.core.models.GeoObjectType; +import com.azure.core.models.JsonPatchDocument; import com.azure.core.util.DateTimeRfc1123; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -14,35 +18,47 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; public class JacksonAdapterTests { + private static final JacksonAdapter ADAPTER = new JacksonAdapter(); + @Test public void emptyMap() throws IOException { final Map map = new HashMap<>(); - final JacksonAdapter serializer = new JacksonAdapter(); - assertEquals("{}", serializer.serialize(map, SerializerEncoding.JSON)); + assertEquals("{}", ADAPTER.serialize(map, SerializerEncoding.JSON)); } @Test public void mapWithNullKey() throws IOException { final Map map = new HashMap<>(); map.put(null, null); - final JacksonAdapter serializer = new JacksonAdapter(); - assertEquals("{}", serializer.serialize(map, SerializerEncoding.JSON)); + assertEquals("{}", ADAPTER.serialize(map, SerializerEncoding.JSON)); } @Test @@ -51,8 +67,7 @@ public void mapWithEmptyKeyAndNullValue() throws IOException { mapHolder.map(new HashMap<>()); mapHolder.map().put("", null); - final JacksonAdapter serializer = new JacksonAdapter(); - assertEquals("{\"map\":{\"\":null}}", serializer.serialize(mapHolder, SerializerEncoding.JSON)); + assertEquals("{\"map\":{\"\":null}}", ADAPTER.serialize(mapHolder, SerializerEncoding.JSON)); } @Test @@ -60,16 +75,16 @@ public void mapWithEmptyKeyAndEmptyValue() throws IOException { final MapHolder mapHolder = new MapHolder(); mapHolder.map = new HashMap<>(); mapHolder.map.put("", ""); - final JacksonAdapter serializer = new JacksonAdapter(); - assertEquals("{\"map\":{\"\":\"\"}}", serializer.serialize(mapHolder, SerializerEncoding.JSON)); + + assertEquals("{\"map\":{\"\":\"\"}}", ADAPTER.serialize(mapHolder, SerializerEncoding.JSON)); } @Test public void mapWithEmptyKeyAndNonEmptyValue() throws IOException { final Map map = new HashMap<>(); map.put("", "test"); - final JacksonAdapter serializer = new JacksonAdapter(); - assertEquals("{\"\":\"test\"}", serializer.serialize(map, SerializerEncoding.JSON)); + + assertEquals("{\"\":\"test\"}", ADAPTER.serialize(map, SerializerEncoding.JSON)); } private static class MapHolder { @@ -235,6 +250,149 @@ private static Stream quoteRemovalSupplier() { ); } + @ParameterizedTest + @MethodSource("textSerializationSupplier") + public void textToStringSerialization(Object value, String expected) throws IOException { + assertEquals(expected, ADAPTER.serialize(value, SerializerEncoding.TEXT)); + } + + @ParameterizedTest + @MethodSource("textSerializationSupplier") + public void textToBytesSerialization(Object value, String expected) throws IOException { + byte[] actual = ADAPTER.serializeToBytes(value, SerializerEncoding.TEXT); + + if (expected == null) { + assertNull(actual); + } else { + assertEquals(expected, new String(actual, StandardCharsets.UTF_8)); + } + } + + @ParameterizedTest + @MethodSource("textSerializationSupplier") + public void textToOutputStreamSerialization(Object value, String expected) throws IOException { + AccessibleByteArrayOutputStream outputStream = new AccessibleByteArrayOutputStream(); + ADAPTER.serialize(value, SerializerEncoding.TEXT, outputStream); + + if (expected == null) { + assertEquals(0, outputStream.count()); + } else { + assertEquals(expected, outputStream.toString(StandardCharsets.UTF_8)); + } + } + + private static Stream textSerializationSupplier() { + Map map = Collections.singletonMap("key", "value"); + + return Stream.of( + Arguments.of(1, "1"), + Arguments.of(1L, "1"), + Arguments.of(1.0F, "1.0"), + Arguments.of(1.0D, "1.0"), + Arguments.of("1", "1"), + Arguments.of(HttpMethod.GET, "GET"), + Arguments.of(GeoObjectType.POINT, "Point"), + Arguments.of(map, String.valueOf(map)), + Arguments.of(null, null) + ); + } + + @ParameterizedTest + @MethodSource("textDeserializationSupplier") + public void stringToTextDeserialization(byte[] stringBytes, Class type, Object expected) throws IOException { + Object actual = ADAPTER.deserialize(new String(stringBytes, StandardCharsets.UTF_8), type, + SerializerEncoding.TEXT); + + if (type == byte[].class) { + assertArrayEquals((byte[]) expected, (byte[]) actual); + } else { + assertEquals(expected, actual); + } + } + + @ParameterizedTest + @MethodSource("textDeserializationSupplier") + public void bytesToTextDeserialization(byte[] bytes, Class type, Object expected) throws IOException { + Object actual = ADAPTER.deserialize(bytes, type, SerializerEncoding.TEXT); + + if (type == byte[].class) { + assertArrayEquals((byte[]) expected, (byte[]) actual); + } else { + assertEquals(expected, actual); + } + } + + @ParameterizedTest + @MethodSource("textDeserializationSupplier") + public void inputStreamToTextDeserialization(byte[] inputStreamBytes, Class type, Object expected) + throws IOException { + Object actual = ADAPTER.deserialize(new ByteArrayInputStream(inputStreamBytes), type, SerializerEncoding.TEXT); + + if (type == byte[].class) { + assertArrayEquals((byte[]) expected, (byte[]) actual); + } else { + assertEquals(expected, actual); + } + } + + private static Stream textDeserializationSupplier() throws MalformedURLException { + byte[] helloBytes = "hello".getBytes(StandardCharsets.UTF_8); + String urlUri = "https://azure.com"; + byte[] urlUriBytes = urlUri.getBytes(StandardCharsets.UTF_8); + OffsetDateTime offsetDateTime = OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS); + DateTimeRfc1123 dateTimeRfc1123 = new DateTimeRfc1123(offsetDateTime); + LocalDate localDate = LocalDate.now(ZoneOffset.UTC); + UUID uuid = UUID.randomUUID(); + HttpMethod httpMethod = HttpMethod.GET; + GeoObjectType geoObjectType = GeoObjectType.POINT; + + return Stream.of( + Arguments.of(helloBytes, String.class, "hello"), + Arguments.of(helloBytes, CharSequence.class, "hello"), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), int.class, 1), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), Integer.class, 1), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), byte.class, (byte) 49), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), Byte.class, (byte) 49), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), long.class, 1L), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), Long.class, 1L), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), short.class, (short) 1), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), Short.class, (short) 1), + Arguments.of("1.0".getBytes(StandardCharsets.UTF_8), double.class, 1.0D), + Arguments.of("1.0".getBytes(StandardCharsets.UTF_8), Double.class, 1.0D), + Arguments.of("1.0".getBytes(StandardCharsets.UTF_8), float.class, 1.0F), + Arguments.of("1.0".getBytes(StandardCharsets.UTF_8), Float.class, 1.0F), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), char.class, '1'), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), Character.class, '1'), + Arguments.of("1".getBytes(StandardCharsets.UTF_8), byte[].class, "1".getBytes(StandardCharsets.UTF_8)), + Arguments.of("true".getBytes(StandardCharsets.UTF_8), boolean.class, true), + Arguments.of("true".getBytes(StandardCharsets.UTF_8), Boolean.class, true), + Arguments.of(urlUriBytes, URL.class, new URL(urlUri)), + Arguments.of(urlUriBytes, URI.class, URI.create(urlUri)), + Arguments.of(getObjectBytes(offsetDateTime), OffsetDateTime.class, offsetDateTime), + Arguments.of(getObjectBytes(dateTimeRfc1123), DateTimeRfc1123.class, dateTimeRfc1123), + Arguments.of(getObjectBytes(localDate), LocalDate.class, localDate), + Arguments.of(getObjectBytes(uuid), UUID.class, uuid), + Arguments.of(getObjectBytes(httpMethod), HttpMethod.class, httpMethod), + Arguments.of(getObjectBytes(geoObjectType), GeoObjectType.class, geoObjectType) + ); + } + + @ParameterizedTest + @MethodSource("textUnsupportedDeserializationSupplier") + public void unsupportedTextTypesDeserialization(Class unsupportedType, + Class exceptionType) { + assertThrows(exceptionType, () -> ADAPTER.deserialize(":////", unsupportedType, SerializerEncoding.TEXT)); + } + + private static Stream textUnsupportedDeserializationSupplier() { + return Stream.of( + Arguments.of(InputStream.class, IllegalStateException.class), + Arguments.of(JsonPatchDocument.class, IllegalStateException.class), + Arguments.of(URL.class, IOException.class), // Thrown when the String isn't a valid URL + Arguments.of(URI.class, IllegalArgumentException.class) // Thrown when the String isn't a valid URI + ); + } + public static final class StronglyTypedHeaders { private final DateTimeRfc1123 date; @@ -253,4 +411,8 @@ public InvalidStronglyTypedHeaders(HttpHeaders httpHeaders) throws Exception { throw new Exception(); } } + + private static byte[] getObjectBytes(Object value) { + return String.valueOf(value).getBytes(StandardCharsets.UTF_8); + } } diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/SerializerEncodingTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/SerializerEncodingTests.java index bcf76b4d1cca1..b5edeb9b3a058 100644 --- a/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/SerializerEncodingTests.java +++ b/sdk/core/azure-core/src/test/java/com/azure/core/util/serializer/SerializerEncodingTests.java @@ -17,8 +17,10 @@ class SerializerEncodingTests { private static final String CONTENT_TYPE = "Content-Type"; @ParameterizedTest - @ValueSource(strings = {"application/xml", "application/atom+xml", "text/xml", "application/foo+XML", "TEXT/XML", - "application/xml;charset=utf-8", "application/atom+xml; charset=utf-32"}) + @ValueSource(strings = { + "application/xml", "application/atom+xml", "text/xml", "application/foo+XML", "TEXT/XML", + "application/xml;charset=utf-8", "application/atom+xml; charset=utf-32" + }) void recognizeXml(String mimeType) { // Arrange HttpHeaders headers = new HttpHeaders(Collections.singletonMap(CONTENT_TYPE, mimeType)); @@ -28,8 +30,10 @@ void recognizeXml(String mimeType) { } @ParameterizedTest - @ValueSource(strings = {"application/json", "application/kv+json", "APPLICATION/JSON", "application/FOO+JSON", - "application/json;charset=utf-8", "application/config+json; charset=utf-32"}) + @ValueSource(strings = { + "application/json", "application/kv+json", "APPLICATION/JSON", "application/FOO+JSON", + "application/json;charset=utf-8", "application/config+json; charset=utf-32" + }) void recognizeJson(String mimeType) { // Arrange HttpHeaders headers = new HttpHeaders(Collections.singletonMap(CONTENT_TYPE, mimeType)); @@ -38,6 +42,18 @@ void recognizeJson(String mimeType) { Assertions.assertEquals(SerializerEncoding.JSON, SerializerEncoding.fromHeaders(headers)); } + @ParameterizedTest + @ValueSource(strings = { + "text/css", "text/csv", "text/html", "text/javascript", "text/plain", "TEXT/PLAIN", "text/plain; charset=utf-8" + }) + void recognizeText(String mimeType) { + // Arrange + HttpHeaders headers = new HttpHeaders(Collections.singletonMap(CONTENT_TYPE, mimeType)); + + // Act & Assert + Assertions.assertEquals(SerializerEncoding.TEXT, SerializerEncoding.fromHeaders(headers)); + } + @Test void defaultNoContentType() { // Arrange