/* * 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 fr.gael.dhus.util.http.HttpAsyncClientProducer; import fr.gael.dhus.util.http.InterruptibleHttpClient; import fr.gael.dhus.util.http.Timeouts; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; 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.http.HttpResponse; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.config.CookieSpecs; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.nio.client.CloseableHttpAsyncClient; import org.apache.http.impl.nio.client.HttpAsyncClients; import org.apache.logging.log4j.LogManager; import org.apache.logging.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 = LogManager.getLogger(ODataClient.class); private final InterruptibleHttpClient httpClient = new InterruptibleHttpClient(new ClientProducer()); 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 (InterruptedException ex) { 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. * @throws InterruptedException if running thread has been interrupted. */ public ODataFeed readFeed(String resource_path, Map<String, String> query_parameters) throws IOException, ODataException, InterruptedException { 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. * @throws InterruptedException if running thread has been interrupted. */ public ODataEntry readEntry(String resource_path, Map<String, String> query_parameters) throws IOException, ODataException, InterruptedException { 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. * @throws InterruptedException if running thread has been interrupted. */ private InputStream execute (String absolute_uri, ContentType content_type, String http_method) throws IOException, InterruptedException { // FIXME: only 'GET' http method is currently supported HttpGet get = new HttpGet(absolute_uri); // `Accept` for GET, `Content-Type` for POST and PUT. get.addHeader("Accept", content_type.type ()); InterruptibleHttpClient.MemoryIWC mem_iwc = new InterruptibleHttpClient.MemoryIWC(); HttpResponse resp = httpClient.interruptibleRequest(get, mem_iwc); int resp_code = resp.getStatusLine().getStatusCode(); if (resp_code != 200) { throw new HttpException(resp_code, resp.getStatusLine().getReasonPhrase()); } InputStream content = new ByteArrayInputStream(mem_iwc.getBytes()); return content; } /** * 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; } } /** Creates a client producer that produces HTTP Basic auth aware clients. */ class ClientProducer implements HttpAsyncClientProducer { @Override public CloseableHttpAsyncClient generateClient () { CredentialsProvider credsProvider = new BasicCredentialsProvider(); credsProvider.setCredentials(new AuthScope (AuthScope.ANY), new UsernamePasswordCredentials(username, password)); RequestConfig rqconf = RequestConfig.custom() .setCookieSpec(CookieSpecs.DEFAULT) .setSocketTimeout(Timeouts.SOCKET_TIMEOUT) .setConnectTimeout(Timeouts.CONNECTION_TIMEOUT) .setConnectionRequestTimeout(Timeouts.CONNECTION_REQUEST_TIMEOUT) .build(); CloseableHttpAsyncClient res = HttpAsyncClients.custom () .setDefaultCredentialsProvider (credsProvider) .setDefaultRequestConfig(rqconf) .build (); res.start (); return res; } } /** * 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; } } }