fr.gael.dhus.olingo.ODataClient.java Source code

Java tutorial

Introduction

Here is the source code for fr.gael.dhus.olingo.ODataClient.java

Source

/*
 * Data Hub Service (DHuS) - For Space data distribution.
 * Copyright (C) 2013,2014,2015 GAEL Systems
 *
 * This file is part of DHuS software sources.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package fr.gael.dhus.olingo;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;

import org.apache.commons.codec.binary.Base64;
import org.apache.log4j.Logger;
import org.apache.olingo.odata2.api.edm.Edm;
import org.apache.olingo.odata2.api.edm.EdmEntitySet;
import org.apache.olingo.odata2.api.edm.EdmException;
import org.apache.olingo.odata2.api.edm.EdmProperty;
import org.apache.olingo.odata2.api.edm.EdmType;
import org.apache.olingo.odata2.api.edm.EdmTypeKind;
import org.apache.olingo.odata2.api.ep.EntityProvider;
import org.apache.olingo.odata2.api.ep.EntityProviderException;
import org.apache.olingo.odata2.api.ep.EntityProviderReadProperties;
import org.apache.olingo.odata2.api.ep.entry.ODataEntry;
import org.apache.olingo.odata2.api.ep.feed.ODataFeed;
import org.apache.olingo.odata2.api.exception.ODataException;
import org.apache.olingo.odata2.api.rt.RuntimeDelegate;
import org.apache.olingo.odata2.api.uri.PathSegment;
import org.apache.olingo.odata2.api.uri.UriInfo;
import org.apache.olingo.odata2.api.uri.UriNotMatchingException;
import org.apache.olingo.odata2.api.uri.UriParser;
import org.apache.olingo.odata2.api.uri.UriSyntaxException;

/**
 * Manages the connection to an OData service.
 */
public class ODataClient {
    private static final Logger LOGGER = Logger.getLogger(ODataClient.class);

    public static final boolean PRINT_RAW_CONTENT = false;

    private final URI serviceRoot;
    private final String username;
    private final String password;
    private final Edm serviceEDM;
    private final UriParser uriParser;

    /**
     * Creates an ODataClient for the given service.
     * 
     * @param url an URL to an OData service, 
     *    does not have to be the root service URL.
     *    This parameter must follow this syntax :
     *    {@code odata://hostname:port/path/...}
     * 
     * @throws URISyntaxException when the {@code url} parameter is invalid.
     * @throws IOException when the OdataClient fails to contact the server 
     *    at {@code url}.
     * @throws ODataException when no OData service have been found at the 
     *    given url.
     */
    public ODataClient(String url) throws URISyntaxException, IOException, ODataException {
        this(url, null, null);
    }

    /**
     * Creates an OdataClient for the given service
     * and credentials (HTTP Basic authentication).
     * 
     * @param url an URL to an OData service, 
     *    does not have to be the root service URL.
     *    this parameter must follow this syntax :
     *    {@code odata://hostname:port/path/...}
     * @param username Username
     * @param password Password
     * 
     * @throws URISyntaxException when the {@code url} parameter is invalid.
     * @throws IOException when the OdataClient fails to contact the server 
     *    at {@code url}.
     * @throws ODataException when no OData service have been found at the 
     *    given url.
     */
    public ODataClient(String url, String username, String password)
            throws URISyntaxException, IOException, ODataException {
        this.username = username;
        this.password = password;

        // Find the service root URL and retrieve the Entity Data Model (EDM).
        URI uri = new URI(url);
        String metadata = "/$metadata";

        URI svc = null;
        Edm edm = null;

        String[] pathSegments = uri.getPath().split("/");
        StringBuilder sb = new StringBuilder();

        // for each possible service root URL.
        for (int i = 1; i < pathSegments.length; i++) {
            sb.append('/').append(pathSegments[i]).append(metadata);
            svc = new URI(uri.getScheme(), uri.getAuthority(), sb.toString(), null, null);
            sb.delete(sb.length() - metadata.length(), sb.length());

            // Test if `svc` is the service root URL.
            try {
                InputStream content = execute(svc.toString(), ContentType.APPLICATION_XML, "GET");

                edm = EntityProvider.readMetadata(content, false);
                svc = new URI(uri.getScheme(), uri.getAuthority(), sb.toString(), null, null);

                break;
            } catch (HttpException | EntityProviderException e) {
                LOGGER.debug("URL not root " + svc, e);
            }
        }

        // no OData service have been found at the given URL.
        if (svc == null || edm == null)
            throw new ODataException("No service found at " + url);

        this.serviceRoot = svc;
        this.serviceEDM = edm;
        this.uriParser = RuntimeDelegate.getUriParser(edm);
    }

