diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000000000..bfb7f2e650b1d --- /dev/null +++ b/common/.gitignore @@ -0,0 +1,31 @@ +build +*.class +target +gen + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ +tmp + +# Package Files # +*.jar +*.war +*.ear +*.MF +.gitrevision +.gradle + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# ide +.idea +*.iml +.DS_Store +**/temp/ + +#eclipse +.project +.classpath +.settings + diff --git a/common/.travis.yml b/common/.travis.yml new file mode 100644 index 0000000000000..10c5ba5405405 --- /dev/null +++ b/common/.travis.yml @@ -0,0 +1,17 @@ +language: android +android: + components: + - build-tools-23.0.1 + - android-23 + - platform-tools + - extra-android-support + - extra-google-m2repository + - extra-android-m2repository +sudo: false +env: +- TERM=dumb # Makes Gradle use plain console output +script: +- mvn clean install -B -Dstyle.color=always +- mvn checkstyle:check -B -Dstyle.color=always +# (ignore android build for now) +# - cd ./azure-android-client-authentication && ./gradlew check diff --git a/common/ChangeLog.txt b/common/ChangeLog.txt new file mode 100644 index 0000000000000..bafac90a6cb73 --- /dev/null +++ b/common/ChangeLog.txt @@ -0,0 +1,15 @@ +2.0.0-beta4 (2018-08-06) +- Added HttpRequest request() property to RestResponse +- Added isProxyHTTPS() property to HttpClientConfiguration + +2.0.0-beta3 (2018-06-26) +- Added FlowableUtil.ensureLength() operator to better handle cases where the request body had an unexpected size + +2.0.0-beta2 (2018-04-23) +- Major refinements to HTTP content streaming, in large part thanks to contributions by [David Moten](https://github.com/davidmoten). +- Removed Joda Time in favor of Java 8 DateTime classes +- NettyClient.Factory now accepts a Netty Bootstrap object allowing for more user configuration of channel attributes, such as the receive buffer size and low/high write watermarks. Currently, specifying an EventLoopGroup or `Class` is not supported. +- Various other minor improvements + +2.0.0-beta1 (2018-03-08) +- First beta featuring Netty and RxJava 2. diff --git a/common/LICENSE b/common/LICENSE new file mode 100644 index 0000000000000..4918d653ba68b --- /dev/null +++ b/common/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Microsoft Azure + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000000000..ce52d05ef7290 --- /dev/null +++ b/common/README.md @@ -0,0 +1,124 @@ +[![Build Status](https://travis-ci.org/Azure/autorest-clientruntime-for-java.svg?branch=v2)](https://travis-ci.org/Azure/autorest-clientruntime-for-java) + +# AutoRest Client Runtimes for Java +The runtime libraries for [AutoRest](https://github.com/azure/autorest) generated Java clients. + +## Usage + +### Prerequisites + +- JDK 1.8 + +### Download + +```xml + + + + com.microsoft.rest.v3 + client-runtime + 2.0.0-beta4 + + + + + com.microsoft.azure.v3 + azure-client-runtime + 2.0.0-beta4 + + + + com.microsoft.azure.v3 + azure-client-authentication + 2.0.0-beta4 + + + + + + + io.netty + netty-tcnative-boringssl-static + 2.0.8.Final + ${os.detected.classifier} + + + + + io.netty + netty-transport-native-epoll + 4.1.23.Final + ${os.detected.classifier} + + + + + io.netty + netty-transport-native-kqueue + 4.1.23.Final + ${os.detected.classifier} + + + + + + + + kr.motd.maven + os-maven-plugin + 1.6.0 + + + +``` + +### Usage + +Non-Azure generated clients will have a constructor that takes no arguments for simple scenarios, while Azure generated clients will require a `ServiceClientCredentials` argument at a minimum. + +If you want to have more control over configuration, consider using HttpPipeline. This enables performing transformations on all HTTP messages sent by a client, similar to interceptors or filters in other HTTP clients. + +You can build an HttpPipeline out of a sequence of RequestPolicyFactories. These policies will get applied in-order to outgoing requests, and then in reverse order for incoming responses. HttpPipelineBuilder includes convenience methods for adding several built-in RequestPolicyFactories, including policies for credentials, logging, response decoding (deserialization), cookies support, and several others. + +```java +// For Java generator +HttpPipeline pipeline = new HttpPipelineBuilder() + .withHostPolicy("http://localhost") + .withDecodingPolicy() + .build(); +AutoRestJavaClient client = new AutoRestJavaClientImpl(pipeline); + +// For Azure.Java generator +HttpPipeline azurePipeline = new HttpPipelineBuilder() + .withCredentialsPolicy(AzureCliCredentials.create()) + .withHttpLoggingPolicy(HttpLogDetailLevel.HEADERS) + .withDecodingPolicy() + .build(); +FooServiceClient azureClient = new FooServiceClientImpl(azurePipeline); +``` + +## Components + +### client-runtime +This is the generic runtime. Add this package as a dependency if you are using `Java` generator in AutoRest. This package depends on [Netty](https://github.com/netty/netty), [Jackson](http://wiki.fasterxml.com/JacksonHome), and [RxJava](https://github.com/ReactiveX/RxJava) for making and processing REST requests. + +### azure-client-runtime +This is the runtime with Azure Resource Management customizations. Add this package as a dependency if you are using `--azure-arm` or `--azure-arm --fluent` generator flags in AutoRest. + +This combination provides a set of Azure specific behaviors, including long running operations, special handling of HEAD operations, and paginated `list()` calls. + +### azure-client-authentication (beta) +This package provides access to Active Directory authentication on JDK using OrgId or application ID / secret combinations. There are currently 3 types of authentication provided: + +- Service principal authentication: `ApplicationTokenCredentials` +- Username / password login without multi-factor auth: `UserTokenCredentials` +- Use the credentials logged in [Azure CLI](https://github.com/azure/azure-cli): `AzureCliCredentials` + +### azure-android-client-authentication (beta) +This package provides access to Active Directory authentication on Android. You can login with Microsoft accounts, OrgId, with or without multi-factor auth. + +## Build +To build this repository, you will need maven 2.0+ and gradle 1.6+. + +## Contributing +This repository is for runtime & authentication specifically. For issues in the generated code, please report in [AutoRest](https://github.com/Azure/autorest). For bugs in the Azure SDK, please report in [Azure SDK for Java](https://github.com/Azure/azure-sdk-for-java). If you are unsure, please file here and state that clearly in the issue. Pull requests are welcomed with clear Javadocs. diff --git a/common/azure-android-client-authentication/build.gradle b/common/azure-android-client-authentication/build.gradle new file mode 100644 index 0000000000000..b93e53c502be0 --- /dev/null +++ b/common/azure-android-client-authentication/build.gradle @@ -0,0 +1,117 @@ +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:1.3.0' + } +} + +apply plugin: 'com.android.library' +apply plugin: 'maven' + +android { + compileSdkVersion 23 + buildToolsVersion "23.0.1" + + defaultConfig { + minSdkVersion 15 + targetSdkVersion 23 + versionCode 1 + versionName "1.0.0-beta6-SNAPSHOT" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_7 + targetCompatibility JavaVersion.VERSION_1_7 + } + + lintOptions { + abortOnError false + } +} + +configurations { + deployerJars +} + +repositories { + mavenCentral() + maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:appcompat-v7:23.0.1' + compile 'com.microsoft.aad:adal:1.1.11' + compile 'com.microsoft.azure:azure-client-runtime:1.0.0-beta2' + testCompile 'junit:junit:4.12' + testCompile 'junit:junit-dep:4.11' + deployerJars "org.apache.maven.wagon:wagon-ftp:2.10" +} + +uploadArchives { + repositories { + mavenDeployer { + configuration = configurations.deployerJars + snapshotRepository(url: "ftp://waws-prod-bay-005.ftp.azurewebsites.windows.net/site/wwwroot/") { + authentication(userName: username, password: password) + } + repository(url: "file://$buildDir/repository") + pom.setArtifactId "azure-android-client-authentication" + pom.project { + name 'Microsoft Azure AutoRest Authentication Library for Java' + description 'This is the authentication library for AutoRest generated Azure Java clients.' + url 'https://github.com/Azure/autorest' + + scm { + url 'scm:git:https://github.com/Azure/AutoRest' + connection 'scm:git:git://github.com/Azure/AutoRest.git' + } + + licenses { + license { + name 'The MIT License (MIT)' + url 'http://opensource.org/licenses/MIT' + distribution 'repo' + } + } + + developers { + developer { + id 'microsoft' + name 'Microsoft' + } + } + } + } + } +} + +task sourcesJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + classifier = 'sources' +} + +task javadoc(type: Javadoc) { + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) + options.encoding = 'UTF-8' +} + +task javadocJar(type: Jar, dependsOn: [javadoc]) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +artifacts { + archives sourcesJar + archives javadocJar +} diff --git a/common/azure-android-client-authentication/gradle.properties b/common/azure-android-client-authentication/gradle.properties new file mode 100644 index 0000000000000..7311d1b56e3bb --- /dev/null +++ b/common/azure-android-client-authentication/gradle.properties @@ -0,0 +1,2 @@ +username = fake +password = fake \ No newline at end of file diff --git a/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.jar b/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000..05ef575b0cd01 Binary files /dev/null and b/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.jar differ diff --git a/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.properties b/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000000..3ae0abbd2b66d --- /dev/null +++ b/common/azure-android-client-authentication/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Nov 11 13:21:00 PST 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.8-bin.zip diff --git a/common/azure-android-client-authentication/gradlew b/common/azure-android-client-authentication/gradlew new file mode 100755 index 0000000000000..9d82f78915133 --- /dev/null +++ b/common/azure-android-client-authentication/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/common/azure-android-client-authentication/gradlew.bat b/common/azure-android-client-authentication/gradlew.bat new file mode 100644 index 0000000000000..f4c57b05d5739 --- /dev/null +++ b/common/azure-android-client-authentication/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM pipelineOptions here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM pipelineOptions to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/common/azure-android-client-authentication/proguard-rules.pro b/common/azure-android-client-authentication/proguard-rules.pro new file mode 100644 index 0000000000000..51508f487ebc5 --- /dev/null +++ b/common/azure-android-client-authentication/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in E:\Users\jianghlu\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/common/azure-android-client-authentication/src/main/AndroidManifest.xml b/common/azure-android-client-authentication/src/main/AndroidManifest.xml new file mode 100644 index 0000000000000..281c2ce4932ba --- /dev/null +++ b/common/azure-android-client-authentication/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/AzureEnvironment.java b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/AzureEnvironment.java new file mode 100644 index 0000000000000..ddb02625b0399 --- /dev/null +++ b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/AzureEnvironment.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.microsoft.azure.credentials; + +/** + * An instance of this class describes an environment in Azure. + */ +public final class AzureEnvironment { + /** + * ActiveDirectory Endpoint for the Azure Environment. + */ + private String authenticationEndpoint; + /** + * Token audience for an endpoint. + */ + private String tokenAudience; + /** + * Determines whether the authentication endpoint should + * be validated with Azure AD. Default value is true. + */ + private boolean validateAuthority; + + /** + * Initializes an instance of AzureEnvironment class. + * + * @param authenticationEndpoint ActiveDirectory Endpoint for the Azure Environment. + * @param tokenAudience token audience for an endpoint. + * @param validateAuthority whether the authentication endpoint should + * be validated with Azure AD. + */ + public AzureEnvironment(String authenticationEndpoint, String tokenAudience, boolean validateAuthority) { + this.authenticationEndpoint = authenticationEndpoint; + this.tokenAudience = tokenAudience; + this.validateAuthority = validateAuthority; + } + + /** + * Provides the settings for authentication with Azure. + */ + public static final AzureEnvironment AZURE = new AzureEnvironment( + "https://login.windows.net/", + "https://management.core.windows.net/", + true); + + /** + * Provides the settings for authentication with Azure China. + */ + public static final AzureEnvironment AZURE_CHINA = new AzureEnvironment( + "https://login.chinacloudapi.cn/", + "https://management.core.chinacloudapi.cn/", + true); + + /** + * Gets the ActiveDirectory Endpoint for the Azure Environment. + * + * @return the ActiveDirectory Endpoint for the Azure Environment. + */ + public String getAuthenticationEndpoint() { + return authenticationEndpoint; + } + + /** + * Gets the token audience for an endpoint. + * + * @return the token audience for an endpoint. + */ + public String getTokenAudience() { + return tokenAudience; + } + + /** + * Gets whether the authentication endpoint should + * be validated with Azure AD. + * + * @return true if the authentication endpoint should be validated with + * Azure AD, false otherwise. + */ + public boolean isValidateAuthority() { + return validateAuthority; + } +} diff --git a/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/UserTokenCredentials.java b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/UserTokenCredentials.java new file mode 100644 index 0000000000000..95d0c14f12f54 --- /dev/null +++ b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/UserTokenCredentials.java @@ -0,0 +1,189 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.microsoft.azure.credentials; + +import android.app.Activity; + +import com.microsoft.aad.adal.AuthenticationCallback; +import com.microsoft.aad.adal.AuthenticationContext; +import com.microsoft.aad.adal.AuthenticationResult; +import com.microsoft.aad.adal.DefaultTokenCacheStore; +import com.microsoft.aad.adal.PromptBehavior; +import com.microsoft.rest.credentials.TokenCredentials; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CountDownLatch; + +import javax.crypto.NoSuchPaddingException; + +/** + * Token based credentials for use with a REST Service Client. + */ +public class UserTokenCredentials extends TokenCredentials { + /** The Active Directory application client id. */ + private String clientId; + /** The domain or tenant id containing this application. */ + private String domain; + /** The Uri where the user will be redirected after authenticating with AD. */ + private String clientRedirectUri; + /** The Azure environment to authenticate with. */ + private AzureEnvironment environment; + /** The caller activity. */ + private Activity activity; + /** The count down latch to synchronize token acquisition. */ + private CountDownLatch signal = new CountDownLatch(1); + /** The static token cache. */ + private static DefaultTokenCacheStore tokenCacheStore; + /** The behavior of when to prompt a login. */ + private PromptBehavior promptBehavior; + + /** + * Initializes a new instance of the UserTokenCredentials. + * + * @param activity The caller activity. + * @param clientId the active directory application client id. + * @param domain the domain or tenant id containing this application. + * @param clientRedirectUri the Uri where the user will be redirected after authenticating with AD. + */ + public UserTokenCredentials( + Activity activity, + String clientId, + String domain, + String clientRedirectUri) { + this(activity, clientId, domain, clientRedirectUri, PromptBehavior.Auto, AzureEnvironment.AZURE); + } + + /** + * Initializes a new instance of the UserTokenCredentials. + * + * @param activity The caller activity. + * @param clientId the active directory application client id. + * @param domain the domain or tenant id containing this application. + * @param clientRedirectUri the Uri where the user will be redirected after authenticating with AD. + * @param promptBehavior the behavior of when to prompt a login. + * @param environment the Azure environment to authenticate with. + * If null is provided, AzureEnvironment.AZURE will be used. + */ + public UserTokenCredentials( + Activity activity, + String clientId, + String domain, + String clientRedirectUri, + PromptBehavior promptBehavior, + AzureEnvironment environment) { + super(null, null); // defer token acquisition + this.clientId = clientId; + this.domain = domain; + this.clientRedirectUri = clientRedirectUri; + this.activity = activity; + this.promptBehavior = promptBehavior; + this.environment = environment; + if (tokenCacheStore == null) { + try { + tokenCacheStore = new DefaultTokenCacheStore(activity); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + tokenCacheStore = null; + } + } + } + + /** + * Clear the items stored in token cache. + */ + public static void clearTokenCache() { + tokenCacheStore.removeAll(); + } + + /** + * Gets the active directory application client id. + * + * @return the active directory application client id. + */ + public String getClientId() { + return clientId; + } + + /** + * Gets the tenant or domain containing the application. + * + * @return the tenant or domain containing the application. + */ + public String getDomain() { + return domain; + } + + /** + * Sets the tenant of domain containing the application. + * + * @param domain the tenant or domain containing the application. + */ + public void setDomain(String domain) { + this.domain = domain; + } + + /** + * Gets the Uri where the user will be redirected after authenticating with AD. + * + * @return the redirecting Uri. + */ + public String getClientRedirectUri() { + return clientRedirectUri; + } + + /** + * Gets the Azure environment to authenticate with. + * + * @return the Azure environment to authenticate with. + */ + public AzureEnvironment getEnvironment() { + return environment; + } + + @Override + public String getToken() throws IOException { + refreshToken(); + return token; + } + + @Override + public void refreshToken() throws IOException { + acquireAccessToken(); + } + + private void acquireAccessToken() throws IOException { + final String authorityUrl = this.getEnvironment().getAuthenticationEndpoint() + this.getDomain(); + AuthenticationContext context = new AuthenticationContext(activity, authorityUrl, true, tokenCacheStore); + final UserTokenCredentials self = this; + context.acquireToken( + this.getEnvironment().getTokenAudience(), + this.getClientId(), + this.getClientRedirectUri(), + null, + promptBehavior, + null, + new AuthenticationCallback() { + @Override + public void onSuccess(AuthenticationResult authenticationResult) { + if (authenticationResult != null && authenticationResult.getAccessToken() != null) { + self.setToken(authenticationResult.getAccessToken()); + signal.countDown(); + } else { + onError(new IOException("Failed to acquire access token")); + } + } + + @Override + public void onError(Exception e) { + signal.countDown(); + } + }); + try { + signal.await(); + } catch (InterruptedException e) { /* Ignore */ } + } +} diff --git a/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/package-info.java b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/package-info.java new file mode 100644 index 0000000000000..2f23671d5be16 --- /dev/null +++ b/common/azure-android-client-authentication/src/main/java/com/microsoft/azure/credentials/package-info.java @@ -0,0 +1,5 @@ +/** + * The package provides 2 credential classes that work with AutoRest + * generated Azure clients for authentication purposes through Azure. + */ +package com.microsoft.azure.credentials; \ No newline at end of file diff --git a/common/azure-android-client-authentication/src/main/res/values/strings.xml b/common/azure-android-client-authentication/src/main/res/values/strings.xml new file mode 100644 index 0000000000000..e2639a86a6c96 --- /dev/null +++ b/common/azure-android-client-authentication/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + azure-android-client-authentication + diff --git a/common/azure-common-auth/pom.xml b/common/azure-common-auth/pom.xml new file mode 100644 index 0000000000000..df55011eea293 --- /dev/null +++ b/common/azure-common-auth/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + com.azure + azure-common-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + com.azure + azure-common-auth + jar + 1.0.0-SNAPSHOT + + Azure Authentication Java Common Library + This package contains the authentication connectors to Active Directory for Azure Java Clients. + https://github.com/Azure/autorest-clientruntime-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + scm:git:https://github.com/Azure/autorest-clientruntime-for-java + scm:git:git@github.com:Azure/autorest-clientruntime-for-java.git + HEAD + + + + UTF-8 + + + + + + microsoft + Microsoft + + + + + + com.azure + azure-common + 1.0.0-SNAPSHOT + + + com.microsoft.azure + adal4j + + + junit + junit + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.8 + + *.implementation.*;*.utils.*;com.microsoft.schemas._2003._10.serialization;*.blob.core.storage + /** +
* Copyright (c) Microsoft Corporation. All rights reserved. +
* Licensed under the MIT License. See License.txt in the project root for +
* license information. +
*/]]>
+
+
+ +
+
+
diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/ApplicationTokenCredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/ApplicationTokenCredentials.java new file mode 100644 index 0000000000000..1b6b1f9034d3b --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/ApplicationTokenCredentials.java @@ -0,0 +1,181 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.microsoft.aad.adal4j.AsymmetricKeyCredential; +import com.microsoft.aad.adal4j.AuthenticationContext; +import com.microsoft.aad.adal4j.AuthenticationException; +import com.microsoft.aad.adal4j.AuthenticationResult; +import com.microsoft.aad.adal4j.ClientCredential; +import com.azure.common.AzureEnvironment; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Token based credentials for use with a REST Service Client. + */ +public class ApplicationTokenCredentials extends AzureTokenCredentials { + /** A mapping from resource endpoint to its cached access token. */ + private Map tokens; + /** The active directory application client id. */ + private String clientId; + /** The authentication secret for the application. */ + private String clientSecret; + /** The PKCS12 certificate byte array. */ + private byte[] clientCertificate; + /** The certificate password. */ + private String clientCertificatePassword; + + /** + * Initializes a new instance of the ApplicationTokenCredentials. + * + * @param clientId the active directory application client id. + * @param domain the domain or tenant id containing this application. + * @param secret the authentication secret for the application. + * @param environment the Azure environment to authenticate with. + * If null is provided, AzureEnvironment.AZURE will be used. + */ + public ApplicationTokenCredentials(String clientId, String domain, String secret, AzureEnvironment environment) { + super(environment, domain); // defer token acquisition + this.clientId = clientId; + this.clientSecret = secret; + this.tokens = new HashMap<>(); + } + + /** + * Initializes a new instance of the ApplicationTokenCredentials. + * + * @param clientId the active directory application client id. + * @param domain the domain or tenant id containing this application. + * @param certificate the PKCS12 certificate file content + * @param password the password to the certificate file + * @param environment the Azure environment to authenticate with. + * If null is provided, AzureEnvironment.AZURE will be used. + */ + public ApplicationTokenCredentials(String clientId, String domain, byte[] certificate, String password, AzureEnvironment environment) { + super(environment, domain); + this.clientId = clientId; + this.clientCertificate = certificate; + this.clientCertificatePassword = password; + this.tokens = new HashMap<>(); + } + + /** + * Initializes the credentials based on the provided credentials file. + * + * @param credentialsFile A file with credentials, using the standard Java properties format. + * and the following keys: + * subscription=<subscription-id> + * tenant=<tenant-id> + * client=<client-id> + * key=<client-key> + * managementURI=<management-URI> + * baseURL=<base-URL> + * authURL=<authentication-URL> + * or a JSON format and the following keys + * { + * "clientId": "<client-id>", + * "clientSecret": "<client-key>", + * "subscriptionId": "<subscription-id>", + * "tenantId": "<tenant-id>", + * } + * and any custom endpoints listed in {@link AzureEnvironment}. + * + * @return The credentials based on the file. + * @throws IOException exception thrown from file access errors. + */ + public static ApplicationTokenCredentials fromFile(File credentialsFile) throws IOException { + return AuthFile.parse(credentialsFile).generateCredentials(); + } + + /** + * Gets the active directory application client id. + * + * @return the active directory application client id. + */ + public String clientId() { + return clientId; + } + + String clientSecret() { + return clientSecret; + } + + byte[] clientCertificate() { + return clientCertificate; + } + + String clientCertificatePassword() { + return clientCertificatePassword; + } + + @Override + public synchronized Mono getToken(String resource) { + AuthenticationResult authenticationResult = tokens.get(resource); + if (authenticationResult != null && authenticationResult.getExpiresOnDate().after(new Date())) { + return Mono.just(authenticationResult.getAccessToken()); + } else { + return acquireAccessToken(resource) + .map(ar -> { + tokens.put(resource, ar); + return ar.getAccessToken(); + }); + } + } + + private Mono acquireAccessToken(String resource) { + String authorityUrl = this.environment().activeDirectoryEndpoint() + this.domain(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + AuthenticationContext context; + try { + context = new AuthenticationContext(authorityUrl, false, executor); + } catch (MalformedURLException mue) { + executor.shutdown(); + throw Exceptions.propagate(mue); + } + if (proxy() != null) { + context.setProxy(proxy()); + } + Mono authMono; + if (clientSecret != null) { + authMono = Mono.create(callback -> { + context.acquireToken( + resource, + new ClientCredential(this.clientId(), clientSecret), + Util.authenticationDelegate(callback)); + }); + } else if (clientCertificate != null && clientCertificatePassword != null) { + authMono = Mono.create(callback -> { + AsymmetricKeyCredential keyCredential = Util.createAsymmetricKeyCredential(clientId, clientCertificate, clientCertificatePassword); + context.acquireToken( + resource, + keyCredential, + Util.authenticationDelegate(callback)); + }); + } else if (clientCertificate != null) { + AsymmetricKeyCredential keyCredential = AsymmetricKeyCredential.create(clientId(), Util.privateKeyFromPem(new String(clientCertificate)), Util.publicKeyFromPem(new String(clientCertificate))); + authMono = Mono.create(callback -> { + context.acquireToken( + resource, + keyCredential, + Util.authenticationDelegate(callback)); + }); + } else { + authMono = Mono.error(new AuthenticationException("Please provide either a non-null secret or a non-null certificate.")); + } + return authMono.doFinally(s -> executor.shutdown()); + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AuthFile.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AuthFile.java new file mode 100644 index 0000000000000..2ca1c92d0cfca --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AuthFile.java @@ -0,0 +1,169 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.gson.reflect.TypeToken; +import com.azure.common.annotations.Beta; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * This class describes the information from a .azureauth file. + */ +@Beta(since = "v1.1.0") +final class AuthFile { + + private String clientId; + private String tenantId; + private String clientSecret; + private String clientCertificate; + private String clientCertificatePassword; + private String subscriptionId; + + @JsonIgnore + private AzureEnvironment environment; + @JsonIgnore + private static final JacksonAdapter ADAPTER = new JacksonAdapter(); + @JsonIgnore + private String authFilePath; + + private AuthFile() { + environment = new AzureEnvironment(new HashMap()); + environment.endpoints().putAll(AzureEnvironment.AZURE.endpoints()); + } + + /** + * Parses an auth file and read into an AuthFile object. + * @param file the auth file to read + * @return the AuthFile object created + * @throws IOException thrown when the auth file or the certificate file cannot be read or parsed + */ + static AuthFile parse(File file) throws IOException { + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + + AuthFile authFile; + if (isJsonBased(content)) { + authFile = ADAPTER.deserialize(content, AuthFile.class, SerializerEncoding.JSON); + Map endpoints = ADAPTER.deserialize(content, new TypeToken>() { }.getType(), SerializerEncoding.JSON); + authFile.environment.endpoints().putAll(endpoints); + } else { + // Set defaults + Properties authSettings = new Properties(); + authSettings.put(CredentialSettings.AUTH_URL.toString(), AzureEnvironment.AZURE.activeDirectoryEndpoint()); + authSettings.put(CredentialSettings.BASE_URL.toString(), AzureEnvironment.AZURE.resourceManagerEndpoint()); + authSettings.put(CredentialSettings.MANAGEMENT_URI.toString(), AzureEnvironment.AZURE.managementEndpoint()); + authSettings.put(CredentialSettings.GRAPH_URL.toString(), AzureEnvironment.AZURE.graphEndpoint()); + authSettings.put(CredentialSettings.VAULT_SUFFIX.toString(), AzureEnvironment.AZURE.keyVaultDnsSuffix()); + + // Load the credentials from the file + StringReader credentialsReader = new StringReader(content); + authSettings.load(credentialsReader); + credentialsReader.close(); + + authFile = new AuthFile(); + authFile.clientId = authSettings.getProperty(CredentialSettings.CLIENT_ID.toString()); + authFile.tenantId = authSettings.getProperty(CredentialSettings.TENANT_ID.toString()); + authFile.clientSecret = authSettings.getProperty(CredentialSettings.CLIENT_KEY.toString()); + authFile.clientCertificate = authSettings.getProperty(CredentialSettings.CLIENT_CERT.toString()); + authFile.clientCertificatePassword = authSettings.getProperty(CredentialSettings.CLIENT_CERT_PASS.toString()); + authFile.subscriptionId = authSettings.getProperty(CredentialSettings.SUBSCRIPTION_ID.toString()); + authFile.environment.endpoints().put(AzureEnvironment.Endpoint.MANAGEMENT.identifier(), authSettings.getProperty(CredentialSettings.MANAGEMENT_URI.toString())); + authFile.environment.endpoints().put(AzureEnvironment.Endpoint.ACTIVE_DIRECTORY.identifier(), authSettings.getProperty(CredentialSettings.AUTH_URL.toString())); + authFile.environment.endpoints().put(AzureEnvironment.Endpoint.RESOURCE_MANAGER.identifier(), authSettings.getProperty(CredentialSettings.BASE_URL.toString())); + authFile.environment.endpoints().put(AzureEnvironment.Endpoint.GRAPH.identifier(), authSettings.getProperty(CredentialSettings.GRAPH_URL.toString())); + authFile.environment.endpoints().put(AzureEnvironment.Endpoint.KEYVAULT.identifier(), authSettings.getProperty(CredentialSettings.VAULT_SUFFIX.toString())); + } + authFile.authFilePath = file.getParent(); + + return authFile; + } + + private static boolean isJsonBased(String content) { + return content.startsWith("{"); + } + + /** + * @return an ApplicationTokenCredentials object from the information in this class + */ + ApplicationTokenCredentials generateCredentials() throws IOException { + if (clientSecret != null) { + return (ApplicationTokenCredentials) new ApplicationTokenCredentials( + clientId, + tenantId, + clientSecret, + environment).withDefaultSubscriptionId(subscriptionId); + } else if (clientCertificate != null) { + byte[] certData; + if (new File(clientCertificate).exists()) { + certData = Files.readAllBytes(Paths.get(clientCertificate)); + } else { + certData = Files.readAllBytes(Paths.get(authFilePath, clientCertificate)); + } + + return (ApplicationTokenCredentials) new ApplicationTokenCredentials( + clientId, + tenantId, + certData, + clientCertificatePassword, + environment).withDefaultSubscriptionId(subscriptionId); + } else { + throw new IllegalArgumentException("Please specify either a client key or a client certificate."); + } + } + + /** + * Contains the keys of the settings in a Properties file to read credentials from. + */ + private enum CredentialSettings { + /** The subscription GUID. */ + SUBSCRIPTION_ID("subscription"), + /** The tenant GUID or domain. */ + TENANT_ID("tenant"), + /** The client id for the client application. */ + CLIENT_ID("client"), + /** The client secret for the service principal. */ + CLIENT_KEY("key"), + /** The client certificate for the service principal. */ + CLIENT_CERT("certificate"), + /** The password for the client certificate for the service principal. */ + CLIENT_CERT_PASS("certificatePassword"), + /** The management endpoint. */ + MANAGEMENT_URI("managementURI"), + /** The base URL to the current Azure environment. */ + BASE_URL("baseURL"), + /** The URL to Active Directory authentication. */ + AUTH_URL("authURL"), + /** The URL to Active Directory Graph. */ + GRAPH_URL("graphURL"), + /** The suffix of Key Vaults. */ + VAULT_SUFFIX("vaultSuffix"); + + /** The name of the key in the properties file. */ + private final String name; + + CredentialSettings(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliCredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliCredentials.java new file mode 100644 index 0000000000000..029d7b04e3337 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliCredentials.java @@ -0,0 +1,135 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.azure.common.annotations.Beta; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Token based credentials for use with a REST Service Client. + */ +@Beta +public final class AzureCliCredentials extends AzureTokenCredentials { + private static final ObjectMapper MAPPER = new JacksonAdapter().serializer().setDateFormat(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss.SSSSSS")); + /** A mapping from resource endpoint to its cached access token. */ + private Map subscriptions; + private File azureProfile; + private File accessTokens; + + private AzureCliCredentials() { + super(null, null); + subscriptions = new ConcurrentHashMap<>(); + } + + private synchronized void loadAccessTokens() throws IOException { + try { + AzureCliSubscription.Wrapper wrapper = MAPPER.readValue(azureProfile, AzureCliSubscription.Wrapper.class); + List tokens = MAPPER.readValue(accessTokens, new TypeReference>() { }); + while (wrapper == null || tokens == null || tokens.isEmpty() || wrapper.subscriptions == null || wrapper.subscriptions.isEmpty()) { + System.err.println("Please login in Azure CLI and press any key to continue after you've successfully logged in."); + System.in.read(); + wrapper = MAPPER.readValue(azureProfile, AzureCliSubscription.Wrapper.class); + tokens = MAPPER.readValue(accessTokens, new TypeReference>() { }); + } + for (AzureCliSubscription subscription : wrapper.subscriptions) { + for (AzureCliToken token : tokens) { + // Find match of user and tenant + if (subscription.isServicePrincipal() == token.isServicePrincipal() + && subscription.userName().equalsIgnoreCase(token.user()) + && subscription.tenant().equalsIgnoreCase(token.tenant())) { + subscriptions.put(subscription.id(), subscription.withToken(token)); + if (subscription.isDefault()) { + withDefaultSubscriptionId(subscription.id()); + } + } + } + } + } catch (IOException e) { + System.err.println(String.format("Cannot read files %s and %s. Are you logged in Azure CLI?", azureProfile.getAbsolutePath(), accessTokens.getAbsolutePath())); + throw e; + } + } + + /** + * Creates an instance of AzureCliCredentials with the default Azure CLI configuration. + * + * @return an instance of AzureCliCredentials + * @throws IOException if the Azure CLI token files are not accessible + */ + public static AzureCliCredentials create() throws IOException { + return create( + Paths.get(System.getProperty("user.home"), ".azure", "azureProfile.json").toFile(), + Paths.get(System.getProperty("user.home"), ".azure", "accessTokens.json").toFile()); + } + + /** + * Creates an instance of AzureCliCredentials with custom locations of the token files. + * + * @param azureProfile the azureProfile.json file created by Azure CLI + * @param accessTokens the accessTokens.json file created by Azure CLI + * @return an instance of AzureCliCredentials + * @throws IOException if the Azure CLI token files are not accessible + */ + public static AzureCliCredentials create(File azureProfile, File accessTokens) throws IOException { + AzureCliCredentials credentials = new AzureCliCredentials(); + credentials.azureProfile = azureProfile; + credentials.accessTokens = accessTokens; + credentials.loadAccessTokens(); + return credentials; + } + + /** + * @return the active directory application client id + */ + public String clientId() { + return subscriptions.get(defaultSubscriptionId()).clientId(); + } + + /** + * @return the tenant or domain the containing the application + */ + @Override + public String domain() { + return subscriptions.get(defaultSubscriptionId()).tenant(); + } + + /** + * @return the Azure environment to authenticate with + */ + public AzureEnvironment environment() { + return subscriptions.get(defaultSubscriptionId()).environment(); + } + + @Override + public synchronized Mono getToken(String resource) { + return subscriptions.get(defaultSubscriptionId()).credentialInstance().getToken(resource) + .onErrorResume(t -> { + System.err.println("Please login in Azure CLI and press any key to continue after you've successfully logged in."); + try { + System.in.read(); + loadAccessTokens(); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + return subscriptions.get(defaultSubscriptionId()).credentialInstance().getToken(resource).subscribeOn(Schedulers.immediate()); + }); + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliSubscription.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliSubscription.java new file mode 100644 index 0000000000000..97039477e0db7 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliSubscription.java @@ -0,0 +1,152 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.annotations.Beta; +import com.azure.common.AzureEnvironment; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * An instance of this class represents a subscription record in azureProfiles.json. + */ +@Beta +final class AzureCliSubscription { + private String environmentName; + private String id; + private String name; + private String tenantId; + private String state; + private UserInfo user; + private String clientId; + private boolean isDefault; + + private AzureTokenCredentials credentialInstance; + + private Map userTokens = new ConcurrentHashMap<>(); + private AzureCliToken servicePrincipalToken; + + String id() { + return id; + } + + boolean isDefault() { + return isDefault; + } + + String clientId() { + return clientId; + } + + AzureCliSubscription withToken(AzureCliToken token) { + if (isServicePrincipal()) { + this.servicePrincipalToken = token; + } else { + if (token.resource() != null) { + this.userTokens.put(token.resource(), token); + } + if (this.clientId == null) { + this.clientId = token.clientId(); + } + } + return this; + } + + AzureEnvironment environment() { + if (environmentName == null) { + return null; + } else if (environmentName.equalsIgnoreCase("AzureCloud")) { + return AzureEnvironment.AZURE; + } else if (environmentName.equalsIgnoreCase("AzureChinaCloud")) { + return AzureEnvironment.AZURE_CHINA; + } else if (environmentName.equalsIgnoreCase("AzureGermanCloud")) { + return AzureEnvironment.AZURE_GERMANY; + } else if (environmentName.equalsIgnoreCase("AzureUSGovernment")) { + return AzureEnvironment.AZURE_US_GOVERNMENT; + } else { + return null; + } + } + + String tenant() { + return tenantId; + } + + boolean isServicePrincipal() { + return user.type.equalsIgnoreCase("ServicePrincipal"); + } + + String userName() { + return user.name; + } + + synchronized AzureTokenCredentials credentialInstance() { + if (credentialInstance != null) { + return credentialInstance; + } + if (isServicePrincipal()) { + credentialInstance = new ApplicationTokenCredentials( + clientId(), + tenant(), + servicePrincipalToken.accessToken(), + environment() + ); + } else { + credentialInstance = new UserTokenCredentials(clientId(), tenant(), null, null, environment()) { + @Override + public synchronized Mono getToken(String resource) { + AzureCliToken token = userTokens.get(resource); + // Management endpoint also works for resource manager + if (token == null && (resource.equalsIgnoreCase(environment().resourceManagerEndpoint()))) { + token = userTokens.get(environment().managementEndpoint()); + } + // Exact match and token hasn't expired + if (token != null && !token.expired()) { + return Mono.just(token.accessToken()); + } + // If found then refresh + boolean shouldRefresh = token != null; + // If not found for the resource, but is MRRT then also refresh + if (token == null && userTokens.values().size() > 0) { + token = new ArrayList<>(userTokens.values()).get(0); + shouldRefresh = token.isMRRT(); + } + if (shouldRefresh) { + AzureCliToken finalToken = token; + return acquireAccessTokenFromRefreshToken(resource, token.refreshToken(), token.isMRRT()) + .map(authenticationResult -> { + try { + AzureCliToken newToken = finalToken.clone().withResource(resource).withAuthenticationResult(authenticationResult); + userTokens.put(resource, newToken); + return newToken.accessToken(); + } catch (CloneNotSupportedException cnse) { + throw Exceptions.propagate(cnse); + } + }); + } else { + return Mono.error(new RuntimeException("No refresh token available for user " + userName())); + } + } + }; + } + return credentialInstance; + } + + private static class UserInfo { + private String type; + private String name; + } + + static class Wrapper { + List subscriptions; + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliToken.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliToken.java new file mode 100644 index 0000000000000..19f739631a585 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureCliToken.java @@ -0,0 +1,132 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.aad.adal4j.AuthenticationResult; +import com.azure.common.annotations.Beta; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * An instance of this class represents an entry in accessTokens.json. + */ +@Beta +final class AzureCliToken implements Cloneable { + @JsonProperty("_authority") + private String authority; + @JsonProperty("_clientId") + private String clientId; + private String tokenType; + private long expiresIn; + private String expiresOn; + private LocalDateTime expiresOnDate; + private String oid; + private String userId; + private String servicePrincipalId; + private String servicePrincipalTenant; + private boolean isMRRT; + private String resource; + private String accessToken; + private String refreshToken; + private String identityProvider; + + boolean isServicePrincipal() { + return servicePrincipalId != null; + } + + String tenant() { + if (isServicePrincipal()) { + return servicePrincipalTenant; + } else { + String[] parts = authority.split("/"); + return parts[parts.length - 1]; + } + } + + String clientId() { + if (isServicePrincipal()) { + return servicePrincipalId; + } else { + return clientId; + } + } + + String authority() { + return authority; + } + + boolean expired() { + return expiresOn != null && expiresOn().isBefore(LocalDateTime.now()); + } + + String accessToken() { + return accessToken; + } + + LocalDateTime expiresOn() { + if (expiresOnDate == null) { + try { + expiresOnDate = LocalDateTime.parse(expiresOn, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS")); + } catch (IllegalArgumentException e) { + expiresOnDate = LocalDateTime.parse(expiresOn); + } + } + return expiresOnDate; + } + + AzureCliToken withAuthenticationResult(AuthenticationResult result) { + this.accessToken = result.getAccessToken(); + this.refreshToken = result.getRefreshToken(); + this.expiresIn = result.getExpiresAfter(); + this.expiresOnDate = LocalDateTime.ofInstant(result.getExpiresOnDate().toInstant(), ZoneId.systemDefault()); + return this; + } + + AzureCliToken withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + String refreshToken() { + return refreshToken; + } + + AzureCliToken withRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + String user() { + if (isServicePrincipal()) { + return servicePrincipalId; + } else { + return userId; + } + } + + boolean isMRRT() { + return isMRRT; + } + + String resource() { + return resource; + } + + AzureCliToken withResource(String resource) { + this.resource = resource; + return this; + } + + public AzureCliToken clone() throws CloneNotSupportedException { + AzureCliToken token = (AzureCliToken) super.clone(); + token.expiresOnDate = LocalDateTime.from(expiresOn()); + return token; + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureTokenCredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureTokenCredentials.java new file mode 100644 index 0000000000000..07b5dc79263a1 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/AzureTokenCredentials.java @@ -0,0 +1,144 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; +import com.azure.common.AzureEnvironment.Endpoint; +import com.azure.common.credentials.AsyncServiceClientCredentials; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.URL; +import java.util.Map; + +/** + * AzureTokenCredentials represents a credentials object with access to Azure + * Resource management. + */ +public abstract class AzureTokenCredentials implements AsyncServiceClientCredentials { + private static final String SCHEME = "Beareer"; + private final AzureEnvironment environment; + private final String domain; + private String defaultSubscription; + + private Proxy proxy; + + /** + * Initializes a new instance of the AzureTokenCredentials. + * + * @param environment the Azure environment to use + * @param domain the tenant or domain the credential is authorized to + */ + public AzureTokenCredentials(AzureEnvironment environment, String domain) { + this.environment = (environment == null) ? AzureEnvironment.AZURE : environment; + this.domain = domain; + } + + /** + * Gets the token from the given endpoint. + * + * @param uri the url + * @return the token + */ + private Mono getTokenFromUri(String uri) { + URL url = null; + try { + url = new URL(uri); + } catch (MalformedURLException e) { + return Mono.error(e); + } + String host = String.format("%s://%s%s/", url.getProtocol(), url.getHost(), url.getPort() > 0 ? ":" + url.getPort() : ""); + String resource = environment().managementEndpoint(); + for (Map.Entry endpoint : environment().endpoints().entrySet()) { + if (host.contains(endpoint.getValue())) { + if (endpoint.getKey().equals(Endpoint.KEYVAULT.identifier())) { + resource = String.format("https://%s/", endpoint.getValue().replaceAll("^\\.*", "")); + break; + } else if (endpoint.getKey().equals(Endpoint.GRAPH.identifier())) { + resource = environment().graphEndpoint(); + break; + } else if (endpoint.getKey().equals(Endpoint.LOG_ANALYTICS.identifier())) { + resource = environment().logAnalyticsEndpoint(); + break; + } else if (endpoint.getKey().equals(Endpoint.APPLICATION_INSIGHTS.identifier())) { + resource = environment().applicationInsightsEndpoint(); + break; + } else if (endpoint.getKey().equals(Endpoint.DATA_LAKE_STORE.identifier()) + || endpoint.getKey().equals(Endpoint.DATA_LAKE_ANALYTICS.identifier())) { + resource = environment().dataLakeEndpointResourceId(); + break; + } + } + } + return getToken(resource); + } + + /** + * Override this method to provide the mechanism to get a token. + * + * @param resource the resource the access token is for + * @return the token to access the resource + */ + public abstract Mono getToken(String resource); + + @Override + public Mono authorizationHeaderValueAsync(String uri) { + return getTokenFromUri(uri).map(token -> "Bearer " + token); + } + + /** + * Override this method to provide the domain or tenant ID the token is valid in. + * + * @return the domain or tenant ID string + */ + public String domain() { + return domain; + } + + /** + * @return the environment details the credential has access to. + */ + public AzureEnvironment environment() { + return environment; + } + + /** + * @return The default subscription ID, if any + */ + public String defaultSubscriptionId() { + return defaultSubscription; + } + + /** + * Set default subscription ID. + * + * @param subscriptionId the default subscription ID. + * @return the credentials object itself. + */ + public AzureTokenCredentials withDefaultSubscriptionId(String subscriptionId) { + this.defaultSubscription = subscriptionId; + return this; + } + + /** + * @return the proxy being used for accessing Active Directory. + */ + public Proxy proxy() { + return proxy; + } + + /** + * Set the proxy used for accessing Active Directory. + * @param proxy the proxy to use + * @return the credential itself + */ + public AzureTokenCredentials withProxy(Proxy proxy) { + this.proxy = proxy; + return this; + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/DelegatedTokenCredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/DelegatedTokenCredentials.java new file mode 100644 index 0000000000000..76bd112a3dd45 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/DelegatedTokenCredentials.java @@ -0,0 +1,270 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.microsoft.aad.adal4j.AsymmetricKeyCredential; +import com.microsoft.aad.adal4j.AuthenticationContext; +import com.microsoft.aad.adal4j.AuthenticationException; +import com.microsoft.aad.adal4j.AuthenticationResult; +import com.microsoft.aad.adal4j.ClientCredential; +import com.azure.common.annotations.Beta; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Date; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Token based credentials to authenticate an application on behalf of a user. + */ +@Beta(since = "1.2.0") +public class DelegatedTokenCredentials extends AzureTokenCredentials { + /** A mapping from resource endpoint to its cached access token. */ + private Map tokens; + private String redirectUrl; + private String authorizationCode; + private ApplicationTokenCredentials applicationCredentials; + + /** + * Initializes a new instance of the DelegatedTokenCredentials. + * + * @param applicationCredentials the credentials representing a service principal + * @param redirectUrl the URL to redirect to after authentication in Active Directory + */ + public DelegatedTokenCredentials(ApplicationTokenCredentials applicationCredentials, String redirectUrl) { + super(applicationCredentials.environment(), applicationCredentials.domain()); // defer token acquisition + this.applicationCredentials = applicationCredentials; + this.tokens = new ConcurrentHashMap<>(); + this.redirectUrl = redirectUrl; + } + + /** + * Initializes a new instance of the DelegatedTokenCredentials, with a pre-acquired oauth2 authorization code. + * + * @param applicationCredentials the credentials representing a service principal + * @param redirectUrl the URL to redirect to after authentication in Active Directory + * @param authorizationCode the oauth2 authorization code + */ + public DelegatedTokenCredentials(ApplicationTokenCredentials applicationCredentials, String redirectUrl, String authorizationCode) { + this(applicationCredentials, redirectUrl); + this.authorizationCode = authorizationCode; + } + + /** + * Creates a new instance of the DelegatedTokenCredentials from an auth file. + * + * @param authFile The credentials based on the file + * @param redirectUrl the URL to redirect to after authentication in Active Directory + * @return a new delegated token credentials + * @throws IOException exception thrown from file access errors. + */ + public static DelegatedTokenCredentials fromFile(File authFile, String redirectUrl) throws IOException { + return new DelegatedTokenCredentials(ApplicationTokenCredentials.fromFile(authFile), redirectUrl); + } + + /** + * Creates a new instance of the DelegatedTokenCredentials from an auth file, + * with a pre-acquired oauth2 authorization code. + * + * @param authFile The credentials based on the file + * @param redirectUrl the URL to redirect to after authentication in Active Directory + * @param authorizationCode the oauth2 authorization code + * @return a new delegated token credentials + * @throws IOException exception thrown from file access errors. + */ + public static DelegatedTokenCredentials fromFile(File authFile, String redirectUrl, String authorizationCode) throws IOException { + return new DelegatedTokenCredentials(ApplicationTokenCredentials.fromFile(authFile), redirectUrl, authorizationCode); + } + + /** + * @return the active directory application client id + */ + public String clientId() { + return applicationCredentials.clientId(); + } + + /** + * @return the URL to authenticate through OAuth2 + */ + public String generateAuthenticationUrl() { + return String.format("%s/%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&response_mode=query&state=%s", + environment().activeDirectoryEndpoint(), domain(), clientId(), this.redirectUrl, UUID.randomUUID()); + } + + /** + * Generate the URL to authenticate through OAuth2. + * + * @param responseMode the method that should be used to send the resulting token back to your app + * @param state a value included in the request that is also returned in the token response + * @return the URL to authenticate through OAuth2 + */ + public String generateAuthenticationUrl(ResponseMode responseMode, String state) { + return String.format("%s/%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&response_mode=%s&state=%s", + environment().activeDirectoryEndpoint(), domain(), clientId(), this.redirectUrl, responseMode.value, state); + } + + /** + * Set the authorization code acquired returned to the redirect URL. + * @param authorizationCode the oauth2 authorization code + */ + public void setAuthorizationCode(String authorizationCode) { + this.authorizationCode = authorizationCode; + } + + @Override + public synchronized Mono getToken(String resource) { + // Find exact match for the resource + AuthenticationResult[] authenticationResult = new AuthenticationResult[1]; + authenticationResult[0] = tokens.get(resource); + // Return if found and not expired + if (authenticationResult[0] != null && authenticationResult[0].getExpiresOnDate().after(new Date())) { + return Mono.just(authenticationResult[0].getAccessToken()); + } + // If found then refresh + boolean shouldRefresh = authenticationResult[0] != null; + // If not found for the resource, but is MRRT then also refresh + if (authenticationResult[0] == null && !tokens.isEmpty()) { + authenticationResult[0] = new ArrayList<>(tokens.values()).get(0); + shouldRefresh = authenticationResult[0].isMultipleResourceRefreshToken(); + } + + if (shouldRefresh) { + return Mono.defer(() -> acquireAccessTokenFromRefreshToken(resource, authenticationResult[0].getRefreshToken(), authenticationResult[0].isMultipleResourceRefreshToken()) + .onErrorResume(t -> acquireNewAccessToken(resource)) + .doOnNext(ar -> tokens.put(resource, ar)) + .then(Mono.just(tokens.get(resource).getAccessToken()))); + } else { + return Mono.just(tokens.get(resource).getAccessToken()); + } + } + + private Mono acquireNewAccessToken(String resource) { + if (authorizationCode == null) { + return Mono.error(new IllegalArgumentException("You must acquire an authorization code by redirecting to the authentication URL")); + } + String authorityUrl = this.environment().activeDirectoryEndpoint() + this.domain(); + AuthenticationContext context; + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + context = new AuthenticationContext(authorityUrl, false, executor); + } catch (MalformedURLException mue) { + executor.shutdown(); + throw Exceptions.propagate(mue); + } + if (proxy() != null) { + context.setProxy(proxy()); + } + Mono authMono; + if (applicationCredentials.clientSecret() != null) { + URI uri; + try { + uri = new URI(redirectUrl); + } catch (URISyntaxException use) { + return Mono.error(use); + } + authMono = Mono.create(callback -> { + context.acquireTokenByAuthorizationCode( + authorizationCode, + uri, + new ClientCredential(applicationCredentials.clientId(), applicationCredentials.clientSecret()), + resource, + Util.authenticationDelegate(callback)); + }); + } else if (applicationCredentials.clientCertificate() != null && applicationCredentials.clientCertificatePassword() != null) { + URI uri; + try { + uri = new URI(redirectUrl); + } catch (URISyntaxException use) { + return Mono.error(use); + } + authMono = Mono.create(callback -> { + AsymmetricKeyCredential keyCredential = Util.createAsymmetricKeyCredential(applicationCredentials.clientId(), applicationCredentials.clientCertificate(), applicationCredentials.clientCertificatePassword()); + context.acquireTokenByAuthorizationCode( + authorizationCode, + uri, + keyCredential, + Util.authenticationDelegate(callback)); + }); + } else if (applicationCredentials.clientCertificate() != null) { + URI uri; + try { + uri = new URI(redirectUrl); + } catch (URISyntaxException use) { + return Mono.error(use); + } + AsymmetricKeyCredential keyCredential = AsymmetricKeyCredential.create(clientId(), + Util.privateKeyFromPem(new String(applicationCredentials.clientCertificate())), + Util.publicKeyFromPem(new String(applicationCredentials.clientCertificate()))); + authMono = Mono.create(callback -> { + context.acquireTokenByAuthorizationCode( + authorizationCode, + uri, + keyCredential, + resource, + Util.authenticationDelegate(callback)); + }); + } else { + authMono = Mono.error(new AuthenticationException("Please provide either a non-null secret or a non-null certificate.")); + } + return authMono.doFinally(s -> executor.shutdown()); + } + + // Refresh tokens are currently not used since we don't know if the refresh token has expired + private Mono acquireAccessTokenFromRefreshToken(String resource, String refreshToken, boolean isMultipleResourceRefreshToken) { + String authorityUrl = this.environment().activeDirectoryEndpoint() + this.domain(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Mono authMono = Mono.defer(() -> { + AuthenticationContext context; + try { + context = new AuthenticationContext(authorityUrl, false, executor); + } catch (MalformedURLException mue) { + throw Exceptions.propagate(mue); + } + if (proxy() != null) { + context.setProxy(proxy()); + } + return Mono.create(callback -> context.acquireTokenByRefreshToken( + refreshToken, + clientId(), + resource, + Util.authenticationDelegate(callback))); + }); + return authMono.doFinally(s -> executor.shutdown()); + } + + /** + * Specifies the method that should be used to send the resulting token back to your app. + */ + public enum ResponseMode { + + /** + * the token is sent as a query parameter. + */ + QUERY("query"), + + /** + * the token is sent as part of a form data. + */ + FORM_DATA("form_data"); + + private String value; + + ResponseMode(String value) { + this.value = value; + } + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForAppService.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForAppService.java new file mode 100644 index 0000000000000..3d9f5c2f21d26 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForAppService.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; + +/** + * Defines the configuration to be used for retrieving access token from + * within an app-service with system assigned MSI enabled. + */ +public class MSIConfigurationForAppService { + private final AzureEnvironment environment; + private String resource; + private String msiEndpoint; + private String msiSecret; + + /** + * Creates MSIConfigurationForAppService. + * + * @param environment azure environment + */ + public MSIConfigurationForAppService(AzureEnvironment environment) { + this.environment = environment; + } + + /** + * Creates MSIConfigurationForAppService. + */ + public MSIConfigurationForAppService() { + this(AzureEnvironment.AZURE); + } + + /** + * @return the azure environment. + */ + public AzureEnvironment azureEnvironment() { + return this.environment; + } + /** + * @return the audience identifying who will consume the token. + */ + public String resource() { + if (this.resource == null) { + this.resource = this.environment.managementEndpoint(); + } + return this.resource; + } + /** + * @return the endpoint from which token needs to be retrieved. + */ + public String msiEndpoint() { + if (this.msiEndpoint == null) { + this.msiEndpoint = System.getenv("MSI_ENDPOINT"); + } + return this.msiEndpoint; + } + /** + * @return the secret to use to retrieve the token. + */ + public String msiSecret() { + if (this.msiSecret == null) { + this.msiSecret = System.getenv("MSI_SECRET"); + } + return this.msiSecret; + } + /** + * Specifies the token audience. + * + * @param resource the audience of the token. + * + * @return MSIConfigurationForAppService + */ + public MSIConfigurationForAppService withResource(String resource) { + this.resource = resource; + return this; + } + /** + * Specifies the endpoint from which token needs to retrieved. + * + * @param msiEndpoint the token endpoint. + * + * @return MSIConfigurationForAppService + */ + public MSIConfigurationForAppService withMsiEndpoint(String msiEndpoint) { + this.msiSecret = msiEndpoint; + return this; + } + /** + * Specifies secret to use to retrieve the token. + * + * @param msiSecret the secret. + * + * @return MSIConfigurationForAppService + */ + public MSIConfigurationForAppService withMsiSecret(String msiSecret) { + this.msiSecret = msiSecret; + return this; + } + + @Override + public MSIConfigurationForAppService clone() { + MSIConfigurationForAppService copy = new MSIConfigurationForAppService(this.azureEnvironment()); + if (this.resource() != null) { + copy.withResource(this.resource()); + } + if (this.msiEndpoint() != null) { + copy.withMsiEndpoint(this.msiEndpoint()); + } + if (this.msiSecret() != null) { + copy.withMsiSecret(this.msiSecret()); + } + return copy; + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForVirtualMachine.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForVirtualMachine.java new file mode 100644 index 0000000000000..90c0b0118574c --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIConfigurationForVirtualMachine.java @@ -0,0 +1,216 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; + +/** + * Defines the configuration to be used for retrieving access token from + * within a VM with user assigned or system assigned MSI enabled. + */ +public class MSIConfigurationForVirtualMachine { + private final AzureEnvironment environment; + private String resource; + private MSITokenSource tokenSource; + private String objectId; + private String clientId; + private String identityId; + private Integer msiPort = null; + private int maxRetry = -1; + + /** + * Creates MSIConfigurationForVirtualMachine. + * + * @param environment azure environment + */ + public MSIConfigurationForVirtualMachine(AzureEnvironment environment) { + this.environment = environment; + } + + /** + * Creates MSIConfigurationForVirtualMachine. + */ + public MSIConfigurationForVirtualMachine() { + this(AzureEnvironment.AZURE); + } + + /** + * @return the azure environment. + */ + public AzureEnvironment azureEnvironment() { + return this.environment; + } + + /** + * @return the token retrieval source (either MSI extension running in VM or IMDS service). + */ + public MSITokenSource tokenSource() { + if (this.tokenSource == null) { + this.tokenSource = MSITokenSource.IMDS_ENDPOINT; + } + return this.tokenSource; + } + /** + * @return the audience identifying who will consume the token. + */ + public String resource() { + if (this.resource == null) { + this.resource = this.environment.managementEndpoint(); + } + return this.resource; + } + /** + * @return the principal id of user assigned or system assigned identity. + */ + public String objectId() { + return this.objectId; + } + /** + * @return the client id of user assigned or system assigned identity. + */ + public String clientId() { + return this.clientId; + } + /** + * @return the ARM resource id of the user assigned identity resource. + */ + public String identityId() { + return this.identityId; + } + /** + * @return the port of token retrieval service running in the extension. + */ + public int msiPort() { + if (this.msiPort == null) { + this.msiPort = 50342; + } + return this.msiPort; + } + + /** + * @return the maximum retries allowed. + */ + public int maxRetry() { + return this.maxRetry; + } + + /** + * Specifies the token retrieval source. + * + * @param tokenSource the source of token + * + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withTokenSource(MSITokenSource tokenSource) { + this.tokenSource = tokenSource; + return this; + } + + /** + * Specifies the token audience. + * + * @param resource the audience of the token. + * + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withResource(String resource) { + this.resource = resource; + return this; + } + + /** + * specifies the principal id of user assigned or system assigned identity. + * + * @param objectId the object (principal) id + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withObjectId(String objectId) { + this.objectId = objectId; + return this; + } + + /** + * Specifies the client id of user assigned or system assigned identity. + * + * @param clientId the client id + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withClientId(String clientId) { + this.clientId = clientId; + return this; + } + + /** + * Specifies the ARM resource id of the user assigned identity resource. + * + * @param identityId the identity ARM id + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withIdentityId(String identityId) { + this.identityId = identityId; + return this; + } + + /** + * Specifies the port of token retrieval msi extension service. + * + * @param msiPort the port + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withMsiPort(int msiPort) { + this.msiPort = msiPort; + return this; + } + + /** + * Specifies the the maximum retries allowed. + * + * @param maxRetry max retry count + * @return MSIConfigurationForVirtualMachine + */ + public MSIConfigurationForVirtualMachine withMaxRetry(int maxRetry) { + this.maxRetry = maxRetry; + return this; + } + + @Override + public MSIConfigurationForVirtualMachine clone() { + MSIConfigurationForVirtualMachine copy = new MSIConfigurationForVirtualMachine(this.azureEnvironment()); + if (this.clientId() != null) { + copy.withClientId(this.clientId()); + } + if (this.identityId() != null) { + copy.withIdentityId(this.identityId()); + } + if (this.objectId() != null) { + copy.withObjectId(this.objectId()); + } + if (this.resource() != null) { + copy.withResource(this.resource()); + } + if (this.tokenSource() != null) { + copy.withTokenSource(this.tokenSource()); + } + copy.withMaxRetry(this.maxRetry()); + copy.withMsiPort(this.msiPort()); + return copy; + } + + + /** + * The source of MSI token. + */ + public enum MSITokenSource { + /** + * Indicate that token should be retrieved from MSI extension installed in the VM. + */ + MSI_EXTENSION, + /** + * Indicate that token should be retrieved from IMDS service. + */ + IMDS_ENDPOINT + } +} \ No newline at end of file diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSICredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSICredentials.java new file mode 100644 index 0000000000000..c620a387e6911 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSICredentials.java @@ -0,0 +1,315 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.annotations.Beta; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import reactor.core.publisher.Mono; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Managed Service Identity token based credentials for use with a REST Service Client. + */ +@Beta +public final class MSICredentials extends AzureTokenCredentials { + // + private final List retrySlots = new ArrayList<>(); + private final Lock lock = new ReentrantLock(); + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + // + private final JacksonAdapter adapter = new JacksonAdapter(); + private final MSIConfigurationForVirtualMachine configForVM; + private final MSIConfigurationForAppService configForAppService; + private final HostType hostType; + private final int maxRetry; + private static final int MAX_RETRY_DEFAULT_LIMIT = 20; + /** + * Creates MSICredentials for application running on MSI enabled virtual machine. + * + * @return MSICredentials + */ + public static MSICredentials forVirtualMachine() { + return new MSICredentials(new MSIConfigurationForVirtualMachine()); + } + + /** + * Creates MSICredentials for application running on MSI enabled virtual machine. + * + * @param config the configuration to be used for token request. + * @return MSICredentials + */ + public static MSICredentials forVirtualMachine(MSIConfigurationForVirtualMachine config) { + return new MSICredentials(config.clone()); + } + + /** + * Creates MSICredentials for application running on MSI enabled app service. + * + * @return MSICredentials + */ + public static MSICredentials forAppService() { + return new MSICredentials(new MSIConfigurationForAppService()); + } + + /** + * Creates MSICredentials for application running on MSI enabled app service. + * + * @param config the configuration to be used for token request. + * @return MSICredentials + */ + public static MSICredentials forAppService(MSIConfigurationForAppService config) { + return new MSICredentials(config.clone()); + } + + private MSICredentials(MSIConfigurationForVirtualMachine config) { + super(config.azureEnvironment(), null /** retrieving MSI token does not require tenant **/); + this.configForVM = config; + this.configForAppService = null; + this.hostType = HostType.VIRTUAL_MACHINE; + this.maxRetry = config.maxRetry() < 0 ? MAX_RETRY_DEFAULT_LIMIT : config.maxRetry(); + // Simplified variant of https://en.wikipedia.org/wiki/Exponential_backoff + for (int x = 0; x < this.maxRetry; x++) { + this.retrySlots.add(500 * ((2 << 1) - 1) / 1000); + } + } + + private MSICredentials(MSIConfigurationForAppService config) { + super(config.azureEnvironment(), null /** retrieving MSI token does not require tenant **/); + this.configForAppService = config; + this.configForVM = null; + this.hostType = HostType.APP_SERVICE; + this.maxRetry = -1; + } + + @Override + public Mono getToken(String tokenAudience) { + switch (hostType) { + case VIRTUAL_MACHINE: + if (this.configForVM.tokenSource() == MSIConfigurationForVirtualMachine.MSITokenSource.MSI_EXTENSION) { + return Mono.fromCallable(() -> this.getTokenForVirtualMachineFromMSIExtension(tokenAudience == null ? this.configForVM.resource() : tokenAudience)); + } else { + return Mono.fromCallable(() -> this.getTokenForVirtualMachineFromIMDSEndpoint(tokenAudience == null ? this.configForVM.resource() : tokenAudience)); + } + case APP_SERVICE: + return Mono.fromCallable(() -> getTokenForAppService(tokenAudience)); + default: + throw new IllegalArgumentException("unknown host type:" + hostType); + } + } + + private String getTokenForAppService(String tokenAudience) throws IOException { + String urlString = String.format("%s?resource=%s&api-version=2017-09-01", this.configForAppService.msiEndpoint(), tokenAudience == null ? this.configForAppService.resource() : tokenAudience); + URL url = new URL(urlString); + HttpURLConnection connection = null; + + try { + connection = (HttpURLConnection) url.openConnection(); + + connection.setRequestMethod("GET"); + connection.setRequestProperty("Secret", this.configForAppService.msiSecret()); + connection.setRequestProperty("Metadata", "true"); + + connection.connect(); + + InputStream stream = connection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"), 100); + String result = reader.readLine(); + + MSIToken msiToken = adapter.deserialize(result, MSIToken.class, SerializerEncoding.JSON); + return msiToken.accessToken(); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private String getTokenForVirtualMachineFromMSIExtension(String tokenAudience) throws IOException { + URL url = new URL(String.format("http://localhost:%d/oauth2/token", this.configForVM.msiPort())); + String postData = String.format("resource=%s", tokenAudience); + if (this.configForVM.objectId() != null) { + postData += String.format("&object_id=%s", this.configForVM.objectId()); + } else if (this.configForVM.clientId() != null) { + postData += String.format("&client_id=%s", this.configForVM.clientId()); + } else if (this.configForVM.identityId() != null) { + postData += String.format("&msi_res_id=%s", this.configForVM.identityId()); + } + HttpURLConnection connection = null; + + try { + connection = (HttpURLConnection) url.openConnection(); + + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); + connection.setRequestProperty("Metadata", "true"); + connection.setRequestProperty("Content-Length", Integer.toString(postData.length())); + connection.setDoOutput(true); + + connection.connect(); + + OutputStreamWriter wr = new OutputStreamWriter(connection.getOutputStream()); + wr.write(postData); + wr.flush(); + + InputStream stream = connection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"), 100); + String result = reader.readLine(); + + MSIToken msiToken = adapter.deserialize(result, MSIToken.class, SerializerEncoding.JSON); + return msiToken.accessToken(); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private String getTokenForVirtualMachineFromIMDSEndpoint(String tokenAudience) { + MSIToken token = cache.get(tokenAudience); + if (token != null && !token.isExpired()) { + return token.accessToken(); + } + lock.lock(); + try { + token = cache.get(tokenAudience); + if (token != null && !token.isExpired()) { + return token.accessToken(); + } + try { + token = retrieveTokenFromIDMSWithRetry(tokenAudience); + if (token != null) { + cache.put(tokenAudience, token); + } + } catch (IOException exception) { + throw new RuntimeException(exception); + } + return token.accessToken(); + } finally { + lock.unlock(); + } + } + + private MSIToken retrieveTokenFromIDMSWithRetry(String tokenAudience) throws IOException { + StringBuilder payload = new StringBuilder(); + final int imdsUpgradeTimeInMs = 70 * 1000; + + // + try { + payload.append("api-version"); + payload.append("="); + payload.append(URLEncoder.encode("2018-02-01", "UTF-8")); + payload.append("&"); + payload.append("resource"); + payload.append("="); + payload.append(URLEncoder.encode(tokenAudience, "UTF-8")); + if (this.configForVM.objectId() != null) { + payload.append("&"); + payload.append("object_id"); + payload.append("="); + payload.append(URLEncoder.encode(this.configForVM.objectId(), "UTF-8")); + } else if (this.configForVM.clientId() != null) { + payload.append("&"); + payload.append("client_id"); + payload.append("="); + payload.append(URLEncoder.encode(this.configForVM.clientId(), "UTF-8")); + } else if (this.configForVM.identityId() != null) { + payload.append("&"); + payload.append("msi_res_id"); + payload.append("="); + payload.append(URLEncoder.encode(this.configForVM.identityId(), "UTF-8")); + } + } catch (IOException exception) { + throw new RuntimeException(exception); + } + + int retry = 1; + while (retry <= maxRetry) { + URL url = new URL(String.format("http://169.254.169.254/metadata/identity/oauth2/token?%s", payload.toString())); + // + HttpURLConnection connection = null; + // + try { + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Metadata", "true"); + connection.connect(); + InputStream stream = connection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, "UTF-8"), 100); + String result = reader.readLine(); + return adapter.deserialize(result, MSIToken.class, SerializerEncoding.JSON); + } catch (Exception exception) { + int responseCode = connection.getResponseCode(); + if (responseCode == 410 || responseCode == 429 || responseCode == 404 || (responseCode >= 500 && responseCode <= 599)) { + int retryTimeoutInMs = retrySlots.get(new Random().nextInt(retry)); + // Error code 410 indicates IMDS upgrade is in progress, which can take up to 70s + // + retryTimeoutInMs = (responseCode == 410 && retryTimeoutInMs < imdsUpgradeTimeInMs) ? imdsUpgradeTimeInMs : retryTimeoutInMs; + retry++; + if (retry > maxRetry) { + break; + } else { + sleep(retryTimeoutInMs); + } + } else { + throw new RuntimeException("Couldn't acquire access token from IMDS, verify your objectId, clientId or msiResourceId", exception); + } + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + // + if (retry > maxRetry) { + throw new RuntimeException(String.format("MSI: Failed to acquire tokens after retrying %s times", maxRetry)); + } + return null; + } + + private static void sleep(int millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } + } + + /** + * The host in which application is running. + */ + private enum HostType { + /** + * indicate that host is an Azure virtual machine. + */ + VIRTUAL_MACHINE, + /** + * indicate that host is an Azure app-service instance. + */ + APP_SERVICE + } +} \ No newline at end of file diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIToken.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIToken.java new file mode 100644 index 0000000000000..f3b5d69684b12 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/MSIToken.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/** + * Type representing response from the local MSI token provider. + */ +class MSIToken { + + private static OffsetDateTime epoch = OffsetDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + + @JsonProperty(value = "token_type") + private String tokenType; + + @JsonProperty(value = "access_token") + private String accessToken; + + @JsonProperty(value = "expires_on") + private String expiresOn; + + String accessToken() { + return accessToken; + } + + String tokenType() { + return tokenType; + } + + boolean isExpired() { + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime expireOn = epoch.plusSeconds(Integer.parseInt(this.expiresOn)); + return now.plusMinutes(5).isAfter(expireOn); + } +} \ No newline at end of file diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/UserTokenCredentials.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/UserTokenCredentials.java new file mode 100644 index 0000000000000..e4511a9d080df --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/UserTokenCredentials.java @@ -0,0 +1,147 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; +import com.microsoft.aad.adal4j.AuthenticationContext; +import com.microsoft.aad.adal4j.AuthenticationResult; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; +import java.util.ArrayList; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Token based credentials for use with a REST Service Client. + */ +public class UserTokenCredentials extends AzureTokenCredentials { + /** A mapping from resource endpoint to its cached access token. */ + private Map tokens; + /** The Active Directory application client id. */ + private String clientId; + /** The user name for the Organization Id account. */ + private String username; + /** The password for the Organization Id account. */ + private String password; + + /** + * Initializes a new instance of the UserTokenCredentials. + * + * @param clientId the active directory application client id. + * @param domain the domain or tenant id containing this application. + * @param username the user name for the Organization Id account. + * @param password the password for the Organization Id account. + * @param environment the Azure environment to authenticate with. + * If null is provided, AzureEnvironment.AZURE will be used. + */ + public UserTokenCredentials(String clientId, String domain, String username, String password, AzureEnvironment environment) { + super(environment, domain); // defer token acquisition + this.clientId = clientId; + this.username = username; + this.password = password; + this.tokens = new ConcurrentHashMap<>(); + } + + /** + * Gets the active directory application client id. + * + * @return the active directory application client id. + */ + public String clientId() { + return clientId; + } + + /** + * Gets the user name for the Organization Id account. + * + * @return the user name. + */ + public String username() { + return username; + } + + @Override + public synchronized Mono getToken(String resource) { + // Find exact match for the resource + AuthenticationResult[] authenticationResult = new AuthenticationResult[1]; + authenticationResult[0] = tokens.get(resource); + // Return if found and not expired + if (authenticationResult[0] != null && authenticationResult[0].getExpiresOnDate().after(new Date())) { + return Mono.just(authenticationResult[0].getAccessToken()); + } + // If found then refresh + boolean shouldRefresh = authenticationResult[0] != null; + // If not found for the resource, but is MRRT then also refresh + if (authenticationResult[0] == null && !tokens.isEmpty()) { + authenticationResult[0] = new ArrayList<>(tokens.values()).get(0); + shouldRefresh = authenticationResult[0].isMultipleResourceRefreshToken(); + } + + if (shouldRefresh) { + return Mono.defer(() -> acquireAccessTokenFromRefreshToken(resource, authenticationResult[0].getRefreshToken(), authenticationResult[0].isMultipleResourceRefreshToken()) + .onErrorResume(t -> acquireNewAccessToken(resource)) + .doOnNext(ar -> tokens.put(resource, ar)) + .then(Mono.just(tokens.get(resource).getAccessToken()))); + } else { + return Mono.just(tokens.get(resource).getAccessToken()); + } + } + + Mono acquireNewAccessToken(String resource) { + String authorityUrl = this.environment().activeDirectoryEndpoint() + this.domain(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Mono authMono = Mono.defer(() -> { + AuthenticationContext context; + try { + context = new AuthenticationContext(authorityUrl, false, executor); + } catch (MalformedURLException mue) { + return Mono.error(mue); + } + if (proxy() != null) { + context.setProxy(proxy()); + } + return Mono.create(callback -> { + context.acquireToken( + resource, + this.clientId(), + this.username(), + this.password, + Util.authenticationDelegate(callback)); + }); + }); + return authMono.doFinally(s -> executor.shutdown()); + } + + // Refresh tokens are currently not used since we don't know if the refresh token has expired + Mono acquireAccessTokenFromRefreshToken(String resource, String refreshToken, boolean isMultipleResourceRefreshToken) { + String authorityUrl = this.environment().activeDirectoryEndpoint() + this.domain(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Mono authMono = Mono.defer(() -> { + AuthenticationContext context; + try { + context = new AuthenticationContext(authorityUrl, false, executor); + } catch (MalformedURLException mue) { + return Mono.error(mue); + } + if (proxy() != null) { + context.setProxy(proxy()); + } + return Mono.create(callback -> { + context.acquireTokenByRefreshToken( + refreshToken, + clientId(), + resource, + Util.authenticationDelegate(callback)); + }); + }); + return authMono.doFinally(s -> executor.shutdown()); + } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/Util.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/Util.java new file mode 100644 index 0000000000000..7f9546575639a --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/Util.java @@ -0,0 +1,101 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.microsoft.aad.adal4j.AsymmetricKeyCredential; +import com.microsoft.aad.adal4j.AuthenticationCallback; +import com.microsoft.aad.adal4j.AuthenticationResult; +import com.azure.common.implementation.util.Base64Util; +import reactor.core.Exceptions; +import reactor.core.publisher.MonoSink; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class Util { + static AuthenticationCallback authenticationDelegate(final MonoSink callback) { + return new AuthenticationCallback() { + @Override + public void onSuccess(Object o) { + callback.success((AuthenticationResult) o); + } + + @Override + public void onFailure(Throwable throwable) { + callback.error(throwable); + } + }; + } + + static AsymmetricKeyCredential createAsymmetricKeyCredential(String clientId, byte[] clientCertificate, String clientCertificatePassword) { + try { + return AsymmetricKeyCredential.create(clientId, new ByteArrayInputStream(clientCertificate), clientCertificatePassword); + } catch (KeyStoreException kse) { + throw Exceptions.propagate(kse); + } catch (NoSuchProviderException nspe) { + throw Exceptions.propagate(nspe); + } catch (NoSuchAlgorithmException nsae) { + throw Exceptions.propagate(nsae); + } catch (CertificateException ce) { + throw Exceptions.propagate(ce); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } catch (UnrecoverableKeyException uke) { + throw Exceptions.propagate(uke); + } + } + + + static PrivateKey privateKeyFromPem(String pem) { + Pattern pattern = Pattern.compile("(?s)-----BEGIN PRIVATE KEY-----.*-----END PRIVATE KEY-----"); + Matcher matcher = pattern.matcher(pem); + matcher.find(); + String base64 = matcher.group() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\n", "") + .replace("\r", ""); + byte[] key = Base64Util.decode(base64.getBytes(StandardCharsets.UTF_8)); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(key); + try { + KeyFactory kf = KeyFactory.getInstance("RSA"); + return kf.generatePrivate(spec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } + + static X509Certificate publicKeyFromPem(String pem) { + Pattern pattern = Pattern.compile("(?s)-----BEGIN CERTIFICATE-----.*-----END CERTIFICATE-----"); + Matcher matcher = pattern.matcher(pem); + matcher.find(); + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + InputStream stream = new ByteArrayInputStream(matcher.group().getBytes()); + return (X509Certificate) factory.generateCertificate(stream); + } catch (CertificateException e) { + throw new RuntimeException(e); + } + } + + private Util() { } +} diff --git a/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/package-info.java b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/package-info.java new file mode 100644 index 0000000000000..d600561d46f95 --- /dev/null +++ b/common/azure-common-auth/src/main/java/com/azure/common/auth/credentials/package-info.java @@ -0,0 +1,4 @@ +/** + * The package provides credential classes for Azure authentication purposes. + */ +package com.azure.common.auth.credentials; \ No newline at end of file diff --git a/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/AuthFileTests.java b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/AuthFileTests.java new file mode 100644 index 0000000000000..ae4bfba1a3a97 --- /dev/null +++ b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/AuthFileTests.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; + +public class AuthFileTests { + @Test + public void canReadJavaPropertiesAuthFile() throws Exception { + File file = new File(AuthFileTests.class.getResource("/properties.azureauth").toURI()); + AuthFile authFile = AuthFile.parse(file); + ApplicationTokenCredentials credentials = authFile.generateCredentials(); + + Assert.assertNotNull(credentials); + Assert.assertEquals("test-client", credentials.clientId()); + Assert.assertEquals("test-tenant", credentials.domain()); + Assert.assertEquals("test-subscription", credentials.defaultSubscriptionId()); + Assert.assertEquals("https://testbase.com/", credentials.environment().resourceManagerEndpoint()); + Assert.assertEquals("https://testmanagement.com/", credentials.environment().managementEndpoint()); + Assert.assertEquals("https://testgraph.net/", credentials.environment().graphEndpoint()); + Assert.assertEquals("https://testauth.net/", credentials.environment().activeDirectoryEndpoint()); + Assert.assertEquals("https://management.core.windows.net:8443/", credentials.environment().sqlManagementEndpoint()); + } + + @Test + public void canReadJsonAuthFile() throws Exception { + File file = new File(AuthFileTests.class.getResource("/json.azureauth").toURI()); + AuthFile authFile = AuthFile.parse(file); + ApplicationTokenCredentials credentials = authFile.generateCredentials(); + + Assert.assertNotNull(credentials); + Assert.assertEquals("sample-clientid", credentials.clientId()); + Assert.assertEquals("sample-tenant", credentials.domain()); + Assert.assertEquals("sample-subscription", credentials.defaultSubscriptionId()); + Assert.assertEquals("https://samplearm.com/", credentials.environment().resourceManagerEndpoint()); + Assert.assertEquals("https://samplemanagement.net/", credentials.environment().managementEndpoint()); + Assert.assertEquals("https://samplegraph.net/", credentials.environment().graphEndpoint()); + Assert.assertEquals("https://samplead.com/", credentials.environment().activeDirectoryEndpoint()); + Assert.assertEquals("https://samplesql.net:8443/", credentials.environment().sqlManagementEndpoint()); + Assert.assertEquals("http://go.microsoft.com/fwlink/?LinkId=254432", credentials.environment().publishingProfile()); + } +} diff --git a/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/UserTokenCredentialsTests.java b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/UserTokenCredentialsTests.java new file mode 100644 index 0000000000000..20d0d3640004d --- /dev/null +++ b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/UserTokenCredentialsTests.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials; + +import com.azure.common.AzureEnvironment; +import com.microsoft.aad.adal4j.AuthenticationResult; +import org.junit.Assert; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.util.Date; + +public class UserTokenCredentialsTests { + private static MockUserTokenCredentials credentials = new MockUserTokenCredentials( + "clientId", + "domain", + "username", + "password", + AzureEnvironment.AZURE + ); + + @Test + public void testAcquireToken() throws Exception { + credentials.acquireAccessToken(); + Assert.assertEquals("token1", credentials.getToken((String)null).block()); + Thread.sleep(1500); + Assert.assertEquals("token2", credentials.getToken((String)null).block()); + } + + public static class MockUserTokenCredentials extends UserTokenCredentials { + private AuthenticationResult authenticationResult; + + public MockUserTokenCredentials(String clientId, String domain, String username, String password, AzureEnvironment environment) { + super(clientId, domain, username, password, environment); + } + + @Override + public Mono getToken(String resource) { + if (authenticationResult != null + && authenticationResult.getExpiresOnDate().before(new Date())) { + acquireAccessTokenFromRefreshToken(); + } else { + acquireAccessToken(); + } + return Mono.just(authenticationResult.getAccessToken()); + } + + private void acquireAccessToken() { + this.authenticationResult = new AuthenticationResult( + null, + "token1", + "refresh", + 1, + null, + null, + false); + } + + private void acquireAccessTokenFromRefreshToken() { + this.authenticationResult = new AuthenticationResult( + null, + "token2", + "refresh", + 1, + null, + null, + false); + } + } +} diff --git a/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpClient.java b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpClient.java new file mode 100644 index 0000000000000..40704be07d324 --- /dev/null +++ b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpClient.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials.http; + +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.ProxyOptions; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * This HttpClient attempts to mimic the behavior of http://httpbin.org without ever making a network call. + */ +public class MockHttpClient implements HttpClient { + private static final HttpResponse mockResponse = new MockHttpResponse(200); + private final List requests; + + public MockHttpClient() { + requests = new ArrayList<>(); + } + + public List requests() { + return requests; + } + + @Override + public Mono send(HttpRequest request) { + requests.add(request); + + return Mono.just(mockResponse); + } + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } +} diff --git a/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpResponse.java b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpResponse.java new file mode 100644 index 0000000000000..36b9587af4b91 --- /dev/null +++ b/common/azure-common-auth/src/test/java/com/azure/common/auth/credentials/http/MockHttpResponse.java @@ -0,0 +1,94 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.auth.credentials.http; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.nio.charset.Charset; + +public class MockHttpResponse extends HttpResponse { + private final static SerializerAdapter serializer = new JacksonAdapter(); + + private final int statusCode; + + private final HttpHeaders headers; + + private byte[] byteArray; + private String string; + + public MockHttpResponse(int statusCode) { + this.statusCode = statusCode; + + headers = new HttpHeaders(); + } + + public MockHttpResponse(int statusCode, byte[] byteArray) { + this(statusCode); + + this.byteArray = byteArray; + } + + public MockHttpResponse(int statusCode, String string) { + this(statusCode); + + this.string = string; + } + + public MockHttpResponse(int statusCode, Object serializable) { + this(statusCode); + + try { + this.string = serializer.serialize(serializable, SerializerEncoding.JSON); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public String headerValue(String name) { + return headers.value(name); + } + + @Override + public HttpHeaders headers() { + return new HttpHeaders(headers); + } + + @Override + public Mono bodyAsByteArray() { + return Mono.just(byteArray); + } + + @Override + public Flux body() { + return Flux.just(Unpooled.wrappedBuffer(byteArray)); + } + + @Override + public Mono bodyAsString() { + return Mono.just(string); + } + + @Override + public Mono bodyAsString(Charset charset) { + return Mono.just(string); + } +} diff --git a/common/azure-common-auth/src/test/resources/json.azureauth b/common/azure-common-auth/src/test/resources/json.azureauth new file mode 100644 index 0000000000000..63a25e228f8a3 --- /dev/null +++ b/common/azure-common-auth/src/test/resources/json.azureauth @@ -0,0 +1,12 @@ +{ + "clientId": "sample-clientid", + "clientSecret": "sample-clientsecret", + "subscriptionId": "sample-subscription", + "tenantId": "sample-tenant", + "activeDirectoryEndpointUrl": "https://samplead.com/", + "resourceManagerEndpointUrl": "https://samplearm.com/", + "activeDirectoryGraphResourceId": "https://samplegraph.net/", + "sqlManagementEndpointUrl": "https://samplesql.net:8443/", + "galleryEndpointUrl": "https://samplegallery.com/", + "managementEndpointUrl": "https://samplemanagement.net/" +} diff --git a/common/azure-common-auth/src/test/resources/properties.azureauth b/common/azure-common-auth/src/test/resources/properties.azureauth new file mode 100644 index 0000000000000..8fe4f029b5ddf --- /dev/null +++ b/common/azure-common-auth/src/test/resources/properties.azureauth @@ -0,0 +1,8 @@ +subscription=test-subscription +tenant=test-tenant +client=test-client +key=test-key!12 +managementURI=https\://testmanagement.com/ +baseURL=https\://testbase.com/ +authURL=https\://testauth.net/ +graphURL=https\://testgraph.net/ \ No newline at end of file diff --git a/common/azure-common-mgmt/pom.xml b/common/azure-common-mgmt/pom.xml new file mode 100644 index 0000000000000..2c94821407962 --- /dev/null +++ b/common/azure-common-mgmt/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + com.azure + azure-common-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + com.azure + azure-common-mgmt + 1.0.0-SNAPSHOT + jar + + Azure Management Java Common Library + This package contains common types for Azure management (ARM) Java clients. + https://github.com/Azure/autorest-clientruntime-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + scm:git:https://github.com/Azure/autorest-clientruntime-for-java + scm:git:git@github.com:Azure/autorest-clientruntime-for-java.git + HEAD + + + + UTF-8 + + + + + + microsoft + Microsoft + + + + + + com.azure + azure-common + 1.0.0-SNAPSHOT + + + junit + junit + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.8 + + *.implementation.*;*.utils.*;com.microsoft.schemas._2003._10.serialization;*.blob.core.storage + /** +
* Copyright (c) Microsoft Corporation. All rights reserved. +
* Licensed under the MIT License. See License.txt in the project root for +
* license information. +
*/]]>
+
+
+ +
+
+
diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AsyncOperationResource.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AsyncOperationResource.java new file mode 100644 index 0000000000000..f3cf8d0ac9706 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AsyncOperationResource.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A deserialized POJO representation of an asynchronous operation. + */ +public class AsyncOperationResource { + @JsonProperty(value = "status") + private String status; + + /** + * The status of the asynchronous operation. + * @return The status of the asynchronous operation. + */ + public String status() { + return status; + } + + /** + * Set the status of the asynchronous operation. + * @param status The status of the asynchronous operation. + */ + public void setStatus(String status) { + this.status = status; + } + + @JsonProperty(value = "id") + private String id; + + /** + * @return The resource's id. + */ + public String id() { + return id; + } + + /** + * Set the id of this resource. + * @param id The id of this resource. + */ + public void setId(String id) { + this.id = id; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureAsyncOperationPollStrategy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureAsyncOperationPollStrategy.java new file mode 100644 index 0000000000000..c5bda8e9fbe43 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureAsyncOperationPollStrategy.java @@ -0,0 +1,207 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.implementation.RestProxy; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.Serializable; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * A PollStrategy type that uses the Azure-AsyncOperation header value to check the status of a long + * running operation. + */ +public final class AzureAsyncOperationPollStrategy extends PollStrategy { + private AzureAsyncOperationPollStrategyData data; + + /** + * The name of the header that indicates that a long running operation will use the + * Azure-AsyncOperation strategy. + */ + public static final String HEADER_NAME = "Azure-AsyncOperation"; + + /** + * Create a new AzureAsyncOperationPollStrategy object that will poll the provided operation + * resource URL. + * @param data The AzureAsyncOperationPollStrategyData data object. + */ + private AzureAsyncOperationPollStrategy(AzureAsyncOperationPollStrategyData data) { + super(data); + this.data = data; + } + + /** + * The AzureAsyncOperationPollStrategy data. + */ + private static class AzureAsyncOperationPollStrategyData extends PollStrategyData { + private boolean pollingCompleted; + private boolean pollingSucceeded; + private boolean gotResourceResponse; + private final HttpMethod initialHttpMethod; + + final URL operationResourceUrl; + final URL originalResourceUrl; + final URL locationUrl; + + /** + * Create a new AzureAsyncOperationPollStrategyData object that will poll the provided operation + * resource URL. + * @param operationResourceUrl The URL of the operation resource this pollStrategy will poll. + * @param originalResourceUrl The URL of the resource that the long running operation is + * operating on. + * @param locationUrl The location uri received from service along with operationResourceUrl. + * @param initialHttpMethod The http method used to initiate the long running operation + * @param delayInMilliseconds The delay (in milliseconds) that the pollStrategy will use when + * polling. + */ + AzureAsyncOperationPollStrategyData(RestProxy restProxy, SwaggerMethodParser methodParser, URL operationResourceUrl, URL originalResourceUrl, URL locationUrl, HttpMethod initialHttpMethod, long delayInMilliseconds) { + super(restProxy, methodParser, delayInMilliseconds); + this.operationResourceUrl = operationResourceUrl; + this.originalResourceUrl = originalResourceUrl; + this.locationUrl = locationUrl; + this.initialHttpMethod = initialHttpMethod; + } + + PollStrategy initializeStrategy(RestProxy restProxy, + SwaggerMethodParser methodParser) { + this.restProxy = restProxy; + this.methodParser = methodParser; + return new AzureAsyncOperationPollStrategy(this); + } + } + + @Override + public HttpRequest createPollRequest() { + URL pollUrl; + if (!data.pollingCompleted) { + pollUrl = data.operationResourceUrl; + } else if (data.pollingSucceeded) { + if (data.initialHttpMethod == HttpMethod.POST || data.initialHttpMethod == HttpMethod.DELETE) { + if (data.locationUrl != null) { + pollUrl = data.locationUrl; + } else { + pollUrl = data.operationResourceUrl; + } + } else { + // For PUT|PATCH do a final get on the original resource uri. + // + pollUrl = data.originalResourceUrl; + } + } else { + throw new IllegalStateException("Polling is completed and did not succeed. Cannot create a polling request."); + } + + return new HttpRequest(HttpMethod.GET, pollUrl); + } + + @Override + public Mono updateFromAsync(HttpResponse httpPollResponse) { + return ensureExpectedStatus(httpPollResponse) + .flatMap(response -> { + updateDelayInMillisecondsFrom(response); + Mono result; + if (!data.pollingCompleted) { + final HttpResponse bufferedHttpPollResponse = response.buffer(); + result = bufferedHttpPollResponse.bodyAsString() + .map(bodyString -> { + AsyncOperationResource operationResource = null; + try { + operationResource = deserialize(bodyString, AsyncOperationResource.class); + } catch (IOException ignored) { } + // + if (operationResource == null || operationResource.status() == null) { + throw new CloudException("The polling response does not contain a valid body", bufferedHttpPollResponse, null); + } else { + final String status = operationResource.status(); + setStatus(status); + + data.pollingCompleted = OperationState.isCompleted(status); + if (data.pollingCompleted) { + data.pollingSucceeded = OperationState.SUCCEEDED.equalsIgnoreCase(status); + clearDelayInMilliseconds(); + + if (!data.pollingSucceeded) { + throw new CloudException("Async operation failed with provisioning state: " + status, bufferedHttpPollResponse); + } + + if (operationResource.id() != null) { + data.gotResourceResponse = true; + } + } + return bufferedHttpPollResponse; + } + }); + } else { + if (data.pollingSucceeded) { + data.gotResourceResponse = true; + } + result = Mono.just(response); + } + return result; + }); + } + + @Override + public boolean isDone() { + return data.pollingCompleted && (!data.pollingSucceeded || !expectsResourceResponse() || data.gotResourceResponse); + } + + /** + * Try to create a new AzureAsyncOperationPollStrategy object that will poll the provided + * operation resource URL. If the provided HttpResponse doesn't have an Azure-AsyncOperation + * header or if the header is empty, then null will be returned. + * @param restProxy The proxy object that is attempting to create a PollStrategy. + * @param methodParser The method parser that describes the service interface method that + * initiated the long running operation. + * @param originalHttpRequest The original HTTP request that initiated the long running + * operation. + * @param httpResponse The HTTP response that the required header values for this pollStrategy + * will be read from. + * @param delayInMilliseconds The delay (in milliseconds) that the resulting pollStrategy will + * use when polling. + */ + static PollStrategy tryToCreate(RestProxy restProxy, SwaggerMethodParser methodParser, HttpRequest originalHttpRequest, HttpResponse httpResponse, long delayInMilliseconds) { + String urlHeader = getHeader(httpResponse); + URL azureAsyncOperationUrl = null; + if (urlHeader != null) { + try { + azureAsyncOperationUrl = new URL(urlHeader); + } catch (MalformedURLException ignored) { + } + } + + urlHeader = httpResponse.headerValue("Location"); + URL locationUrl = null; + if (urlHeader != null) { + try { + locationUrl = new URL(urlHeader); + } catch (MalformedURLException ignored) { + } + } + + return azureAsyncOperationUrl != null + ? new AzureAsyncOperationPollStrategy( + new AzureAsyncOperationPollStrategyData(restProxy, methodParser, azureAsyncOperationUrl, originalHttpRequest.url(), locationUrl, originalHttpRequest.httpMethod(), delayInMilliseconds)) + : null; + } + + static String getHeader(HttpResponse httpResponse) { + return httpResponse.headerValue(HEADER_NAME); + } + + @Override + public Serializable strategyData() { + return this.data; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureProxy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureProxy.java new file mode 100644 index 0000000000000..2d0f36949ae73 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureProxy.java @@ -0,0 +1,407 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.AzureEnvironment; +import com.azure.common.credentials.AsyncServiceClientCredentials; +import com.azure.common.mgmt.annotations.AzureHost; +import com.azure.common.mgmt.policy.AsyncCredentialsPolicy; +import com.azure.common.mgmt.serializer.AzureJacksonAdapter; +import com.azure.common.implementation.exception.InvalidReturnTypeException; +import com.azure.common.implementation.OperationDescription; +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.SwaggerInterfaceParser; +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.credentials.ServiceClientCredentials; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.policy.HttpPipelinePolicy; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.policy.CookiePolicy; +import com.azure.common.http.policy.CredentialsPolicy; +import com.azure.common.http.policy.RetryPolicy; +import com.azure.common.http.policy.UserAgentPolicy; +import com.azure.common.implementation.serializer.HttpResponseDecoder; +import com.azure.common.implementation.serializer.HttpResponseDecoder.HttpDecodedResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.util.TypeUtil; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.net.NetworkInterface; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * This class can be used to create an Azure specific proxy implementation for a provided Swagger + * generated interface. + */ +public final class AzureProxy extends RestProxy { + private static long defaultPollingDelayInMilliseconds = 30 * 1000; + + /** + * Create a new instance of RestProxy. + * @param httpPipeline The HttpPipeline that will be used by this AzureProxy to send HttpRequests. + * @param serializer The serializer that will be used to convert response bodies to POJOs. + * @param interfaceParser The parser that contains information about the swagger interface that + * this RestProxy "implements". + */ + private AzureProxy(HttpPipeline httpPipeline, SerializerAdapter serializer, SwaggerInterfaceParser interfaceParser) { + super(httpPipeline, serializer, interfaceParser); + } + + /** + * @return The millisecond delay that will occur by default between long running operation polls. + */ + public static long defaultDelayInMilliseconds() { + return AzureProxy.defaultPollingDelayInMilliseconds; + } + + /** + * Set the millisecond delay that will occur by default between long running operation polls. + * @param defaultPollingDelayInMilliseconds The number of milliseconds to delay before sending the next + * long running operation status poll. + */ + public static void setDefaultPollingDelayInMilliseconds(long defaultPollingDelayInMilliseconds) { + AzureProxy.defaultPollingDelayInMilliseconds = defaultPollingDelayInMilliseconds; + } + + /** + * Get the default serializer. + * @return the default serializer. + */ + public static SerializerAdapter createDefaultSerializer() { + return new AzureJacksonAdapter(); + } + + private static String operatingSystem; + private static String operatingSystem() { + if (operatingSystem == null) { + operatingSystem = System.getProperty("os.name") + "/" + System.getProperty("os.version"); + } + return operatingSystem; + } + + private static String macAddressHash; + private static String macAddressHash() { + if (macAddressHash == null) { + byte[] macBytes = null; + try { + Enumeration networks = NetworkInterface.getNetworkInterfaces(); + while (networks.hasMoreElements()) { + NetworkInterface network = networks.nextElement(); + macBytes = network.getHardwareAddress(); + + if (macBytes != null) { + break; + } + } + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(macBytes); + StringBuffer builder = new StringBuffer(); + for (int i = 0; i < hash.length; i++) { + builder.append(String.format("%02x", hash[i])); + } + macAddressHash = builder.toString(); + } catch (Throwable t) { + // It's okay ignore mac address hash telemetry + } + + if (macBytes == null) { + macAddressHash = "Unknown"; + } + + } + return macAddressHash; + } + + private static String javaVersion; + private static String javaVersion() { + if (javaVersion == null) { + final String versionProperty = System.getProperty("java.version"); + javaVersion = versionProperty != null ? versionProperty : "Unknown"; + } + return javaVersion; + } + + private static String getDefaultUserAgentString(Class swaggerInterface) { + final String packageImplementationVersion = swaggerInterface == null ? "" : "/" + swaggerInterface.getPackage().getImplementationVersion(); + final String operatingSystem = operatingSystem(); + final String macAddressHash = macAddressHash(); + final String javaVersion = javaVersion(); + return String.format("Azure-SDK-For-Java%s OS:%s MacAddressHash:%s Java:%s", + packageImplementationVersion, + operatingSystem, + macAddressHash, + javaVersion); + } + + /** + * Create the default HttpPipeline. + * @param swaggerInterface The interface that the pipeline will use to generate a user-agent + * string. + * @return the default HttpPipeline. + */ + public static HttpPipeline createDefaultPipeline(Class swaggerInterface) { + return createDefaultPipeline(swaggerInterface, (HttpPipelinePolicy) null); + } + + /** + * Create the default HttpPipeline. + * @param swaggerInterface The interface that the pipeline will use to generate a user-agent + * string. + * @param credentials The credentials to use to apply authentication to the pipeline. + * @return the default HttpPipeline. + */ + public static HttpPipeline createDefaultPipeline(Class swaggerInterface, ServiceClientCredentials credentials) { + return createDefaultPipeline(swaggerInterface, new CredentialsPolicy(credentials)); + } + + /** + * Create the default HttpPipeline. + * @param swaggerInterface The interface that the pipeline will use to generate a user-agent + * string. + * @param credentials The credentials to use to apply authentication to the pipeline. + * @return the default HttpPipeline. + */ + public static HttpPipeline createDefaultPipeline(Class swaggerInterface, AsyncServiceClientCredentials credentials) { + return createDefaultPipeline(swaggerInterface, new AsyncCredentialsPolicy(credentials)); + } + + /** + * Create the default HttpPipeline. + * @param swaggerInterface The interface that the pipeline will use to generate a user-agent + * string. + * @param credentialsPolicy The credentials policy factory to use to apply authentication to the + * pipeline. + * @return the default HttpPipeline. + */ + public static HttpPipeline createDefaultPipeline(Class swaggerInterface, HttpPipelinePolicy credentialsPolicy) { + // Order in which policies applied will be the order in which they appear in the array + // + List policies = new ArrayList(); + policies.add(new UserAgentPolicy(getDefaultUserAgentString(swaggerInterface))); + policies.add(new RetryPolicy()); + policies.add(new CookiePolicy()); + if (credentialsPolicy != null) { + policies.add(credentialsPolicy); + } + return new HttpPipeline(policies.toArray(new HttpPipelinePolicy[policies.size()])); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * @param swaggerInterface The Swagger interface to provide a proxy implementation for. + * @param azureServiceClient The AzureServiceClient that contains the details to use to create + * the AzureProxy implementation of the swagger interface. + * @param The type of the Swagger interface. + * @return A proxy implementation of the provided Swagger interface. + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, AzureServiceClient azureServiceClient) { + return AzureProxy.create(swaggerInterface, azureServiceClient.azureEnvironment(), azureServiceClient.httpPipeline(), azureServiceClient.serializerAdapter()); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * @param swaggerInterface The Swagger interface to provide a proxy implementation for. + * @param httpPipeline The HTTP httpPipeline will be used to make REST calls. + * @param serializer The serializer that will be used to convert POJOs to and from request and + * response bodies. + * @param The type of the Swagger interface. + * @return A proxy implementation of the provided Swagger interface. + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, HttpPipeline httpPipeline, SerializerAdapter serializer) { + return AzureProxy.create(swaggerInterface, null, httpPipeline, serializer); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * @param swaggerInterface The Swagger interface to provide a proxy implementation for. + * @param azureEnvironment The azure environment that the proxy implementation will target. + * @param httpPipeline The HTTP httpPipeline will be used to make REST calls. + * @param serializer The serializer that will be used to convert POJOs to and from request and + * response bodies. + * @param The type of the Swagger interface. + * @return A proxy implementation of the provided Swagger interface. + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, AzureEnvironment azureEnvironment, HttpPipeline httpPipeline, SerializerAdapter serializer) { + String baseUrl = null; + + if (azureEnvironment != null) { + final AzureHost azureHost = swaggerInterface.getAnnotation(AzureHost.class); + if (azureHost != null) { + baseUrl = azureEnvironment.url(azureHost.endpoint()); + } + } + + final SwaggerInterfaceParser interfaceParser = new SwaggerInterfaceParser(swaggerInterface, serializer, baseUrl); + final AzureProxy azureProxy = new AzureProxy(httpPipeline, serializer, interfaceParser); + return (A) Proxy.newProxyInstance(swaggerInterface.getClassLoader(), new Class[]{swaggerInterface}, azureProxy); + } + + @Override + protected Object handleHttpResponse(final HttpRequest httpRequest, Mono asyncHttpResponse, final SwaggerMethodParser methodParser, Type returnType) { + if (TypeUtil.isTypeOrSubTypeOf(returnType, Flux.class)) { + final Type operationStatusType = ((ParameterizedType) returnType).getActualTypeArguments()[0]; + if (!TypeUtil.isTypeOrSubTypeOf(operationStatusType, OperationStatus.class)) { + throw new InvalidReturnTypeException("AzureProxy only supports swagger interface methods that return Flux (such as " + methodParser.fullyQualifiedMethodName() + "()) if the Flux's inner type that is OperationStatus (not " + returnType.toString() + ")."); + } else { + // Get ResultTypeT in OperationStatus + final Type operationStatusResultType = ((ParameterizedType) operationStatusType).getActualTypeArguments()[0]; + // + return asyncHttpResponse.flatMapMany(httpResponse -> { + return createPollStrategy(httpRequest, Mono.just(httpResponse), methodParser) + .flatMapMany(pollStrategy -> { + Mono> first = handleBodyReturnType(httpResponse, methodParser, operationStatusResultType) + .map(operationResult -> new OperationStatus(operationResult, pollStrategy.status())) + .switchIfEmpty(Mono.defer((Supplier>>) () -> Mono.just(new OperationStatus((Object) null, pollStrategy.status())))); + Flux> rest = pollStrategy.pollUntilDoneWithStatusUpdates(httpRequest, methodParser, operationStatusResultType); + return first.concatWith(rest); + }); + }); + } + } else { + final Mono lastAsyncHttpResponse = createPollStrategy(httpRequest, asyncHttpResponse, methodParser) + .flatMap((Function>) pollStrategy -> pollStrategy.pollUntilDone()); + return handleRestReturnType(new HttpResponseDecoder(this.serializer()).decode(lastAsyncHttpResponse, methodParser), methodParser, returnType); + } + } + + @Override + protected Object handleResumeOperation(final HttpRequest httpRequest, + OperationDescription operationDescription, + final SwaggerMethodParser methodParser, + Type returnType) { + final Type operationStatusType = ((ParameterizedType) returnType).getActualTypeArguments()[0]; + if (!TypeUtil.isTypeOrSubTypeOf(operationStatusType, OperationStatus.class)) { + throw new InvalidReturnTypeException("AzureProxy only supports swagger interface methods that return Flux (such as " + methodParser.fullyQualifiedMethodName() + "()) if the Flux's inner type that is OperationStatus (not " + returnType.toString() + ")."); + } + + PollStrategy.PollStrategyData pollStrategyData = + (PollStrategy.PollStrategyData) operationDescription.pollStrategyData(); + PollStrategy pollStrategy = pollStrategyData.initializeStrategy(this, methodParser); + return pollStrategy.pollUntilDoneWithStatusUpdates(httpRequest, methodParser, operationStatusType); + } + + private Mono createPollStrategy(final HttpRequest originalHttpRequest, final Mono asyncOriginalHttpDecodedResponse, final SwaggerMethodParser methodParser) { + return asyncOriginalHttpDecodedResponse + .flatMap((Function>) originalHttpDecodedResponse -> { + final int httpStatusCode = originalHttpDecodedResponse.sourceResponse().statusCode(); + final HttpResponse originalHttpResponse = originalHttpDecodedResponse.sourceResponse(); + final int[] longRunningOperationStatusCodes = new int[] {200, 201, 202}; + return ensureExpectedStatus(originalHttpDecodedResponse, methodParser, longRunningOperationStatusCodes) + .flatMap(response -> { + Mono result = null; + + final Long parsedDelayInMilliseconds = PollStrategy.delayInMillisecondsFrom(originalHttpResponse); + final long delayInMilliseconds = parsedDelayInMilliseconds != null ? parsedDelayInMilliseconds : AzureProxy.defaultDelayInMilliseconds(); + + final HttpMethod originalHttpRequestMethod = originalHttpRequest.httpMethod(); + + PollStrategy pollStrategy = null; + if (httpStatusCode == 200) { + pollStrategy = AzureAsyncOperationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpResponse, delayInMilliseconds); + if (pollStrategy != null) { + result = Mono.just(pollStrategy); + } + else { + result = createProvisioningStateOrCompletedPollStrategy(originalHttpRequest, originalHttpResponse, methodParser, delayInMilliseconds); + } + } + else if (originalHttpRequestMethod == HttpMethod.PUT || originalHttpRequestMethod == HttpMethod.PATCH) { + if (httpStatusCode == 201) { + pollStrategy = AzureAsyncOperationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpResponse, delayInMilliseconds); + if (pollStrategy == null) { + result = createProvisioningStateOrCompletedPollStrategy(originalHttpRequest, originalHttpResponse, methodParser, delayInMilliseconds); + } + } else if (httpStatusCode == 202) { + pollStrategy = AzureAsyncOperationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpResponse, delayInMilliseconds); + if (pollStrategy == null) { + pollStrategy = LocationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpDecodedResponse.sourceResponse(), delayInMilliseconds); + } + } + } + else { + if (httpStatusCode == 202) { + pollStrategy = AzureAsyncOperationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpResponse, delayInMilliseconds); + if (pollStrategy == null) { + pollStrategy = LocationPollStrategy.tryToCreate(AzureProxy.this, methodParser, originalHttpRequest, originalHttpResponse, delayInMilliseconds); + if (pollStrategy == null) { + throw new CloudException("Response does not contain an Azure-AsyncOperation or Location header.", originalHttpResponse); + } + } + } + } + + if (pollStrategy == null && result == null) { + pollStrategy = new CompletedPollStrategy( + new CompletedPollStrategy.CompletedPollStrategyData(AzureProxy.this, methodParser, originalHttpResponse)); + } + + if (pollStrategy != null) { + result = Mono.just(pollStrategy); + } + + return result; + }); + }); + } + + private Mono createProvisioningStateOrCompletedPollStrategy(final HttpRequest httpRequest, HttpResponse httpResponse, final SwaggerMethodParser methodParser, final long delayInMilliseconds) { + Mono pollStrategyMono; + + final HttpMethod httpRequestMethod = httpRequest.httpMethod(); + if (httpRequestMethod == HttpMethod.DELETE + || httpRequestMethod == HttpMethod.GET + || httpRequestMethod == HttpMethod.HEAD + || !methodParser.expectsResponseBody()) { + pollStrategyMono = Mono.just(new CompletedPollStrategy( + new CompletedPollStrategy.CompletedPollStrategyData(AzureProxy.this, methodParser, httpResponse))); + } else { + final HttpResponse bufferedOriginalHttpResponse = httpResponse.buffer(); + pollStrategyMono = bufferedOriginalHttpResponse.bodyAsString() + .map(originalHttpResponseBody -> { + if (originalHttpResponseBody == null || originalHttpResponseBody.isEmpty()) { + throw new CloudException("The HTTP response does not contain a body.", bufferedOriginalHttpResponse); + } + PollStrategy pollStrategy; + try { + final SerializerAdapter serializer = serializer(); + final ResourceWithProvisioningState resource = serializer.deserialize(originalHttpResponseBody, ResourceWithProvisioningState.class, SerializerEncoding.JSON); + if (resource != null && resource.properties() != null && !OperationState.isCompleted(resource.properties().provisioningState())) { + pollStrategy = new ProvisioningStatePollStrategy( + new ProvisioningStatePollStrategy.ProvisioningStatePollStrategyData( + AzureProxy.this, methodParser, httpRequest, resource.properties().provisioningState(), delayInMilliseconds)); + } else { + pollStrategy = new CompletedPollStrategy( + new CompletedPollStrategy.CompletedPollStrategyData( + AzureProxy.this, methodParser, bufferedOriginalHttpResponse)); + } + } catch (IOException e) { + throw Exceptions.propagate(e); + } + return pollStrategy; + }); + } + return pollStrategyMono; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureServiceClient.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureServiceClient.java new file mode 100644 index 0000000000000..c011cde7a7022 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/AzureServiceClient.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.AzureEnvironment; +import com.azure.common.ServiceClient; +import com.azure.common.http.HttpPipeline; +import com.azure.common.implementation.serializer.SerializerAdapter; + +/** + * The base class for generated Azure service clients. + */ +public abstract class AzureServiceClient extends ServiceClient { + /** + * The environment that this AzureServiceClient targets. + */ + private final AzureEnvironment azureEnvironment; + + /** + * Initializes a new instance of the AzureServiceClient class. + * + * @param httpPipeline The HTTP pipeline to send requests through + * @param azureEnvironment The environment that this AzureServiceClient targets. + */ + protected AzureServiceClient(HttpPipeline httpPipeline, AzureEnvironment azureEnvironment) { + super(httpPipeline); + + this.azureEnvironment = azureEnvironment; + } + + @Override + protected SerializerAdapter createSerializerAdapter() { + return AzureProxy.createDefaultSerializer(); + } + + /** + * Get the environment that this AzureServiceClient targets. + * @return the environment that this AzureServiceClient targets. + */ + public AzureEnvironment azureEnvironment() { + return azureEnvironment; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudError.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudError.java new file mode 100644 index 0000000000000..6a73b4793ac4f --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudError.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import java.util.ArrayList; +import java.util.List; + +/** + * An instance of this class provides additional information about an http error response. + */ +public final class CloudError { + /** + * The error code parsed from the body of the http error response. + */ + private String code; + + /** + * The error message parsed from the body of the http error response. + */ + private String message; + + /** + * The target of the error. + */ + private String target; + + /** + * Details for the error. + */ + private List details; + + /** + * Initializes a new instance of CloudError. + */ + public CloudError() { + this.details = new ArrayList(); + } + + /** + * @return the error code parsed from the body of the http error response + */ + public String code() { + return code; + } + + /** + * Sets the error code parsed from the body of the http error response. + * + * @param code the error code + * @return the CloudError object itself + */ + public CloudError withCode(String code) { + this.code = code; + return this; + } + + /** + * @return the error message + */ + public String message() { + return message; + } + + /** + * Sets the error message parsed from the body of the http error response. + * + * @param message the error message + * @return the CloudError object itself + */ + public CloudError withMessage(String message) { + this.message = message; + return this; + } + + /** + * @return the target of the error + */ + public String target() { + return target; + } + + /** + * Sets the target of the error. + * + * @param target the target of the error + * @return the CloudError object itself + */ + public CloudError withTarget(String target) { + this.target = target; + return this; + } + + /** + * @return the details for the error + */ + public List details() { + return details; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudException.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudException.java new file mode 100644 index 0000000000000..0101257302157 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CloudException.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.http.rest.RestException; +import com.azure.common.http.HttpResponse; + +/** + * Exception thrown for an invalid response with custom error information. + */ +public final class CloudException extends RestException { + /** + * Initializes a new instance of the CloudException class. + * + * @param message the exception message or the response content if a message is not available + * @param response the HTTP response + */ + public CloudException(String message, HttpResponse response) { + super(message, response); + } + + /** + * Initializes a new instance of the CloudException class. + * + * @param message the exception message or the response content if a message is not available + * @param response the HTTP response + * @param body the deserialized response body + */ + public CloudException(String message, HttpResponse response, CloudError body) { + super(message, response, body); + } + + @Override + public CloudError body() { + return (CloudError) super.body(); + } + + @Override + public String toString() { + String message = super.toString(); + if (body() != null && body().message() != null) { + message = message + ": " + body().message(); + } + return message; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CompletedPollStrategy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CompletedPollStrategy.java new file mode 100644 index 0000000000000..cc461635ae8ed --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/CompletedPollStrategy.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.Serializable; +import java.lang.reflect.Type; + +/** + * The "polling strategy" that is used when a request completes immediately and does not require any + * further polling. + */ +public class CompletedPollStrategy extends PollStrategy { + private final HttpResponse firstHttpResponse; + private CompletedPollStrategyData data; + + /** + * Create a new CompletedPollStrategy. + * @param data The poll strategy data. + */ + public CompletedPollStrategy(CompletedPollStrategyData data) { + super(data); + this.firstHttpResponse = data.firstHttpResponse.buffer(); + setStatus(OperationState.SUCCEEDED); + this.data = data; + } + + /** + * The CompletedPollStrategy data. + */ + public static class CompletedPollStrategyData extends PollStrategyData { + HttpResponse firstHttpResponse; + + /** + * Create a new CompletedPollStrategyData. + * @param restProxy The RestProxy that created this PollStrategy. + * @param methodParser The method parser that describes the service interface method that + * initiated the long running operation. + * @param firstHttpResponse The HTTP response to the original HTTP request. + */ + public CompletedPollStrategyData(RestProxy restProxy, SwaggerMethodParser methodParser, HttpResponse firstHttpResponse) { + super(restProxy, methodParser, 0); + this.firstHttpResponse = firstHttpResponse; + } + + PollStrategy initializeStrategy(RestProxy restProxy, + SwaggerMethodParser methodParser) { + this.restProxy = restProxy; + this.methodParser = methodParser; + return new CompletedPollStrategy(this); + } + } + + public + @Override + HttpRequest createPollRequest() { + throw new UnsupportedOperationException(); + } + + @Override + Mono updateFromAsync(HttpResponse httpPollResponse) { + return Mono.error(new UnsupportedOperationException()); + } + + @Override + boolean isDone() { + return true; + } + + Flux> pollUntilDoneWithStatusUpdates(final HttpRequest originalHttpRequest, final SwaggerMethodParser methodParser, final Type operationStatusResultType) { + return createOperationStatusMono(originalHttpRequest, firstHttpResponse, methodParser, operationStatusResultType) + .flatMapMany(cos -> Flux.just(cos)); + } + + Mono pollUntilDone() { + return Mono.just(firstHttpResponse); + } + + @Override + public Serializable strategyData() { + return this.data; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/LocationPollStrategy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/LocationPollStrategy.java new file mode 100644 index 0000000000000..9e0d0370c6ac9 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/LocationPollStrategy.java @@ -0,0 +1,160 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +import java.io.Serializable; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * A PollStrategy type that uses the Location header value to check the status of a long running + * operation. + */ +public final class LocationPollStrategy extends PollStrategy { + LocationPollStrategyData data; + + /** + * The name of the header that indicates that a long running operation will use the Location + * strategy. + */ + public static final String HEADER_NAME = "Location"; + + private LocationPollStrategy(LocationPollStrategyData data) { + super(data); + this.data = data; + } + + /** + * The LocationPollStrategy data. + */ + public static class LocationPollStrategyData extends PollStrategyData { + URL locationUrl; + boolean done; + + /** + * Create a new LocationPollStrategyData. + */ + public LocationPollStrategyData() { + super(null, null, 0); + this.locationUrl = null; + } + + /** + * Create a new LocationPollStrategyData. + * @param restProxy The RestProxy that created this PollStrategy. + * @param methodParser The method parser that describes the service interface method that + * initiated the long running operation. + * @param locationUrl The location url. + * @param delayInMilliseconds The delay value. + */ + public LocationPollStrategyData(RestProxy restProxy, + SwaggerMethodParser methodParser, + URL locationUrl, + long delayInMilliseconds) { + super(restProxy, methodParser, delayInMilliseconds); + this.locationUrl = locationUrl; + } + + PollStrategy initializeStrategy(RestProxy restProxy, + SwaggerMethodParser methodParser) { + this.restProxy = restProxy; + this.methodParser = methodParser; + return new LocationPollStrategy(this); + } + } + + @Override + public HttpRequest createPollRequest() { + return new HttpRequest(HttpMethod.GET, data.locationUrl); + } + + @Override + public Mono updateFromAsync(HttpResponse httpPollResponse) { + return ensureExpectedStatus(httpPollResponse, new int[] {202}) + .map(response -> { + final int httpStatusCode = response.statusCode(); + updateDelayInMillisecondsFrom(response); + if (httpStatusCode == 202) { + String newLocationUrl = getHeader(response); + if (newLocationUrl != null) { + try { + data.locationUrl = new URL(newLocationUrl); + } catch (MalformedURLException mfue) { + throw Exceptions.propagate(mfue); + } + } + } + else { + data.done = true; + } + return response; + }); + } + + @Override + public boolean isDone() { + return data.done; + } + + /** + * Try to create a new LocationOperationPollStrategy object that will poll the provided location + * URL. If the provided HttpResponse doesn't have a Location header or the header is empty, + * then null will be returned. + * @param originalHttpRequest The original HTTP request. + * @param methodParser The method parser that describes the service interface method that + * initiated the long running operation. + * @param httpResponse The HTTP response that the required header values for this pollStrategy + * will be read from. + * @param delayInMilliseconds The delay (in milliseconds) that the resulting pollStrategy will + * use when polling. + */ + static PollStrategy tryToCreate(RestProxy restProxy, SwaggerMethodParser methodParser, HttpRequest originalHttpRequest, HttpResponse httpResponse, long delayInMilliseconds) { + final String locationUrl = getHeader(httpResponse); + + URL pollUrl = null; + if (locationUrl != null && !locationUrl.isEmpty()) { + if (locationUrl.startsWith("/")) { + try { + final URL originalRequestUrl = originalHttpRequest.url(); + pollUrl = new URL(originalRequestUrl, locationUrl); + } catch (MalformedURLException ignored) { + } + } + else { + final String locationUrlLower = locationUrl.toLowerCase(); + if (locationUrlLower.startsWith("http://") || locationUrlLower.startsWith("https://")) { + try { + pollUrl = new URL(locationUrl); + } catch (MalformedURLException ignored) { + } + } + } + } + + return pollUrl == null + ? null + : new LocationPollStrategy( + new LocationPollStrategyData(restProxy, methodParser, pollUrl, delayInMilliseconds)); + } + + static String getHeader(HttpResponse httpResponse) { + return httpResponse.headerValue(HEADER_NAME); + } + + @Override + public Serializable strategyData() { + return this.data; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationState.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationState.java new file mode 100644 index 0000000000000..bfa81efabba17 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationState.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +/** + * The different states that a long running operation can be in. + */ +public final class OperationState { + /** + * The provisioning state of the operation resource if the operation is still in progress. + */ + public static final String IN_PROGRESS = "InProgress"; + + /** + * The provisioning state of the operation resource if the operation is successful. + */ + public static final String SUCCEEDED = "Succeeded"; + + /** + * The provisioning state of the operation resource if the operation is unsuccessful. + */ + public static final String FAILED = "Failed"; + + /** + * The provisioning state of the operation resource if the operation is canceled. + */ + public static final String CANCELED = "Canceled"; + + /** + * Get whether or not the provided operation state represents a completed state. + * @param operationState The operation state to check. + * @return Whether or not the provided operation state represents a completed state. + */ + public static boolean isCompleted(String operationState) { + return operationState == null + || operationState.length() == 0 + || SUCCEEDED.equalsIgnoreCase(operationState) + || isFailedOrCanceled(operationState); + } + + /** + * Get whether or not the provided operation state represents a failed or canceled state. + * @param operationState The operation state to check. + * @return Whether or not the provided operation state represents a failed or canceled state. + */ + public static boolean isFailedOrCanceled(String operationState) { + return FAILED.equalsIgnoreCase(operationState) + || CANCELED.equalsIgnoreCase(operationState); + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationStatus.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationStatus.java new file mode 100644 index 0000000000000..4e8dd5f3d5b60 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/OperationStatus.java @@ -0,0 +1,104 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.implementation.OperationDescription; +import com.azure.common.http.rest.RestException; +import com.azure.common.http.HttpRequest; + +/** + * The current state of polling for the result of a long running operation. + * @param The type of value that will be returned from the long running operation. + */ +public class OperationStatus { + private final PollStrategy pollStrategy; + private final HttpRequest originalHttpRequest; + private final T result; + private final RestException error; + private final String status; + + /** + * Create a new OperationStatus with the provided PollStrategy. + * @param pollStrategy The polling strategy that the OperationStatus will use to check the + * progress of a long running operation. + */ + OperationStatus(PollStrategy pollStrategy, HttpRequest originalHttpRequest) { + this.originalHttpRequest = originalHttpRequest; + this.pollStrategy = pollStrategy; + this.result = null; + this.error = null; + this.status = pollStrategy.status(); + } + + /** + * Create a new OperationStatus with the provided result. + * @param result The final result of a long running operation. + */ + OperationStatus(T result, String provisioningState) { + this.pollStrategy = null; + this.originalHttpRequest = null; + this.result = result; + this.error = null; + this.status = provisioningState; + } + + OperationStatus(RestException error, String provisioningState) { + this.pollStrategy = null; + this.originalHttpRequest = null; + this.result = null; + this.error = error; + this.status = provisioningState; + } + + /** + * @return Whether or not the long running operation is done. + */ + public boolean isDone() { + return pollStrategy == null; + } + + /** + * @return the current status of the long running operation. + */ + public String status() { + return status; + } + + /** + * If the long running operation is done, get the result of the operation. If the operation is + * not done or if the operation failed, then return null. + * @return The result of the operation, or null if the operation isn't done yet or if it failed. + */ + public T result() { + return result; + } + + /** + * If the long running operation failed, get the error that occurred. If the operation is not + * done or did not fail, then return null. + * @return The error of the operation, or null if the operation isn't done or didn't fail. + */ + public RestException error() { + return error; + } + + /** + * Builds an object that can be used to resume the polling of the operation. + * @return The OperationDescription. + */ + public OperationDescription buildDescription() { + if (this.isDone()) { + return null; + } + + return new OperationDescription( + this.pollStrategy.methodParser().fullyQualifiedMethodName(), + this.pollStrategy.strategyData(), + this.originalHttpRequest); + } + +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Page.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Page.java new file mode 100644 index 0000000000000..84921bee220cc --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Page.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import java.util.List; + +/** + * Defines a page interface in Azure responses. + * + * @param the element type. + */ +public interface Page { + /** + * Gets the link to the next page. + * + * @return the link. + */ + String nextPageLink(); + + /** + * Gets the list of items. + * + * @return the list of items. + */ + List items(); +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PagedList.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PagedList.java new file mode 100644 index 0000000000000..8a2f237ec0c9d --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PagedList.java @@ -0,0 +1,413 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.http.rest.RestException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.ConcurrentModificationException; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.NoSuchElementException; + +/** + * Defines a list response from a paging operation. The pages are + * lazy initialized when an instance of this class is iterated. + * + * @param the element type. + */ +public abstract class PagedList implements List { + /** The actual items in the list. */ + private List items; + /** Stores the latest page fetched. */ + private Page currentPage; + /** Cached page right after the current one. */ + private Page cachedPage; + + /** + * Creates an instance of Pagedlist. + */ + public PagedList() { + items = new ArrayList<>(); + } + + /** + * Creates an instance of PagedList from a {@link Page} response. + * + * @param page the {@link Page} object. + */ + public PagedList(Page page) { + this(); + if (page == null) { + return; + } + List retrievedItems = page.items(); + if (retrievedItems != null) { + items.addAll(retrievedItems); + } + currentPage = page; + cachePage(page.nextPageLink()); + } + + private void cachePage(String nextPageLink) { + try { + while (nextPageLink != null && nextPageLink != "") { + cachedPage = nextPage(nextPageLink); + if (cachedPage == null) { + break; + } + nextPageLink = cachedPage.nextPageLink(); + if (hasNextPage()) { + // a legit, non-empty page has been fetched, otherwise keep fetching + break; + } + } + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + + /** + * Override this method to load the next page of items from a next page link. + * + * @param nextPageLink the link to get the next page of items. + * @return the {@link Page} object storing a page of items and a link to the next page. + * @throws RestException thrown if an error is raised from Azure. + * @throws IOException thrown if there's any failure in deserialization. + */ + public abstract Page nextPage(String nextPageLink) throws RestException, IOException; + + /** + * If there are more pages available. + * + * @return true if there are more pages to load. False otherwise. + */ + public boolean hasNextPage() { + return this.cachedPage != null && this.cachedPage.items() != null && !this.cachedPage.items().isEmpty(); + } + + /** + * Loads a page from next page link. + * The exceptions are wrapped into Java Runtime exceptions. + */ + public void loadNextPage() { + this.currentPage = cachedPage; + cachedPage = null; + this.items.addAll(currentPage.items()); + cachePage(currentPage.nextPageLink()); + } + + /** + * Keep loading the next page from the next page link until all items are loaded. + */ + public void loadAll() { + while (hasNextPage()) { + loadNextPage(); + } + } + + /** + * Gets the latest page fetched. + * + * @return the latest page. + */ + public Page currentPage() { + return currentPage; + } + + /** + * Sets the current page. + * + * @param currentPage the current page. + */ + protected void setCurrentPage(Page currentPage) { + this.currentPage = currentPage; + List retrievedItems = currentPage.items(); + if (retrievedItems != null) { + items.addAll(retrievedItems); + } + cachePage(currentPage.nextPageLink()); + } + + /** + * The implementation of {@link ListIterator} for PagedList. + */ + private class ListItr implements ListIterator { + /** + * index of next element to return. + */ + private int nextIndex; + /** + * index of last element returned; -1 if no such action happened. + */ + private int lastRetIndex = -1; + + /** + * Creates an instance of the ListIterator. + * + * @param index the position in the list to start. + */ + ListItr(int index) { + this.nextIndex = index; + } + + @Override + public boolean hasNext() { + return this.nextIndex != items.size() || hasNextPage(); + } + + @Override + public E next() { + if (this.nextIndex >= items.size()) { + if (!hasNextPage()) { + throw new NoSuchElementException(); + } else { + loadNextPage(); + } + // Recurse until we load a page with non-zero items. + return next(); + } else { + try { + E nextItem = items.get(this.nextIndex); + this.lastRetIndex = this.nextIndex; + this.nextIndex = this.nextIndex + 1; + return nextItem; + } catch (IndexOutOfBoundsException ex) { + // The nextIndex got invalid means a different instance of iterator + // removed item from this index. + throw new ConcurrentModificationException(); + } + } + } + + @Override + public void remove() { + if (this.lastRetIndex < 0) { + throw new IllegalStateException(); + } else { + try { + items.remove(this.lastRetIndex); + this.nextIndex = this.lastRetIndex; + this.lastRetIndex = -1; + } catch (IndexOutOfBoundsException ex) { + throw new ConcurrentModificationException(); + } + } + } + + @Override + public boolean hasPrevious() { + return this.nextIndex != 0; + } + + @Override + public E previous() { + int i = this.nextIndex - 1; + if (i < 0) { + throw new NoSuchElementException(); + } else if (i >= items.size()) { + throw new ConcurrentModificationException(); + } else { + try { + this.nextIndex = i; + this.lastRetIndex = i; + return items.get(this.lastRetIndex); + } catch (IndexOutOfBoundsException ex) { + throw new ConcurrentModificationException(); + } + } + } + + @Override + public int nextIndex() { + return this.nextIndex; + } + + @Override + public int previousIndex() { + return this.nextIndex - 1; + } + + @Override + public void set(E e) { + if (this.lastRetIndex < 0) { + throw new IllegalStateException(); + } else { + try { + items.set(this.lastRetIndex, e); + } catch (IndexOutOfBoundsException ex) { + throw new ConcurrentModificationException(); + } + } + } + + @Override + public void add(E e) { + try { + items.add(this.nextIndex, e); + this.nextIndex = this.nextIndex + 1; + this.lastRetIndex = -1; + } catch (IndexOutOfBoundsException ex) { + throw new ConcurrentModificationException(); + } + } + } + + @Override + public int size() { + loadAll(); + return items.size(); + } + + @Override + public boolean isEmpty() { + return items.isEmpty() && !hasNextPage(); + } + + @Override + public boolean contains(Object o) { + return indexOf(o) >= 0; + } + + @Override + public Iterator iterator() { + return new ListItr(0); + } + + @Override + public Object[] toArray() { + loadAll(); + return items.toArray(); + } + + @Override + public T[] toArray(T[] a) { + loadAll(); + return items.toArray(a); + } + + @Override + public boolean add(E e) { + return items.add(e); + } + + @Override + public boolean remove(Object o) { + return items.remove(o); + } + + @Override + public boolean containsAll(Collection c) { + for (Object e : c) { + if (!contains(e)) { + return false; + } + } + return true; + } + + @Override + public boolean addAll(Collection c) { + return items.addAll(c); + } + + @Override + public boolean addAll(int index, Collection c) { + return items.addAll(index, c); + } + + @Override + public boolean removeAll(Collection c) { + return items.removeAll(c); + } + + @Override + public boolean retainAll(Collection c) { + return items.retainAll(c); + } + + @Override + public void clear() { + items.clear(); + } + + @Override + public E get(int index) { + while (index >= items.size() && hasNextPage()) { + loadNextPage(); + } + return items.get(index); + } + + @Override + public E set(int index, E element) { + return items.set(index, element); + } + + @Override + public void add(int index, E element) { + items.add(index, element); + } + + @Override + public E remove(int index) { + return items.remove(index); + } + + @Override + public int indexOf(Object o) { + int index = 0; + if (o == null) { + for (E item : this) { + if (item == null) { + return index; + } + ++index; + } + } else { + for (E item : this) { + if (item == o) { + return index; + } + ++index; + } + } + return -1; + } + + @Override + public int lastIndexOf(Object o) { + loadAll(); + return items.lastIndexOf(o); + } + + @Override + public ListIterator listIterator() { + return new ListItr(0); + } + + @Override + public ListIterator listIterator(int index) { + while (index >= items.size() && hasNextPage()) { + loadNextPage(); + } + return new ListItr(index); + } + + @Override + public List subList(int fromIndex, int toIndex) { + while ((fromIndex >= items.size() + || toIndex >= items.size()) + && hasNextPage()) { + loadNextPage(); + } + return items.subList(fromIndex, toIndex); + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PollStrategy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PollStrategy.java new file mode 100644 index 0000000000000..c1404d134ae69 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/PollStrategy.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.http.rest.RestException; +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.http.ContextData; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.serializer.HttpResponseDecoder; +import com.azure.common.implementation.serializer.SerializerEncoding; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.Serializable; +import java.lang.reflect.Type; +import java.time.Duration; + +/** + * An abstract class for the different strategies that an OperationStatus can use when checking the + * status of a long running operation. + */ +abstract class PollStrategy { + private final RestProxy restProxy; + private final SwaggerMethodParser methodParser; + + private long delayInMilliseconds; + private String status; + + PollStrategy(PollStrategyData data) { + this.restProxy = data.restProxy; + this.methodParser = data.methodParser; + this.delayInMilliseconds = data.delayInMilliseconds; + } + + abstract static class PollStrategyData implements Serializable { + transient RestProxy restProxy; + transient SwaggerMethodParser methodParser; + long delayInMilliseconds; + + PollStrategyData(RestProxy restProxy, + SwaggerMethodParser methodParser, + long delayInMilliseconds) { + this.restProxy = restProxy; + this.methodParser = methodParser; + this.delayInMilliseconds = delayInMilliseconds; + } + + + abstract PollStrategy initializeStrategy(RestProxy restProxy, + SwaggerMethodParser methodParser); + } + + @SuppressWarnings("unchecked") + protected T deserialize(String value, Type returnType) throws IOException { + return (T) restProxy.serializer().deserialize(value, returnType, SerializerEncoding.JSON); + } + + protected Mono ensureExpectedStatus(HttpResponse httpResponse) { + return ensureExpectedStatus(httpResponse, null); + } + + protected Mono ensureExpectedStatus(HttpResponse httpResponse, int[] additionalAllowedStatusCodes) { + Mono asyncDecodedResponse = new HttpResponseDecoder(restProxy.serializer()).decode(Mono.just(httpResponse), this.methodParser); + return asyncDecodedResponse.flatMap(decodedResponse -> { + return restProxy.ensureExpectedStatus(decodedResponse, methodParser, additionalAllowedStatusCodes); + }).map(decodedResponse -> httpResponse); + } + + protected String fullyQualifiedMethodName() { + return methodParser.fullyQualifiedMethodName(); + } + + protected boolean expectsResourceResponse() { + return methodParser.expectsResponseBody(); + } + + /** + * Set the delay in milliseconds to 0. + */ + final void clearDelayInMilliseconds() { + this.delayInMilliseconds = 0; + } + + /** + * Update the delay in milliseconds from the provided HTTP poll response. + * @param httpPollResponse The HTTP poll response to update the delay in milliseconds from. + */ + final void updateDelayInMillisecondsFrom(HttpResponse httpPollResponse) { + final Long parsedDelayInMilliseconds = delayInMillisecondsFrom(httpPollResponse); + if (parsedDelayInMilliseconds != null) { + delayInMilliseconds = parsedDelayInMilliseconds; + } + } + + static Long delayInMillisecondsFrom(HttpResponse httpResponse) { + Long result = null; + + final String retryAfterSecondsString = httpResponse.headerValue("Retry-After"); + if (retryAfterSecondsString != null && !retryAfterSecondsString.isEmpty()) { + result = Long.valueOf(retryAfterSecondsString) * 1000; + } + + return result; + } + + /** + * If this OperationStatus has a retryAfterSeconds value, return an Mono that is delayed by the + * number of seconds that are in the retryAfterSeconds value. If this OperationStatus doesn't have + * a retryAfterSeconds value, then return an Single with no delay. + * @return A Mono with delay if this OperationStatus has a retryAfterSeconds value. + */ + Mono delayAsync() { + Mono result = Mono.empty(); + if (delayInMilliseconds > 0) { + result = result.delaySubscription(Duration.ofMillis(delayInMilliseconds)); + } + return result; + } + + /** + * @return the current status of the long running operation. + */ + String status() { + return status; + } + + /** + * Set the current status of the long running operation. + * @param status The current status of the long running operation. + */ + void setStatus(String status) { + this.status = status; + } + + /** + * Create a new HTTP poll request. + * @return A new HTTP poll request. + */ + abstract HttpRequest createPollRequest(); + + /** + * Update the status of this PollStrategy from the provided HTTP poll response. + * @param httpPollResponse The response of the most recent poll request. + * @return A Completable that can be used to chain off of this operation. + */ + abstract Mono updateFromAsync(HttpResponse httpPollResponse); + + /** + * Get whether or not this PollStrategy's long running operation is done. + * @return Whether or not this PollStrategy's long running operation is done. + */ + abstract boolean isDone(); + + Mono sendPollRequestWithDelay() { + return Mono.defer(() -> delayAsync().then(Mono.defer(() -> { + final HttpRequest pollRequest = createPollRequest(); + return restProxy.send(pollRequest, new ContextData("caller-method", fullyQualifiedMethodName())); + })).flatMap(response -> updateFromAsync(response))); + } + + Mono> createOperationStatusMono(HttpRequest httpRequest, HttpResponse httpResponse, SwaggerMethodParser methodParser, Type operationStatusResultType) { + OperationStatus operationStatus; + if (!isDone()) { + operationStatus = new OperationStatus<>(this, httpRequest); + } else { + try { + final Object resultObject = restProxy.handleRestReturnType(new HttpResponseDecoder(restProxy.serializer()).decode(Mono.just(httpResponse), this.methodParser), methodParser, operationStatusResultType); + operationStatus = new OperationStatus<>(resultObject, status()); + } catch (RestException e) { + operationStatus = new OperationStatus<>(e, OperationState.FAILED); + } + } + return Mono.just(operationStatus); + } + + Flux> pollUntilDoneWithStatusUpdates(final HttpRequest originalHttpRequest, final SwaggerMethodParser methodParser, final Type operationStatusResultType) { + return sendPollRequestWithDelay() + .flatMap(httpResponse -> createOperationStatusMono(originalHttpRequest, httpResponse, methodParser, operationStatusResultType)) + .repeat() + .takeUntil(operationStatus -> isDone()); + } + + Mono pollUntilDone() { + return sendPollRequestWithDelay() + .repeat() + .takeUntil(ignored -> isDone()) + .last(); + } + + /** + * @return The data for the strategy. + */ + public abstract Serializable strategyData(); + + SwaggerMethodParser methodParser() { + return this.methodParser; + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ProvisioningStatePollStrategy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ProvisioningStatePollStrategy.java new file mode 100644 index 0000000000000..767bc24483302 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ProvisioningStatePollStrategy.java @@ -0,0 +1,107 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.SwaggerMethodParser; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.Serializable; + +/** + * A PollStrategy that will continue to poll a resource's URL until the resource's provisioning + * state property is in a completed state. + */ +public final class ProvisioningStatePollStrategy extends PollStrategy { + private ProvisioningStatePollStrategyData data; + ProvisioningStatePollStrategy(ProvisioningStatePollStrategyData data) { + super(data); + setStatus(data.provisioningState); + this.data = data; + } + + /** + * The ProvisioningStatePollStrategy data. + */ + public static class ProvisioningStatePollStrategyData extends PollStrategy.PollStrategyData { + HttpRequest originalRequest; + String provisioningState; + + /** + * Create a new ProvisioningStatePollStrategyData. + * @param restProxy The RestProxy that created this PollStrategy. + * @param methodParser The method parser that describes the service interface method that + * initiated the long running operation. + * @param originalRequest The HTTP response to the original HTTP request. + * @param provisioningState The provisioning state. + * @param delayInMilliseconds The delay value. + */ + public ProvisioningStatePollStrategyData(RestProxy restProxy, + SwaggerMethodParser methodParser, + HttpRequest originalRequest, + String provisioningState, + long delayInMilliseconds) { + super(restProxy, methodParser, delayInMilliseconds); + this.originalRequest = originalRequest; + this.provisioningState = provisioningState; + } + + PollStrategy initializeStrategy(RestProxy restProxy, + SwaggerMethodParser methodParser) { + this.restProxy = restProxy; + this.methodParser = methodParser; + return new ProvisioningStatePollStrategy(this); + } + + } + + @Override + HttpRequest createPollRequest() { + return new HttpRequest(HttpMethod.GET, data.originalRequest.url()); + } + + @Override + Mono updateFromAsync(HttpResponse pollResponse) { + return ensureExpectedStatus(pollResponse) + .flatMap(response -> { + final HttpResponse bufferedHttpPollResponse = response.buffer(); + return bufferedHttpPollResponse.bodyAsString() + .map(responseBody -> { + ResourceWithProvisioningState resource = null; + try { + resource = deserialize(responseBody, ResourceWithProvisioningState.class); + } catch (IOException ignored) { + } + + if (resource == null || resource.properties() == null || resource.properties().provisioningState() == null) { + throw new CloudException("The polling response does not contain a valid body", bufferedHttpPollResponse, null); + } + else if (OperationState.isFailedOrCanceled(resource.properties().provisioningState())) { + throw new CloudException("Async operation failed with provisioning state: " + resource.properties().provisioningState(), bufferedHttpPollResponse); + } + else { + setStatus(resource.properties().provisioningState()); + } + return bufferedHttpPollResponse; + }); + }); + } + + @Override + boolean isDone() { + return OperationState.isCompleted(status()); + } + + @Override + public Serializable strategyData() { + return this.data; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Resource.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Resource.java new file mode 100644 index 0000000000000..bf91cf9f6777b --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/Resource.java @@ -0,0 +1,112 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * The Resource model. + */ +public class Resource { + /** + * Resource Id. + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String id; + + /** + * Resource name. + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String name; + + /** + * Resource type. + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String type; + + /** + * Resource location. + */ + @JsonProperty(required = true) + private String location; + + /** + * Resource tags. + */ + private Map tags; + + /** + * Get the id value. + * + * @return the id value + */ + public String id() { + return this.id; + } + + /** + * Get the name value. + * + * @return the name value + */ + public String name() { + return this.name; + } + + /** + * Get the type value. + * + * @return the type value + */ + public String type() { + return this.type; + } + + /** + * Get the location value. + * + * @return the location value + */ + public String location() { + return this.location; + } + + /** + * Set the location value. + * + * @param location the location value to set + * @return the resource itself + */ + public Resource withLocation(String location) { + this.location = location; + return this; + } + + /** + * Get the tags value. + * + * @return the tags value + */ + public Map getTags() { + return this.tags; + } + + /** + * Set the tags value. + * + * @param tags the tags value to set + * @return the resource itself + */ + public Resource withTags(Map tags) { + this.tags = tags; + return this; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ResourceWithProvisioningState.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ResourceWithProvisioningState.java new file mode 100644 index 0000000000000..a63a81c8b4f5f --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/ResourceWithProvisioningState.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The ResourceWithProvisioningState class is a POJO representation of any Azure resource that has a + * provisioningState property. + */ +public class ResourceWithProvisioningState { + @JsonProperty(value = "properties") + private Properties properties; + + /** + * @return The inner properties object. + */ + public Properties properties() { + return properties; + } + + /** + * Set the properties of this ResourceWithProvisioningState. + * @param properties The properties of this ResourceWithProvisioningState. + */ + public void setProperties(Properties properties) { + this.properties = properties; + } + + /** + * Inner properties class. + */ + public static class Properties { + @JsonProperty(value = "provisioningState") + private String provisioningState; + + /** + * @return The provisioning state of the resource. + */ + String provisioningState() { + return provisioningState; + } + + /** + * Set the provisioning state of this ResourceWithProvisioningState. + * @param provisioningState The provisioning state of this ResourceWithProvisioningState. + */ + public void setProvisioningState(String provisioningState) { + this.provisioningState = provisioningState; + } + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/SubResource.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/SubResource.java new file mode 100644 index 0000000000000..7e986295a46a7 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/SubResource.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +/** + * The SubResource model. + */ +public class SubResource { + /** + * Resource Id. + */ + private String id; + + /** + * Get the id value. + * + * @return the id value + */ + public String id() { + return this.id; + } + + /** + * Set the id value. + * + * @param id the id value to set + * @return the sub resource itself + */ + public SubResource withId(String id) { + this.id = id; + return this; + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/AzureHost.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/AzureHost.java new file mode 100644 index 0000000000000..1f65619d407a8 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/AzureHost.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.annotations; + +import com.azure.common.AzureEnvironment; +import com.azure.common.AzureEnvironment.Endpoint; +import com.azure.common.annotations.Host; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; + +/** + * An extension to {@link Host}, allowing endpoints + * of {@link AzureEnvironment} to be specified instead of string + * host names. This allows self adaptive base URLs based on the environment the + * client is running in. + * + * Example 1: Azure Resource Manager + * + * {@literal @}AzureHost(AzureEnvironment.Endpoint.RESOURCE_MANAGER) + * interface VirtualMachinesService { + * {@literal @}GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}") + * VirtualMachine getByResourceGroup(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId); + * } + * + * Example 2: Azure Key Vault + * + * {@literal @}AzureHost(AzureEnvironment.Endpoint.KEY_VAULT) + * interface KeyVaultService { + * {@literal @}GET("secrets/{secretName}") + * Secret getSecret(@HostParam String vaultName, @PathParam("secretName") String secretName); + * } + */ +@Target(value = {TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AzureHost { + /** + * The endpoint that all REST APIs within the Swagger interface will send their requests to. + * @return The endpoint that all REST APIs within the Swagger interface will send their requests + * to. + */ + String value() default ""; + + /** + * The endpoint that all REST APIs within the Swagger interface will send their requests to. + * @return The endpoint that all REST APIs within the Swagger interface will send their requests + * to. + */ + AzureEnvironment.Endpoint endpoint() default Endpoint.RESOURCE_MANAGER; +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/package-info.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/package-info.java new file mode 100644 index 0000000000000..7b1b924ccf9da --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/annotations/package-info.java @@ -0,0 +1,4 @@ +/** + * Annotations used on Swagger generated interfaces that are specific to Azure ARM REST APIs. + */ +package com.azure.common.mgmt.annotations; \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/package-info.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/package-info.java new file mode 100644 index 0000000000000..694a07d00a3f2 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing the types for Azure ARM client side http communication with a REST endpoint. + */ +package com.azure.common.mgmt; \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/AsyncCredentialsPolicy.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/AsyncCredentialsPolicy.java new file mode 100644 index 0000000000000..4c032cf91cf2b --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/AsyncCredentialsPolicy.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.policy; + +import com.azure.common.credentials.AsyncServiceClientCredentials; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.policy.HttpPipelinePolicy; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +/** + * Creates a policy which adds credentials from AsyncServiceClientCredentials to a request. + */ +public class AsyncCredentialsPolicy implements HttpPipelinePolicy { + private final AsyncServiceClientCredentials credentials; + + /** + * Creates CredentialsPolicy. + * + * @param credentials The credentials to use for authentication. + */ + public AsyncCredentialsPolicy(AsyncServiceClientCredentials credentials) { + this.credentials = credentials; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return credentials.authorizationHeaderValueAsync(context.httpRequest().url().toString()) + .flatMap(token -> { + context.httpRequest().headers().set("Authorization", token); + return next.process(); + }); + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/package-info.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/package-info.java new file mode 100644 index 0000000000000..30edee352565d --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/policy/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing HttpPipelinePolicy interface and it's implementations used by Azure ARM Clients. + */ +package com.azure.common.mgmt.policy; \ No newline at end of file diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/AzureJacksonAdapter.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/AzureJacksonAdapter.java new file mode 100644 index 0000000000000..1c568b4a96f4b --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/AzureJacksonAdapter.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.serializer; + +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; + +/** + * A serialization helper class overriding {@link JacksonAdapter} with extra + * functionality useful for Azure operations. + */ +public final class AzureJacksonAdapter extends JacksonAdapter implements SerializerAdapter { + /** + * Creates an instance of the Azure flavored Jackson adapter. + */ + public AzureJacksonAdapter() { + super(); + serializer().registerModule(CloudErrorDeserializer.getModule(simpleMapper())); + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/CloudErrorDeserializer.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/CloudErrorDeserializer.java new file mode 100644 index 0000000000000..50800af952b07 --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/CloudErrorDeserializer.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.serializer; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.azure.common.mgmt.CloudError; + +import java.io.IOException; + +/** + * Custom serializer for serializing {@link CloudError} objects. + */ +final class CloudErrorDeserializer extends JsonDeserializer { + /** Object mapper for default deserializations. */ + private ObjectMapper mapper; + + /** + * Creates an instance of CloudErrorDeserializer. + * + * @param mapper the object mapper for default deserializations. + */ + private CloudErrorDeserializer(ObjectMapper mapper) { + this.mapper = mapper; + } + + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @param mapper the object mapper for default deserializations. + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + static SimpleModule getModule(ObjectMapper mapper) { + SimpleModule module = new SimpleModule(); + module.addDeserializer(CloudError.class, new CloudErrorDeserializer(mapper)); + return module; + } + + @Override + public CloudError deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode errorNode = p.readValueAsTree(); + if (errorNode == null) { + return null; + } + if (errorNode.get("error") != null) { + errorNode = errorNode.get("error"); + } + String nodeContent = errorNode.toString(); + nodeContent = nodeContent.replaceFirst("(?i)\"code\"", "\"code\"") + .replaceFirst("(?i)\"message\"", "\"message\"") + .replaceFirst("(?i)\"target\"", "\"target\"") + .replaceFirst("(?i)\"details\"", "\"details\""); + JsonParser parser = new JsonFactory().createParser(nodeContent); + parser.setCodec(mapper); + return parser.readValueAs(CloudError.class); + } +} diff --git a/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/package-info.java b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/package-info.java new file mode 100644 index 0000000000000..4826f193421ea --- /dev/null +++ b/common/azure-common-mgmt/src/main/java/com/azure/common/mgmt/serializer/package-info.java @@ -0,0 +1,4 @@ +/** + * The package contains classes that handle serialization and deserialization for the REST call payloads in Azure ARM. + */ +package com.azure.common.mgmt.serializer; \ No newline at end of file diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyTests.java new file mode 100644 index 0000000000000..fe9f05770955c --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyTests.java @@ -0,0 +1,864 @@ +package com.azure.common.mgmt; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.azure.common.mgmt.http.MockAzureHttpClient; +import com.azure.common.mgmt.http.MockAzureHttpResponse; +import com.azure.common.implementation.OperationDescription; +import com.azure.common.http.rest.RestException; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import com.azure.common.implementation.exception.InvalidReturnTypeException; +import com.azure.common.annotations.DELETE; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.PUT; +import com.azure.common.annotations.PathParam; +import com.azure.common.annotations.ResumeOperation; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.Assert.*; + +public class AzureProxyTests { + private long delayInMillisecondsBackup; + + @Before + public void beforeTest() { + delayInMillisecondsBackup = AzureProxy.defaultDelayInMilliseconds(); + AzureProxy.setDefaultPollingDelayInMilliseconds(0); + } + + @After + public void afterTest() { + AzureProxy.setDefaultPollingDelayInMilliseconds(delayInMillisecondsBackup); + } + + @Host("https://mock.azure.com") + private interface MockResourceService { + @GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + MockResource get(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + Mono getAsync(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + MockResource create(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location") + @ExpectedResponses({200}) + MockResource createWithLocation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + MockResource createWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsRemaining); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Azure-AsyncOperation") + @ExpectedResponses({200}) + MockResource createWithAzureAsyncOperation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Azure-AsyncOperation&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + MockResource createWithAzureAsyncOperationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsRemaining); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=ProvisioningState") + @ExpectedResponses({200}) + MockResource createWithProvisioningState(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=ProvisioningState&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + MockResource createWithProvisioningStateAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsRemaining); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + Mono createAsync(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location") + @ExpectedResponses({200}) + Mono createAsyncWithLocation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Mono createAsyncWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Azure-AsyncOperation") + @ExpectedResponses({200}) + Mono createAsyncWithAzureAsyncOperation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Azure-AsyncOperation&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Mono createAsyncWithAzureAsyncOperationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=ProvisioningState") + @ExpectedResponses({200}) + Mono createAsyncWithProvisioningState(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=ProvisioningState&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Mono createAsyncWithProvisioningStateAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Flux beginCreateAsyncWithBadReturnType(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining=1&InitialResponseStatusCode=294") + @ExpectedResponses({200}) + Flux> beginCreateAsyncWithLocationAndPollsAndUnexpectedStatusCode(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Flux> beginCreateAsyncWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @ExpectedResponses({200}) + @ResumeOperation + Flux> resumeCreateAsyncWithLocationAndPolls(OperationDescription operationDescription); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Azure-AsyncOperation&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Flux> beginCreateAsyncWithAzureAsyncOperationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=ProvisioningState&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Flux> beginCreateAsyncWithProvisioningStateAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + void delete(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location") + @ExpectedResponses({200}) + void deleteWithLocation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + void deleteWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}") + @ExpectedResponses({200}) + Mono deleteAsync(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location") + @ExpectedResponses({200}) + Mono deleteAsyncWithLocation(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Mono deleteAsyncWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/mockprovider/mockresources/{mockResourceName}?PollType=Location&PollsRemaining={pollsRemaining}") + @ExpectedResponses({200}) + Flux> beginDeleteAsyncWithLocationAndPolls(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String resourceGroupName, @PathParam("mockResourceName") String mockResourceName, @PathParam("pollsRemaining") int pollsUntilResource); + + @DELETE("errors/403") + @ExpectedResponses({200}) + Mono deleteAsyncWithForbiddenResponse(); + } + + @Test + public void get() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .get("1", "mine", "a"); + assertNotNull(resource); + assertEquals("a", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void getAsync() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .getAsync("1", "mine", "b") + .block(); + assertNotNull(resource); + assertEquals("b", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void create() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .create("1", "mine", "c"); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void createWithLocation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithLocation("1", "mine", "c"); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void createWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithLocationAndPolls("1", "mine", "c", 2); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(2, httpClient.pollRequests()); + } + + @Test + public void createWithAzureAsyncOperation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithAzureAsyncOperation("1", "mine", "c"); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void createWithAzureAsyncOperationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithAzureAsyncOperationAndPolls("1", "mine", "c", 2); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(2, httpClient.pollRequests()); + } + + @Test + public void createWithProvisioningState() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithProvisioningState("1", "mine", "c"); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void createWithProvisioningStateAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createWithProvisioningStateAndPolls("1", "mine", "c", 3); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(3, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void createAsync() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsync("1", "mine", "c") + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithLocation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithLocation("1", "mine", "c") + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithLocationAndPolls("1", "mine", "c", 3) + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(3, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithAzureAsyncOperation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithAzureAsyncOperation("1", "mine", "c") + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithAzureAsyncOperationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithAzureAsyncOperationAndPolls("1", "mine", "c", 3) + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(3, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithAzureAsyncOperationAndPollsWithDelay() throws InterruptedException { + final long delayInMilliseconds = 100; + AzureProxy.setDefaultPollingDelayInMilliseconds(delayInMilliseconds); + + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + final int pollsUntilResource = 3; + createMockService(MockResourceService.class, httpClient) + .createAsyncWithAzureAsyncOperationAndPolls("1", "mine", "c", pollsUntilResource) + .subscribe(); + + Thread.sleep((long)(delayInMilliseconds * 0.75)); + + for (int i = 0; i < pollsUntilResource; ++i) { + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(i, httpClient.pollRequests()); + + Thread.sleep(delayInMilliseconds); + } + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(pollsUntilResource, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithProvisioningState() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithProvisioningState("1", "mine", "c") + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void createAsyncWithProvisioningStateAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResource resource = createMockService(MockResourceService.class, httpClient) + .createAsyncWithProvisioningStateAndPolls("1", "mine", "c", 5) + .block(); + assertNotNull(resource); + assertEquals("c", resource.name); + + assertEquals(5, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void beginCreateAsyncWithBadReturnType() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final MockResourceService service = createMockService(MockResourceService.class, httpClient); + try { + service.beginCreateAsyncWithBadReturnType("1", "mine", "c", 2); + fail("Expected exception."); + } + catch (InvalidReturnTypeException e) { + assertContains(e.getMessage(), "AzureProxyTests$MockResourceService.beginCreateAsyncWithBadReturnType()"); + assertContains(e.getMessage(), "reactor.core.publisher.Flux"); + } + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); // Request won't reach HttpClient and fail in AzureProxy + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void beginCreateAsyncWithLocationAndPollsAndUnexpectedStatusCode() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithLocationAndPollsAndUnexpectedStatusCode("1", "mine", "c") + .subscribe( + new Consumer>() { + @Override + public void accept(OperationStatus mockResourceOperationStatus) { + fail(); + } + }, + new Consumer() { + @Override + public void accept(Throwable throwable) { + assertEquals(RestException.class, throwable.getClass()); + assertEquals("Status code 294, (empty body)", throwable.getMessage()); + } + }); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void beginCreateAsyncWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value resource = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithLocationAndPolls("1", "mine", "c", 3) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + assertEquals(2, inProgressCount.get()); + assertNotNull(resource.get()); + assertEquals("c", resource.get().name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(3, httpClient.pollRequests()); + } + + @Test + public void beginAndResumeCreateAsyncWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value resource = new Value<>(); + final StringBuffer data = new StringBuffer(); + final ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + mapper.enableDefaultTyping(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithLocationAndPolls("1", "mine", "c", 10) + .take(2) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + OperationDescription operationDescription = operationStatus.buildDescription(); + try { + data.append(mapper.writeValueAsString(operationDescription)); + } catch (JsonProcessingException e) { + fail("Error serializing OperationDescription object"); + e.printStackTrace(); + } + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + OperationDescription operationDescription = null; + PollStrategy.PollStrategyData pollData = null; + try { + operationDescription = mapper.readValue(data.toString(), OperationDescription.class); + pollData = (PollStrategy.PollStrategyData)operationDescription.pollStrategyData(); + } catch (IOException e) { + fail("Error deserializing OperationDescription object"); + e.printStackTrace(); + } + + assertNotNull(operationDescription); + assertNotNull(pollData); + + createMockService(MockResourceService.class, httpClient) + .resumeCreateAsyncWithLocationAndPolls(operationDescription) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + OperationDescription operationDescription = operationStatus.buildDescription(); + try { + data.append(mapper.writeValueAsString(operationDescription)); + } catch (JsonProcessingException e) { + fail("Error serializing OperationDescription object"); + e.printStackTrace(); + } + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + } + + @Test + public void beginCreateAsyncWithAzureAsyncOperationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value resource = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithAzureAsyncOperationAndPolls("1", "mine", "c", 3) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + assertEquals(3, inProgressCount.get()); + assertNotNull(resource.get()); + assertEquals("c", resource.get().name); + + assertEquals(1, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(3, httpClient.pollRequests()); + } + + @Test + public void beginCreateAsyncWithProvisioningStateAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value resource = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithProvisioningStateAndPolls("1", "mine", "c", 4) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + assertEquals(3, inProgressCount.get()); + assertNotNull(resource.get()); + assertEquals("c", resource.get().name); + + assertEquals(4, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void beginCreateAsyncWithLocationAndPollsWhenPollsUntilResourceIs0() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value resource = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginCreateAsyncWithLocationAndPolls("1", "mine", "c", 0) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + resource.set(operationStatus.result()); + } + } + }); + + assertEquals(0, inProgressCount.get()); + assertNotNull(resource.get()); + assertEquals("c", resource.get().name); + + assertEquals(0, httpClient.getRequests()); + assertEquals(1, httpClient.createRequests()); + assertEquals(0, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void delete() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .delete("1", "mine", "c"); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void deleteWithLocation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .deleteWithLocation("1", "mine", "c"); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void deleteWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .deleteWithLocationAndPolls("1", "mine", "c", 4); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(4, httpClient.pollRequests()); + } + + @Test + public void deleteAsync() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .deleteAsync("1", "mine", "c") + .block(); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void deleteAsyncWithLocation() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .deleteAsyncWithLocation("1", "mine", "c") + .block(); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(1, httpClient.pollRequests()); + } + + @Test + public void deleteAsyncWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + createMockService(MockResourceService.class, httpClient) + .deleteAsyncWithLocationAndPolls("1", "mine", "c", 10) + .block(); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(10, httpClient.pollRequests()); + } + + @Test + public void beginDeleteAsyncWithLocationAndPolls() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value completed = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginDeleteAsyncWithLocationAndPolls("1", "mine", "c", 3) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + completed.set(true); + } + } + }); + + assertEquals(2, inProgressCount.get()); + assertNotNull(completed.get()); + assertTrue(completed.get()); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(3, httpClient.pollRequests()); + } + + @Test + public void beginDeleteAsyncWithLocationAndPollsWhenPollsUntilResourceIs0() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient(); + + final AtomicInteger inProgressCount = new AtomicInteger(); + final Value completed = new Value<>(); + + createMockService(MockResourceService.class, httpClient) + .beginDeleteAsyncWithLocationAndPolls("1", "mine", "c", 0) + .subscribe(new Consumer>() { + @Override + public void accept(OperationStatus operationStatus) { + if (!operationStatus.isDone()) { + inProgressCount.incrementAndGet(); + } + else { + completed.set(true); + } + } + }); + + assertEquals(0, inProgressCount.get()); + assertNotNull(completed.get()); + assertTrue(completed.get()); + + assertEquals(0, httpClient.getRequests()); + assertEquals(0, httpClient.createRequests()); + assertEquals(1, httpClient.deleteRequests()); + assertEquals(0, httpClient.pollRequests()); + } + + @Test + public void deleteAsyncWithForbiddenResponse() { + final MockAzureHttpClient httpClient = new MockAzureHttpClient() { + @Override + public Mono send(HttpRequest request) { + return Mono.just(new MockAzureHttpResponse(request, 403, MockAzureHttpClient.responseHeaders())); + } + }; + + final MockResourceService service = createMockService(MockResourceService.class, httpClient); + try { + service.deleteAsyncWithForbiddenResponse().block(); + fail("Expected RestException to be thrown."); + } + catch (RestException e) { + assertEquals(403, e.response().statusCode()); + assertEquals("Status code 403, (empty body)", e.getMessage()); + } + } + + private static T createMockService(Class serviceClass, MockAzureHttpClient httpClient) { + HttpPipeline pipeline = new HttpPipeline(httpClient); + + return AzureProxy.create(serviceClass, null, pipeline, serializer); + } + + private static void assertContains(String value, String expectedSubstring) { + assertTrue("Expected \"" + value + "\" to contain \"" + expectedSubstring + "\".", value.contains(expectedSubstring)); + } + + private static final SerializerAdapter serializer = new JacksonAdapter(); +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyTests.java new file mode 100644 index 0000000000000..e5ef41e04cccc --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyTests.java @@ -0,0 +1,768 @@ +package com.azure.common.mgmt; + +import com.azure.common.implementation.exception.InvalidReturnTypeException; +import com.azure.common.implementation.http.ContentType; +import com.azure.common.http.rest.RestException; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.http.HttpPipeline; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import com.azure.common.annotations.BodyParam; +import com.azure.common.annotations.DELETE; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HEAD; +import com.azure.common.annotations.HeaderParam; +import com.azure.common.annotations.Headers; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.HostParam; +import com.azure.common.annotations.PATCH; +import com.azure.common.annotations.POST; +import com.azure.common.annotations.PUT; +import com.azure.common.annotations.PathParam; +import com.azure.common.annotations.QueryParam; +import com.azure.common.annotations.UnexpectedResponseExceptionType; +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpHeaders; +import org.junit.Assert; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public abstract class AzureProxyToRestProxyTests { + /** + * Get the HTTP client that will be used for each test. This will be called once per test. + * @return The HTTP client to use for each test. + */ + protected abstract HttpClient createHttpClient(); + + @Host("http://httpbin.org") + private interface Service1 { + @GET("bytes/100") + @ExpectedResponses({200}) + byte[] getByteArray(); + + @GET("bytes/100") + @ExpectedResponses({200}) + Mono getByteArrayAsync(); + + @GET("bytes/100") + Mono getByteArrayAsyncWithNoExpectedResponses(); + } + + @Test + public void SyncRequestWithByteArrayReturnType() { + final byte[] result = createService(Service1.class) + .getByteArray(); + assertNotNull(result); + assertEquals(result.length, 100); + } + + @Test + public void AsyncRequestWithByteArrayReturnType() { + final byte[] result = createService(Service1.class) + .getByteArrayAsync() + .block(); + assertNotNull(result); + assertEquals(result.length, 100); + } + + @Test + public void getByteArrayAsyncWithNoExpectedResponses() { + final byte[] result = createService(Service1.class) + .getByteArrayAsyncWithNoExpectedResponses() + .block(); + assertNotNull(result); + assertEquals(result.length, 100); + } + + @Host("http://{hostName}.org") + private interface Service2 { + @GET("bytes/{numberOfBytes}") + @ExpectedResponses({200}) + byte[] getByteArray(@HostParam("hostName") String host, @PathParam("numberOfBytes") int numberOfBytes); + + @GET("bytes/{numberOfBytes}") + @ExpectedResponses({200}) + Mono getByteArrayAsync(@HostParam("hostName") String host, @PathParam("numberOfBytes") int numberOfBytes); + } + + @Test + public void SyncRequestWithByteArrayReturnTypeAndParameterizedHostAndPath() { + final byte[] result = createService(Service2.class) + .getByteArray("httpbin", 50); + assertNotNull(result); + assertEquals(result.length, 50); + } + + @Test + public void AsyncRequestWithByteArrayReturnTypeAndParameterizedHostAndPath() { + final byte[] result = createService(Service2.class) + .getByteArrayAsync("httpbin", 50) + .block(); + assertNotNull(result); + assertEquals(result.length, 50); + } + + @Host("http://httpbin.org") + private interface Service3 { + @GET("bytes/2") + @ExpectedResponses({200}) + void getNothing(); + + @GET("bytes/2") + @ExpectedResponses({200}) + Mono getNothingAsync(); + } + + @Test + public void SyncGetRequestWithNoReturn() { + createService(Service3.class).getNothing(); + } + + @Test + public void AsyncGetRequestWithNoReturn() { + createService(Service3.class) + .getNothingAsync() + .block(); + } + + @Host("http://httpbin.org") + private interface Service5 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(); + + @GET("anything/with+plus") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithPlus(); + + @GET("anything/{path}") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithPathParam(@PathParam("path") String pathParam); + + @GET("anything/{path}") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithEncodedPathParam(@PathParam(value="path", encoded=true) String pathParam); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(); + } + + @Test + public void SyncGetRequestWithAnything() { + final HttpBinJSON json = createService(Service5.class) + .getAnything(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPlus(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+plus", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParam() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("withpathparam"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/withpathparam", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParamWithSpace() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("with path param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with path param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParamWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("with+path+param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+path+param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParam() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("withpathparam"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/withpathparam", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParamWithPercent20() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("with%20path%20param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with path param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParamWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("with+path+param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+path+param", json.url); + } + + @Test + public void AsyncGetRequestWithAnything() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + } + + @Host("http://httpbin.org") + private interface Service6 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(@QueryParam("a") String a, @QueryParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithEncoded(@QueryParam(value="a", encoded=true) String a, @QueryParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(@QueryParam("a") String a, @QueryParam("b") int b); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnything() { + final HttpBinJSON json = createService(Service6.class) + .getAnything("A", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A&b=15", json.url); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnythingWithPercent20() { + final HttpBinJSON json = createService(Service6.class) + .getAnything("A%20Z", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A%2520Z&b=15", json.url); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnythingWithEncodedWithPercent20() { + final HttpBinJSON json = createService(Service6.class) + .getAnythingWithEncoded("x%20y", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=x y&b=15", json.url); + } + + @Test + public void AsyncGetRequestWithQueryParametersAndAnything() { + final HttpBinJSON json = createService(Service6.class) + .getAnythingAsync("A", 15) + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A&b=15", json.url); + } + + @Host("http://httpbin.org") + private interface Service7 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(@HeaderParam("a") String a, @HeaderParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(@HeaderParam("a") String a, @HeaderParam("b") int b); + } + + @Test + public void SyncGetRequestWithHeaderParametersAndAnythingReturn() { + final HttpBinJSON json = createService(Service7.class) + .getAnything("A", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("A", headers.value("A")); + assertArrayEquals(new String[]{"A"}, headers.values("A")); + assertEquals("15", headers.value("B")); + assertArrayEquals(new String[]{"15"}, headers.values("B")); + } + + @Test + public void AsyncGetRequestWithHeaderParametersAndAnything() { + final HttpBinJSON json = createService(Service7.class) + .getAnythingAsync("A", 15) + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("A", headers.value("A")); + assertArrayEquals(new String[]{"A"}, headers.values("A")); + assertEquals("15", headers.value("B")); + assertArrayEquals(new String[]{"15"}, headers.values("B")); + } + + @Host("http://httpbin.org") + private interface Service8 { + @POST("post") + @ExpectedResponses({200}) + HttpBinJSON post(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String postBody); + + @POST("post") + @ExpectedResponses({200}) + Mono postAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String postBody); + } + + @Test + public void SyncPostRequestWithStringBody() { + final HttpBinJSON json = createService(Service8.class) + .post("I'm a post body!"); + assertEquals(String.class, json.data.getClass()); + assertEquals("I'm a post body!", (String)json.data); + } + + @Test + public void AsyncPostRequestWithStringBody() { + final HttpBinJSON json = createService(Service8.class) + .postAsync("I'm a post body!") + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("I'm a post body!", (String)json.data); + } + + @Host("http://httpbin.org") + private interface Service9 { + @PUT("put") + @ExpectedResponses({200}) + HttpBinJSON put(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) int putBody); + + @PUT("put") + @ExpectedResponses({200}) + Mono putAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) int putBody); + + @PUT("put") + @ExpectedResponses({201}) + HttpBinJSON putWithUnexpectedResponse(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + + @PUT("put") + @ExpectedResponses({201}) + @UnexpectedResponseExceptionType(MyAzureException.class) + HttpBinJSON putWithUnexpectedResponseAndExceptionType(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + } + + @Test + public void SyncPutRequestWithIntBody() { + final HttpBinJSON json = createService(Service9.class) + .put(42); + assertEquals(String.class, json.data.getClass()); + assertEquals("42", (String)json.data); + } + + @Test + public void AsyncPutRequestWithIntBody() { + final HttpBinJSON json = createService(Service9.class) + .putAsync(42) + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("42", (String)json.data); + } + + @Test + public void SyncPutRequestWithUnexpectedResponse() { + try { + createService(Service9.class) + .putWithUnexpectedResponse("I'm the body!"); + fail("Expected RestException would be thrown."); + } catch (RestException e) { + assertNotNull(e.body()); + assertTrue(e.body() instanceof LinkedHashMap); + + final LinkedHashMap expectedBody = (LinkedHashMap)e.body(); + assertEquals("I'm the body!", expectedBody.get("data")); + } + } + + @Test + public void SyncPutRequestWithUnexpectedResponseAndExceptionType() { + try { + createService(Service9.class) + .putWithUnexpectedResponseAndExceptionType("I'm the body!"); + fail("Expected RestException would be thrown."); + } catch (MyAzureException e) { + assertNotNull(e.body()); + assertEquals("I'm the body!", e.body().data); + } catch (Throwable e) { + fail("Throwable of wrong type thrown."); + } + } + + @Host("http://httpbin.org") + private interface Service10 { + @HEAD("anything") + @ExpectedResponses({200}) + RestResponseBase restResponseHead(); + + + @HEAD("anything") + @ExpectedResponses({200}) + void voidHead(); + + @HEAD("anything") + @ExpectedResponses({200}) + Mono> restResponseHeadAsync(); + + @HEAD("anything") + @ExpectedResponses({200}) + Mono completableHeadAsync(); + } + + @Test + public void SyncRestResponseHeadRequest() { + RestResponseBase res = createService(Service10.class) + .restResponseHead(); + assertNull(res.body()); + } + + @Test + public void SyncVoidHeadRequest() { + createService(Service10.class) + .voidHead(); + } + + @Test + public void AsyncRestResponseHeadRequest() { + RestResponseBase res = createService(Service10.class) + .restResponseHeadAsync() + .block(); + + assertNull(res.body()); + } + + @Test + public void AsyncCompletableHeadRequest() { + createService(Service10.class) + .completableHeadAsync() + .block(); + } + + @Host("http://httpbin.org") + private interface Service11 { + @DELETE("delete") + @ExpectedResponses({200}) + HttpBinJSON delete(@BodyParam(ContentType.APPLICATION_JSON) boolean bodyBoolean); + + @DELETE("delete") + @ExpectedResponses({200}) + Mono deleteAsync(@BodyParam(ContentType.APPLICATION_JSON) boolean bodyBoolean); + } + + @Test + public void SyncDeleteRequest() { + final HttpBinJSON json = createService(Service11.class) + .delete(false); + assertEquals(String.class, json.data.getClass()); + assertEquals("false", (String)json.data); + } + + @Test + public void AsyncDeleteRequest() { + final HttpBinJSON json = createService(Service11.class) + .deleteAsync(false) + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("false", (String)json.data); + } + + @Host("http://httpbin.org") + private interface Service12 { + @PATCH("patch") + @ExpectedResponses({200}) + HttpBinJSON patch(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String bodyString); + + @PATCH("patch") + @ExpectedResponses({200}) + Mono patchAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String bodyString); + } + + @Test + public void SyncPatchRequest() { + final HttpBinJSON json = createService(Service12.class) + .patch("body-contents"); + assertEquals(String.class, json.data.getClass()); + assertEquals("body-contents", (String)json.data); + } + + @Test + public void AsyncPatchRequest() { + final HttpBinJSON json = createService(Service12.class) + .patchAsync("body-contents") + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("body-contents", (String)json.data); + } + + @Host("http://httpbin.org") + private interface Service13 { + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue", "MyOtherHeader:My,Header,Value" }) + HttpBinJSON get(); + + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue", "MyOtherHeader:My,Header,Value" }) + Mono getAsync(); + } + + @Test + public void SyncHeadersRequest() { + final HttpBinJSON json = createService(Service13.class) + .get(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + assertArrayEquals(new String[]{"MyHeaderValue"}, headers.values("MyHeader")); + assertEquals("My,Header,Value", headers.value("MyOtherHeader")); + assertArrayEquals(new String[]{"My", "Header", "Value"}, headers.values("MyOtherHeader")); + } + + @Test + public void AsyncHeadersRequest() { + final HttpBinJSON json = createService(Service13.class) + .getAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + assertArrayEquals(new String[]{"MyHeaderValue"}, headers.values("MyHeader")); + } + + @Host("https://httpbin.org") + private interface Service14 { + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue" }) + HttpBinJSON get(); + + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue" }) + Mono getAsync(); + } + + @Test + public void AsyncHttpsHeadersRequest() { + final HttpBinJSON json = createService(Service14.class) + .getAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + } + + @Host("https://httpbin.org") + private interface Service15 { + @GET("anything") + @ExpectedResponses({200}) + Flux get(); + } + + @Test + public void service15Get() { + final Service15 service = createService(Service15.class); + try { + service.get(); + fail("Expected exception."); + } + catch (InvalidReturnTypeException e) { + assertContains(e.getMessage(), "reactor.core.publisher.Flux"); + assertContains(e.getMessage(), "AzureProxyToRestProxyTests$Service15.get()"); + } + } + + @Host("http://httpbin.org") + private interface Service16 { + @PUT("put") + @ExpectedResponses({200}) + HttpBinJSON put(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] putBody); + + @PUT("put") + @ExpectedResponses({200}) + Mono putAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] putBody); + } + + @Test + public void service16Put() { + final Service16 service = createService(Service16.class); + final HttpBinJSON result = service.put(new byte[] { 0, 1, 2, 3, 4, 5 }); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/put", result.url); + assertTrue(result.data instanceof String); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5 }, ((String)result.data).getBytes()); + } + + @Test + public void service16PutAsync() { + final Service16 service = createService(Service16.class); + final HttpBinJSON result = service.putAsync(new byte[] { 0, 1, 2, 3, 4, 5 }) + .block(); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/put", result.url); + assertTrue(result.data instanceof String); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5 }, ((String)result.data).getBytes()); + } + + @Host("http://{hostPart1}{hostPart2}.org") + private interface Service17 { + @GET("get") + @ExpectedResponses({200}) + HttpBinJSON get(@HostParam("hostPart1") String hostPart1, @HostParam("hostPart2") String hostPart2); + + @GET("get") + @ExpectedResponses({200}) + Mono getAsync(@HostParam("hostPart1") String hostPart1, @HostParam("hostPart2") String hostPart2); + } + + @Test + public void SyncRequestWithMultipleHostParams() { + final Service17 service17 = createService(Service17.class); + final HttpBinJSON result = service17.get("http", "bin"); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/get", result.url); + } + + @Test + public void AsyncRequestWithMultipleHostParams() { + final Service17 service17 = createService(Service17.class); + final HttpBinJSON result = service17.getAsync("http", "bin").block(); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/get", result.url); + } + + @Host("https://httpbin.org") + private interface Service18 { + @GET("status/200") + void getStatus200(); + + @GET("status/200") + @ExpectedResponses({200}) + void getStatus200WithExpectedResponse200(); + + @GET("status/300") + void getStatus300(); + + @GET("status/300") + @ExpectedResponses({300}) + void getStatus300WithExpectedResponse300(); + + @GET("status/400") + void getStatus400(); + + @GET("status/400") + @ExpectedResponses({400}) + void getStatus400WithExpectedResponse400(); + + @GET("status/500") + void getStatus500(); + + @GET("status/500") + @ExpectedResponses({500}) + void getStatus500WithExpectedResponse500(); + } + + @Test + public void service18GetStatus200() { + createService(Service18.class) + .getStatus200(); + } + + @Test + public void service18GetStatus200WithExpectedResponse200() { + createService(Service18.class) + .getStatus200WithExpectedResponse200(); + } + + @Test + public void service18GetStatus300() { + createService(Service18.class) + .getStatus300(); + } + + @Test + public void service18GetStatus300WithExpectedResponse300() { + createService(Service18.class) + .getStatus300WithExpectedResponse300(); + } + + @Test(expected = RestException.class) + public void service18GetStatus400() { + createService(Service18.class) + .getStatus400(); + } + + @Test + public void service18GetStatus400WithExpectedResponse400() { + createService(Service18.class) + .getStatus400WithExpectedResponse400(); + } + + @Test(expected = RestException.class) + public void service18GetStatus500() { + createService(Service18.class) + .getStatus500(); + } + + @Test + public void service18GetStatus500WithExpectedResponse500() { + createService(Service18.class) + .getStatus500WithExpectedResponse500(); + } + + private T createService(Class serviceClass) { + HttpPipeline pipeline = new HttpPipeline(createHttpClient()); + // + return AzureProxy.create(serviceClass, null, pipeline, serializer); + } + + private static void assertContains(String value, String expectedSubstring) { + assertTrue("Expected \"" + value + "\" to contain \"" + expectedSubstring + "\".", value.contains(expectedSubstring)); + } + + private static void assertMatchWithHttpOrHttps(String url1, String url2) { + final String s1 = "http://" + url1; + if (s1.equalsIgnoreCase(url2)) { + return; + } + final String s2 = "https://" + url1; + if (s2.equalsIgnoreCase(url2)) { + return; + } + Assert.assertTrue("'" + url2 + "' does not match with '" + s1 + "' or '" + s2 + "'." , false); + } + + private static final SerializerAdapter serializer = new JacksonAdapter(); +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithMockTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithMockTests.java new file mode 100644 index 0000000000000..541c228f45665 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithMockTests.java @@ -0,0 +1,11 @@ +package com.azure.common.mgmt; + +import com.azure.common.mgmt.http.MockAzureHttpClient; +import com.azure.common.http.HttpClient; + +public class AzureProxyToRestProxyWithMockTests extends AzureProxyToRestProxyTests { + @Override + protected HttpClient createHttpClient() { + return new MockAzureHttpClient(); + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithNettyTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithNettyTests.java new file mode 100644 index 0000000000000..77bbc35399102 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureProxyToRestProxyWithNettyTests.java @@ -0,0 +1,11 @@ +package com.azure.common.mgmt; + +import com.azure.common.http.HttpClient; + +public class AzureProxyToRestProxyWithNettyTests extends AzureProxyToRestProxyTests { + + @Override + protected HttpClient createHttpClient() { + return HttpClient.createDefault(); + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureTests.java new file mode 100644 index 0000000000000..5e11515972dcb --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/AzureTests.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import com.azure.common.mgmt.annotations.AzureHost; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HostParam; +import com.azure.common.annotations.PathParam; + +public class AzureTests { + + @AzureHost("{vaultBaseUrl}") + public interface HttpBinService { + @GET("secrets/{secretName}") + String getSecret(@HostParam("vaultBaseUrl") String vaultBaseUrl, @PathParam("secretName") String secretName); + } + +// @AzureHost not yet supported. +// @Test +// public void getBytes() throws Exception { +// RestClient client = RestClient.newDefaultBuilder() +// .withBaseUrl("http://localhost") +// .withResponseBuilderFactory(new ServiceResponseBuilder.Factory()) +// .build(); +// HttpBinService service = RestProxy.create(HttpBinService.class, client); +// +// Assert.assertEquals("http://vault1.vault.azure.net/secrets/{secretName}", service.getSecret("http://vault1.vault.azure.net", "secret1")); +// } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/HttpBinJSON.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/HttpBinJSON.java new file mode 100644 index 0000000000000..03a6374750427 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/HttpBinJSON.java @@ -0,0 +1,12 @@ +package com.azure.common.mgmt; + +import java.util.Map; + +/** + * Maps to the JSON return values from http://httpbin.org. + */ +public class HttpBinJSON { + public String url; + public Map headers; + public Object data; +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MockResource.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MockResource.java new file mode 100644 index 0000000000000..b1b7aac382e21 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MockResource.java @@ -0,0 +1,11 @@ +package com.azure.common.mgmt; + +public class MockResource { + public String name; + + public Properties properties; + + public static class Properties { + public String provisioningState; + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MyAzureException.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MyAzureException.java new file mode 100644 index 0000000000000..3c09f711f5a97 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/MyAzureException.java @@ -0,0 +1,15 @@ +package com.azure.common.mgmt; + +import com.azure.common.http.rest.RestException; +import com.azure.common.http.HttpResponse; + +public class MyAzureException extends RestException { + public MyAzureException(String message, HttpResponse response, HttpBinJSON body) { + super(message, response, body); + } + + @Override + public HttpBinJSON body() { + return (HttpBinJSON) super.body(); + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/PagedListTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/PagedListTests.java new file mode 100644 index 0000000000000..f9252d27b70b6 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/PagedListTests.java @@ -0,0 +1,343 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.ListIterator; +import java.util.function.Supplier; + +public class PagedListTests { + private PagedList list; + + @Before + public void setupList() { + list = new PagedList(new TestPage(0, 21)) { + @Override + public Page nextPage(String nextPageLink) { + int pageNum = Integer.parseInt(nextPageLink); + return new TestPage(pageNum, 21); + } + }; + } + + @Test + public void sizeTest() { + Assert.assertEquals(20, list.size()); + } + + @Test + public void getTest() { + Assert.assertEquals(15, (int) list.get(15)); + } + + @Test + public void iterateTest() { + int j = 0; + for (int i : list) { + Assert.assertEquals(i, j++); + } + } + + @Test + public void removeTest() { + Integer i = list.get(10); + list.remove(10); + Assert.assertEquals(19, list.size()); + Assert.assertEquals(19, (int) list.get(18)); + } + + @Test + public void addTest() { + Integer i = list.get(10); + list.add(100); + Assert.assertEquals(21, list.size()); + Assert.assertEquals(100, (int) list.get(11)); + Assert.assertEquals(19, (int) list.get(20)); + } + + @Test + public void containsTest() { + Assert.assertTrue(list.contains(0)); + Assert.assertTrue(list.contains(3)); + Assert.assertTrue(list.contains(19)); + Assert.assertFalse(list.contains(20)); + } + + @Test + public void containsAllTest() { + List subList = new ArrayList<>(); + subList.addAll(Arrays.asList(0, 3, 19)); + Assert.assertTrue(list.containsAll(subList)); + subList.add(20); + Assert.assertFalse(list.containsAll(subList)); + } + + @Test + public void subListTest() { + List subList = list.subList(5, 15); + Assert.assertEquals(10, subList.size()); + Assert.assertTrue(list.containsAll(subList)); + Assert.assertEquals(7, (int) subList.get(2)); + } + + @Test + public void testIndexOf() { + Assert.assertEquals(15, list.indexOf(15)); + } + + @Test + public void testLastIndexOf() { + Assert.assertEquals(15, list.lastIndexOf(15)); + } + + + @Test + public void testIteratorWithListSizeInvocation() { + ListIterator itr = list.listIterator(); + list.size(); + int j = 0; + while (itr.hasNext()) { + Assert.assertEquals(j++, (long) itr.next()); + } + } + + @Test + public void testIteratorPartsWithSizeInvocation() { + ListIterator itr = list.listIterator(); + int j = 0; + while (j < 5) { + Assert.assertTrue(itr.hasNext()); + Assert.assertEquals(j++, (long) itr.next()); + } + list.size(); + while (j < 10) { + Assert.assertTrue(itr.hasNext()); + Assert.assertEquals(j++, (long) itr.next()); + } + } + + @Test + public void testIteratorWithLoadNextPageInvocation() { + ListIterator itr = list.listIterator(); + int j = 0; + while (j < 5) { + Assert.assertTrue(itr.hasNext()); + Assert.assertEquals(j++, (long) itr.next()); + } + list.loadNextPage(); + while (j < 10) { + Assert.assertTrue(itr.hasNext()); + Assert.assertEquals(j++, (long) itr.next()); + } + list.loadNextPage(); + while (itr.hasNext()) { + Assert.assertEquals(j++, (long) itr.next()); + } + Assert.assertEquals(20, j); + } + + @Test + public void testIteratorOperations() { + ListIterator itr1 = list.listIterator(); + IllegalStateException expectedException = null; + try { + itr1.remove(); + } catch (IllegalStateException ex) { + expectedException = ex; + } + Assert.assertNotNull(expectedException); + + ListIterator itr2 = list.listIterator(); + Assert.assertTrue(itr2.hasNext()); + Assert.assertEquals(0, (long) itr2.next()); + itr2.remove(); + Assert.assertTrue(itr2.hasNext()); + Assert.assertEquals(1, (long) itr2.next()); + + itr2.set(100); + Assert.assertTrue(itr2.hasPrevious()); + Assert.assertEquals(100, (long) itr2.previous()); + Assert.assertTrue(itr2.hasNext()); + Assert.assertEquals(100, (long) itr2.next()); + } + + @Test + public void testAddViaIteratorWhileIterating() { + ListIterator itr1 = list.listIterator(); + while (itr1.hasNext()) { + Integer val = itr1.next(); + if (val < 10) { + itr1.add(99); + } + } + Assert.assertEquals(30, list.size()); + } + + @Test + public void testRemoveViaIteratorWhileIterating() { + ListIterator itr1 = list.listIterator(); + while (itr1.hasNext()) { + itr1.next(); + itr1.remove(); + } + Assert.assertEquals(0, list.size()); + } + + @Test + public void canHandleIntermediateEmptyPage() { + List pagedList = new PagedList(new Page() { + @Override + public String nextPageLink() { + return "A"; + } + + @Override + public List items() { + List list = new ArrayList<>(); + list.add(1); + list.add(2); + return list; + } + }) { + @Override + public Page nextPage(String nextPageLink) { + if (nextPageLink == "A") { + return new Page() { + @Override + public String nextPageLink() { + return "B"; + } + + @Override + public List items() { + return new ArrayList<>(); // EMPTY PAGE + } + }; + } else if (nextPageLink == "B") { + return new Page() { + @Override + public String nextPageLink() { + return "C"; + } + + @Override + public List items() { + List list = new ArrayList<>(); + list.add(3); + list.add(4); + return list; + } + }; + } else if (nextPageLink == "C") { + return new Page() { + @Override + public String nextPageLink() { + return null; + } + + @Override + public List items() { + List list = new ArrayList<>(); + list.add(5); + list.add(6); + return list; + } + }; + } + throw new RuntimeException("nextPage should not be called after a page with next link as null"); + } + }; + ListIterator itr = pagedList.listIterator(); + int c = 1; + while (itr.hasNext()) { + Assert.assertEquals(c, (int) itr.next()); + c++; + } + Assert.assertEquals(7, c); + } + + @Test + public void canCreateFluxFromPagedList() { + // Test lazy flux can be created by ensuring loadNextPage invoked lazily + // + class FluxFromPagedList { + int loadNextPageCallCount; + + Flux toFlux() { + return firstFlux().concatWith(nextFlux()); + } + + Flux firstFlux() { + return Flux.defer((Supplier>) () -> Flux.fromIterable(list.currentPage().items())); + } + + Flux nextFlux() { + return Flux.defer((Supplier>) () -> { + if (list.hasNextPage()) { + list.loadNextPage(); + loadNextPageCallCount++; + return Flux.fromIterable(list.currentPage().items()).concatWith(Flux.defer(new Supplier>() { + @Override + public Flux get() { + return nextFlux(); + } + })); + } else { + return Flux.empty(); + } + }); + } + } + + FluxFromPagedList obpl = new FluxFromPagedList(); + + final Integer[] cnt = new Integer[] { 0 }; + obpl.toFlux().subscribe(integer -> { + Assert.assertEquals(cnt[0], integer); + cnt[0]++; + }); + Assert.assertEquals(20, (long) cnt[0]); + Assert.assertEquals(19, obpl.loadNextPageCallCount); + } + + + public static class TestPage implements Page { + private int page; + private int max; + + public TestPage(int page, int max) { + this.page = page; + this.max = max; + } + + @Override + public String nextPageLink() { + if (page + 1 == max) { + return null; + } + return Integer.toString(page + 1); + } + + @Override + public List items() { + if (page + 1 != max) { + List items = new ArrayList<>(); + items.add(page); + return items; + } else { + return new ArrayList<>(); + } + } + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/Value.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/Value.java new file mode 100644 index 0000000000000..679e510a38b63 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/Value.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt; + +/** + * A container for a generic type. Serves a similar purpose as pointers in C/C++. It's a workaround + * for the fact that Java doesn't allow mutation of local variables in closure. + * @param + */ +class Value { + private T value; + + /** + * Create a new Value with inner value. + */ + Value() { + } + + /** + * Create a new Value with the provided inner value. + * @param value + */ + Value(T value) { + set(value); + } + + /** + * Get the inner value of this Value. + * @return The inner value of this Value. + */ + public T get() { + return value; + } + + /** + * Set the inner value of this Value. + * @param value The new inner value of this Value. + */ + public void set(T value) { + this.value = value; + } + + @Override + public String toString() { + return String.valueOf(value); + } +} \ No newline at end of file diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/ValueTests.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/ValueTests.java new file mode 100644 index 0000000000000..758272207c324 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/ValueTests.java @@ -0,0 +1,21 @@ +package com.azure.common.mgmt; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ValueTests { + @Test + public void constructorWithNoArguments() { + final Value v = new Value<>(); + assertNull(v.get()); + assertEquals("null", v.toString()); + } + + @Test + public void constructorWithArgument() { + final Value v = new Value<>(20); + assertEquals(20, v.get().intValue()); + assertEquals("20", v.toString()); + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpClient.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpClient.java new file mode 100644 index 0000000000000..a444c34e0b88b --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpClient.java @@ -0,0 +1,337 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.http; + +import com.azure.common.mgmt.AsyncOperationResource; +import com.azure.common.mgmt.AzureAsyncOperationPollStrategy; +import com.azure.common.mgmt.MockResource; +import com.azure.common.mgmt.OperationState; +import com.azure.common.mgmt.HttpBinJSON; +import com.azure.common.mgmt.LocationPollStrategy; +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.ProxyOptions; +import com.azure.common.implementation.util.FluxUtil; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; + +/** + * This HttpClient attempts to mimic the behavior of http://httpbin.org without ever making a network call. + */ +public class MockAzureHttpClient implements HttpClient { + private int pollsRemaining; + + private int getRequests; + private int createRequests; + private int deleteRequests; + private int pollRequests; + + public int getRequests() { + return getRequests; + } + + public int createRequests() { + return createRequests; + } + + public int deleteRequests() { + return deleteRequests; + } + + public int pollRequests() { + return pollRequests; + } + + @Override + public Mono send(HttpRequest request) { + MockAzureHttpResponse response = null; + + try { + final URL requestUrl = request.url(); + final String requestHost = requestUrl.getHost(); + final String requestPath = requestUrl.getPath(); + final String requestPathLower = requestPath.toLowerCase(); + if (requestHost.equalsIgnoreCase("httpbin.org")) { + if (requestPathLower.equals("/anything") || requestPathLower.startsWith("/anything/")) { + if ("HEAD".equals(request.httpMethod())) { + response = new MockAzureHttpResponse(request, 200, responseHeaders(), ""); + } else { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString() + // This is just to mimic the behavior we've seen with httpbin.org. + .replace("%20", " "); + json.headers = toMap(request.headers()); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + } + else if (requestPathLower.startsWith("/bytes/")) { + final String byteCountString = requestPath.substring("/bytes/".length()); + final int byteCount = Integer.parseInt(byteCountString); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), new byte[byteCount]); + } + else if (requestPathLower.equals("/delete")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = bodyToString(request); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + else if (requestPathLower.equals("/get")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.headers = toMap(request.headers()); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + else if (requestPathLower.equals("/patch")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = bodyToString(request); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + else if (requestPathLower.equals("/post")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = bodyToString(request); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + else if (requestPathLower.equals("/put")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = bodyToString(request); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), json); + } + else if (requestPathLower.startsWith("/status/")) { + final String statusCodeString = requestPathLower.substring("/status/".length()); + final int statusCode = Integer.valueOf(statusCodeString); + response = new MockAzureHttpResponse(request, statusCode, responseHeaders()); + } + } + else if (requestHost.equalsIgnoreCase("mock.azure.com")) { + if (request.httpMethod() == HttpMethod.GET) { + if (requestPathLower.contains("/mockprovider/mockresources/")) { + ++getRequests; + --pollsRemaining; + + final MockResource resource = new MockResource(); + resource.name = requestPath.substring(requestPath.lastIndexOf('/') + 1); + resource.properties = new MockResource.Properties(); + resource.properties.provisioningState = (pollsRemaining <= 0 ? OperationState.SUCCEEDED : OperationState.IN_PROGRESS); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), resource); + } + else if (requestPathLower.contains("/mockprovider/mockoperations/")) { + ++pollRequests; + + final Map requestQueryMap = queryToMap(requestUrl.getQuery()); + + final String pollType = requestQueryMap.get("PollType"); + + if (pollType.equalsIgnoreCase(AzureAsyncOperationPollStrategy.HEADER_NAME)) { + String operationStatus; + if (pollsRemaining <= 1) { + operationStatus = OperationState.SUCCEEDED; + } + else { + --pollsRemaining; + operationStatus = OperationState.IN_PROGRESS; + } + final AsyncOperationResource operationResource = new AsyncOperationResource(); + operationResource.setStatus(operationStatus); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), operationResource); + } + else if (pollType.equalsIgnoreCase(LocationPollStrategy.HEADER_NAME)) { + if (pollsRemaining <= 1) { + final MockResource mockResource = new MockResource(); + mockResource.name = "c"; + mockResource.properties = new MockResource.Properties(); + mockResource.properties.provisioningState = OperationState.SUCCEEDED; + response = new MockAzureHttpResponse(request, 200, responseHeaders(), mockResource); + } + else { + --pollsRemaining; + response = new MockAzureHttpResponse(request, 202, responseHeaders()) + .withHeader(LocationPollStrategy.HEADER_NAME, request.url().toString()); + } + } + } + } + else if (request.httpMethod() == HttpMethod.PUT) { + ++createRequests; + + final Map requestQueryMap = queryToMap(requestUrl.getQuery()); + + final String pollType = requestQueryMap.get("PollType"); + String pollsRemainingString = requestQueryMap.get("PollsRemaining"); + + if (pollType == null || "0".equals(pollsRemainingString)) { + final MockResource resource = new MockResource(); + resource.name = "c"; + resource.properties = new MockResource.Properties(); + resource.properties.provisioningState = OperationState.SUCCEEDED; + response = new MockAzureHttpResponse(request, 200, responseHeaders(), resource); + } + else if (pollType.equalsIgnoreCase("ProvisioningState")) { + + if (pollsRemainingString == null) { + pollsRemaining = 1; + } + else { + pollsRemaining = Integer.valueOf(pollsRemainingString); + } + + final MockResource resource = new MockResource(); + resource.name = "c"; + resource.properties = new MockResource.Properties(); + resource.properties.provisioningState = (pollsRemaining <= 0 ? OperationState.SUCCEEDED : OperationState.IN_PROGRESS); + response = new MockAzureHttpResponse(request, 200, responseHeaders(), resource); + } + else { + if (pollsRemainingString == null) { + pollsRemaining = 1; + } + else { + pollsRemaining = Integer.valueOf(pollsRemainingString); + } + + final String initialResponseStatusCodeString = requestQueryMap.get("InitialResponseStatusCode"); + int initialResponseStatusCode; + if (initialResponseStatusCodeString != null) { + initialResponseStatusCode = Integer.valueOf(initialResponseStatusCodeString); + } + else if (pollType.equalsIgnoreCase(LocationPollStrategy.HEADER_NAME)) { + initialResponseStatusCode = 202; + } + else { + initialResponseStatusCode = 201; + } + + response = new MockAzureHttpResponse(request, initialResponseStatusCode, responseHeaders()); + + final String pollUrl = "https://mock.azure.com/subscriptions/1/resourceGroups/mine/providers/mockprovider/mockoperations/1"; + if (pollType.contains(AzureAsyncOperationPollStrategy.HEADER_NAME)) { + response.withHeader(AzureAsyncOperationPollStrategy.HEADER_NAME, pollUrl + "?PollType=" + AzureAsyncOperationPollStrategy.HEADER_NAME); + } + if (pollType.contains(LocationPollStrategy.HEADER_NAME)) { + response.withHeader(LocationPollStrategy.HEADER_NAME, pollUrl + "?PollType=" + LocationPollStrategy.HEADER_NAME); + } + } + } + else if (request.httpMethod() == HttpMethod.DELETE) { + ++deleteRequests; + + final Map requestQueryMap = queryToMap(requestUrl.getQuery()); + + final String pollType = requestQueryMap.get("PollType"); + String pollsRemainingString = requestQueryMap.get("PollsRemaining"); + + if (pollType == null || "0".equals(pollsRemainingString)) { + response = new MockAzureHttpResponse(request, 200, responseHeaders()); + } + else if (pollType.equals(LocationPollStrategy.HEADER_NAME)) { + if (pollsRemainingString == null) { + pollsRemaining = 1; + } + else { + pollsRemaining = Integer.valueOf(pollsRemainingString); + } + + final String initialResponseStatusCodeString = requestQueryMap.get("InitialResponseStatusCode"); + int initialResponseStatusCode; + if (initialResponseStatusCodeString != null) { + initialResponseStatusCode = Integer.valueOf(initialResponseStatusCodeString); + } + else if (pollType.equalsIgnoreCase(LocationPollStrategy.HEADER_NAME)) { + initialResponseStatusCode = 202; + } + else { + initialResponseStatusCode = 201; + } + + response = new MockAzureHttpResponse(request, initialResponseStatusCode, responseHeaders()); + + final String pollUrl = "https://mock.azure.com/subscriptions/1/resourceGroups/mine/providers/mockprovider/mockoperations/1"; + if (pollType.contains(AzureAsyncOperationPollStrategy.HEADER_NAME)) { + response.withHeader(AzureAsyncOperationPollStrategy.HEADER_NAME, pollUrl + "?PollType=" + AzureAsyncOperationPollStrategy.HEADER_NAME); + } + if (pollType.contains(LocationPollStrategy.HEADER_NAME)) { + response.withHeader(LocationPollStrategy.HEADER_NAME, pollUrl + "?PollType=" + LocationPollStrategy.HEADER_NAME); + } + } + } + } + } + catch (Exception ignored) { + } + + return Mono.just(response); + } + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + + private static Map queryToMap(String url) { + final Map result = new HashMap<>(); + + if (url != null) { + final int questionMarkIndex = url.indexOf('?'); + if (questionMarkIndex >= 0) { + url = url.substring(questionMarkIndex + 1); + } + + for (String querySegments : url.split("&")) { + final String[] querySegmentParts = querySegments.split("="); + result.put(querySegmentParts[0], querySegmentParts[1]); + } + } + + return result; + } + + private static String bodyToString(HttpRequest request) throws IOException { + Mono asyncString = FluxUtil.collectBytesInByteBufStream(request.body(), false) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)); + return asyncString.block(); + } + + private static Map toMap(HttpHeaders headers) { + final Map result = new HashMap<>(); + for (final HttpHeader header : headers) { + result.put(header.name(), header.value()); + } + return result; + } + + public static HttpHeaders responseHeaders() { + return new HttpHeaders() + .set("Date", "Fri, 13 Oct 2017 20:33:09 GMT") + .set("Via", "1.1 vegur") + .set("Connection", "keep-alive") + .set("X-Processed-Time", "1.0") + .set("Access-Control-Allow-Credentials", "true") + .set("Content-Type", "application/json"); + } +} diff --git a/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpResponse.java b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpResponse.java new file mode 100644 index 0000000000000..760c1655f1b72 --- /dev/null +++ b/common/azure-common-mgmt/src/test/java/com/azure/common/mgmt/http/MockAzureHttpResponse.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.mgmt.http; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +public class MockAzureHttpResponse extends HttpResponse { + private final static SerializerAdapter serializer = new JacksonAdapter(); + + private final int statusCode; + + private final HttpHeaders headers; + + private final byte[] bodyBytes; + + public MockAzureHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers, byte[] bodyBytes) { + this.headers = headers; + + this.statusCode = statusCode; + this.bodyBytes = bodyBytes; + this.withRequest(request); + } + + public MockAzureHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers) { + this(request, statusCode, headers, new byte[0]); + } + + public MockAzureHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers, String string) { + this(request, statusCode, headers, string == null ? new byte[0] : string.getBytes()); + } + + public MockAzureHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers, Object serializable) { + this(request, statusCode, headers, serialize(serializable)); + } + + private static byte[] serialize(Object serializable) { + byte[] result = null; + try { + final String serializedString = serializer.serialize(serializable, SerializerEncoding.JSON); + result = serializedString == null ? null : serializedString.getBytes(); + } catch (IOException e) { + e.printStackTrace(); + } + return result; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public String headerValue(String name) { + return headers.value(name); + } + + @Override + public HttpHeaders headers() { + return new HttpHeaders(headers); + } + + @Override + public Mono bodyAsByteArray() { + return Mono.just(bodyBytes); + } + + @Override + public Flux body() { + return Flux.just(Unpooled.wrappedBuffer(bodyBytes)); + } + + @Override + public Mono bodyAsString() { + return Mono.just(new String(bodyBytes, StandardCharsets.UTF_8)); + } + + @Override + public Mono bodyAsString(Charset charset) { + return Mono.just(new String(bodyBytes, charset)); + } + + public MockAzureHttpResponse withHeader(String headerName, String headerValue) { + headers.set(headerName, headerValue); + return this; + } +} diff --git a/common/azure-common/pom.xml b/common/azure-common/pom.xml new file mode 100644 index 0000000000000..44555a3e9b182 --- /dev/null +++ b/common/azure-common/pom.xml @@ -0,0 +1,187 @@ + + + 4.0.0 + + com.azure + azure-common-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + com.azure + azure-common + jar + + Azure Java Common Library + This package contains common types for Azure Java clients. + https://github.com/Azure/autorest-clientruntime-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + scm:git:https://github.com/Azure/autorest-clientruntime-for-java + scm:git:git@github.com:Azure/autorest-clientruntime-for-java.git + HEAD + + + + UTF-8 + + + + + + microsoft + Microsoft + + + + + + io.netty + netty-handler + + + io.netty + netty-handler-proxy + + + io.netty + netty-buffer + + + io.netty + netty-codec-http + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + org.slf4j + slf4j-api + + + + io.projectreactor.netty + reactor-netty + 0.8.3.RELEASE + + + io.projectreactor + reactor-test + 3.2.3.RELEASE + test + + + + junit + junit + test + + + org.slf4j + slf4j-simple + test + + + io.reactivex.rxjava2 + rxjava + test + + + com.github.tomakehurst + wiremock-standalone + 2.15.0 + test + + + org.eclipse.jetty + jetty-http + 9.4.8.v20171121 + test + + + org.eclipse.jetty + jetty-server + 9.4.8.v20171121 + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.8 + + *.implementation.*;*.utils.*;com.microsoft.schemas._2003._10.serialization;*.blob.core.storage + /** +
* Copyright (c) Microsoft Corporation. All rights reserved. +
* Licensed under the MIT License. See License.txt in the project root for +
* license information. +
*/]]>
+
+
+ + + org.eclipse.jetty + jetty-maven-plugin + 9.3.22.v20171030 + + + 11081 + + + /javasdktest/upload + + temp/ + + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + + java + + + + com.azure.common.MockServer + test + + +
+
+
diff --git a/common/azure-common/src/main/java/com/azure/common/AzureEnvironment.java b/common/azure-common/src/main/java/com/azure/common/AzureEnvironment.java new file mode 100644 index 0000000000000..79eceab2645bd --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/AzureEnvironment.java @@ -0,0 +1,329 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * An instance of this class describes an environment in Azure. + */ +public final class AzureEnvironment { + /** the map of all endpoints. */ + private final Map endpoints; + + /** + * Initializes an instance of AzureEnvironment class. + * + * @param endpoints a map storing all the endpoint info + */ + public AzureEnvironment(Map endpoints) { + this.endpoints = endpoints; + } + + /** + * Provides the settings for authentication with Azure. + */ + public static final AzureEnvironment AZURE = new AzureEnvironment(new HashMap() {{ + put("portalUrl", "http://go.microsoft.com/fwlink/?LinkId=254433"); + put("publishingProfileUrl", "http://go.microsoft.com/fwlink/?LinkId=254432"); + put("managementEndpointUrl", "https://management.core.windows.net/"); + put("resourceManagerEndpointUrl", "https://management.azure.com/"); + put("sqlManagementEndpointUrl", "https://management.core.windows.net:8443/"); + put("sqlServerHostnameSuffix", ".database.windows.net"); + put("galleryEndpointUrl", "https://gallery.azure.com/"); + put("activeDirectoryEndpointUrl", "https://login.microsoftonline.com/"); + put("activeDirectoryResourceId", "https://management.core.windows.net/"); + put("activeDirectoryGraphResourceId", "https://graph.windows.net/"); + put("dataLakeEndpointResourceId", "https://datalake.azure.net/"); + put("activeDirectoryGraphApiVersion", "2013-04-05"); + put("storageEndpointSuffix", ".core.windows.net"); + put("keyVaultDnsSuffix", ".vault.azure.net"); + put("azureDataLakeStoreFileSystemEndpointSuffix", "azuredatalakestore.net"); + put("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix", "azuredatalakeanalytics.net"); + put("azureLogAnalyticsResourceId", "https://api.loganalytics.io/"); + put("azureApplicationInsightsResourceId", "https://api.applicationinsights.io/"); + }}); + + /** + * Provides the settings for authentication with Azure China. + */ + public static final AzureEnvironment AZURE_CHINA = new AzureEnvironment(new HashMap() {{ + put("portalUrl", "http://go.microsoft.com/fwlink/?LinkId=301902"); + put("publishingProfileUrl", "http://go.microsoft.com/fwlink/?LinkID=301774"); + put("managementEndpointUrl", "https://management.core.chinacloudapi.cn/"); + put("resourceManagerEndpointUrl", "https://management.chinacloudapi.cn/"); + put("sqlManagementEndpointUrl", "https://management.core.chinacloudapi.cn:8443/"); + put("sqlServerHostnameSuffix", ".database.chinacloudapi.cn"); + put("galleryEndpointUrl", "https://gallery.chinacloudapi.cn/"); + put("activeDirectoryEndpointUrl", "https://login.chinacloudapi.cn/"); + put("activeDirectoryResourceId", "https://management.core.chinacloudapi.cn/"); + put("activeDirectoryGraphResourceId", "https://graph.chinacloudapi.cn/"); + // TODO: add resource id for the china cloud for datalake once it is defined. + put("dataLakeEndpointResourceId", "N/A"); + put("activeDirectoryGraphApiVersion", "2013-04-05"); + put("storageEndpointSuffix", ".core.chinacloudapi.cn"); + put("keyVaultDnsSuffix", ".vault.azure.cn"); + // TODO: add dns suffixes for the china cloud for datalake store and datalake analytics once they are defined. + put("azureDataLakeStoreFileSystemEndpointSuffix", "N/A"); + put("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix", "N/A"); + put("azureLogAnalyticsResourceId", "N/A"); + put("azureApplicationInsightsResourceId", "N/A"); + }}); + + /** + * Provides the settings for authentication with Azure US Government. + */ + public static final AzureEnvironment AZURE_US_GOVERNMENT = new AzureEnvironment(new HashMap() {{ + put("portalUrl", "https://manage.windowsazure.us"); + put("publishingProfileUrl", "https://manage.windowsazure.us/publishsettings/index"); + put("managementEndpointUrl", "https://management.core.usgovcloudapi.net/"); + put("resourceManagerEndpointUrl", "https://management.usgovcloudapi.net/"); + put("sqlManagementEndpointUrl", "https://management.core.usgovcloudapi.net:8443/"); + put("sqlServerHostnameSuffix", ".database.usgovcloudapi.net"); + put("galleryEndpointUrl", "https://gallery.usgovcloudapi.net/"); + put("activeDirectoryEndpointUrl", "https://login.microsoftonline.us/"); + put("activeDirectoryResourceId", "https://management.core.usgovcloudapi.net/"); + put("activeDirectoryGraphResourceId", "https://graph.windows.net/"); + // TODO: add resource id for the US government for datalake once it is defined. + put("dataLakeEndpointResourceId", "N/A"); + put("activeDirectoryGraphApiVersion", "2013-04-05"); + put("storageEndpointSuffix", ".core.usgovcloudapi.net"); + put("keyVaultDnsSuffix", ".vault.usgovcloudapi.net"); + // TODO: add dns suffixes for the US government for datalake store and datalake analytics once they are defined. + put("azureDataLakeStoreFileSystemEndpointSuffix", "N/A"); + put("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix", "N/A"); + put("azureLogAnalyticsResourceId", "https://api.loganalytics.us/"); + put("azureApplicationInsightsResourceId", "N/A"); + }}); + + /** + * Provides the settings for authentication with Azure Germany. + */ + public static final AzureEnvironment AZURE_GERMANY = new AzureEnvironment(new HashMap() {{ + put("portalUrl", "http://portal.microsoftazure.de/"); + put("publishingProfileUrl", "https://manage.microsoftazure.de/publishsettings/index"); + put("managementEndpointUrl", "https://management.core.cloudapi.de/"); + put("resourceManagerEndpointUrl", "https://management.microsoftazure.de/"); + put("sqlManagementEndpointUrl", "https://management.core.cloudapi.de:8443/"); + put("sqlServerHostnameSuffix", ".database.cloudapi.de"); + put("galleryEndpointUrl", "https://gallery.cloudapi.de/"); + put("activeDirectoryEndpointUrl", "https://login.microsoftonline.de/"); + put("activeDirectoryResourceId", "https://management.core.cloudapi.de/"); + put("activeDirectoryGraphResourceId", "https://graph.cloudapi.de/"); + // TODO: add resource id for the germany cloud for datalake once it is defined. + put("dataLakeEndpointResourceId", "N/A"); + put("activeDirectoryGraphApiVersion", "2013-04-05"); + put("storageEndpointSuffix", ".core.cloudapi.de"); + put("keyVaultDnsSuffix", ".vault.microsoftazure.de"); + // TODO: add dns suffixes for the germany cloud for datalake store and datalake analytics once they are defined. + put("azureDataLakeStoreFileSystemEndpointSuffix", "N/A"); + put("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix", "N/A"); + put("azureLogAnalyticsResourceId", "N/A"); + put("azureApplicationInsightsResourceId", "N/A"); + }}); + + /** + * @return the entirety of the endpoints associated with the current environment. + */ + public Map endpoints() { + return endpoints; + } + + /** + * @return the array of known environments to Azure SDK. + */ + public static AzureEnvironment[] knownEnvironments() { + List environments = Arrays.asList(AZURE, AZURE_CHINA, AZURE_GERMANY, AZURE_US_GOVERNMENT); + return environments.toArray(new AzureEnvironment[environments.size()]); + } + + /** + * @return the management portal URL. + */ + public String portal() { + return endpoints.get("portalUrl"); + } + + /** + * @return the publish settings file URL. + */ + public String publishingProfile() { + return endpoints.get("publishingProfileUrl"); + } + + /** + * @return the management service endpoint. + */ + public String managementEndpoint() { + return endpoints.get("managementEndpointUrl"); + } + + /** + * @return the resource management endpoint. + */ + public String resourceManagerEndpoint() { + return endpoints.get("resourceManagerEndpointUrl"); + } + + /** + * @return the sql server management endpoint for mobile commands. + */ + public String sqlManagementEndpoint() { + return endpoints.get("sqlManagementEndpointUrl"); + } + + /** + * @return the dns suffix for sql servers. + */ + public String sqlServerHostnameSuffix() { + return endpoints.get("sqlServerHostnameSuffix"); + } + + /** + * @return the Active Directory login endpoint. + */ + public String activeDirectoryEndpoint() { + return endpoints.get("activeDirectoryEndpointUrl").replaceAll("/$", "") + "/"; + } + + /** + * @return The resource ID to obtain AD tokens for. + */ + public String activeDirectoryResourceId() { + return endpoints.get("activeDirectoryResourceId"); + } + + /** + * @return the template gallery endpoint. + */ + public String galleryEndpoint() { + return endpoints.get("galleryEndpointUrl"); + } + + /** + * @return the Active Directory resource ID. + */ + public String graphEndpoint() { + return endpoints.get("activeDirectoryGraphResourceId"); + } + + /** + * @return the Data Lake resource ID. + */ + public String dataLakeEndpointResourceId() { + return endpoints.get("dataLakeEndpointResourceId"); + } + + /** + * @return the Active Directory api version. + */ + public String activeDirectoryGraphApiVersion() { + return endpoints.get("activeDirectoryGraphApiVersion"); + } + + /** + * @return the endpoint suffix for storage accounts. + */ + public String storageEndpointSuffix() { + return endpoints.get("storageEndpointSuffix"); + } + + /** + * @return the keyvault service dns suffix. + */ + public String keyVaultDnsSuffix() { + return endpoints.get("keyVaultDnsSuffix"); + } + + /** + * @return the data lake store filesystem service dns suffix. + */ + public String azureDataLakeStoreFileSystemEndpointSuffix() { + return endpoints.get("azureDataLakeStoreFileSystemEndpointSuffix"); + } + + /** + * @return the data lake analytics job and catalog service dns suffix. + */ + public String azureDataLakeAnalyticsCatalogAndJobEndpointSuffix() { + return endpoints.get("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix"); + } + + /** + * @return the log analytics endpoint. + */ + public String logAnalyticsEndpoint() { + return endpoints.get("azureLogAnalyticsResourceId"); + } + + /** + * @return the log analytics endpoint. + */ + public String applicationInsightsEndpoint() { + return endpoints.get("azureApplicationInsightsResourceId"); + } + + + /** + * The enum representing available endpoints in an environment. + */ + public enum Endpoint { + /** Azure management endpoint. */ + MANAGEMENT("managementEndpointUrl"), + /** Azure Resource Manager endpoint. */ + RESOURCE_MANAGER("resourceManagerEndpointUrl"), + /** Azure SQL endpoint. */ + SQL("sqlManagementEndpointUrl"), + /** Azure Gallery endpoint. */ + GALLERY("galleryEndpointUrl"), + /** Active Directory authentication endpoint. */ + ACTIVE_DIRECTORY("activeDirectoryEndpointUrl"), + /** Azure Active Directory Graph APIs endpoint. */ + GRAPH("activeDirectoryGraphResourceId"), + /** Key Vault DNS suffix. */ + KEYVAULT("keyVaultDnsSuffix"), + /** Azure Data Lake Store DNS suffix. */ + DATA_LAKE_STORE("azureDataLakeStoreFileSystemEndpointSuffix"), + /** Azure Data Lake Analytics DNS suffix. */ + DATA_LAKE_ANALYTICS("azureDataLakeAnalyticsCatalogAndJobEndpointSuffix"), + /** Azure Log Analytics endpoint. */ + LOG_ANALYTICS("azureLogAnalyticsResourceId"), + /** Azure Application Insights. */ + APPLICATION_INSIGHTS("azureApplicationInsightsResourceId"); + + private String field; + + Endpoint(String value) { + this.field = value; + } + + /** + * @return a unique identifier for the endpoint in the environment + */ + public String identifier() { + return field; + } + + @Override + public String toString() { + return field; + } + } + + /** + * Get the endpoint URL for the current environment. + * + * @param endpoint the endpoint + * @return the URL + */ + public String url(Endpoint endpoint) { + return endpoints.get(endpoint.identifier()); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/ServiceClient.java b/common/azure-common/src/main/java/com/azure/common/ServiceClient.java new file mode 100644 index 0000000000000..892d52dd3cb5d --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/ServiceClient.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common; + +import com.azure.common.http.HttpPipeline; +import com.azure.common.implementation.RestProxy; +import com.azure.common.implementation.serializer.SerializerAdapter; + +/** + * The base class for REST service clients. + */ +public abstract class ServiceClient { + /** + * The HTTP pipeline to send requests through. + */ + private HttpPipeline httpPipeline; + + /** + * The lazily-created serializer for this ServiceClient. + */ + private SerializerAdapter serializerAdapter; + + /** + * Creates ServiceClient. + * + * @param httpPipeline The HTTP pipeline to send requests through + */ + protected ServiceClient(HttpPipeline httpPipeline) { + this.httpPipeline = httpPipeline; + } + + /** + * @return the HTTP pipeline to send requests through. + */ + public HttpPipeline httpPipeline() { + return this.httpPipeline; + } + + /** + * @return the serializer for this ServiceClient. + */ + public SerializerAdapter serializerAdapter() { + if (this.serializerAdapter == null) { + this.serializerAdapter = createSerializerAdapter(); + } + return this.serializerAdapter; + } + + protected SerializerAdapter createSerializerAdapter() { + return RestProxy.createDefaultSerializer(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/Beta.java b/common/azure-common/src/main/java/com/azure/common/annotations/Beta.java new file mode 100644 index 0000000000000..6dc2919b3def2 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/Beta.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.ElementType.CONSTRUCTOR; + +import java.lang.annotation.Documented; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to indicate that a functionality is in preview. + */ +@Documented +@Retention(RetentionPolicy.CLASS) +@Target({ TYPE, METHOD, PARAMETER, CONSTRUCTOR }) +@Inherited +/** + * Indicates functionality that is in preview and as such is subject to change in non-backwards compatible ways in future releases, + * including removal, regardless of any compatibility expectations set by the containing library version. + * + * Examples: + * + * {@literal @}Beta + * {@literal @}Beta(since="v1.0.0") + * {@literal @}Beta(since="v1.2.0", reason="the feature is in preview") + * {@literal @}Beta("introducing Foo which eventually replaces Bar") + */ +public @interface Beta { + /** + * @return the free-form value for the annotation (used if details cannot be provided using since and reason attributes). + */ + String value() default ""; + + /** + * @return the version number indicating when the annotated target was first introduced to the library as in beta. + */ + String since() default ""; + + /** + * @return the reason for annotating the target as beta. + */ + String reason() default ""; + + /** + * @return the warning message. + */ + String warningText() default "This functionality is in preview and as such is subject to change in non-backwards compatible ways in future releases, including removal, regardless of any compatibility expectations set by the containing library version."; +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/BodyParam.java b/common/azure-common/src/main/java/com/azure/common/annotations/BodyParam.java new file mode 100644 index 0000000000000..861f4aa2aa7b8 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/BodyParam.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to annotate a parameter to send to a REST endpoint as HTTP Request content. + * + *

If the parameter type extends InputStream, this payload is streamed to server through "application/octet-stream". + * Otherwise, the body is serialized first and sent as "application/json" or "application/xml", based on the serializer. + *

+ * + *

Example 1: Put JSON

+ * + *
+ * {@literal @}PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  VirtualMachine createOrUpdate(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId, @BodyParam("application/json") VirtualMachine vm);
+ * + *

Example 2: Stream

+ * + *
+ * {@literal @}POST("formdata/stream/uploadfile")
+ *  void uploadFileViaBody(@BodyParam("application/octet-stream") FileInputStream fileContent);
+ */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface BodyParam { + /** + * @return the Content-Type that the body should be treated as + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/DELETE.java b/common/azure-common/src/main/java/com/azure/common/annotations/DELETE.java new file mode 100644 index 0000000000000..cd794b732e0a4 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/DELETE.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP DELETE method annotation describing the parameterized relative path to a REST endpoint for resource deletion. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (otherwise the parse cannot tell if it's absolute or relative).

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}DELETE("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  void delete(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}DELETE({vaultBaseUrl}/secrets/{secretName})
+ *  void delete(@PathParam("vaultBaseUrl" encoded = true) String vaultBaseUrl, @PathParam("secretName") String secretName);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface DELETE { + /** + * Get the relative path of the annotated method's DELETE URL. + * @return The relative path of the annotated method's DELETE URL. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/ExpectedResponses.java b/common/azure-common/src/main/java/com/azure/common/annotations/ExpectedResponses.java new file mode 100644 index 0000000000000..ac7a927e04fbe --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/ExpectedResponses.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to annotate list of HTTP status codes that are expected in response from a REST endpoint. + * + *

Example:

+ * + *
+ * {@literal @}ExpectedResponses({200, 201})
+ * {@literal @}POST("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CustomerInsights/hubs/{hubName}/images/getEntityTypeImageUploadUrl")
+ *  void getUploadUrlForEntityType(@Path("resourceGroupName") String resourceGroupName, @Path("hubName") String hubName, @Path("subscriptionId") String subscriptionId, @Body GetImageUploadUrlInputInner parameters);
+ */ +@Retention(RUNTIME) +@Target(METHOD) +public @interface ExpectedResponses { + /** + * The status code that will trigger that an error of type errorType should be returned. + * @return The status code that will trigger than an error of type errorType should be returned. + */ + int[] value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/GET.java b/common/azure-common/src/main/java/com/azure/common/annotations/GET.java new file mode 100644 index 0000000000000..15e3093124ddc --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/GET.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP GET method annotation describing the parameterized relative path to a REST endpoint for resource retrieval. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (otherwise the parse cannot tell if it's absolute or relative).

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  VirtualMachine getByResourceGroup(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}GET({nextLink})
+ * {@literal List} listNext(@PathParam("nextLink") String nextLink);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface GET { + /** + * Get the relative path of the annotated method's GET URL. + * @return The relative path of the annotated method's GET URL. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/HEAD.java b/common/azure-common/src/main/java/com/azure/common/annotations/HEAD.java new file mode 100644 index 0000000000000..7d16558ed8112 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/HEAD.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP HEAD method annotation describing the parameterized relative path to a REST endpoint. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (otherwise the parse cannot tell if it's absolute or relative)

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}HEAD("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  boolean checkNameAvailability(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}HEAD(https://management.azure.com/{storageAccountId})
+ *  boolean checkNameAvailability(@PathParam("nextLink") String storageAccountId);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HEAD { + /** + * Get the relative path of the annotated method's HEAD URL. + * @return The relative path of the annotated method's HEAD URL. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/HeaderCollection.java b/common/azure-common/src/main/java/com/azure/common/annotations/HeaderCollection.java new file mode 100644 index 0000000000000..608dbbe9d14b7 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/HeaderCollection.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation on a deserialized header type that indicates that the property should + * be treated as a header collection with the provided prefix. + */ +@Retention(RUNTIME) +@Target(FIELD) +public @interface HeaderCollection { + /** + * The header collection prefix. + * + * @return The header collection prefix + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/HeaderParam.java b/common/azure-common/src/main/java/com/azure/common/annotations/HeaderParam.java new file mode 100644 index 0000000000000..e6ed211cc1f59 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/HeaderParam.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Replaces the header with the value of its target. The value specified here replaces headers specified statically in + * the {@link Headers}. If the parameter this annotation is attached to is a Map type, then this will be treated as a + * header collection. In that case each of the entries in the argument's map will be individual header values that use + * the value of this annotation as a prefix to their key/header name. + * + *

Example 1:

+ * + *
+ * {@code @PUT("{functionId}")}
+ * {@code Mono>} createOrReplace(@PathParam("functionId", encoded = true) String functionId, @BodyParam FunctionInner function, @HeaderParam("If-Match") String ifMatch);
+ * + *

"If-Match: user passed value" will show up as one of the headers.

+ * + *

Example 2:

+ * + *
+ * {@code @}GET("subscriptions/{subscriptionId}/providers/Microsoft.ServiceBus/namespaces")
+ * {@code Mono>} list(@Path("subscriptionId") String subscriptionId, @Header("accept-language") String acceptLanguage, @Header("User-Agent") String userAgent);
+ * + *

"accept-language" generated by the HTTP client will be overwritten by the user passed value.

+ * + *

Example 3:

+ * + *
+ * {@code @GET("subscriptions/{subscriptionId}/providers/Microsoft.ServiceBus/namespaces")}
+ * {@code Mono>} list(@Path("subscriptionId") String subscriptionId, @Header("Authorization") String token);
+ * + *

The token parameter will replace the effect of any credentials in the HTTP pipeline.

+ * + *

Example 4:

+ * + *
+ * {@code @PUT("{containerName}/{blob}")}
+ * {@code @ExpectedResponses({200})}
+ * {@code Mono> setMetadata(@HostParam("url") String url, @QueryParam("timeout") Integer timeout, @HeaderParam("x-ms-meta-") Map metadata, @HeaderParam("x-ms-lease-id") String leaseId, @HeaderParam("If-Modified-Since") String ifModifiedSince, @HeaderParam("If-Unmodified-Since") String ifUnmodifiedSince, @HeaderParam("If-Match") String ifMatches, @HeaderParam("If-None-Match") String ifNoneMatch, @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @QueryParam("comp") String comp);}
+ * + *

The metadata parameter will be expanded out so that each entry becomes + * "x-ms-meta-{@literal }: {@literal }".

+ */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface HeaderParam { + /** + * The name of the variable in the endpoint uri template which will be replaced with the value + * of the parameter annotated with this annotation. + * @return The name of the variable in the endpoint uri template which will be replaced with the + * value of the parameter annotated with this annotation. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/Headers.java b/common/azure-common/src/main/java/com/azure/common/annotations/Headers.java new file mode 100644 index 0000000000000..bae4987e05297 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/Headers.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to annotate list of static headers sent to a REST endpoint. + * + *

Headers are comma separated strings, with each in the format of "header name: header value1,header value2".

+ * + *

Examples:

+ * + *
+ * {@literal @}Headers({ "Content-Type: application/json; charset=utf-8", "accept-language: en-US" })
+ * {@literal @}POST("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CustomerInsights/hubs/{hubName}/images/getEntityTypeImageUploadUrl")
+ *  void getUploadUrlForEntityType(@Path("resourceGroupName") String resourceGroupName, @Path("hubName") String hubName, @Path("subscriptionId") String subscriptionId, @Body GetImageUploadUrlInputInner parameters);
+ */ +@Retention(RUNTIME) +@Target(METHOD) +public @interface Headers { + /** + * List of static headers. + * @return List of static headers. + */ + String[] value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/Host.java b/common/azure-common/src/main/java/com/azure/common/annotations/Host.java new file mode 100644 index 0000000000000..f44627b2f8fbf --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/Host.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; + +/** + * Annotation for parameterized host name targeting a REST service. + * + *

This is the 'host' field or 'x-ms-parameterized-host.hostTemplate' field in a Swagger document. parameters are + * enclosed in {}s, e.g. {accountName}. An HTTP client must accept the parameterized host as the base URL for the request, + * replacing the parameters during runtime with the actual values users provide.

+ * + *

For parameterized hosts, parameters annotated with {@link HostParam} must be provided. See Java docs in + * {@link HostParam} for directions for host parameters.

+ * + *

The host's value must contain the scheme/protocol and the host. The host's value may contain the + * port number.

+ * + *

Example 1: Static annotation

+ * + *
+ * {@literal @}Host("https://management.azure.com")
+ *  interface VirtualMachinesService {
+ *   {@literal @}GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *    VirtualMachine getByResourceGroup(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId);
+ *  }
+ * + *

Example 2: Dynamic annotation

+ * + *
+ * {@literal @}Host("https://{vaultName}.vault.azure.net:443")
+ *  interface KeyVaultService {
+ *    {@literal @}GET("secrets/{secretName}")
+ *     Secret get(@HostParam("vaultName") String vaultName, @PathParam("secretName") String secretName);
+ *  }
+ */ +@Target(value = {TYPE}) +@Retention(RetentionPolicy.RUNTIME) // Record this annotation in the class file and make it available during runtime. +public @interface Host { + /** + * Get the protocol/scheme, host, and optional port number in a single string. + * @return The protocol/scheme, host, and optional port number in a single string. + */ + String value() default ""; +} diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/HostParam.java b/common/azure-common/src/main/java/com/azure/common/annotations/HostParam.java new file mode 100644 index 0000000000000..036ecb650458e --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/HostParam.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to annotate replacement of parameterized segments in a dynamic {@link Host}. + * + *

You provide the value, which should be the same (case sensitive) with the parameterized segments in '{}' in the + * host, unless there's only one parameterized segment, then you can leave the value empty. This is extremely + * useful when the designer of the API interface doesn't know about the named parameters in the host.

+ * + *

Example 1: Named parameters

+ * + *
+ * {@literal @}Host("{accountName}.{suffix}")
+ *  interface DatalakeService {
+ *   {@literal @}GET("jobs/{jobIdentity}")
+ *    Job getJob(@HostParam("accountName") String accountName, @HostParam("suffix") String suffix, @PathParam("jobIdentity") jobIdentity);
+ *  }
+ * + *

Example 2: Unnamed parameter

+ * + *
+ * {@literal @}Host(KEY_VAULT_ENDPOINT)
+ *  interface KeyVaultService {
+ *   {@literal @}GET("secrets/{secretName}")
+ *    Secret get(@HostParam String vaultName, @PathParam("secretName") String secretName);
+ *  }
+ */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface HostParam { + /** + * The name of the variable in the endpoint uri template which will be replaced with the value + * of the parameter annotated with this annotation. + * @return The name of the variable in the endpoint uri template which will be replaced with the + * value of the parameter annotated with this annotation. + */ + String value(); + /** + * A value true for this argument indicates that value of {@link HostParam#value()} is already + * encoded hence engine should not encode it, by default value will be encoded. + * @return Whether or not this argument is already encoded. + */ + boolean encoded() default true; +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/PATCH.java b/common/azure-common/src/main/java/com/azure/common/annotations/PATCH.java new file mode 100644 index 0000000000000..ba76c6bfc381b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/PATCH.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP PATCH method annotation describing the parameterized relative path to a REST endpoint for resource update. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (Otherwise the parse cannot tell if it's absolute or relative).

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}PATCH("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  VirtualMachine patch(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId, @BodyParam VirtualMachineUpdateParameters updateParameters);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}PATCH({vaultBaseUrl}/secrets/{secretName})
+ *  Secret patch(@PathParam("vaultBaseUrl" encoded = true) String vaultBaseUrl, @PathParam("secretName") String secretName, @BodyParam SecretUpdateParameters updateParameters);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PATCH { + /** + * Get the relative path of the annotated method's PATCH URL. + * @return The relative path of the annotated method's PATCH URL. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/POST.java b/common/azure-common/src/main/java/com/azure/common/annotations/POST.java new file mode 100644 index 0000000000000..cd3743c0c66a7 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/POST.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP POST method annotation describing the parameterized relative path to a REST endpoint for an action. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (Otherwise the parse cannot tell if it's absolute or relative).

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}POST("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}/restart")
+ *  void restart(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}POST(https://{functionApp}.azurewebsites.net/admin/functions/{name}/keys/{keyName})
+ *  NameValuePair generateFunctionKey(@PathParam("functionApp") String functionApp, @PathParam("name") String function, @PathParam("keyName") String keyName);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface POST { + /** + * Get the relative path of the annotated method's POST URL. + * @return The relative path of the annotated method's POST URL. + */ + String value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/PUT.java b/common/azure-common/src/main/java/com/azure/common/annotations/PUT.java new file mode 100644 index 0000000000000..f2ef16a8d32bb --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/PUT.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * HTTP PUT method annotation describing the parameterized relative path to a REST endpoint for resource creation or update. + * + *

The required value can be either a relative path or an absolute path. When it's an absolute path, it must start + * with a protocol or a parameterized segment (Otherwise the parse cannot tell if it's absolute or relative).

+ * + *

Example 1: Relative path segments

+ * + *
+ * {@literal @}PUT("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}")
+ *  VirtualMachine createOrUpdate(@PathParam("resourceGroupName") String rgName, @PathParam("vmName") String vmName, @PathParam("subscriptionId") String subscriptionId, @BodyParam VirtualMachine vm);
+ * + *

Example 2: Absolute path segment

+ * + *
+ * {@literal @}PUT({vaultBaseUrl}/secrets/{secretName})
+ *  Secret createOrUpdate(@PathParam("vaultBaseUrl" encoded = true) String vaultBaseUrl, @PathParam("secretName") String secretName, @BodyParam SecretCreateParameters secret);
+ */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PUT { + /** + * Get the relative path of the annotated method's PUT URL. + * @return The relative path of the annotated method's PUT URL. + */ + String value(); +} diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/PathParam.java b/common/azure-common/src/main/java/com/azure/common/annotations/PathParam.java new file mode 100644 index 0000000000000..7097e71c31099 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/PathParam.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation to annotate replacement for a named path segment in REST endpoint URL. + * + *

A parameter that is annotated with PathParam will be ignored if the "uri template" does not contain a path + * segment variable with name {@link PathParam#value()}.

+ * + *

Example 1:

+ * + *
+ * {@literal @}GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/")
+ *  VirtualMachine getByResourceGroup(@PathParam("subscriptionId") String subscriptionId, @PathParam("resourceGroupName") String rgName, @PathParam("foo") String bar);
+ * + *

The value of parameters subscriptionId, resourceGroupName will be encoded and encoded value will be used to + * replace the corresponding path segment {subscriptionId}, {resourceGroupName} + * respectively.

+ * + *

Example 2: (A use case where PathParam.encoded=true will be used)

+ * + *

It is possible that, a path segment variable can be used to represent sub path:

+ * + *
+ * {@literal @}GET("http://wq.com/foo/{subpath}/values")
+ *  String getValues(@PathParam("subpath") String param1);
+ * + *

In this case, if consumer pass "a/b" as the value for param1 then the resolved url looks like: + * "http://wq.com/foo/a%2Fb/values".

+ * + *

For such cases the encoded attribute can be used:

+ * + *
+ * {@literal @}GET("http://wq.com/foo/{subpath}/values")
+ *  String getValues(@PathParam(value = "subpath", encoded = true) String param1);
+ * + *

In this case, if consumer pass "a/b" as the value for param1 then the resolved url looks as expected: + * "http://wq.com/foo/a/b/values".

+ */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface PathParam { + /** + * The name of the variable in the endpoint uri template which will be replaced with the value + * of the parameter annotated with this annotation. + * @return The name of the variable in the endpoint uri template which will be replaced with the + * value of the parameter annotated with this annotation. + */ + String value(); + /** + * A value true for this argument indicates that value of {@link PathParam#value()} is already encoded + * hence engine should not encode it, by default value will be encoded. + * @return Whether or not this path parameter is already encoded. + */ + boolean encoded() default false; +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/QueryParam.java b/common/azure-common/src/main/java/com/azure/common/annotations/QueryParam.java new file mode 100644 index 0000000000000..958e8ba852c5a --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/QueryParam.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation for query parameters to be appended to a REST API Request URI. + * + *

Example 1:

+ * + *
+ * {@literal @}GET("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/resources")
+ * {@literal Single>} listByResourceGroup(@PathParam("resourceGroupName") String resourceGroupName, @PathParam("subscriptionId") String subscriptionId, @QueryParam("$filter") String filter, @QueryParam("$expand") String expand, @QueryParam("$top") Integer top, @QueryParam("api-version") String apiVersion);
+ * + *

The value of parameters filter, expand, top, apiVersion will be encoded and encoded value will be used to replace the corresponding path segment {$filter}, + * {$expand}, {$top}, {api-version} respectively.

+ * + *

Example 2: (A use case where PathParam.encoded=true will be used)

+ * + *

It is possible that, a path segment variable can be used to represent sub path:

+ * + *
+ * {@literal @}GET("http://wq.com/foo/{subpath}/values")
+ *  String getValues(@PathParam("subpath") String param, @QueryParam("connectionString") String connectionString);
+ * + *

In this case, if consumer pass "a=b" as the value for query then the resolved url looks like: + * "http://wq.com/foo/paramblah/values?connectionString=a%3Db"

+ * + *

For such cases the encoded attribute can be used:

+ * + *
+ * {@literal @}GET("http://wq.com/foo/{subpath}/values")
+ *  String getValues(@PathParam("subpath") String param, @QueryParam("query", encoded = true) String query);
+ * + *

In this case, if consumer pass "a=b" as the value for param1 then the resolved url looks as expected: + * "http://wq.com/foo/paramblah/values?connectionString=a=b"

+ */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface QueryParam { + /** + * The name of the variable in the endpoint uri template which will be replaced with the value + * of the parameter annotated with this annotation. + * @return The name of the variable in the endpoint uri template which will be replaced with the + * value of the parameter annotated with this annotation. + */ + String value(); + /** + * A value true for this argument indicates that value of {@link QueryParam#value()} is already encoded + * hence engine should not encode it, by default value will be encoded. + * @return Whether or not this query parameter is already encoded. + */ + boolean encoded() default false; +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/ResumeOperation.java b/common/azure-common/src/main/java/com/azure/common/annotations/ResumeOperation.java new file mode 100644 index 0000000000000..1ff0457c041e5 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/ResumeOperation.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for method representing continuation operation. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ResumeOperation { +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/ReturnValueWireType.java b/common/azure-common/src/main/java/com/azure/common/annotations/ReturnValueWireType.java new file mode 100644 index 0000000000000..3de32cc8ffe5d --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/ReturnValueWireType.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Annotation for the type that will be used to deserialize the return value of a REST API response. + */ +@Retention(RUNTIME) +@Target(METHOD) +public @interface ReturnValueWireType { + /** + * The type that the service interface method's return value will be converted from. + * @return The type that the service interface method's return value will be converted from. + */ + Class value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/SkipParentValidation.java b/common/azure-common/src/main/java/com/azure/common/annotations/SkipParentValidation.java new file mode 100644 index 0000000000000..ca59542de0ad8 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/SkipParentValidation.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to notify the validator to skip validation for the properties in the parent class. + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface SkipParentValidation { +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/UnexpectedResponseExceptionType.java b/common/azure-common/src/main/java/com/azure/common/annotations/UnexpectedResponseExceptionType.java new file mode 100644 index 0000000000000..9266e7feca85d --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/UnexpectedResponseExceptionType.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.annotations; + +import com.azure.common.http.rest.RestException; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * The error type that will be thrown or returned when an unexpected status code is returned from an REST API. + * + *

Example:

+ * + *
+ * {@literal @}UnexpectedResponseExceptionType(MyCustomException.class)
+ * {@literal @}POST("subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.CustomerInsights/hubs/{hubName}/images/getEntityTypeImageUploadUrl")
+ *  void getUploadUrlForEntityType(@Path("resourceGroupName") String resourceGroupName, @Path("hubName") String hubName, @Path("subscriptionId") String subscriptionId, @Body GetImageUploadUrlInputInner parameters);
+ * 
+ */ +@Retention(RUNTIME) +@Target(METHOD) +public @interface UnexpectedResponseExceptionType { + /** + * The type of RestException that should be thrown/returned when the API returns an unrecognized + * status code. + * @return The type of RestException that should be thrown/returned. + */ + Class value(); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/annotations/package-info.java b/common/azure-common/src/main/java/com/azure/common/annotations/package-info.java new file mode 100644 index 0000000000000..7758987283cd2 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/annotations/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing annotations for client side methods that maps to REST APIs. + */ +package com.azure.common.annotations; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/credentials/AsyncServiceClientCredentials.java b/common/azure-common/src/main/java/com/azure/common/credentials/AsyncServiceClientCredentials.java new file mode 100644 index 0000000000000..e8a3c0de83fef --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/credentials/AsyncServiceClientCredentials.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.credentials; + +import reactor.core.publisher.Mono; + +/** + * Provides credentials to be put in the HTTP Authorization header. + */ +public interface AsyncServiceClientCredentials { + /** + * @param uri The URI to which the request is being made. + * @return The value containing currently valid credentials to put in the HTTP header. + */ + Mono authorizationHeaderValueAsync(String uri); +} diff --git a/common/azure-common/src/main/java/com/azure/common/credentials/BasicAuthenticationCredentials.java b/common/azure-common/src/main/java/com/azure/common/credentials/BasicAuthenticationCredentials.java new file mode 100644 index 0000000000000..055911097b5df --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/credentials/BasicAuthenticationCredentials.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.credentials; + +import com.azure.common.implementation.util.Base64Util; + +import java.io.UnsupportedEncodingException; + +/** + * Basic Auth credentials for use with a REST Service Client. + */ +public class BasicAuthenticationCredentials implements ServiceClientCredentials { + /** + * Basic auth user name. + */ + private String userName; + + /** + * Basic auth password. + */ + private String password; + + /** + * Creates a basic authentication credential. + * + * @param userName basic auth user name + * @param password basic auth password + */ + public BasicAuthenticationCredentials(String userName, String password) { + this.userName = userName; + this.password = password; + } + + @Override + public String authorizationHeaderValue(String uri) { + String credential = userName + ":" + password; + String encodedCredential; + try { + encodedCredential = Base64Util.encodeToString(credential.getBytes("UTF8")); + } catch (UnsupportedEncodingException e) { + // The encoding is hard-coded, so if it's unsupported, it needs to be fixed right here. + throw new RuntimeException(e); + } + + return "Basic " + encodedCredential; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/credentials/ServiceClientCredentials.java b/common/azure-common/src/main/java/com/azure/common/credentials/ServiceClientCredentials.java new file mode 100644 index 0000000000000..d90f8a7a73589 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/credentials/ServiceClientCredentials.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.credentials; + +import java.io.IOException; + +/** + * Provides credentials to be put in the HTTP Authorization header. + */ +public interface ServiceClientCredentials { + /** + * The Authorization header value for the provided url. + * + * @param uri The URI to which the request is being made. + * @return The value containing currently valid credentials to put in the HTTP header. + * @throws IOException if unable to get the authorization header value + */ + String authorizationHeaderValue(String uri) throws IOException; +} diff --git a/common/azure-common/src/main/java/com/azure/common/credentials/TokenCredentials.java b/common/azure-common/src/main/java/com/azure/common/credentials/TokenCredentials.java new file mode 100644 index 0000000000000..b2fce4367d9f4 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/credentials/TokenCredentials.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.credentials; + +import java.io.IOException; + +/** + * Token based credentials for use with a REST Service Client. + */ +public class TokenCredentials implements ServiceClientCredentials { + /** + * The authentication scheme. + */ + private String scheme; + + /** + * The secure token. + */ + private String token; + + /** + * Creates TokenCredentials. + * + * @param scheme scheme to use. If null, defaults to Bearer + * @param token valid token + */ + public TokenCredentials(String scheme, String token) { + if (scheme == null) { + scheme = "Bearer"; + } + this.scheme = scheme; + this.token = token; + } + + @Override + public String authorizationHeaderValue(String uri) throws IOException { + return scheme + " " + token; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/credentials/package-info.java b/common/azure-common/src/main/java/com/azure/common/credentials/package-info.java new file mode 100644 index 0000000000000..074f6935a785b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/credentials/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing basic credential classes for authentication purposes. + */ +package com.azure.common.credentials; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/ContextData.java b/common/azure-common/src/main/java/com/azure/common/http/ContextData.java new file mode 100644 index 0000000000000..11c54b5a2ba25 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/ContextData.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import java.util.Optional; + +/** + * {@code ContextData} offers a means of passing arbitrary data (key-value pairs) to {@link HttpPipeline}'s + * policy objects. Most applications do not need to pass arbitrary data to the pipeline and can pass + * {@code ContextData.NONE} or {@code null}. Each context object is immutable. + * The {@code addData(Object, Object)} method creates a new {@code ContextData} object that refers + * to its parent, forming a linked list. + */ +public class ContextData { + // All fields must be immutable. + // + /** + * Signifies that no data need be passed to the pipeline. + */ + public static final ContextData NONE = new ContextData(null, null, null); + + private final ContextData parent; + private final Object key; + private final Object value; + + /** + * Constructs a new {@link ContextData} object. + * + * @param key the key + * @param value the value + */ + public ContextData(Object key, Object value) { + if (key == null) { + throw new IllegalArgumentException("key cannot be null"); + } + this.parent = null; + this.key = key; + this.value = value; + } + + private ContextData(ContextData parent, Object key, Object value) { + this.parent = parent; + this.key = key; + this.value = value; + } + + /** + * Adds a new immutable {@link ContextData} object with the specified key-value pair to + * the existing {@link ContextData} chain. + * + * @param key the key + * @param value the value + * @return the new {@link ContextData} object containing the specified pair added to the set of pairs + */ + public ContextData addData(Object key, Object value) { + if (key == null) { + throw new IllegalArgumentException("key cannot be null"); + } + return new ContextData(this, key, value); + } + + /** + * Scans the linked-list of {@link ContextData} objects looking for one with the specified key. + * Note that the first key found, i.e. the most recently added, will be returned. + * + * @param key the key to search for + * @return the value of the key if it exists + */ + public Optional getData(Object key) { + if (key == null) { + throw new IllegalArgumentException("key cannot be null"); + } + for (ContextData c = this; c != null; c = c.parent) { + if (key.equals(c.key)) { + return Optional.of(c.value); + } + } + return Optional.empty(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpClient.java b/common/azure-common/src/main/java/com/azure/common/http/HttpClient.java new file mode 100644 index 0000000000000..b0f407800e840 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpClient.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import reactor.core.publisher.Mono; +import java.util.function.Supplier; + +/** + * A generic interface for sending HTTP requests and getting responses. + */ +public interface HttpClient { + /** + * Send the provided request asynchronously. + * + * @param request The HTTP request to send + * @return A {@link Mono} that emits response asynchronously + */ + Mono send(HttpRequest request); + + /** + * Create default HttpClient instance. + * + * @return the HttpClient + */ + static HttpClient createDefault() { + return new ReactorNettyClient(); + } + + /** + * Apply the provided proxy configuration to the HttpClient. + * + * @param proxyOptions the proxy configuration supplier + * @return a HttpClient with proxy applied + */ + HttpClient proxy(Supplier proxyOptions); + + /** + * Apply or remove a wire logger configuration. + * + * @param enableWiretap wiretap config + * @return a HttpClient with wire logging enabled or disabled + */ + HttpClient wiretap(boolean enableWiretap); + + /** + * Set the port that client should connect to. + * + * @param port the port + * @return a HttpClient with port applied + */ + HttpClient port(int port); +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpHeader.java b/common/azure-common/src/main/java/com/azure/common/http/HttpHeader.java new file mode 100644 index 0000000000000..e77b633da7716 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpHeader.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +/** + * A single header within a HTTP request or response. + * + * If multiple header values are added to a HTTP request or response with + * the same name (case-insensitive), then the values will be appended + * to the end of the same Header with commas separating them. + */ +public class HttpHeader { + private final String name; + private String value; + + /** + * Create a HttpHeader instance using the provided name and value. + * + * @param name the name + * @param value the value + */ + public HttpHeader(String name, String value) { + this.name = name; + this.value = value; + } + + /** + * Get the header name. + * + * @return the name of this Header + */ + public String name() { + return name; + } + + /** + * Get the header value. + * + * @return the value of this Header + */ + public String value() { + return value; + } + + /** + * Get the comma separated value as an array. + * + * @return the values of this Header that are separated by a comma + */ + public String[] values() { + return value == null ? null : value.split(","); + } + + /** + * Add a new value to the end of the Header. + * + * @param value the value to add + */ + public void addValue(String value) { + this.value += "," + value; + } + + /** + * Get the String representation of the header. + * + * @return the String representation of this HttpHeader + */ + @Override + public String toString() { + return name + ":" + value; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpHeaders.java b/common/azure-common/src/main/java/com/azure/common/http/HttpHeaders.java new file mode 100644 index 0000000000000..8bcb0e5b835ae --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpHeaders.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializable; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * A collection of headers on an HTTP request or response. + */ +public class HttpHeaders implements Iterable, JsonSerializable { + private final Map headers = new HashMap<>(); + + /** + * Create an empty HttpHeaders instance. + */ + public HttpHeaders() { + } + + /** + * Create a HttpHeaders instance with the provided initial headers. + * + * @param headers the map of initial headers + */ + public HttpHeaders(Map headers) { + for (final Map.Entry header : headers.entrySet()) { + this.set(header.getKey(), header.getValue()); + } + } + + /** + * Create a HttpHeaders instance with the provided initial headers. + * + * @param headers the collection of initial headers + */ + public HttpHeaders(Iterable headers) { + this(); + + for (final HttpHeader header : headers) { + this.set(header.name(), header.value()); + } + } + + /** + * Gets the number of headers in the collection. + * + * @return the number of headers in this collection. + */ + public int size() { + return headers.size(); + } + + /** + * Set a header. + * + * if header with same name already exists then the value will be overwritten. + * if value is null and header with provided name already exists then it will be removed. + * + * @param name the name + * @param value the value + * @return this HttpHeaders + */ + public HttpHeaders set(String name, String value) { + final String headerKey = name.toLowerCase(); + if (value == null) { + headers.remove(headerKey); + } + else { + headers.put(headerKey, new HttpHeader(name, value)); + } + return this; + } + + /** + * Get the header value for the provided header name. Null will be returned if the header + * name isn't found. + * + * @param name the name of the header to look for + * @return The String value of the header, or null if the header isn't found + */ + public String value(String name) { + final HttpHeader header = getHeader(name); + return header == null ? null : header.value(); + } + + /** + * Get the header values for the provided header name. Null will be returned if + * the header name isn't found. + * + * @param name the name of the header to look for + * @return the values of the header, or null if the header isn't found + */ + public String[] values(String name) { + final HttpHeader header = getHeader(name); + return header == null ? null : header.values(); + } + + private HttpHeader getHeader(String headerName) { + final String headerKey = headerName.toLowerCase(); + return headers.get(headerKey); + } + + /** + * Get {@link Map} representation of the HttpHeaders collection. + * + * @return the headers as map + */ + public Map toMap() { + final Map result = new HashMap<>(); + for (final HttpHeader header : headers.values()) { + result.put(header.name(), header.value()); + } + return result; + } + + @Override + public Iterator iterator() { + return headers.values().iterator(); + } + + @Override + public void serialize(JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeObject(toMap()); + } + + @Override + public void serializeWithType(JsonGenerator jsonGenerator, SerializerProvider serializerProvider, TypeSerializer typeSerializer) throws IOException { + serialize(jsonGenerator, serializerProvider); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpMethod.java b/common/azure-common/src/main/java/com/azure/common/http/HttpMethod.java new file mode 100644 index 0000000000000..d713b70a046c3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpMethod.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +/** + * The HTTP request methods. + */ +public enum HttpMethod { + /** + * The HTTP GET method. + */ + GET, + + /** + * The HTTP PUT method. + */ + PUT, + + /** + * The HTTP POST method. + */ + POST, + + /** + * The HTTP PATCH method. + */ + PATCH, + + /** + * The HTTP DELETE method. + */ + DELETE, + + /** + * The HTTP HEAD method. + */ + HEAD, +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpPipeline.java b/common/azure-common/src/main/java/com/azure/common/http/HttpPipeline.java new file mode 100644 index 0000000000000..c02101e713b7a --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpPipeline.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import com.azure.common.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * The http pipeline. + */ +public final class HttpPipeline { + private final HttpClient httpClient; + private final HttpPipelinePolicy[] pipelinePolicies; + + /** + * Creates a HttpPipeline holding array of policies that gets applied to all request initiated through + * {@link HttpPipeline#send(HttpPipelineCallContext)} and it's response. + * + * @param httpClient the http client to write request to wire and receive response from wire. + * @param pipelinePolicies pipeline policies in the order they need to applied, a copy of this array will + * be made hence changing the original array after the creation of pipeline + * will not mutate the pipeline + */ + public HttpPipeline(HttpClient httpClient, HttpPipelinePolicy... pipelinePolicies) { + Objects.requireNonNull(httpClient); + Objects.requireNonNull(pipelinePolicies); + this.pipelinePolicies = Arrays.copyOf(pipelinePolicies, pipelinePolicies.length); + this.httpClient = httpClient; + } + + /** + * Creates a HttpPipeline holding array of policies that gets applied all request initiated through + * {@link HttpPipeline#send(HttpPipelineCallContext)} and it's response. + * + * The default HttpClient {@link HttpClient#createDefault()} will be used to write request to wire and + * receive response from wire. + * + * @param pipelinePolicies pipeline policies in the order they need to applied, a copy of this array will + * be made hence changing the original array after the creation of pipeline + * will not mutate the pipeline + */ + public HttpPipeline(HttpPipelinePolicy... pipelinePolicies) { + this(HttpClient.createDefault(), pipelinePolicies); + } + + /** + * Creates a HttpPipeline holding array of policies that gets applied to all request initiated through + * {@link HttpPipeline#send(HttpPipelineCallContext)} and it's response. + * + * @param httpClient the http client to write request to wire and receive response from wire. + * @param pipelinePolicies pipeline policies in the order they need to applied, a copy of this list + * will be made so changing the original list after the creation of pipeline + * will not mutate the pipeline + */ + public HttpPipeline(HttpClient httpClient, List pipelinePolicies) { + Objects.requireNonNull(httpClient); + Objects.requireNonNull(pipelinePolicies); + this.pipelinePolicies = pipelinePolicies.toArray(new HttpPipelinePolicy[0]); + this.httpClient = httpClient; + } + + /** + * Creates a HttpPipeline holding array of policies that gets applied all request initiated through + * {@link HttpPipeline#send(HttpPipelineCallContext)} and it's response. + * + * The default HttpClient {@link HttpClient#createDefault()} will be used to write request to wire and + * receive response from wire. + * + * @param pipelinePolicies pipeline policies in the order they need to applied, a copy of this list + * will be made so changing the original list after the creation of pipeline + * will not mutate the pipeline + */ + public HttpPipeline(List pipelinePolicies) { + this(HttpClient.createDefault(), pipelinePolicies); + } + + /** + * Get the policies in the pipeline. + * + * @return policies in the pipeline + */ + public HttpPipelinePolicy[] pipelinePolicies() { + return Arrays.copyOf(this.pipelinePolicies, this.pipelinePolicies.length); + } + + /** + * Get the {@link HttpClient} associated with the pipeline. + * + * @return the {@link HttpClient} associated with the pipeline + */ + public HttpClient httpClient() { + return this.httpClient; + } + + /** + * Creates a new context local to the provided http request. + * + * @param httpRequest the request for a context needs to be created + * @return the request context + */ + public HttpPipelineCallContext newContext(HttpRequest httpRequest) { + return new HttpPipelineCallContext(httpRequest); + } + + /** + * Creates a new context local to the provided http request. + * + * @param httpRequest the request for a context needs to be created + * @param data the data to associate with the context + * @return the request context + */ + public HttpPipelineCallContext newContext(HttpRequest httpRequest, ContextData data) { + return new HttpPipelineCallContext(httpRequest, data); + } + + /** + * Wraps the request in a context and send it through pipeline. + * + * @param request the request + * @return a publisher upon subscription flows the context through policies, sends the request and emits response upon completion + */ + public Mono send(HttpRequest request) { + return this.send(this.newContext(request)); + } + + /** + * Sends the context (containing request) through pipeline. + * + * @param context the request context + * @return a publisher upon subscription flows the context through policies, sends the request and emits response upon completion + */ + public Mono send(HttpPipelineCallContext context) { + // Return deferred to mono for complete lazy behaviour. + // + return Mono.defer(() -> { + HttpPipelineNextPolicy next = new HttpPipelineNextPolicy(this, context); + return next.process(); + }); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineCallContext.java b/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineCallContext.java new file mode 100644 index 0000000000000..8dbcddc92201f --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineCallContext.java @@ -0,0 +1,95 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import java.util.Objects; +import java.util.Optional; + +/** + * Type representing context local to a single http request and it's response. + */ +public final class HttpPipelineCallContext { + private HttpRequest httpRequest; + private ContextData data; + + // + /** + * Package private ctr. + * + * Creates HttpPipelineCallContext. + * + * @param httpRequest the request for which context needs to be created + * + * @throws IllegalArgumentException if there are multiple policies with same name + */ + HttpPipelineCallContext(HttpRequest httpRequest) { + this(httpRequest, ContextData.NONE); + } + + /** + * Package private ctr. + * + * Creates HttpPipelineCallContext. + * + * @param httpRequest the request for which context needs to be created + * @param data the data to associate with this context + * + * @throws IllegalArgumentException if there are multiple policies with same name + */ + HttpPipelineCallContext(HttpRequest httpRequest, ContextData data) { + Objects.requireNonNull(httpRequest); + Objects.requireNonNull(data); + // + this.httpRequest = httpRequest; + this.data = data; + } + // + + // + + /** + * Stores a key-value data in the context. + * + * @param key the key + * @param value the value + */ + public void setData(String key, Object value) { + this.data = this.data.addData(key, value); + } + + /** + * Gets a value with the given key stored in the context. + * + * @param key the key + * @return the value + */ + public Optional getData(String key) { + return this.data.getData(key); + } + + /** + * Get the http request. + * + * @return the request. + */ + public HttpRequest httpRequest() { + return this.httpRequest; + } + + /** + * Sets the http request object in the context. + * + * @param request request object + * @return HttpPipelineCallContext + */ + public HttpPipelineCallContext withHttpRequest(HttpRequest request) { + this.httpRequest = request; + return this; + } + + // +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineNextPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineNextPolicy.java new file mode 100644 index 0000000000000..c1ea6e80bb77d --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpPipelineNextPolicy.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import com.azure.common.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +/** + * A type that invokes next policy in the pipeline. + */ +public class HttpPipelineNextPolicy { + private final HttpPipeline pipeline; + private final HttpPipelineCallContext context; + private int currentPolicyIndex; + + /** + * Package Private ctr. + * + * Creates HttpPipelineNextPolicy. + * + * @param pipeline the pipeline + * @param context the request-response context + */ + HttpPipelineNextPolicy(final HttpPipeline pipeline, HttpPipelineCallContext context) { + this.pipeline = pipeline; + this.context = context; + this.currentPolicyIndex = -1; + } + + /** + * Invokes the next {@link HttpPipelinePolicy}. + * + * @return a publisher upon subscription invokes next policy and emits response from the policy. + */ + public Mono process() { + final int size = this.pipeline.pipelinePolicies().length; + if (this.currentPolicyIndex > size) { + return Mono.error(new IllegalStateException("There is no more policies to execute.")); + } else { + this.currentPolicyIndex++; + if (this.currentPolicyIndex == size) { + return this.pipeline.httpClient().send(this.context.httpRequest()); + } else { + return this.pipeline.pipelinePolicies()[this.currentPolicyIndex].process(this.context, this); + } + } + } + + @Override + public HttpPipelineNextPolicy clone() { + HttpPipelineNextPolicy cloned = new HttpPipelineNextPolicy(this.pipeline, this.context); + cloned.currentPolicyIndex = this.currentPolicyIndex; + return cloned; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpRequest.java b/common/azure-common/src/main/java/com/azure/common/http/HttpRequest.java new file mode 100644 index 0000000000000..360f9c8962cbd --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpRequest.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import reactor.core.publisher.Flux; + +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * The outgoing Http request. + */ +public class HttpRequest { + private HttpMethod httpMethod; + private URL url; + private HttpHeaders headers; + private Flux body; + + /** + * Create a new HttpRequest instance. + * + * @param httpMethod the HTTP request method + * @param url the target address to send the request to + */ + public HttpRequest(HttpMethod httpMethod, URL url) { + this.httpMethod = httpMethod; + this.url = url; + this.headers = new HttpHeaders(); + } + + /** + * Create a new HttpRequest instance. + * + * @param httpMethod the HTTP request method + * @param url the target address to send the request to + * @param headers the HTTP headers to use with this request + * @param body the request content + */ + public HttpRequest(HttpMethod httpMethod, URL url, HttpHeaders headers, Flux body) { + this.httpMethod = httpMethod; + this.url = url; + this.headers = headers; + this.body = body; + } + + /** + * Get the request method. + * + * @return the request method + */ + public HttpMethod httpMethod() { + return httpMethod; + } + + /** + * Set the request method. + * + * @param httpMethod the request method + * @return this HttpRequest + */ + public HttpRequest withHttpMethod(HttpMethod httpMethod) { + this.httpMethod = httpMethod; + return this; + } + + /** + * Get the target address. + * + * @return the target address + */ + public URL url() { + return url; + } + + /** + * Set the target address to send the request to. + * + * @param url target address as {@link URL} + * @return this HttpRequest + */ + public HttpRequest withUrl(URL url) { + this.url = url; + return this; + } + + /** + * Get the request headers. + * + * @return headers to be sent + */ + public HttpHeaders headers() { + return headers; + } + + /** + * Set the request headers. + * + * @param headers the set of headers + * @return this HttpRequest + */ + public HttpRequest withHeaders(HttpHeaders headers) { + this.headers = headers; + return this; + } + + /** + * Set a request header, replacing any existing value. + * A null for {@code value} will remove the header if one with matching name exists. + * + * @param name the header name + * @param value the header value + * @return this HttpRequest + */ + public HttpRequest withHeader(String name, String value) { + headers.set(name, value); + return this; + } + + /** + * Get the request content. + * + * @return the content to be send + */ + public Flux body() { + return body; + } + + /** + * Set the request content. + * + * @param content the request content + * @return this HttpRequest + */ + public HttpRequest withBody(String content) { + final byte[] bodyBytes = content.getBytes(StandardCharsets.UTF_8); + return withBody(bodyBytes); + } + + /** + * Set the request content. + * The Content-Length header will be set based on the given content's length + * + * @param content the request content + * @return this HttpRequest + */ + public HttpRequest withBody(byte[] content) { + headers.set("Content-Length", String.valueOf(content.length)); + // Unpooled.wrappedBuffer(body) allocates ByteBuf from unpooled heap + return withBody(Flux.just(Unpooled.wrappedBuffer(content))); + } + + /** + * Set request content. + * + * Caller must set the Content-Length header to indicate the length of the content, + * or use Transfer-Encoding: chunked. + * + * @param content the request content + * @return this HttpRequest + */ + public HttpRequest withBody(Flux content) { + this.body = content; + return this; + } + + /** + * Creates a clone of the request. + * + * The main purpose of this is so that this HttpRequest can be changed and the resulting + * HttpRequest can be a backup. This means that the buffered HttpHeaders and body must + * not be able to change from side effects of this HttpRequest. + * + * @return a new HTTP request instance with cloned instances of all mutable properties. + */ + public HttpRequest buffer() { + final HttpHeaders bufferedHeaders = new HttpHeaders(headers); + return new HttpRequest(httpMethod, url, bufferedHeaders, body); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/HttpResponse.java b/common/azure-common/src/main/java/com/azure/common/http/HttpResponse.java new file mode 100644 index 0000000000000..1a17d210942da --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/HttpResponse.java @@ -0,0 +1,139 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import java.io.Closeable; +import java.nio.charset.Charset; + +import com.azure.common.implementation.http.BufferedHttpResponse; +import io.netty.buffer.ByteBuf; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.Connection; + +/** + * The type representing response of {@link HttpRequest}. + */ +public abstract class HttpResponse implements Closeable { + private HttpRequest request; + + /** + * Get the response status code. + * + * @return the response status code + */ + public abstract int statusCode(); + + /** + * Lookup a response header with the provided name. + * + * @param name the name of the header to lookup. + * @return the value of the header, or null if the header doesn't exist in the response. + */ + public abstract String headerValue(String name); + + /** + * Get all response headers. + * + * @return the response headers + */ + public abstract HttpHeaders headers(); + + /** + * Get the publisher emitting response content chunks. + * + *

+ * Returns a stream of the response's body content. Emissions may occur on the + * Netty EventLoop threads which are shared across channels and should not be + * blocked. Blocking should be avoided as much as possible/practical in reactive + * programming but if you do use methods like {@code blockingSubscribe} or {@code blockingGet} + * on the stream then be sure to use {@code subscribeOn} and {@code observeOn} + * before the blocking call. For example: + * + *

+     * {@code
+     *   response.body()
+     *     .map(bb -> bb.limit())
+     *     .reduce((x,y) -> x + y)
+     *     .subscribeOn(Schedulers.io())
+     *     .observeOn(Schedulers.io())
+     *     .blockingGet();
+     * }
+     * 
+ *

+ * The above code is a simplistic example and would probably run fine without + * the `subscribeOn` and `observeOn` but should be considered a template for + * more complex situations. + * + * @return The response's content as a stream of {@link ByteBuf}. + */ + public abstract Flux body(); + + /** + * Get the response content as a byte[]. + * + * @return this response content as a byte[] + */ + public abstract Mono bodyAsByteArray(); + + /** + * Get the response content as a string. + * + * @return This response content as a string + */ + public abstract Mono bodyAsString(); + + /** + * Get the response content as a string. + * + * @param charset the charset to use as encoding + * @return This response content as a string + */ + public abstract Mono bodyAsString(Charset charset); + + /** + * Get the request which resulted in this response. + * + * @return the request which resulted in this response. + */ + public final HttpRequest request() { + return request; + } + + /** + * Sets the request which resulted in this HttpResponse. + * + * @param request the request + * @return this HTTP response + */ + public final HttpResponse withRequest(HttpRequest request) { + this.request = request; + return this; + } + + /** + * Get a new Response object wrapping this response with it's content + * buffered into memory. + * + * @return the new Response object + */ + public HttpResponse buffer() { + return new BufferedHttpResponse(this); + } + + /** + * Closes the response content stream, if any. + */ + @Override + public void close() { + } + + // package private for test purpose + Connection internConnection() { + return null; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/ProxyOptions.java b/common/azure-common/src/main/java/com/azure/common/http/ProxyOptions.java new file mode 100644 index 0000000000000..372603173f870 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/ProxyOptions.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import reactor.netty.tcp.ProxyProvider.Proxy; + +import java.net.InetSocketAddress; + +/** + * proxy configuration. + */ +public class ProxyOptions { + private final InetSocketAddress address; + private final Type type; + + /** + * Creates ProxyOptions. + * + * @param type the proxy type + * @param address the proxy address (ip and port number) + */ + public ProxyOptions(Type type, InetSocketAddress address) { + this.type = type; + this.address = address; + } + + /** + * @return the address of the proxy. + */ + public InetSocketAddress address() { + return address; + } + + /** + * @return the type of the proxy. + */ + public Type type() { + return type; + } + + /** + * The type of the proxy. + */ + public enum Type { + /** + * HTTP proxy type. + */ + HTTP(Proxy.HTTP), + /** + * SOCKS4 proxy type. + */ + SOCKS4(Proxy.SOCKS4), + /** + * SOCKS5 proxy type. + */ + SOCKS5(Proxy.SOCKS5); + + private final Proxy value; + + Type(Proxy reactorProxyType) { + this.value = reactorProxyType; + } + + Proxy value() { + return value; + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/ReactorNettyClient.java b/common/azure-common/src/main/java/com/azure/common/http/ReactorNettyClient.java new file mode 100644 index 0000000000000..2257d65f8d06b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/ReactorNettyClient.java @@ -0,0 +1,199 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpMethod; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.ByteBufFlux; +import reactor.netty.Connection; +import reactor.netty.NettyOutbound; +import reactor.netty.http.client.HttpClientRequest; +import reactor.netty.http.client.HttpClientResponse; + +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * HttpClient that is implemented using reactor-netty. + */ +class ReactorNettyClient implements HttpClient { + private reactor.netty.http.client.HttpClient httpClient; + + /** + * Creates default ReactorNettyClient. + */ + ReactorNettyClient() { + this(reactor.netty.http.client.HttpClient.create()); + } + + /** + * Creates ReactorNettyClient with provided http client. + * + * @param httpClient the reactor http client + */ + private ReactorNettyClient(reactor.netty.http.client.HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Creates ReactorNettyClient with provided http client with configuration applied. + * + * @param httpClient the reactor http client + * @param config the configuration to apply on the http client + */ + private ReactorNettyClient(reactor.netty.http.client.HttpClient httpClient, Function config) { + this.httpClient = config.apply(httpClient); + } + + @Override + public Mono send(final HttpRequest request) { + Objects.requireNonNull(request.httpMethod()); + Objects.requireNonNull(request.url()); + Objects.requireNonNull(request.url().getProtocol()); + // + Mono response = httpClient + .request(HttpMethod.valueOf(request.httpMethod().toString())) + .uri(request.url().toString()) + .send(bodySendDelegate(request)) + .responseConnection(responseDelegate(request)) + .single(); + return response; + } + + /** + * Delegate to send the request content. + * + * @param restRequest the Rest request contains the body to be sent + * @return a delegate upon invocation sets the request body in reactor-netty outbound object + */ + private static BiFunction> bodySendDelegate(final HttpRequest restRequest) { + BiFunction> sendDelegate = (reactorNettyRequest, reactorNettyOutbound) -> { + for (HttpHeader header : restRequest.headers()) { + reactorNettyRequest.header(header.name(), header.value()); + } + if (restRequest.body() != null) { + Flux nettyByteBufFlux = restRequest.body().map(Unpooled::wrappedBuffer); + return reactorNettyOutbound.send(nettyByteBufFlux); + } else { + return reactorNettyOutbound; + } + }; + return sendDelegate; + } + + /** + * Delegate to receive response. + * + * @param restRequest the Rest request whose response this delegate handles + * @return a delegate upon invocation setup Rest response object + */ + private static BiFunction> responseDelegate(final HttpRequest restRequest) { + BiFunction> responseDelegate = (reactorNettyResponse, reactorNettyConnection) -> { + HttpResponse httpResponse = new HttpResponse() { + @Override + public int statusCode() { + return reactorNettyResponse.status().code(); + } + + @Override + public String headerValue(String name) { + return reactorNettyResponse.responseHeaders().get(name); + } + + @Override + public com.azure.common.http.HttpHeaders headers() { + Map map = new HashMap<>(); + reactorNettyResponse.responseHeaders().forEach(e -> map.put(e.getKey(), e.getValue())); + return new com.azure.common.http.HttpHeaders(map); + } + + @Override + public Flux body() { + final ByteBufFlux body = bodyIntern(); + // + return body.doFinally(s -> { + if (!reactorNettyConnection.isDisposed()) { + reactorNettyConnection.channel().eventLoop().execute(reactorNettyConnection::dispose); + } + }); + } + + @Override + public Mono bodyAsByteArray() { + return bodyIntern().aggregate().asByteArray().doFinally(s -> { + if (!reactorNettyConnection.isDisposed()) { + reactorNettyConnection.channel().eventLoop().execute(reactorNettyConnection::dispose); + } + }); + } + + @Override + public Mono bodyAsString() { + return bodyIntern().aggregate().asString().doFinally(s -> { + if (!reactorNettyConnection.isDisposed()) { + reactorNettyConnection.channel().eventLoop().execute(reactorNettyConnection::dispose); + } + }); + } + + @Override + public Mono bodyAsString(Charset charset) { + return bodyIntern().aggregate().asString(charset).doFinally(s -> { + if (!reactorNettyConnection.isDisposed()) { + reactorNettyConnection.channel().eventLoop().execute(reactorNettyConnection::dispose); + } + }); + } + + @Override + public void close() { + if (!reactorNettyConnection.isDisposed()) { + reactorNettyConnection.channel().eventLoop().execute(reactorNettyConnection::dispose); + } + } + + private ByteBufFlux bodyIntern() { + return reactorNettyConnection.inbound().receive(); + } + + @Override + Connection internConnection() { + return reactorNettyConnection; + } + }; + return Mono.just(httpResponse.withRequest(restRequest)); + }; + return responseDelegate; + } + + @Override + public final HttpClient proxy(Supplier proxyOptionsSupplier) { + return new ReactorNettyClient(this.httpClient, client -> client.tcpConfiguration(c -> { + ProxyOptions options = proxyOptionsSupplier.get(); + return c.proxy(ts -> ts.type(options.type().value()).address(options.address())); + })); + } + + @Override + public final HttpClient wiretap(boolean enableWiretap) { + return new ReactorNettyClient(this.httpClient, client -> client.wiretap(enableWiretap)); + } + + @Override + public final HttpClient port(int port) { + return new ReactorNettyClient(this.httpClient, client -> client.port(port)); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/package-info.java b/common/azure-common/src/main/java/com/azure/common/http/package-info.java new file mode 100644 index 0000000000000..1ccbcd9d19cf8 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing the HTTP abstractions between the AnnotationParser, RestProxy and HTTP client. + */ +package com.azure.common.http; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/AddDatePolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/AddDatePolicy.java new file mode 100644 index 0000000000000..f055fb5a9629a --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/AddDatePolicy.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * The Pipeline policy that adds Date header in RFC 1123 format when sending an HTTP request. + */ +public class AddDatePolicy implements HttpPipelinePolicy { + private final DateTimeFormatter format = DateTimeFormatter + .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'") + .withZone(ZoneId.of("UTC")) + .withLocale(Locale.US); + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return Mono.defer(() -> { + context.httpRequest().headers().set("Date", format.format(OffsetDateTime.now())); + return next.process(); + }); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/AddHeadersPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/AddHeadersPolicy.java new file mode 100644 index 0000000000000..4e159764fc714 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/AddHeadersPolicy.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import reactor.core.publisher.Mono; + +/** + * The Pipeline policy that adds a particular set of headers to HTTP requests. + */ +public class AddHeadersPolicy implements HttpPipelinePolicy { + private final HttpHeaders headers; + + /** + * Creates a AddHeadersPolicy. + * + * @param headers The headers to add to outgoing requests. + */ + public AddHeadersPolicy(HttpHeaders headers) { + this.headers = headers; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + for (HttpHeader header : headers) { + context.httpRequest().withHeader(header.name(), header.value()); + } + return next.process(); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/CookiePolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/CookiePolicy.java new file mode 100644 index 0000000000000..672ff727644e5 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/CookiePolicy.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.HttpResponse; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The Pipeline policy that which stores cookies based on the response Set-Cookie header and adds cookies to requests. + */ +public class CookiePolicy implements HttpPipelinePolicy { + private final CookieHandler cookies = new CookieManager(); + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + try { + final URI uri = context.httpRequest().url().toURI(); + + Map> cookieHeaders = new HashMap<>(); + for (HttpHeader header : context.httpRequest().headers()) { + cookieHeaders.put(header.name(), Arrays.asList(context.httpRequest().headers().values(header.name()))); + } + + Map> requestCookies = cookies.get(uri, cookieHeaders); + for (Map.Entry> entry : requestCookies.entrySet()) { + context.httpRequest().headers().set(entry.getKey(), String.join(",", entry.getValue())); + } + + return next.process().map(httpResponse -> { + Map> responseHeaders = new HashMap<>(); + for (HttpHeader header : httpResponse.headers()) { + responseHeaders.put(header.name(), Collections.singletonList(header.value())); + } + + try { + cookies.put(uri, responseHeaders); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + return httpResponse; + }); + } catch (URISyntaxException | IOException e) { + return Mono.error(e); + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/CredentialsPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/CredentialsPolicy.java new file mode 100644 index 0000000000000..27f5472fa6244 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/CredentialsPolicy.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.credentials.ServiceClientCredentials; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import reactor.core.publisher.Mono; + +import java.io.IOException; + +/** + * The Pipeline policy that adds credentials from ServiceClientCredentials to a request. + */ +public class CredentialsPolicy implements HttpPipelinePolicy { + private final ServiceClientCredentials credentials; + + /** + * Creates CredentialsPolicy. + * + * @param credentials the credentials + */ + public CredentialsPolicy(ServiceClientCredentials credentials) { + this.credentials = credentials; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + try { + String token = credentials.authorizationHeaderValue(context.httpRequest().url().toString()); + context.httpRequest().headers().set("Authorization", token); + return next.process(); + } catch (IOException e) { + return Mono.error(e); + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/HostPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/HostPolicy.java new file mode 100644 index 0000000000000..61e2f92c3a070 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/HostPolicy.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.implementation.http.UrlBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; + +/** + * The Pipeline policy that adds the given host to each HttpRequest. + */ +public class HostPolicy implements HttpPipelinePolicy { + private final String host; + private static final Logger LOGGER = LoggerFactory.getLogger(HostPolicy.class); + + /** + * Create HostPolicy. + * + * @param host The host to set on every HttpRequest. + */ + public HostPolicy(String host) { + this.host = host; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + LOGGER.info("Setting host to {0}", host); + + Mono result; + final UrlBuilder urlBuilder = UrlBuilder.parse(context.httpRequest().url()); + try { + context.httpRequest().withUrl(urlBuilder.withHost(host).toURL()); + result = next.process(); + } catch (MalformedURLException e) { + result = Mono.error(e); + } + return result; + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLogDetailLevel.java b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLogDetailLevel.java new file mode 100644 index 0000000000000..3e8893b65bd23 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLogDetailLevel.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +/** + * The level of detail to log on HTTP messages. + */ +public enum HttpLogDetailLevel { + /** + * Logging is turned off. + */ + NONE, + + /** + * Logs only URLs, HTTP methods, and time to finish the request. + */ + BASIC, + + /** + * Logs everything in BASIC, plus all the request and response headers. + */ + HEADERS, + + /** + * Logs everything in BASIC, plus all the request and response body. + * Note that only payloads in plain text or plain text encoded in GZIP + * will be logged. + */ + BODY, + + /** + * Logs everything in HEADERS and BODY. + */ + BODY_AND_HEADERS; + + /** + * @return a value indicating whether a request's URL should be logged. + */ + public boolean shouldLogURL() { + return this != NONE; + } + + /** + * @return a value indicating whether HTTP message headers should be logged. + */ + public boolean shouldLogHeaders() { + return this == HEADERS || this == BODY_AND_HEADERS; + } + + /** + * @return a value indicating whether HTTP message bodies should be logged. + */ + public boolean shouldLogBody() { + return this == BODY || this == BODY_AND_HEADERS; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLoggingPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLoggingPolicy.java new file mode 100644 index 0000000000000..581186e0f29d3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpLoggingPolicy.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.implementation.util.FluxUtil; +import io.netty.handler.codec.http.HttpResponseStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * The Pipeline policy that handles logging of HTTP requests and responses. + */ +public class HttpLoggingPolicy implements HttpPipelinePolicy { + private static final ObjectMapper PRETTY_PRINTER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private final HttpLogDetailLevel detailLevel; + private final boolean prettyPrintJSON; + private static final int MAX_BODY_LOG_SIZE = 1024 * 16; + + /** + * Creates an HttpLoggingPolicy with the given log level. + * + * @param detailLevel The HTTP logging detail level. + */ + public HttpLoggingPolicy(HttpLogDetailLevel detailLevel) { + this(detailLevel, false); + } + + /** + * Creates an HttpLoggingPolicy with the given log level and pretty printing setting. + * + * @param detailLevel The HTTP logging detail level. + * @param prettyPrintJSON If true, pretty prints JSON message bodies when logging. + * If the detailLevel does not include body logging, this flag does nothing. + */ + public HttpLoggingPolicy(HttpLogDetailLevel detailLevel, boolean prettyPrintJSON) { + this.detailLevel = detailLevel; + this.prettyPrintJSON = prettyPrintJSON; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + // + Optional data = context.getData("caller-method"); + String callerMethod; + if (!data.isPresent() || data.get() == null) { + callerMethod = ""; + } else { + callerMethod = (String) data.get(); + } + // + final Logger logger = LoggerFactory.getLogger(callerMethod); + final long startNs = System.nanoTime(); + // + Mono logRequest = logRequest(logger, context.httpRequest()); + Function> logResponseDelegate = logResponseDelegate(logger, context.httpRequest().url(), startNs); + // + return logRequest.then(next.process()).flatMap(logResponseDelegate) + .doOnError(throwable -> log(logger, "<-- HTTP FAILED: " + throwable)); + } + + private Mono logRequest(final Logger logger, final HttpRequest request) { + if (detailLevel.shouldLogURL()) { + log(logger, String.format("--> %s %s", request.httpMethod(), request.url())); + } + + if (detailLevel.shouldLogHeaders()) { + for (HttpHeader header : request.headers()) { + log(logger, header.toString()); + } + } + // + Mono reqBodyLoggingMono = Mono.empty(); + // + if (detailLevel.shouldLogBody()) { + if (request.body() == null) { + log(logger, "(empty body)"); + log(logger, "--> END " + request.httpMethod()); + } else { + boolean isHumanReadableContentType = !"application/octet-stream".equalsIgnoreCase(request.headers().value("Content-Type")); + final long contentLength = getContentLength(request.headers()); + + if (contentLength < MAX_BODY_LOG_SIZE && isHumanReadableContentType) { + try { + Mono collectedBytes = FluxUtil.collectBytesInByteBufStream(request.body(), true); + reqBodyLoggingMono = collectedBytes.flatMap(bytes -> { + String bodyString = new String(bytes, StandardCharsets.UTF_8); + bodyString = prettyPrintIfNeeded(logger, request.headers().value("Content-Type"), bodyString); + log(logger, String.format("%s-byte body:\n%s", contentLength, bodyString)); + log(logger, "--> END " + request.httpMethod()); + return Mono.empty(); + }); + } catch (Exception e) { + reqBodyLoggingMono = Mono.error(e); + } + } else { + log(logger, contentLength + "-byte body: (content not logged)"); + log(logger, "--> END " + request.httpMethod()); + } + } + } + return reqBodyLoggingMono; + } + + private Function> logResponseDelegate(final Logger logger, final URL url, final long startNs) { + return (HttpResponse response) -> { + long tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + // + String contentLengthString = response.headerValue("Content-Length"); + String bodySize; + if (contentLengthString == null || contentLengthString.isEmpty()) { + bodySize = "unknown-length"; + } else { + bodySize = contentLengthString + "-byte"; + } + + HttpResponseStatus responseStatus = HttpResponseStatus.valueOf(response.statusCode()); + if (detailLevel.shouldLogURL()) { + log(logger, String.format("<-- %s %s %s (%s ms, %s body)", response.statusCode(), responseStatus.reasonPhrase(), url, tookMs, bodySize)); + } + + if (detailLevel.shouldLogHeaders()) { + for (HttpHeader header : response.headers()) { + log(logger, header.toString()); + } + } + + if (detailLevel.shouldLogBody()) { + long contentLength = getContentLength(response.headers()); + final String contentTypeHeader = response.headerValue("Content-Type"); + if ((contentTypeHeader == null || !"application/octet-stream".equalsIgnoreCase(contentTypeHeader)) + && contentLength != 0 && contentLength < MAX_BODY_LOG_SIZE) { + final HttpResponse bufferedResponse = response.buffer(); + return bufferedResponse.bodyAsString().map(bodyStr -> { + bodyStr = prettyPrintIfNeeded(logger, contentTypeHeader, bodyStr); + log(logger, "Response body:\n" + bodyStr); + log(logger, "<-- END HTTP"); + return bufferedResponse; + }); + } else { + log(logger, "(body content not logged)"); + log(logger, "<-- END HTTP"); + } + } else { + log(logger, "<-- END HTTP"); + } + return Mono.just(response); + }; + } + + private String prettyPrintIfNeeded(Logger logger, String contentType, String body) { + String result = body; + if (prettyPrintJSON && contentType != null && (contentType.startsWith("application/json") || contentType.startsWith("text/json"))) { + try { + final Object deserialized = PRETTY_PRINTER.readTree(body); + result = PRETTY_PRINTER.writeValueAsString(deserialized); + } catch (Exception e) { + log(logger, "Failed to pretty print JSON: " + e.getMessage()); + } + } + return result; + } + + /** + * Process the log using an SLF4j logger and an HTTP message. + * + * @param logger the SLF4j logger with the context of the request + * @param s the message for logging + */ + private void log(Logger logger, String s) { + logger.info(s); + } + + private long getContentLength(HttpHeaders headers) { + long contentLength = 0; + try { + contentLength = Long.parseLong(headers.value("content-length")); + } catch (NumberFormatException | NullPointerException ignored) { + } + + return contentLength; + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/HttpPipelinePolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpPipelinePolicy.java new file mode 100644 index 0000000000000..fa44ac8ff1d97 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/HttpPipelinePolicy.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import reactor.core.publisher.Mono; + +/** + * Pipeline policy. + */ +@FunctionalInterface +public interface HttpPipelinePolicy { + /** + * Process provided request context and invokes the next policy. + * + * @param context request context + * @param next the next policy to invoke + * @return publisher that initiate the request upon subscription and emits response on completion. + */ + Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next); +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/PortPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/PortPolicy.java new file mode 100644 index 0000000000000..574fb45923af3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/PortPolicy.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.http.UrlBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; + +/** + * The Pipeline policy that adds a given port to each HttpRequest. + */ +public class PortPolicy implements HttpPipelinePolicy { + private final int port; + private final boolean overwrite; + private static final Logger LOGGER = LoggerFactory.getLogger(PortPolicy.class); + + /** + * Create a new PortPolicy object. + * + * @param port The port to set. + * @param overwrite Whether or not to overwrite a HttpRequest's port if it already has one. + */ + public PortPolicy(int port, boolean overwrite) { + this.port = port; + this.overwrite = overwrite; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + final UrlBuilder urlBuilder = UrlBuilder.parse(context.httpRequest().url()); + if (overwrite || urlBuilder.port() == null) { + LOGGER.info("Changing port to {0}", port); + + try { + context.httpRequest().withUrl(urlBuilder.withPort(port).toURL()); + } catch (MalformedURLException e) { + return Mono.error(e); + } + } + return next.process(); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/ProtocolPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/ProtocolPolicy.java new file mode 100644 index 0000000000000..dc3038e45a8d5 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/ProtocolPolicy.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.implementation.http.UrlBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; + +/** + * The Pipeline policy that adds a given protocol to each HttpRequest. + */ +public class ProtocolPolicy implements HttpPipelinePolicy { + private final String protocol; + private final boolean overwrite; + private static final Logger LOGGER = LoggerFactory.getLogger(ProtocolPolicy.class); + + /** + * Create a new ProtocolPolicy. + * + * @param protocol The protocol to set. + * @param overwrite Whether or not to overwrite a HttpRequest's protocol if it already has one. + */ + public ProtocolPolicy(String protocol, boolean overwrite) { + this.protocol = protocol; + this.overwrite = overwrite; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + final UrlBuilder urlBuilder = UrlBuilder.parse(context.httpRequest().url()); + if (overwrite || urlBuilder.scheme() == null) { + LOGGER.info("Setting protocol to {0}", protocol); + + try { + context.httpRequest().withUrl(urlBuilder.withScheme(protocol).toURL()); + } catch (MalformedURLException e) { + return Mono.error(e); + } + } + return next.process(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/ProxyAuthenticationPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/ProxyAuthenticationPolicy.java new file mode 100644 index 0000000000000..e67bd6226b87c --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/ProxyAuthenticationPolicy.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.implementation.util.Base64Util; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +/** + * The Pipeline policy that adds basic proxy authentication to outgoing HTTP requests. + */ +public class ProxyAuthenticationPolicy implements HttpPipelinePolicy { + private final String username; + private final String password; + + /** + * Creates a ProxyAuthenticationPolicy. + * + * @param username the username for authentication. + * @param password the password for authentication. + */ + public ProxyAuthenticationPolicy(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + String auth = username + ":" + password; + String encodedAuth = Base64Util.encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + context.httpRequest().withHeader("Proxy-Authentication", "Basic " + encodedAuth); + return next.process(); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/RequestIdPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/RequestIdPolicy.java new file mode 100644 index 0000000000000..2f2917e543fe1 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/RequestIdPolicy.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** + * The Pipeline policy that puts a UUID in the request header. Azure uses the request id as + * the unique identifier for the request. + */ +public class RequestIdPolicy implements HttpPipelinePolicy { + private static final String REQUEST_ID_HEADER = "x-ms-client-request-id"; + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + String requestId = context.httpRequest().headers().value(REQUEST_ID_HEADER); + if (requestId == null) { + context.httpRequest().headers().set(REQUEST_ID_HEADER, UUID.randomUUID().toString()); + } + return next.process(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/RetryPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/RetryPolicy.java new file mode 100644 index 0000000000000..c41205447ca8a --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/RetryPolicy.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +import java.net.HttpURLConnection; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +/** + * A pipeline policy that retries when a recoverable HTTP error occurs. + */ +public class RetryPolicy implements HttpPipelinePolicy { + private static final int DEFAULT_MAX_RETRIES = 3; + private static final int DEFAULT_DELAY = 0; + private static final ChronoUnit DEFAULT_TIME_UNIT = ChronoUnit.MILLIS; + private final int maxRetries; + private final Duration delayDuration; + + /** + * Creates a RetryPolicy with the default number of retry attempts and delay between retries. + */ + public RetryPolicy() { + this.maxRetries = DEFAULT_MAX_RETRIES; + this.delayDuration = Duration.of(DEFAULT_DELAY, DEFAULT_TIME_UNIT); + } + + /** + * Creates a RetryPolicy. + * + * @param maxRetries the maximum number of retries to attempt. + * @param delayDuration the delay between retries + */ + public RetryPolicy(int maxRetries, Duration delayDuration) { + this.maxRetries = maxRetries; + this.delayDuration = delayDuration; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return attemptAsync(context, next, context.httpRequest(), 0); + } + + private Mono attemptAsync(final HttpPipelineCallContext context, final HttpPipelineNextPolicy next, final HttpRequest originalHttpRequest, final int tryCount) { + context.withHttpRequest(originalHttpRequest.buffer()); + return next.clone().process() + .flatMap(httpResponse -> { + if (shouldRetry(httpResponse, tryCount)) { + return attemptAsync(context, next, originalHttpRequest, tryCount + 1).delaySubscription(this.delayDuration); + } else { + return Mono.just(httpResponse); + } + }) + .onErrorResume(err -> { + if (tryCount < maxRetries) { + return attemptAsync(context, next, originalHttpRequest, tryCount + 1).delaySubscription(this.delayDuration); + } else { + return Mono.error(err); + } + }); + } + + private boolean shouldRetry(HttpResponse response, int tryCount) { + int code = response.statusCode(); + return tryCount < maxRetries + && (code == HttpURLConnection.HTTP_CLIENT_TIMEOUT + || (code >= HttpURLConnection.HTTP_INTERNAL_ERROR + && code != HttpURLConnection.HTTP_NOT_IMPLEMENTED + && code != HttpURLConnection.HTTP_VERSION)); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/TimeoutPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/TimeoutPolicy.java new file mode 100644 index 0000000000000..9baaeda7dd60c --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/TimeoutPolicy.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.HttpPipelineNextPolicy; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +/** + * The Pipeline policy that limits the time allowed between sending a request + * and receiving the response. + * + */ +public class TimeoutPolicy implements HttpPipelinePolicy { + private final Duration timoutDuration; + + /** + * Creates a TimeoutPolicy. + * + * @param timoutDuration the timeout duration + */ + public TimeoutPolicy(Duration timoutDuration) { + this.timoutDuration = timoutDuration; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return next.process().timeout(this.timoutDuration); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/UserAgentPolicy.java b/common/azure-common/src/main/java/com/azure/common/http/policy/UserAgentPolicy.java new file mode 100644 index 0000000000000..ee2244cdfa456 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/UserAgentPolicy.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +/** + * Pipeline policy that adds 'User-Agent' header to a request. + */ +public class UserAgentPolicy implements HttpPipelinePolicy { + private static final String DEFAULT_USER_AGENT_HEADER = "AutoRest-Java"; + private final String userAgent; + + /** + * Creates UserAgentPolicy. + * + * @param userAgent The user agent string to add to request headers. + */ + public UserAgentPolicy(String userAgent) { + if (userAgent != null) { + this.userAgent = userAgent; + } else { + this.userAgent = DEFAULT_USER_AGENT_HEADER; + } + } + + /** + * Creates a {@link UserAgentPolicy} with a default user agent string. + */ + public UserAgentPolicy() { + this.userAgent = DEFAULT_USER_AGENT_HEADER; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + String header = context.httpRequest().headers().value("User-Agent"); + if (header == null || DEFAULT_USER_AGENT_HEADER.equals(header)) { + header = userAgent; + } else { + header = userAgent + " " + header; + } + context.httpRequest().headers().set("User-Agent", header); + return next.process(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/http/policy/package-info.java b/common/azure-common/src/main/java/com/azure/common/http/policy/package-info.java new file mode 100644 index 0000000000000..db48bb0f54953 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/policy/package-info.java @@ -0,0 +1,5 @@ +/** + * Package containing HttpPipelinePolicy interface and it's implementations. + */ +package com.azure.common.http.policy; + diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestException.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestException.java new file mode 100644 index 0000000000000..986c88c53ca25 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestException.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.rest; + +import com.azure.common.http.HttpResponse; + +/** + * An exception thrown for an invalid response with custom error information. + */ +public class RestException extends RuntimeException { + /** + * Information about the associated HTTP response. + */ + private HttpResponse response; + + /** + * The HTTP response body. + */ + private Object body; + + /** + * Initializes a new instance of the RestException class. + * + * @param message the exception message or the response content if a message is not available + * @param response the HTTP response + */ + public RestException(String message, HttpResponse response) { + super(message); + this.response = response; + } + + /** + * Initializes a new instance of the RestException class. + * + * @param message the exception message or the response content if a message is not available + * @param response the HTTP response + * @param body the deserialized response body + */ + public RestException(String message, HttpResponse response, Object body) { + super(message); + this.response = response; + this.body = body; + } + + /** + * Initializes a new instance of the RestException class. + * + * @param message the exception message or the response content if a message is not available + * @param response the HTTP response + * @param cause the Throwable which caused the creation of this RestException + */ + public RestException(String message, HttpResponse response, Throwable cause) { + super(message, cause); + this.response = response; + } + + /** + * @return information about the associated HTTP response + */ + public HttpResponse response() { + return response; + } + + /** + * @return the HTTP response body + */ + public Object body() { + return body; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestPagedResponse.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestPagedResponse.java new file mode 100644 index 0000000000000..0f3586ab09ba3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestPagedResponse.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import java.io.Closeable; +import java.util.List; + +/** + * Response of a REST API that returns page. + * + * @param the type items in the page + */ +public interface RestPagedResponse extends RestResponse>, Closeable { + /** + * Gets the items in the page. + * + * @return The items in the page. + */ + List items(); + + /** + * Get the link to retrieve RestPagedResponse containing next page. + * + * @return the next page link. + */ + String nextLink(); + + /** + * Returns the items in the page. + * + * @return The items in the page. + */ + default List body() { + return items(); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponse.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponse.java new file mode 100644 index 0000000000000..618735aa937db --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponse.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; + +/** + * REST response with a strongly-typed content specified. + * + * @param The deserialized type of the response content. + * @see RestResponseBase + */ +public interface RestResponse { + + /** + * Get the HTTP response status code. + * + * @return the status code of the HTTP response. + */ + int statusCode(); + + /** + * Get the headers from the HTTP response. + * + * @return an HttpHeaders instance containing the HTTP response headers. + */ + HttpHeaders headers(); + + /** + * Get the HTTP request which resulted in this response. + * + * @return the HTTP request. + */ + HttpRequest request(); + + /** + * @return the deserialized body of the HTTP response. + */ + T body(); +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponseBase.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponseBase.java new file mode 100644 index 0000000000000..4dad85c448796 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestResponseBase.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; + +/** + * The response of a REST request. + * + * @param The deserialized type of the response headers. + * @param The deserialized type of the response body. + */ +public class RestResponseBase implements RestResponse { + private final HttpRequest request; + private final int statusCode; + private final H deserializedHeaders; + private final HttpHeaders headers; + private final T body; + + /** + * Create RestResponseBase. + * + * @param request the request which resulted in this response + * @param statusCode the status code of the HTTP response + * @param headers the headers of the HTTP response + * @param deserializedHeaders the deserialized headers of the HTTP response + * @param body the deserialized body + */ + public RestResponseBase(HttpRequest request, int statusCode, HttpHeaders headers, T body, H deserializedHeaders) { + this.request = request; + this.statusCode = statusCode; + this.headers = headers; + this.deserializedHeaders = deserializedHeaders; + this.body = body; + } + + /** + * @return the request which resulted in this RestResponseBase. + */ + @Override + public HttpRequest request() { + return request; + } + + /** + * {@inheritDoc} + */ + @Override + public int statusCode() { + return statusCode; + } + + /** + * {@inheritDoc} + */ + @Override + public HttpHeaders headers() { + return headers; + } + + /** + * Get the headers from the HTTP response, transformed into the header type H. + * + * @return an instance of header type H, containing the HTTP response headers. + */ + public H deserializedHeaders() { + return deserializedHeaders; + } + + /** + * {@inheritDoc} + */ + @Override + public T body() { + return body; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestStreamResponse.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestStreamResponse.java new file mode 100644 index 0000000000000..3a98167eb2631 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestStreamResponse.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; +import io.netty.buffer.ByteBuf; +import reactor.core.publisher.Flux; + +import java.io.Closeable; + +/** + * REST response with a streaming content. + */ +public final class RestStreamResponse extends SimpleRestResponse> implements Closeable { + /** + * Creates RestStreamResponse. + * + * @param request the request which resulted in this response + * @param statusCode the status code of the HTTP response + * @param headers the headers of the HTTP response + * @param body the streaming body + */ + public RestStreamResponse(HttpRequest request, int statusCode, HttpHeaders headers, Flux body) { + super(request, statusCode, headers, body); + } + + /** + * @return the stream content + */ + @Override + public Flux body() { + return super.body(); + } + + /** + * Disposes the connection associated with this RestStreamResponse. + */ + @Override + public void close() { + body().subscribe().dispose(); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/RestVoidResponse.java b/common/azure-common/src/main/java/com/azure/common/http/rest/RestVoidResponse.java new file mode 100644 index 0000000000000..22e7a5864a0aa --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/RestVoidResponse.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; + +/** + * REST response containing only a status code and raw headers. + */ +public final class RestVoidResponse extends SimpleRestResponse { + /** + * Creates RestVoidResponse. + * + * @param request the request which resulted in this response + * @param statusCode the status code of the HTTP response + * @param headers the headers of the HTTP response + */ + public RestVoidResponse(HttpRequest request, int statusCode, HttpHeaders headers) { + super(request, statusCode, headers, null); + } + +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/SimpleRestResponse.java b/common/azure-common/src/main/java/com/azure/common/http/rest/SimpleRestResponse.java new file mode 100644 index 0000000000000..bc7b15d2382e9 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/SimpleRestResponse.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ +package com.azure.common.http.rest; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpRequest; + +/** + * REST response with a strongly-typed content specified. + * + * @param The deserialized type of the response content. + */ +public class SimpleRestResponse implements RestResponse { + private final HttpRequest request; + private final int statusCode; + private final HttpHeaders headers; + private final T body; + + /** + * Creates RestResponse. + * + * @param request the request which resulted in this response + * @param statusCode the status code of the HTTP response + * @param headers the headers of the HTTP response + * @param body the deserialized body + */ + public SimpleRestResponse(HttpRequest request, int statusCode, HttpHeaders headers, T body) { + this.request = request; + this.statusCode = statusCode; + this.headers = headers; + this.body = body; + } + + /** + * @return the request which resulted in this RestResponse. + */ + @Override + public HttpRequest request() { + return request; + } + + /** + * @return the status code of the HTTP response. + */ + @Override + public int statusCode() { + return statusCode; + } + + /** + * {@inheritDoc} + */ + @Override + public HttpHeaders headers() { + return headers; + } + + /** + * @return the deserialized body of the HTTP response. + */ + @Override + public T body() { + return body; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/http/rest/package-info.java b/common/azure-common/src/main/java/com/azure/common/http/rest/package-info.java new file mode 100644 index 0000000000000..9ed7cfe59057e --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/http/rest/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing REST-related APIs. + */ +package com.azure.common.http.rest; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/Base64Url.java b/common/azure-common/src/main/java/com/azure/common/implementation/Base64Url.java new file mode 100644 index 0000000000000..c478d8dbbbbeb --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/Base64Url.java @@ -0,0 +1,126 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.implementation.util.Base64Util; + +import java.util.Arrays; + +/** + * Wrapper over Base64Url encoded byte array used during serialization and deserialization. + */ +public final class Base64Url { + /** + * The Base64Url encoded bytes. + */ + private final byte[] bytes; + + /** + * Creates a new Base64Url object with the specified encoded string. + * + * @param string The encoded string. + */ + public Base64Url(String string) { + if (string == null) { + this.bytes = null; + } else { + string = unquote(string); + this.bytes = string.getBytes(); + } + } + + /** + * Creates a new Base64Url object with the specified encoded bytes. + * + * @param bytes The encoded bytes. + */ + public Base64Url(byte[] bytes) { + this.bytes = unquote(bytes); + } + + private static byte[] unquote(byte[] bytes) { + if (bytes != null && bytes.length > 1) { + bytes = unquote(new String(bytes)).getBytes(); + } + return bytes; + } + + private static String unquote(String string) { + if (string != null && !string.isEmpty()) { + final char firstCharacter = string.charAt(0); + if (firstCharacter == '\"' || firstCharacter == '\'') { + final int base64UrlStringLength = string.length(); + final char lastCharacter = string.charAt(base64UrlStringLength - 1); + if (lastCharacter == firstCharacter) { + string = string.substring(1, base64UrlStringLength - 1); + } + } + } + return string; + } + + /** + * Encode a byte array into Base64Url encoded bytes. + * + * @param bytes The byte array to encode. + * @return a Base64Url instance + */ + public static Base64Url encode(byte[] bytes) { + if (bytes == null) { + return new Base64Url((String) null); + } else { + return new Base64Url(Base64Util.encodeURLWithoutPadding(bytes)); + } + } + + /** + * Returns the underlying encoded byte array. + * + * @return The underlying encoded byte array. + */ + public byte[] encodedBytes() { + return bytes; + } + + /** + * Decode the bytes and return. + * + * @return The decoded byte array. + */ + public byte[] decodedBytes() { + if (this.bytes == null) { + return null; + } + + final byte[] decodedBytes = Base64Util.decodeURL(bytes); + return decodedBytes; + } + + @Override + public String toString() { + return bytes == null ? null : new String(bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + + if (!(obj instanceof Base64Url)) { + return false; + } + + Base64Url rhs = (Base64Url) obj; + return Arrays.equals(this.bytes, rhs.encodedBytes()); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/CollectionFormat.java b/common/azure-common/src/main/java/com/azure/common/implementation/CollectionFormat.java new file mode 100644 index 0000000000000..bf257ebd00bd6 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/CollectionFormat.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +/** + * Swagger collection format to use for joining {@link java.util.List} parameters in + * paths, queries, and headers. + * See https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#fixed-fields-7. + */ +public enum CollectionFormat { + /** + * Comma separated values. + * E.g. foo,bar + */ + CSV(","), + /** + * Space separated values. + * E.g. foo bar + */ + SSV(" "), + /** + * Tab separated values. + * E.g. foo\tbar + */ + TSV("\t"), + /** + * Pipe(|) separated values. + * E.g. foo|bar + */ + PIPES("|"), + /** + * Corresponds to multiple parameter instances instead of multiple values + * for a single instance. + * E.g. foo=bar&foo=baz + */ + MULTI("&"); + + /** + * The delimiter separating the values. + */ + private String delimiter; + + /** + * Creates CollectionFormat enum. + * + * @param delimiter the delimiter as a string. + */ + CollectionFormat(String delimiter) { + this.delimiter = delimiter; + } + + /** + * Gets the delimiter used to join a list of parameters. + * + * @return the delimiter of the current collection format. + */ + public String getDelimiter() { + return delimiter; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/DateTimeRfc1123.java b/common/azure-common/src/main/java/com/azure/common/implementation/DateTimeRfc1123.java new file mode 100644 index 0000000000000..0e9ed137e5f25 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/DateTimeRfc1123.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** + * Wrapper over java.time.OffsetDateTime used for specifying RFC1123 format during serialization and deserialization. + */ +public final class DateTimeRfc1123 { + /** + * The pattern of the datetime used for RFC1123 datetime format. + */ + private static final DateTimeFormatter RFC1123_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withZone(ZoneId.of("UTC")).withLocale(Locale.US); + /** + * The actual datetime object. + */ + private final OffsetDateTime dateTime; + + /** + * Creates a new DateTimeRfc1123 object with the specified DateTime. + * @param dateTime The DateTime object to wrap. + */ + public DateTimeRfc1123(OffsetDateTime dateTime) { + this.dateTime = dateTime; + } + + /** + * Creates a new DateTimeRfc1123 object with the specified DateTime. + * @param formattedString The datetime string in RFC1123 format + */ + public DateTimeRfc1123(String formattedString) { + this.dateTime = OffsetDateTime.parse(formattedString, DateTimeFormatter.RFC_1123_DATE_TIME); + } + + /** + * Returns the underlying DateTime. + * @return The underlying DateTime. + */ + public OffsetDateTime dateTime() { + if (this.dateTime == null) { + return null; + } + return this.dateTime; + } + + @Override + public String toString() { + return RFC1123_DATE_TIME_FORMATTER.format(this.dateTime); + } + + @Override + public int hashCode() { + return this.dateTime.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + + if (!(obj instanceof DateTimeRfc1123)) { + return false; + } + + DateTimeRfc1123 rhs = (DateTimeRfc1123) obj; + return this.dateTime.equals(rhs.dateTime()); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/EncodedParameter.java b/common/azure-common/src/main/java/com/azure/common/implementation/EncodedParameter.java new file mode 100644 index 0000000000000..08fe66508588c --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/EncodedParameter.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +/** + * Type representing result of encoding a query parameter or header name/value pair for a + * HTTP request. It contains the query parameter or header name, plus the query parameter's value or + * header's value. + */ +class EncodedParameter { + private final String name; + private final String encodedValue; + + /** + * Create a EncodedParameter using the provided parameter name and encoded value. + * + * @param name the name of the new parameter + * @param encodedValue the encoded value of the new parameter + */ + EncodedParameter(String name, String encodedValue) { + this.name = name; + this.encodedValue = encodedValue; + } + + /** + * Get this parameter's name. + * + * @return the name of this parameter + */ + public String name() { + return name; + } + + /** + * Get the encoded value for this parameter. + * + * @return the encoded value for this parameter + */ + public String encodedValue() { + return encodedValue; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/OperationDescription.java b/common/azure-common/src/main/java/com/azure/common/implementation/OperationDescription.java new file mode 100644 index 0000000000000..9fe92687905cc --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/OperationDescription.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.http.HttpRequest; + +import java.io.Serializable; +import java.net.URL; +import java.util.Map; + +/** + * Type that holds composes data from an originating operation + * that can be used to resume the polling of the original operation. + */ +public class OperationDescription implements Serializable { + private Serializable pollStrategyData; + private Map headers; + private String httpMethod; + private URL url; + private String fullyQualifiedMethodName; + + /** + * Create OperationDescription. + */ + public OperationDescription() { + this.fullyQualifiedMethodName = null; + this.pollStrategyData = null; + this.headers = null; + this.url = null; + this.httpMethod = null; + } + + /** + * Create a new Substitution. + * + * @param fullyQualifiedMethodName the fully qualified method name from the originating call + * @param pollStrategyData the data for the originating methods polling strategy + * @param originalHttpRequest the initial http request from the originating call + */ + public OperationDescription(String fullyQualifiedMethodName, + Serializable pollStrategyData, + HttpRequest originalHttpRequest) { + this.fullyQualifiedMethodName = fullyQualifiedMethodName; + this.pollStrategyData = pollStrategyData; + this.headers = originalHttpRequest.headers().toMap(); + this.url = originalHttpRequest.url(); + this.httpMethod = originalHttpRequest.httpMethod().toString(); + } + + /** + * Get the Serializable poll strategy data. + * + * @return the Serializable poll strategy data + */ + public Serializable pollStrategyData() { + return this.pollStrategyData; + } + + /** + * Get the originating requests url. + * + * @return the originating requests url + */ + public URL url() { + return this.url; + } + + /** + * @return the originating requests http method. + */ + public String httpMethod() { + return this.httpMethod; + } + + /** + * Get the originating requests headers. + * + * @return the originating requests headers + */ + public Map headers() { + return this.headers; + } + + /** + * Get the originating method name. + * + * @return the originating method name + */ + String methodName() { + int lastIndex = this.fullyQualifiedMethodName.lastIndexOf("."); + return this.fullyQualifiedMethodName.substring(lastIndex + 1); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/PercentEscaper.java b/common/azure-common/src/main/java/com/azure/common/implementation/PercentEscaper.java new file mode 100644 index 0000000000000..a2b81844bb4f7 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/PercentEscaper.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import java.util.ArrayList; +import java.util.List; + +/** + * An escaper that escapes URL data through percent encoding. + */ +final class PercentEscaper { + + private static final String[] HEX = { + "%00", "%01", "%02", "%03", "%04", "%05", "%06", "%07", + "%08", "%09", "%0a", "%0b", "%0c", "%0d", "%0e", "%0f", + "%10", "%11", "%12", "%13", "%14", "%15", "%16", "%17", + "%18", "%19", "%1a", "%1b", "%1c", "%1d", "%1e", "%1f", + "%20", "%21", "%22", "%23", "%24", "%25", "%26", "%27", + "%28", "%29", "%2a", "%2b", "%2c", "%2d", "%2e", "%2f", + "%30", "%31", "%32", "%33", "%34", "%35", "%36", "%37", + "%38", "%39", "%3a", "%3b", "%3c", "%3d", "%3e", "%3f", + "%40", "%41", "%42", "%43", "%44", "%45", "%46", "%47", + "%48", "%49", "%4a", "%4b", "%4c", "%4d", "%4e", "%4f", + "%50", "%51", "%52", "%53", "%54", "%55", "%56", "%57", + "%58", "%59", "%5a", "%5b", "%5c", "%5d", "%5e", "%5f", + "%60", "%61", "%62", "%63", "%64", "%65", "%66", "%67", + "%68", "%69", "%6a", "%6b", "%6c", "%6d", "%6e", "%6f", + "%70", "%71", "%72", "%73", "%74", "%75", "%76", "%77", + "%78", "%79", "%7a", "%7b", "%7c", "%7d", "%7e", "%7f", + "%80", "%81", "%82", "%83", "%84", "%85", "%86", "%87", + "%88", "%89", "%8a", "%8b", "%8c", "%8d", "%8e", "%8f", + "%90", "%91", "%92", "%93", "%94", "%95", "%96", "%97", + "%98", "%99", "%9a", "%9b", "%9c", "%9d", "%9e", "%9f", + "%a0", "%a1", "%a2", "%a3", "%a4", "%a5", "%a6", "%a7", + "%a8", "%a9", "%aa", "%ab", "%ac", "%ad", "%ae", "%af", + "%b0", "%b1", "%b2", "%b3", "%b4", "%b5", "%b6", "%b7", + "%b8", "%b9", "%ba", "%bb", "%bc", "%bd", "%be", "%bf", + "%c0", "%c1", "%c2", "%c3", "%c4", "%c5", "%c6", "%c7", + "%c8", "%c9", "%ca", "%cb", "%cc", "%cd", "%ce", "%cf", + "%d0", "%d1", "%d2", "%d3", "%d4", "%d5", "%d6", "%d7", + "%d8", "%d9", "%da", "%db", "%dc", "%dd", "%de", "%df", + "%e0", "%e1", "%e2", "%e3", "%e4", "%e5", "%e6", "%e7", + "%e8", "%e9", "%ea", "%eb", "%ec", "%ed", "%ee", "%ef", + "%f0", "%f1", "%f2", "%f3", "%f4", "%f5", "%f6", "%f7", + "%f8", "%f9", "%fa", "%fb", "%fc", "%fd", "%fe", "%ff" + }; + + private final boolean usePlusForSpace; + + private final List safeChars = new ArrayList<>(); + + /** + * Creates a percent escaper. + * @param safeChars a collection of characters that will not be escaped + * @param usePlusForSpace escape ' ' as '+' if true, "%20" otherwise + */ + PercentEscaper(String safeChars, boolean usePlusForSpace) { + for (int i = 0; i != safeChars.length(); i++) { + this.safeChars.add(safeChars.charAt(i)); + } + this.usePlusForSpace = usePlusForSpace; + } + + /** + * Creates a percent escaper with default settings and encode ' ' as "%20". + */ + PercentEscaper() { + this("-._~", false); + } + + /** + * Escapes a string with the current settings on the escaper. + * @param original the origin string to escape + * @return the escaped string + */ + public String escape(String original) { + StringBuilder output = new StringBuilder(); + for (int i = 0; i != utf16ToAscii(original).length(); i++) { + char c = original.charAt(i); + if (c == ' ') { + output.append(usePlusForSpace ? "+" : HEX[' ']); + } else if (c >= 'a' && c <= 'z') { + output.append(c); + } else if (c >= 'A' && c <= 'Z') { + output.append(c); + } else if (c >= '0' && c <= '9') { + output.append(c); + } else if (safeChars.contains(c)) { + output.append(c); + } else { + output.append(HEX[c]); + } + } + return output.toString(); + } + + private String utf16ToAscii(String input) { + byte[] ascii = new byte[input.length()]; + for (int i = 0; i < input.length(); i++) { + ascii[i] = (byte) input.charAt(i); + } + return new String(ascii); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/RestProxy.java b/common/azure-common/src/main/java/com/azure/common/implementation/RestProxy.java new file mode 100644 index 0000000000000..7416a3dbc5b47 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/RestProxy.java @@ -0,0 +1,625 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.http.rest.RestException; +import com.azure.common.ServiceClient; +import com.azure.common.annotations.ResumeOperation; +import com.azure.common.credentials.ServiceClientCredentials; +import com.azure.common.implementation.http.ContentType; +import com.azure.common.http.ContextData; +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.policy.HttpPipelinePolicy; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.http.UrlBuilder; +import com.azure.common.http.policy.CookiePolicy; +import com.azure.common.http.policy.CredentialsPolicy; +import com.azure.common.http.policy.RetryPolicy; +import com.azure.common.http.policy.UserAgentPolicy; +import com.azure.common.http.rest.RestResponse; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.implementation.serializer.HttpResponseDecoder; +import com.azure.common.implementation.serializer.HttpResponseDecoder.HttpDecodedResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import com.azure.common.implementation.util.FluxUtil; +import com.azure.common.implementation.util.TypeUtil; +import io.netty.buffer.ByteBuf; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Type to create a proxy implementation for an interface describing REST API methods. + * + * RestProxy can create proxy implementations for interfaces with methods that return + * deserialized Java objects as well as asynchronous Single objects that resolve to a + * deserialized Java object. + */ +public class RestProxy implements InvocationHandler { + private final HttpPipeline httpPipeline; + private final SerializerAdapter serializer; + private final SwaggerInterfaceParser interfaceParser; + private final HttpResponseDecoder decoder; + + /** + * Create a RestProxy. + * + * @param httpPipeline the HttpPipelinePolicy and HttpClient httpPipeline that will be used to send HTTP + * requests. + * @param serializer the serializer that will be used to convert response bodies to POJOs. + * @param interfaceParser the parser that contains information about the interface describing REST API methods + * that this RestProxy "implements". + */ + public RestProxy(HttpPipeline httpPipeline, SerializerAdapter serializer, SwaggerInterfaceParser interfaceParser) { + this.httpPipeline = httpPipeline; + this.serializer = serializer; + this.interfaceParser = interfaceParser; + this.decoder = new HttpResponseDecoder(this.serializer); + } + + /** + * Get the SwaggerMethodParser for the provided method. The Method must exist on the Swagger + * interface that this RestProxy was created to "implement". + * + * @param method the method to get a SwaggerMethodParser for + * @return the SwaggerMethodParser for the provided method + */ + private SwaggerMethodParser methodParser(Method method) { + return interfaceParser.methodParser(method); + } + + /** + * Get the SerializerAdapter used by this RestProxy. + * + * @return The SerializerAdapter used by this RestProxy + */ + public SerializerAdapter serializer() { + return serializer; + } + + /** + * Send the provided request asynchronously, applying any request policies provided to the HttpClient instance. + * + * @param request the HTTP request to send + * @param contextData the context + * @return a {@link Mono} that emits HttpResponse asynchronously + */ + public Mono send(HttpRequest request, ContextData contextData) { + return httpPipeline.send(httpPipeline.newContext(request, contextData)); + } + + @Override + public Object invoke(Object proxy, final Method method, Object[] args) { + try { + final SwaggerMethodParser methodParser; + final HttpRequest request; + if (method.isAnnotationPresent(ResumeOperation.class)) { + OperationDescription opDesc = (OperationDescription) args[0]; + Method resumeMethod = null; + Method[] methods = method.getDeclaringClass().getMethods(); + for (Method origMethod : methods) { + if (origMethod.getName().equals(opDesc.methodName())) { + resumeMethod = origMethod; + break; + } + } + + methodParser = methodParser(resumeMethod); + request = createHttpRequest(opDesc, methodParser, args); + final Type returnType = methodParser.returnType(); + return handleResumeOperation(request, opDesc, methodParser, returnType); + + } else { + methodParser = methodParser(method); + request = createHttpRequest(methodParser, args); + final Mono asyncResponse = send(request, methodParser.contextData(args).addData("caller-method", methodParser.fullyQualifiedMethodName())); + // + Mono asyncDecodedResponse = this.decoder.decode(asyncResponse, methodParser); + // + return handleHttpResponse(request, asyncDecodedResponse, methodParser, methodParser.returnType()); + } + + } catch (Exception e) { + throw Exceptions.propagate(e); + } + } + + /** + * Create a HttpRequest for the provided Swagger method using the provided arguments. + * + * @param methodParser the Swagger method parser to use + * @param args the arguments to use to populate the method's annotation values + * @return a HttpRequest + * @throws IOException thrown if the body contents cannot be serialized + */ + @SuppressWarnings("unchecked") + private HttpRequest createHttpRequest(SwaggerMethodParser methodParser, Object[] args) throws IOException { + UrlBuilder urlBuilder; + + // Sometimes people pass in a full URL for the value of their PathParam annotated argument. + // This definitely happens in paging scenarios. In that case, just use the full URL and + // ignore the Host annotation. + final String path = methodParser.path(args); + final UrlBuilder pathUrlBuilder = UrlBuilder.parse(path); + if (pathUrlBuilder.scheme() != null) { + urlBuilder = pathUrlBuilder; + } + else { + urlBuilder = new UrlBuilder(); + + // We add path to the UrlBuilder first because this is what is + // provided to the HTTP Method annotation. Any path substitutions + // from other substitution annotations will overwrite this. + urlBuilder.withPath(path); + + final String scheme = methodParser.scheme(args); + urlBuilder.withScheme(scheme); + + final String host = methodParser.host(args); + urlBuilder.withHost(host); + } + + for (final EncodedParameter queryParameter : methodParser.encodedQueryParameters(args)) { + urlBuilder.setQueryParameter(queryParameter.name(), queryParameter.encodedValue()); + } + + final URL url = urlBuilder.toURL(); + final HttpRequest request = configRequest(new HttpRequest(methodParser.httpMethod(), url), methodParser, args); + + // Headers from Swagger method arguments always take precedence over inferred headers from body types + for (final HttpHeader header : methodParser.headers(args)) { + request.withHeader(header.name(), header.value()); + } + + return request; + } + + /** + * Create a HttpRequest for the provided Swagger method using the provided arguments. + * + * @param methodParser the Swagger method parser to use + * @param args the arguments to use to populate the method's annotation values + * @return a HttpRequest + * @throws IOException thrown if the body contents cannot be serialized + */ + @SuppressWarnings("unchecked") + private HttpRequest createHttpRequest(OperationDescription operationDescription, SwaggerMethodParser methodParser, Object[] args) throws IOException { + final HttpRequest request = configRequest(new HttpRequest(methodParser.httpMethod(), operationDescription.url()), methodParser, args); + + // Headers from Swagger method arguments always take precedence over inferred headers from body types + for (final String headerName : operationDescription.headers().keySet()) { + request.withHeader(headerName, operationDescription.headers().get(headerName)); + } + + return request; + } + + private HttpRequest configRequest(HttpRequest request, SwaggerMethodParser methodParser, Object[] args) throws IOException { + final Object bodyContentObject = methodParser.body(args); + if (bodyContentObject == null) { + request.headers().set("Content-Length", "0"); + } else { + String contentType = methodParser.bodyContentType(); + if (contentType == null || contentType.isEmpty()) { + if (bodyContentObject instanceof byte[] || bodyContentObject instanceof String) { + contentType = ContentType.APPLICATION_OCTET_STREAM; + } + else { + contentType = ContentType.APPLICATION_JSON; + } + } + + request.headers().set("Content-Type", contentType); + + boolean isJson = false; + final String[] contentTypeParts = contentType.split(";"); + for (String contentTypePart : contentTypeParts) { + if (contentTypePart.trim().equalsIgnoreCase(ContentType.APPLICATION_JSON)) { + isJson = true; + break; + } + } + + if (isJson) { + final String bodyContentString = serializer.serialize(bodyContentObject, SerializerEncoding.JSON); + request.withBody(bodyContentString); + } + else if (FluxUtil.isFluxByteBuf(methodParser.bodyJavaType())) { + // Content-Length or Transfer-Encoding: chunked must be provided by a user-specified header when a Flowable is given for the body. + //noinspection ConstantConditions + request.withBody((Flux) bodyContentObject); + } + else if (bodyContentObject instanceof byte[]) { + request.withBody((byte[]) bodyContentObject); + } + else if (bodyContentObject instanceof String) { + final String bodyContentString = (String) bodyContentObject; + if (!bodyContentString.isEmpty()) { + request.withBody(bodyContentString); + } + } + else { + final String bodyContentString = serializer.serialize(bodyContentObject, SerializerEncoding.fromHeaders(request.headers())); + request.withBody(bodyContentString); + } + } + + return request; + } + + private Mono ensureExpectedStatus(Mono asyncDecodedResponse, final SwaggerMethodParser methodParser) { + return asyncDecodedResponse + .flatMap(decodedHttpResponse -> ensureExpectedStatus(decodedHttpResponse, methodParser, null)); + } + + private static Exception instantiateUnexpectedException(Class exceptionType, + Class exceptionBodyType, + HttpResponse httpResponse, + String responseContent, + Object responseDecodedContent) { + final int responseStatusCode = httpResponse.statusCode(); + String contentType = httpResponse.headerValue("Content-Type"); + String bodyRepresentation; + if ("application/octet-stream".equalsIgnoreCase(contentType)) { + bodyRepresentation = "(" + httpResponse.headerValue("Content-Length") + "-byte body)"; + } else { + bodyRepresentation = responseContent.isEmpty() ? "(empty body)" : "\"" + responseContent + "\""; + } + + Exception result; + try { + final Constructor exceptionConstructor = exceptionType.getConstructor(String.class, HttpResponse.class, exceptionBodyType); + result = exceptionConstructor.newInstance("Status code " + responseStatusCode + ", " + bodyRepresentation, + httpResponse, + responseDecodedContent); + } catch (ReflectiveOperationException e) { + String message = "Status code " + responseStatusCode + ", but an instance of " + + exceptionType.getCanonicalName() + " cannot be created." + + " Response body: " + bodyRepresentation; + // + result = new IOException(message, e); + } + return result; + } + + /** + * Create a publisher that (1) emits error if the provided response {@code decodedResponse} has + * 'disallowed status code' OR (2) emits provided response if it's status code ia allowed. + * + * 'disallowed status code' is one of the status code defined in the provided SwaggerMethodParser + * or is in the int[] of additional allowed status codes. + * + * @param decodedResponse The HttpResponse to check. + * @param methodParser The method parser that contains information about the service interface + * method that initiated the HTTP request. + * @param additionalAllowedStatusCodes Additional allowed status codes that are permitted based + * on the context of the HTTP request. + * @return An async-version of the provided decodedResponse. + */ + public Mono ensureExpectedStatus(final HttpDecodedResponse decodedResponse, final SwaggerMethodParser methodParser, int[] additionalAllowedStatusCodes) { + final int responseStatusCode = decodedResponse.sourceResponse().statusCode(); + final Mono asyncResult; + if (!methodParser.isExpectedResponseStatusCode(responseStatusCode, additionalAllowedStatusCodes)) { + Mono bodyAsString = decodedResponse.sourceResponse().bodyAsString(); + // + asyncResult = bodyAsString.flatMap((Function>) responseContent -> { + // bodyAsString() emits non-empty string, now look for decoded version of same string + Mono decodedErrorBody = decodedResponse.decodedBody(); + // + return decodedErrorBody.flatMap((Function>) responseDecodedErrorObject -> { + // decodedBody() emits 'responseDecodedErrorObject' the successfully decoded exception body object + Throwable exception = instantiateUnexpectedException(methodParser.exceptionType(), + methodParser.exceptionBodyType(), + decodedResponse.sourceResponse(), + responseContent, + responseDecodedErrorObject); + return Mono.error(exception); + // + }).switchIfEmpty(Mono.defer((Supplier>) () -> { + // decodedBody() emits empty, indicate unable to decode 'responseContent', + // create exception with un-decodable content string and without exception body object. + Throwable exception = instantiateUnexpectedException(methodParser.exceptionType(), + methodParser.exceptionBodyType(), + decodedResponse.sourceResponse(), + responseContent, + null); + return Mono.error(exception); + // + })); + }).switchIfEmpty(Mono.defer((Supplier>) () -> { + // bodyAsString() emits empty, indicate no body, create exception empty content string no exception body object. + Throwable exception = instantiateUnexpectedException(methodParser.exceptionType(), + methodParser.exceptionBodyType(), + decodedResponse.sourceResponse(), + "", + null); + return Mono.error(exception); + // + })); + } else { + asyncResult = Mono.just(decodedResponse); + } + return asyncResult; + } + + private Mono handleRestResponseReturnType(HttpDecodedResponse response, SwaggerMethodParser methodParser, Type entityType) { + Mono asyncResult; + + if (TypeUtil.isTypeOrSubTypeOf(entityType, RestResponse.class)) { + Type bodyType = TypeUtil.getRestResponseBodyType(entityType); + + if (TypeUtil.isTypeOrSubTypeOf(bodyType, Void.class)) { + asyncResult = response.sourceResponse().body().ignoreElements() + .then(Mono.just(createResponse(response, entityType, null))); + } else { + asyncResult = handleBodyReturnType(response, methodParser, bodyType) + .map((Function>) bodyAsObject -> createResponse(response, entityType, bodyAsObject)) + .switchIfEmpty(Mono.defer((Supplier>>) () -> Mono.just(createResponse(response, entityType, null)))); + } + } else { + // For now we're just throwing if the Maybe didn't emit a value. + asyncResult = handleBodyReturnType(response, methodParser, entityType); + } + + return asyncResult; + } + + private RestResponse createResponse(HttpDecodedResponse response, Type entityType, Object bodyAsObject) { + final HttpResponse httpResponse = response.sourceResponse(); + final HttpRequest httpRequest = httpResponse.request(); + final int responseStatusCode = httpResponse.statusCode(); + final HttpHeaders responseHeaders = httpResponse.headers(); + + // determine the type of response class. If the type is the 'RestResponse' interface, we will use the + // 'RestResponseBase' class instead. + Class> cls = (Class>) TypeUtil.getRawClass(entityType); + if (cls.equals(RestResponse.class)) { + cls = (Class>) (Object) RestResponseBase.class; + } + + // we try to find the most specific constructor, which we do in the following order: + // 1) (HttpRequest httpRequest, int statusCode, HttpHeaders headers, Object body, Object deserializedHeaders) + // 2) (HttpRequest httpRequest, int statusCode, HttpHeaders headers, Object body) + // 3) (HttpRequest httpRequest, int statusCode, HttpHeaders headers) + List> ctors = Arrays.stream(cls.getDeclaredConstructors()) + .filter(ctor -> { + int paramCount = ctor.getParameterCount(); + return paramCount >= 3 && paramCount <= 5; + }) + .sorted(Comparator.comparingInt(Constructor::getParameterCount)) + .collect(Collectors.toList()); + + if (ctors.isEmpty()) { + throw new RuntimeException("Cannot find suitable constructor for class " + cls); + } + + // try to create an instance using our list of potential candidates + for (int i = 0; i < ctors.size(); i++) { + final Constructor> ctor = (Constructor>) ctors.get(i); + + try { + final int paramCount = ctor.getParameterCount(); + + switch (paramCount) { + case 3: + return ctor.newInstance(httpRequest, responseStatusCode, responseHeaders); + case 4: + return ctor.newInstance(httpRequest, responseStatusCode, responseHeaders, bodyAsObject); + case 5: + return ctor.newInstance(httpRequest, responseStatusCode, responseHeaders, bodyAsObject, response.decodedHeaders().block()); + default: + throw new IllegalStateException("Response constructor with expected parameters not found."); + } + } catch (IllegalAccessException | InvocationTargetException | InstantiationException e) { + throw reactor.core.Exceptions.propagate(e); + } + } + // error + throw new RuntimeException("Cannot find suitable constructor for class " + cls); + } + + protected final Mono handleBodyReturnType(final HttpDecodedResponse response, final SwaggerMethodParser methodParser, final Type entityType) { + final int responseStatusCode = response.sourceResponse().statusCode(); + final HttpMethod httpMethod = methodParser.httpMethod(); + final Type returnValueWireType = methodParser.returnValueWireType(); + + final Mono asyncResult; + if (httpMethod == HttpMethod.HEAD + && (TypeUtil.isTypeOrSubTypeOf(entityType, Boolean.TYPE) || TypeUtil.isTypeOrSubTypeOf(entityType, Boolean.class))) { + boolean isSuccess = (responseStatusCode / 100) == 2; + asyncResult = Mono.just(isSuccess); + } else if (TypeUtil.isTypeOrSubTypeOf(entityType, byte[].class)) { + // Mono + Mono responseBodyBytesAsync = response.sourceResponse().bodyAsByteArray(); + if (returnValueWireType == Base64Url.class) { + // Mono + responseBodyBytesAsync = responseBodyBytesAsync.map(base64UrlBytes -> new Base64Url(base64UrlBytes).decodedBytes()); + } + asyncResult = responseBodyBytesAsync; + } else if (FluxUtil.isFluxByteBuf(entityType)) { + // Mono> + asyncResult = Mono.just(response.sourceResponse().body()); + } else { + // Mono + asyncResult = response.decodedBody(); + } + return asyncResult; + } + + protected Object handleHttpResponse(final HttpRequest httpRequest, Mono asyncDecodedHttpResponse, SwaggerMethodParser methodParser, Type returnType) { + return handleRestReturnType(asyncDecodedHttpResponse, methodParser, returnType); + } + + protected Object handleResumeOperation(HttpRequest httpRequest, OperationDescription operationDescription, SwaggerMethodParser methodParser, Type returnType) + throws Exception { + throw new Exception("The resume operation is not available in the base RestProxy class."); + } + + /** + * Handle the provided asynchronous HTTP response and return the deserialized value. + * + * @param asyncHttpDecodedResponse the asynchronous HTTP response to the original HTTP request + * @param methodParser the SwaggerMethodParser that the request originates from + * @param returnType the type of value that will be returned + * @return the deserialized result + */ + public final Object handleRestReturnType(Mono asyncHttpDecodedResponse, final SwaggerMethodParser methodParser, final Type returnType) { + final Mono asyncExpectedResponse = ensureExpectedStatus(asyncHttpDecodedResponse, methodParser); + final Object result; + if (TypeUtil.isTypeOrSubTypeOf(returnType, Mono.class)) { + final Type monoTypeParam = TypeUtil.getTypeArgument(returnType); + if (TypeUtil.isTypeOrSubTypeOf(monoTypeParam, Void.class)) { + // ProxyMethod ReturnType: Mono + result = asyncExpectedResponse.then(); + } else { + // ProxyMethod ReturnType: Mono> + result = asyncExpectedResponse.flatMap(response -> + handleRestResponseReturnType(response, methodParser, monoTypeParam)); + } + } else if (FluxUtil.isFluxByteBuf(returnType)) { + // ProxyMethod ReturnType: Flux + result = asyncExpectedResponse.flatMapMany(ar -> ar.sourceResponse().body()); + } else if (TypeUtil.isTypeOrSubTypeOf(returnType, void.class) || TypeUtil.isTypeOrSubTypeOf(returnType, Void.class)) { + // ProxyMethod ReturnType: Void + asyncExpectedResponse.block(); + result = null; + } else { + // ProxyMethod ReturnType: T where T != async (Mono, Flux) or sync Void + // Block the deserialization until a value T is received + result = asyncExpectedResponse + .flatMap(httpResponse -> handleRestResponseReturnType(httpResponse, methodParser, returnType)) + .block(); + } + return result; + } + + /** + * Create an instance of the default serializer. + * + * @return the default serializer + */ + public static SerializerAdapter createDefaultSerializer() { + return new JacksonAdapter(); + } + + /** + * Create the default HttpPipeline. + * + * @return the default HttpPipeline + */ + public static HttpPipeline createDefaultPipeline() { + return createDefaultPipeline((HttpPipelinePolicy) null); + } + + /** + * Create the default HttpPipeline. + * + * @param credentials the credentials to use to apply authentication to the pipeline + * @return the default HttpPipeline + */ + public static HttpPipeline createDefaultPipeline(ServiceClientCredentials credentials) { + return createDefaultPipeline(new CredentialsPolicy(credentials)); + } + + /** + * Create the default HttpPipeline. + * @param credentialsPolicy the credentials policy factory to use to apply authentication to the + * pipeline + * @return the default HttpPipeline + */ + public static HttpPipeline createDefaultPipeline(HttpPipelinePolicy credentialsPolicy) { + List policies = new ArrayList<>(); + policies.add(new UserAgentPolicy()); + policies.add(new RetryPolicy()); + policies.add(new CookiePolicy()); + if (credentialsPolicy != null) { + policies.add(credentialsPolicy); + } + return new HttpPipeline(policies.toArray(new HttpPipelinePolicy[policies.size()])); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * + * @param swaggerInterface the Swagger interface to provide a proxy implementation for + * @param the type of the Swagger interface + * @return a proxy implementation of the provided Swagger interface + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface) { + return create(swaggerInterface, createDefaultPipeline(), createDefaultSerializer()); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * + * @param swaggerInterface the Swagger interface to provide a proxy implementation for + * + * @param httpPipeline the HttpPipelinePolicy and HttpClient pipline that will be used to send Http + * requests + * @param the type of the Swagger interface + * @return a proxy implementation of the provided Swagger interface + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, HttpPipeline httpPipeline) { + return create(swaggerInterface, httpPipeline, createDefaultSerializer()); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * + * @param swaggerInterface the Swagger interface to provide a proxy implementation for + * @param serviceClient the ServiceClient that contains the details to use to create the + * RestProxy implementation of the swagger interface + * @param the type of the Swagger interface + * @return a proxy implementation of the provided Swagger interface + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, ServiceClient serviceClient) { + return create(swaggerInterface, serviceClient.httpPipeline(), serviceClient.serializerAdapter()); + } + + /** + * Create a proxy implementation of the provided Swagger interface. + * + * @param swaggerInterface the Swagger interface to provide a proxy implementation for + * @param httpPipeline the HttpPipelinePolicy and HttpClient pipline that will be used to send Http + * requests + * @param serializer the serializer that will be used to convert POJOs to and from request and + * response bodies + * @param the type of the Swagger interface. + * @return a proxy implementation of the provided Swagger interface + */ + @SuppressWarnings("unchecked") + public static A create(Class swaggerInterface, HttpPipeline httpPipeline, SerializerAdapter serializer) { + final SwaggerInterfaceParser interfaceParser = new SwaggerInterfaceParser(swaggerInterface, serializer); + final RestProxy restProxy = new RestProxy(httpPipeline, serializer, interfaceParser); + return (A) Proxy.newProxyInstance(swaggerInterface.getClassLoader(), new Class[]{swaggerInterface}, restProxy); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/Substitution.java b/common/azure-common/src/main/java/com/azure/common/implementation/Substitution.java new file mode 100644 index 0000000000000..11ad72f490f44 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/Substitution.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +/** + * A Substitution is a value that can be used to replace placeholder values in a URL. Placeholders + * look like: "http://{host}.com/{fileName}.html", where "{host}" and "{fileName}" are the + * placeholders. + */ +class Substitution { + private final String urlParameterName; + private final int methodParameterIndex; + private final boolean shouldEncode; + + /** + * Create a new Substitution. + * @param urlParameterName The name that is used between curly quotes as a placeholder in the + * target URL. + * @param methodParameterIndex The index of the parameter in the original interface method where + * the value for the placeholder is. + * @param shouldEncode Whether or not the value from the method's argument should be encoded + * when the substitution is taking place. + */ + Substitution(String urlParameterName, int methodParameterIndex, boolean shouldEncode) { + this.urlParameterName = urlParameterName; + this.methodParameterIndex = methodParameterIndex; + this.shouldEncode = shouldEncode; + } + + /** + * Get the placeholder's name. + * @return The name of the placeholder. + */ + public String urlParameterName() { + return urlParameterName; + } + + /** + * Get the index of the method parameter where the replacement value is. + * @return The index of the method parameter where the replacement value is. + */ + public int methodParameterIndex() { + return methodParameterIndex; + } + + /** + * Get whether or not the replacement value from the method argument needs to be encoded when the + * substitution is taking place. + * @return Whether or not the replacement value from the method argument needs to be encoded + * when the substitution is taking place. + */ + public boolean shouldEncode() { + return shouldEncode; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerInterfaceParser.java b/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerInterfaceParser.java new file mode 100644 index 0000000000000..443c2a3c7e598 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerInterfaceParser.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.implementation.exception.MissingRequiredAnnotationException; +import com.azure.common.annotations.Host; +import com.azure.common.implementation.serializer.SerializerAdapter; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * The type responsible for creating individual Swagger interface method parsers from a Swagger + * interface. + */ +public class SwaggerInterfaceParser { + private final SerializerAdapter serializer; + private final String host; + private final Map methodParsers = new HashMap<>(); + + /** + * Create a SwaggerInterfaceParser object with the provided fully qualified interface + * name. + * @param swaggerInterface The interface that will be parsed. + * @param serializer The serializer that will be used to serialize non-String header values and query values. + */ + public SwaggerInterfaceParser(Class swaggerInterface, SerializerAdapter serializer) { + this(swaggerInterface, serializer, null); + } + + /** + * Create a SwaggerInterfaceParser object with the provided fully qualified interface + * name. + * @param swaggerInterface The interface that will be parsed. + * @param serializer The serializer that will be used to serialize non-String header values and query values. + * @param host The host of URLs that this Swagger interface targets. + */ + public SwaggerInterfaceParser(Class swaggerInterface, SerializerAdapter serializer, String host) { + this.serializer = serializer; + + if (host != null && !host.isEmpty()) { + this.host = host; + } + else { + final Host hostAnnotation = swaggerInterface.getAnnotation(Host.class); + if (hostAnnotation != null && !hostAnnotation.value().isEmpty()) { + this.host = hostAnnotation.value(); + } + else { + throw new MissingRequiredAnnotationException(Host.class, swaggerInterface); + } + } + } + + /** + * Get the method parser that is associated with the provided swaggerMethod. The method parser + * can be used to get details about the Swagger REST API call. + * + * @param swaggerMethod the method to generate a parser for + * @return the SwaggerMethodParser associated with the provided swaggerMethod + */ + public SwaggerMethodParser methodParser(Method swaggerMethod) { + SwaggerMethodParser result = methodParsers.get(swaggerMethod); + if (result == null) { + result = new SwaggerMethodParser(swaggerMethod, serializer, host()); + methodParsers.put(swaggerMethod, result); + } + return result; + } + + /** + * Get the desired host that the provided Swagger interface will target with its REST API + * calls. This value is retrieved from the @Host annotation placed on the Swagger interface. + * @return The value of the @Host annotation. + */ + String host() { + return host; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerMethodParser.java b/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerMethodParser.java new file mode 100644 index 0000000000000..98a57d4a49b6b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/SwaggerMethodParser.java @@ -0,0 +1,537 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.implementation.exception.MissingRequiredAnnotationException; +import com.azure.common.http.rest.RestException; +import com.azure.common.annotations.BodyParam; +import com.azure.common.annotations.DELETE; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HEAD; +import com.azure.common.annotations.HeaderParam; +import com.azure.common.annotations.Headers; +import com.azure.common.annotations.HostParam; +import com.azure.common.annotations.PATCH; +import com.azure.common.annotations.POST; +import com.azure.common.annotations.PUT; +import com.azure.common.annotations.PathParam; +import com.azure.common.annotations.QueryParam; +import com.azure.common.annotations.ReturnValueWireType; +import com.azure.common.annotations.UnexpectedResponseExceptionType; +import com.azure.common.http.ContextData; +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.rest.RestResponse; +import com.azure.common.implementation.serializer.HttpResponseDecodeData; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.util.TypeUtil; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The type to parse details of a specific Swagger REST API call from a provided Swagger interface + * method. + */ +public class SwaggerMethodParser implements HttpResponseDecodeData { + private final SerializerAdapter serializer; + private final String rawHost; + private final String fullyQualifiedMethodName; + private HttpMethod httpMethod; + private String relativePath; + private final List hostSubstitutions = new ArrayList<>(); + private final List pathSubstitutions = new ArrayList<>(); + private final List querySubstitutions = new ArrayList<>(); + private final List headerSubstitutions = new ArrayList<>(); + private final HttpHeaders headers = new HttpHeaders(); + private Integer bodyContentMethodParameterIndex; + private String bodyContentType; + private Type bodyJavaType; + private int[] expectedStatusCodes; + private Type returnType; + private Type returnValueWireType; + private Class exceptionType; + private Class exceptionBodyType; + + /** + * Create a SwaggerMethodParser object using the provided fully qualified method name. + * + * @param swaggerMethod the Swagger method to parse. + * @param rawHost the raw host value from the @Host annotation. Before this can be used as the + * host value in an HTTP request, it must be processed through the possible host + * substitutions. + */ + SwaggerMethodParser(Method swaggerMethod, SerializerAdapter serializer, String rawHost) { + this.serializer = serializer; + this.rawHost = rawHost; + + final Class swaggerInterface = swaggerMethod.getDeclaringClass(); + + fullyQualifiedMethodName = swaggerInterface.getName() + "." + swaggerMethod.getName(); + + if (swaggerMethod.isAnnotationPresent(GET.class)) { + setHttpMethodAndRelativePath(HttpMethod.GET, swaggerMethod.getAnnotation(GET.class).value()); + } + else if (swaggerMethod.isAnnotationPresent(PUT.class)) { + setHttpMethodAndRelativePath(HttpMethod.PUT, swaggerMethod.getAnnotation(PUT.class).value()); + } + else if (swaggerMethod.isAnnotationPresent(HEAD.class)) { + setHttpMethodAndRelativePath(HttpMethod.HEAD, swaggerMethod.getAnnotation(HEAD.class).value()); + } + else if (swaggerMethod.isAnnotationPresent(DELETE.class)) { + setHttpMethodAndRelativePath(HttpMethod.DELETE, swaggerMethod.getAnnotation(DELETE.class).value()); + } + else if (swaggerMethod.isAnnotationPresent(POST.class)) { + setHttpMethodAndRelativePath(HttpMethod.POST, swaggerMethod.getAnnotation(POST.class).value()); + } + else if (swaggerMethod.isAnnotationPresent(PATCH.class)) { + setHttpMethodAndRelativePath(HttpMethod.PATCH, swaggerMethod.getAnnotation(PATCH.class).value()); + } + else { + final ArrayList> requiredAnnotationOptions = new ArrayList<>(); + requiredAnnotationOptions.add(GET.class); + requiredAnnotationOptions.add(PUT.class); + requiredAnnotationOptions.add(HEAD.class); + requiredAnnotationOptions.add(DELETE.class); + requiredAnnotationOptions.add(POST.class); + requiredAnnotationOptions.add(PATCH.class); + throw new MissingRequiredAnnotationException(requiredAnnotationOptions, swaggerMethod); + } + + returnType = swaggerMethod.getGenericReturnType(); + + final ReturnValueWireType returnValueWireTypeAnnotation = swaggerMethod.getAnnotation(ReturnValueWireType.class); + if (returnValueWireTypeAnnotation != null) { + Class returnValueWireType = returnValueWireTypeAnnotation.value(); + if (returnValueWireType == Base64Url.class || returnValueWireType == UnixTime.class || returnValueWireType == DateTimeRfc1123.class) { + this.returnValueWireType = returnValueWireType; + } + else { + if (TypeUtil.isTypeOrSubTypeOf(returnValueWireType, List.class)) { + this.returnValueWireType = returnValueWireType.getGenericInterfaces()[0]; + } + } + } + + if (swaggerMethod.isAnnotationPresent(Headers.class)) { + final Headers headersAnnotation = swaggerMethod.getAnnotation(Headers.class); + final String[] headers = headersAnnotation.value(); + for (final String header : headers) { + final int colonIndex = header.indexOf(":"); + if (colonIndex >= 0) { + final String headerName = header.substring(0, colonIndex).trim(); + if (!headerName.isEmpty()) { + final String headerValue = header.substring(colonIndex + 1).trim(); + if (!headerValue.isEmpty()) { + this.headers.set(headerName, headerValue); + } + } + } + } + } + + final ExpectedResponses expectedResponses = swaggerMethod.getAnnotation(ExpectedResponses.class); + if (expectedResponses != null) { + expectedStatusCodes = expectedResponses.value(); + } + + final UnexpectedResponseExceptionType unexpectedResponseExceptionType = swaggerMethod.getAnnotation(UnexpectedResponseExceptionType.class); + if (unexpectedResponseExceptionType == null) { + exceptionType = RestException.class; + } + else { + exceptionType = unexpectedResponseExceptionType.value(); + } + + try { + final Method exceptionBodyMethod = exceptionType.getDeclaredMethod("body"); + exceptionBodyType = exceptionBodyMethod.getReturnType(); + } catch (NoSuchMethodException e) { + // Should always have a body() method. Register Object as a fallback plan. + exceptionBodyType = Object.class; + } + + final Annotation[][] allParametersAnnotations = swaggerMethod.getParameterAnnotations(); + for (int parameterIndex = 0; parameterIndex < allParametersAnnotations.length; ++parameterIndex) { + final Annotation[] parameterAnnotations = swaggerMethod.getParameterAnnotations()[parameterIndex]; + for (final Annotation annotation : parameterAnnotations) { + final Class annotationType = annotation.annotationType(); + if (annotationType.equals(HostParam.class)) { + final HostParam hostParamAnnotation = (HostParam) annotation; + hostSubstitutions.add(new Substitution(hostParamAnnotation.value(), parameterIndex, !hostParamAnnotation.encoded())); + } + else if (annotationType.equals(PathParam.class)) { + final PathParam pathParamAnnotation = (PathParam) annotation; + pathSubstitutions.add(new Substitution(pathParamAnnotation.value(), parameterIndex, !pathParamAnnotation.encoded())); + } + else if (annotationType.equals(QueryParam.class)) { + final QueryParam queryParamAnnotation = (QueryParam) annotation; + querySubstitutions.add(new Substitution(queryParamAnnotation.value(), parameterIndex, !queryParamAnnotation.encoded())); + } + else if (annotationType.equals(HeaderParam.class)) { + final HeaderParam headerParamAnnotation = (HeaderParam) annotation; + headerSubstitutions.add(new Substitution(headerParamAnnotation.value(), parameterIndex, false)); + } + else if (annotationType.equals(BodyParam.class)) { + final BodyParam bodyParamAnnotation = (BodyParam) annotation; + bodyContentMethodParameterIndex = parameterIndex; + bodyContentType = bodyParamAnnotation.value(); + bodyJavaType = swaggerMethod.getGenericParameterTypes()[parameterIndex]; + } + } + } + } + + /** + * Get the fully qualified method that was called to invoke this HTTP request. + * + * @return the fully qualified method that was called to invoke this HTTP request + */ + public String fullyQualifiedMethodName() { + return fullyQualifiedMethodName; + } + + /** + * Get the HTTP method that will be used to complete the Swagger method's request. + * + * @return the HTTP method that will be used to complete the Swagger method's request + */ + public HttpMethod httpMethod() { + return httpMethod; + } + + /** + * Get the HTTP response status codes that are expected when a request is sent out for this + * Swagger method. If the returned int[] is null, then all status codes less than 400 are + * allowed. + * + * @return the expected HTTP response status codes for this Swagger method or null if all status + * codes less than 400 are allowed. + */ + @Override + public int[] expectedStatusCodes() { + return expectedStatusCodes; + } + + /** + * Get the scheme to use for HTTP requests for this Swagger method. + * + * @param swaggerMethodArguments the arguments to use for scheme/host substitutions. + * @return the final host to use for HTTP requests for this Swagger method. + */ + public String scheme(Object[] swaggerMethodArguments) { + final String substitutedHost = applySubstitutions(rawHost, hostSubstitutions, swaggerMethodArguments, UrlEscapers.PATH_ESCAPER); + final String[] substitutedHostParts = substitutedHost.split("://"); + return substitutedHostParts.length < 1 ? null : substitutedHostParts[0]; + } + + /** + * Get the host to use for HTTP requests for this Swagger method. + * + * @param swaggerMethodArguments the arguments to use for host substitutions + * @return the final host to use for HTTP requests for this Swagger method + */ + public String host(Object[] swaggerMethodArguments) { + final String substitutedHost = applySubstitutions(rawHost, hostSubstitutions, swaggerMethodArguments, UrlEscapers.PATH_ESCAPER); + final String[] substitutedHostParts = substitutedHost.split("://"); + return substitutedHostParts.length < 2 ? substitutedHost : substitutedHost.split("://")[1]; + } + + /** + * Get the path that will be used to complete the Swagger method's request. + * + * @param methodArguments the method arguments to use with the path substitutions + * @return the path value with its placeholders replaced by the matching substitutions + */ + public String path(Object[] methodArguments) { + return applySubstitutions(relativePath, pathSubstitutions, methodArguments, UrlEscapers.PATH_ESCAPER); + } + + /** + * Get the encoded query parameters that have been added to this value based on the provided + * method arguments. + * + * @param swaggerMethodArguments the arguments that will be used to create the query parameters' + * values + * @return an Iterable with the encoded query parameters + */ + public Iterable encodedQueryParameters(Object[] swaggerMethodArguments) { + final List result = new ArrayList<>(); + if (querySubstitutions != null) { + final PercentEscaper escaper = UrlEscapers.QUERY_ESCAPER; + + for (Substitution querySubstitution : querySubstitutions) { + final int parameterIndex = querySubstitution.methodParameterIndex(); + if (0 <= parameterIndex && parameterIndex < swaggerMethodArguments.length) { + final Object methodArgument = swaggerMethodArguments[querySubstitution.methodParameterIndex()]; + String parameterValue = serialize(methodArgument); + if (parameterValue != null) { + if (querySubstitution.shouldEncode() && escaper != null) { + parameterValue = escaper.escape(parameterValue); + } + + result.add(new EncodedParameter(querySubstitution.urlParameterName(), parameterValue)); + } + } + } + } + return result; + } + + /** + * Get the headers that have been added to this value based on the provided method arguments. + * @param swaggerMethodArguments The arguments that will be used to create the headers' values. + * @return An Iterable with the headers. + */ + public Iterable headers(Object[] swaggerMethodArguments) { + final HttpHeaders result = new HttpHeaders(headers); + + if (headerSubstitutions != null) { + for (Substitution headerSubstitution : headerSubstitutions) { + final int parameterIndex = headerSubstitution.methodParameterIndex(); + if (0 <= parameterIndex && parameterIndex < swaggerMethodArguments.length) { + final Object methodArgument = swaggerMethodArguments[headerSubstitution.methodParameterIndex()]; + if (methodArgument instanceof Map) { + final Map headerCollection = (Map) methodArgument; + final String headerCollectionPrefix = headerSubstitution.urlParameterName(); + for (final Map.Entry headerCollectionEntry : headerCollection.entrySet()) { + final String headerName = headerCollectionPrefix + headerCollectionEntry.getKey(); + final String headerValue = serialize(headerCollectionEntry.getValue()); + result.set(headerName, headerValue); + } + } else { + final String headerName = headerSubstitution.urlParameterName(); + final String headerValue = serialize(methodArgument); + result.set(headerName, headerValue); + } + } + } + } + return result; + } + + /** + * Get the {@link ContextData} passed into the proxy method. + * + * @param swaggerMethodArguments the arguments passed to the proxy method + * @return the context, or null if no context was provided + */ + public ContextData contextData(Object[] swaggerMethodArguments) { + Object firstArg = swaggerMethodArguments != null && swaggerMethodArguments.length > 0 ? swaggerMethodArguments[0] : null; + if (firstArg instanceof ContextData) { + return (ContextData) firstArg; + } else { + return ContextData.NONE; + } + } + + /** + * Get whether or not the provided response status code is one of the expected status codes for + * this Swagger method. + * + * @param responseStatusCode the status code that was returned in the HTTP response + * @param additionalAllowedStatusCodes an additional set of allowed status codes that will be + * merged with the existing set of allowed status codes for + * this query + * @return whether or not the provided response status code is one of the expected status codes + * for this Swagger method + */ + public boolean isExpectedResponseStatusCode(int responseStatusCode, int[] additionalAllowedStatusCodes) { + boolean result; + + if (expectedStatusCodes == null) { + result = (responseStatusCode < 400); + } + else { + result = contains(expectedStatusCodes, responseStatusCode) + || contains(additionalAllowedStatusCodes, responseStatusCode); + } + + return result; + } + + private static boolean contains(int[] values, int searchValue) { + boolean result = false; + + if (values != null && values.length > 0) { + for (int value : values) { + if (searchValue == value) { + result = true; + break; + } + } + } + + return result; + } + + /** + * Get the type of RestException that will be thrown if the HTTP response's status code is not + * one of the expected status codes. + * + * @return the type of RestException that will be thrown if the HTTP response's status code is + * not one of the expected status codes + */ + public Class exceptionType() { + return exceptionType; + } + + /** + * Get the type of body Object that a thrown RestException will contain if the HTTP response's + * status code is not one of the expected status codes. + * + * @return the type of body Object that a thrown RestException will contain if the HTTP + * response's status code is not one of the expected status codes + */ + @Override + public Class exceptionBodyType() { + return exceptionBodyType; + } + + /** + * Get the object to be used as the body of the HTTP request. + * + * @param swaggerMethodArguments the method arguments to get the body object from + * @return the object that will be used as the body of the HTTP request + */ + public Object body(Object[] swaggerMethodArguments) { + Object result = null; + + if (bodyContentMethodParameterIndex != null + && swaggerMethodArguments != null + && 0 <= bodyContentMethodParameterIndex + && bodyContentMethodParameterIndex < swaggerMethodArguments.length) { + result = swaggerMethodArguments[bodyContentMethodParameterIndex]; + } + + return result; + } + + /** + * Get the Content-Type of the body of this Swagger method. + * + * @return the Content-Type of the body of this Swagger method + */ + public String bodyContentType() { + return bodyContentType; + } + + /** + * Get the return type for the method that this object describes. + * + * @return the return type for the method that this object describes. + */ + @Override + public Type returnType() { + return returnType; + } + + + /** + * Get the type of the body parameter to this method, if present. + * + * @return the return type of the body parameter to this method + */ + public Type bodyJavaType() { + return bodyJavaType; + } + + /** + * Get the type that the return value will be send across the network as. If returnValueWireType + * is not null, then the raw HTTP response body will need to parsed to this type and then + * converted to the actual returnType. + * + * @return the type that the raw HTTP response body will be sent as + */ + @Override + public Type returnValueWireType() { + return returnValueWireType; + } + + /** + * Checks whether or not the Swagger method expects the response to contain a body. + * + * @return true if Swagger method expects the response to contain a body, false otherwise + */ + public boolean expectsResponseBody() { + boolean result = true; + + if (TypeUtil.isTypeOrSubTypeOf(returnType, Void.class)) { + result = false; + } + else if (TypeUtil.isTypeOrSubTypeOf(returnType, Mono.class) || TypeUtil.isTypeOrSubTypeOf(returnType, Flux.class)) { + final ParameterizedType asyncReturnType = (ParameterizedType) returnType; + final Type syncReturnType = asyncReturnType.getActualTypeArguments()[0]; + if (TypeUtil.isTypeOrSubTypeOf(syncReturnType, Void.class)) { + result = false; + } else if (TypeUtil.isTypeOrSubTypeOf(syncReturnType, RestResponse.class)) { + result = TypeUtil.restResponseTypeExpectsBody((ParameterizedType) TypeUtil.getSuperType(syncReturnType, RestResponse.class)); + } + } else if (TypeUtil.isTypeOrSubTypeOf(returnType, RestResponse.class)) { + result = TypeUtil.restResponseTypeExpectsBody((ParameterizedType) returnType); + } + + return result; + } + + /** + * Set both the HTTP method and the path that will be used to complete the Swagger method's + * request. + * + * @param httpMethod the HTTP method that will be used to complete the Swagger method's request + * @param relativePath the path in the URL that will be used to complete the Swagger method's + * request + */ + private void setHttpMethodAndRelativePath(HttpMethod httpMethod, String relativePath) { + this.httpMethod = httpMethod; + this.relativePath = relativePath; + } + + String serialize(Object value) { + String result = null; + if (value != null) { + if (value instanceof String) { + result = (String) value; + } + else { + result = serializer.serializeRaw(value); + } + } + return result; + } + + private String applySubstitutions(String originalValue, Iterable substitutions, Object[] methodArguments, PercentEscaper escaper) { + String result = originalValue; + + if (methodArguments != null) { + for (Substitution substitution : substitutions) { + final int substitutionParameterIndex = substitution.methodParameterIndex(); + if (0 <= substitutionParameterIndex && substitutionParameterIndex < methodArguments.length) { + final Object methodArgument = methodArguments[substitutionParameterIndex]; + + String substitutionValue = serialize(methodArgument); + if (substitutionValue != null && !substitutionValue.isEmpty() && substitution.shouldEncode() && escaper != null) { + substitutionValue = escaper.escape(substitutionValue); + } + + result = result.replace("{" + substitution.urlParameterName() + "}", substitutionValue); + } + } + } + + return result; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/UnixTime.java b/common/azure-common/src/main/java/com/azure/common/implementation/UnixTime.java new file mode 100644 index 0000000000000..e32ada994ca91 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/UnixTime.java @@ -0,0 +1,75 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/** + * A wrapper over java.time.OffsetDateTime used for specifying unix seconds format during serialization and deserialization. + */ +public final class UnixTime { + /** + * The actual datetime object. + */ + private final OffsetDateTime dateTime; + + /** + * Creates aUnixTime object with the specified DateTime. + * + * @param dateTime The DateTime object to wrap + */ + public UnixTime(OffsetDateTime dateTime) { + this.dateTime = dateTime; + } + + /** + * Creates a UnixTime object with the specified DateTime. + * + * @param unixSeconds The Unix seconds value + */ + public UnixTime(long unixSeconds) { + this.dateTime = OffsetDateTime.ofInstant(Instant.ofEpochSecond(unixSeconds), ZoneOffset.UTC); + } + + /** + * Get the underlying DateTime. + * + * @return The underlying DateTime + */ + public OffsetDateTime dateTime() { + if (this.dateTime == null) { + return null; + } + return this.dateTime; + } + + @Override + public String toString() { + return dateTime.toString(); + } + + @Override + public int hashCode() { + return this.dateTime.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + + if (!(obj instanceof UnixTime)) { + return false; + } + + UnixTime rhs = (UnixTime) obj; + return this.dateTime.equals(rhs.dateTime()); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/UrlEscapers.java b/common/azure-common/src/main/java/com/azure/common/implementation/UrlEscapers.java new file mode 100644 index 0000000000000..7705e954d7cea --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/UrlEscapers.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +/** + * Collection of useful URL escapers. + */ +final class UrlEscapers { + + private static final String UNRESERVED_SYMBOLS = "-._~"; + private static final String SUB_DELIMS = "!$&'()*+,;="; + + /** An escaper for escaping path parameters. */ + public static final PercentEscaper PATH_ESCAPER = new PercentEscaper(UNRESERVED_SYMBOLS + SUB_DELIMS + ":@", false); + /** An escaper for escaping query parameters. */ + public static final PercentEscaper QUERY_ESCAPER = new PercentEscaper(UNRESERVED_SYMBOLS + "/?", false); + /** An escaper for escaping form parameters. */ + public static final PercentEscaper FORM_ESCAPER = new PercentEscaper(UNRESERVED_SYMBOLS, true); + +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/Validator.java b/common/azure-common/src/main/java/com/azure/common/implementation/Validator.java new file mode 100644 index 0000000000000..4204d64d23ae1 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/Validator.java @@ -0,0 +1,164 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.azure.common.annotations.SkipParentValidation; +import com.azure.common.implementation.util.TypeUtil; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +/** + * Validates user provided parameters are not null if they are required. + */ +public final class Validator { + /** + * Private Ctr. + */ + private Validator() { } + + /** + * Validates a user provided required parameter to be not null. + * + * An {@link IllegalArgumentException} is thrown if a property fails the validation. + * + * @param parameter the parameter to validate + * @throws IllegalArgumentException thrown when the Validator determines the argument is invalid + */ + public static void validate(Object parameter) { + // Validation of top level payload is done outside + if (parameter == null) { + return; + } + + Class type = parameter.getClass(); + if (type == Double.class + || type == Float.class + || type == Long.class + || type == Integer.class + || type == Short.class + || type == Character.class + || type == Byte.class + || type == Boolean.class) { + type = wrapperToPrimitive(type); + } + if (type.isPrimitive() + || type.isEnum() + || type.isAssignableFrom(Class.class) + || type.isAssignableFrom(LocalDate.class) + || type.isAssignableFrom(OffsetDateTime.class) + || type.isAssignableFrom(String.class) + || type.isAssignableFrom(DateTimeRfc1123.class) + || type.isAssignableFrom(Duration.class)) { + return; + } + + Annotation skipParentAnnotation = type.getAnnotation(SkipParentValidation.class); + // + if (skipParentAnnotation == null) { + for (Class c : TypeUtil.getAllClasses(type)) { + validateClass(c, parameter); + } + } else { + validateClass(type, parameter); + } + } + + private static Class wrapperToPrimitive(Class clazz) { + if (!clazz.isPrimitive()) { + return clazz; + } + + if (clazz == Integer.class) { + return Integer.TYPE; + } else if (clazz == Long.class) { + return Long.TYPE; + } else if (clazz == Boolean.class) { + return Boolean.TYPE; + } else if (clazz == Byte.class) { + return Byte.TYPE; + } else if (clazz == Character.class) { + return Character.TYPE; + } else if (clazz == Float.class) { + return Float.TYPE; + } else if (clazz == Double.class) { + return Double.TYPE; + } else if (clazz == Short.class) { + return Short.TYPE; + } else if (clazz == Void.class) { + return Void.TYPE; + } + + return clazz; + } + + private static void validateClass(Class c, Object parameter) { + // Ignore checks for Object type. + if (c.isAssignableFrom(Object.class)) { + return; + } + // + for (Field field : c.getDeclaredFields()) { + field.setAccessible(true); + int mod = field.getModifiers(); + // Skip static fields since we don't have any, skip final fields since users can't modify them + if (Modifier.isFinal(mod) || Modifier.isStatic(mod)) { + continue; + } + JsonProperty annotation = field.getAnnotation(JsonProperty.class); + // Skip read-only properties (WRITE_ONLY) + if (annotation != null && annotation.access().equals(JsonProperty.Access.WRITE_ONLY)) { + continue; + } + Object property; + try { + property = field.get(parameter); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + if (property == null) { + if (annotation != null && annotation.required()) { + throw new IllegalArgumentException(field.getName() + " is required and cannot be null."); + } + } else { + try { + Class propertyType = property.getClass(); + if (List.class.isAssignableFrom(propertyType)) { + List items = (List) property; + for (Object item : items) { + Validator.validate(item); + } + } + else if (Map.class.isAssignableFrom(propertyType)) { + Map entries = (Map) property; + for (Map.Entry entry : entries.entrySet()) { + Validator.validate(entry.getKey()); + Validator.validate(entry.getValue()); + } + } + else if (parameter.getClass() != propertyType) { + Validator.validate(property); + } + } catch (IllegalArgumentException ex) { + if (ex.getCause() == null) { + // Build property chain + throw new IllegalArgumentException(field.getName() + "." + ex.getMessage()); + } else { + throw ex; + } + } + } + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/exception/InvalidReturnTypeException.java b/common/azure-common/src/main/java/com/azure/common/implementation/exception/InvalidReturnTypeException.java new file mode 100644 index 0000000000000..86d6c61029864 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/exception/InvalidReturnTypeException.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.exception; + +/** + * An exception thrown when a Swagger interface defines a method with an invalid return + * type. + */ +public class InvalidReturnTypeException extends RuntimeException { + /** + * Create a new InvalidReturnTypeException with the provided message. + * @param message The message for this exception. + */ + public InvalidReturnTypeException(String message) { + super(message); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/exception/MissingRequiredAnnotationException.java b/common/azure-common/src/main/java/com/azure/common/implementation/exception/MissingRequiredAnnotationException.java new file mode 100644 index 0000000000000..e8f40a5797d6c --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/exception/MissingRequiredAnnotationException.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.exception; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.List; + +/** + * An exception thrown when a Swagger interface is parsed and it is missing required + * annotations. + */ +public class MissingRequiredAnnotationException extends RuntimeException { + /** + * Create a new MissingRequiredAnnotationException for the provided missing required annotation + * on the provided swaggerInterface. + * @param requiredAnnotation The annotation that is required. + * @param swaggerInterface The swagger interface that is missing the required annotation. + */ + public MissingRequiredAnnotationException(Class requiredAnnotation, Class swaggerInterface) { + super("A " + getAnnotationName(requiredAnnotation) + " annotation must be defined on " + swaggerInterface.getName() + "."); + } + + /** + * Create a new MissingRequiredAnnotationException for the provided missing required annotation + * on the provided swaggerInterface method. + * @param requiredAnnotation The annotation that is required. + * @param swaggerInterfaceMethod The swagger interface method that is missing the required annotation. + */ + public MissingRequiredAnnotationException(Class requiredAnnotation, Method swaggerInterfaceMethod) { + super("A " + getAnnotationName(requiredAnnotation) + " annotation must be defined on the method " + methodFullName(swaggerInterfaceMethod) + "."); + } + + /** + * Create a new MissingRequiredAnnotationException for the provided missing required annotation + * options on the provided swaggerInterface method. + * @param requiredAnnotationOptions The options for the annotation that is required. + * @param swaggerInterfaceMethod The swagger interface method that is missing the required annotation. + */ + public MissingRequiredAnnotationException(List> requiredAnnotationOptions, Method swaggerInterfaceMethod) { + super("Either " + optionsToString(requiredAnnotationOptions) + " annotation must be defined on the method " + methodFullName(swaggerInterfaceMethod) + "."); + } + + private static String getAnnotationName(Class annotation) { + return annotation.getSimpleName(); + } + + private static String optionsToString(List> requiredAnnotationOptions) { + final StringBuilder result = new StringBuilder(); + + final int optionCount = requiredAnnotationOptions.size(); + for (int i = 0; i < optionCount; ++i) { + if (1 <= i) { + result.append(", "); + } + if (i == optionCount - 1) { + result.append("or "); + } + result.append(getAnnotationName(requiredAnnotationOptions.get(i))); + } + + return result.toString(); + } + + private static String methodFullName(Method swaggerInterfaceMethod) { + return swaggerInterfaceMethod.getDeclaringClass().getName() + "." + swaggerInterfaceMethod.getName() + "()"; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/exception/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/exception/package-info.java new file mode 100644 index 0000000000000..6b37a83df04b7 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/exception/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing implementation-specific exception APIs that should not be used by end-users. + */ +package com.azure.common.implementation.exception; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/BufferedHttpResponse.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/BufferedHttpResponse.java new file mode 100644 index 0000000000000..3c6fcb8f9ce6c --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/BufferedHttpResponse.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpResponse; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * HTTP response which will buffer the response's body when/if it is read. + */ +public final class BufferedHttpResponse extends HttpResponse { + private final HttpResponse innerHttpResponse; + private final Mono cachedBody; + + /** + * Creates a buffered HTTP response. + * + * @param innerHttpResponse The HTTP response to buffer + */ + public BufferedHttpResponse(HttpResponse innerHttpResponse) { + this.innerHttpResponse = innerHttpResponse; + this.cachedBody = innerHttpResponse.bodyAsByteArray().cache(); + this.withRequest(innerHttpResponse.request()); + } + + @Override + public int statusCode() { + return innerHttpResponse.statusCode(); + } + + @Override + public String headerValue(String name) { + return innerHttpResponse.headerValue(name); + } + + @Override + public HttpHeaders headers() { + return innerHttpResponse.headers(); + } + + @Override + public Mono bodyAsByteArray() { + return cachedBody; + } + + @Override + public Flux body() { + return bodyAsByteArray().flatMapMany(bytes -> Flux.just(Unpooled.wrappedBuffer(bytes))); + } + + @Override + public Mono bodyAsString() { + return bodyAsByteArray() + .map(bytes -> bytes == null ? null : new String(bytes, StandardCharsets.UTF_8)); + } + + @Override + public Mono bodyAsString(Charset charset) { + return bodyAsByteArray() + .map(bytes -> bytes == null ? null : new String(bytes, charset)); + } + + @Override + public BufferedHttpResponse buffer() { + return this; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/ContentType.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/ContentType.java new file mode 100644 index 0000000000000..1c2dae5cebe88 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/ContentType.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +/** + * The different values that commonly used for Content-Type header. + */ +public final class ContentType { + /** + * the default JSON Content-Type header. + */ + public static final String APPLICATION_JSON = "application/json"; + + /** + * the default binary Content-Type header. + */ + public static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + + /** + * Private ctr. + */ + private ContentType() { + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlBuilder.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlBuilder.java new file mode 100644 index 0000000000000..ebed556baeb58 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlBuilder.java @@ -0,0 +1,327 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A builder class that is used to create URLs. + */ +public final class UrlBuilder { + private String scheme; + private String host; + private String port; + private String path; + + // LinkedHashMap preserves insertion order + private final Map query = new LinkedHashMap<>(); + + /** + * Set the scheme/protocol that will be used to build the final URL. + * @param scheme The scheme/protocol that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withScheme(String scheme) { + if (scheme == null || scheme.isEmpty()) { + this.scheme = null; + } + else { + with(scheme, UrlTokenizerState.SCHEME); + } + return this; + } + + /** + * Get the scheme/protocol that has been assigned to this UrlBuilder. + * @return the scheme/protocol that has been assigned to this UrlBuilder. + */ + public String scheme() { + return scheme; + } + + /** + * Set the host that will be used to build the final URL. + * @param host The host that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withHost(String host) { + if (host == null || host.isEmpty()) { + this.host = null; + } + else { + with(host, UrlTokenizerState.SCHEME_OR_HOST); + } + return this; + } + + /** + * Get the host that has been assigned to this UrlBuilder. + * @return the host that has been assigned to this UrlBuilder. + */ + public String host() { + return host; + } + + /** + * Set the port that will be used to build the final URL. + * @param port The port that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withPort(String port) { + if (port == null || port.isEmpty()) { + this.port = null; + } + else { + with(port, UrlTokenizerState.PORT); + } + return this; + } + + /** + * Set the port that will be used to build the final URL. + * @param port The port that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withPort(int port) { + return withPort(Integer.toString(port)); + } + + /** + * Get the port that has been assigned to this UrlBuilder. + * @return the port that has been assigned to this UrlBuilder. + */ + public Integer port() { + return port == null ? null : Integer.valueOf(port); + } + + /** + * Set the path that will be used to build the final URL. + * @param path The path that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withPath(String path) { + if (path == null || path.isEmpty()) { + this.path = null; + } + else { + with(path, UrlTokenizerState.PATH); + } + return this; + } + + /** + * Get the path that has been assigned to this UrlBuilder. + * @return the path that has been assigned to this UrlBuilder. + */ + public String path() { + return path; + } + + /** + * Set the provided query parameter name and encoded value to query string for the final URL. + * @param queryParameterName The name of the query parameter. + * @param queryParameterEncodedValue The encoded value of the query parameter. + * @return The provided query parameter name and encoded value to query string for the final + * URL. + */ + public UrlBuilder setQueryParameter(String queryParameterName, String queryParameterEncodedValue) { + query.put(queryParameterName, queryParameterEncodedValue); + return this; + } + + /** + * Set the query that will be used to build the final URL. + * @param query The query that will be used to build the final URL. + * @return This UrlBuilder so that multiple setters can be chained together. + */ + public UrlBuilder withQuery(String query) { + if (query == null || query.isEmpty()) { + this.query.clear(); + } + else { + with(query, UrlTokenizerState.QUERY); + } + return this; + } + + /** + * Get the query that has been assigned to this UrlBuilder. + * @return the query that has been assigned to this UrlBuilder. + */ + public Map query() { + return query; + } + + private UrlBuilder with(String text, UrlTokenizerState startState) { + final UrlTokenizer tokenizer = new UrlTokenizer(text, startState); + + while (tokenizer.next()) { + final UrlToken token = tokenizer.current(); + final String tokenText = token.text(); + final UrlTokenType tokenType = token.type(); + switch (tokenType) { + case SCHEME: + scheme = emptyToNull(tokenText); + break; + + case HOST: + host = emptyToNull(tokenText); + break; + + case PORT: + port = emptyToNull(tokenText); + break; + + case PATH: + final String tokenPath = emptyToNull(tokenText); + if (path == null || path.equals("/") || !tokenPath.equals("/")) { + path = tokenPath; + } + break; + + case QUERY: + String queryString = emptyToNull(tokenText); + if (queryString != null) { + if (queryString.startsWith("?")) { + queryString = queryString.substring(1); + } + + for (String entry : queryString.split("&")) { + String[] nameValue = entry.split("="); + if (nameValue.length == 2) { + setQueryParameter(nameValue[0], nameValue[1]); + } else { + throw new IllegalArgumentException("Malformed query entry: " + entry); + } + } + } + + break; + + default: + break; + } + } + return this; + } + + /** + * Get the URL that is being built. + * @return The URL that is being built. + * @throws MalformedURLException if the URL is not fully formed. + */ + public URL toURL() throws MalformedURLException { + return new URL(toString()); + } + + /** + * Get the string representation of the URL that is being built. + * @return The string representation of the URL that is being built. + */ + public String toString() { + final StringBuilder result = new StringBuilder(); + + final boolean isAbsolutePath = path != null && (path.startsWith("http://") || path.startsWith("https://")); + if (!isAbsolutePath) { + if (scheme != null) { + result.append(scheme); + + if (!scheme.endsWith("://")) { + result.append("://"); + } + } + + if (host != null) { + result.append(host); + } + } + + if (port != null) { + result.append(":"); + result.append(port); + } + + if (path != null) { + if (result.length() != 0 && !path.startsWith("/")) { + result.append('/'); + } + result.append(path); + } + + if (!query.isEmpty()) { + StringBuilder queryBuilder = new StringBuilder("?"); + for (Map.Entry entry : query.entrySet()) { + if (queryBuilder.length() > 1) { + queryBuilder.append("&"); + } + queryBuilder.append(entry.getKey()); + queryBuilder.append("="); + queryBuilder.append(entry.getValue()); + } + + result.append(queryBuilder.toString()); + } + + return result.toString(); + } + + /** + * Parse a UrlBuilder from the provided URL string. + * @param url The string to parse. + * @return The UrlBuilder that was parsed from the string. + */ + public static UrlBuilder parse(String url) { + final UrlBuilder result = new UrlBuilder(); + result.with(url, UrlTokenizerState.SCHEME_OR_HOST); + return result; + } + + /** + * Parse a UrlBuilder from the provided URL object. + * @param url The URL object to parse. + * @return The UrlBuilder that was parsed from the URL object. + */ + public static UrlBuilder parse(URL url) { + final UrlBuilder result = new UrlBuilder(); + + if (url != null) { + final String protocol = url.getProtocol(); + if (protocol != null && !protocol.isEmpty()) { + result.withScheme(protocol); + } + + final String host = url.getHost(); + if (host != null && !host.isEmpty()) { + result.withHost(host); + } + + final int port = url.getPort(); + if (port != -1) { + result.withPort(port); + } + + final String path = url.getPath(); + if (path != null && !path.isEmpty()) { + result.withPath(path); + } + + final String query = url.getQuery(); + if (query != null && !query.isEmpty()) { + result.withQuery(query); + } + } + + return result; + } + + private static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlToken.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlToken.java new file mode 100644 index 0000000000000..39f336514384b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlToken.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +class UrlToken { + private final String text; + private final UrlTokenType type; + + UrlToken(String text, UrlTokenType type) { + this.text = text; + this.type = type; + } + + String text() { + return text; + } + + UrlTokenType type() { + return type; + } + + @Override + public boolean equals(Object rhs) { + return rhs instanceof UrlToken && equals((UrlToken) rhs); + } + + public boolean equals(UrlToken rhs) { + return rhs != null && text.equals(rhs.text) && type == rhs.type; + } + + @Override + public String toString() { + return "\"" + text + "\" (" + type + ")"; + } + + @Override + public int hashCode() { + return (text == null ? 0 : text.hashCode()) ^ type.hashCode(); + } + + static UrlToken scheme(String text) { + return new UrlToken(text, UrlTokenType.SCHEME); + } + + static UrlToken host(String text) { + return new UrlToken(text, UrlTokenType.HOST); + } + + static UrlToken port(String text) { + return new UrlToken(text, UrlTokenType.PORT); + } + + static UrlToken path(String text) { + return new UrlToken(text, UrlTokenType.PATH); + } + + static UrlToken query(String text) { + return new UrlToken(text, UrlTokenType.QUERY); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenType.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenType.java new file mode 100644 index 0000000000000..de756e6e9b025 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenType.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +enum UrlTokenType { + SCHEME, + + HOST, + + PORT, + + PATH, + + QUERY, +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizer.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizer.java new file mode 100644 index 0000000000000..5044cddab64cf --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizer.java @@ -0,0 +1,231 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +class UrlTokenizer { + private final String text; + private final int textLength; + private UrlTokenizerState state; + private int currentIndex; + private UrlToken currentToken; + + UrlTokenizer(String text) { + this(text, UrlTokenizerState.SCHEME_OR_HOST); + } + + UrlTokenizer(String text, UrlTokenizerState state) { + this.text = text; + this.textLength = (text == null ? 0 : text.length()); + this.state = state; + this.currentIndex = 0; + this.currentToken = null; + } + + private boolean hasCurrentCharacter() { + return currentIndex < textLength; + } + + private char currentCharacter() { + return text.charAt(currentIndex); + } + + private void nextCharacter() { + nextCharacter(1); + } + + private void nextCharacter(int step) { + if (hasCurrentCharacter()) { + currentIndex += step; + } + } + + private String peekCharacters(int charactersToPeek) { + int endIndex = currentIndex + charactersToPeek; + if (textLength < endIndex) { + endIndex = textLength; + } + return text.substring(currentIndex, endIndex); + } + + UrlToken current() { + return currentToken; + } + + boolean next() { + if (!hasCurrentCharacter()) { + currentToken = null; + } + else { + switch (state) { + case SCHEME: + final String scheme = readUntilNotLetterOrDigit(); + currentToken = UrlToken.scheme(scheme); + if (!hasCurrentCharacter()) { + state = UrlTokenizerState.DONE; + } + else { + state = UrlTokenizerState.HOST; + } + break; + + case SCHEME_OR_HOST: + final String schemeOrHost = readUntilCharacter(':', '/', '?'); + if (!hasCurrentCharacter()) { + currentToken = UrlToken.host(schemeOrHost); + state = UrlTokenizerState.DONE; + } + else if (currentCharacter() == ':') { + if (peekCharacters(3).equals("://")) { + currentToken = UrlToken.scheme(schemeOrHost); + state = UrlTokenizerState.HOST; + } + else { + currentToken = UrlToken.host(schemeOrHost); + state = UrlTokenizerState.PORT; + } + } + else if (currentCharacter() == '/') { + currentToken = UrlToken.host(schemeOrHost); + state = UrlTokenizerState.PATH; + } + else if (currentCharacter() == '?') { + currentToken = UrlToken.host(schemeOrHost); + state = UrlTokenizerState.QUERY; + } + break; + + case HOST: + if (peekCharacters(3).equals("://")) { + nextCharacter(3); + } + + final String host = readUntilCharacter(':', '/', '?'); + currentToken = UrlToken.host(host); + + if (!hasCurrentCharacter()) { + state = UrlTokenizerState.DONE; + } + else if (currentCharacter() == ':') { + state = UrlTokenizerState.PORT; + } + else if (currentCharacter() == '/') { + state = UrlTokenizerState.PATH; + } + else { + state = UrlTokenizerState.QUERY; + } + break; + + case PORT: + if (currentCharacter() == ':') { + nextCharacter(); + } + + final String port = readUntilCharacter('/', '?'); + currentToken = UrlToken.port(port); + + if (!hasCurrentCharacter()) { + state = UrlTokenizerState.DONE; + } + else if (currentCharacter() == '/') { + state = UrlTokenizerState.PATH; + } + else { + state = UrlTokenizerState.QUERY; + } + break; + + case PATH: + final String path = readUntilCharacter('?'); + currentToken = UrlToken.path(path); + + if (!hasCurrentCharacter()) { + state = UrlTokenizerState.DONE; + } + else { + state = UrlTokenizerState.QUERY; + } + break; + + case QUERY: + if (currentCharacter() == '?') { + nextCharacter(); + } + + final String query = readRemaining(); + currentToken = UrlToken.query(query); + state = UrlTokenizerState.DONE; + break; + + default: + break; + } + } + + return currentToken != null; + } + + private String readUntilNotLetterOrDigit() { + String result = ""; + + if (hasCurrentCharacter()) { + final StringBuilder builder = new StringBuilder(); + while (hasCurrentCharacter()) { + final char currentCharacter = currentCharacter(); + if (!Character.isLetterOrDigit(currentCharacter)) { + break; + } + else { + builder.append(currentCharacter); + nextCharacter(); + } + } + result = builder.toString(); + } + + return result; + } + + private String readUntilCharacter(char... terminatingCharacters) { + String result = ""; + + if (hasCurrentCharacter()) { + final StringBuilder builder = new StringBuilder(); + boolean foundTerminator = false; + while (hasCurrentCharacter()) { + final char currentCharacter = currentCharacter(); + + for (final char terminatingCharacter : terminatingCharacters) { + if (currentCharacter == terminatingCharacter) { + foundTerminator = true; + break; + } + } + + if (foundTerminator) { + break; + } + else { + builder.append(currentCharacter); + nextCharacter(); + } + } + result = builder.toString(); + } + + return result; + } + + private String readRemaining() { + String result = ""; + if (currentIndex < textLength) { + result = text.substring(currentIndex, textLength); + currentIndex = textLength; + } + return result; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizerState.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizerState.java new file mode 100644 index 0000000000000..0b4c01d9372d2 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/UrlTokenizerState.java @@ -0,0 +1,23 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.http; + +enum UrlTokenizerState { + SCHEME, + + SCHEME_OR_HOST, + + HOST, + + PORT, + + PATH, + + QUERY, + + DONE +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/http/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/http/package-info.java new file mode 100644 index 0000000000000..4ba8707947041 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/http/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing implementation-specific HTTP APIs that should not be used by end-users. + */ +package com.azure.common.implementation.http; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/package-info.java new file mode 100644 index 0000000000000..add95d19e6407 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing implementation-specific APIs that should not be used by end-users. + */ +package com.azure.common.implementation; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseBodyDecoder.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseBodyDecoder.java new file mode 100644 index 0000000000000..a0882c2ab7ee1 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseBodyDecoder.java @@ -0,0 +1,403 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import com.azure.common.implementation.Base64Url; +import com.azure.common.implementation.DateTimeRfc1123; +import com.azure.common.http.rest.RestException; +import com.azure.common.http.rest.RestResponse; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.implementation.UnixTime; +import com.azure.common.annotations.ReturnValueWireType; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.rest.SimpleRestResponse; +import com.azure.common.implementation.util.FluxUtil; +import com.azure.common.implementation.util.TypeUtil; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Decoder to decode body of HTTP response. + */ +final class HttpResponseBodyDecoder { + /** + * Decodes body of a http response. + * + * The content reading and decoding happens when caller subscribe to the returned {@code Mono}, + * if the response body is not decodable then {@code Mono.empty()} will be returned. + * + * @param httpResponse the response containing the body to be decoded + * @param serializer the adapter to use for decoding + * @param decodeData the necessary data required to decode a Http response + * @return publisher that emits decoded response body upon subscription if body is decodable, + * no emission if the body is not-decodable + */ + static Mono decode(HttpResponse httpResponse, SerializerAdapter serializer, HttpResponseDecodeData decodeData) { + ensureRequestSet(httpResponse); + // + return Mono.defer(() -> { + if (isErrorStatus(httpResponse, decodeData)) { + return httpResponse.bodyAsString() + .flatMap(bodyString -> { + try { + final Object decodedErrorEntity = deserializeBody(bodyString, + decodeData.exceptionBodyType(), + null, + serializer, + SerializerEncoding.fromHeaders(httpResponse.headers())); + return decodedErrorEntity == null ? Mono.empty() : Mono.just(decodedErrorEntity); + } catch (IOException | MalformedValueException ignored) { + // This translates in RestProxy as a RestException with no deserialized body. + // The response content will still be accessible via the .response() member. + } + return Mono.empty(); + }); + } else if (httpResponse.request().httpMethod() == HttpMethod.HEAD) { + // RFC: A response to a HEAD method should not have a body. If so, it must be ignored + return Mono.empty(); + } else if (!isReturnTypeDecodable(decodeData)) { + return Mono.empty(); + } else { + return httpResponse.bodyAsString() + .flatMap(bodyString -> { + try { + final Object decodedSuccessEntity = deserializeBody(bodyString, + extractEntityTypeFromReturnType(decodeData), + decodeData.returnValueWireType(), + serializer, + SerializerEncoding.fromHeaders(httpResponse.headers())); + return decodedSuccessEntity == null ? Mono.empty() : Mono.just(decodedSuccessEntity); + } catch (MalformedValueException e) { + return Mono.error(new RestException("HTTP response has a malformed body.", httpResponse, e)); + } catch (IOException e) { + return Mono.error(new RestException("Deserialization Failed.", httpResponse, e)); + } + }); + } + }); + } + + /** + * @return true if the body is decodable, false otherwise + */ + static boolean isDecodable(HttpResponse httpResponse, HttpResponseDecodeData decodeData) { + ensureRequestSet(httpResponse); + // + if (isErrorStatus(httpResponse, decodeData)) { + // For error cases we always try to decode the non-empty response body + // either to a strongly typed exception model or to Object + return true; + } else if (httpResponse.request().httpMethod() == HttpMethod.HEAD) { + // RFC: A response to a HEAD method should not have a body. If so, it must be ignored + return false; + } else { + return isReturnTypeDecodable(decodeData); + } + } + + /** + * @return the decoded type used to decode the response body, null if the body is not decodable. + */ + static Type decodedType(HttpResponse httpResponse, HttpResponseDecodeData decodeData) { + ensureRequestSet(httpResponse); + // + if (isErrorStatus(httpResponse, decodeData)) { + // For error cases we always try to decode the non-empty response body + // either to a strongly typed exception model or to Object + return decodeData.exceptionBodyType(); + } else if (httpResponse.request().httpMethod() == HttpMethod.HEAD) { + // RFC: A response to a HEAD method should not have a body. If so, it must be ignored + return null; + } else if (!isReturnTypeDecodable(decodeData)) { + return null; + } else { + return extractEntityTypeFromReturnType(decodeData); + } + } + + /** + * Checks the response status code is considered as error. + * + * @param httpResponse the response to check + * @param decodeData the response metadata + * @return true if the response status code is considered as error, false otherwise. + */ + static boolean isErrorStatus(HttpResponse httpResponse, HttpResponseDecodeData decodeData) { + final int[] expectedStatuses = decodeData.expectedStatusCodes(); + if (expectedStatuses != null) { + return !contains(expectedStatuses, httpResponse.statusCode()); + } else { + return httpResponse.statusCode() / 100 != 2; + } + } + + /** + * Deserialize the given string value representing content of a REST API response. + * + * @param value the string value to deserialize + * @param resultType the return type of the java proxy method + * @param wireType value of optional {@link ReturnValueWireType} annotation present in java proxy method indicating + * 'entity type' (wireType) of REST API wire response body + * @param encoding the encoding format of value + * @return Deserialized object + * @throws IOException + */ + private static Object deserializeBody(String value, Type resultType, Type wireType, SerializerAdapter serializer, SerializerEncoding encoding) throws IOException { + final Object result; + + if (wireType == null) { + result = serializer.deserialize(value, resultType, encoding); + } else { + final Type wireResponseType = constructWireResponseType(resultType, wireType); + final Object wireResponse = serializer.deserialize(value, wireResponseType, encoding); + result = convertToResultType(wireResponse, resultType, wireType); + } + return result; + } + + /** + * Given: + * (1). the {@code java.lang.reflect.Type} (resultType) of java proxy method return value + * (2). and {@link ReturnValueWireType} annotation value indicating 'entity type' (wireType) + * of same REST API's wire response body + * this method construct 'response body Type'. + * + * Note: When {@link ReturnValueWireType} annotation is applied to a proxy method, then the raw + * HTTP response content will need to parsed using the derived 'response body Type' then converted + * to actual {@code returnType}. + * + * @param resultType the {@code java.lang.reflect.Type} of java proxy method return value + * @param wireType the {@code java.lang.reflect.Type} of entity in REST API response body + * @return the {@code java.lang.reflect.Type} of REST API response body + */ + private static Type constructWireResponseType(Type resultType, Type wireType) { + Objects.requireNonNull(resultType); + Objects.requireNonNull(wireType); + // + Type wireResponseType = resultType; + + if (resultType == byte[].class) { + if (wireType == Base64Url.class) { + wireResponseType = Base64Url.class; + } + } else if (resultType == OffsetDateTime.class) { + if (wireType == DateTimeRfc1123.class) { + wireResponseType = DateTimeRfc1123.class; + } else if (wireType == UnixTime.class) { + wireResponseType = UnixTime.class; + } + } else { + if (TypeUtil.isTypeOrSubTypeOf(resultType, List.class)) { + final Type resultElementType = TypeUtil.getTypeArgument(resultType); + final Type wireResponseElementType = constructWireResponseType(resultElementType, wireType); + + wireResponseType = TypeUtil.createParameterizedType( + (Class) ((ParameterizedType) resultType).getRawType(), wireResponseElementType); + } else if (TypeUtil.isTypeOrSubTypeOf(resultType, Map.class) || TypeUtil.isTypeOrSubTypeOf(resultType, RestResponse.class)) { + Type[] typeArguments = TypeUtil.getTypeArguments(resultType); + final Type resultValueType = typeArguments[1]; + final Type wireResponseValueType = constructWireResponseType(resultValueType, wireType); + + wireResponseType = TypeUtil.createParameterizedType( + (Class) ((ParameterizedType) resultType).getRawType(), typeArguments[0], wireResponseValueType); + } + } + return wireResponseType; + } + + /** + * Converts the object {@code wireResponse} that was deserialized using 'response body Type' + * (produced by {@code constructWireResponseType(args)} method) to resultType. + * + * @param wireResponse the object to convert + * @param resultType the {@code java.lang.reflect.Type} to convert wireResponse to + * @param wireType the {@code java.lang.reflect.Type} of the wireResponse + * @return converted object + */ + private static Object convertToResultType(Object wireResponse, Type resultType, Type wireType) { + Object result = wireResponse; + + if (wireResponse != null) { + if (resultType == byte[].class) { + if (wireType == Base64Url.class) { + result = ((Base64Url) wireResponse).decodedBytes(); + } + } else if (resultType == OffsetDateTime.class) { + if (wireType == DateTimeRfc1123.class) { + result = ((DateTimeRfc1123) wireResponse).dateTime(); + } else if (wireType == UnixTime.class) { + result = ((UnixTime) wireResponse).dateTime(); + } + } else { + if (TypeUtil.isTypeOrSubTypeOf(resultType, List.class)) { + final Type resultElementType = TypeUtil.getTypeArgument(resultType); + + final List wireResponseList = (List) wireResponse; + + final int wireResponseListSize = wireResponseList.size(); + for (int i = 0; i < wireResponseListSize; ++i) { + final Object wireResponseElement = wireResponseList.get(i); + final Object resultElement = convertToResultType(wireResponseElement, resultElementType, wireType); + if (wireResponseElement != resultElement) { + wireResponseList.set(i, resultElement); + } + } + // + result = wireResponseList; + } else if (TypeUtil.isTypeOrSubTypeOf(resultType, Map.class)) { + final Type resultValueType = TypeUtil.getTypeArguments(resultType)[1]; + + final Map wireResponseMap = (Map) wireResponse; + + final Set wireResponseKeys = wireResponseMap.keySet(); + for (String wireResponseKey : wireResponseKeys) { + final Object wireResponseValue = wireResponseMap.get(wireResponseKey); + final Object resultValue = convertToResultType(wireResponseValue, resultValueType, wireType); + if (wireResponseValue != resultValue) { + wireResponseMap.put(wireResponseKey, resultValue); + } + } + // + result = wireResponseMap; + } else if (TypeUtil.isTypeOrSubTypeOf(resultType, RestResponseBase.class)) { + RestResponseBase restResponseBase = (RestResponseBase) wireResponse; + Object wireResponseBody = restResponseBase.body(); + + // TODO: anuchan - RestProxy is always in charge of creating RestResponseBase--so this doesn't seem right + Object resultBody = convertToResultType(wireResponseBody, TypeUtil.getTypeArguments(resultType)[1], wireType); + if (wireResponseBody != resultBody) { + result = new RestResponseBase<>(restResponseBase.request(), restResponseBase.statusCode(), restResponseBase.headers(), resultBody, restResponseBase.deserializedHeaders()); + } else { + result = restResponseBase; + } + } else if (TypeUtil.isTypeOrSubTypeOf(resultType, RestResponse.class)) { + RestResponse restResponse = (RestResponse) wireResponse; + Object wireResponseBody = restResponse.body(); + + // TODO: anuchan - RestProxy is always in charge of creating RestResponseBase--so this doesn't seem right + Object resultBody = convertToResultType(wireResponseBody, TypeUtil.getTypeArguments(resultType)[1], wireType); + if (wireResponseBody != resultBody) { + result = new SimpleRestResponse<>(restResponse.request(), restResponse.statusCode(), restResponse.headers(), resultBody); + } else { + result = restResponse; + } + } + } + } + return result; + } + + /** + * Get the {@link Type} of the REST API 'returned entity'. + * + * In the declaration of a java proxy method corresponding to the REST API, the 'returned entity' can be: + * + * 1. emission value of the reactor publisher returned by proxy method + * + * e.g. {@code Mono getFoo(args);} + * {@code Flux getFoos(args);} + * where Foo is the REST API 'returned entity'. + * + * 2. OR content (body) of {@link RestResponseBase} emitted by the reactor publisher returned from proxy method + * + * e.g. {@code Mono> getFoo(args);} + * {@code Flux> getFoos(args);} + * where Foo is the REST API return entity. + * + * @return the entity type. + */ + private static Type extractEntityTypeFromReturnType(HttpResponseDecodeData decodeData) { + Type token = decodeData.returnType(); + if (token != null) { + if (TypeUtil.isTypeOrSubTypeOf(token, Mono.class)) { + token = TypeUtil.getTypeArgument(token); + } else if (TypeUtil.isTypeOrSubTypeOf(token, Flux.class)) { + Type t = TypeUtil.getTypeArgument(token); + try { + // TODO: anuchan - unwrap OperationStatus a different way + // Check for OperationStatus + if (TypeUtil.isTypeOrSubTypeOf(t, Class.forName("com.azure.common.mgmt.OperationStatus"))) { + token = t; + } + } catch (ClassNotFoundException ignored) { + } + } + + if (TypeUtil.isTypeOrSubTypeOf(token, RestResponse.class)) { + token = TypeUtil.getRestResponseBodyType(token); + } + + try { + // TODO: anuchan - unwrap OperationStatus a different way + if (TypeUtil.isTypeOrSubTypeOf(token, Class.forName("com.azure.common.mgmt.OperationStatus"))) { + // Get Type of 'T' from OperationStatus + token = TypeUtil.getTypeArgument(token); + } + } catch (Exception ignored) { + } + } + return token; + } + + /** + * Checks the return type represents a decodable type. + * + * @param decodeData the decode metadata + * @return true if decodable, false otherwise. + */ + private static boolean isReturnTypeDecodable(HttpResponseDecodeData decodeData) { + Type returnType = decodeData.returnType(); + if (returnType == null) { + return false; + } else { + return !FluxUtil.isFluxByteBuf(returnType) + && !(TypeUtil.isTypeOrSubTypeOf(returnType, Mono.class) && TypeUtil.isTypeOrSubTypeOf(TypeUtil.getTypeArgument(returnType), Void.class)) + && !TypeUtil.isTypeOrSubTypeOf(returnType, byte[].class) + && !TypeUtil.isTypeOrSubTypeOf(returnType, Void.TYPE) && !TypeUtil.isTypeOrSubTypeOf(returnType, Void.class); + } + } + + /** + * Checks an given value exists in an array. + * + * @param values array of ints + * @param searchValue value to check for existence + * @return true if value exists in the array, false otherwise + */ + private static boolean contains(int[] values, int searchValue) { + Objects.requireNonNull(values); + for (int value : values) { + if (searchValue == value) { + return true; + } + } + return false; + } + + /** + * Ensure that request property and method is set in the response. + * + * @param httpResponse the response to validate + * @return the validated response + */ + private static HttpResponse ensureRequestSet(HttpResponse httpResponse) { + Objects.requireNonNull(httpResponse.request()); + Objects.requireNonNull(httpResponse.request().httpMethod()); + return httpResponse; + } +} + diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecodeData.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecodeData.java new file mode 100644 index 0000000000000..46efc1bf69486 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecodeData.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.annotations.HeaderCollection; +import com.azure.common.implementation.util.TypeUtil; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Type; + +/** + * Type representing necessary information required to decode a specific Http response. + */ +public interface HttpResponseDecodeData { + /** + * Get the type of the entity to deserialize the body. + * + * @return the return type + */ + Type returnType(); + + /** + * Get the type of the entity to be used to deserialize 'Matching' headers. + * + * The 'header entity' is optional and client can choose it when a strongly typed model is needed for headers. + * + * 'Matching' headers are the HTTP response headers those with: + * 1. header names same as name of a properties in the 'header entity'. + * 2. header names start with value of {@link HeaderCollection} annotation applied to the properties in the 'header entity'. + * + * @return headers entity type + */ + default Type headersType() { + Type token = this.returnType(); + Type headersType = null; + + if (TypeUtil.isTypeOrSubTypeOf(token, Mono.class)) { + token = TypeUtil.getTypeArgument(token); + } + + // Only the RestResponseBase class supports a custom header type. All other RestResponse subclasses do not. + if (TypeUtil.isTypeOrSubTypeOf(token, RestResponseBase.class)) { + headersType = TypeUtil.getTypeArguments(TypeUtil.getSuperType(token, RestResponseBase.class))[0]; + } + + return headersType; + } + + /** + * Get the expected HTTP response status codes. + * + * 1. If the returned int[] is null, then all 2XX status codes are considered as success code. + * 2. If the returned int[] is not-null, only the codes in the array are considered as success code. + * + * @return the expected HTTP response status codes + */ + default int[] expectedStatusCodes() { + return null; + } + + /** + * Get the type of the 'entity' in HTTP response content. + * + * When this method return non-null {@code java.lang.reflect.Type} then the raw HTTP response + * content will need to parsed to this {@code java.lang.reflect.Type} then converted to actual + * {@code returnType}. + * + * @return the type that the raw HTTP response content will be sent as + */ + default Type returnValueWireType() { + return null; + } + + /** + * Get the type of error body Object to be used for deserializing body when HTTP response status + * code is not one of the expected status codes. + * + * expected status codes are the codes returned by {@code expectedStatusCodes()}, + * when {@code expectedStatusCodes()} returns null then 2XX are considered as expected status codes + * + * @return the type of error body Object to be used for deserializing body when HTTP response + * status code is not one of the expected status codes + */ + default Class exceptionBodyType() { + return Object.class; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecoder.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecoder.java new file mode 100644 index 0000000000000..0e76b08906a63 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseDecoder.java @@ -0,0 +1,131 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import com.azure.common.http.HttpResponse; +import reactor.core.publisher.Mono; + +import java.io.Closeable; +import java.lang.reflect.Type; + +/** + * Decode {@link HttpResponse} to {@link HttpDecodedResponse}. + */ +public final class HttpResponseDecoder { + // The adapter for deserialization + private final SerializerAdapter serializer; + + /** + * Creates HttpResponseDecoder. + * + * @param serializer the serializer + */ + public HttpResponseDecoder(SerializerAdapter serializer) { + this.serializer = serializer; + } + + /** + * Asynchronously decodes a {@link HttpResponse}. + * + * @param response the publisher that emits response to be decoded + * @param decodeData the necessary data required to decode the response emitted by {@code response} + * @return a publisher that emits decoded HttpResponse upon subscription + */ + public Mono decode(Mono response, HttpResponseDecodeData decodeData) { + return response.map(r -> new HttpDecodedResponse(r, this.serializer, decodeData)); + } + + /** + * A decorated HTTP response which has subscribable body and headers that supports lazy decoding. + * + * Subscribing to body kickoff http content reading, it's decoding then emission of decoded object. + * Subscribing to header kickoff header decoding and emission of decoded object. + */ + public static final class HttpDecodedResponse implements Closeable { + private final HttpResponse response; + private final SerializerAdapter serializer; + private final HttpResponseDecodeData decodeData; + private Mono bodyCached; + private Mono headersCached; + + /** + * Creates HttpDecodedResponse. + * Package private Ctr. + * + * @param response the publisher that emits the raw response upon subscription which needs to be decoded + * @param serializer the decoder + * @param decodeData the necessary data required to decode a Http response + */ + HttpDecodedResponse(final HttpResponse response, SerializerAdapter serializer, HttpResponseDecodeData decodeData) { + if (HttpResponseBodyDecoder.isDecodable(response, decodeData)) { + this.response = response.buffer(); + } else { + this.response = response; + } + this.serializer = serializer; + this.decodeData = decodeData; + } + + /** + * @return get the raw response that this decoded response based on + */ + public HttpResponse sourceResponse() { + return this.response; + } + + /** + * Gets the publisher when subscribed the http content gets read, decoded + * and emitted. {@code Mono.empty()} gets emitted if the content is not + * decodable. + * + * @return publisher that emits decoded http content + */ + public Mono decodedBody() { + if (this.bodyCached == null) { + this.bodyCached = HttpResponseBodyDecoder.decode(this.response, + this.serializer, + this.decodeData).cache(); + } + return this.bodyCached; + } + + /** + * Gets the publisher when subscribed the http header gets decoded and emitted. + * {@code Mono.empty()} gets emitted if the headers are not decodable. + * + * @return publisher that emits entity instance representing decoded http headers + */ + public Mono decodedHeaders() { + if (this.headersCached == null) { + this.headersCached = HttpResponseHeaderDecoder.decode(this.response, + this.serializer, + this.decodeData).cache(); + } + return this.headersCached; + } + + /** + * @return the {@code java.lang.reflect.Type} used to decode the response body, + * null if the body is not decodable + */ + public Type decodedType() { + return HttpResponseBodyDecoder.decodedType(this.response, this.decodeData); + } + + /** + * @return true if the response status code is considered as error, false otherwise + */ + public boolean isErrorStatus() { + return HttpResponseBodyDecoder.isErrorStatus(this.response, this.decodeData); + } + + @Override + public void close() { + this.response.close(); + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseHeaderDecoder.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseHeaderDecoder.java new file mode 100644 index 0000000000000..f6e9bf0d4a2c4 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/HttpResponseHeaderDecoder.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import com.azure.common.http.rest.RestException; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.annotations.HeaderCollection; +import com.azure.common.http.HttpHeader; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpResponse; +import com.azure.common.implementation.util.TypeUtil; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + +/** + * Decoder to decode header of HTTP response. + */ +final class HttpResponseHeaderDecoder { + /** + * Decode headers of the http response. + * + * The decoding happens when caller subscribed to the returned {@code Mono}, + * if the response header is not decodable then {@code Mono.empty()} will be returned. + * + * @param httpResponse the response containing the headers to be decoded + * @param serializer the adapter to use for decoding + * @param decodeData the necessary data required to decode a Http response + * @return publisher that emits decoded response header upon subscription if header is decodable, + * no emission if the header is not-decodable + */ + static Mono decode(HttpResponse httpResponse, SerializerAdapter serializer, HttpResponseDecodeData decodeData) { + Type headerType = decodeData.headersType(); + if (headerType == null) { + return Mono.empty(); + } else { + return Mono.defer(() -> { + try { + return Mono.just(deserializeHeaders(httpResponse.headers(), serializer, decodeData)); + } catch (IOException e) { + return Mono.error(new RestException("HTTP response has malformed headers", httpResponse, e)); + } + }); + } + } + + /** + * Deserialize the provided headers returned from a REST API to an entity instance declared as + * the model to hold 'Matching' headers. + * + * 'Matching' headers are the REST API returned headers those with: + * 1. header names same as name of a properties in the entity. + * 2. header names start with value of {@link HeaderCollection} annotation applied to the properties in the entity. + * + * When needed, the 'header entity' types must be declared as first generic argument of {@link RestResponseBase} returned + * by java proxy method corresponding to the REST API. + * e.g. + * {@code Mono> getMetadata(args);} + * {@code + * class FooMetadataHeaders { + * String name; + * @HeaderCollection("header-collection-prefix-") + * Map headerCollection; + * } + * } + * + * in the case of above example, this method produces an instance of FooMetadataHeaders from provided {@headers}. + * + * @param headers the REST API returned headers + * @return instance of header entity type created based on provided {@headers}, if header entity model does + * not exists then return null + * @throws IOException + */ + private static Object deserializeHeaders(HttpHeaders headers, SerializerAdapter serializer, HttpResponseDecodeData decodeData) throws IOException { + final Type deserializedHeadersType = decodeData.headersType(); + if (deserializedHeadersType == null) { + return null; + } else { + final String headersJsonString = serializer.serialize(headers, SerializerEncoding.JSON); + Object deserializedHeaders = serializer.deserialize(headersJsonString, deserializedHeadersType, SerializerEncoding.JSON); + + final Class deserializedHeadersClass = TypeUtil.getRawClass(deserializedHeadersType); + final Field[] declaredFields = deserializedHeadersClass.getDeclaredFields(); + for (final Field declaredField : declaredFields) { + if (declaredField.isAnnotationPresent(HeaderCollection.class)) { + final Type declaredFieldType = declaredField.getGenericType(); + if (TypeUtil.isTypeOrSubTypeOf(declaredField.getType(), Map.class)) { + final Type[] mapTypeArguments = TypeUtil.getTypeArguments(declaredFieldType); + if (mapTypeArguments.length == 2 && mapTypeArguments[0] == String.class && mapTypeArguments[1] == String.class) { + final HeaderCollection headerCollectionAnnotation = declaredField.getAnnotation(HeaderCollection.class); + final String headerCollectionPrefix = headerCollectionAnnotation.value().toLowerCase(); + final int headerCollectionPrefixLength = headerCollectionPrefix.length(); + if (headerCollectionPrefixLength > 0) { + final Map headerCollection = new HashMap<>(); + for (final HttpHeader header : headers) { + final String headerName = header.name(); + if (headerName.toLowerCase().startsWith(headerCollectionPrefix)) { + headerCollection.put(headerName.substring(headerCollectionPrefixLength), header.value()); + } + } + + final boolean declaredFieldAccessibleBackup = declaredField.isAccessible(); + try { + if (!declaredFieldAccessibleBackup) { + declaredField.setAccessible(true); + } + declaredField.set(deserializedHeaders, headerCollection); + } catch (IllegalAccessException ignored) { + } finally { + if (!declaredFieldAccessibleBackup) { + declaredField.setAccessible(declaredFieldAccessibleBackup); + } + } + } + } + } + } + } + return deserializedHeaders; + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/JsonFlatten.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/JsonFlatten.java new file mode 100644 index 0000000000000..d682ae5a652c3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/JsonFlatten.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation used for flattening properties separated by '.'. + * E.g. a property with JsonProperty value "properties.value" + * will have "value" property under the "properties" tree on + * the wire. + * + */ +@Target({ElementType.ANNOTATION_TYPE, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface JsonFlatten { +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/MalformedValueException.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/MalformedValueException.java new file mode 100644 index 0000000000000..715f9cf80bc52 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/MalformedValueException.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +/** + * An exception thrown while parsing an invalid input during serialization or deserialization. + */ +public class MalformedValueException extends RuntimeException { + /** + * Create a MalformedValueException instance. + * + * @param message the exception message + */ + public MalformedValueException(String message) { + super(message); + } + + /** + * Create a MalformedValueException instance. + * + * @param message the exception message + * @param cause the actual cause + */ + public MalformedValueException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerAdapter.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerAdapter.java new file mode 100644 index 0000000000000..5993991bd9d1d --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerAdapter.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + +import com.azure.common.implementation.CollectionFormat; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.List; + +/** + * An interface defining the behaviors of a serializer. + */ +public interface SerializerAdapter { + /** + * Serializes an object into a string. + * + * @param object the object to serialize + * @param encoding the encoding to use for serialization + * @return the serialized string. Null if the object to serialize is null + * @throws IOException exception from serialization + */ + String serialize(Object object, SerializerEncoding encoding) throws IOException; + + /** + * Serializes an object into a raw string. The leading and trailing quotes will be trimmed. + * + * @param object the object to serialize + * @return the serialized string. Null if the object to serialize is null + */ + String serializeRaw(Object object); + + /** + * Serializes a list into a string with the delimiter specified with the + * Swagger collection format joining each individual serialized items in + * the list. + * + * @param list the list to serialize + * @param format the Swagger collection format + * @return the serialized string + */ + String serializeList(List list, CollectionFormat format); + + /** + * Deserializes a string into a {@link U} object. + * + * @param value the string value to deserialize + * @param the type of the deserialized object + * @param type the type to deserialize + * @param encoding the encoding used in the serialized value + * @return the deserialized object + * @throws IOException exception from deserialization + */ + U deserialize(String value, Type type, SerializerEncoding encoding) throws IOException; +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerEncoding.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerEncoding.java new file mode 100644 index 0000000000000..97cf623f6a29f --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/SerializerEncoding.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer; + + +import com.azure.common.http.HttpHeaders; + +/** + * Supported serialization encoding formats. + */ +public enum SerializerEncoding { + /** + * JavaScript Object Notation. + */ + JSON, + + /** + * Extensible Markup Language. + */ + XML; + + /** + * Determines the serializer encoding to use based on the Content-Type header. + * + * @param headers the headers to check the encoding for + * @return the serializer encoding to use for the body + */ + public static SerializerEncoding fromHeaders(HttpHeaders headers) { + String mimeContentType = headers.value("Content-Type"); + if (mimeContentType != null) { + String[] parts = mimeContentType.split(";"); + if (parts[0].equalsIgnoreCase("application/xml") || parts[0].equalsIgnoreCase("text/xml")) { + return XML; + } + } + + return JSON; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesDeserializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesDeserializer.java new file mode 100644 index 0000000000000..0f15c4f691e0f --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesDeserializer.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; +import com.fasterxml.jackson.databind.deser.ResolvableDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.azure.common.implementation.util.TypeUtil; + +import java.io.IOException; +import java.lang.reflect.Field; + +/** + * Custom serializer for deserializing complex types with additional properties. + * If a complex type has a property named "additionalProperties" with serialized + * name empty ("") of type Map<String, Object>, all extra properties on the + * payload will be stored in this map. + */ +final class AdditionalPropertiesDeserializer extends StdDeserializer implements ResolvableDeserializer { + /** + * The default mapperAdapter for the current type. + */ + private final JsonDeserializer defaultDeserializer; + + /** + * The object mapper for default deserializations. + */ + private final ObjectMapper mapper; + + /** + * Creates FlatteningDeserializer. + * @param vc handled type + * @param defaultDeserializer the default JSON mapperAdapter + * @param mapper the object mapper for default deserializations + */ + protected AdditionalPropertiesDeserializer(Class vc, JsonDeserializer defaultDeserializer, ObjectMapper mapper) { + super(vc); + this.defaultDeserializer = defaultDeserializer; + this.mapper = mapper; + } + + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @param mapper the object mapper for default deserializations + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule(final ObjectMapper mapper) { + SimpleModule module = new SimpleModule(); + module.setDeserializerModifier(new BeanDeserializerModifier() { + @Override + public JsonDeserializer modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer deserializer) { + for (Class c : TypeUtil.getAllClasses(beanDesc.getBeanClass())) { + Field[] fields = c.getDeclaredFields(); + for (Field field : fields) { + if ("additionalProperties".equalsIgnoreCase(field.getName())) { + JsonProperty property = field.getAnnotation(JsonProperty.class); + if (property != null && property.value().isEmpty()) { + return new AdditionalPropertiesDeserializer(beanDesc.getBeanClass(), deserializer, mapper); + } + } + } + } + return deserializer; + } + }); + return module; + } + + @SuppressWarnings("unchecked") + @Override + public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + ObjectNode root = mapper.readTree(jp); + ObjectNode copy = root.deepCopy(); + + // compare top level fields and keep only missing fields + final Class tClass = this.defaultDeserializer.handledType(); + for (Class c : TypeUtil.getAllClasses(tClass)) { + Field[] fields = c.getDeclaredFields(); + for (Field field : fields) { + JsonProperty property = field.getAnnotation(JsonProperty.class); + String key = property.value().split("((? implements ResolvableSerializer { + /** + * The default mapperAdapter for the current type. + */ + private final JsonSerializer defaultSerializer; + + /** + * The object mapper for default serializations. + */ + private final ObjectMapper mapper; + + /** + * Creates an instance of FlatteningSerializer. + * @param vc handled type + * @param defaultSerializer the default JSON serializer + * @param mapper the object mapper for default serializations + */ + protected AdditionalPropertiesSerializer(Class vc, JsonSerializer defaultSerializer, ObjectMapper mapper) { + super(vc, false); + this.defaultSerializer = defaultSerializer; + this.mapper = mapper; + } + + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @param mapper the object mapper for default serializations + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule(final ObjectMapper mapper) { + SimpleModule module = new SimpleModule(); + module.setSerializerModifier(new BeanSerializerModifier() { + @Override + public JsonSerializer modifySerializer(SerializationConfig config, BeanDescription beanDesc, JsonSerializer serializer) { + for (Class c : TypeUtil.getAllClasses(beanDesc.getBeanClass())) { + if (c.isAssignableFrom(Object.class)) { + continue; + } + Field[] fields = c.getDeclaredFields(); + for (Field field : fields) { + if ("additionalProperties".equalsIgnoreCase(field.getName())) { + JsonProperty property = field.getAnnotation(JsonProperty.class); + if (property != null && property.value().isEmpty()) { + return new AdditionalPropertiesSerializer(beanDesc.getBeanClass(), serializer, mapper); + } + } + } + } + return serializer; + } + }); + return module; + } + + @Override + public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + // serialize the original object into JsonNode + ObjectNode root = mapper.valueToTree(value); + // take additional properties node out + Entry additionalPropertiesField = null; + Iterator> fields = root.fields(); + while (fields.hasNext()) { + Entry field = fields.next(); + if ("additionalProperties".equalsIgnoreCase(field.getKey())) { + additionalPropertiesField = field; + break; + } + } + if (additionalPropertiesField != null) { + root.remove(additionalPropertiesField.getKey()); + // put each item back in + ObjectNode extraProperties = (ObjectNode) additionalPropertiesField.getValue(); + fields = extraProperties.fields(); + while (fields.hasNext()) { + Entry field = fields.next(); + root.put(field.getKey(), field.getValue()); + } + } + + jgen.writeTree(root); + } + + @Override + public void resolve(SerializerProvider provider) throws JsonMappingException { + ((ResolvableSerializer) defaultSerializer).resolve(provider); + } + + @Override + public void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, TypeSerializer typeSerializer) throws IOException { + serialize(value, gen, provider); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/Base64UrlSerializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/Base64UrlSerializer.java new file mode 100644 index 0000000000000..bf0d1519e43a4 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/Base64UrlSerializer.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.azure.common.implementation.Base64Url; + +import java.io.IOException; + +/** + * Custom serializer for serializing {@code Byte[]} objects into Base64 strings. + */ +final class Base64UrlSerializer extends JsonSerializer { + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Base64Url.class, new Base64UrlSerializer()); + return module; + } + + @Override + public void serialize(Base64Url value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + jgen.writeString(value.toString()); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/ByteArraySerializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/ByteArraySerializer.java new file mode 100644 index 0000000000000..3515bdcd653e1 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/ByteArraySerializer.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.io.IOException; + +/** + * Custom serializer for serializing {@code Byte[]} objects into Base64 strings. + */ +final class ByteArraySerializer extends JsonSerializer { + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Byte[].class, new ByteArraySerializer()); + return module; + } + + @Override + public void serialize(Byte[] value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + byte[] bytes = new byte[value.length]; + for (int i = 0; i < value.length; i++) { + bytes[i] = value[i]; + } + jgen.writeBinary(bytes); + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeRfc1123Serializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeRfc1123Serializer.java new file mode 100644 index 0000000000000..c8ff88a87eed5 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeRfc1123Serializer.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.azure.common.implementation.DateTimeRfc1123; + +import java.io.IOException; + +/** + * Custom serializer for serializing {@link DateTimeRfc1123} object into RFC1123 formats. + */ +final class DateTimeRfc1123Serializer extends JsonSerializer { + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(DateTimeRfc1123.class, new DateTimeRfc1123Serializer()); + return module; + } + + @Override + public void serialize(DateTimeRfc1123 value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + if (provider.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)) { + jgen.writeNumber(value.dateTime().toInstant().toEpochMilli()); + } else { + jgen.writeString(value.toString()); //Use the default toString as it is RFC1123. + } + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializer.java new file mode 100644 index 0000000000000..68a30cd9af5b3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializer.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +/** + * Custom serializer for serializing {@link OffsetDateTime} object into ISO8601 formats. + */ +final class DateTimeSerializer extends JsonSerializer { + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(OffsetDateTime.class, new DateTimeSerializer()); + return module; + } + + @Override + public void serialize(OffsetDateTime value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + if (provider.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)) { + jgen.writeNumber(value.toInstant().toEpochMilli()); + } else { + jgen.writeString(toString(value)); + } + } + + /** + * Convert the provided OffsetDateTime to its String representation. + * @param offsetDateTime The OffsetDateTime to convert. + * @return The String representation of the provided offsetDateTime. + */ + public static String toString(OffsetDateTime offsetDateTime) { + String result = null; + if (offsetDateTime != null) { + offsetDateTime = offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC); + result = DateTimeFormatter.ISO_INSTANT.format(offsetDateTime); + if (result.startsWith("+")) { + result = result.substring(1); + } + } + return result; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DurationSerializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DurationSerializer.java new file mode 100644 index 0000000000000..97e8c8d5fa664 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/DurationSerializer.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.io.IOException; +import java.time.Duration; + +/** + * Custom serializer for serializing {@link Duration} object into ISO8601 formats. + */ +final class DurationSerializer extends JsonSerializer { + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Duration.class, new DurationSerializer()); + return module; + } + + @Override + public void serialize(Duration duration, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + jsonGenerator.writeString(DurationSerializer.toString(duration)); + } + + /** + * Convert to provided Duration to an ISO 8601 String with a days component. + * @param duration The Duration to convert. + * @return The String representation of the provided Duration. + */ + public static String toString(Duration duration) { + String result = null; + if (duration != null) { + if (duration.isZero()) { + result = "PT0S"; + } else { + final StringBuilder builder = new StringBuilder(); + + builder.append('P'); + + final long days = duration.toDays(); + if (days > 0) { + builder.append(days); + builder.append('D'); + duration = duration.minusDays(days); + } + + final long hours = duration.toHours(); + if (hours > 0) { + builder.append('T'); + builder.append(hours); + builder.append('H'); + duration = duration.minusHours(hours); + } + + final long minutes = duration.toMinutes(); + if (minutes > 0) { + if (hours == 0) { + builder.append('T'); + } + + builder.append(minutes); + builder.append('M'); + duration = duration.minusMinutes(minutes); + } + + final long seconds = duration.getSeconds(); + if (seconds > 0) { + if (hours == 0 && minutes == 0) { + builder.append('T'); + } + + builder.append(seconds); + duration = duration.minusSeconds(seconds); + } + + long milliseconds = duration.toMillis(); + if (milliseconds > 0) { + if (hours == 0 && minutes == 0 && seconds == 0) { + builder.append("T"); + } + + if (seconds == 0) { + builder.append("0"); + } + + builder.append('.'); + + if (milliseconds <= 99) { + builder.append('0'); + + if (milliseconds <= 9) { + builder.append('0'); + } + } + + // Remove trailing zeros. + while (milliseconds % 10 == 0) { + milliseconds /= 10; + } + builder.append(milliseconds); + } + + if (seconds > 0 || milliseconds > 0) { + builder.append('S'); + } + + result = builder.toString(); + } + } + return result; + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/FlatteningDeserializer.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/FlatteningDeserializer.java new file mode 100644 index 0000000000000..d042daf73f994 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/FlatteningDeserializer.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; +import com.fasterxml.jackson.databind.deser.ResolvableDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.azure.common.implementation.serializer.JsonFlatten; +import com.azure.common.implementation.util.TypeUtil; + +import java.io.IOException; +import java.lang.reflect.Field; + +/** + * Custom serializer for deserializing complex types with wrapped properties. + * For example, a property with annotation @JsonProperty(value = "properties.name") + * will be mapped to a top level "name" property in the POJO model. + */ +final class FlatteningDeserializer extends StdDeserializer implements ResolvableDeserializer { + /** + * The default mapperAdapter for the current type. + */ + private final JsonDeserializer defaultDeserializer; + + /** + * The object mapper for default deserializations. + */ + private final ObjectMapper mapper; + + /** + * Creates an instance of FlatteningDeserializer. + * @param vc handled type + * @param defaultDeserializer the default JSON mapperAdapter + * @param mapper the object mapper for default deserializations + */ + protected FlatteningDeserializer(Class vc, JsonDeserializer defaultDeserializer, ObjectMapper mapper) { + super(vc); + this.defaultDeserializer = defaultDeserializer; + this.mapper = mapper; + } + + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @param mapper the object mapper for default deserializations + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule(final ObjectMapper mapper) { + SimpleModule module = new SimpleModule(); + module.setDeserializerModifier(new BeanDeserializerModifier() { + @Override + public JsonDeserializer modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer deserializer) { + if (beanDesc.getBeanClass().getAnnotation(JsonFlatten.class) != null) { + return new FlatteningDeserializer(beanDesc.getBeanClass(), deserializer, mapper); + } + return deserializer; + } + }); + return module; + } + + @SuppressWarnings("unchecked") + @Override + public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + JsonNode root = mapper.readTree(jp); + final Class tClass = this.defaultDeserializer.handledType(); + for (Class c : TypeUtil.getAllClasses(tClass)) { + // Ignore checks for Object type. + if (c.isAssignableFrom(Object.class)) { + continue; + } + for (Field field : c.getDeclaredFields()) { + JsonNode node = root; + JsonProperty property = field.getAnnotation(JsonProperty.class); + if (property != null) { + String value = property.value(); + if (value.matches(".+[^\\\\]\\..+")) { + String[] values = value.split("((? implements ResolvableSerializer { + /** + * The default mapperAdapter for the current type. + */ + private final JsonSerializer defaultSerializer; + + /** + * The object mapper for default serializations. + */ + private final ObjectMapper mapper; + + /** + * Creates an instance of FlatteningSerializer. + * @param vc handled type + * @param defaultSerializer the default JSON serializer + * @param mapper the object mapper for default serializations + */ + protected FlatteningSerializer(Class vc, JsonSerializer defaultSerializer, ObjectMapper mapper) { + super(vc, false); + this.defaultSerializer = defaultSerializer; + this.mapper = mapper; + } + + /** + * Gets a module wrapping this serializer as an adapter for the Jackson + * ObjectMapper. + * + * @param mapper the object mapper for default serializations + * @return a simple module to be plugged onto Jackson ObjectMapper. + */ + public static SimpleModule getModule(final ObjectMapper mapper) { + SimpleModule module = new SimpleModule(); + module.setSerializerModifier(new BeanSerializerModifier() { + @Override + public JsonSerializer modifySerializer(SerializationConfig config, BeanDescription beanDesc, JsonSerializer serializer) { + if (beanDesc.getBeanClass().getAnnotation(JsonFlatten.class) != null) { + return new FlatteningSerializer(beanDesc.getBeanClass(), serializer, mapper); + } + return serializer; + } + }); + return module; + } + + private List getAllDeclaredFields(Class clazz) { + List fields = new ArrayList(); + while (clazz != null && !clazz.equals(Object.class)) { + for (Field f : clazz.getDeclaredFields()) { + int mod = f.getModifiers(); + if (!Modifier.isFinal(mod) && !Modifier.isStatic(mod)) { + fields.add(f); + } + } + clazz = clazz.getSuperclass(); + } + return fields; + } + + @SuppressWarnings("unchecked") + private void escapeMapKeys(Object value) { + if (value == null) { + return; + } + + if (value.getClass().isPrimitive() + || value.getClass().isEnum() + || value instanceof OffsetDateTime + || value instanceof Duration + || value instanceof String) { + return; + } + + int mod = value.getClass().getModifiers(); + if (Modifier.isFinal(mod) || Modifier.isStatic(mod)) { + return; + } + + if (value instanceof List) { + for (Object val : ((List) value)) { + escapeMapKeys(val); + } + return; + } + + if (value instanceof Map) { + for (String key : new HashSet<>(((Map) value).keySet())) { + if (key.contains(".")) { + String newKey = key.replaceAll("((?) value).remove(key); + ((Map) value).put(newKey, val); + } + } + for (Object val : ((Map) value).values()) { + escapeMapKeys(val); + } + return; + } + + for (Field f : getAllDeclaredFields(value.getClass())) { + f.setAccessible(true); + try { + escapeMapKeys(f.get(value)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + + @Override + public void serialize(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + if (value == null) { + jgen.writeNull(); + return; + } + escapeMapKeys(value); + // BFS for all collapsed properties + ObjectNode root = mapper.valueToTree(value); + ObjectNode res = root.deepCopy(); + Queue source = new LinkedBlockingQueue(); + Queue target = new LinkedBlockingQueue(); + source.add(root); + target.add(res); + while (!source.isEmpty()) { + ObjectNode current = source.poll(); + ObjectNode resCurrent = target.poll(); + Iterator> fields = current.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + ObjectNode node = resCurrent; + String key = field.getKey(); + JsonNode outNode = resCurrent.get(key); + if (key.matches(".+[^\\\\]\\..+")) { + // Handle flattening properties + // + String[] values = key.split("((? 0 + && (field.getValue()).get(0) instanceof ObjectNode) { + Iterator sourceIt = field.getValue().elements(); + Iterator targetIt = outNode.elements(); + while (sourceIt.hasNext()) { + source.add((ObjectNode) sourceIt.next()); + target.add((ObjectNode) targetIt.next()); + } + } + } + } + jgen.writeTree(res); + } + + @Override + public void resolve(SerializerProvider provider) throws JsonMappingException { + ((ResolvableSerializer) defaultSerializer).resolve(provider); + } + + @Override + public void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, TypeSerializer typeSerializer) throws IOException { + serialize(value, gen, provider); + } +} \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/JacksonAdapter.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/JacksonAdapter.java new file mode 100644 index 0000000000000..fedd95fb1b19b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/JacksonAdapter.java @@ -0,0 +1,191 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.azure.common.implementation.CollectionFormat; +import com.azure.common.implementation.serializer.MalformedValueException; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; + +import java.io.IOException; +import java.io.StringWriter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of {@link SerializerAdapter} for Jackson. + */ +public class JacksonAdapter implements SerializerAdapter { + /** + * An instance of {@link ObjectMapper} to serialize/deserialize objects. + */ + private final ObjectMapper mapper; + + /** + * An instance of {@link ObjectMapper} that does not do flattening. + */ + private final ObjectMapper simpleMapper; + + private final XmlMapper xmlMapper; + + /** + * Creates a new JacksonAdapter instance with default mapper settings. + */ + public JacksonAdapter() { + simpleMapper = initializeObjectMapper(new ObjectMapper()); + xmlMapper = initializeObjectMapper(new XmlMapper()); + xmlMapper.configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true); + xmlMapper.setDefaultUseWrapper(false); + ObjectMapper flatteningMapper = initializeObjectMapper(new ObjectMapper()) + .registerModule(FlatteningSerializer.getModule(simpleMapper())) + .registerModule(FlatteningDeserializer.getModule(simpleMapper())); + mapper = initializeObjectMapper(new ObjectMapper()) + // Order matters: must register in reverse order of hierarchy + .registerModule(AdditionalPropertiesSerializer.getModule(flatteningMapper)) + .registerModule(AdditionalPropertiesDeserializer.getModule(flatteningMapper)) + .registerModule(FlatteningSerializer.getModule(simpleMapper())) + .registerModule(FlatteningDeserializer.getModule(simpleMapper())); } + + /** + * Gets a static instance of {@link ObjectMapper} that doesn't handle flattening. + * + * @return an instance of {@link ObjectMapper}. + */ + protected ObjectMapper simpleMapper() { + return simpleMapper; + } + + /** + * @return the original serializer type + */ + public ObjectMapper serializer() { + return mapper; + } + + @Override + public String serialize(Object object, SerializerEncoding encoding) throws IOException { + if (object == null) { + return null; + } + StringWriter writer = new StringWriter(); + if (encoding == SerializerEncoding.XML) { + xmlMapper.writeValue(writer, object); + } else { + serializer().writeValue(writer, object); + } + + return writer.toString(); + } + + @Override + public String serializeRaw(Object object) { + if (object == null) { + return null; + } + try { + return serialize(object, SerializerEncoding.JSON).replaceAll("^\"*", "").replaceAll("\"*$", ""); + } catch (IOException ex) { + return null; + } + } + + @Override + public String serializeList(List list, CollectionFormat format) { + if (list == null) { + return null; + } + List serialized = new ArrayList<>(); + for (Object element : list) { + String raw = serializeRaw(element); + serialized.add(raw != null ? raw : ""); + } + return String.join(format.getDelimiter(), serialized); + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(String value, final Type type, SerializerEncoding encoding) throws IOException { + if (value == null || value.isEmpty()) { + return null; + } + + final JavaType javaType = createJavaType(type); + try { + if (encoding == SerializerEncoding.XML) { + return (T) xmlMapper.readValue(value, javaType); + } else { + return (T) serializer().readValue(value, javaType); + } + } catch (JsonParseException jpe) { + throw new MalformedValueException(jpe.getMessage(), jpe); + } + } + + /** + * Initializes an instance of JacksonMapperAdapter with default configurations + * applied to the object mapper. + * + * @param mapper the object mapper to use. + */ + private static T initializeObjectMapper(T mapper) { + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS, true) + .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) + .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .registerModule(new JavaTimeModule()) + .registerModule(ByteArraySerializer.getModule()) + .registerModule(Base64UrlSerializer.getModule()) + .registerModule(DateTimeSerializer.getModule()) + .registerModule(DateTimeRfc1123Serializer.getModule()) + .registerModule(DurationSerializer.getModule()); + mapper.setVisibility(mapper.getSerializationConfig().getDefaultVisibilityChecker() + .withFieldVisibility(JsonAutoDetect.Visibility.ANY) + .withSetterVisibility(JsonAutoDetect.Visibility.NONE) + .withGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withIsGetterVisibility(JsonAutoDetect.Visibility.NONE)); + return mapper; + } + + private JavaType createJavaType(Type type) { + JavaType result; + if (type == null) { + result = null; + } + else if (type instanceof JavaType) { + result = (JavaType) type; + } + else if (type instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) type; + final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + JavaType[] javaTypeArguments = new JavaType[actualTypeArguments.length]; + for (int i = 0; i != actualTypeArguments.length; i++) { + javaTypeArguments[i] = createJavaType(actualTypeArguments[i]); + } + result = mapper.getTypeFactory().constructParametricType((Class) parameterizedType.getRawType(), javaTypeArguments); + } + else { + result = mapper.getTypeFactory().constructType(type); + } + return result; + } + +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/package-info.java new file mode 100644 index 0000000000000..6008e2604b08b --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/jackson/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing serialization and deserialization implementation using JSON library for Java (Jackson). + */ +package com.azure.common.implementation.serializer.jackson; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/serializer/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/package-info.java new file mode 100644 index 0000000000000..4d312dbefc673 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/serializer/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing interfaces describing serialization and deserialization contract. + */ +package com.azure.common.implementation.serializer; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/util/Base64Util.java b/common/azure-common/src/main/java/com/azure/common/implementation/util/Base64Util.java new file mode 100644 index 0000000000000..ffca5e3bfcaa3 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/util/Base64Util.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import java.util.Base64; + +/** + * Utility type exposing Base64 encoding and decoding methods. + */ +public final class Base64Util { + /** + * Encodes a byte array to base64. + * @param src the byte array to encode + * @return the base64 encoded bytes + */ + public static byte[] encode(byte[] src) { + return src == null ? null : Base64.getEncoder().encode(src); + } + + /** + * Encodes a byte array to base64 URL format. + * @param src the byte array to encode + * @return the base64 URL encoded bytes + */ + public static byte[] encodeURLWithoutPadding(byte[] src) { + return src == null ? null : Base64.getUrlEncoder().withoutPadding().encode(src); + } + + /** + * Encodes a byte array to a base 64 string. + * @param src the byte array to encode + * @return the base64 encoded string + */ + public static String encodeToString(byte[] src) { + return src == null ? null : Base64.getEncoder().encodeToString(src); + } + + /** + * Decodes a base64 encoded byte array. + * @param encoded the byte array to decode + * @return the decoded byte array + */ + public static byte[] decode(byte[] encoded) { + return encoded == null ? null : Base64.getDecoder().decode(encoded); + } + + /** + * Decodes a byte array in base64 URL format. + * @param src the byte array to decode + * @return the decoded byte array + */ + public static byte[] decodeURL(byte[] src) { + return src == null ? null : Base64.getUrlDecoder().decode(src); + } + + /** + * Decodes a base64 encoded string. + * @param encoded the string to decode + * @return the decoded byte array + */ + public static byte[] decodeString(String encoded) { + return encoded == null ? null : Base64.getDecoder().decode(encoded); + } + + // Private Ctr + private Base64Util() { + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/util/FluxUtil.java b/common/azure-common/src/main/java/com/azure/common/implementation/util/FluxUtil.java new file mode 100644 index 0000000000000..ca732b4c5d3c0 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/util/FluxUtil.java @@ -0,0 +1,407 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.CompositeByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCountUtil; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Operators; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.channels.CompletionHandler; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; +import java.util.concurrent.atomic.AtomicLongFieldUpdater; + +/** + * Utility type exposing methods to deal with {@link Flux}. + */ +public final class FluxUtil { + /** + * Checks if a type is Flux<ByteBuf>. + * + * @param entityType the type to check + * @return whether the type represents a Flux that emits ByteBuf + */ + public static boolean isFluxByteBuf(Type entityType) { + if (TypeUtil.isTypeOrSubTypeOf(entityType, Flux.class)) { + final Type innerType = TypeUtil.getTypeArguments(entityType)[0]; + if (TypeUtil.isTypeOrSubTypeOf(innerType, ByteBuf.class)) { + return true; + } + } + return false; + } + + /** + * Collects ByteBuf emitted by a Flux into a byte array. + * @param stream A stream which emits ByteBuf instances. + * @param autoReleaseEnabled if ByteBuf instances in stream gets automatically released as they consumed + * @return A Mono which emits the concatenation of all the ByteBuf instances given by the source Flux. + */ + public static Mono collectBytesInByteBufStream(Flux stream, boolean autoReleaseEnabled) { + if (autoReleaseEnabled) { + // A stream is auto-release enabled means - the ByteBuf chunks in the stream get + // released as consumer consumes each chunk. + return Mono.using(Unpooled::compositeBuffer, + cbb -> stream.collect(() -> cbb, + (cbb1, buffer) -> cbb1.addComponent(true, Unpooled.wrappedBuffer(buffer).retain())), + ReferenceCountUtil::release) + .filter((CompositeByteBuf cbb) -> cbb.isReadable()) + .map(FluxUtil::byteBufToArray); + } else { + return stream.collect(Unpooled::compositeBuffer, + (cbb1, buffer) -> cbb1.addComponent(true, Unpooled.wrappedBuffer(buffer))) + .filter((CompositeByteBuf cbb) -> cbb.isReadable()) + .map(FluxUtil::byteBufToArray); + } + } + + /** + * Splits a ByteBuf into ByteBuf chunks. + * + * @param whole the ByteBuf to split + * @param chunkSize the maximum size of each ByteBuf chunk + * @return A stream that emits chunks of the original whole ByteBuf + */ + public static Flux split(final ByteBuf whole, final int chunkSize) { + return Flux.generate(whole::readerIndex, (readFromIndex, synchronousSync) -> { + final int writerIndex = whole.writerIndex(); + // + if (readFromIndex >= writerIndex) { + synchronousSync.complete(); + return writerIndex; + } else { + int readSize = Math.min(writerIndex - readFromIndex, chunkSize); + // Netty slice operation will not increment the ref count. + // + // Here we invoke 'retain' on each slice, since + // consumer of the returned Flux stream is responsible for + // releasing each chunk as it gets consumed. + // + synchronousSync.next(whole.slice(readFromIndex, readSize).retain()); + return readFromIndex + readSize; + } + }); + } + + /** + * Gets the content of the provided ByteBuf as a byte array. + * This method will create a new byte array even if the ByteBuf can + * have optionally backing array. + * + * + * @param byteBuf the byte buffer + * @return the byte array + */ + public static byte[] byteBufToArray(ByteBuf byteBuf) { + int length = byteBuf.readableBytes(); + byte[] byteArray = new byte[length]; + byteBuf.getBytes(byteBuf.readerIndex(), byteArray); + return byteArray; + } + + /** + * Collects byte buffers emitted by a Flux into a ByteBuf. + * + * @param stream A stream which emits ByteBuf instances. + * @param autoReleaseEnabled if ByteBuf instances in stream gets automatically released as they consumed + * @return A Mono which emits the concatenation of all the byte buffers given by the source Flux. + */ + public static Mono collectByteBufStream(Flux stream, boolean autoReleaseEnabled) { + if (autoReleaseEnabled) { + Mono mergedCbb = Mono.using( + // Resource supplier + () -> { + CompositeByteBuf initialCbb = Unpooled.compositeBuffer(); + return initialCbb; + }, + // source Mono creator + (CompositeByteBuf initialCbb) -> { + Mono reducedCbb = stream.reduce(initialCbb, (CompositeByteBuf currentCbb, ByteBuf nextBb) -> { + CompositeByteBuf updatedCbb = currentCbb.addComponent(nextBb.retain()); + return updatedCbb; + }); + // + return reducedCbb + .doOnNext((CompositeByteBuf cbb) -> cbb.writerIndex(cbb.capacity())) + .filter((CompositeByteBuf cbb) -> cbb.isReadable()); + }, + // Resource cleaner + (CompositeByteBuf finalCbb) -> finalCbb.release()); + return mergedCbb; + } else { + return stream.collect(Unpooled::compositeBuffer, + (cbb1, buffer) -> cbb1.addComponent(true, Unpooled.wrappedBuffer(buffer))) + .filter((CompositeByteBuf cbb) -> cbb.isReadable()) + .map(bb -> bb); + } + } + + private static final int DEFAULT_CHUNK_SIZE = 1024 * 64; + + //region Utility methods to create Flux that read and emits chunks from AsynchronousFileChannel. + + /** + * Creates a {@link Flux} from an {@link AsynchronousFileChannel} + * which reads part of a file into chunks of the given size. + * + * @param fileChannel The file channel. + * @param chunkSize the size of file chunks to read. + * @param offset The offset in the file to begin reading. + * @param length The number of bytes to read from the file. + * @return the Flowable. + */ + public static Flux byteBufStreamFromFile(AsynchronousFileChannel fileChannel, int chunkSize, long offset, long length) { + return new ByteBufStreamFromFile(fileChannel, chunkSize, offset, length); + } + + /** + * Creates a {@link Flux} from an {@link AsynchronousFileChannel} + * which reads part of a file. + * + * @param fileChannel The file channel. + * @param offset The offset in the file to begin reading. + * @param length The number of bytes to read from the file. + * @return the Flowable. + */ + public static Flux byteBufStreamFromFile(AsynchronousFileChannel fileChannel, long offset, long length) { + return byteBufStreamFromFile(fileChannel, DEFAULT_CHUNK_SIZE, offset, length); + } + + /** + * Creates a {@link Flux} from an {@link AsynchronousFileChannel} + * which reads the entire file. + * + * @param fileChannel The file channel. + * @return The AsyncInputStream. + */ + public static Flux byteBufStreamFromFile(AsynchronousFileChannel fileChannel) { + try { + long size = fileChannel.size(); + return byteBufStreamFromFile(fileChannel, DEFAULT_CHUNK_SIZE, 0, size); + } catch (IOException e) { + return Flux.error(e); + } + } + //endregion + + //region ByteBufStreamFromFile implementation + private static final class ByteBufStreamFromFile extends Flux { + private final ByteBufAllocator alloc; + private final AsynchronousFileChannel fileChannel; + private final int chunkSize; + private final long offset; + private final long length; + + ByteBufStreamFromFile(AsynchronousFileChannel fileChannel, int chunkSize, long offset, long length) { + this.alloc = ByteBufAllocator.DEFAULT; + this.fileChannel = fileChannel; + this.chunkSize = chunkSize; + this.offset = offset; + this.length = length; + } + + @Override + public void subscribe(CoreSubscriber actual) { + FileReadSubscription subscription = new FileReadSubscription(actual, fileChannel, alloc, chunkSize, offset, length); + actual.onSubscribe(subscription); + } + + static final class FileReadSubscription implements Subscription, CompletionHandler { + private static final int NOT_SET = -1; + private static final long serialVersionUID = -6831808726875304256L; + // + private final Subscriber subscriber; + private volatile long position; + // + private final AsynchronousFileChannel fileChannel; + private final ByteBufAllocator alloc; + private final int chunkSize; + private final long offset; + private final long length; + // + private volatile boolean done; + private Throwable error; + private volatile ByteBuf next; + private volatile boolean cancelled; + // + volatile int wip; + @SuppressWarnings("rawtypes") + static final AtomicIntegerFieldUpdater WIP = AtomicIntegerFieldUpdater.newUpdater(FileReadSubscription.class, "wip"); + volatile long requested; + @SuppressWarnings("rawtypes") + static final AtomicLongFieldUpdater REQUESTED = AtomicLongFieldUpdater.newUpdater(FileReadSubscription.class, "requested"); + // + + FileReadSubscription(Subscriber subscriber, AsynchronousFileChannel fileChannel, ByteBufAllocator alloc, int chunkSize, long offset, long length) { + this.subscriber = subscriber; + // + this.fileChannel = fileChannel; + this.alloc = alloc; + this.chunkSize = chunkSize; + this.offset = offset; + this.length = length; + // + this.position = NOT_SET; + } + + //region Subscription implementation + + @Override + public void request(long n) { + if (Operators.validate(n)) { + Operators.addCap(REQUESTED, this, n); + drain(); + } + } + + @Override + public void cancel() { + this.cancelled = true; + } + + //endregion + + //region CompletionHandler implementation + + @Override + public void completed(Integer bytesRead, ByteBuf buffer) { + if (!cancelled) { + if (bytesRead == -1) { + done = true; + } else { + // use local variable to perform fewer volatile reads + long pos = position; + // + int bytesWanted = (int) Math.min(bytesRead, maxRequired(pos)); + buffer.writerIndex(bytesWanted); + long position2 = pos + bytesWanted; + //noinspection NonAtomicOperationOnVolatileField + position = position2; + next = buffer; + if (position2 >= offset + length) { + done = true; + } + } + drain(); + } + } + + @Override + public void failed(Throwable exc, ByteBuf attachment) { + if (!cancelled) { + // must set error before setting done to true + // so that is visible in drain loop + error = exc; + done = true; + drain(); + } + } + + //endregion + + private void drain() { + if (WIP.getAndIncrement(this) != 0) { + return; + } + // on first drain (first request) we initiate the first read + if (position == NOT_SET) { + position = offset; + doRead(); + } + int missed = 1; + for (;;) { + if (cancelled) { + return; + } + if (REQUESTED.get(this) > 0) { + boolean emitted = false; + // read d before next to avoid race + boolean d = done; + ByteBuf bb = next; + if (bb != null) { + next = null; + // + // try { + subscriber.onNext(bb); + // } finally { + // Note: Don't release here, we follow netty disposal pattern + // it's consumers responsiblity to release chunks after consumption. + // + // ReferenceCountUtil.release(bb); + // } + // + emitted = true; + } else { + emitted = false; + } + if (d) { + if (error != null) { + subscriber.onError(error); + // exit without reducing wip so that further drains will be NOOP + return; + } else { + subscriber.onComplete(); + // exit without reducing wip so that further drains will be NOOP + return; + } + } + if (emitted) { + // do this after checking d to avoid calling read + // when done + Operators.produced(REQUESTED, this, 1); + // + doRead(); + } + } + missed = WIP.addAndGet(this, -missed); + if (missed == 0) { + return; + } + } + } + + private void doRead() { + // use local variable to limit volatile reads + long pos = position; + int readSize = Math.min(chunkSize, maxRequired(pos)); + ByteBuf innerBuf = alloc.buffer(readSize, readSize); + fileChannel.read(innerBuf.nioBuffer(0, readSize), pos, innerBuf, this); + } + + private int maxRequired(long pos) { + long maxRequired = offset + length - pos; + if (maxRequired <= 0) { + return 0; + } else { + int m = (int) (maxRequired); + // support really large files by checking for overflow + if (m < 0) { + return Integer.MAX_VALUE; + } else { + return m; + } + } + } + } + } + + //endregion + + // Private Ctr + private FluxUtil() { + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/util/TypeUtil.java b/common/azure-common/src/main/java/com/azure/common/implementation/util/TypeUtil.java new file mode 100644 index 0000000000000..bdae35c928e46 --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/util/TypeUtil.java @@ -0,0 +1,207 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Utility type exposing methods to deal with {@link Type}. + */ +public final class TypeUtil { + /** + * Find all super classes including provided class. + * + * @param clazz the raw class to find super types for + * @return the list of super classes + */ + public static List> getAllClasses(Class clazz) { + List> types = new ArrayList<>(); + while (clazz != null) { + types.add(clazz); + clazz = clazz.getSuperclass(); + } + return types; + } + + /** + * Get the generic arguments for a type. + * + * @param type the type to get arguments + * @return the generic arguments, empty if type is not parameterized + */ + public static Type[] getTypeArguments(Type type) { + if (!(type instanceof ParameterizedType)) { + return new Type[0]; + } + return ((ParameterizedType) type).getActualTypeArguments(); + } + + /** + * Get the generic argument, or the first if the type has more than one. + * + * @param type the type to get arguments + * @return the generic argument, null if type is not parameterized + */ + public static Type getTypeArgument(Type type) { + if (!(type instanceof ParameterizedType)) { + return null; + } + return ((ParameterizedType) type).getActualTypeArguments()[0]; + } + + /** + * Get the raw class for a given type. + * + * @param type the input type + * @return the raw class + */ + public static Class getRawClass(Type type) { + if (type instanceof ParameterizedType) { + return (Class) ((ParameterizedType) type).getRawType(); + } else { + return (Class) type; + } + } + + /** + * Get the super type for a given type. + * + * @param type the input type + * @return the direct super type + */ + public static Type getSuperType(Type type) { + if (type instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) type; + Type genericSuperClass = ((Class) parameterizedType.getRawType()).getGenericSuperclass(); + if (genericSuperClass instanceof ParameterizedType) { + /* + * Find erased generic types for the super class and replace + * with actual type arguments from the parameterized type + */ + Type[] superTypeArguments = getTypeArguments(genericSuperClass); + List typeParameters = Arrays.asList(((Class) parameterizedType.getRawType()).getTypeParameters()); + int j = 0; + for (int i = 0; i != superTypeArguments.length; i++) { + if (typeParameters.contains(superTypeArguments[i])) { + superTypeArguments[i] = parameterizedType.getActualTypeArguments()[j++]; + } + } + return new ParameterizedType() { + @Override + public Type[] getActualTypeArguments() { + return superTypeArguments; + } + + @Override + public Type getRawType() { + return ((ParameterizedType) genericSuperClass).getRawType(); + } + + @Override + public Type getOwnerType() { + return null; + } + }; + } else { + return genericSuperClass; + } + } else { + return ((Class) type).getGenericSuperclass(); + } + } + + /** + * Get the super type for a type in its super type chain, which has + * a raw class that matches the specified class. + * + * @param subType the sub type to find super type for + * @param rawSuperType the raw class for the super type + * @return the super type that matches the requirement + */ + public static Type getSuperType(Type subType, Class rawSuperType) { + while (subType != null && getRawClass(subType) != rawSuperType) { + subType = getSuperType(subType); + } + return subType; + } + + /** + * Determines if a type is the same or a subtype for another type. + * + * @param subType the supposed sub type + * @param superType the supposed super type + * @return true if the first type is the same or a subtype for the second type + */ + public static boolean isTypeOrSubTypeOf(Type subType, Type superType) { + Class sub = getRawClass(subType); + Class sup = getRawClass(superType); + + return sup.isAssignableFrom(sub); + } + + /** + * Create a parameterized type from a raw class and its type arguments. + * + * @param rawClass the raw class to construct the parameterized type + * @param genericTypes the generic arguments + * @return the parameterized type + */ + public static ParameterizedType createParameterizedType(Class rawClass, Type... genericTypes) { + return new ParameterizedType() { + @Override + public Type[] getActualTypeArguments() { + return genericTypes; + } + + @Override + public Type getRawType() { + return rawClass; + } + + @Override + public Type getOwnerType() { + return null; + } + }; + } + + /** + * Returns whether the rest response expects to have any body (by checking if the body parameter type is set to Void, + * in which case no body is expected). + * + * @param restResponseReturnType The RestResponse subtype containing the type arguments we are inspecting. + * @return True if a body is expected, false if a Void body is expected. + */ + public static boolean restResponseTypeExpectsBody(ParameterizedType restResponseReturnType) { + return getRestResponseBodyType(restResponseReturnType) != Void.class; + } + + /** + * Returns the body type expected in the rest response. + * + * @param restResponseReturnType The RestResponse subtype containing the type arguments we are inspecting. + * @return The type of the body. + */ + public static Type getRestResponseBodyType(Type restResponseReturnType) { + // if this type has type arguments, then we look at the last one to determine if it expects a body + final Type[] restResponseTypeArguments = TypeUtil.getTypeArguments(restResponseReturnType); + if (restResponseTypeArguments != null && restResponseTypeArguments.length > 0) { + return restResponseTypeArguments[restResponseTypeArguments.length - 1]; + } else { + // no generic type on this RestResponse sub-type, so we go up to parent + return getRestResponseBodyType(TypeUtil.getSuperType(restResponseReturnType)); + } + } + + // Private Ctr + private TypeUtil() { + } +} diff --git a/common/azure-common/src/main/java/com/azure/common/implementation/util/package-info.java b/common/azure-common/src/main/java/com/azure/common/implementation/util/package-info.java new file mode 100644 index 0000000000000..1e7bba742b3bf --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/implementation/util/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing utility classes with helper methods for the runtime. + */ +package com.azure.common.implementation.util; \ No newline at end of file diff --git a/common/azure-common/src/main/java/com/azure/common/package-info.java b/common/azure-common/src/main/java/com/azure/common/package-info.java new file mode 100644 index 0000000000000..657249cf826cf --- /dev/null +++ b/common/azure-common/src/main/java/com/azure/common/package-info.java @@ -0,0 +1,4 @@ +/** + * Package containing the types for client side http communication with a REST endpoint. + */ +package com.azure.common; \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/CredentialsTests.java b/common/azure-common/src/test/java/com/azure/common/CredentialsTests.java new file mode 100644 index 0000000000000..0b8cc3458f0c9 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/CredentialsTests.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common; + +import com.azure.common.credentials.BasicAuthenticationCredentials; +import com.azure.common.credentials.TokenCredentials; + +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.policy.CredentialsPolicy; +import com.azure.common.http.policy.HttpPipelinePolicy; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.MockHttpClient; +import org.junit.Assert; +import org.junit.Test; + +import java.net.URL; + +public class CredentialsTests { + + @Test + public void basicCredentialsTest() throws Exception { + BasicAuthenticationCredentials credentials = new BasicAuthenticationCredentials("user", "pass"); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + String headerValue = context.httpRequest().headers().value("Authorization"); + Assert.assertEquals("Basic dXNlcjpwYXNz", headerValue); + return next.process(); + }; + // + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient(), + new CredentialsPolicy(credentials), + auditorPolicy); + + + HttpRequest request = new HttpRequest(HttpMethod.GET, new URL("http://localhost")); + pipeline.send(request).block(); + } + + @Test + public void tokenCredentialsTest() throws Exception { + TokenCredentials credentials = new TokenCredentials(null, "this_is_a_token"); + + HttpPipelinePolicy auditorPolicy = (context, next) -> { + String headerValue = context.httpRequest().headers().value("Authorization"); + Assert.assertEquals("Bearer this_is_a_token", headerValue); + return next.process(); + }; + + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient(), + new CredentialsPolicy(credentials), + auditorPolicy); + + HttpRequest request = new HttpRequest(HttpMethod.GET, new URL("http://localhost")); + pipeline.send(request).block(); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/MockServer.java b/common/azure-common/src/test/java/com/azure/common/MockServer.java new file mode 100644 index 0000000000000..01cec3f887d27 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/MockServer.java @@ -0,0 +1,103 @@ +package com.azure.common; + + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.server.handler.HandlerWrapper; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Random; + +public class MockServer { + private static class TestHandler extends HandlerWrapper { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException { + LoggerFactory.getLogger(getClass()).info("Received request for " + baseRequest.getRequestURL()); + baseRequest.setHandled(true); + Random random = new Random(); + + byte[] buf = new byte[8192]; + InputStream is = request.getInputStream(); + MessageDigest md5; + try { + md5 = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + while (true) { + int bytesRead = is.read(buf); + if (bytesRead == -1) { + break; + } + md5.update(buf, 0, bytesRead); + + int randomNumber = random.nextInt(100000); + if (randomNumber == 12345) { + LoggerFactory.getLogger(getClass()).info("Server had a transient error."); + response.setStatus(503); + response.getWriter().println("Error! Please try again."); + + // Appears to be necessary to read all the request content to prevent hangs + // Would like to be able to test scenarios where the server drops the connection + // when we're in the middle of sending request content. + while (is.read(buf) != -1) ; + + return; + } + } + + byte[] md5Digest = md5.digest(); + String encodedMD5 = Base64.getEncoder().encodeToString(md5Digest); + if (request.getMethod().equals("DELETE")) { + response.setStatus(202); + } else { + response.setStatus(201); + } + response.setHeader("Content-MD5", encodedMD5); + LoggerFactory.getLogger(getClass()).info("Finished handling request " + baseRequest.getRequestURL()); + } + } + + public static void main(String[] args) throws Exception { + int port = 8080; + String portString = System.getenv("JAVA_SDK_TEST_PORT"); + if (portString != null) { + port = Integer.parseInt(portString, 10); + } + + Server server = new Server(port); + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setDirectoriesListed(true); + + String tempPath = System.getenv("JAVA_STRESS_TEST_TEMP_PATH"); + if (tempPath == null || tempPath.isEmpty()) { + tempPath = "client-runtime/temp"; + } + + resourceHandler.setResourceBase(tempPath); + ContextHandler ch = new ContextHandler("/javasdktest/upload"); + ch.setHandler(resourceHandler); + + HandlerList handlers = new HandlerList(); + handlers.addHandler(ch); + handlers.addHandler(new TestHandler()); + + server.setHandler(handlers); + + System.out.println("Starting MockServer"); + server.start(); + server.join(); + System.out.println("Shutting down MockServer"); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/MyRestException.java b/common/azure-common/src/test/java/com/azure/common/MyRestException.java new file mode 100644 index 0000000000000..0e2b9ef8ad86d --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/MyRestException.java @@ -0,0 +1,16 @@ +package com.azure.common; + +import com.azure.common.entities.HttpBinJSON; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.rest.RestException; + +public class MyRestException extends RestException { + public MyRestException(String message, HttpResponse response, HttpBinJSON body) { + super(message, response, body); + } + + @Override + public HttpBinJSON body() { + return (HttpBinJSON) super.body(); + } +} \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/UserAgentTests.java b/common/azure-common/src/test/java/com/azure/common/UserAgentTests.java new file mode 100644 index 0000000000000..ad8f498be55ba --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/UserAgentTests.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common; + +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.MockHttpClient; +import com.azure.common.http.MockHttpResponse; +import com.azure.common.http.policy.UserAgentPolicy; +import org.junit.Assert; +import org.junit.Test; + +import reactor.core.publisher.Mono; + +import java.net.URL; + +public class UserAgentTests { + @Test + public void defaultUserAgentTests() throws Exception { + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + Assert.assertEquals( + request.headers().value("User-Agent"), + "AutoRest-Java"); + return Mono.just(new MockHttpResponse(request, 200)); + } + }, + new UserAgentPolicy("AutoRest-Java")); + + HttpResponse response = pipeline.send(new HttpRequest( + HttpMethod.GET, new URL("http://localhost"))).block(); + + Assert.assertEquals(200, response.statusCode()); + } + + @Test + public void customUserAgentTests() throws Exception { + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + String header = request.headers().value("User-Agent"); + Assert.assertEquals("Awesome", header); + return Mono.just(new MockHttpResponse(request, 200)); + } + }, + new UserAgentPolicy("Awesome")); + + HttpResponse response = pipeline.send(new HttpRequest(HttpMethod.GET, + new URL("http://localhost"))).block(); + Assert.assertEquals(200, response.statusCode()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/entities/AccessPolicy.java b/common/azure-common/src/test/java/com/azure/common/entities/AccessPolicy.java new file mode 100644 index 0000000000000..9b344f77bd48c --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/AccessPolicy.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + * + * Code generated by Microsoft (R) AutoRest Code Generator. + * Changes may cause incorrect behavior and will be lost if the code is + * regenerated. + */ + +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; + +/** + * An Access policy. + */ +public class AccessPolicy { + /** + * the date-time the policy is active. + */ + @JsonProperty(value = "Start") + private OffsetDateTime start; + + /** + * the date-time the policy expires. + */ + @JsonProperty(value = "Expiry") + private OffsetDateTime expiry; + + /** + * the permissions for the acl policy. + */ + @JsonProperty(value = "Permission") + private String permission; + + /** + * Get the start value. + * + * @return the start value + */ + public OffsetDateTime start() { + return this.start; + } + + /** + * Set the start value. + * + * @param start the start value to set + * @return the AccessPolicy object itself. + */ + public AccessPolicy withStart(OffsetDateTime start) { + this.start = start; + return this; + } + + /** + * Get the expiry value. + * + * @return the expiry value + */ + public OffsetDateTime expiry() { + return this.expiry; + } + + /** + * Set the expiry value. + * + * @param expiry the expiry value to set + * @return the AccessPolicy object itself. + */ + public AccessPolicy withExpiry(OffsetDateTime expiry) { + this.expiry = expiry; + return this; + } + + /** + * Get the permission value. + * + * @return the permission value + */ + public String permission() { + return this.permission; + } + + /** + * Set the permission value. + * + * @param permission the permission value to set + * @return the AccessPolicy object itself. + */ + public AccessPolicy withPermission(String permission) { + this.permission = permission; + return this; + } + +} diff --git a/common/azure-common/src/test/java/com/azure/common/entities/HttpBinHeaders.java b/common/azure-common/src/test/java/com/azure/common/entities/HttpBinHeaders.java new file mode 100644 index 0000000000000..47a1ad371c33e --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/HttpBinHeaders.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + * + * Code generated by Microsoft (R) AutoRest Code Generator. + */ + +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.azure.common.implementation.DateTimeRfc1123; + +/** + * Defines headers for httpbin.org operations. + */ +public class HttpBinHeaders { + @JsonProperty(value = "Date") + public DateTimeRfc1123 date; + + @JsonProperty(value = "Via") + public String via; + + @JsonProperty(value = "Connection") + public String connection; + + @JsonProperty(value = "X-Processed-Time") + public double xProcessedTime; + + @JsonProperty(value = "Access-Control-Allow-Credentials") + public boolean accessControlAllowCredentials; +} diff --git a/common/azure-common/src/test/java/com/azure/common/entities/HttpBinJSON.java b/common/azure-common/src/test/java/com/azure/common/entities/HttpBinJSON.java new file mode 100644 index 0000000000000..8f497aa576643 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/HttpBinJSON.java @@ -0,0 +1,12 @@ +package com.azure.common.entities; + +import java.util.Map; + +/** + * Maps to the JSON return values from http://httpbin.org. + */ +public class HttpBinJSON { + public String url; + public Map headers; + public Object data; +} \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifierInner.java b/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifierInner.java new file mode 100644 index 0000000000000..e9ce9e558a892 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifierInner.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + * + * Code generated by Microsoft (R) AutoRest Code Generator. + * Changes may cause incorrect behavior and will be lost if the code is + * regenerated. + */ + +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * signed identifier. + */ +public class SignedIdentifierInner { + /** + * a unique id. + */ + @JsonProperty(value = "Id", required = true) + private String id; + + /** + * The access policy. + */ + @JsonProperty(value = "AccessPolicy", required = true) + private AccessPolicy accessPolicy; + + /** + * Get the id value. + * + * @return the id value + */ + public String id() { + return this.id; + } + + /** + * Set the id value. + * + * @param id the id value to set + * @return the SignedIdentifierInner object itself. + */ + public SignedIdentifierInner withId(String id) { + this.id = id; + return this; + } + + /** + * Get the accessPolicy value. + * + * @return the accessPolicy value + */ + public AccessPolicy accessPolicy() { + return this.accessPolicy; + } + + /** + * Set the accessPolicy value. + * + * @param accessPolicy the accessPolicy value to set + * @return the SignedIdentifierInner object itself. + */ + public SignedIdentifierInner withAccessPolicy(AccessPolicy accessPolicy) { + this.accessPolicy = accessPolicy; + return this; + } + +} diff --git a/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifiersWrapper.java b/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifiersWrapper.java new file mode 100644 index 0000000000000..12ddcc5493f76 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/SignedIdentifiersWrapper.java @@ -0,0 +1,26 @@ +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; + +import java.util.List; + +@JacksonXmlRootElement(localName = "SignedIdentifiers") +public class SignedIdentifiersWrapper { + @JacksonXmlProperty(localName = "SignedIdentifier") + private final List signedIdentifiers; + @JsonCreator + public SignedIdentifiersWrapper(@JsonProperty("signedIdentifiers") List signedIdentifiers) { + this.signedIdentifiers = signedIdentifiers; + } + /** + * Get the SignedIdentifiers value. + * + * @return the SignedIdentifiers value + */ + public List signedIdentifiers() { + return signedIdentifiers; + } +} \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/entities/Slide.java b/common/azure-common/src/test/java/com/azure/common/entities/Slide.java new file mode 100644 index 0000000000000..09020f59d5180 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/Slide.java @@ -0,0 +1,15 @@ +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; + +public class Slide { + @JacksonXmlProperty(localName = "type", isAttribute = true) + public String type; + + @JsonProperty("title") + public String title; + + @JsonProperty("item") + public String[] items; +} diff --git a/common/azure-common/src/test/java/com/azure/common/entities/Slideshow.java b/common/azure-common/src/test/java/com/azure/common/entities/Slideshow.java new file mode 100644 index 0000000000000..0ddda5e271683 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/entities/Slideshow.java @@ -0,0 +1,18 @@ +package com.azure.common.entities; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; + +public class Slideshow { + @JacksonXmlProperty(localName = "title", isAttribute = true) + public String title; + + @JacksonXmlProperty(localName = "date", isAttribute = true) + public String date; + + @JacksonXmlProperty(localName = "author", isAttribute = true) + public String author; + + @JsonProperty("slide") + public Slide[] slides; +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/HttpHeaderTests.java b/common/azure-common/src/test/java/com/azure/common/http/HttpHeaderTests.java new file mode 100644 index 0000000000000..e8d6811b2030a --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/HttpHeaderTests.java @@ -0,0 +1,15 @@ +package com.azure.common.http; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class HttpHeaderTests { + @Test + public void addValue() + { + final HttpHeader header = new HttpHeader("a", "b"); + header.addValue("c"); + assertEquals("a:b,c", header.toString()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/HttpHeadersTests.java b/common/azure-common/src/test/java/com/azure/common/http/HttpHeadersTests.java new file mode 100644 index 0000000000000..c5b06e446faf8 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/HttpHeadersTests.java @@ -0,0 +1,31 @@ +package com.azure.common.http; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class HttpHeadersTests { + @Test + public void testSet() + { + final HttpHeaders headers = new HttpHeaders(); + + headers.set("a", "b"); + assertEquals("b", headers.value("a")); + + headers.set("a", "c"); + assertEquals("c", headers.value("a")); + + headers.set("a", null); + assertNull(headers.value("a")); + + headers.set("A", ""); + assertEquals("", headers.value("a")); + + headers.set("A", "b"); + assertEquals("b", headers.value("A")); + + headers.set("a", null); + assertNull(headers.value("a")); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/HttpMethodTests.java b/common/azure-common/src/test/java/com/azure/common/http/HttpMethodTests.java new file mode 100644 index 0000000000000..c4ab802e5b33f --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/HttpMethodTests.java @@ -0,0 +1,43 @@ +package com.azure.common.http; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class HttpMethodTests { + @Test + public void GET() + { + assertEquals("GET", HttpMethod.GET.toString()); + } + + @Test + public void PUT() + { + assertEquals("PUT", HttpMethod.PUT.toString()); + } + + @Test + public void POST() + { + assertEquals("POST", HttpMethod.POST.toString()); + } + + @Test + public void PATCH() + { + assertEquals("PATCH", HttpMethod.PATCH.toString()); + } + + @Test + public void DELETE() + { + assertEquals("DELETE", HttpMethod.DELETE.toString()); + } + + @Test + public void HEAD() + { + assertEquals("HEAD", HttpMethod.HEAD.toString()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/HttpPipelineTests.java b/common/azure-common/src/test/java/com/azure/common/http/HttpPipelineTests.java new file mode 100644 index 0000000000000..b1800c35228e2 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/HttpPipelineTests.java @@ -0,0 +1,139 @@ +package com.azure.common.http; + +import com.azure.common.http.policy.PortPolicy; +import com.azure.common.http.policy.ProtocolPolicy; +import com.azure.common.http.policy.RequestIdPolicy; +import com.azure.common.http.policy.RetryPolicy; +import com.azure.common.http.policy.UserAgentPolicy; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.function.Supplier; + +import static org.junit.Assert.*; + +public class HttpPipelineTests { + @Test + public void constructorWithNoArguments() { + HttpPipeline pipeline = new HttpPipeline(); + assertEquals(0, pipeline.pipelinePolicies().length); + assertNotNull(pipeline.httpClient()); + assertTrue(pipeline.httpClient() instanceof ReactorNettyClient); + } + + @Test + public void withRequestPolicy() { + HttpPipeline pipeline = new HttpPipeline(new PortPolicy(80, true), + new ProtocolPolicy("ftp", true), + new RetryPolicy()); + + assertEquals(3, pipeline.pipelinePolicies().length); + assertEquals(PortPolicy.class, pipeline.pipelinePolicies()[0].getClass()); + assertEquals(ProtocolPolicy.class, pipeline.pipelinePolicies()[1].getClass()); + assertEquals(RetryPolicy.class, pipeline.pipelinePolicies()[2].getClass()); + assertNotNull(pipeline.httpClient()); + assertTrue(pipeline.httpClient() instanceof ReactorNettyClient); + } + + @Test + public void withRequestOptions() throws MalformedURLException { + HttpPipeline pipeline = new HttpPipeline(new PortPolicy(80, true), + new ProtocolPolicy("ftp", true), + new RetryPolicy()); + + HttpPipelineCallContext context = pipeline.newContext(new HttpRequest(HttpMethod.GET, new URL("http://foo.com"))); + assertNotNull(context); + assertNotNull(pipeline.httpClient()); + assertTrue(pipeline.httpClient() instanceof ReactorNettyClient); + } + + @Test + public void withNoRequestPolicies() throws MalformedURLException { + final HttpMethod expectedHttpMethod = HttpMethod.GET; + final URL expectedUrl = new URL("http://my.site.com"); + final HttpPipeline httpPipeline = new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + assertEquals(0, request.headers().size()); + assertEquals(expectedHttpMethod, request.httpMethod()); + assertEquals(expectedUrl, request.url()); + return Mono.just(new MockHttpResponse(request, 200)); + } + }); + + final HttpResponse response = httpPipeline.send(new HttpRequest(expectedHttpMethod, expectedUrl)).block(); + assertNotNull(response); + assertEquals(200, response.statusCode()); + } + + @Test + public void withUserAgentRequestPolicy() throws MalformedURLException { + final HttpMethod expectedHttpMethod = HttpMethod.GET; + final URL expectedUrl = new URL("http://my.site.com/1"); + final String expectedUserAgent = "my-user-agent"; + final HttpClient httpClient = new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + assertEquals(1, request.headers().size()); + assertEquals(expectedUserAgent, request.headers().value("User-Agent")); + assertEquals(expectedHttpMethod, request.httpMethod()); + assertEquals(expectedUrl, request.url()); + return Mono.just(new MockHttpResponse(request, 200)); + } + }; + + final HttpPipeline httpPipeline = new HttpPipeline(httpClient, + new UserAgentPolicy(expectedUserAgent)); + + final HttpResponse response = httpPipeline.send(new HttpRequest(expectedHttpMethod, expectedUrl)).block(); + assertNotNull(response); + assertEquals(200, response.statusCode()); + } + + @Test + public void withRequestIdRequestPolicy() throws MalformedURLException { + final HttpMethod expectedHttpMethod = HttpMethod.GET; + final URL expectedUrl = new URL("http://my.site.com/1"); + final HttpPipeline httpPipeline = new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + assertEquals(1, request.headers().size()); + final String requestId = request.headers().value("x-ms-client-request-id"); + assertNotNull(requestId); + assertFalse(requestId.isEmpty()); + + assertEquals(expectedHttpMethod, request.httpMethod()); + assertEquals(expectedUrl, request.url()); + return Mono.just(new MockHttpResponse(request, 200)); + } + }, + new RequestIdPolicy()); + + final HttpResponse response = httpPipeline.send(new HttpRequest(expectedHttpMethod, expectedUrl)).block(); + assertNotNull(response); + assertEquals(200, response.statusCode()); + } + + private static abstract class MockHttpClient implements HttpClient { + + @Override + public abstract Mono send(HttpRequest request); + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/HttpRequestTests.java b/common/azure-common/src/test/java/com/azure/common/http/HttpRequestTests.java new file mode 100644 index 0000000000000..ed3fe4c031df8 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/HttpRequestTests.java @@ -0,0 +1,52 @@ +package com.azure.common.http; + +import io.netty.buffer.Unpooled; +import org.junit.Test; +import reactor.core.publisher.Flux; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.Assert.*; + +public class HttpRequestTests { + @Test + public void constructor() throws MalformedURLException { + final HttpRequest request = new HttpRequest(HttpMethod.POST, new URL("http://request.url")); + assertEquals(HttpMethod.POST, request.httpMethod()); + assertEquals(new URL("http://request.url"), request.url()); + } + + @Test + public void testClone() throws IOException { + final HttpHeaders headers = new HttpHeaders(); + headers.set("my-header", "my-value"); + headers.set("other-header", "other-value"); + + final HttpRequest request = new HttpRequest( + HttpMethod.PUT, + new URL("http://request.url"), + headers, + Flux.just(Unpooled.buffer(0, 0))); + + final HttpRequest bufferedRequest = request.buffer(); + + assertNotSame(request, bufferedRequest); + + assertEquals(request.httpMethod(), bufferedRequest.httpMethod()); + assertEquals(request.url(), bufferedRequest.url()); + + assertNotSame(request.headers(), bufferedRequest.headers()); + assertEquals(request.headers().toMap().size(), bufferedRequest.headers().toMap().size()); + for (HttpHeader clonedHeader : bufferedRequest.headers()) { + for (HttpHeader originalHeader : request.headers()) { + assertNotSame(clonedHeader, originalHeader); + } + + assertEquals(clonedHeader.value(), request.headers().value(clonedHeader.name())); + } + + assertSame(request.body(), bufferedRequest.body()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/MockHttpClient.java b/common/azure-common/src/test/java/com/azure/common/http/MockHttpClient.java new file mode 100644 index 0000000000000..3341ee7079201 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/MockHttpClient.java @@ -0,0 +1,224 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import com.azure.common.implementation.Base64Url; +import com.azure.common.implementation.DateTimeRfc1123; +import com.azure.common.entities.HttpBinJSON; +import com.azure.common.implementation.util.FluxUtil; +import reactor.core.publisher.Mono; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * This HttpClient attempts to mimic the behavior of http://httpbin.org without ever making a network call. + */ +public class MockHttpClient implements HttpClient { + private static final HttpHeaders responseHeaders = new HttpHeaders() + .set("Date", "Fri, 13 Oct 2017 20:33:09 GMT") + .set("Via", "1.1 vegur") + .set("Connection", "keep-alive") + .set("X-Processed-Time", "1.0") + .set("Access-Control-Allow-Credentials", "true") + .set("Content-Type", "application/json"); + + @Override + public Mono send(HttpRequest request) { + HttpResponse response = null; + + try { + final URL requestUrl = request.url(); + final String requestHost = requestUrl.getHost(); + if ("httpbin.org".equalsIgnoreCase(requestHost)) { + final String requestPath = requestUrl.getPath(); + final String requestPathLower = requestPath.toLowerCase(); + if (requestPathLower.equals("/anything") || requestPathLower.startsWith("/anything/")) { + if ("HEAD".equals(request.httpMethod())) { + response = new MockHttpResponse(request, 200, ""); + } else { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString() + // This is just to mimic the behavior we've seen with httpbin.org. + .replace("%20", " "); + json.headers = toMap(request.headers()); + response = new MockHttpResponse(request, 200, json); + } + } + else if (requestPathLower.startsWith("/bytes/")) { + final String byteCountString = requestPath.substring("/bytes/".length()); + final int byteCount = Integer.parseInt(byteCountString); + HttpHeaders newHeaders = new HttpHeaders(responseHeaders) + .set("Content-Type", "application/octet-stream") + .set("Content-Length", Integer.toString(byteCount)); + response = new MockHttpResponse(request, 200, newHeaders, byteCount == 0 ? null : new byte[byteCount]); + } + else if (requestPathLower.startsWith("/base64urlbytes/")) { + final String byteCountString = requestPath.substring("/base64urlbytes/".length()); + final int byteCount = Integer.parseInt(byteCountString); + final byte[] bytes = new byte[byteCount]; + for (int i = 0; i < byteCount; ++i) { + bytes[i] = (byte)i; + } + final Base64Url base64EncodedBytes = bytes.length == 0 ? null : Base64Url.encode(bytes); + response = new MockHttpResponse(request, 200, responseHeaders, base64EncodedBytes); + } + else if (requestPathLower.equals("/base64urllistofbytes")) { + final List base64EncodedBytesList = new ArrayList<>(); + for (int i = 0; i < 3; ++i) { + final int byteCount = (i + 1) * 10; + final byte[] bytes = new byte[byteCount]; + for (int j = 0; j < byteCount; ++j) { + bytes[j] = (byte)j; + } + final Base64Url base64UrlEncodedBytes = Base64Url.encode(bytes); + base64EncodedBytesList.add(base64UrlEncodedBytes.toString()); + } + response = new MockHttpResponse(request, 200, responseHeaders, base64EncodedBytesList); + } + else if (requestPathLower.equals("/base64urllistoflistofbytes")) { + final List> result = new ArrayList<>(); + for (int i = 0; i < 2; ++i) { + final List innerList = new ArrayList<>(); + for (int j = 0; j < (i + 1) * 2; ++j) { + final int byteCount = (j + 1) * 5; + final byte[] bytes = new byte[byteCount]; + for (int k = 0; k < byteCount; ++k) { + bytes[k] = (byte)k; + } + + final Base64Url base64UrlEncodedBytes = Base64Url.encode(bytes); + innerList.add(base64UrlEncodedBytes.toString()); + } + result.add(innerList); + } + response = new MockHttpResponse(request, 200, responseHeaders, result); + } + else if (requestPathLower.equals("/base64urlmapofbytes")) { + final Map result = new HashMap<>(); + for (int i = 0; i < 2; ++i) { + final String key = Integer.toString(i); + + final int byteCount = (i + 1) * 10; + final byte[] bytes = new byte[byteCount]; + for (int j = 0; j < byteCount; ++j) { + bytes[j] = (byte)j; + } + + final Base64Url base64UrlEncodedBytes = Base64Url.encode(bytes); + result.put(key, base64UrlEncodedBytes.toString()); + } + response = new MockHttpResponse(request, 200, responseHeaders, result); + } + else if (requestPathLower.equals("/datetimerfc1123")) { + final DateTimeRfc1123 now = new DateTimeRfc1123(OffsetDateTime.ofInstant(Instant.ofEpochSecond(0), ZoneOffset.UTC)); + final String result = now.toString(); + response = new MockHttpResponse(request, 200, responseHeaders, result); + } + else if (requestPathLower.equals("/unixtime")) { + response = new MockHttpResponse(request, 200, responseHeaders, 0); + } + else if (requestPathLower.equals("/delete")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = createHttpBinResponseDataForRequest(request); + response = new MockHttpResponse(request, 200, json); + } + else if (requestPathLower.equals("/get")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.headers = toMap(request.headers()); + response = new MockHttpResponse(request, 200, json); + } + else if (requestPathLower.equals("/patch")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = createHttpBinResponseDataForRequest(request); + response = new MockHttpResponse(request, 200, json); + } + else if (requestPathLower.equals("/post")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = createHttpBinResponseDataForRequest(request); + json.headers = toMap(request.headers()); + response = new MockHttpResponse(request, 200, json); + } + else if (requestPathLower.equals("/put")) { + final HttpBinJSON json = new HttpBinJSON(); + json.url = request.url().toString(); + json.data = createHttpBinResponseDataForRequest(request); + json.headers = toMap(request.headers()); + response = new MockHttpResponse(request, 200, responseHeaders, json); + } + else if (requestPathLower.startsWith("/status/")) { + final String statusCodeString = requestPathLower.substring("/status/".length()); + final int statusCode = Integer.valueOf(statusCodeString); + response = new MockHttpResponse(request, statusCode); + } + } + } + catch (Exception ex) { + return Mono.error(ex); + } + + if (response == null) { + response = new MockHttpResponse(request, 500); + } + + return Mono.just(response); + } + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + + private static String createHttpBinResponseDataForRequest(HttpRequest request) { + String body = bodyToString(request); + if (body == null) { + return ""; + } else { + return body; + } + } + + private static String bodyToString(HttpRequest request) { + String body = ""; + if (request.body() != null) { + Mono asyncString = FluxUtil.collectBytesInByteBufStream(request.body(), true) + .map(bytes -> new String(bytes, StandardCharsets.UTF_8)); + body = asyncString.block(); + } + return body; + } + + private static Map toMap(HttpHeaders headers) { + final Map result = new HashMap<>(); + for (final HttpHeader header : headers) { + result.put(header.name(), header.value()); + } + return result; + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/MockHttpResponse.java b/common/azure-common/src/test/java/com/azure/common/http/MockHttpResponse.java new file mode 100644 index 0000000000000..25442b76fdfc0 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/MockHttpResponse.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http; + +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +public class MockHttpResponse extends HttpResponse { + private final static SerializerAdapter serializer = new JacksonAdapter(); + + private final int statusCode; + + private final HttpHeaders headers; + + private final byte[] bodyBytes; + + public MockHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers, byte[] bodyBytes) { + this.statusCode = statusCode; + this.headers = headers; + this.bodyBytes = bodyBytes; + this.withRequest(request); + } + + public MockHttpResponse(HttpRequest request, int statusCode, byte[] bodyBytes) { + this(request, statusCode, new HttpHeaders(), bodyBytes); + } + + public MockHttpResponse(HttpRequest request, int statusCode) { + this(request, statusCode, new byte[0]); + } + + public MockHttpResponse(HttpRequest request, int statusCode, String string) { + this(request, statusCode, new HttpHeaders(), string == null ? new byte[0] : string.getBytes()); + } + + public MockHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers) { + this(request, statusCode, headers, new byte[0]); + } + + public MockHttpResponse(HttpRequest request, int statusCode, HttpHeaders headers, Object serializable) { + this(request, statusCode, headers, serialize(serializable)); + } + + public MockHttpResponse(HttpRequest request, int statusCode, Object serializable) { + this(request, statusCode, new HttpHeaders(), serialize(serializable)); + } + + private static byte[] serialize(Object serializable) { + byte[] result = null; + try { + final String serializedString = serializer.serialize(serializable, SerializerEncoding.JSON); + result = serializedString == null ? null : serializedString.getBytes(); + } catch (IOException e) { + e.printStackTrace(); + } + return result; + } + + @Override + public int statusCode() { + return statusCode; + } + + @Override + public String headerValue(String name) { + return headers.value(name); + } + + @Override + public HttpHeaders headers() { + return new HttpHeaders(headers); + } + + @Override + public Mono bodyAsByteArray() { + if (bodyBytes == null) { + return Mono.empty(); + } else { + return Mono.just(bodyBytes); + } + } + + @Override + public Flux body() { + if (bodyBytes == null) { + return Flux.empty(); + } else { + return Flux.just(Unpooled.wrappedBuffer(bodyBytes)); + } + } + + @Override + public Mono bodyAsString() { + if (bodyBytes == null) { + return Mono.empty(); + } else { + return Mono.just(new String(bodyBytes, StandardCharsets.UTF_8)); + } + } + + @Override + public Mono bodyAsString(Charset charset) { + if (bodyBytes == null) { + return Mono.empty(); + } else { + return Mono.just(new String(bodyBytes, charset)); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/ReactorNettyClientTests.java b/common/azure-common/src/test/java/com/azure/common/http/ReactorNettyClientTests.java new file mode 100644 index 0000000000000..796d8010b42e9 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/ReactorNettyClientTests.java @@ -0,0 +1,340 @@ +package com.azure.common.http; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCountUtil; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +import io.reactivex.Completable; +import io.reactivex.schedulers.Schedulers; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.test.StepVerifierOptions; + +public class ReactorNettyClientTests { + + private static final String SHORT_BODY = "hi there"; + private static final String LONG_BODY = createLongBody(); + + private static WireMockServer server; + + @BeforeClass + public static void beforeClass() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort().disableRequestJournal()); + server.stubFor( + WireMock.get("/short").willReturn(WireMock.aResponse().withBody(SHORT_BODY))); + server.stubFor(WireMock.get("/long").willReturn(WireMock.aResponse().withBody(LONG_BODY))); + server.stubFor(WireMock.get("/error") + .willReturn(WireMock.aResponse().withBody("error").withStatus(500))); + server.stubFor( + WireMock.post("/shortPost").willReturn(WireMock.aResponse().withBody(SHORT_BODY))); + server.start(); + // ResourceLeakDetector.setLevel(Level.PARANOID); + } + + @AfterClass + public static void afterClass() { + if (server != null) { + server.shutdown(); + } + } + + @Test + public void testFlowableResponseShortBodyAsByteArrayAsync() { + checkBodyReceived(SHORT_BODY, "/short"); + } + + @Test + public void testFlowableResponseLongBodyAsByteArrayAsync() { + checkBodyReceived(LONG_BODY, "/long"); + } + + + @Test + public void testMultipleSubscriptionsEmitsError() { + HttpResponse response = getResponse("/short"); + // Subscription:1 + response.bodyAsByteArray().block(); + // Subscription:2 + StepVerifier.create(response.bodyAsByteArray()) + .expectNextCount(0) // TODO: Check with smaldini, what is the verifier operator equivalent to .awaitDone(20, TimeUnit.SECONDS) + .verifyError(IllegalStateException.class); + + } + + @Test + public void testDispose() throws InterruptedException { + HttpResponse response = getResponse("/long"); + response.body().subscribe().dispose(); + // Wait for scheduled connection disposal action to execute on netty event-loop + Thread.sleep(5000); + Assert.assertTrue(response.internConnection().isDisposed()); + } + + + + @Test + public void testCancel() { + HttpResponse response = getResponse("/long"); + // + StepVerifierOptions stepVerifierOptions = StepVerifierOptions.create(); + stepVerifierOptions.initialRequest(0); + // + StepVerifier.create(response.body(), stepVerifierOptions) + .expectNextCount(0) + .thenRequest(1) + .expectNextCount(1) + .thenCancel() + .verify(); + Assert.assertTrue(response.internConnection().isDisposed()); + } + + @Test + public void testFlowableWhenServerReturnsBodyAndNoErrorsWhenHttp500Returned() { + HttpResponse response = getResponse("/error"); + StepVerifier.create(response.bodyAsString()) + .expectNext("error") // TODO: .awaitDone(20, TimeUnit.SECONDS) [See previous todo] + .verifyComplete(); + assertEquals(500, response.statusCode()); + } + + @Test + @Ignore("Not working accurately at present") + public void testFlowableBackpressure() { + HttpResponse response = getResponse("/long"); + // + StepVerifierOptions stepVerifierOptions = StepVerifierOptions.create(); + stepVerifierOptions.initialRequest(0); + // + StepVerifier.create(response.body(), stepVerifierOptions) + .expectNextCount(0) + .thenRequest(1) + .expectNextCount(1) + .thenRequest(3) + .expectNextCount(3) + .thenRequest(Long.MAX_VALUE)// TODO: Check with smaldini, what is the verifier operator to ignore all next emissions + .expectNextCount(1507) + .verifyComplete(); + } + + @Test + public void testRequestBodyIsErrorShouldPropagateToResponse() { + HttpClient client = HttpClient.createDefault(); + HttpRequest request = new HttpRequest(HttpMethod.POST, url(server, "/shortPost")) + .withHeader("Content-Length", "123") + .withBody(Flux.error(new RuntimeException("boo"))); + + StepVerifier.create(client.send(request)) + .expectErrorMessage("boo") + .verify(); + } + + @Test + public void testRequestBodyEndsInErrorShouldPropagateToResponse() { + HttpClient client = HttpClient.createDefault(); + String contentChunk = "abcdefgh"; + int repetitions = 1000; + HttpRequest request = new HttpRequest(HttpMethod.POST, url(server, "/shortPost")) + .withHeader("Content-Length", String.valueOf(contentChunk.length() * repetitions)) + .withBody(Flux.just(contentChunk) + .repeat(repetitions) + .map(s -> Unpooled.wrappedBuffer(s.getBytes(StandardCharsets.UTF_8))) + .concatWith(Flux.error(new RuntimeException("boo")))); + StepVerifier.create(client.send(request)) + // .awaitDone(10, TimeUnit.SECONDS) + .expectErrorMessage("boo") + .verify(); + } + + @Test(timeout = 5000) + public void testServerShutsDownSocketShouldPushErrorToContentFlowable() + throws IOException, InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference sock = new AtomicReference<>(); + ServerSocket ss = new ServerSocket(0); + try { + Completable.fromCallable(() -> { + latch.countDown(); + Socket socket = ss.accept(); + sock.set(socket); + // give the client time to get request across + Thread.sleep(500); + // respond but don't send the complete response + byte[] bytes = new byte[1024]; + int n = socket.getInputStream().read(bytes); + System.out.println(new String(bytes, 0, n, StandardCharsets.UTF_8)); + String response = "HTTP/1.1 200 OK\r\n" // + + "Content-Type: text/plain\r\n" // + + "Content-Length: 10\r\n" // + + "\r\n" // + + "zi"; + OutputStream out = socket.getOutputStream(); + out.write(response.getBytes()); + out.flush(); + // kill the socket with HTTP response body incomplete + socket.close(); + return 1; + }) + .subscribeOn(Schedulers.io()) + .subscribe(); + // + latch.await(); + HttpClient client = HttpClient.createDefault(); + HttpRequest request = new HttpRequest(HttpMethod.GET, + new URL("http://localhost:" + ss.getLocalPort() + "/get")); + HttpResponse response = client.send(request).block(); + assertEquals(200, response.statusCode()); + System.out.println("reading body"); + // + StepVerifier.create(response.bodyAsByteArray()) + // .awaitDone(20, TimeUnit.SECONDS) + .verifyError(IOException.class); + } finally { + ss.close(); + } + } + + @Test + public void testConcurrentRequests() throws NoSuchAlgorithmException { + long t = System.currentTimeMillis(); + int numRequests = 100; // 100 = 1GB of data read + long timeoutSeconds = 60; + HttpClient client = HttpClient.createDefault(); + byte[] expectedDigest = digest(LONG_BODY); + + Mono numBytesMono = Flux.range(1, numRequests) + .parallel(10) + .runOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .flatMap(n -> Mono.fromCallable(() -> getResponse(client, "/long")).flatMapMany(response -> { + MessageDigest md = md5Digest(); + return response.body() + .doOnNext(bb -> { + bb.retain(); + if (bb.hasArray()) { + // Heap buffer + md.update(bb.array()); + } else { + // Direct buffer + int len = bb.readableBytes(); + byte[] array = new byte[len]; + bb.getBytes(bb.readerIndex(), array); + md.update(array); + } + }) + .map(bb -> new NumberedByteBuf(n, bb)) +// .doOnComplete(() -> System.out.println("completed " + n)) + .doOnComplete(() -> Assert.assertArrayEquals("wrong digest!", expectedDigest, + md.digest())); + })) + .sequential() + // enable the doOnNext call to see request numbers and thread names + // .doOnNext(g -> System.out.println(g.n + " " + + // Thread.currentThread().getName())) + .map(nbb -> { + long bytesCount = (long) nbb.bb.readableBytes(); + ReferenceCountUtil.release(nbb.bb); + return bytesCount; + }) + .reduce((x, y) -> x + y) + .subscribeOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .publishOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)); + + StepVerifier.create(numBytesMono) +// .awaitDone(timeoutSeconds, TimeUnit.SECONDS) + .expectNext((long)(numRequests * LONG_BODY.getBytes(StandardCharsets.UTF_8).length)) + .verifyComplete(); +// +// long numBytes = numBytesMono.block(); +// t = System.currentTimeMillis() - t; +// System.out.println("totalBytesRead=" + numBytes / 1024 / 1024 + "MB in " + t / 1000.0 + "s"); +// assertEquals(numRequests * LONG_BODY.getBytes(StandardCharsets.UTF_8).length, numBytes); + } + + private static MessageDigest md5Digest() { + try { + return MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private static byte[] digest(String s) throws NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("MD5"); + md.update(s.getBytes(StandardCharsets.UTF_8)); + byte[] expectedDigest = md.digest(); + return expectedDigest; + } + + private static final class NumberedByteBuf { + final long n; + final ByteBuf bb; + + NumberedByteBuf(long n, ByteBuf bb) { + this.n = n; + this.bb = bb; + } + } + + private static HttpResponse getResponse(String path) { + HttpClient client = HttpClient.createDefault(); + return getResponse(client, path); + } + + private static HttpResponse getResponse(HttpClient client, String path) { + HttpRequest request = new HttpRequest(HttpMethod.GET, url(server, path)); + return client.send(request).block(); + } + + private static URL url(WireMockServer server, String path) { + try { + return new URL("http://localhost:" + server.port() + path); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + private static String createLongBody() { + StringBuilder s = new StringBuilder(10000000); + for (int i = 0; i < 1000000; i++) { + s.append("abcdefghijk"); + } + return s.toString(); + } + + private void checkBodyReceived(String expectedBody, String path) { + HttpClient client = HttpClient.createDefault(); + HttpResponse response = doRequest(client, path); + String s = new String(response.bodyAsByteArray().block(), + StandardCharsets.UTF_8); + assertEquals(expectedBody, s); + } + + private HttpResponse doRequest(HttpClient client, String path) { + HttpRequest request = new HttpRequest(HttpMethod.GET, url(server, path)); + HttpResponse response = client.send(request).block(); + return response; + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/policy/HostPolicyTests.java b/common/azure-common/src/test/java/com/azure/common/http/policy/HostPolicyTests.java new file mode 100644 index 0000000000000..46876bba7fad3 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/policy/HostPolicyTests.java @@ -0,0 +1,69 @@ +package com.azure.common.http.policy; + +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.ProxyOptions; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; + +public class HostPolicyTests { + @Test + public void withNoPort() throws MalformedURLException { + final HttpPipeline pipeline = createPipeline("localhost", "ftp://localhost"); + pipeline.send(createHttpRequest("ftp://www.example.com")).block(); + } + + @Test + public void withPort() throws MalformedURLException { + final HttpPipeline pipeline = createPipeline("localhost", "ftp://localhost:1234"); + pipeline.send(createHttpRequest("ftp://www.example.com:1234")); + } + + private static HttpPipeline createPipeline(String host, String expectedUrl) { + return new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + return Mono.empty(); // NOP + } + }, + new HostPolicy(host), + (context, next) -> { + assertEquals(expectedUrl, context.httpRequest().url().toString()); + return next.process(); + }); + } + + private static HttpRequest createHttpRequest(String url) throws MalformedURLException { + return new HttpRequest(HttpMethod.GET, new URL(url)); + } + + private static abstract class MockHttpClient implements HttpClient { + + @Override + public abstract Mono send(HttpRequest request); + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/policy/ProtocolPolicyTests.java b/common/azure-common/src/test/java/com/azure/common/http/policy/ProtocolPolicyTests.java new file mode 100644 index 0000000000000..2957fe2777e93 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/policy/ProtocolPolicyTests.java @@ -0,0 +1,83 @@ +package com.azure.common.http.policy; + +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.ProxyOptions; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; + +public class ProtocolPolicyTests { + + @Test + public void withOverwrite() throws MalformedURLException { + final HttpPipeline pipeline = createPipeline("ftp", "ftp://www.bing.com"); + pipeline.send(createHttpRequest("http://www.bing.com")); + } + + @Test + public void withNoOverwrite() throws MalformedURLException { + final HttpPipeline pipeline = createPipeline("ftp", false, "https://www.bing.com"); + pipeline.send(createHttpRequest("https://www.bing.com")); + } + private static HttpPipeline createPipeline(String protocol, String expectedUrl) { + return new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + return Mono.empty(); // NOP + } + }, + new ProtocolPolicy(protocol, true), + (context, next) -> { + assertEquals(expectedUrl, context.httpRequest().url().toString()); + return next.process(); + }); + } + + private static HttpPipeline createPipeline(String protocol, boolean overwrite, String expectedUrl) { + return new HttpPipeline(new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + return Mono.empty(); // NOP + } + }, + new ProtocolPolicy(protocol, overwrite), + (context, next) -> { + assertEquals(expectedUrl, context.httpRequest().url().toString()); + return next.process(); + }); + } + + private static HttpRequest createHttpRequest(String url) throws MalformedURLException { + return new HttpRequest(HttpMethod.GET, new URL(url)); + } + + private static abstract class MockHttpClient implements HttpClient { + + @Override + public abstract Mono send(HttpRequest request); + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/policy/ProxyAuthenticationPolicyTests.java b/common/azure-common/src/test/java/com/azure/common/http/policy/ProxyAuthenticationPolicyTests.java new file mode 100644 index 0000000000000..fa829da39ef54 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/policy/ProxyAuthenticationPolicyTests.java @@ -0,0 +1,38 @@ +package com.azure.common.http.policy; + +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.MockHttpClient; +import org.junit.Test; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class ProxyAuthenticationPolicyTests { + @Test + public void test() throws MalformedURLException { + final AtomicBoolean auditorVisited = new AtomicBoolean(false); + final String username = "testuser"; + final String password = "testpass"; + // + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient(), + new ProxyAuthenticationPolicy(username, password), + (context, next) -> { + assertEquals("Basic dGVzdHVzZXI6dGVzdHBhc3M=", context.httpRequest().headers().value("Proxy-Authentication")); + auditorVisited.set(true); + return next.process(); + }); + + pipeline.send(new HttpRequest(HttpMethod.GET, new URL("http://localhost"))) + .block(); + + if (!auditorVisited.get()) { + fail(); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/policy/RequestIdPolicyTests.java b/common/azure-common/src/test/java/com/azure/common/http/policy/RequestIdPolicyTests.java new file mode 100644 index 0000000000000..4f3fef6151cbe --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/policy/RequestIdPolicyTests.java @@ -0,0 +1,115 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.MockHttpClient; +import io.netty.buffer.ByteBuf; +import org.junit.Assert; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.net.URL; +import java.nio.charset.Charset; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +public class RequestIdPolicyTests { + private final HttpResponse mockResponse = new HttpResponse() { + @Override + public int statusCode() { + return 500; + } + + @Override + public String headerValue(String name) { + return null; + } + + @Override + public HttpHeaders headers() { + return new HttpHeaders(); + } + + @Override + public Mono bodyAsByteArray() { + return Mono.empty(); + } + + @Override + public Flux body() { + return Flux.empty(); + } + + @Override + public Mono bodyAsString() { + return Mono.empty(); + } + + @Override + public Mono bodyAsString(Charset charset) { + return Mono.empty(); + } + }; + + private static final String REQUEST_ID_HEADER = "x-ms-client-request-id"; + + @Test + public void newRequestIdForEachCall() throws Exception { + HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + String firstRequestId = null; + @Override + public Mono send(HttpRequest request) { + if (firstRequestId != null) { + String newRequestId = request.headers().value(REQUEST_ID_HEADER); + Assert.assertNotNull(newRequestId); + Assert.assertNotEquals(newRequestId, firstRequestId); + } + + firstRequestId = request.headers().value(REQUEST_ID_HEADER); + if (firstRequestId == null) { + Assert.fail(); + } + return Mono.just(mockResponse); + } + }, + new RequestIdPolicy()); + + pipeline.send(new HttpRequest(HttpMethod.GET, new URL("http://localhost/"))).block(); + pipeline.send(new HttpRequest(HttpMethod.GET, new URL("http://localhost/"))).block(); + } + + @Test + public void sameRequestIdForRetry() throws Exception { + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + String firstRequestId = null; + + @Override + public Mono send(HttpRequest request) { + if (firstRequestId != null) { + String newRequestId = request.headers().value(REQUEST_ID_HEADER); + Assert.assertNotNull(newRequestId); + Assert.assertEquals(newRequestId, firstRequestId); + } + firstRequestId = request.headers().value(REQUEST_ID_HEADER); + if (firstRequestId == null) { + Assert.fail(); + } + return Mono.just(mockResponse); + } + }, + new RequestIdPolicy(), + new RetryPolicy(1, Duration.of(0, ChronoUnit.SECONDS))); + + pipeline.send(new HttpRequest(HttpMethod.GET, new URL("http://localhost/"))).block(); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/http/policy/RetryPolicyTests.java b/common/azure-common/src/test/java/com/azure/common/http/policy/RetryPolicyTests.java new file mode 100644 index 0000000000000..6f7aa54365904 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/http/policy/RetryPolicyTests.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.http.policy; + +import com.azure.common.http.*; +import org.junit.Assert; +import org.junit.Test; + +import reactor.core.publisher.Mono; + +import java.net.URL; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +public class RetryPolicyTests { + @Test + public void exponentialRetryEndOn501() throws Exception { + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + // Send 408, 500, 502, all retried, with a 501 ending + private final int[] codes = new int[]{408, 500, 502, 501}; + private int count = 0; + + @Override + public Mono send(HttpRequest request) { + return Mono.just(new MockHttpResponse(request, codes[count++])); + } + }, + new RetryPolicy(3, Duration.of(0, ChronoUnit.MILLIS))); + + HttpResponse response = pipeline.send(new HttpRequest(HttpMethod.GET, + new URL("http://localhost/"))).block(); + + Assert.assertEquals(501, response.statusCode()); + } + + @Test + public void exponentialRetryMax() throws Exception { + final int maxRetries = 5; + final HttpPipeline pipeline = new HttpPipeline(new MockHttpClient() { + int count = -1; + + @Override + public Mono send(HttpRequest request) { + Assert.assertTrue(count++ < maxRetries); + return Mono.just(new MockHttpResponse(request, 500)); + } + }, + new RetryPolicy(maxRetries, Duration.of(0, ChronoUnit.MILLIS))); + + + HttpResponse response = pipeline.send(new HttpRequest(HttpMethod.GET, + new URL("http://localhost/"))).block(); + + Assert.assertEquals(500, response.statusCode()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/Base64UrlTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/Base64UrlTests.java new file mode 100644 index 0000000000000..58c8737d24732 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/Base64UrlTests.java @@ -0,0 +1,111 @@ +package com.azure.common.implementation; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class Base64UrlTests { + @Test + public void constructorWithNullBytes() { + final Base64Url base64Url = new Base64Url((byte[])null); + assertNull(base64Url.encodedBytes()); + assertNull(base64Url.decodedBytes()); + assertNull(base64Url.toString()); + } + + @Test + public void constructorWithEmptyBytes() { + final Base64Url base64Url = new Base64Url(new byte[0]); + assertArrayEquals(new byte[0], base64Url.encodedBytes()); + assertArrayEquals(new byte[0], base64Url.decodedBytes()); + assertEquals("", base64Url.toString()); + } + + @Test + public void constructorWithNonEmptyBytes() { + final Base64Url base64Url = new Base64Url(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }); + assertArrayEquals(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }, base64Url.encodedBytes()); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, base64Url.decodedBytes()); + assertEquals("AAECAwQFBgcICQ", base64Url.toString()); + } + + @Test + public void constructorWithNullString() { + final Base64Url base64Url = new Base64Url((String)null); + assertNull(base64Url.encodedBytes()); + assertNull(base64Url.decodedBytes()); + assertNull(base64Url.toString()); + } + + @Test + public void constructorWithEmptyString() { + final Base64Url base64Url = new Base64Url(""); + assertArrayEquals(new byte[0], base64Url.encodedBytes()); + assertArrayEquals(new byte[0], base64Url.decodedBytes()); + assertEquals("", base64Url.toString()); + } + + @Test + public void constructorWithEmptyDoubleQuotedString() { + final Base64Url base64Url = new Base64Url("\"\""); + assertArrayEquals(new byte[0], base64Url.encodedBytes()); + assertArrayEquals(new byte[0], base64Url.decodedBytes()); + assertEquals("", base64Url.toString()); + } + + @Test + public void constructorWithEmptySingleQuotedString() { + final Base64Url base64Url = new Base64Url("\'\'"); + assertArrayEquals(new byte[0], base64Url.encodedBytes()); + assertArrayEquals(new byte[0], base64Url.decodedBytes()); + assertEquals("", base64Url.toString()); + } + + @Test + public void constructorWithNonEmptyString() { + final Base64Url base64Url = new Base64Url("AAECAwQFBgcICQ"); + assertArrayEquals(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }, base64Url.encodedBytes()); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, base64Url.decodedBytes()); + assertEquals("AAECAwQFBgcICQ", base64Url.toString()); + } + + @Test + public void constructorWithNonEmptyDoubleQuotedString() { + final Base64Url base64Url = new Base64Url("\"AAECAwQFBgcICQ\""); + assertArrayEquals(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }, base64Url.encodedBytes()); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, base64Url.decodedBytes()); + assertEquals("AAECAwQFBgcICQ", base64Url.toString()); + } + + @Test + public void constructorWithNonEmptySingleQuotedString() { + final Base64Url base64Url = new Base64Url("\'AAECAwQFBgcICQ\'"); + assertArrayEquals(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }, base64Url.encodedBytes()); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, base64Url.decodedBytes()); + assertEquals("AAECAwQFBgcICQ", base64Url.toString()); + } + + @Test + public void encodeWithNullBytes() { + final Base64Url base64Url = Base64Url.encode(null); + assertNull(base64Url.encodedBytes()); + assertNull(base64Url.decodedBytes()); + assertNull(base64Url.toString()); + } + + @Test + public void encodeWithEmptyBytes() { + final Base64Url base64Url = Base64Url.encode(new byte[0]); + assertArrayEquals(new byte[0], base64Url.encodedBytes()); + assertArrayEquals(new byte[0], base64Url.decodedBytes()); + assertEquals("", base64Url.toString()); + } + + @Test + public void encodeWithNonEmptyBytes() { + final Base64Url base64Url = Base64Url.encode(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + assertArrayEquals(new byte[] { 65, 65, 69, 67, 65, 119, 81, 70, 66, 103, 99, 73, 67, 81 }, base64Url.encodedBytes()); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, base64Url.decodedBytes()); + assertEquals("AAECAwQFBgcICQ", base64Url.toString()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/EncodedParameterTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/EncodedParameterTests.java new file mode 100644 index 0000000000000..ef626abf010cd --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/EncodedParameterTests.java @@ -0,0 +1,14 @@ +package com.azure.common.implementation; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class EncodedParameterTests { + @Test + public void constructor() { + final EncodedParameter ep = new EncodedParameter("ABC", "123"); + assertEquals("ABC", ep.name()); + assertEquals("123", ep.encodedValue()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyStressTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyStressTests.java new file mode 100644 index 0000000000000..d358bdcc63000 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyStressTests.java @@ -0,0 +1,537 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.azure.common.MockServer; +import com.azure.common.http.rest.RestException; +import com.azure.common.annotations.BodyParam; +import com.azure.common.annotations.DELETE; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HeaderParam; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.PUT; +import com.azure.common.annotations.PathParam; +import com.azure.common.implementation.http.ContentType; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpPipelineCallContext; +import com.azure.common.http.HttpPipelineNextPolicy; +import com.azure.common.http.policy.HttpLoggingPolicy; +import com.azure.common.http.policy.HttpPipelinePolicy; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.policy.AddDatePolicy; +import com.azure.common.http.policy.AddHeadersPolicy; +import com.azure.common.http.policy.HostPolicy; +import com.azure.common.http.policy.HttpLogDetailLevel; +import com.azure.common.http.rest.RestStreamResponse; +import com.azure.common.http.rest.RestVoidResponse; +import com.azure.common.implementation.util.FlowableUtils; +import com.azure.common.implementation.util.FluxUtil; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ResourceLeakDetector; +import io.reactivex.Completable; +import io.reactivex.CompletableSource; +import io.reactivex.Flowable; +import io.reactivex.functions.Function; + +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.lang.ProcessBuilder.Redirect; +import java.nio.MappedByteBuffer; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.channels.FileChannel; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Random; +import java.util.concurrent.Callable; +import java.util.concurrent.ThreadLocalRandom; + +import static org.junit.Assert.assertArrayEquals; + +public class RestProxyStressTests { + private static IOService service; + private static Process testServer; + // By default will spawn a test server running on the default port. + // If JAVA_SDK_TEST_PORT is specified in the environment, we assume + // the server is already running on that port. + private static int port = 8080; + + @BeforeClass + public static void beforeClass() throws IOException { + Assume.assumeTrue( + "Set the environment variable JAVA_SDK_STRESS_TESTS to \"true\" to run stress tests", + Boolean.parseBoolean(System.getenv("JAVA_SDK_STRESS_TESTS"))); + + ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID); + LoggerFactory.getLogger(RestProxyStressTests.class).info("ResourceLeakDetector level: " + ResourceLeakDetector.getLevel()); + + String tempFolderPath = System.getenv("JAVA_STRESS_TEST_TEMP_PATH"); + if (tempFolderPath == null || tempFolderPath.isEmpty()) { + tempFolderPath = "temp"; + } + + HttpHeaders headers = new HttpHeaders() + .set("x-ms-version", "2017-04-17"); + // Order in which policies applied will be the order in which they added to builder + List polices = new ArrayList(); + polices.add(new AddDatePolicy()); + polices.add(new AddHeadersPolicy(headers)); + polices.add(new ThrottlingRetryPolicy()); + // + String liveStressTests = System.getenv("JAVA_SDK_TEST_SAS"); + if (liveStressTests == null || liveStressTests.isEmpty()) { + launchTestServer(); + polices.add(new HostPolicy("http://localhost:" + port)); + } + // + polices.add(new HttpLoggingPolicy(HttpLogDetailLevel.BASIC, false)); + // + service = RestProxy.create(IOService.class, + new HttpPipeline(polices.toArray(new HttpPipelinePolicy[polices.size()]))); + + TEMP_FOLDER_PATH = Paths.get(tempFolderPath); + create100MFiles(false); + } + + private static void launchTestServer() throws IOException { + String portString = System.getenv("JAVA_SDK_TEST_PORT"); + // TODO: figure out why test server hangs only when spawned as a subprocess + Assume.assumeTrue("JAVA_SDK_TEST_PORT must specify the port of a running local server", portString != null); + if (portString != null) { + port = Integer.parseInt(portString, 10); + LoggerFactory.getLogger(RestProxyStressTests.class).warn("Attempting to connect to already-running test server on port {}", port); + } else { + String javaHome = System.getProperty("java.home"); + String javaExecutable = javaHome + File.separator + "bin" + File.separator + "java"; + String classpath = System.getProperty("java.class.path"); + String className = MockServer.class.getCanonicalName(); + + ProcessBuilder builder = new ProcessBuilder( + javaExecutable, "-cp", classpath, className).redirectErrorStream(true).redirectOutput(Redirect.INHERIT); + testServer = builder.start(); + } + } + + @AfterClass + public static void afterClass() throws Exception { + if (testServer != null) { + testServer.destroy(); + } + } + + private static final class ThrottlingRetryPolicy implements HttpPipelinePolicy { + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + return process(1 + ThreadLocalRandom.current().nextInt(5), context, next); + } + + Mono process(final int waitTimeSeconds, final HttpPipelineCallContext context, final HttpPipelineNextPolicy nextPolicy) { + return nextPolicy.clone().process().flatMap(httpResponse -> { + if (httpResponse.statusCode() != 503 && httpResponse.statusCode() != 500) { + return Mono.just(httpResponse); + } else { + LoggerFactory.getLogger(getClass()).warn("Received " + httpResponse.statusCode() + " for request. Waiting " + waitTimeSeconds + " seconds before retry."); + final int nextWaitTime = 5 + ThreadLocalRandom.current().nextInt(10); + httpResponse.body().subscribe().dispose(); // TODO: Anu re-evaluate this + return Mono.delay(Duration.of(waitTimeSeconds, ChronoUnit.SECONDS)) + .then(process(nextWaitTime, context, nextPolicy)); + } + }).onErrorResume(throwable -> { + if (throwable instanceof IOException) { + LoggerFactory.getLogger(getClass()).warn("I/O exception occurred: " + throwable.getMessage()); + return process(context, nextPolicy).delaySubscription(Duration.of(waitTimeSeconds, ChronoUnit.SECONDS)); + } + LoggerFactory.getLogger(getClass()).warn("Unrecoverable exception occurred: " + throwable.getMessage()); + return Mono.error(throwable); + }); + } + } + + @Host("https://javasdktest.blob.core.windows.net") + interface IOService { + @ExpectedResponses({201}) + @PUT("/javasdktest/upload/100m-{id}.dat?{sas}") + Mono upload100MB(@PathParam("id") String id, @PathParam(value = "sas", encoded = true) String sas, @HeaderParam("x-ms-blob-type") String blobType, @BodyParam(ContentType.APPLICATION_OCTET_STREAM) Flux stream, @HeaderParam("content-length") long contentLength); + + @GET("/javasdktest/upload/100m-{id}.dat?{sas}") + Mono download100M(@PathParam("id") String id, @PathParam(value = "sas", encoded = true) String sas); + + @ExpectedResponses({201}) + @PUT("/testcontainer{id}?restype=container&{sas}") + Mono createContainer(@PathParam("id") String id, @PathParam(value = "sas", encoded = true) String sas); + + @ExpectedResponses({202}) + @DELETE("/testcontainer{id}?restype=container&{sas}") + Mono deleteContainer(@PathParam("id") String id, @PathParam(value = "sas", encoded = true) String sas); + } + + private static Path TEMP_FOLDER_PATH; + private static final int NUM_FILES = 100; + private static final int FILE_SIZE = 1024 * 1024 * 100; + private static final int CHUNK_SIZE = 8192; + private static final int CHUNKS_PER_FILE = FILE_SIZE / CHUNK_SIZE; + + private static void deleteRecursive(Path tempFolderPath) throws IOException { + try { + Files.walkFileTree(tempFolderPath, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (exc != null) { + throw exc; + } + + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } catch (NoSuchFileException ignored) { + } + } + + private static void create100MFiles(boolean recreate) throws IOException { + final Flowable contentGenerator = Flowable.generate(Random::new, (random, emitter) -> { + java.nio.ByteBuffer buf = java.nio.ByteBuffer.allocate(CHUNK_SIZE); + random.nextBytes(buf.array()); + emitter.onNext(buf); + }); + + if (recreate) { + deleteRecursive(TEMP_FOLDER_PATH); + } + + if (Files.exists(TEMP_FOLDER_PATH)) { + LoggerFactory.getLogger(RestProxyStressTests.class).info("Temp files directory already exists: " + TEMP_FOLDER_PATH.toAbsolutePath()); + } else { + LoggerFactory.getLogger(RestProxyStressTests.class).info("Generating temp files in directory: " + TEMP_FOLDER_PATH.toAbsolutePath()); + Files.createDirectory(TEMP_FOLDER_PATH); + Flowable.range(0, NUM_FILES).flatMapCompletable(new Function() { + @Override + public Completable apply(Integer integer) throws Exception { + final int i = integer; + final Path filePath = TEMP_FOLDER_PATH.resolve("100m-" + i + ".dat"); + + Files.deleteIfExists(filePath); + Files.createFile(filePath); + final AsynchronousFileChannel file = AsynchronousFileChannel.open(filePath, StandardOpenOption.READ, StandardOpenOption.WRITE); + final MessageDigest messageDigest = MessageDigest.getInstance("MD5"); + + Flowable fileContent = contentGenerator + .take(CHUNKS_PER_FILE) + .doOnNext(buf -> messageDigest.update(buf.array())); + + return FlowableUtils.writeFile(fileContent, file).andThen(Completable.defer(new Callable() { + @Override + public CompletableSource call() throws Exception { + file.close(); + Files.write(TEMP_FOLDER_PATH.resolve("100m-" + i + "-md5.dat"), messageDigest.digest()); + LoggerFactory.getLogger(getClass()).info("Finished writing file " + i); + return Completable.complete(); + } + })); + } + }).blockingAwait(); + } + } + + @Test + @Ignore("Should only be run manually") + public void prepare100MFiles() throws Exception { + create100MFiles(true); + } + + @Test + public void upload100MParallelTest() { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + + Flux md5s = Flux.range(0, NUM_FILES) + .map(integer -> { + final Path filePath = TEMP_FOLDER_PATH.resolve("100m-" + integer + "-md5.dat"); + try { + return Files.readAllBytes(filePath); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }); + // + Instant uploadStart = Instant.now(); + // + Flux.range(0, NUM_FILES) + .zipWith(md5s, (id, md5) -> { + AsynchronousFileChannel fileStream = null; + try { + fileStream = AsynchronousFileChannel.open(TEMP_FOLDER_PATH.resolve("100m-" + id + ".dat")); + } catch (IOException ioe) { + Exceptions.propagate(ioe); + } + return service.upload100MB(String.valueOf(id), sas, "BlockBlob", FluxUtil.byteBufStreamFromFile(fileStream), FILE_SIZE).map(response -> { + String base64MD5 = response.headers().value("Content-MD5"); + byte[] receivedMD5 = Base64.getDecoder().decode(base64MD5); + Assert.assertArrayEquals(md5, receivedMD5); + return response; + }); + }) + .flatMapDelayError(m -> m, 15, 1) + .blockLast(); + // + long durationMilliseconds = Duration.between(uploadStart, Instant.now()).toMillis(); + LoggerFactory.getLogger(getClass()).info("Upload took " + durationMilliseconds + " milliseconds."); + } + + @Test + public void uploadMemoryMappedTest() { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + + Flux md5s = Flux.range(0, NUM_FILES) + .map(integer -> { + final Path filePath = TEMP_FOLDER_PATH.resolve("100m-" + integer + "-md5.dat"); + try { + return Files.readAllBytes(filePath); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }); + + Instant uploadStart = Instant.now(); + // + Flux.range(0, NUM_FILES) + .zipWith(md5s, (id, md5) -> { + FileChannel fileStream = null; + try { + fileStream = FileChannel.open(TEMP_FOLDER_PATH.resolve("100m-" + id + ".dat"), StandardOpenOption.READ); + } catch (IOException ioe) { + Exceptions.propagate(ioe); + } + // + ByteBuf mappedByteBufFile = null; + Flux stream = null; + try { + MappedByteBuffer mappedByteBufferFile = fileStream.map(FileChannel.MapMode.READ_ONLY, 0, fileStream.size()); + mappedByteBufFile = Unpooled.wrappedBuffer(mappedByteBufferFile); + stream = FluxUtil.split(mappedByteBufFile, CHUNK_SIZE); + } catch (IOException ioe) { + mappedByteBufFile.release(); + Exceptions.propagate(ioe); + } + // + return service.upload100MB(String.valueOf(id), sas, "BlockBlob", stream, FILE_SIZE).map(response -> { + String base64MD5 = response.headers().value("Content-MD5"); + byte[] receivedMD5 = Base64.getDecoder().decode(base64MD5); + Assert.assertArrayEquals(md5, receivedMD5); + return response; + }); + }) + .flatMapDelayError(m -> m, 15, 1) + .blockLast(); + // + long durationMilliseconds = Duration.between(uploadStart, Instant.now()).toMillis(); + LoggerFactory.getLogger(getClass()).info("Upload took " + durationMilliseconds + " milliseconds."); + } + + + /** + * Run after running one of the corresponding upload tests. + */ + @Test + public void download100MParallelTest() { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + + Flux md5s = Flux.range(0, NUM_FILES) + .map(integer -> { + final Path filePath = TEMP_FOLDER_PATH.resolve("100m-" + integer + "-md5.dat"); + try { + return Files.readAllBytes(filePath); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }); + // + Instant downloadStart = Instant.now(); + // + Flux.range(0, NUM_FILES) + .zipWith(md5s, (id, md5) -> { + return service.download100M(String.valueOf(id), sas).flatMap(response -> { + Flux content; + try { + MessageDigest messageDigest = MessageDigest.getInstance("MD5"); + content = response.body() + .doOnNext(buf -> messageDigest.update(buf.slice().nioBuffer())); + + return content.last().doOnSuccess(b -> { + assertArrayEquals(md5, messageDigest.digest()); + LoggerFactory.getLogger(getClass()).info("Finished downloading and MD5 validated for " + id); + + }); + + } catch (NoSuchAlgorithmException nsae) { + throw Exceptions.propagate(nsae); + } + }); + }) + .flatMapDelayError(m -> m, 15, 1) + .blockLast(); + // + long durationMilliseconds = Duration.between(downloadStart, Instant.now()).toMillis(); + LoggerFactory.getLogger(getClass()).info("Download took " + durationMilliseconds + " milliseconds."); + } + + @Test + public void downloadUploadStreamingTest() { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + + Flux md5s = Flux.range(0, NUM_FILES) + .map(integer -> { + final Path filePath = TEMP_FOLDER_PATH.resolve("100m-" + integer + "-md5.dat"); + try { + return Files.readAllBytes(filePath); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }); + // + Instant downloadStart = Instant.now(); + // + Flux.range(0, NUM_FILES) + .zipWith(md5s, (integer, md5) -> { + final int id = integer; + Flux downloadContent = service.download100M(String.valueOf(id), sas) + // Ideally we would intercept this content to load an MD5 to check consistency between download and upload directly, + // but it's sufficient to demonstrate that no corruption occurred between preparation->upload->download->upload. + .flatMapMany(RestStreamResponse::body) + .map(reactorNettybb -> { + // + // This test 'downloadUploadStreamingTest' exercises piping scenario. + // + // A. Receive ByteBufFlux from reactor-netty from NettyInbound.receive() [via service.download100M]. + // B. Directly pass this ByteBufFlux to Outbound.send() [via service.upload100MB] + // + // NettyOutbound.send(NettyInbound.receive()) + // + // A property of ByteBufFlux publisher is - The chunks in the stream gets released automatically once 'onNext' returns. + // + // The Outbound.send method subscribe to ByteBufFlux + // 1. on each onNext call, the received ByteBuf chunk gets 'scheduled' to write through Netty.write() + // 2. onNext returns. + // 3. repeat 1 & 2 until stream completes or errored. + // + // The scheduling & immediate return from onNext [1 & 2] can result in the a chunk of ByteBufFlux to be released + // before the scheduled Netty.write() completes. + // + // This can cause following issues: + // a. Write of content of released chunks, which is bad. + // b. Netty.write() calls release on the ByteBuf after write is done. We have double release problem here. + // + // Solution is to aware of ByteBufFlux auto-release property and retain each chunk before passing to Netty.write(). + // + return reactorNettybb.retain(); + }); + // + return service.upload100MB("copy-" + integer, sas, "BlockBlob", downloadContent, FILE_SIZE) + .flatMap(uploadResponse -> { + String base64MD5 = uploadResponse.headers().value("Content-MD5"); + byte[] uploadMD5 = Base64.getDecoder().decode(base64MD5); + assertArrayEquals(md5, uploadMD5); + LoggerFactory.getLogger(getClass()).info("Finished upload and validation for id " + id); + return Mono.just(uploadResponse); + }); + }) + .flatMapDelayError(m -> m, 30, 1) + .blockLast(); + // + long durationMilliseconds = Duration.between(downloadStart, Instant.now()).toMillis(); + LoggerFactory.getLogger(getClass()).info("Download/Upload took " + durationMilliseconds + " milliseconds."); + } + + @Test + public void cancellationTest() throws Exception { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + final Disposable d = Flux.range(0, NUM_FILES) + .flatMap(integer -> + service.download100M(String.valueOf(integer), sas) + .flatMapMany(RestStreamResponse::body)) + .subscribe(); + + Mono.delay(Duration.ofSeconds(10)).then(Mono.defer(() -> { + d.dispose(); + return Mono.empty(); + })).block(); + // Wait to see if any leak reports come up + Thread.sleep(10000); + } + + @Test + public void testHighParallelism() { + final String sas = System.getenv("JAVA_SDK_TEST_SAS") == null ? "" : System.getenv("JAVA_SDK_TEST_SAS"); + + HttpHeaders headers = new HttpHeaders() + .set("x-ms-version", "2017-04-17"); + // Order in which policies applied will be the order in which they added to builder + // + List policies = new ArrayList<>(); + policies.add(new AddDatePolicy()); + policies.add(new AddHeadersPolicy(headers)); + policies.add(new ThrottlingRetryPolicy()); + + if (sas == null || sas.isEmpty()) { + policies.add(new HostPolicy("http://localhost:" + port)); + } + + final IOService innerService = RestProxy.create(IOService.class, + new HttpPipeline(policies.toArray(new HttpPipelinePolicy[policies.size()]))); + + // When running with MockServer, connections sometimes get dropped, + // but this doesn't seem to result in any bad behavior as long as we retry. + + Flux.range(0, 10000) + .flatMap(integer -> + innerService.createContainer(integer.toString(), sas) + .onErrorResume(throwable -> { + if (throwable instanceof RestException) { + RestException restException = (RestException) throwable; + if ((restException.response().statusCode() == 409 || restException.response().statusCode() == 404)) { + return Mono.empty(); + } + } + return Mono.error(throwable); + }) + .then(innerService.deleteContainer(integer.toString(), sas))) + .blockLast(); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyTests.java new file mode 100644 index 0000000000000..cee0c9886c947 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyTests.java @@ -0,0 +1,1488 @@ +package com.azure.common.implementation; + +import com.azure.common.MyRestException; +import com.azure.common.http.rest.RestException; +import com.azure.common.annotations.BodyParam; +import com.azure.common.annotations.DELETE; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HEAD; +import com.azure.common.annotations.HeaderParam; +import com.azure.common.annotations.Headers; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.HostParam; +import com.azure.common.annotations.PATCH; +import com.azure.common.annotations.POST; +import com.azure.common.annotations.PUT; +import com.azure.common.annotations.PathParam; +import com.azure.common.annotations.QueryParam; +import com.azure.common.annotations.UnexpectedResponseExceptionType; +import com.azure.common.entities.HttpBinHeaders; +import com.azure.common.entities.HttpBinJSON; +import com.azure.common.implementation.http.ContentType; +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.policy.HttpLogDetailLevel; +import com.azure.common.http.policy.HttpLoggingPolicy; +import com.azure.common.http.rest.RestResponse; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.http.rest.RestStreamResponse; +import com.azure.common.http.rest.RestVoidResponse; +import com.azure.common.implementation.serializer.SerializerAdapter; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import com.azure.common.implementation.util.FluxUtil; +import io.netty.buffer.ByteBuf; +import io.netty.util.ReferenceCountUtil; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.channels.AsynchronousFileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +public abstract class RestProxyTests { + + /** + * Get the HTTP client that will be used for each test. This will be called once per test. + * @return The HTTP client to use for each test. + */ + protected abstract HttpClient createHttpClient(); + + @Host("http://httpbin.org") + private interface Service1 { + @GET("bytes/100") + @ExpectedResponses({200}) + byte[] getByteArray(); + + @GET("bytes/100") + @ExpectedResponses({200}) + Mono getByteArrayAsync(); + + @GET("bytes/100") + Mono getByteArrayAsyncWithNoExpectedResponses(); + } + + @Test + public void SyncRequestWithByteArrayReturnType() { + final byte[] result = createService(Service1.class) + .getByteArray(); + assertNotNull(result); + assertEquals(100, result.length); + } + + @Test + public void AsyncRequestWithByteArrayReturnType() { + final byte[] result = createService(Service1.class) + .getByteArrayAsync() + .block(); + assertNotNull(result); + assertEquals(100, result.length); + } + + @Test + public void getByteArrayAsyncWithNoExpectedResponses() { + final byte[] result = createService(Service1.class) + .getByteArrayAsyncWithNoExpectedResponses() + .block(); + assertNotNull(result); + assertEquals(result.length, 100); + } + + @Host("http://{hostName}.org") + private interface Service2 { + @GET("bytes/{numberOfBytes}") + @ExpectedResponses({200}) + byte[] getByteArray(@HostParam("hostName") String host, @PathParam("numberOfBytes") int numberOfBytes); + + @GET("bytes/{numberOfBytes}") + @ExpectedResponses({200}) + Mono getByteArrayAsync(@HostParam("hostName") String host, @PathParam("numberOfBytes") int numberOfBytes); + } + + @Test + public void SyncRequestWithByteArrayReturnTypeAndParameterizedHostAndPath() { + final byte[] result = createService(Service2.class) + .getByteArray("httpbin", 50); + assertNotNull(result); + assertEquals(result.length, 50); + } + + @Test + public void AsyncRequestWithByteArrayReturnTypeAndParameterizedHostAndPath() { + final byte[] result = createService(Service2.class) + .getByteArrayAsync("httpbin", 50) + .block(); + assertNotNull(result); + assertEquals(result.length, 50); + } + + @Test + public void SyncRequestWithEmptyByteArrayReturnTypeAndParameterizedHostAndPath() { + final byte[] result = createService(Service2.class) + .getByteArray("httpbin", 0); + // If no body then for async returns Mono.empty() for sync return null. + assertNull(result); + } + + @Host("http://httpbin.org") + private interface Service3 { + @GET("bytes/2") + @ExpectedResponses({200}) + void getNothing(); + + @GET("bytes/2") + @ExpectedResponses({200}) + Mono getNothingAsync(); + } + + @Test + public void SyncGetRequestWithNoReturn() { + createService(Service3.class).getNothing(); + } + + @Test + public void AsyncGetRequestWithNoReturn() { + createService(Service3.class) + .getNothingAsync() + .block(); + } + + @Host("http://httpbin.org") + private interface Service5 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(); + + @GET("anything/with+plus") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithPlus(); + + @GET("anything/{path}") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithPathParam(@PathParam("path") String pathParam); + + @GET("anything/{path}") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithEncodedPathParam(@PathParam(value="path", encoded=true) String pathParam); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(); + } + + @Test + public void SyncGetRequestWithAnything() { + final HttpBinJSON json = createService(Service5.class) + .getAnything(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPlus(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+plus", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParam() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("withpathparam"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/withpathparam", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParamWithSpace() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("with path param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with path param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithPathParamWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithPathParam("with+path+param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+path+param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParam() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("withpathparam"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/withpathparam", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParamWithPercent20() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("with%20path%20param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with path param", json.url); + } + + @Test + public void SyncGetRequestWithAnythingWithEncodedPathParamWithPlus() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingWithEncodedPathParam("with+path+param"); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything/with+path+param", json.url); + } + + @Test + public void AsyncGetRequestWithAnything() { + final HttpBinJSON json = createService(Service5.class) + .getAnythingAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + } + + @Host("http://httpbin.org") + private interface Service6 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(@QueryParam("a") String a, @QueryParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnythingWithEncoded(@QueryParam(value="a", encoded=true) String a, @QueryParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(@QueryParam("a") String a, @QueryParam("b") int b); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnything() { + final HttpBinJSON json = createService(Service6.class) + .getAnything("A", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A&b=15", json.url); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnythingWithPercent20() { + final HttpBinJSON json = createService(Service6.class) + .getAnything("A%20Z", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A%2520Z&b=15", json.url); + } + + @Test + public void SyncGetRequestWithQueryParametersAndAnythingWithEncodedWithPercent20() { + final HttpBinJSON json = createService(Service6.class) + .getAnythingWithEncoded("x%20y", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=x y&b=15", json.url); + } + + @Test + public void AsyncGetRequestWithQueryParametersAndAnything() { + final HttpBinJSON json = createService(Service6.class) + .getAnythingAsync("A", 15) + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?a=A&b=15", json.url); + } + + @Test + public void SyncGetRequestWithNullQueryParameter() { + final HttpBinJSON json = createService(Service6.class) + .getAnything(null, 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything?b=15", json.url); + } + + @Host("http://httpbin.org") + private interface Service7 { + @GET("anything") + @ExpectedResponses({200}) + HttpBinJSON getAnything(@HeaderParam("a") String a, @HeaderParam("b") int b); + + @GET("anything") + @ExpectedResponses({200}) + Mono getAnythingAsync(@HeaderParam("a") String a, @HeaderParam("b") int b); + } + + @Test + public void SyncGetRequestWithHeaderParametersAndAnythingReturn() { + final HttpBinJSON json = createService(Service7.class) + .getAnything("A", 15); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("A", headers.value("A")); + assertArrayEquals(new String[]{"A"}, headers.values("A")); + assertEquals("15", headers.value("B")); + assertArrayEquals(new String[]{"15"}, headers.values("B")); + } + + @Test + public void AsyncGetRequestWithHeaderParametersAndAnything() { + final HttpBinJSON json = createService(Service7.class) + .getAnythingAsync("A", 15) + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("A", headers.value("A")); + assertArrayEquals(new String[]{"A"}, headers.values("A")); + assertEquals("15", headers.value("B")); + assertArrayEquals(new String[]{"15"}, headers.values("B")); + } + + @Test + public void SyncGetRequestWithNullHeader() { + final HttpBinJSON json = createService(Service7.class) + .getAnything(null, 15); + + final HttpHeaders headers = new HttpHeaders(json.headers); + + assertEquals(null, headers.value("A")); + assertArrayEquals(null, headers.values("A")); + assertEquals("15", headers.value("B")); + assertArrayEquals(new String[]{"15"}, headers.values("B")); + } + + @Host("http://httpbin.org") + private interface Service8 { + @POST("post") + @ExpectedResponses({200}) + HttpBinJSON post(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String postBody); + + @POST("post") + @ExpectedResponses({200}) + Mono postAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String postBody); + } + + @Test + public void SyncPostRequestWithStringBody() { + final HttpBinJSON json = createService(Service8.class) + .post("I'm a post body!"); + assertEquals(String.class, json.data.getClass()); + assertEquals("I'm a post body!", (String)json.data); + } + + @Test + public void AsyncPostRequestWithStringBody() { + final HttpBinJSON json = createService(Service8.class) + .postAsync("I'm a post body!") + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("I'm a post body!", (String)json.data); + } + + @Test + public void SyncPostRequestWithNullBody() { + final HttpBinJSON result = createService(Service8.class).post(null); + assertEquals("", result.data); + } + + @Host("http://httpbin.org") + private interface Service9 { + @PUT("put") + @ExpectedResponses({200}) + HttpBinJSON put(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) int putBody); + + @PUT("put") + @ExpectedResponses({200}) + Mono putAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) int putBody); + + @PUT("put") + @ExpectedResponses({201}) + HttpBinJSON putWithUnexpectedResponse(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + + @PUT("put") + @ExpectedResponses({201}) + Mono putWithUnexpectedResponseAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + + @PUT("put") + @ExpectedResponses({201}) + @UnexpectedResponseExceptionType(MyRestException.class) + HttpBinJSON putWithUnexpectedResponseAndExceptionType(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + + @PUT("put") + @ExpectedResponses({201}) + @UnexpectedResponseExceptionType(MyRestException.class) + Mono putWithUnexpectedResponseAndExceptionTypeAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String putBody); + } + + @Test + public void SyncPutRequestWithIntBody() { + final HttpBinJSON json = createService(Service9.class) + .put(42); + assertEquals(String.class, json.data.getClass()); + assertEquals("42", (String)json.data); + } + + @Test + public void AsyncPutRequestWithIntBody() { + final HttpBinJSON json = createService(Service9.class) + .putAsync(42) + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("42", (String)json.data); + } + + @Test + public void SyncPutRequestWithUnexpectedResponse() { + try { + createService(Service9.class) + .putWithUnexpectedResponse("I'm the body!"); + fail("Expected RestException would be thrown."); + } catch (RestException e) { + assertNotNull(e.body()); + assertTrue(e.body() instanceof LinkedHashMap); + + final LinkedHashMap expectedBody = (LinkedHashMap)e.body(); + assertEquals("I'm the body!", expectedBody.get("data")); + } + } + + @Test + public void AsyncPutRequestWithUnexpectedResponse() { + try { + createService(Service9.class) + .putWithUnexpectedResponseAsync("I'm the body!") + .block(); + fail("Expected RestException would be thrown."); + } catch (RestException e) { + assertNotNull(e.body()); + assertTrue(e.body() instanceof LinkedHashMap); + + final LinkedHashMap expectedBody = (LinkedHashMap)e.body(); + assertEquals("I'm the body!", expectedBody.get("data")); + } + } + + @Test + public void SyncPutRequestWithUnexpectedResponseAndExceptionType() { + try { + createService(Service9.class) + .putWithUnexpectedResponseAndExceptionType("I'm the body!"); + fail("Expected RestException would be thrown."); + } catch (MyRestException e) { + assertNotNull(e.body()); + Assert.assertEquals("I'm the body!", e.body().data); + } catch (Throwable e) { + fail("Expected MyRestException would be thrown. Instead got " + e.getClass().getSimpleName()); + } + } + + @Test + public void AsyncPutRequestWithUnexpectedResponseAndExceptionType() { + try { + createService(Service9.class) + .putWithUnexpectedResponseAndExceptionTypeAsync("I'm the body!") + .block(); + fail("Expected RestException would be thrown."); + } catch (MyRestException e) { + assertNotNull(e.body()); + Assert.assertEquals("I'm the body!", e.body().data); + } catch (Throwable e) { + fail("Expected MyRestException would be thrown. Instead got " + e.getClass().getSimpleName()); + } + } + + @Host("http://httpbin.org") + private interface Service10 { + @HEAD("anything") + @ExpectedResponses({200}) + RestVoidResponse head(); + + @HEAD("anything") + @ExpectedResponses({200}) + boolean headBoolean(); + + @HEAD("anything") + @ExpectedResponses({200}) + void voidHead(); + + @HEAD("anything") + @ExpectedResponses({200}) + Mono headAsync(); + + @HEAD("anything") + @ExpectedResponses({200}) + Mono headBooleanAsync(); + + @HEAD("anything") + @ExpectedResponses({200}) + Mono completableHeadAsync(); + } + + @Test + public void SyncHeadRequest() { + final Void body = createService(Service10.class) + .head() + .body(); + assertNull(body); + } + + @Test + public void SyncHeadBooleanRequest() { + final boolean result = createService(Service10.class).headBoolean(); + assertTrue(result); + } + + @Test + public void SyncVoidHeadRequest() { + createService(Service10.class) + .voidHead(); + } + + @Test + public void AsyncHeadRequest() { + final Void body = createService(Service10.class) + .headAsync() + .block() + .body(); + + assertNull(body); + } + + @Test + public void AsyncHeadBooleanRequest() { + final boolean result = createService(Service10.class).headBooleanAsync().block(); + assertTrue(result); + } + + @Test + public void AsyncCompletableHeadRequest() { + createService(Service10.class) + .completableHeadAsync() + .block(); + } + + @Host("http://httpbin.org") + private interface Service11 { + @DELETE("delete") + @ExpectedResponses({200}) + HttpBinJSON delete(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) boolean bodyBoolean); + + @DELETE("delete") + @ExpectedResponses({200}) + Mono deleteAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) boolean bodyBoolean); + } + + @Test + public void SyncDeleteRequest() { + final HttpBinJSON json = createService(Service11.class) + .delete(false); + assertEquals(String.class, json.data.getClass()); + assertEquals("false", (String)json.data); + } + + @Test + public void AsyncDeleteRequest() { + final HttpBinJSON json = createService(Service11.class) + .deleteAsync(false) + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("false", (String)json.data); + } + + @Host("http://httpbin.org") + private interface Service12 { + @PATCH("patch") + @ExpectedResponses({200}) + HttpBinJSON patch(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String bodyString); + + @PATCH("patch") + @ExpectedResponses({200}) + Mono patchAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String bodyString); + } + + @Test + public void SyncPatchRequest() { + final HttpBinJSON json = createService(Service12.class) + .patch("body-contents"); + assertEquals(String.class, json.data.getClass()); + assertEquals("body-contents", (String)json.data); + } + + @Test + public void AsyncPatchRequest() { + final HttpBinJSON json = createService(Service12.class) + .patchAsync("body-contents") + .block(); + assertEquals(String.class, json.data.getClass()); + assertEquals("body-contents", (String)json.data); + } + + @Host("http://httpbin.org") + private interface Service13 { + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue", "MyOtherHeader:My,Header,Value" }) + HttpBinJSON get(); + + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue", "MyOtherHeader:My,Header,Value" }) + Mono getAsync(); + } + + @Test + public void SyncHeadersRequest() { + final HttpBinJSON json = createService(Service13.class) + .get(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + assertArrayEquals(new String[]{"MyHeaderValue"}, headers.values("MyHeader")); + assertEquals("My,Header,Value", headers.value("MyOtherHeader")); + assertArrayEquals(new String[]{"My", "Header", "Value"}, headers.values("MyOtherHeader")); + } + + @Test + public void AsyncHeadersRequest() { + final HttpBinJSON json = createService(Service13.class) + .getAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + assertArrayEquals(new String[]{"MyHeaderValue"}, headers.values("MyHeader")); + } + + @Host("https://httpbin.org") + private interface Service14 { + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue" }) + HttpBinJSON get(); + + @GET("anything") + @ExpectedResponses({200}) + @Headers({ "MyHeader:MyHeaderValue" }) + Mono getAsync(); + } + + @Test + public void AsyncHttpsHeadersRequest() { + final HttpBinJSON json = createService(Service14.class) + .getAsync() + .block(); + assertNotNull(json); + assertMatchWithHttpOrHttps("httpbin.org/anything", json.url); + assertNotNull(json.headers); + final HttpHeaders headers = new HttpHeaders(json.headers); + assertEquals("MyHeaderValue", headers.value("MyHeader")); + } + + @Host("https://httpbin.org") + private interface Service16 { + @PUT("put") + @ExpectedResponses({200}) + HttpBinJSON putByteArray(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] bytes); + + @PUT("put") + @ExpectedResponses({200}) + Mono putByteArrayAsync(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] bytes); + } + + @Test + public void service16Put() throws Exception { + final Service16 service16 = createService(Service16.class); + final byte[] expectedBytes = new byte[] { 1, 2, 3, 4 }; + final HttpBinJSON httpBinJSON = service16.putByteArray(expectedBytes); + + // httpbin sends the data back as a string like "\u0001\u0002\u0003\u0004" + assertTrue(httpBinJSON.data instanceof String); + + final String base64String = (String) httpBinJSON.data; + final byte[] actualBytes = base64String.getBytes(); + assertArrayEquals(expectedBytes, actualBytes); + } + + @Test + public void service16PutAsync() throws Exception { + final Service16 service16 = createService(Service16.class); + final byte[] expectedBytes = new byte[] { 1, 2, 3, 4 }; + final HttpBinJSON httpBinJSON = service16.putByteArrayAsync(expectedBytes) + .block(); + assertTrue(httpBinJSON.data instanceof String); + + final String base64String = (String) httpBinJSON.data; + final byte[] actualBytes = base64String.getBytes(); + assertArrayEquals(expectedBytes, actualBytes); + } + + @Host("http://{hostPart1}{hostPart2}.org") + private interface Service17 { + @GET("get") + @ExpectedResponses({200}) + HttpBinJSON get(@HostParam("hostPart1") String hostPart1, @HostParam("hostPart2") String hostPart2); + + @GET("get") + @ExpectedResponses({200}) + Mono getAsync(@HostParam("hostPart1") String hostPart1, @HostParam("hostPart2") String hostPart2); + } + + @Test + public void SyncRequestWithMultipleHostParams() { + final Service17 service17 = createService(Service17.class); + final HttpBinJSON result = service17.get("http", "bin"); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/get", result.url); + } + + @Test + public void AsyncRequestWithMultipleHostParams() { + final Service17 service17 = createService(Service17.class); + final HttpBinJSON result = service17.getAsync("http", "bin").block(); + assertNotNull(result); + assertMatchWithHttpOrHttps("httpbin.org/get", result.url); + } + + @Host("https://httpbin.org") + private interface Service18 { + @GET("status/200") + void getStatus200(); + + @GET("status/200") + @ExpectedResponses({200}) + void getStatus200WithExpectedResponse200(); + + @GET("status/300") + void getStatus300(); + + @GET("status/300") + @ExpectedResponses({300}) + void getStatus300WithExpectedResponse300(); + + @GET("status/400") + void getStatus400(); + + @GET("status/400") + @ExpectedResponses({400}) + void getStatus400WithExpectedResponse400(); + + @GET("status/500") + void getStatus500(); + + @GET("status/500") + @ExpectedResponses({500}) + void getStatus500WithExpectedResponse500(); + } + + @Test + public void service18GetStatus200() { + createService(Service18.class) + .getStatus200(); + } + + @Test + public void service18GetStatus200WithExpectedResponse200() { + createService(Service18.class) + .getStatus200WithExpectedResponse200(); + } + + @Test + public void service18GetStatus300() { + createService(Service18.class) + .getStatus300(); + } + + @Test + public void service18GetStatus300WithExpectedResponse300() { + createService(Service18.class) + .getStatus300WithExpectedResponse300(); + } + + @Test(expected = RestException.class) + public void service18GetStatus400() { + createService(Service18.class) + .getStatus400(); + } + + @Test + public void service18GetStatus400WithExpectedResponse400() { + createService(Service18.class) + .getStatus400WithExpectedResponse400(); + } + + @Test(expected = RestException.class) + public void service18GetStatus500() { + createService(Service18.class) + .getStatus500(); + } + + @Test + public void service18GetStatus500WithExpectedResponse500() { + createService(Service18.class) + .getStatus500WithExpectedResponse500(); + } + + @Host("http://httpbin.org") + private interface Service19 { + @PUT("put") + HttpBinJSON putWithNoContentTypeAndStringBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @PUT("put") + HttpBinJSON putWithNoContentTypeAndByteArrayBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] body); + + @PUT("put") + HttpBinJSON putWithHeaderApplicationJsonContentTypeAndStringBody(@BodyParam(ContentType.APPLICATION_JSON) String body); + + @PUT("put") + @Headers({ "Content-Type: application/json" }) + HttpBinJSON putWithHeaderApplicationJsonContentTypeAndByteArrayBody(@BodyParam(ContentType.APPLICATION_JSON) byte[] body); + + @PUT("put") + @Headers({ "Content-Type: application/json; charset=utf-8" }) + HttpBinJSON putWithHeaderApplicationJsonContentTypeAndCharsetAndStringBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @PUT("put") + @Headers({ "Content-Type: application/octet-stream" }) + HttpBinJSON putWithHeaderApplicationOctetStreamContentTypeAndStringBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @PUT("put") + @Headers({ "Content-Type: application/octet-stream" }) + HttpBinJSON putWithHeaderApplicationOctetStreamContentTypeAndByteArrayBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] body); + + @PUT("put") + HttpBinJSON putWithBodyParamApplicationJsonContentTypeAndStringBody(@BodyParam(ContentType.APPLICATION_JSON) String body); + + @PUT("put") + HttpBinJSON putWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBody(@BodyParam(ContentType.APPLICATION_JSON + "; charset=utf-8") String body); + + @PUT("put") + HttpBinJSON putWithBodyParamApplicationJsonContentTypeAndByteArrayBody(@BodyParam(ContentType.APPLICATION_JSON) byte[] body); + + @PUT("put") + HttpBinJSON putWithBodyParamApplicationOctetStreamContentTypeAndStringBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @PUT("put") + HttpBinJSON putWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) byte[] body); + } + + @Test + public void service19PutWithNoContentTypeAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithNoContentTypeAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndStringBody(""); + assertEquals("", result.data); + } + + @Test + public void service19PutWithNoContentTypeAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndStringBody("hello"); + assertEquals("hello", result.data); + } + + @Test + public void service19PutWithNoContentTypeAndByteArrayBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndByteArrayBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithNoContentTypeAndByteArrayBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndByteArrayBody(new byte[0]); + assertEquals("", result.data); + } + + @Test + public void service19PutWithNoContentTypeAndByteArrayBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithNoContentTypeAndByteArrayBody(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals(new String(new byte[] { 0, 1, 2, 3, 4 }), result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndStringBody(""); + assertEquals("\"\"", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndStringBody("soups and stuff"); + assertEquals("\"soups and stuff\"", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndByteArrayBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndByteArrayBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndByteArrayBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndByteArrayBody(new byte[0]); + assertEquals("\"\"", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndByteArrayBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndByteArrayBody(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals("\"AAECAwQ=\"", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndCharsetAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndCharsetAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndCharsetAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndCharsetAndStringBody(""); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationJsonContentTypeAndCharsetAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationJsonContentTypeAndCharsetAndStringBody("soups and stuff"); + assertEquals("soups and stuff", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndStringBody(""); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndStringBody("penguins"); + assertEquals("penguins", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndByteArrayBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndByteArrayBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndByteArrayBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndByteArrayBody(new byte[0]); + assertEquals("", result.data); + } + + @Test + public void service19PutWithHeaderApplicationOctetStreamContentTypeAndByteArrayBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithHeaderApplicationOctetStreamContentTypeAndByteArrayBody(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals(new String(new byte[] { 0, 1, 2, 3, 4 }), result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndStringBody(""); + assertEquals("\"\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndStringBody("soups and stuff"); + assertEquals("\"soups and stuff\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBody(""); + assertEquals("\"\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndCharsetAndStringBody("soups and stuff"); + assertEquals("\"soups and stuff\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndByteArrayBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndByteArrayBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndByteArrayBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndByteArrayBody(new byte[0]); + assertEquals("\"\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationJsonContentTypeAndByteArrayBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationJsonContentTypeAndByteArrayBody(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals("\"AAECAwQ=\"", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndStringBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndStringBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndStringBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndStringBody(""); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndStringBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndStringBody("penguins"); + assertEquals("penguins", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBodyWithNullBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBody(null); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBodyWithEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBody(new byte[0]); + assertEquals("", result.data); + } + + @Test + public void service19PutWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBodyWithNonEmptyBody() { + final HttpBinJSON result = createService(Service19.class) + .putWithBodyParamApplicationOctetStreamContentTypeAndByteArrayBody(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals(new String(new byte[] { 0, 1, 2, 3, 4 }), result.data); + } + + @Host("http://httpbin.org") + private interface Service20 { + @GET("bytes/100") + RestResponseBase getBytes100OnlyHeaders(); + + @GET("bytes/100") + RestResponseBase getBytes100OnlyRawHeaders(); + + @GET("bytes/100") + RestResponseBase getBytes100BodyAndHeaders(); + + @PUT("put") + RestResponseBase putOnlyHeaders(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @PUT("put") + RestResponseBase putBodyAndHeaders(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + + @GET("bytes/100") + RestResponseBase getBytesOnlyStatus(); + + @GET("bytes/100") + RestVoidResponse getVoidRestResponse(); + + @PUT("put") + RestResponse putBody(@BodyParam(ContentType.APPLICATION_OCTET_STREAM) String body); + } + + @Test + public void service20GetBytes100OnlyHeaders() { + final RestResponseBase response = createService(Service20.class) + .getBytes100OnlyHeaders(); + assertNotNull(response); + + assertEquals(200, response.statusCode()); + + final HttpBinHeaders headers = response.deserializedHeaders(); + assertNotNull(headers); + assertEquals(true, headers.accessControlAllowCredentials); + assertEquals("keep-alive", headers.connection.toLowerCase()); + assertNotNull(headers.date); + // assertEquals("1.1 vegur", headers.via); + assertNotEquals(0, headers.xProcessedTime); + } + + @Test + public void service20GetBytes100BodyAndHeaders() { + final RestResponseBase response = createService(Service20.class) + .getBytes100BodyAndHeaders(); + assertNotNull(response); + + assertEquals(200, response.statusCode()); + + final byte[] body = response.body(); + assertNotNull(body); + assertEquals(100, body.length); + + final HttpBinHeaders headers = response.deserializedHeaders(); + assertNotNull(headers); + assertEquals(true, headers.accessControlAllowCredentials); + assertNotNull(headers.date); + // assertEquals("1.1 vegur", headers.via); + assertNotEquals(0, headers.xProcessedTime); + } + + @Test + public void service20GetBytesOnlyStatus() { + final RestResponse response = createService(Service20.class) + .getBytesOnlyStatus(); + assertNotNull(response); + assertEquals(200, response.statusCode()); + } + + @Test + public void service20GetBytesOnlyHeaders() { + final RestResponse response = createService(Service20.class) + .getBytes100OnlyRawHeaders(); + + assertNotNull(response); + assertEquals(200, response.statusCode()); + assertNotNull(response.headers()); + assertNotEquals(0, response.headers().size()); + } + + @Test + public void service20PutOnlyHeaders() { + final RestResponseBase response = createService(Service20.class) + .putOnlyHeaders("body string"); + assertNotNull(response); + + assertEquals(200, response.statusCode()); + + final HttpBinHeaders headers = response.deserializedHeaders(); + assertNotNull(headers); + assertEquals(true, headers.accessControlAllowCredentials); + assertEquals("keep-alive", headers.connection.toLowerCase()); + assertNotNull(headers.date); + // assertEquals("1.1 vegur", headers.via); + assertNotEquals(0, headers.xProcessedTime); + } + + @Test + public void service20PutBodyAndHeaders() { + final RestResponseBase response = createService(Service20.class) + .putBodyAndHeaders("body string"); + assertNotNull(response); + + assertEquals(200, response.statusCode()); + + final HttpBinJSON body = response.body(); + assertNotNull(body); + assertMatchWithHttpOrHttps("httpbin.org/put", body.url); + assertEquals("body string", body.data); + + final HttpBinHeaders headers = response.deserializedHeaders(); + assertNotNull(headers); + assertEquals(true, headers.accessControlAllowCredentials); + assertEquals("keep-alive", headers.connection.toLowerCase()); + assertNotNull(headers.date); + // assertEquals("1.1 vegur", headers.via); + assertNotEquals(0, headers.xProcessedTime); + } + + @Test + public void service20GetVoidRestResponse() { + final RestVoidResponse response = createService(Service20.class).getVoidRestResponse(); + assertNotNull(response); + assertEquals(200, response.statusCode()); + } + + @Test + public void service20GetRestResponseBody() { + final RestResponse response = createService(Service20.class).putBody("body string"); + assertNotNull(response); + assertEquals(200, response.statusCode()); + + final HttpBinJSON body = response.body(); + assertNotNull(body); + assertMatchWithHttpOrHttps("httpbin.org/put", body.url); + assertEquals("body string", body.data); + + final HttpHeaders headers = response.headers(); + assertNotNull(headers); + } + + @Host("http://httpbin.org") + interface UnexpectedOKService { + @GET("/bytes/1024") + @ExpectedResponses({400}) + RestStreamResponse getBytes(); + } + + @Test + public void UnexpectedHTTPOK() { + try { + createService(UnexpectedOKService.class).getBytes(); + fail(); + } catch (RestException e) { + assertEquals("Status code 200, (1024-byte body)", e.getMessage()); + } + } + + @Host("https://www.example.com") + private interface Service21 { + @GET("http://httpbin.org/bytes/100") + @ExpectedResponses({200}) + byte[] getBytes100(); + } + + @Test + public void service21GetBytes100() { + final byte[] bytes = createService(Service21.class) + .getBytes100(); + assertNotNull(bytes); + assertEquals(100, bytes.length); + } + + @Host("http://httpbin.org") + interface DownloadService { + @GET("/bytes/30720") + RestStreamResponse getBytes(); + + @GET("/bytes/30720") + Flux getBytesFlowable(); + } + + @Test + public void SimpleDownloadTest() { + try (RestStreamResponse response = createService(DownloadService.class).getBytes()) { + int count = 0; + for (ByteBuf byteBuf : response.body().doOnNext(b -> b.retain()).toIterable()) { + // assertEquals(1, byteBuf.refCnt()); + count += byteBuf.readableBytes(); + ReferenceCountUtil.refCnt(byteBuf); + } + assertEquals(30720, count); + } + } + + @Test + public void RawFlowableDownloadTest() { + Flux response = createService(DownloadService.class).getBytesFlowable(); + int count = 0; + for (ByteBuf byteBuf : response.doOnNext(b -> b.retain()).toIterable()) { + count += byteBuf.readableBytes(); + ReferenceCountUtil.refCnt(byteBuf); + } + assertEquals(30720, count); + } + + @Host("https://httpbin.org") + interface FlowableUploadService { + @PUT("/put") + RestResponse put(@BodyParam("text/plain") Flux content, @HeaderParam("Content-Length") long contentLength); + } + + @Test + public void FlowableUploadTest() throws Exception { + Path filePath = Paths.get(getClass().getClassLoader().getResource("upload.txt").toURI()); + Flux stream = FluxUtil.byteBufStreamFromFile(AsynchronousFileChannel.open(filePath)); + + final HttpClient httpClient = createHttpClient(); + // Scenario: Log the body so that body buffering/replay behavior is exercised. + // + // Order in which policies applied will be the order in which they added to builder + // + final HttpPipeline httpPipeline = new HttpPipeline(httpClient, + new HttpLoggingPolicy(HttpLogDetailLevel.BODY_AND_HEADERS, true)); + // + RestResponse response = RestProxy.create(FlowableUploadService.class, httpPipeline, serializer).put(stream, Files.size(filePath)); + + assertEquals("The quick brown fox jumps over the lazy dog", response.body().data); + } + + @Test + public void SegmentUploadTest() throws Exception { + Path filePath = Paths.get(getClass().getClassLoader().getResource("upload.txt").toURI()); + AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(filePath, StandardOpenOption.READ); + RestResponse response = createService(FlowableUploadService.class) + .put(FluxUtil.byteBufStreamFromFile(fileChannel, 4, 15), 15); + + assertEquals("quick brown fox", response.body().data); + } + + @Host("{url}") + interface Service22 { + @GET("{container}/{blob}") + byte[] getBytes(@HostParam("url") String url); + } + + @Test + public void service22GetBytes() { + final byte[] bytes = createService(Service22.class).getBytes("http://httpbin.org/bytes/27"); + assertNotNull(bytes); + assertEquals(27, bytes.length); + } + + @Host("http://httpbin.org/") + interface Service23 { + @GET("bytes/28") + byte[] getBytes(); + } + + @Test + public void service23GetBytes() { + final byte[] bytes = createService(Service23.class) + .getBytes(); + assertNotNull(bytes); + assertEquals(28, bytes.length); + } + + @Host("http://httpbin.org/") + interface Service24 { + @PUT("put") + HttpBinJSON put(@HeaderParam("ABC") Map headerCollection); + } + + @Test + public void service24Put() { + final Map headerCollection = new HashMap<>(); + headerCollection.put("DEF", "GHIJ"); + headerCollection.put("123", "45"); + final HttpBinJSON result = createService(Service24.class) + .put(headerCollection); + assertNotNull(result.headers); + final HttpHeaders resultHeaders = new HttpHeaders(result.headers); + assertEquals("GHIJ", resultHeaders.value("ABCDEF")); + assertEquals("45", resultHeaders.value("ABC123")); + } + + @Host("http://httpbin.org") + interface Service25 { + @GET("anything") + HttpBinJSON get(); + + @GET("anything") + Mono getAsync(); + + @GET("anything") + Mono> getBodyResponseAsync(); + } + + @Test(expected = RestException.class) + @Ignore("Decoding is not a policy anymore") + public void testMissingDecodingPolicyCausesException() { + Service25 service = RestProxy.create(Service25.class, new HttpPipeline()); + service.get(); + } + + @Test(expected = RestException.class) + @Ignore("Decoding is not a policy anymore") + public void testSingleMissingDecodingPolicyCausesException() { + Service25 service = RestProxy.create(Service25.class, new HttpPipeline()); + service.getAsync().block(); + service.getBodyResponseAsync().block(); + } + + @Test(expected = RestException.class) + @Ignore("Decoding is not a policy anymore") + public void testSingleBodyResponseMissingDecodingPolicyCausesException() { + Service25 service = RestProxy.create(Service25.class, new HttpPipeline()); + service.getBodyResponseAsync().block(); + } + + // Helpers + protected T createService(Class serviceClass) { + final HttpClient httpClient = createHttpClient(); + return createService(serviceClass, httpClient); + } + + protected T createService(Class serviceClass, HttpClient httpClient) { + final HttpPipeline httpPipeline = new HttpPipeline(httpClient); + + return RestProxy.create(serviceClass, httpPipeline, serializer); + } + + private static void assertContains(String value, String expectedSubstring) { + assertTrue("Expected \"" + value + "\" to contain \"" + expectedSubstring + "\".", value.contains(expectedSubstring)); + } + + private static void assertMatchWithHttpOrHttps(String url1, String url2) { + final String s1 = "http://" + url1; + if (s1.equalsIgnoreCase(url2)) { + return; + } + final String s2 = "https://" + url1; + if (s2.equalsIgnoreCase(url2)) { + return; + } + Assert.assertTrue("'" + url2 + "' does not match with '" + s1 + "' or '" + s2 + "'." , false); + } + + private static final SerializerAdapter serializer = new JacksonAdapter(); +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithHttpProxyNettyTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithHttpProxyNettyTests.java new file mode 100644 index 0000000000000..ccea70d65aacb --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithHttpProxyNettyTests.java @@ -0,0 +1,18 @@ +package com.azure.common.implementation; + +import com.azure.common.http.HttpClient; +import com.azure.common.http.ProxyOptions; +import com.azure.common.http.ProxyOptions.Type; +import org.junit.Ignore; + +import java.net.InetSocketAddress; + +@Ignore("Should only be run manually when a local proxy server (e.g. Fiddler) is running") +public class RestProxyWithHttpProxyNettyTests extends RestProxyTests { + + @Override + protected HttpClient createHttpClient() { + InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888); + return HttpClient.createDefault().proxy(() -> new ProxyOptions(Type.HTTP, address)); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithMockTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithMockTests.java new file mode 100644 index 0000000000000..9db96e8060883 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithMockTests.java @@ -0,0 +1,416 @@ +package com.azure.common.implementation; + +import com.azure.common.http.rest.RestException; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.HeaderCollection; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.ReturnValueWireType; +import com.azure.common.entities.HttpBinJSON; +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.MockHttpClient; +import com.azure.common.http.MockHttpResponse; +import com.azure.common.http.rest.RestResponse; +import com.azure.common.http.rest.RestResponseBase; +import com.azure.common.http.ProxyOptions; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class RestProxyWithMockTests extends RestProxyTests { + @Override + protected HttpClient createHttpClient() { + return new MockHttpClient(); + } + + @Host("http://httpbin.org") + private interface Service1 { + @GET("Base64UrlBytes/10") + @ReturnValueWireType(Base64Url.class) + byte[] getBase64UrlBytes10(); + + @GET("Base64UrlListOfBytes") + @ReturnValueWireType(Base64Url.class) + List getBase64UrlListOfBytes(); + + @GET("Base64UrlListOfListOfBytes") + @ReturnValueWireType(Base64Url.class) + List> getBase64UrlListOfListOfBytes(); + + @GET("Base64UrlMapOfBytes") + @ReturnValueWireType(Base64Url.class) + Map getBase64UrlMapOfBytes(); + + @GET("DateTimeRfc1123") + @ReturnValueWireType(DateTimeRfc1123.class) + OffsetDateTime getDateTimeRfc1123(); + + @GET("UnixTime") + @ReturnValueWireType(UnixTime.class) + OffsetDateTime getDateTimeUnix(); + } + + @Test + public void service1GetBase64UrlBytes10() { + final byte[] bytes = createService(Service1.class) + .getBase64UrlBytes10(); + assertNotNull(bytes); + assertEquals(10, bytes.length); + for (int i = 0; i < 10; ++i) { + assertEquals((byte)i, bytes[i]); + } + } + + @Test + public void service1GetBase64UrlListOfBytes() { + final List bytesList = createService(Service1.class) + .getBase64UrlListOfBytes(); + assertNotNull(bytesList); + assertEquals(3, bytesList.size()); + + for (int i = 0; i < bytesList.size(); ++i) { + final byte[] bytes = bytesList.get(i); + assertNotNull(bytes); + assertEquals((i + 1) * 10, bytes.length); + for (int j = 0; j < bytes.length; ++j) { + assertEquals((byte)j, bytes[j]); + } + } + } + + @Test + public void service1GetBase64UrlListOfListOfBytes() { + final List> bytesList = createService(Service1.class) + .getBase64UrlListOfListOfBytes(); + assertNotNull(bytesList); + assertEquals(2, bytesList.size()); + + for (int i = 0; i < bytesList.size(); ++i) { + final List innerList = bytesList.get(i); + assertEquals((i + 1) * 2, innerList.size()); + + for (int j = 0; j < innerList.size(); ++j) { + final byte[] bytes = innerList.get(j); + assertNotNull(bytes); + assertEquals((j + 1) * 5, bytes.length); + for (int k = 0; k < bytes.length; ++k) { + assertEquals(k, bytes[k]); + } + } + } + } + + @Test + public void service1GetBase64UrlMapOfBytes() { + final Map bytesMap = createService(Service1.class) + .getBase64UrlMapOfBytes(); + assertNotNull(bytesMap); + assertEquals(2, bytesMap.size()); + + for (int i = 0; i < bytesMap.size(); ++i) { + final byte[] bytes = bytesMap.get(Integer.toString(i)); + + final int expectedArrayLength = (i + 1) * 10; + assertEquals(expectedArrayLength, bytes.length); + for (int j = 0; j < expectedArrayLength; ++j) { + assertEquals((byte)j, bytes[j]); + } + } + } + + @Test + public void service1GetDateTimeRfc1123() { + final OffsetDateTime dateTime = createService(Service1.class) + .getDateTimeRfc1123(); + assertNotNull(dateTime); + assertEquals(OffsetDateTime.ofInstant(Instant.ofEpochMilli(0), ZoneOffset.UTC), dateTime); + } + + @Test + public void service1GetDateTimeUnix() { + final OffsetDateTime dateTime = createService(Service1.class) + .getDateTimeUnix(); + assertNotNull(dateTime); + assertEquals(OffsetDateTime.ofInstant(Instant.ofEpochMilli(0), ZoneOffset.UTC), dateTime); + } + + + @Host("http://httpbin.org") + interface ServiceErrorWithCharsetService { + @GET("/get") + @ExpectedResponses({400}) + HttpBinJSON get(); + } + + @Test + public void ServiceErrorWithResponseContentType() { + ServiceErrorWithCharsetService service = RestProxy.create( + ServiceErrorWithCharsetService.class, + new HttpPipeline(new SimpleMockHttpClient() { + @Override + public Mono send(HttpRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + HttpResponse response = new MockHttpResponse(request, 200, headers, + "{ \"error\": \"Something went wrong, but at least this JSON is valid.\"}".getBytes(StandardCharsets.UTF_8)); + return Mono.just(response); + } + })); + + try { + service.get(); + fail(); + } catch (RuntimeException ex) { + assertEquals(ex.getMessage(), "Status code 200, \"{ \"error\": \"Something went wrong, but at least this JSON is valid.\"}\""); + } + } + + @Test + public void ServiceErrorWithResponseContentTypeBadJSON() { + ServiceErrorWithCharsetService service = RestProxy.create( + ServiceErrorWithCharsetService.class, + new HttpPipeline(new SimpleMockHttpClient() { + @Override + public Mono send(HttpRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + HttpResponse response = new MockHttpResponse(request, 200, headers, "BAD JSON".getBytes(StandardCharsets.UTF_8)); + return Mono.just(response); + } + })); + + try { + service.get(); + fail(); + } catch (RestException ex) { + assertContains(ex.getMessage(), "Status code 200"); + assertContains(ex.getMessage(), "\"BAD JSON\""); + } + } + + @Test + public void ServiceErrorWithResponseContentTypeCharset() { + ServiceErrorWithCharsetService service = RestProxy.create( + ServiceErrorWithCharsetService.class, + new HttpPipeline(new SimpleMockHttpClient() { + @Override + public Mono send(HttpRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json; charset=UTF-8"); + + HttpResponse response = new MockHttpResponse(request, 200, headers, + "{ \"error\": \"Something went wrong, but at least this JSON is valid.\"}".getBytes(StandardCharsets.UTF_8)); + return Mono.just(response); + } + })); + + try { + service.get(); + fail(); + } catch (RuntimeException ex) { + assertEquals(ex.getMessage(), "Status code 200, \"{ \"error\": \"Something went wrong, but at least this JSON is valid.\"}\""); + } + } + + @Test + public void ServiceErrorWithResponseContentTypeCharsetBadJSON() { + ServiceErrorWithCharsetService service = RestProxy.create( + ServiceErrorWithCharsetService.class, + new HttpPipeline(new SimpleMockHttpClient() { + @Override + public Mono send(HttpRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json; charset=UTF-8"); + + HttpResponse response = new MockHttpResponse(request, 200, headers, "BAD JSON".getBytes(StandardCharsets.UTF_8)); + return Mono.just(response); + } + })); + + try { + service.get(); + fail(); + } catch (RestException ex) { + assertContains(ex.getMessage(), "Status code 200"); + assertContains(ex.getMessage(), "\"BAD JSON\""); + } + } + + private static class HeaderCollectionTypePublicFields { + public String name; + + @HeaderCollection("header-collection-prefix-") + public Map headerCollection; + } + + private static class HeaderCollectionTypeProtectedFields { + protected String name; + + @HeaderCollection("header-collection-prefix-") + protected Map headerCollection; + } + + private static class HeaderCollectionTypePrivateFields { + private String name; + + @HeaderCollection("header-collection-prefix-") + private Map headerCollection; + } + + private static class HeaderCollectionTypePackagePrivateFields { + String name; + + @HeaderCollection("header-collection-prefix-") + Map headerCollection; + } + + @Host("https://www.example.com") + interface ServiceHeaderCollections { + @GET("url/path") + RestResponseBase publicFields(); + + @GET("url/path") + RestResponseBase protectedFields(); + + @GET("url/path") + RestResponseBase privateFields(); + + @GET("url/path") + RestResponseBase packagePrivateFields(); + } + + private static final HttpClient headerCollectionHttpClient = new MockHttpClient() { + @Override + public Mono send(HttpRequest request) { + final HttpHeaders headers = new HttpHeaders(); + headers.set("name", "Phillip"); + headers.set("header-collection-prefix-one", "1"); + headers.set("header-collection-prefix-two", "2"); + headers.set("header-collection-prefix-three", "3"); + final MockHttpResponse response = new MockHttpResponse(request, 200, headers); + return Mono.just(response); + } + }; + + private ServiceHeaderCollections createHeaderCollectionsService() { + return createService(ServiceHeaderCollections.class, headerCollectionHttpClient); + } + + private static void assertHeaderCollectionsRawHeaders(RestResponse response) { + final HttpHeaders responseRawHeaders = response.headers(); + assertEquals("Phillip", responseRawHeaders.value("name")); + assertEquals("1", responseRawHeaders.value("header-collection-prefix-one")); + assertEquals("2", responseRawHeaders.value("header-collection-prefix-two")); + assertEquals("3", responseRawHeaders.value("header-collection-prefix-three")); + assertEquals(4, responseRawHeaders.size()); + } + + private static void assertHeaderCollections(Map headerCollections) { + final Map expectedHeaderCollections = new HashMap<>(); + expectedHeaderCollections.put("one", "1"); + expectedHeaderCollections.put("two", "2"); + expectedHeaderCollections.put("three", "3"); + + for (final String key : headerCollections.keySet()) { + assertEquals(expectedHeaderCollections.get(key), headerCollections.get(key)); + } + assertEquals(expectedHeaderCollections.size(), headerCollections.size()); + } + + @Test + public void serviceHeaderCollectionPublicFields() { + final RestResponseBase response = createHeaderCollectionsService() + .publicFields(); + assertNotNull(response); + assertHeaderCollectionsRawHeaders(response); + + final HeaderCollectionTypePublicFields responseHeaders = response.deserializedHeaders(); + assertNotNull(responseHeaders); + assertEquals("Phillip", responseHeaders.name); + assertHeaderCollections(responseHeaders.headerCollection); + } + + @Test + public void serviceHeaderCollectionProtectedFields() { + final RestResponseBase response = createHeaderCollectionsService() + .protectedFields(); + assertNotNull(response); + assertHeaderCollectionsRawHeaders(response); + + final HeaderCollectionTypeProtectedFields responseHeaders = response.deserializedHeaders(); + assertNotNull(responseHeaders); + assertEquals("Phillip", responseHeaders.name); + assertHeaderCollections(responseHeaders.headerCollection); + } + + @Test + public void serviceHeaderCollectionPrivateFields() { + final RestResponseBase response = createHeaderCollectionsService() + .privateFields(); + assertNotNull(response); + assertHeaderCollectionsRawHeaders(response); + + final HeaderCollectionTypePrivateFields responseHeaders = response.deserializedHeaders(); + assertNotNull(responseHeaders); + assertEquals("Phillip", responseHeaders.name); + assertHeaderCollections(responseHeaders.headerCollection); + } + + @Test + public void serviceHeaderCollectionPackagePrivateFields() { + final RestResponseBase response = createHeaderCollectionsService() + .packagePrivateFields(); + assertNotNull(response); + assertHeaderCollectionsRawHeaders(response); + + final HeaderCollectionTypePackagePrivateFields responseHeaders = response.deserializedHeaders(); + assertNotNull(responseHeaders); + assertEquals("Phillip", responseHeaders.name); + assertHeaderCollections(responseHeaders.headerCollection); + } + + private static void assertContains(String value, String expectedSubstring) { + assertTrue("Expected \"" + value + "\" to contain \"" + expectedSubstring + "\".", value.contains(expectedSubstring)); + } + + + private static abstract class SimpleMockHttpClient implements HttpClient { + + @Override + public abstract Mono send(HttpRequest request); + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy not implemented."); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap not implemented."); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port not implemented."); + } + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithNettyTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithNettyTests.java new file mode 100644 index 0000000000000..92c0fad41bd0e --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyWithNettyTests.java @@ -0,0 +1,11 @@ +package com.azure.common.implementation; + +import com.azure.common.http.HttpClient; + +public class RestProxyWithNettyTests extends RestProxyTests { + + @Override + protected HttpClient createHttpClient() { + return HttpClient.createDefault(); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyXMLTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyXMLTests.java new file mode 100644 index 0000000000000..43b69112e2782 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/RestProxyXMLTests.java @@ -0,0 +1,227 @@ +/** + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * + */ + +package com.azure.common.implementation; + +import com.azure.common.annotations.BodyParam; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.Host; +import com.azure.common.annotations.PUT; +import com.azure.common.entities.AccessPolicy; +import com.azure.common.entities.SignedIdentifierInner; +import com.azure.common.entities.SignedIdentifiersWrapper; +import com.azure.common.entities.Slideshow; +import com.azure.common.http.HttpClient; +import com.azure.common.http.HttpHeaders; +import com.azure.common.http.HttpMethod; +import com.azure.common.http.HttpPipeline; +import com.azure.common.http.HttpRequest; +import com.azure.common.http.HttpResponse; +import com.azure.common.http.MockHttpResponse; +import com.azure.common.http.ProxyOptions; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.jackson.JacksonAdapter; +import com.azure.common.implementation.util.FluxUtil; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.Assert.*; + + +public class RestProxyXMLTests { + static class MockXMLHTTPClient implements HttpClient { + private HttpResponse response(HttpRequest request, String resource) throws IOException, URISyntaxException { + URL url = getClass().getClassLoader().getResource(resource); + byte[] bytes = Files.readAllBytes(Paths.get(url.toURI())); + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/xml"); + HttpResponse res = new MockHttpResponse(request, 200, headers, bytes); + return res; + } + @Override + public Mono send(HttpRequest request) { + try { + if (request.url().toString().endsWith("GetContainerACLs")) { + return Mono.just(response(request, "GetContainerACLs.xml")); + } else if (request.url().toString().endsWith("GetXMLWithAttributes")) { + return Mono.just(response(request, "GetXMLWithAttributes.xml")); + } else { + return Mono.just(new MockHttpResponse(request, 404)); + } + } catch (IOException | URISyntaxException e) { + return Mono.error(e); + } + } + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + } + + @Host("http://unused") + interface MyXMLService { + @GET("GetContainerACLs") + SignedIdentifiersWrapper getContainerACLs(); + + @PUT("SetContainerACLs") + void setContainerACLs(@BodyParam("application/xml") SignedIdentifiersWrapper signedIdentifiers); + } + + @Test + public void canReadXMLResponse() throws Exception { + // + final HttpPipeline pipeline = new HttpPipeline(new MockXMLHTTPClient()); + + // + MyXMLService myXMLService = RestProxy.create(MyXMLService.class, + pipeline, + new JacksonAdapter()); + List identifiers = myXMLService.getContainerACLs().signedIdentifiers(); + assertNotNull(identifiers); + assertNotEquals(0, identifiers.size()); + } + + static class MockXMLReceiverClient implements HttpClient { + byte[] receivedBytes = null; + + @Override + public Mono send(HttpRequest request) { + if (request.url().toString().endsWith("SetContainerACLs")) { + return FluxUtil.collectBytesInByteBufStream(request.body(), false) + .map(bytes -> { + receivedBytes = bytes; + return new MockHttpResponse(request, 200); + }); + } else { + return Mono.just(new MockHttpResponse(request, 404)); + } + } + + @Override + public HttpClient proxy(Supplier proxyOptions) { + throw new IllegalStateException("MockHttpClient.proxy"); + } + + @Override + public HttpClient wiretap(boolean enableWiretap) { + throw new IllegalStateException("MockHttpClient.wiretap"); + } + + @Override + public HttpClient port(int port) { + throw new IllegalStateException("MockHttpClient.port"); + } + } + + @Test + public void canWriteXMLRequest() throws Exception { + URL url = getClass().getClassLoader().getResource("GetContainerACLs.xml"); + byte[] bytes = Files.readAllBytes(Paths.get(url.toURI())); + HttpRequest request = new HttpRequest(HttpMethod.PUT, new URL("http://unused/SetContainerACLs")); + request.withBody(bytes); + + SignedIdentifierInner si = new SignedIdentifierInner(); + si.withId("MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="); + + AccessPolicy ap = new AccessPolicy(); + ap.withStart(OffsetDateTime.parse("2009-09-28T08:49:37.0000000Z")); + ap.withExpiry(OffsetDateTime.parse("2009-09-29T08:49:37.0000000Z")); + ap.withPermission("rwd"); + + si.withAccessPolicy(ap); + List expectedAcls = Collections.singletonList(si); + + JacksonAdapter serializer = new JacksonAdapter(); + MockXMLReceiverClient httpClient = new MockXMLReceiverClient(); + // + final HttpPipeline pipeline = new HttpPipeline(httpClient); + // + MyXMLService myXMLService = RestProxy.create(MyXMLService.class, + pipeline, + serializer); + SignedIdentifiersWrapper wrapper = new SignedIdentifiersWrapper(expectedAcls); + myXMLService.setContainerACLs(wrapper); + + SignedIdentifiersWrapper actualAclsWrapped = serializer.deserialize( + new String(httpClient.receivedBytes, StandardCharsets.UTF_8), + SignedIdentifiersWrapper.class, + SerializerEncoding.XML); + + List actualAcls = actualAclsWrapped.signedIdentifiers(); + + // Ideally we'd just check for "things that matter" about the XML-- e.g. the tag names, structure, and attributes needs to be the same, + // but it doesn't matter if one document has a trailing newline or has UTF-8 in the header instead of utf-8, or if comments are missing. + assertEquals(expectedAcls.size(), actualAcls.size()); + assertEquals(expectedAcls.get(0).id(), actualAcls.get(0).id()); + assertEquals(expectedAcls.get(0).accessPolicy().expiry(), actualAcls.get(0).accessPolicy().expiry()); + assertEquals(expectedAcls.get(0).accessPolicy().start(), actualAcls.get(0).accessPolicy().start()); + assertEquals(expectedAcls.get(0).accessPolicy().permission(), actualAcls.get(0).accessPolicy().permission()); + } + + @Host("http://unused") + public interface MyXMLServiceWithAttributes { + @GET("GetXMLWithAttributes") + Slideshow getSlideshow(); + } + + @Test + public void canDeserializeXMLWithAttributes() throws Exception { + JacksonAdapter serializer = new JacksonAdapter(); + // + final HttpPipeline pipeline = new HttpPipeline(new MockXMLHTTPClient()); + + // + MyXMLServiceWithAttributes myXMLService = RestProxy.create( + MyXMLServiceWithAttributes.class, + pipeline, + serializer); + + Slideshow slideshow = myXMLService.getSlideshow(); + assertEquals("Sample Slide Show", slideshow.title); + assertEquals("Date of publication", slideshow.date); + assertEquals("Yours Truly", slideshow.author); + assertEquals(2, slideshow.slides.length); + + assertEquals("all", slideshow.slides[0].type); + assertEquals("Wake up to WonderWidgets!", slideshow.slides[0].title); + assertNull(slideshow.slides[0].items); + + assertEquals("all", slideshow.slides[1].type); + assertEquals("Overview", slideshow.slides[1].title); + assertEquals(3, slideshow.slides[1].items.length); + assertEquals("Why WonderWidgets are great", slideshow.slides[1].items[0]); + assertEquals("", slideshow.slides[1].items[1]); + assertEquals("Who buys WonderWidgets", slideshow.slides[1].items[2]); + + String xml = serializer.serialize(slideshow, SerializerEncoding.XML); + Slideshow newSlideshow = serializer.deserialize(xml, Slideshow.class, SerializerEncoding.XML); + String newXML = serializer.serialize(newSlideshow, SerializerEncoding.XML); + assertEquals(xml, newXML); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/SubstitutionTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/SubstitutionTests.java new file mode 100644 index 0000000000000..a51a534d50837 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/SubstitutionTests.java @@ -0,0 +1,15 @@ +package com.azure.common.implementation; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SubstitutionTests { + @Test + public void constructor() { + final Substitution s = new Substitution("A", 2, true); + assertEquals("A", s.urlParameterName()); + assertEquals(2, s.methodParameterIndex()); + assertEquals(true, s.shouldEncode()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerInterfaceParserTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerInterfaceParserTests.java new file mode 100644 index 0000000000000..4c4f87a2c4455 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerInterfaceParserTests.java @@ -0,0 +1,54 @@ +package com.azure.common.implementation; + +import com.azure.common.implementation.exception.MissingRequiredAnnotationException; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.GET; +import com.azure.common.annotations.Host; +import org.junit.Test; + +import java.lang.reflect.Method; + +import static org.junit.Assert.*; + +public class SwaggerInterfaceParserTests { + + interface TestInterface1 { + String testMethod1(); + } + + @Host("https://management.azure.com") + interface TestInterface2 { + } + + @Test(expected = MissingRequiredAnnotationException.class) + public void hostWithNoHostAnnotation() { + new SwaggerInterfaceParser(TestInterface1.class, null); + } + + @Test + public void hostWithHostAnnotation() { + final SwaggerInterfaceParser interfaceParser = new SwaggerInterfaceParser(TestInterface2.class, null); + assertEquals("https://management.azure.com", interfaceParser.host()); + } + + @Host("https://azure.com") + interface TestInterface3 { + @GET("my/url/path") + @ExpectedResponses({200}) + void testMethod3(); + } + + @Test + public void methodParser() { + final SwaggerInterfaceParser interfaceParser = new SwaggerInterfaceParser(TestInterface3.class, null); + final Method testMethod3 = TestInterface3.class.getDeclaredMethods()[0]; + assertEquals("testMethod3", testMethod3.getName()); + + final SwaggerMethodParser methodParser = interfaceParser.methodParser(testMethod3); + assertNotNull(methodParser); + assertEquals("com.azure.common.implementation.SwaggerInterfaceParserTests$TestInterface3.testMethod3", methodParser.fullyQualifiedMethodName()); + + final SwaggerMethodParser methodDetails2 = interfaceParser.methodParser(testMethod3); + assertSame(methodParser, methodDetails2); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerMethodParserTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerMethodParserTests.java new file mode 100644 index 0000000000000..1168e187ddaf2 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/SwaggerMethodParserTests.java @@ -0,0 +1,76 @@ +package com.azure.common.implementation; + +import com.azure.common.implementation.exception.MissingRequiredAnnotationException; +import com.azure.common.MyRestException; +import com.azure.common.http.rest.RestException; +import com.azure.common.annotations.ExpectedResponses; +import com.azure.common.annotations.PATCH; +import com.azure.common.annotations.UnexpectedResponseExceptionType; +import com.azure.common.entities.HttpBinJSON; +import com.azure.common.http.HttpMethod; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Method; + +import static org.junit.Assert.*; + +public class SwaggerMethodParserTests { + + interface TestInterface1 { + void testMethod1(); + } + + @Test(expected = MissingRequiredAnnotationException.class) + public void withNoAnnotations() { + final Method testMethod1 = TestInterface1.class.getDeclaredMethods()[0]; + assertEquals("testMethod1", testMethod1.getName()); + + new SwaggerMethodParser(testMethod1, RestProxy.createDefaultSerializer(), "https://raw.host.com"); + } + + interface TestInterface2 { + @PATCH("my/rest/api/path") + @ExpectedResponses({200}) + void testMethod2(); + } + + @Test + public void withOnlyExpectedResponse() throws IOException { + final Method testMethod2 = TestInterface2.class.getDeclaredMethods()[0]; + assertEquals("testMethod2", testMethod2.getName()); + + final SwaggerMethodParser methodParser = new SwaggerMethodParser(testMethod2, RestProxy.createDefaultSerializer(), "https://raw.host.com"); + assertEquals("com.azure.common.implementation.SwaggerMethodParserTests$TestInterface2.testMethod2", methodParser.fullyQualifiedMethodName()); + assertEquals(HttpMethod.PATCH, methodParser.httpMethod()); + assertArrayEquals(new int[] { 200 }, methodParser.expectedStatusCodes()); + assertEquals(RestException.class, methodParser.exceptionType()); + assertEquals(Object.class, methodParser.exceptionBodyType()); + assertEquals(false, methodParser.headers(null).iterator().hasNext()); + assertEquals("https", methodParser.scheme(null)); + assertEquals("raw.host.com", methodParser.host(null)); + } + + interface TestInterface3 { + @PATCH("my/rest/api/path") + @ExpectedResponses({200}) + @UnexpectedResponseExceptionType(MyRestException.class) + void testMethod3(); + } + + @Test + public void withExpectedResponseAndUnexpectedResponseExceptionType() throws IOException { + final Method testMethod3 = TestInterface3.class.getDeclaredMethods()[0]; + assertEquals("testMethod3", testMethod3.getName()); + + final SwaggerMethodParser methodParser = new SwaggerMethodParser(testMethod3, RestProxy.createDefaultSerializer(), "https://raw.host.com"); + assertEquals("com.azure.common.implementation.SwaggerMethodParserTests$TestInterface3.testMethod3", methodParser.fullyQualifiedMethodName()); + assertEquals(HttpMethod.PATCH, methodParser.httpMethod()); + assertArrayEquals(new int[] { 200 }, methodParser.expectedStatusCodes()); + assertEquals(MyRestException.class, methodParser.exceptionType()); + assertEquals(HttpBinJSON.class, methodParser.exceptionBodyType()); + assertEquals(false, methodParser.headers(null).iterator().hasNext()); + assertEquals("https", methodParser.scheme(null)); + assertEquals("raw.host.com", methodParser.host(null)); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/UrlEscaperTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/UrlEscaperTests.java new file mode 100644 index 0000000000000..ab481f898c4e3 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/UrlEscaperTests.java @@ -0,0 +1,67 @@ +package com.azure.common.implementation; + +import org.junit.Assert; +import org.junit.Test; + +public class UrlEscaperTests { + private static String simple = "abcABC-123"; + private static String genDelim = "abc[456#78"; + private static String safeForPath = "abc:456@78"; + private static String safeForQuery = "abc/456?78"; + + @Test + public void canEscapePathSimple() { + PercentEscaper escaper = UrlEscapers.PATH_ESCAPER; + String actual = escaper.escape(simple); + Assert.assertEquals(simple, actual); + } + + @Test + public void canEscapeQuerySimple() { + PercentEscaper escaper = UrlEscapers.QUERY_ESCAPER; + String actual = escaper.escape(simple); + Assert.assertEquals(simple, actual); + } + + @Test + public void canEscapePathWithGenDelim() { + PercentEscaper escaper = UrlEscapers.PATH_ESCAPER; + String actual = escaper.escape(genDelim); + Assert.assertEquals("abc%5b456%2378", actual); + } + + @Test + public void canEscapeQueryWithGenDelim() { + PercentEscaper escaper = UrlEscapers.QUERY_ESCAPER; + String actual = escaper.escape(genDelim); + Assert.assertEquals("abc%5b456%2378", actual); + } + + @Test + public void canEscapePathWithSafeForPath() { + PercentEscaper escaper = UrlEscapers.PATH_ESCAPER; + String actual = escaper.escape(safeForPath); + Assert.assertEquals(safeForPath, actual); + } + + @Test + public void canEscapeQueryWithSafeForPath() { + PercentEscaper escaper = UrlEscapers.QUERY_ESCAPER; + String actual = escaper.escape(safeForPath); + Assert.assertEquals("abc%3a456%4078", actual); + } + + @Test + public void canEscapePathWithSafeForQuery() { + PercentEscaper escaper = UrlEscapers.PATH_ESCAPER; + String actual = escaper.escape(safeForQuery); + Assert.assertEquals("abc%2f456%3f78", actual); + } + + @Test + public void canEscapeQueryWithSafeForQuery() { + PercentEscaper escaper = UrlEscapers.QUERY_ESCAPER; + String actual = escaper.escape(safeForQuery); + Assert.assertEquals(safeForQuery, actual); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/ValidatorTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/ValidatorTests.java new file mode 100644 index 0000000000000..a4f40f7d51ca7 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/ValidatorTests.java @@ -0,0 +1,194 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.TextNode; + +import org.junit.Assert; +import org.junit.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.fail; + +public class ValidatorTests { + @Test + public void validateInt() throws Exception { + IntWrapper body = new IntWrapper(); + body.value = 2; + body.nullable = null; + Validator.validate(body); // pass + } + + @Test + public void validateInteger() throws Exception { + IntegerWrapper body = new IntegerWrapper(); + body.value = 3; + Validator.validate(body); // pass + try { + body.value = null; + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("value is required")); + } + } + + @Test + public void validateString() throws Exception { + StringWrapper body = new StringWrapper(); + body.value = ""; + Validator.validate(body); // pass + try { + body.value = null; + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("value is required")); + } + } + + @Test + public void validateLocalDate() throws Exception { + LocalDateWrapper body = new LocalDateWrapper(); + body.value = LocalDate.of(1, 2, 3); + Validator.validate(body); // pass + try { + body.value = null; + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("value is required")); + } + } + + @Test + public void validateList() throws Exception { + ListWrapper body = new ListWrapper(); + try { + body.list = null; + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("list is required")); + } + body.list = new ArrayList(); + Validator.validate(body); // pass + StringWrapper wrapper = new StringWrapper(); + wrapper.value = "valid"; + body.list.add(wrapper); + Validator.validate(body); // pass + body.list.add(null); + Validator.validate(body); // pass + body.list.add(new StringWrapper()); + try { + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("list.value is required")); + } + } + + @Test + public void validateMap() throws Exception { + MapWrapper body = new MapWrapper(); + try { + body.map = null; + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("map is required")); + } + body.map = new HashMap(); + Validator.validate(body); // pass + StringWrapper wrapper = new StringWrapper(); + wrapper.value = "valid"; + body.map.put(LocalDate.of(1, 2, 3), wrapper); + Validator.validate(body); // pass + body.map.put(LocalDate.of(1, 2, 3), null); + Validator.validate(body); // pass + body.map.put(LocalDate.of(1, 2, 3), new StringWrapper()); + try { + Validator.validate(body); // fail + fail(); + } catch (IllegalArgumentException ex) { + Assert.assertTrue(ex.getMessage().contains("map.value is required")); + } + } + + @Test + public void validateObject() throws Exception { + Product product = new Product(); + Validator.validate(product); + } + + @Test + public void validateRecursive() throws Exception { + TextNode textNode = new TextNode("\"\""); + Validator.validate(textNode); + } + + public final class IntWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 2 LINES + public int value; + public Object nullable; + } + + public final class IntegerWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public Integer value; + } + + public final class StringWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public String value; + } + + public final class LocalDateWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public LocalDate value; + } + + public final class ListWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public List list; + } + + public final class MapWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public Map map; + } + + public enum Color { + RED, + GREEN, + Blue + } + + public final class EnumWrapper { + @JsonProperty(required = true) + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 1 LINE + public Color color; + } + + public final class Product { + // CHECKSTYLE IGNORE VisibilityModifier FOR NEXT 2 LINES + public String id; + public String tag; + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlBuilderTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlBuilderTests.java new file mode 100644 index 0000000000000..4922618150c9f --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlBuilderTests.java @@ -0,0 +1,687 @@ +package com.azure.common.implementation.http; + +import org.hamcrest.CoreMatchers; +import org.junit.Test; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.Assert.*; + +public class UrlBuilderTests { + @Test + public void withScheme() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http"); + assertEquals("http://", builder.toString()); + } + + @Test + public void withSchemeWhenSchemeIsNull() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http"); + builder.withScheme(null); + assertNull(builder.scheme()); + } + + @Test + public void withSchemeWhenSchemeIsEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http"); + builder.withScheme(""); + assertNull(builder.scheme()); + } + + @Test + public void withSchemeWhenSchemeIsNotEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http"); + builder.withScheme("https"); + assertEquals("https", builder.scheme()); + } + + @Test + public void withSchemeWhenSchemeContainsTerminator() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http://"); + assertEquals("http", builder.scheme()); + assertNull(builder.host()); + assertEquals("http://", builder.toString()); + } + + @Test + public void withSchemeWhenSchemeContainsHost() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http://www.example.com"); + assertEquals("http", builder.scheme()); + assertEquals("www.example.com", builder.host()); + assertEquals("http://www.example.com", builder.toString()); + } + + @Test + public void withSchemeAndHost() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com"); + assertEquals("http://www.example.com", builder.toString()); + } + + @Test + public void withSchemeAndHostWhenHostHasWhitespace() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.exa mple.com"); + assertEquals("http://www.exa mple.com", builder.toString()); + } + + @Test + public void withHost() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com"); + assertEquals("www.example.com", builder.toString()); + } + + @Test + public void withHostWhenHostIsNull() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com"); + builder.withHost(null); + assertNull(builder.host()); + } + + @Test + public void withHostWhenHostIsEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com"); + builder.withHost(""); + assertNull(builder.host()); + } + + @Test + public void withHostWhenHostIsNotEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com"); + builder.withHost("www.bing.com"); + assertEquals("www.bing.com", builder.host()); + } + + @Test + public void withHostWhenHostContainsSchemeTerminator() { + final UrlBuilder builder = new UrlBuilder() + .withHost("://www.example.com"); + assertNull(builder.scheme()); + assertEquals("www.example.com", builder.host()); + assertEquals("www.example.com", builder.toString()); + } + + @Test + public void withHostWhenHostContainsScheme() { + final UrlBuilder builder = new UrlBuilder() + .withHost("https://www.example.com"); + assertEquals("https", builder.scheme()); + assertEquals("www.example.com", builder.host()); + assertEquals("https://www.example.com", builder.toString()); + } + + @Test + public void withHostWhenHostContainsColonButNoPort() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com:"); + assertEquals("www.example.com", builder.host()); + assertNull(builder.port()); + assertEquals("www.example.com", builder.toString()); + } + + @Test + public void withHostWhenHostContainsPort() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com:1234"); + assertEquals("www.example.com", builder.host()); + assertEquals(1234, builder.port().intValue()); + assertEquals("www.example.com:1234", builder.toString()); + } + + @Test + public void withHostWhenHostContainsForwardSlashButNoPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com/"); + assertEquals("www.example.com", builder.host()); + assertEquals("/", builder.path()); + assertEquals("www.example.com/", builder.toString()); + } + + @Test + public void withHostWhenHostContainsPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com/index.html"); + assertEquals("www.example.com", builder.host()); + assertEquals("/index.html", builder.path()); + assertEquals("www.example.com/index.html", builder.toString()); + } + + @Test + public void withHostWhenHostContainsQuestionMarkButNoQuery() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com?"); + assertEquals("www.example.com", builder.host()); + assertEquals(0, builder.query().size()); + assertEquals("www.example.com", builder.toString()); + } + + @Test + public void withHostWhenHostContainsQuery() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com?a=b"); + assertEquals("www.example.com", builder.host()); + assertThat(builder.toString(), CoreMatchers.containsString("a=b")); + assertEquals("www.example.com?a=b", builder.toString()); + } + + @Test + public void withHostWhenHostHasWhitespace() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.exampl e.com"); + assertEquals("www.exampl e.com", builder.toString()); + } + + @Test + public void withHostAndPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com") + .withPath("my/path"); + assertEquals("www.example.com/my/path", builder.toString()); + } + + @Test + public void withHostAndPathWithSlashAfterHost() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com/") + .withPath("my/path"); + assertEquals("www.example.com/my/path", builder.toString()); + } + + @Test + public void withHostAndPathWithSlashBeforePath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com") + .withPath("/my/path"); + assertEquals("www.example.com/my/path", builder.toString()); + } + + @Test + public void withHostAndPathWithSlashAfterHostAndBeforePath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com/") + .withPath("/my/path"); + assertEquals("www.example.com/my/path", builder.toString()); + } + + @Test + public void withHostAndPathWithWhitespaceInPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com") + .withPath("my path"); + assertEquals("www.example.com/my path", builder.toString()); + } + + @Test + public void withHostAndPathWithPlusInPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com") + .withPath("my+path"); + assertEquals("www.example.com/my+path", builder.toString()); + } + + @Test + public void withHostAndPathWithPercent20InPath() { + final UrlBuilder builder = new UrlBuilder() + .withHost("www.example.com") + .withPath("my%20path"); + assertEquals("www.example.com/my%20path", builder.toString()); + } + + @Test + public void withPortInt() { + final UrlBuilder builder = new UrlBuilder() + .withPort(50); + assertEquals(50, builder.port().intValue()); + assertEquals(":50", builder.toString()); + } + + @Test + public void withPortStringWithNull() { + final UrlBuilder builder = new UrlBuilder() + .withPort(null); + assertNull(builder.port()); + assertEquals("", builder.toString()); + } + + @Test + public void withPortStringWithEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withPort(""); + assertNull(builder.port()); + assertEquals("", builder.toString()); + } + + @Test + public void withPortString() { + final UrlBuilder builder = new UrlBuilder() + .withPort("50"); + assertEquals(50, builder.port().intValue()); + assertEquals(":50", builder.toString()); + } + + @Test + public void withPortStringWithForwardSlashButNoPath() { + final UrlBuilder builder = new UrlBuilder() + .withPort("50/"); + assertEquals(50, builder.port().intValue()); + assertEquals("/", builder.path()); + assertEquals(":50/", builder.toString()); + } + + @Test + public void withPortStringWithPath() { + final UrlBuilder builder = new UrlBuilder() + .withPort("50/index.html"); + assertEquals(50, builder.port().intValue()); + assertEquals("/index.html", builder.path()); + assertEquals(":50/index.html", builder.toString()); + } + + @Test + public void withPortStringWithQuestionMarkButNoQuery() { + final UrlBuilder builder = new UrlBuilder() + .withPort("50?"); + assertEquals(50, builder.port().intValue()); + assertEquals(0, builder.query().size()); + assertEquals(":50", builder.toString()); + } + + @Test + public void withPortStringWithQuery() { + final UrlBuilder builder = new UrlBuilder() + .withPort("50?a=b&c=d"); + assertEquals(50, builder.port().intValue()); + assertThat(builder.toString(), CoreMatchers.containsString("?a=b&c=d")); + assertEquals(":50?a=b&c=d", builder.toString()); + } + + @Test + public void withPortStringWhenPortIsNull() { + final UrlBuilder builder = new UrlBuilder() + .withPort(8080); + builder.withPort(null); + assertNull(builder.port()); + } + + @Test + public void withPortStringWhenPortIsEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withPort(8080); + builder.withPort(""); + assertNull(builder.port()); + } + + @Test + public void withPortStringWhenPortIsNotEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withPort(8080); + builder.withPort("123"); + assertEquals(123, builder.port().intValue()); + } + + @Test + public void withSchemeAndHostAndOneQueryParameter() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("A", "B"); + assertEquals("http://www.example.com?A=B", builder.toString()); + } + + @Test + public void withSchemeAndHostAndOneQueryParameterWhenQueryParameterNameHasWhitespace() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("App les", "B"); + assertEquals("http://www.example.com?App les=B", builder.toString()); + } + + @Test + public void withSchemeAndHostAndOneQueryParameterWhenQueryParameterNameHasPercent20() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("App%20les", "B"); + assertEquals("http://www.example.com?App%20les=B", builder.toString()); + } + + @Test + public void withSchemeAndHostAndOneQueryParameterWhenQueryParameterValueHasWhitespace() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("Apples", "Go od"); + assertEquals("http://www.example.com?Apples=Go od", builder.toString()); + } + + @Test + public void withSchemeAndHostAndOneQueryParameterWhenQueryParameterValueHasPercent20() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("Apples", "Go%20od"); + assertEquals("http://www.example.com?Apples=Go%20od", builder.toString()); + } + + @Test + public void withSchemeAndHostAndTwoQueryParameters() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("A", "B") + .setQueryParameter("C", "D"); + assertEquals("http://www.example.com?A=B&C=D", builder.toString()); + } + + @Test + public void withSchemeAndHostAndPathAndTwoQueryParameters() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .setQueryParameter("A", "B") + .setQueryParameter("C", "D") + .withPath("index.html"); + assertEquals("http://www.example.com/index.html?A=B&C=D", builder.toString()); + } + + @Test + public void withPathWhenBuilderPathIsNullAndPathIsNull() { + final UrlBuilder builder = new UrlBuilder(); + builder.withPath(null); + assertNull(builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsNullAndPathIsEmptyString() { + final UrlBuilder builder = new UrlBuilder(); + builder.withPath(""); + assertNull(builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsNullAndPathIsForwardSlash() { + final UrlBuilder builder = new UrlBuilder(); + builder.withPath("/"); + assertEquals("/", builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsNullAndPath() { + final UrlBuilder builder = new UrlBuilder(); + builder.withPath("test/path.html"); + assertEquals("test/path.html", builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsForwardSlashAndPathIsNull() { + final UrlBuilder builder = new UrlBuilder() + .withPath("/"); + builder.withPath(null); + assertNull(builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsForwardSlashAndPathIsEmptyString() { + final UrlBuilder builder = new UrlBuilder() + .withPath("/"); + builder.withPath(""); + assertNull(builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsForwardSlashAndPathIsForwardSlash() { + final UrlBuilder builder = new UrlBuilder() + .withPath("/"); + builder.withPath("/"); + assertEquals("/", builder.path()); + } + + @Test + public void withPathWhenBuilderPathIsForwardSlashAndPath() { + final UrlBuilder builder = new UrlBuilder() + .withPath("/"); + builder.withPath("test/path.html"); + assertEquals("test/path.html", builder.path()); + } + + @Test + public void withAbsolutePath() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .withPath("http://www.othersite.com"); + assertEquals("http://www.othersite.com", builder.toString()); + } + + @Test + public void withQueryInPath() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .withPath("mypath?thing=stuff") + .setQueryParameter("otherthing", "otherstuff"); + assertEquals("http://www.example.com/mypath?thing=stuff&otherthing=otherstuff", builder.toString()); + } + + @Test + public void withAbsolutePathAndQuery() { + final UrlBuilder builder = new UrlBuilder() + .withScheme("http") + .withHost("www.example.com") + .withPath("http://www.othersite.com/mypath?thing=stuff") + .setQueryParameter("otherthing", "otherstuff"); + assertEquals("http://www.othersite.com/mypath?thing=stuff&otherthing=otherstuff", builder.toString()); + } + + @Test + public void withQueryWithNull() { + final UrlBuilder builder = new UrlBuilder() + .withQuery(null); + assertEquals(0, builder.query().size()); + assertEquals("", builder.toString()); + } + + @Test + public void withQueryWithEmpty() { + final UrlBuilder builder = new UrlBuilder() + .withQuery(""); + assertEquals(0, builder.query().size()); + assertEquals("", builder.toString()); + } + + @Test + public void withQueryWithQuestionMark() { + final UrlBuilder builder = new UrlBuilder() + .withQuery("?"); + assertEquals(0, builder.query().size()); + assertEquals("", builder.toString()); + } + + @Test + public void parseWithNullString() { + final UrlBuilder builder = UrlBuilder.parse((String)null); + assertEquals("", builder.toString()); + } + + @Test + public void parseWithEmpty() { + final UrlBuilder builder = UrlBuilder.parse(""); + assertEquals("", builder.toString()); + } + + @Test + public void parseWithHost() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com"); + assertEquals("www.bing.com", builder.toString()); + } + + @Test + public void parseWithProtocolAndHost() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com"); + assertEquals("https://www.bing.com", builder.toString()); + } + + @Test + public void parseWithHostAndPort() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:8080"); + assertEquals("www.bing.com:8080", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPort() { + final UrlBuilder builder = UrlBuilder.parse("ftp://www.bing.com:8080"); + assertEquals("ftp://www.bing.com:8080", builder.toString()); + } + + @Test + public void parseWithHostAndPath() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com/my/path"); + assertEquals("www.bing.com/my/path", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPath() { + final UrlBuilder builder = UrlBuilder.parse("ftp://www.bing.com/my/path"); + assertEquals("ftp://www.bing.com/my/path", builder.toString()); + } + + @Test + public void parseWithHostAndPortAndPath() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:1234/my/path"); + assertEquals("www.bing.com:1234/my/path", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPortAndPath() { + final UrlBuilder builder = UrlBuilder.parse("ftp://www.bing.com:2345/my/path"); + assertEquals("ftp://www.bing.com:2345/my/path", builder.toString()); + } + + @Test + public void parseWithHostAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com?a=1"); + assertEquals("www.bing.com?a=1", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com?a=1"); + assertEquals("https://www.bing.com?a=1", builder.toString()); + } + + @Test + public void parseWithHostAndPortAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:123?a=1"); + assertEquals("www.bing.com:123?a=1", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPortAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com:987?a=1"); + assertEquals("https://www.bing.com:987?a=1", builder.toString()); + } + + @Test + public void parseWithHostAndPathAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com/folder/index.html?a=1"); + assertEquals("www.bing.com/folder/index.html?a=1", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPathAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com/image.gif?a=1"); + assertEquals("https://www.bing.com/image.gif?a=1", builder.toString()); + } + + @Test + public void parseWithHostAndPortAndPathAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:123/index.html?a=1"); + assertEquals("www.bing.com:123/index.html?a=1", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPortAndPathAndOneQueryParameter() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com:987/my/path/again?a=1"); + assertEquals("https://www.bing.com:987/my/path/again?a=1", builder.toString()); + } + + @Test + public void parseWithHostAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com?a=1&b=2"); + assertEquals("www.bing.com?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com?a=1&b=2"); + assertEquals("https://www.bing.com?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithHostAndPortAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:123?a=1&b=2"); + assertEquals("www.bing.com:123?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPortAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com:987?a=1&b=2"); + assertEquals("https://www.bing.com:987?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithHostAndPathAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com/folder/index.html?a=1&b=2"); + assertEquals("www.bing.com/folder/index.html?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPathAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com/image.gif?a=1&b=2"); + assertEquals("https://www.bing.com/image.gif?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithHostAndPortAndPathAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("www.bing.com:123/index.html?a=1&b=2"); + assertEquals("www.bing.com:123/index.html?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithProtocolAndHostAndPortAndPathAndTwoQueryParameters() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com:987/my/path/again?a=1&b=2"); + assertEquals("https://www.bing.com:987/my/path/again?a=1&b=2", builder.toString()); + } + + @Test + public void parseWithColonInPath() { + final UrlBuilder builder = UrlBuilder.parse("https://www.bing.com/my:/path"); + assertEquals("https://www.bing.com/my:/path", builder.toString()); + } + + @Test + public void parseURLWithNull() { + final UrlBuilder builder = UrlBuilder.parse((URL)null); + assertEquals("", builder.toString()); + } + + @Test + public void parseURLWithSchemeAndHost() throws MalformedURLException { + final UrlBuilder builder = UrlBuilder.parse(new URL("http://www.bing.com")); + assertEquals("http://www.bing.com", builder.toString()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlTokenizerTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlTokenizerTests.java new file mode 100644 index 0000000000000..38269d80e7e73 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/http/UrlTokenizerTests.java @@ -0,0 +1,208 @@ +package com.azure.common.implementation.http; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class UrlTokenizerTests { + @Test + public void constructor() { + final UrlTokenizer tokenizer = new UrlTokenizer("http://www.bing.com"); + assertNull(tokenizer.current()); + } + + @Test + public void nextWithNullText() { + final UrlTokenizer tokenizer = new UrlTokenizer(null); + assertFalse(tokenizer.next()); + assertNull(tokenizer.current()); + } + + @Test + public void nextWithEmptyText() { + final UrlTokenizer tokenizer = new UrlTokenizer(""); + assertFalse(tokenizer.next()); + assertNull(tokenizer.current()); + } + + @Test + public void nextWithSchemeButNoSeparator() { + nextTest("http", UrlToken.host("http")); + } + + @Test + public void nextWithSchemeAndColon() { + nextTest("http:", + UrlToken.host("http"), + UrlToken.port("")); + } + + @Test + public void nextWithSchemeAndColonAndForwardSlash() { + nextTest("http:/", + UrlToken.host("http"), + UrlToken.port(""), + UrlToken.path("/")); + } + + @Test + public void nextWithSchemeAndColonAndTwoForwardSlashes() { + nextTest("http://", + UrlToken.scheme("http"), + UrlToken.host("")); + } + + @Test + public void nextWithSchemeAndHost() { + nextTest("https://www.example.com", + UrlToken.scheme("https"), + UrlToken.host("www.example.com")); + } + + @Test + public void nextWithSchemeAndHostAndColon() { + nextTest("https://www.example.com:", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.port("")); + } + + @Test + public void nextWithSchemeAndHostAndPort() { + nextTest("https://www.example.com:8080", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.port("8080")); + } + + @Test + public void nextWithSchemeAndHostAndPortAndForwardSlash() { + nextTest("ftp://www.bing.com:123/", + UrlToken.scheme("ftp"), + UrlToken.host("www.bing.com"), + UrlToken.port("123"), + UrlToken.path("/")); + } + + @Test + public void nextWithSchemeAndHostAndPortAndPath() { + nextTest("ftp://www.bing.com:123/a/b/c.txt", + UrlToken.scheme("ftp"), + UrlToken.host("www.bing.com"), + UrlToken.port("123"), + UrlToken.path("/a/b/c.txt")); + } + + @Test + public void nextWithSchemeAndHostAndPortAndQuestionMark() { + nextTest("ftp://www.bing.com:123?", + UrlToken.scheme("ftp"), + UrlToken.host("www.bing.com"), + UrlToken.port("123"), + UrlToken.query("")); + } + + @Test + public void nextWithSchemeAndHostAndPortAndQuery() { + nextTest("ftp://www.bing.com:123?a=b&c=d", + UrlToken.scheme("ftp"), + UrlToken.host("www.bing.com"), + UrlToken.port("123"), + UrlToken.query("a=b&c=d")); + } + + @Test + public void nextWithSchemeAndHostAndForwardSlash() { + nextTest("https://www.example.com/", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.path("/")); + } + + @Test + public void nextWithSchemeAndHostAndPath() { + nextTest("https://www.example.com/index.html", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.path("/index.html")); + } + + @Test + public void nextWithSchemeAndHostAndPathAndQuestionMark() { + nextTest("https://www.example.com/index.html?", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.path("/index.html"), + UrlToken.query("")); + } + + @Test + public void nextWithSchemeAndHostAndPathAndQuery() { + nextTest("https://www.example.com/index.html?alpha=beta", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.path("/index.html"), + UrlToken.query("alpha=beta")); + } + + @Test + public void nextWithSchemeAndHostAndQuestionMark() { + nextTest("https://www.example.com?", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.query("")); + } + + @Test + public void nextWithSchemeAndHostAndQuery() { + nextTest("https://www.example.com?a=b", + UrlToken.scheme("https"), + UrlToken.host("www.example.com"), + UrlToken.query("a=b")); + } + + @Test + public void nextWithHostAndForwardSlash() { + nextTest("www.test.com/", + UrlToken.host("www.test.com"), + UrlToken.path("/")); + } + + @Test + public void nextWithHostAndQuestionMark() { + nextTest("www.test.com?", + UrlToken.host("www.test.com"), + UrlToken.query("")); + } + + @Test + public void nextWithPath() { + nextTest("folder/index.html", + UrlToken.host("folder"), + UrlToken.path("/index.html")); + } + + @Test + public void nextWithForwardSlashAndPath() { + nextTest("/folder/index.html", + UrlToken.host(""), + UrlToken.path("/folder/index.html")); + } + + private static void nextTest(String text, UrlToken... expectedTokens) { + final UrlTokenizer tokenizer = new UrlTokenizer(text); + final List tokenList = new ArrayList<>(); + while (tokenizer.next()) { + tokenList.add(tokenizer.current()); + } + final UrlToken[] tokenArray = new UrlToken[tokenList.size()]; + tokenList.toArray(tokenArray); + assertArrayEquals(expectedTokens, tokenArray); + + assertFalse(tokenizer.next()); + assertNull(tokenizer.current()); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesSerializerTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesSerializerTests.java new file mode 100644 index 0000000000000..749e5ced663f4 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/AdditionalPropertiesSerializerTests.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.util.Foo; +import com.azure.common.implementation.util.FooChild; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; + +public class AdditionalPropertiesSerializerTests { + @Test + public void canSerializeAdditionalProperties() throws Exception { + Foo foo = new Foo(); + foo.bar = "hello.world"; + foo.baz = new ArrayList<>(); + foo.baz.add("hello"); + foo.baz.add("hello.world"); + foo.qux = new HashMap<>(); + foo.qux.put("hello", "world"); + foo.qux.put("a.b", "c.d"); + foo.qux.put("bar.a", "ttyy"); + foo.qux.put("bar.b", "uuzz"); + foo.additionalProperties = new HashMap<>(); + foo.additionalProperties.put("bar", "baz"); + foo.additionalProperties.put("a.b", "c.d"); + foo.additionalProperties.put("properties.bar", "barbar"); + + String serialized = new JacksonAdapter().serialize(foo, SerializerEncoding.JSON); + Assert.assertEquals("{\"$type\":\"foo\",\"properties\":{\"bar\":\"hello.world\",\"props\":{\"baz\":[\"hello\",\"hello.world\"],\"q\":{\"qux\":{\"hello\":\"world\",\"a.b\":\"c.d\",\"bar.b\":\"uuzz\",\"bar.a\":\"ttyy\"}}}},\"bar\":\"baz\",\"a.b\":\"c.d\",\"properties.bar\":\"barbar\"}", serialized); + } + + @Test + public void canDeserializeAdditionalProperties() throws Exception { + String wireValue = "{\"$type\":\"foo\",\"properties\":{\"bar\":\"hello.world\",\"props\":{\"baz\":[\"hello\",\"hello.world\"],\"q\":{\"qux\":{\"hello\":\"world\",\"a.b\":\"c.d\",\"bar.b\":\"uuzz\",\"bar.a\":\"ttyy\"}}}},\"bar\":\"baz\",\"a.b\":\"c.d\",\"properties.bar\":\"barbar\"}"; + Foo deserialized = new JacksonAdapter().deserialize(wireValue, Foo.class, SerializerEncoding.JSON); + Assert.assertNotNull(deserialized.additionalProperties); + Assert.assertEquals("baz", deserialized.additionalProperties.get("bar")); + Assert.assertEquals("c.d", deserialized.additionalProperties.get("a.b")); + Assert.assertEquals("barbar", deserialized.additionalProperties.get("properties.bar")); + } + + @Test + public void canSerializeAdditionalPropertiesThroughInheritance() throws Exception { + Foo foo = new FooChild(); + foo.bar = "hello.world"; + foo.baz = new ArrayList<>(); + foo.baz.add("hello"); + foo.baz.add("hello.world"); + foo.qux = new HashMap<>(); + foo.qux.put("hello", "world"); + foo.qux.put("a.b", "c.d"); + foo.qux.put("bar.a", "ttyy"); + foo.qux.put("bar.b", "uuzz"); + foo.additionalProperties = new HashMap<>(); + foo.additionalProperties.put("bar", "baz"); + foo.additionalProperties.put("a.b", "c.d"); + foo.additionalProperties.put("properties.bar", "barbar"); + + String serialized = new JacksonAdapter().serialize(foo, SerializerEncoding.JSON); + Assert.assertEquals("{\"$type\":\"foochild\",\"properties\":{\"bar\":\"hello.world\",\"props\":{\"baz\":[\"hello\",\"hello.world\"],\"q\":{\"qux\":{\"hello\":\"world\",\"a.b\":\"c.d\",\"bar.b\":\"uuzz\",\"bar.a\":\"ttyy\"}}}},\"bar\":\"baz\",\"a.b\":\"c.d\",\"properties.bar\":\"barbar\"}", serialized); + } + + @Test + public void canDeserializeAdditionalPropertiesThroughInheritance() throws Exception { + String wireValue = "{\"$type\":\"foochild\",\"properties\":{\"bar\":\"hello.world\",\"props\":{\"baz\":[\"hello\",\"hello.world\"],\"q\":{\"qux\":{\"hello\":\"world\",\"a.b\":\"c.d\",\"bar.b\":\"uuzz\",\"bar.a\":\"ttyy\"}}}},\"bar\":\"baz\",\"a.b\":\"c.d\",\"properties.bar\":\"barbar\"}"; + Foo deserialized = new JacksonAdapter().deserialize(wireValue, Foo.class, SerializerEncoding.JSON); + Assert.assertNotNull(deserialized.additionalProperties); + Assert.assertEquals("baz", deserialized.additionalProperties.get("bar")); + Assert.assertEquals("c.d", deserialized.additionalProperties.get("a.b")); + Assert.assertEquals("barbar", deserialized.additionalProperties.get("properties.bar")); + Assert.assertTrue(deserialized instanceof FooChild); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializerTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializerTests.java new file mode 100644 index 0000000000000..047e6d8a059c6 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DateTimeSerializerTests.java @@ -0,0 +1,25 @@ +package com.azure.common.implementation.serializer.jackson; + +import org.junit.Test; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.junit.Assert.*; + +public class DateTimeSerializerTests { + @Test + public void toStringWithNull() { + assertNull(DateTimeSerializer.toString(null)); + } + + @Test + public void toStringOffsetDateTime() { + assertEquals("0001-01-01T14:00:00Z", DateTimeSerializer.toString(OffsetDateTime.of(1, 1, 1, 0, 0, 0, 0, ZoneOffset.ofHours(-14)))); + assertEquals("10000-01-01T13:59:59.999Z", DateTimeSerializer.toString(OffsetDateTime.of(LocalDate.of(10000, 1, 1), LocalTime.parse("13:59:59.999"), ZoneOffset.UTC))); + assertEquals("2010-01-01T12:34:56Z", DateTimeSerializer.toString(OffsetDateTime.of(2010, 1, 1, 12, 34, 56, 0, ZoneOffset.UTC))); + assertEquals("0001-01-01T00:00:00Z", DateTimeSerializer.toString(OffsetDateTime.of(1, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC))); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DurationSerializerTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DurationSerializerTests.java new file mode 100644 index 0000000000000..c4bccde1448f3 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/DurationSerializerTests.java @@ -0,0 +1,204 @@ +package com.azure.common.implementation.serializer.jackson; + +import org.junit.Test; + +import java.time.Duration; + +import static org.junit.Assert.*; + +public class DurationSerializerTests { + @Test + public void toStringWithNull() { + assertNull(DurationSerializer.toString(null)); + } + + @Test + public void toStringWith0Milliseconds() { + assertEquals("PT0S", DurationSerializer.toString(Duration.ofMillis(0))); + } + + @Test + public void toStringWith1Milliseconds() { + assertEquals("PT0.001S", DurationSerializer.toString(Duration.ofMillis(1))); + } + + @Test + public void toStringWith9Milliseconds() { + assertEquals("PT0.009S", DurationSerializer.toString(Duration.ofMillis(9))); + } + + @Test + public void toStringWith10Milliseconds() { + assertEquals("PT0.01S", DurationSerializer.toString(Duration.ofMillis(10))); + } + + @Test + public void toStringWith11Milliseconds() { + assertEquals("PT0.011S", DurationSerializer.toString(Duration.ofMillis(11))); + } + + @Test + public void toStringWith99Milliseconds() { + assertEquals("PT0.099S", DurationSerializer.toString(Duration.ofMillis(99))); + } + + @Test + public void toStringWith100Milliseconds() { + assertEquals("PT0.1S", DurationSerializer.toString(Duration.ofMillis(100))); + } + + @Test + public void toStringWith101Milliseconds() { + assertEquals("PT0.101S", DurationSerializer.toString(Duration.ofMillis(101))); + } + + @Test + public void toStringWith999Milliseconds() { + assertEquals("PT0.999S", DurationSerializer.toString(Duration.ofMillis(999))); + } + + @Test + public void toStringWith10illiseconds() { + assertEquals("PT1S", DurationSerializer.toString(Duration.ofMillis(1000))); + } + + @Test + public void toStringWith1Second() { + assertEquals("PT1S", DurationSerializer.toString(Duration.ofSeconds(1))); + } + + @Test + public void toStringWith9Seconds() { + assertEquals("PT9S", DurationSerializer.toString(Duration.ofSeconds(9))); + } + + @Test + public void toStringWith10Seconds() { + assertEquals("PT10S", DurationSerializer.toString(Duration.ofSeconds(10))); + } + + @Test + public void toStringWith11Seconds() { + assertEquals("PT11S", DurationSerializer.toString(Duration.ofSeconds(11))); + } + + @Test + public void toStringWith59Seconds() { + assertEquals("PT59S", DurationSerializer.toString(Duration.ofSeconds(59))); + } + + @Test + public void toStringWith60Seconds() { + assertEquals("PT1M", DurationSerializer.toString(Duration.ofSeconds(60))); + } + + @Test + public void toStringWith61Seconds() { + assertEquals("PT1M1S", DurationSerializer.toString(Duration.ofSeconds(61))); + } + + @Test + public void toStringWith1Minute() { + assertEquals("PT1M", DurationSerializer.toString(Duration.ofMinutes(1))); + } + + @Test + public void toStringWith9Minutes() { + assertEquals("PT9M", DurationSerializer.toString(Duration.ofMinutes(9))); + } + + @Test + public void toStringWith10Minutes() { + assertEquals("PT10M", DurationSerializer.toString(Duration.ofMinutes(10))); + } + + @Test + public void toStringWith11Minutes() { + assertEquals("PT11M", DurationSerializer.toString(Duration.ofMinutes(11))); + } + + @Test + public void toStringWith59Minutes() { + assertEquals("PT59M", DurationSerializer.toString(Duration.ofMinutes(59))); + } + + @Test + public void toStringWith60Minutes() { + assertEquals("PT1H", DurationSerializer.toString(Duration.ofMinutes(60))); + } + + @Test + public void toStringWith61Minutes() { + assertEquals("PT1H1M", DurationSerializer.toString(Duration.ofMinutes(61))); + } + + @Test + public void toStringWith1Hour() { + assertEquals("PT1H", DurationSerializer.toString(Duration.ofHours(1))); + } + + @Test + public void toStringWith9Hours() { + assertEquals("PT9H", DurationSerializer.toString(Duration.ofHours(9))); + } + + @Test + public void toStringWith10Hours() { + assertEquals("PT10H", DurationSerializer.toString(Duration.ofHours(10))); + } + + @Test + public void toStringWith11Hours() { + assertEquals("PT11H", DurationSerializer.toString(Duration.ofHours(11))); + } + + @Test + public void toStringWith23Hours() { + assertEquals("PT23H", DurationSerializer.toString(Duration.ofHours(23))); + } + + @Test + public void toStringWith24Hours() { + assertEquals("P1D", DurationSerializer.toString(Duration.ofHours(24))); + } + + @Test + public void toStringWith25Hours() { + assertEquals("P1DT1H", DurationSerializer.toString(Duration.ofHours(25))); + } + + @Test + public void toStringWith1Day() { + assertEquals("P1D", DurationSerializer.toString(Duration.ofDays(1))); + } + + @Test + public void toStringWith9Days() { + assertEquals("P9D", DurationSerializer.toString(Duration.ofDays(9))); + } + + @Test + public void toStringWith10Days() { + assertEquals("P10D", DurationSerializer.toString(Duration.ofDays(10))); + } + + @Test + public void toStringWith11Days() { + assertEquals("P11D", DurationSerializer.toString(Duration.ofDays(11))); + } + + @Test + public void toStringWith99Days() { + assertEquals("P99D", DurationSerializer.toString(Duration.ofDays(99))); + } + + @Test + public void toStringWith100Days() { + assertEquals("P100D", DurationSerializer.toString(Duration.ofDays(100))); + } + + @Test + public void toStringWith101Days() { + assertEquals("P101D", DurationSerializer.toString(Duration.ofDays(101))); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/FlatteningSerializerTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/FlatteningSerializerTests.java new file mode 100644 index 0000000000000..b3528cf066f8b --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/FlatteningSerializerTests.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.azure.common.implementation.serializer.SerializerEncoding; +import com.azure.common.implementation.serializer.JsonFlatten; +import com.azure.common.implementation.util.Foo; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +public class FlatteningSerializerTests { + @Test + public void canFlatten() throws Exception { + Foo foo = new Foo(); + foo.bar = "hello.world"; + foo.baz = new ArrayList<>(); + foo.baz.add("hello"); + foo.baz.add("hello.world"); + foo.qux = new HashMap<>(); + foo.qux.put("hello", "world"); + foo.qux.put("a.b", "c.d"); + foo.qux.put("bar.a", "ttyy"); + foo.qux.put("bar.b", "uuzz"); + foo.moreProps = "hello"; + + JacksonAdapter adapter = new JacksonAdapter(); + + // serialization + String serialized = adapter.serialize(foo, SerializerEncoding.JSON); + Assert.assertEquals("{\"$type\":\"foo\",\"properties\":{\"bar\":\"hello.world\",\"props\":{\"baz\":[\"hello\",\"hello.world\"],\"q\":{\"qux\":{\"hello\":\"world\",\"a.b\":\"c.d\",\"bar.b\":\"uuzz\",\"bar.a\":\"ttyy\"}}},\"more.props\":\"hello\"}}", serialized); + + // deserialization + Foo deserialized = adapter.deserialize(serialized, Foo.class, SerializerEncoding.JSON); + Assert.assertEquals("hello.world", deserialized.bar); + Assert.assertArrayEquals(new String[]{"hello", "hello.world"}, deserialized.baz.toArray()); + Assert.assertNotNull(deserialized.qux); + Assert.assertEquals("world", deserialized.qux.get("hello")); + Assert.assertEquals("c.d", deserialized.qux.get("a.b")); + Assert.assertEquals("ttyy", deserialized.qux.get("bar.a")); + Assert.assertEquals("uuzz", deserialized.qux.get("bar.b")); + Assert.assertEquals("hello", deserialized.moreProps); + } + + @Test + public void canSerializeMapKeysWithDotAndSlash() throws Exception { + String serialized = new JacksonAdapter().serialize(prepareSchoolModel(), SerializerEncoding.JSON); + Assert.assertEquals("{\"teacher\":{\"students\":{\"af.B/D\":{},\"af.B/C\":{}}},\"tags\":{\"foo.aa\":\"bar\",\"x.y\":\"zz\"},\"properties\":{\"name\":\"school1\"}}", serialized); + } + + @JsonFlatten + private class School { + @JsonProperty(value = "teacher") + private Teacher teacher; + + @JsonProperty(value = "properties.name") + private String name; + + @JsonProperty(value = "tags") + private Map tags; + + public School withTeacher(Teacher teacher) { + this.teacher = teacher; + return this; + } + + public School withName(String name) { + this.name = name; + return this; + } + + public School withTags(Map tags) { + this.tags = tags; + return this; + } + } + + private class Student { + } + + private class Teacher { + @JsonProperty(value = "students") + private Map students; + + public Teacher withStudents(Map students) { + this.students = students; + return this; + } + } + + private School prepareSchoolModel() { + Teacher teacher = new Teacher(); + + Map students = new HashMap(); + students.put("af.B/C", new Student()); + students.put("af.B/D", new Student()); + + teacher.withStudents(students); + + School school = new School().withName("school1"); + school.withTeacher(teacher); + + Map schoolTags = new HashMap(); + schoolTags.put("foo.aa", "bar"); + schoolTags.put("x.y", "zz"); + + school.withTags(schoolTags); + + return school; + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/JacksonAdapterTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/JacksonAdapterTests.java new file mode 100644 index 0000000000000..4210c974a183c --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/serializer/jackson/JacksonAdapterTests.java @@ -0,0 +1,60 @@ +package com.azure.common.implementation.serializer.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.azure.common.implementation.serializer.SerializerEncoding; +import org.junit.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +public class JacksonAdapterTests { + @Test + public void emptyMap() throws IOException { + final Map map = new HashMap<>(); + final JacksonAdapter serializer = new JacksonAdapter(); + assertEquals("{}", serializer.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)); + } + + @Test + public void mapWithEmptyKeyAndNullValue() throws IOException { + final MapHolder mapHolder = new MapHolder(); + mapHolder.map = new HashMap<>(); + mapHolder.map.put("", null); + + final JacksonAdapter serializer = new JacksonAdapter(); + assertEquals("{\"map\":{\"\":null}}", serializer.serialize(mapHolder, SerializerEncoding.JSON)); + } + + @Test + 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)); + } + + @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)); + } + + private static class MapHolder { + @JsonInclude(content = JsonInclude.Include.ALWAYS) + public Map map = new HashMap<>(); + } +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/util/FlowableUtils.java b/common/azure-common/src/test/java/com/azure/common/implementation/util/FlowableUtils.java new file mode 100644 index 0000000000000..21947c53cea1b --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/util/FlowableUtils.java @@ -0,0 +1,97 @@ +package com.azure.common.implementation.util; + +import org.reactivestreams.Subscription; +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.FlowableSubscriber; +import java.nio.ByteBuffer; + +import java.nio.channels.AsynchronousFileChannel; +import java.nio.channels.CompletionHandler; + +/** + * The methods exposed by this type is based on rx-java publishers. + * We are unable to implement similar utility using reactor.core.publisher.Flux (instead of io.reactivex.Flowable), its seems a bug in Reactor + * TODO: anuchan open a bug in the reactor github repo. Repro can be found here https://github.com/anuchandy/flux-asyncfilechannel + */ +public class FlowableUtils { + //region Utility methods to write Flowable to AsynchronousFileChannel. + /** + * Writes the bytes emitted by a Flowable to an AsynchronousFileChannel. + * + * @param content the Flowable content + * @param outFile the file channel + * @return a Completable which performs the write operation when subscribed + */ + public static Completable writeFile(Flowable content, AsynchronousFileChannel outFile) { + return writeFile(content, outFile, 0); + } + + /** + * Writes the bytes emitted by a Flowable to an AsynchronousFileChannel + * starting at the given position in the file. + * + * @param content the Flowable content + * @param outFile the file channel + * @param position the position in the file to begin writing + * @return a Completable which performs the write operation when subscribed + */ + public static Completable writeFile(Flowable content, AsynchronousFileChannel outFile, long position) { + return Completable.create(emitter -> content.subscribe(new FlowableSubscriber() { + // volatile ensures that writes to these fields by one thread will be immediately visible to other threads. + // An I/O pool thread will write to isWriting and read isCompleted, + // while another thread may read isWriting and write to isCompleted. + volatile boolean isWriting = false; + volatile boolean isCompleted = false; + volatile Subscription subscription; + volatile long pos = position; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + s.request(1); + } + + @Override + public void onNext(ByteBuffer bytes) { + isWriting = true; + outFile.write(bytes, pos, null, onWriteCompleted); + } + + + CompletionHandler onWriteCompleted = new CompletionHandler() { + @Override + public void completed(Integer bytesWritten, Object attachment) { + isWriting = false; + if (isCompleted) { + emitter.onComplete(); + } + //noinspection NonAtomicOperationOnVolatileField + pos += bytesWritten; + subscription.request(1); + } + + @Override + public void failed(Throwable exc, Object attachment) { + subscription.cancel(); + emitter.onError(exc); + } + }; + + @Override + public void onError(Throwable throwable) { + subscription.cancel(); + emitter.onError(throwable); + } + + @Override + public void onComplete() { + isCompleted = true; + if (!isWriting) { + emitter.onComplete(); + } + } + })); + } + //endregion +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/util/FluxUtilTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/util/FluxUtilTests.java new file mode 100644 index 0000000000000..6c30105ca3fe9 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/util/FluxUtilTests.java @@ -0,0 +1,254 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCountUtil; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.Exceptions; +import reactor.test.StepVerifier; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static org.junit.Assert.*; + +public class FluxUtilTests { + + @Test + public void testCanReadSlice() throws IOException { + File file = new File("target/test1"); + FileOutputStream stream = new FileOutputStream(file); + stream.write("hello there".getBytes(StandardCharsets.UTF_8)); + try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ)) { + byte[] bytes = FluxUtil.byteBufStreamFromFile(channel, 1, 3) + .map(bb -> { + byte[] bt = toBytes(bb); + ReferenceCountUtil.release(bb); + return bt; + }) + .collect(() -> new ByteArrayOutputStream(), + (bos, b) -> { + try { + bos.write(b); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }) + .block() + .toByteArray(); + assertEquals("ell", new String(bytes, StandardCharsets.UTF_8)); + } catch (IOException ioe) { + + } + + } + + @Test + public void testCanReadEmptyFile() throws IOException { + File file = new File("target/test2"); + file.createNewFile(); + try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ)) { + byte[] bytes = FluxUtil.byteBufStreamFromFile(channel, 1, 3) + .map(bb -> { + byte[] bt = toBytes(bb); + ReferenceCountUtil.release(bb); + return bt; + }) + .collect(() -> new ByteArrayOutputStream(), + (bos, b) -> { + try { + bos.write(b); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }) + .block().toByteArray(); + assertEquals(0, bytes.length); + } + assertTrue(file.delete()); + } + + @Test + public void testAsynchronyShortInput() throws IOException { + File file = new File("target/test3"); + FileOutputStream stream = new FileOutputStream(file); + stream.write("hello there".getBytes(StandardCharsets.UTF_8)); + try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ)) { + byte[] bytes = FluxUtil.byteBufStreamFromFile(channel) + .map(bb -> { + byte[] bt = toBytes(bb); + ReferenceCountUtil.release(bb); + return bt; + }) + .limitRequest(1) + .subscribeOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .publishOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .collect(() -> new ByteArrayOutputStream(), + (bos, b) -> { + try { + bos.write(b); + } catch (IOException ioe) { + throw Exceptions.propagate(ioe); + } + }) + .block() + .toByteArray(); + assertEquals("hello there", new String(bytes, StandardCharsets.UTF_8)); + } + assertTrue(file.delete()); + } + + private static final int NUM_CHUNKS_IN_LONG_INPUT = 10_000_000; + + @Test + public void testAsynchronyLongInput() throws IOException, NoSuchAlgorithmException { + File file = new File("target/test4"); + byte[] array = "1234567690".getBytes(StandardCharsets.UTF_8); + MessageDigest digest = MessageDigest.getInstance("MD5"); + try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file))) { + for (int i = 0; i < NUM_CHUNKS_IN_LONG_INPUT; i++) { + out.write(array); + digest.update(array); + } + } + System.out.println("long input file size="+ file.length()/(1024*1024) + "MB"); + byte[] expected = digest.digest(); + digest.reset(); + try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ)) { + FluxUtil.byteBufStreamFromFile(channel) + .subscribeOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .publishOn(reactor.core.scheduler.Schedulers.newElastic("io", 30)) + .toIterable().forEach(bb -> { + digest.update(bb.nioBuffer()); + ReferenceCountUtil.release(bb); + }); + + assertArrayEquals(expected, digest.digest()); + } + assertTrue(file.delete()); + } + + @Test + @Ignore("Need to sync with smaldini to find equivalent for rx.test.awaitDone") + public void testBackpressureLongInput() throws IOException, NoSuchAlgorithmException { +// File file = new File("target/test4"); +// byte[] array = "1234567690".getBytes(StandardCharsets.UTF_8); +// MessageDigest digest = MessageDigest.getInstance("MD5"); +// try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file))) { +// for (int i = 0; i < NUM_CHUNKS_IN_LONG_INPUT; i++) { +// out.write(array); +// digest.update(array); +// } +// } +// byte[] expected = digest.digest(); +// digest.reset(); +// +// try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ)) { +// FluxUtil1.byteBufferStreamFromFile(channel) +// .rebatchRequests(1) +// .subscribeOn(Schedulers.io()) +// .observeOn(Schedulers.io()) +// .doOnNext(bb -> digest.update(bb)) +// .test(0) +// .assertNoValues() +// .requestMore(1) +// .awaitCount(1) +// .assertValueCount(1) +// .requestMore(1) +// .awaitCount(2) +// .assertValueCount(2) +// .requestMore(Long.MAX_VALUE) +// .awaitDone(20, TimeUnit.SECONDS) +// .assertComplete(); +// } +// +// assertArrayEquals(expected, digest.digest()); +// assertTrue(file.delete()); + } + + @Test + public void testSplitForMultipleSplitSizesFromOneTo16() throws NoSuchAlgorithmException { + ByteBuf bb = null; + try { + bb = Unpooled.directBuffer(1000); + byte [] oneByte = new byte[1]; + for (int i = 0; i < 1000; i++) { + oneByte[0] = (byte) i; + bb.writeBytes(oneByte); + } + MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(bb.nioBuffer()); + byte[] expected = digest.digest(); + for (int size = 1; size < 16; size++) { + System.out.println("size=" + size); + digest.reset(); + bb.readerIndex(0); + // + FluxUtil.split(bb, 3).doOnNext(b -> digest.update(b.nioBuffer())) + .subscribe(); +// +// StepVerifier.create(FluxUtil1.split(bb, 3).doOnNext(b -> digest.update(b))) +// .expectNextCount(?) // TODO: ? is Unknown. Check with smaldini - what is the Verifier way to ignore all next calls and simply check stream completes? +// .verifyComplete(); +// + assertArrayEquals(expected, digest.digest()); + } + } finally { + if (bb != null) { + bb.release(); + } + } + } + + @Test + public void testSplitOnEmptyContent() { + ByteBuf bb = null; + try { + bb = Unpooled.directBuffer(16); + StepVerifier.create(FluxUtil.split(bb, 3)) + .expectNextCount(0) + .expectComplete() + .verify(); + } finally { + if (bb != null) { + bb.release(); + } + } + } + + @Test + public void toByteArrayWithEmptyByteBuffer() { + assertArrayEquals(new byte[0], FluxUtil.byteBufToArray(Unpooled.wrappedBuffer(new byte[0]))); + } + + @Test + public void toByteArrayWithNonEmptyByteBuffer() { + final ByteBuf byteBuffer = Unpooled.wrappedBuffer(new byte[] { 0, 1, 2, 3, 4 }); + assertEquals(5, byteBuffer.readableBytes()); + final byte[] byteArray = FluxUtil.byteBufToArray(byteBuffer); + assertArrayEquals(new byte[] { 0, 1, 2, 3, 4 }, byteArray); + assertEquals(5, byteBuffer.readableBytes()); + } +// + private static byte[] toBytes(ByteBuf bb) { + byte[] bytes = new byte[bb.readableBytes()]; + bb.readBytes(bytes); + return bytes; + } + +} diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/util/Foo.java b/common/azure-common/src/test/java/com/azure/common/implementation/util/Foo.java new file mode 100644 index 0000000000000..10c0453c05820 --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/util/Foo.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.azure.common.implementation.serializer.JsonFlatten; + +import java.util.List; +import java.util.Map; + +/** + * Class for testing serialization. + */ +@JsonFlatten +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "$type") +@JsonTypeName("foo") +@JsonSubTypes({ + @JsonSubTypes.Type(name = "foochild", value = FooChild.class) +}) +public class Foo { + @JsonProperty(value = "properties.bar") + public String bar; + @JsonProperty(value = "properties.props.baz") + public List baz; + @JsonProperty(value = "properties.props.q.qux") + public Map qux; + @JsonProperty(value = "properties.more\\.props") + public String moreProps; + @JsonProperty(value = "props.empty") + public Integer empty; + @JsonProperty(value = "") + public Map additionalProperties; +} \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/util/FooChild.java b/common/azure-common/src/test/java/com/azure/common/implementation/util/FooChild.java new file mode 100644 index 0000000000000..5e463ad7310db --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/util/FooChild.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for + * license information. + */ + +package com.azure.common.implementation.util; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.azure.common.implementation.serializer.JsonFlatten; + +/** + * Class for testing serialization. + */ +@JsonFlatten +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "$type") +@JsonTypeName("foochild") +public class FooChild extends Foo { +} \ No newline at end of file diff --git a/common/azure-common/src/test/java/com/azure/common/implementation/util/TypeUtilTests.java b/common/azure-common/src/test/java/com/azure/common/implementation/util/TypeUtilTests.java new file mode 100644 index 0000000000000..6717a1d93a16b --- /dev/null +++ b/common/azure-common/src/test/java/com/azure/common/implementation/util/TypeUtilTests.java @@ -0,0 +1,110 @@ +package com.azure.common.implementation.util; + +import org.junit.Assert; +import org.junit.Test; + +import java.lang.reflect.Type; +import java.util.List; + +public class TypeUtilTests { + + @Test + public void testGetClasses() { + Puppy puppy = new Puppy(); + List> classes = TypeUtil.getAllClasses(puppy.getClass()); + Assert.assertEquals(4, classes.size()); + Assert.assertTrue(classes.contains(Puppy.class)); + Assert.assertTrue(classes.contains(Dog.class)); + Assert.assertTrue(classes.contains(Pet.class)); + Assert.assertTrue(classes.contains(Object.class)); + } + + @Test + public void testGetTypeArguments() { + Type[] puppyArgs = TypeUtil.getTypeArguments(Puppy.class); + Type[] dogArgs = TypeUtil.getTypeArguments(Puppy.class.getGenericSuperclass()); + Type[] petArgs = TypeUtil.getTypeArguments(Dog.class.getGenericSuperclass()); + + Assert.assertEquals(0, puppyArgs.length); + Assert.assertEquals(1, dogArgs.length); + Assert.assertEquals(2, petArgs.length); + } + + @Test + public void testGetTypeArgument() { + Type dogArgs = TypeUtil.getTypeArgument(Puppy.class.getGenericSuperclass()); + Assert.assertEquals(Kid.class, dogArgs); + } + + @Test + public void testGetRawClass() { + Type petType = Puppy.class.getSuperclass().getGenericSuperclass(); + Assert.assertEquals(Pet.class, TypeUtil.getRawClass(petType)); + } + + @Test + public void testGetSuperType() { + Type dogType = TypeUtil.getSuperType(Puppy.class); + Type petType = TypeUtil.getSuperType(dogType); + + Type[] arguments = TypeUtil.getTypeArguments(petType); + Assert.assertEquals(2, arguments.length); + Assert.assertEquals(Kid.class, arguments[0]); + Assert.assertEquals(String.class, arguments[1]); + } + + @Test + public void testGetTopSuperType() { + Type petType = TypeUtil.getSuperType(Puppy.class, Pet.class); + + Type[] arguments = TypeUtil.getTypeArguments(petType); + Assert.assertEquals(2, arguments.length); + Assert.assertEquals(Kid.class, arguments[0]); + Assert.assertEquals(String.class, arguments[1]); + } + + @Test + public void testIsTypeOrSubTypeOf() { + Type dogType = TypeUtil.getSuperType(Puppy.class); + Type petType = TypeUtil.getSuperType(dogType); + + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(Puppy.class, dogType)); + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(Puppy.class, Puppy.class)); + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(Puppy.class, petType)); + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(dogType, petType)); + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(dogType, dogType)); + Assert.assertTrue(TypeUtil.isTypeOrSubTypeOf(petType, petType)); + } + + @Test + public void testCreateParameterizedType() { + Type dogType = TypeUtil.getSuperType(Puppy.class); + Type petType = TypeUtil.getSuperType(dogType); + + Type createdType = TypeUtil.createParameterizedType(Pet.class, Kid.class, String.class); + Assert.assertEquals(TypeUtil.getRawClass(petType), TypeUtil.getRawClass(createdType)); + Assert.assertArrayEquals(TypeUtil.getTypeArguments(petType), TypeUtil.getTypeArguments(createdType)); + } + + private static abstract class Pet { + abstract T owner(); + } + + private static class Human { + } + + private static class Kid extends Human { + } + + private static class Dog extends Pet { + private T owner; + + @Override + public T owner() { + return owner; + } + } + + private static class Puppy extends Dog { + } +} diff --git a/common/azure-common/src/test/resources/GetContainerACLs.xml b/common/azure-common/src/test/resources/GetContainerACLs.xml new file mode 100644 index 0000000000000..9d33b4a791449 --- /dev/null +++ b/common/azure-common/src/test/resources/GetContainerACLs.xml @@ -0,0 +1,11 @@ + + + + MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI= + + 2009-09-28T08:49:37.0000000Z + 2009-09-29T08:49:37.0000000Z + rwd + + + \ No newline at end of file diff --git a/common/azure-common/src/test/resources/GetXMLWithAttributes.xml b/common/azure-common/src/test/resources/GetXMLWithAttributes.xml new file mode 100644 index 0000000000000..db2867ef813ca --- /dev/null +++ b/common/azure-common/src/test/resources/GetXMLWithAttributes.xml @@ -0,0 +1,16 @@ + + + + Wake up to WonderWidgets! + + + + Overview + Why WonderWidgets are great + + Who buys WonderWidgets + + diff --git a/common/azure-common/src/test/resources/upload.txt b/common/azure-common/src/test/resources/upload.txt new file mode 100644 index 0000000000000..ff3bb63948b4b --- /dev/null +++ b/common/azure-common/src/test/resources/upload.txt @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog \ No newline at end of file diff --git a/common/build-tools/pom.xml b/common/build-tools/pom.xml new file mode 100644 index 0000000000000..2eba61f473287 --- /dev/null +++ b/common/build-tools/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + com.azure + azure-common-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + azure-common-build-tools + 1.0.0-SNAPSHOT + jar + + Build tools for Azure common Java libraries + This package contains the build tools for Azure Java client common libraries. + https://github.com/Azure/autorest-clientruntime-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + scm:git:https://github.com/Azure/autorest-clientruntime-for-java + scm:git:git@github.com:Azure/autorest-clientruntime-for-java.git + HEAD + + + + UTF-8 + + + + + + microsoft + Microsoft + + + diff --git a/common/build-tools/src/main/resources/checkstyle.xml b/common/build-tools/src/main/resources/checkstyle.xml new file mode 100644 index 0000000000000..ab36c681e1d56 --- /dev/null +++ b/common/build-tools/src/main/resources/checkstyle.xml @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/build-tools/src/main/resources/suppressions.xml b/common/build-tools/src/main/resources/suppressions.xml new file mode 100644 index 0000000000000..16a6bd2e73a98 --- /dev/null +++ b/common/build-tools/src/main/resources/suppressions.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/common/pom.xml b/common/pom.xml new file mode 100644 index 0000000000000..8e1fcb14fd789 --- /dev/null +++ b/common/pom.xml @@ -0,0 +1,206 @@ + + + 4.0.0 + + com.azure + azure-common-parent + 1.0.0-SNAPSHOT + pom + + Azure Common Libraries for Java + This package contains the common libraries Java clients. + https://github.com/Azure/autorest-clientruntime-for-java + + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + repo + + + + + scm:git:https://github.com/Azure/autorest-clientruntime-for-java + scm:git:git@github.com:Azure/autorest-clientruntime-for-java.git + HEAD + + + + 4.1.33.Final + 2.9.4 + UTF-8 + + + + + + microsoft + Microsoft + + + + + + ossrh + Sonatype Snapshots + https://oss.sonatype.org/content/repositories/snapshots/ + true + default + + + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + ${jackson.version} + + + io.reactivex.rxjava2 + rxjava + 2.2.0 + + + org.slf4j + slf4j-api + 1.7.22 + + + io.netty + netty-buffer + ${netty.version} + + + io.netty + netty-handler + ${netty.version} + + + io.netty + netty-handler-proxy + ${netty.version} + + + io.netty + netty-codec-http + ${netty.version} + + + com.microsoft.azure + adal4j + 1.6.1 + + + + org.slf4j + slf4j-simple + 1.7.22 + test + + + junit + junit + 4.12 + test + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 2.17 + + + com.azure + azure-common-build-tools + 1.0.0-SNAPSHOT + + + com.puppycrawl.tools + checkstyle + 6.18 + + + + checkstyle.xml + samedir=build-tools/src/main/resources + suppressions.xml + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.1 + + 1.8 + 1.8 + true + true + -Xlint:unchecked + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.0.0 + + *.implementation.*;*.utils.*;com.microsoft.schemas._2003._10.serialization;*.blob.core.storage + /** +
* Copyright (c) Microsoft Corporation. All rights reserved. +
* Licensed under the MIT License. See License.txt in the project root for +
* license information. +
*/]]>
+
+
+
+ + + + org.apache.maven.plugins + maven-resources-plugin + 2.4.3 + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.18.1 + + + **/Test*.java + **/*Test.java + **/*Tests.java + **/*TestCase.java + + + + + org.apache.maven.plugins + maven-release-plugin + 2.5.2 + + + +
+ + build-tools + azure-common + azure-common-mgmt + azure-common-auth + +
diff --git a/common/versionConfig.csv b/common/versionConfig.csv new file mode 100644 index 0000000000000..3193fca87ba2b --- /dev/null +++ b/common/versionConfig.csv @@ -0,0 +1,5 @@ +com.microsoft.azure.v3,autorest-clientruntime-for-java,2.0.0,2.0.1-SNAPSHOT +com.microsoft.rest.v3,client-runtime,2.0.0,2.0.1-SNAPSHOT +com.microsoft.azure.v3,azure-client-runtime,2.0.0-beta5,2.0.1-SNAPSHOT +com.microsoft.azure.v3,azure-client-authentication,2.0.0-beta5,2.0.1-SNAPSHOT +com.microsoft.azure.v3,autorest-build-tools,2.0.0-beta5,2.0.1-SNAPSHOT diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index ae5d8f24b05bb..131ee6e821ce8 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -40,6 +40,10 @@ known_presence_issues: - ['keyvault/data-plane/azure-keyvault-extensions', '#2847'] - ['keyvault/data-plane/azure-keyvault-webkey', '#2847'] - ['mediaservices/data-plane', '#2847'] + - ['common/build-tools', '#2847'] + - ['common/azure-common', '#2847'] + - ['common/azure-common-auth', '#2847'] + - ['common/azure-common-mgmt', '#2847'] known_content_issues: - ['sdk/template/azure-sdk-template/README.md','has other required sections'] - ['README.md', '#3113'] @@ -53,3 +57,4 @@ known_content_issues: - ['loganalytics/data-plane/README.md', '#3113'] - ['storage/data-plane/README.md', '#3113'] - ['storage/data-plane/swagger/README.md', '#3113'] + - ['common/README.md', '#3113']