org.ctoolkit.restapi.client.adapter.GoogleApiProxyFactory.java Source code

Java tutorial

Introduction

Here is the source code for org.ctoolkit.restapi.client.adapter.GoogleApiProxyFactory.java

Source

/*
 * Copyright (c) 2019 Comvai, s.r.o. All Rights Reserved.
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 */

package org.ctoolkit.restapi.client.adapter;

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpExecuteInterceptor;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.HttpResponseInterceptor;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.PemReader;
import com.google.api.client.util.SecurityUtils;
import com.google.common.base.Charsets;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.eventbus.EventBus;
import org.ctoolkit.restapi.client.ApiCredential;
import org.ctoolkit.restapi.client.provider.AuthKeyProvider;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.MissingResourceException;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.ctoolkit.restapi.client.ApiCredential.CREDENTIAL_ATTR;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_CREDENTIAL_PREFIX;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_NUMBER_OF_RETRIES;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_READ_TIMEOUT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_API_KEY;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_APPLICATION_NAME;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_CLIENT_ID;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_CREDENTIAL_ON;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_DISABLE_GZIP_CONTENT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_ENDPOINT_URL;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_FILE_NAME;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_FILE_NAME_JSON;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_NUMBER_OF_RETRIES;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_PROJECT_ID;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_READ_TIMEOUT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_SCOPES;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_SERVICE_ACCOUNT_EMAIL;

/**
 * The factory to build proxy instance to allow authenticated calls to Google APIs on behalf of the application
 * instead of an end-user by OAuth 2.0.
 * <p>
 * OAuth 2.0 allows users to share specific data with application (for example, contact lists)
 * while keeping their usernames, passwords, and other information private.
 *
 * @author <a href="mailto:aurel.medvegy@ctoolkit.org">Aurel Medvegy</a>
 */
public abstract class GoogleApiProxyFactory {
    protected final EventBus eventBus;

    private final Map<String, String> credential;

    private HttpTransport httpTransport;

    private JsonFactory jsonFactory;

    /**
     * Optional authentication key provider (client responsibility).
     */
    private AuthKeyProvider keyProvider;

    /**
     * Create factory instance.
     */
    protected GoogleApiProxyFactory(@Nonnull Map<String, String> credential, @Nonnull EventBus eventBus) {
        this.credential = credential;
        this.eventBus = eventBus;
    }

    protected void setKeyProvider(AuthKeyProvider keyProvider) {
        this.keyProvider = keyProvider;
    }

    /**
     * Returns singleton instance of the HTTP transport.
     *
     * @return the reusable {@link HttpTransport} instance
     */
    public final HttpTransport getHttpTransport() throws GeneralSecurityException, IOException {
        if (httpTransport == null) {
            httpTransport = GoogleNetHttpTransport.newTrustedTransport();
        }
        return httpTransport;
    }

    /**
     * Returns singleton instance of the JSON factory.
     *
     * @return the reusable {@link JsonFactory} instance
     */
    public final JsonFactory getJsonFactory() {
        if (jsonFactory == null) {
            jsonFactory = JacksonFactory.getDefaultInstance();
        }
        return jsonFactory;
    }

    /**
     * Returns value set by {@link ApiCredential#setProjectId(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the project ID
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public String getProjectId(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_PROJECT_ID);
    }

    /**
     * Returns value set by {@link ApiCredential#setClientId(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the client ID
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public String getClientId(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_CLIENT_ID);
    }

    /**
     * Returns value set by {@link ApiCredential#setDisableGZipContent(boolean)} (boolean)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return true to disable GZip compression. Otherwise HTTP content will be compressed.
     */
    public final boolean isDisableGZipContent(@Nullable String prefix) {
        return getBoolean(PROPERTY_DISABLE_GZIP_CONTENT, prefix);
    }