    /**
     * Reads a feed (the content of an EntitySet).
     * 
     * @param resource_path the resource path to the parent of the requested
     *    EntitySet, as defined in {@link #getResourcePath(URI)}.
     * @param query_parameters Query parameters, as defined in {@link URI}.
     * 
     * @return an ODataFeed containing the ODataEntries for the given 
     *    {@code resource_path}.
     * 
     * @throws HttpException if the server emits an HTTP error code.
     * @throws IOException if the connection with the remote service fails.
     * @throws EdmException if the EDM does not contain the given entitySetName.
     * @throws EntityProviderException if reading of data (de-serialization)
     *    fails.
     * @throws UriSyntaxException violation of the OData URI construction rules.
     * @throws UriNotMatchingException URI parsing exception.
     * @throws ODataException encapsulate the OData exceptions described above.
     */
    public ODataFeed readFeed(String resource_path, Map<String, String> query_parameters)
            throws IOException, ODataException {
        if (resource_path == null || resource_path.isEmpty())
            throw new IllegalArgumentException("resource_path must not be null or empty.");

        ContentType contentType = ContentType.APPLICATION_ATOM_XML;

        String absolutUri = serviceRoot.toString() + '/' + resource_path;

        // Builds the query parameters string part of the URL.
        absolutUri = appendQueryParam(absolutUri, query_parameters);

        InputStream content = execute(absolutUri, contentType, "GET");

        return EntityProvider.readFeed(contentType.type(), getEntitySet(resource_path), content,
                EntityProviderReadProperties.init().build());
    }

    /**
     * Reads an entry (an Entity, a property, a complexType, ...).
     * 
     * @param resource_path the resource path to the parent of the requested
     *    EntitySet, as defined in {@link #getResourcePath(URI)}.
     * @param query_parameters Query parameters, as defined in {@link URI}.
     * 
     * @return an ODataEntry for the given {@code resource_path}.
     * 
     * @throws HttpException if the server emits an HTTP error code.
     * @throws IOException if the connection with the remote service fails.
     * @throws EdmException if the EDM does not contain the given entitySetName.
     * @throws EntityProviderException if reading of data (de-serialization)
     *    fails.
     * @throws UriSyntaxException violation of the OData URI construction rules.
     * @throws UriNotMatchingException URI parsing exception.
     * @throws ODataException encapsulate the OData exceptions described above.
     */
    public ODataEntry readEntry(String resource_path, Map<String, String> query_parameters)
            throws IOException, ODataException {
        if (resource_path == null || resource_path.isEmpty())
            throw new IllegalArgumentException("resource_path must not be null or empty.");

        ContentType contentType = ContentType.APPLICATION_ATOM_XML;

        String absolutUri = serviceRoot.toString() + '/' + resource_path;

        // Builds the query parameters string part of the URL.
        absolutUri = appendQueryParam(absolutUri, query_parameters);

        InputStream content = execute(absolutUri, contentType, "GET");

        return EntityProvider.readEntry(contentType.type(), getEntitySet(resource_path), content,
                EntityProviderReadProperties.init().build());
    }

    /**
     * Returns the Entity Data Model (EDM) served by this OData service.
     * @return the schema for this OData service.
     */
    public Edm getSchema() {
        // The class `Edm` is immutable.
        return this.serviceEDM;
    }

    /**
     * Returns an UriParser configured with this service EDM.
     * @return an UriParser.
     */
    public UriParser getUriParser() {
        return this.uriParser;
    }

    /**
     * Returns the service root URL for this OData service.
     * @return the service root URL.
     */
    public String getServiceRoot() {
        return this.serviceRoot.toString();
    }

