Java tutorial
/* * 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; } } }