    /**
     * Returns value set by {@link ApiCredential#setScopes(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the unmodifiable list of API scopes
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public List<String> getScopes(@Nullable String prefix) {
        String values = getStringValue(prefix, PROPERTY_SCOPES);
        if (values == null) {
            return Collections.emptyList();
        }
        @SuppressWarnings("UnstableApiUsage")
        List<String> list = Splitter.on(",").trimResults().omitEmptyStrings().splitToList(values);
        return Collections.unmodifiableList(list);
    }

    /**
     * Returns value set by {@link ApiCredential#setServiceAccountEmail(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the service email
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getServiceAccountEmail(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_SERVICE_ACCOUNT_EMAIL);
    }

    /**
     * Returns value set by {@link ApiCredential#setApplicationName(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the application name
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getApplicationName(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_APPLICATION_NAME);
    }

    /**
     * Returns value set by {@link ApiCredential#setFileName(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the file name path
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getFileName(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_FILE_NAME);
    }

    /**
     * Returns value set by {@link ApiCredential#setFileNameJson(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the file name path
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getFileNameJson(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_FILE_NAME_JSON);
    }

    /**
     * Returns value set by {@link ApiCredential#setApiKey(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the API key
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getApiKey(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_API_KEY);
    }

    /**
     * Returns value set by {@link ApiCredential#setEndpointUrl(String)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the endpoint URL
     * @throws MissingResourceException if default credential was requested and haven't been found
     */
    public final String getEndpointUrl(@Nullable String prefix) {
        return getStringValue(prefix, PROPERTY_ENDPOINT_URL);
    }

    /**
     * Returns value set by {@link ApiCredential#setNumberOfRetries(int)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the number of configured retries
     */
    public int getNumberOfRetries(@Nullable String prefix) {
        return getInteger(PROPERTY_NUMBER_OF_RETRIES, DEFAULT_NUMBER_OF_RETRIES, prefix);
    }

    /**
     * Returns value set by {@link ApiCredential#setRequestReadTimeout(int)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned 20000 (20 seconds).
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the request read timeout in milliseconds
     */
    public int getReadTimeout(@Nullable String prefix) {
        return getInteger(PROPERTY_READ_TIMEOUT, DEFAULT_READ_TIMEOUT, prefix);
    }

    /**
     * Returns the integer value for given property.
     *
     * @param property     the name of the property to retrieve
     * @param defaultValue the default value if no value will be found
     * @param prefix       the prefix used to identify specific credential or null for default
     * @return the integer value
     */
    private int getInteger(@Nonnull String property, @Nonnull String defaultValue, @Nullable String prefix) {
        if (Strings.isNullOrEmpty(prefix)) {
            prefix = DEFAULT_CREDENTIAL_PREFIX;
        }

        String errorMessage = "Property name is expected";
        String value = credential.get(CREDENTIAL_ATTR + prefix + "." + checkNotNull(property, errorMessage));
        if (value == null) {
            value = credential.get(CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + checkNotNull(property));
        }
        if (value == null) {
            value = checkNotNull(defaultValue, "Default value is expected");
        }
        return Integer.valueOf(value);
    }

    /**
     * Returns value set by {@link ApiCredential#setCredentialOn(boolean)}
     * or defined by property file.
     * If specific credential wouldn't not be found, default will be returned.
     *
     * @param prefix the prefix used to identify specific credential or null for default
     * @return the true if credential will be used to authenticate
     */
    public final boolean isCredentialOn(@Nullable String prefix) {
        return getBoolean(PROPERTY_CREDENTIAL_ON, prefix);
    }

    /**
     * Returns the boolean value for given property.
     *
     * @param property the name of the property to retrieve
     * @param prefix   the prefix used to identify specific credential or null for default
     * @return the boolean value
     */
    private boolean getBoolean(@Nonnull String property, @Nullable String prefix) {
        if (Strings.isNullOrEmpty(prefix)) {
            prefix = DEFAULT_CREDENTIAL_PREFIX;
        }

        String fullProperty = CREDENTIAL_ATTR + prefix + "." + property;
        String value = credential.get(fullProperty);

        if (value == null) {
            fullProperty = CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property;
            value = credential.get(fullProperty);
        }

        if (value == null) {
            return false;
        }

        return Boolean.valueOf(value);
    }

    private String getStringValue(String prefix, String property) {
        checkArgument(!Strings.isNullOrEmpty(property));
        if (Strings.isNullOrEmpty(prefix)) {
            prefix = DEFAULT_CREDENTIAL_PREFIX;
        }

        String value = credential.get(CREDENTIAL_ATTR + prefix + "." + property);
        if (value == null) {
            value = credential.get(CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property);
        }

        defaultPropertyValueCheck(prefix, property, value);

        return value;
    }