    /**
     * Returns the resource path relative to this OData root service URL.
     * A resource path is a slash '/' separated list of EntitySets, Entities,
     * Properties, ComplexTypes and Values.<br>
     * 
     * This method works only on the path part of the URI as returned by 
     * {@link URI#getPath()}.<br>
     * 
     * Example: the root service URL is "odata://odata.org/services/address.svc"
     * the passed URI is 
     *    "odata://odata.org/services/address.svc/Contact(33)/PhoneNumber"
     * The result will be "/Contact(33)/PhoneNumber".<br>
     * 
     * As this method only work on the {@code path} part of the URI, the passed
     * URI may just contain a path.
     * Example: "/services/address.svc/Contact(33)/PhoneNumber"
     * 
     * @param uri URI to extract a resource path from.
     * @return the resource path.
     */
    public String getResourcePath(URI uri) {
        if (uri == null)
            throw new IllegalArgumentException("uri must not be null.");

        String uri_path = uri.getPath();
        String svc_path = this.serviceRoot.getPath();
        if (uri_path.startsWith(svc_path)) {
            return uri_path.substring(svc_path.length());
        }
        return null;
    }

    /**
     * Gets the EdmEntitySet for the last segment of the given
     * {@code resource_path}.
     * If the last segment is not an EntitySet or a NavigationProperty, it will
     * return the EntitySet of the previous segment.
     * This method navigate through the EDM to resolve the EntitySet, thus it
     * may be slow.
     * 
     * @param resource_path path to a resource on the OData service.
     * @return An instance of EdmEntitySet for the last EntitySet in the
     *    {@code resource_path}.
     * @throws EdmException if the navigation through the EDM failed.
     * @throws UriSyntaxException violation of the OData URI construction rules.
     * @throws UriNotMatchingException URI parsing exception.
     * @throws ODataException encapsulate the OData exceptions described above.
     */
    public EdmEntitySet getEntitySet(String resource_path) throws ODataException {
        if (resource_path == null || resource_path.isEmpty())
            throw new IllegalArgumentException("resource_path must not be null or empty.");

        return parseRequest(resource_path, null).getTargetEntitySet();
    }

    /**
     * Creates a UriInfo from a resource path and query parameters.
     * The returned object may be one of UriInfo subclasses.
     * 
     * @param resource_path path to a resource on the OData service.
     * @param query_parameters OData query parameters, can be {@code null}
     * 
     * @return an UriInfo instance exposing informations about each segment of
     *    the resource path and the query parameters.
     * 
     * @throws UriSyntaxException violation of the OData URI construction rules.
     * @throws UriNotMatchingException URI parsing exception.
     * @throws EdmException if a problem occurs while reading the EDM.
     * @throws ODataException encapsulate the OData exceptions described above.
     */
    public UriInfo parseRequest(String resource_path, Map<String, String> query_parameters) throws ODataException {
        List<PathSegment> path_segments;

        if (resource_path != null && !resource_path.isEmpty()) {
            path_segments = new ArrayList<>();

            StringTokenizer st = new StringTokenizer(resource_path, "/");

            while (st.hasMoreTokens()) {
                path_segments.add(UriParser.createPathSegment(st.nextToken(), null));
            }
        } else
            path_segments = Collections.emptyList();

        if (query_parameters == null)
            query_parameters = Collections.emptyMap();

        return this.uriParser.parse(path_segments, query_parameters);
    }

    /**
     * Returns the kind of resource the given URI is addressing.
     * It can be the service root or an entity set or an entity or a simple
     * property or a complex property.
     * 
     * @param uri References an OData resource at this service.
     * 
     * @return the kind of resource the given URI is addressing
     *  
     * @throws UriSyntaxException violation of the OData URI construction rules.
     * @throws UriNotMatchingException URI parsing exception.
     * @throws EdmException if a problem occurs while reading the EDM.
     * @throws ODataException encapsulate the OData exceptions described above.
     */
    public resourceKind whatIs(URI uri) throws ODataException {
        if (uri == null)
            throw new IllegalArgumentException("uri must not be null.");

        Map<String, String> query_parameters = null;

        if (uri.getQuery() != null) {
            query_parameters = new HashMap<>();
            StringTokenizer st = new StringTokenizer(uri.getQuery(), "&");

            while (st.hasMoreTokens()) {
                String[] key_val = st.nextToken().split("=", 2);
                if (key_val.length != 2)
                    throw new UriSyntaxException(UriSyntaxException.URISYNTAX);

                query_parameters.put(key_val[0], key_val[1]);
            }
        }

        String resource_path = getResourcePath(uri);

        UriInfo uri_info = parseRequest(resource_path, query_parameters);

        EdmType et = uri_info.getTargetType();
        if (et == null)
            return resourceKind.SERVICE_ROOT;

        EdmTypeKind etk = et.getKind();
        if (etk == EdmTypeKind.ENTITY) {
            if (uri_info.getTargetKeyPredicates().isEmpty())
                return resourceKind.ENTITY_SET;
            return resourceKind.ENTITY;
        }
        if (etk == EdmTypeKind.SIMPLE)
            return resourceKind.SIMPLE_PROPERTY;
        if (etk == EdmTypeKind.COMPLEX)
            return resourceKind.COMPLEX_PROPERTY;

        return resourceKind.UNKNOWN;
    }

    /**
     * Makes the key predicate for the given Entity and EntitySet.
     * 
     * @param entity_set the EntitySet
     * @param entity an entity whose key property values will be used to make
     *    the key predicate.
     * @return a comma separated list of key=value couples.
     * @throws EdmException not likely to happen.
     */
    public String makeKeyPredicate(EdmEntitySet entity_set, ODataEntry entity) throws EdmException {
        if (entity_set == null)
            throw new IllegalArgumentException("entity_set must not be null.");

        if (entity == null)
            throw new IllegalArgumentException("entity must not be null.");

        List<EdmProperty> edm_props = entity_set.getEntityType().getKeyProperties();

        StringBuilder sb = new StringBuilder();

        for (EdmProperty edm_prop : edm_props) {
            String key_prop_name = edm_prop.getName();
            Object key_prop_val = entity.getProperties().get(key_prop_name);

            if (sb.length() > 0)
                sb.append(',');

            sb.append(key_prop_name).append('=');

            if (key_prop_val instanceof String)
                sb.append('\'').append(key_prop_val).append('\'');
            else
                sb.append(key_prop_val);
        }

        return sb.toString();
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((password == null) ? 0 : password.hashCode());
        result = prime * result + serviceRoot.hashCode();
        result = prime * result + ((username == null) ? 0 : username.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        ODataClient other = (ODataClient) obj;
        if (password == null) {
            if (other.password != null)
                return false;
        } else if (!password.equals(other.password))
            return false;
        if (serviceRoot == null) {
            if (other.serviceRoot != null)
                return false;
        } else if (!serviceRoot.equals(other.serviceRoot))
            return false;
        if (username == null) {
            if (other.username != null)
                return false;
        } else if (!username.equals(other.username))
            return false;
        return true;
    }

    /**
     * Builds and appends the query parameter part at the end of the given URL.
     * @param base_url an URL to append query parameters to.
     * @param query_parameters can be {@code null}, see {@link URI}.
     * @return the given URL with its query parameters.
     */
    private String appendQueryParam(String base_url, Map<String, String> query_parameters) {
        if (query_parameters != null && !query_parameters.isEmpty()) {
            StringBuilder sb = new StringBuilder(base_url).append('?');
            for (Map.Entry<String, String> entry : query_parameters.entrySet()) {
                String value = entry.getValue().replaceAll(" ", "%20");
                sb.append(entry.getKey()).append('=').append(value);
                sb.append('&');
            }
            sb.deleteCharAt(sb.length() - 1);

            return sb.toString();
        } else {
            return base_url;
        }
    }

    /**
     * Performs the execution of an OData command through HTTP.
     * 
     * @param absolute_uri The not that relative URI to query.
     * @param content_type The content type can be JSON, XML, Atom+XML,
     *    see {@link OdataContentType}.
     * @param http_method {@code "POST", "GET", "PUT", "DELETE", ...}
     * 
     * @return The response as a stream. You may assume it's UTF-8 encoded.
     * 
     * @throws HttpException if the server emits an HTTP error code.
     * @throws IOException if an error occurred connecting to the server.
     */
    private InputStream execute(String absolute_uri, ContentType content_type, String http_method)
            throws IOException {
        URL url = new URL(absolute_uri);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();

        // HTTP Basic Authentication.
        String userpass = this.username + ":" + this.password;
        String basicAuth = "Basic " + new String(new Base64().encode(userpass.getBytes()));
        connection.setRequestProperty("Authorization", basicAuth);

        // GET, POST, ...
        connection.setRequestMethod(http_method);

        // `Accept` for GET, `Content-Type` for POST and PUT.
        connection.setRequestProperty("Accept", content_type.type());

        connection.connect();

        int resp_code = connection.getResponseCode();
        // 2XX == success, 3XX == redirect (handled by the HTTPUrlConnection)
        if (resp_code == 200) {
            InputStream content = connection.getInputStream();

            content = logRawContent(http_method + " request on uri '" + absolute_uri + "' with content:\n", content,
                    "\n");

            return content;
        } else if (resp_code >= 300 && resp_code < 400) {
            // HttpURLConnection should follow redirections automatically,
            // but won't follow if the protocol changes.
            // See https://bugs.openjdk.java.net/browse/JDK-4620571
            // If the scheme has changed (http -> https) follow the redirection.

            String redi_uri = connection.getHeaderField("Location");

            if (redi_uri == null || redi_uri.isEmpty())
                throw new HttpException(connection.getResponseCode(),
                        connection.getResponseMessage() + " redirection failure.");

            if (!redi_uri.startsWith("https"))
                throw new HttpException(connection.getResponseCode(),
                        connection.getResponseMessage() + " unsecure redirection.");

            LOGGER.debug("Attempting redirection to " + redi_uri);
            connection.disconnect();

            return execute(redi_uri, content_type, http_method);
        } else {
            throw new HttpException(connection.getResponseCode(), connection.getResponseMessage());
        }
    }

    /**
     * Only log content if {@link #PRINT_RAW_CONTENT} is flagged as true.
     * 
     * @param prefix at the beginning of the log line.
     * @param content the content to log.
     * @param suffix at the end of the log line.
     * @return a new InputStream to read from.
     * @throws IOException may occur while reading in {@code content}.
     */
    private InputStream logRawContent(String prefix, InputStream content, String suffix) throws IOException {
        if (PRINT_RAW_CONTENT) {
            byte[] buffer = streamToArray(content);
            // Caution! bad output possible if `content` contains binary data.
            LOGGER.info(prefix + new String(buffer, "UTF-8") + suffix);
            return new ByteArrayInputStream(buffer);
        }
        return content;
    }

    /**
     * Transforms passed stream into array of bytes.
     * 
     * @param stream the stream to read.
     * @return an array of bytes.
     * @throws IOException if an error occurred while reading the stream.
     */
    private byte[] streamToArray(InputStream stream) throws IOException {
        byte[] result = new byte[0];
        byte[] tmp = new byte[8192];
        int readCount;
        while ((readCount = stream.read(tmp)) > 0) {
            byte[] innerTmp = new byte[result.length + readCount];
            System.arraycopy(result, 0, innerTmp, 0, result.length);
            System.arraycopy(tmp, 0, innerTmp, result.length, readCount);
            result = innerTmp;
        }
        stream.close();
        return result;
    }

    /**
     * Signals that an HTTP request failed.
     */
    public static class HttpException extends IOException {
        private static final long serialVersionUID = 1L;

        private final int statusCode;

        /**
         * Constructs an ODataHttpException with the specified HTTP status code.
         * @param status_code HTTP status code
         *    (eg: 500 for internal server error).
         */
        public HttpException(int status_code) {
            this(status_code, null);
        }

        /**
         * Constructs an ODataHttpException with the specified HTTP status code
         * and detail message.
         * @param status_code HTTP status code
         *    (eg: 500 for internal server error).
         * @param message the detail message.
         */
        public HttpException(int status_code, String message) {
            super(message);
            this.statusCode = status_code;
        }

        /**
         * Gets the HTTP status code.
         * @return the HTTP status code.
         */
        public int getStatusCode() {
            return this.statusCode;
        }
    }

    /**
     * Returned by {@link ODataClient#whatIs(URI)}.
     */
    public static enum resourceKind {
        /** Is the service root. */
        SERVICE_ROOT,
        /** Is an entity. */
        ENTITY,
        /** Is an entity set. */
        ENTITY_SET,
        /** Is a simple property. */
        SIMPLE_PROPERTY,
        /** Is a complex property. */
        COMPLEX_PROPERTY,
        /** Unknown, you will probably get an Exception instead of this */
        UNKNOWN;
    }

    /**
     * Enumerates the list of OData supported content types.
     */
    private static enum ContentType {
        /** JSON Encoded EntitySets and Entities. */
        APPLICATION_JSON("application/json"),
        /** XML schema (Entity Data Model), Entities, messages. */
        APPLICATION_XML("application/xml"),
        /** Atom+XML Encoded EntitySets (feeds). */
        APPLICATION_ATOM_XML("application/atom+xml"),
        /** Create/Update requests. */
        APPLICATION_FORM("application/x-www-form-urlencoded");

        private final String contentType;

        private ContentType(String type) {
            this.contentType = type;
        }

        /**
         * To specify the {@code Accept} and/or {@code Content-Type}
         * HTTP Header fields.
         * @return the related content type string.
         */
        public String type() {
            return this.contentType;
        }
    }
}