    private void defaultPropertyValueCheck(String prefix, String property, String value) {
        if (value != null || !DEFAULT_CREDENTIAL_PREFIX.equals(prefix)) {
            return;
        }

        String fullProperty = CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property;
        String className = ApiCredential.class.getName();
        String message = "No value configured for default credential: '" + fullProperty + "'";
        throw new MissingResourceException(message, className, fullProperty);
    }

    /**
     * Creates the thread-safe Google-specific implementation of the OAuth 2.0.
     *
     * @param scopes      the space-separated OAuth scopes to use with the service account flow
     *                    or {@code null} for none.
     * @param userAccount the email address. If you want to impersonate a user account, specify the email address.
     *                    Useful for domain-wide delegation.
     * @return the thread-safe credential instance
     */
    public HttpRequestInitializer authorize(@Nonnull Collection<String> scopes, @Nullable String userAccount,
            @Nonnull String prefix) throws GeneralSecurityException, IOException {
        GoogleCredential googleCredential;
        Collection<String> checkedScopes = checkNotNull(scopes, "Scopes is mandatory");

        if (isJsonConfiguration(checkNotNull(prefix, "API short name is mandatory"))) {
            // json file load right before usage
            InputStream json = getServiceAccountJson(prefix);

            googleCredential = new ConfiguredByJsonGoogleCredential(json, prefix).setTransport(getHttpTransport())
                    .setJsonFactory(getJsonFactory()).setServiceAccountScopes(checkedScopes)
                    .setServiceAccountUser(userAccount).build();
        } else {
            String serviceAccountEmail = getServiceAccountEmail(prefix);
            if (serviceAccountEmail == null) {
                throw new NullPointerException("Missing service account email.");
            }

            // p12 file load right before usage
            URL resource = getServiceAccountPrivateKeyP12Resource(prefix);

            googleCredential = new ConfiguredGoogleCredential(prefix).setTransport(getHttpTransport())
                    .setJsonFactory(getJsonFactory()).setServiceAccountId(serviceAccountEmail)
                    .setServiceAccountScopes(checkedScopes)
                    .setServiceAccountPrivateKeyFromP12File(new File(resource.getPath()))
                    .setServiceAccountUser(userAccount).build();
        }

        return googleCredential;
    }

    public boolean isJsonConfiguration(String prefix) {
        if (keyProvider != null && keyProvider.isConfigured(prefix)) {
            // if defined authentication key takes precedence
            return true;
        }

        try {
            return getFileNameJson(prefix) != null;
        } catch (MissingResourceException e) {
            return false;
        }
    }

    public URL getServiceAccountPrivateKeyP12Resource(String prefix) {
        String fileName = getFileName(prefix);
        return GoogleApiProxyFactory.class.getResource(fileName);
    }

    /**
     * Returns the Google APIs service account key as JSON.
     *
     * @param prefix the prefix used to identify specific credential
     * @return the service account key as JSON
     */
    public InputStream getServiceAccountJson(String prefix) {
        InputStream stream;

        if (keyProvider != null && keyProvider.isConfigured(prefix)) {
            stream = keyProvider.get(prefix);
        } else {
            String fileName = getFileNameJson(prefix);
            stream = GoogleApiProxyFactory.class.getResourceAsStream(fileName);

            if (stream == null) {
                throw new IllegalArgumentException("No file has been found with name '" + fileName + "'");
            }
        }
        return stream;
    }

    public HttpRequestInitializer newRequestConfig(@Nullable String prefix,
            @Nullable HttpResponseInterceptor interceptor) {
        return new RequestConfig(prefix, interceptor);
    }

    private PrivateKey privateKeyFromPkcs8(String privateKeyPem) throws IOException {
        Reader reader = new StringReader(privateKeyPem);
        PemReader.Section section = PemReader.readFirstSectionAndClose(reader, "PRIVATE KEY");
        if (section == null) {
            throw new IOException("Invalid PKCS8 data.");
        }

        byte[] bytes = section.getBase64DecodedBytes();
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(bytes);

        try {
            KeyFactory keyFactory = SecurityUtils.getRsaKeyFactory();
            return keyFactory.generatePrivate(keySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
            throw new IOException("Unexpected exception reading PKCS data", e);
        }
    }

    /**
     * Configure HTTP request right before execution.
     *
     * @param request         the HTTP request
     * @param numberOfRetries the number of configured retries
     * @param readTimeout     the request read timeout in milliseconds
     */
    protected final void configureHttpRequest(@Nonnull HttpRequest request, int numberOfRetries, int readTimeout) {
        request.setNumberOfRetries(numberOfRetries);
        request.setReadTimeout(readTimeout);
    }

    private class RequestConfig implements HttpRequestInitializer {
        private final int numberOfRetries;

        private final int readTimeout;

        private final HttpResponseInterceptor responseInterceptor;

        HttpExecuteInterceptor interceptor = new HttpExecuteInterceptor() {
            @Override
            public void intercept(HttpRequest request) {
                eventBus.post(new BeforeRequestEvent(request));
            }
        };

        private RequestConfig(String prefix, HttpResponseInterceptor responseInterceptor) {
            this.numberOfRetries = getNumberOfRetries(prefix);
            this.readTimeout = getReadTimeout(prefix);
            this.responseInterceptor = responseInterceptor;
        }

        public void initialize(HttpRequest request) {
            configureHttpRequest(request, numberOfRetries, readTimeout);
            request.setInterceptor(interceptor);

            if (responseInterceptor != null) {
                request.setResponseInterceptor(responseInterceptor);
            }
        }
    }

    /**
     * Custom GoogleCredential implementation
     */
    private class ConfiguredGoogleCredential extends GoogleCredential.Builder {
        private final int numberOfRetries;

        private final int readTimeout;

        private ConfiguredGoogleCredential(String prefix) {
            this.numberOfRetries = getNumberOfRetries(prefix);
            this.readTimeout = getReadTimeout(prefix);
        }

        @Override
        public GoogleCredential build() {
            return new GoogleCredential(this) {
                @Override
                public void intercept(HttpRequest request) throws IOException {
                    String authorization = request.getHeaders().getAuthorization();
                    // the authorization header set by facade client has a preference, see Request#authBy(String)
                    if (authorization == null) {
                        super.intercept(request);
                    }
                    eventBus.post(new BeforeRequestEvent(request));
                }

                @Override
                public void initialize(HttpRequest request) throws IOException {
                    super.initialize(request);
                    configureHttpRequest(request, numberOfRetries, readTimeout);
                }
            };
        }
    }

    private class ConfiguredByJsonGoogleCredential extends ConfiguredGoogleCredential {
        ConfiguredByJsonGoogleCredential(InputStream jsonStream, String prefix) throws IOException {
            super(prefix);
            JsonObjectParser parser = new JsonObjectParser(GoogleApiProxyFactory.this.getJsonFactory());
            GenericJson fileContents = parser.parseAndClose(jsonStream, Charsets.UTF_8, GenericJson.class);

            String clientId = (String) fileContents.get("client_id");
            String clientEmail = (String) fileContents.get("client_email");
            String privateKeyPem = (String) fileContents.get("private_key");
            String privateKeyId = (String) fileContents.get("private_key_id");

            if (clientId == null || clientEmail == null || privateKeyPem == null || privateKeyId == null) {
                throw new IOException("Error reading service account credential from stream, "
                        + "expecting  'client_id', 'client_email', 'private_key' and 'private_key_id'.");
            }

            PrivateKey privateKey = privateKeyFromPkcs8(privateKeyPem);

            // setup credential from json
            setServiceAccountId(clientEmail);
            setServiceAccountPrivateKey(privateKey);
            setServiceAccountPrivateKeyId(privateKeyId);

            String tokenUri = (String) fileContents.get("token_uri");
            if (tokenUri != null) {
                setTokenServerEncodedUrl(tokenUri);
            }
            String projectId = (String) fileContents.get("project_id");
            if (projectId != null) {
                setServiceAccountProjectId(projectId);
            }
        }
    }
}