/**
* This work was created by the National Center for Ecological Analysis and Synthesis
* at the University of California Santa Barbara (UCSB).
*
* Copyright 2011-2014 Regents of the University of California
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package edu.ucsb.nceas.ezid;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicAuthCache;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.HttpParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.util.EntityUtils;
import edu.ucsb.nceas.ezid.profile.InternalProfile;
/**
* EZIDService provides access to the EZID identifier service maintained by the
* California Digital Library (EZID).
* The service includes methods for creating identifiers using several different
* standards such as DOI, ARK, and others. To use the service, you must have an
* account with the EZID service, which is first used to login to the service. Once
* successfully authenticated, calls can be made to create an identifier, to mint an
* identifier (same as create, but the EZID service creates a random identifier), to
* delete an identifier, or to get or set the metadata associated with an identifier.
*
* A typical interaction might proceed as follows:
*
* {@code
* try {
* EZIDService ezid = new EZIDService();
* String newId = "";
* ezid.login("username", "password");
* newId = ezid.mintIdentifier("doi:10.5072/FK2", null);
* HashMap metadata = new HashMap();
* metadata.put("_target", "http://example.com/some/target/resource");
* newId = ezid.createIdentifier("doi:10.5072/FK2/TEST/10101", metadata);
* metadata = ezid.getMetadata(newId);
* HashMap moreMetadata = new HashMap();
* moreMetadata.put("datacite.title", "This is a test identifier");
* ezid.setMetadata(newId, moreMetadata);
* } catch (EZIDException e) {
* // Handle the error
* }
* }
*
*
* @author Matthew Jones, NCEAS, UC Santa Barbara
*/
public class EZIDService
{
private static final int GET = 1;
private static final int PUT = 2;
private static final int POST = 3;
private static final int DELETE = 4;
private static final int CONNECTIONS_PER_ROUTE = 8;
private String serviceBaseUrl = "https://ezid.cdlib.org/";
private String loginServiceEndpoint = null;
private String logoutServiceEndpoint = null;
private String idServiceEndpoint = null;
private String mintServiceEndpoint = null;
private CloseableHttpClient httpclient = null;
protected static Log log = LogFactory.getLog(EZIDService.class);
/**
* Construct an EZIDService to be used to access EZID.
* @param serviceBaseUrl
* Configure the service to use a specific EZID instance.
* In the past, EZID has made available a testing or staging server
* like http://n2t-stage.cdlib.org/ezid
*/
public EZIDService(String baseUrl) {
httpclient = createThreadSafeClient();
// use override if provided
if (baseUrl != null) {
serviceBaseUrl = baseUrl;
}
loginServiceEndpoint = serviceBaseUrl + "/login";
logoutServiceEndpoint = serviceBaseUrl + "/logout";
idServiceEndpoint = serviceBaseUrl + "/id";
mintServiceEndpoint = serviceBaseUrl + "/shoulder";
}
/**
* Default EZID service constructor uses default service base URL
*/
public EZIDService() {
this(null);
}
/**
* Log into the EZID service using account credentials provided by EZID. The cookie
* returned by EZID is cached in a local CookieStore for the duration of the EZIDService,
* and so subsequent calls uning this instance of the service will function as
* fully authenticated. An exception is thrown if authentication fails.
* @param username to identify the user account from EZID
* @param password the secret password for this account
* @throws EZIDException if authentication fails for any reason
*/
public void login(String username, String password) throws EZIDException {
try {
URI serviceUri = new URI(loginServiceEndpoint);
HttpHost targetHost = new HttpHost(serviceUri.getHost(), serviceUri.getPort(), serviceUri.getScheme());
CredentialsProvider credsProvider = new BasicCredentialsProvider();
credsProvider.setCredentials(
new AuthScope(targetHost.getHostName(), targetHost.getPort()),
new UsernamePasswordCredentials(username, password));
AuthCache authCache = new BasicAuthCache();
BasicScheme basicAuth = new BasicScheme();
authCache.put(targetHost, basicAuth);
HttpClientContext localcontext = HttpClientContext.create();
localcontext.setAuthCache(authCache);
localcontext.setCredentialsProvider(credsProvider);
ResponseHandler handler = new ResponseHandler() {
public byte[] handleResponse(HttpResponse response) throws ClientProtocolException, IOException {
HttpEntity entity = response.getEntity();
if (entity != null) {
return EntityUtils.toByteArray(entity);
} else {
return null;
}
}
};
byte[] body = null;
HttpGet httpget = new HttpGet(loginServiceEndpoint);
body = httpclient.execute(httpget, handler, localcontext);
String message = new String(body);
String msg = parseIdentifierResponse(message);
} catch (URISyntaxException e) {
throw new EZIDException(e.getMessage());
} catch (ClientProtocolException e) {
throw new EZIDException(e.getMessage());
} catch (IOException e) {
throw new EZIDException(e.getMessage());
}
}
/**
* Log out of the EZID service, invalidating the current session.
*/
public void logout() throws EZIDException {
String ezidEndpoint = logoutServiceEndpoint;
byte[] response = sendRequest(GET, ezidEndpoint);
String message = new String(response);
String msg = parseIdentifierResponse(message);
}
/**
* Request that an identifier be created in the EZID system. The identifier
* must be one of the identifier types supported by EZID, such as ARK, DOI,
* or URN, and for each type accounts may only create identifiers with prefixes
* that are authorized for their EZID account. For example, all identifiers
* created by an account might need to start with the string "doi:10.5072/FK2", so
* a request to create "doi:10.5072/FK2/MYID1" might succeed whereas a request
* to create "doi:10.5072/MA/MYID1" might fail, depending on the account. Metadata
* elements can be passed as a HashMap and will be added when the identifier is created.
* To omit setting metadata, pass 'null' as the metadata parameter. To have EZID
* generate a unique ID itself, @see {@link edu.ucsb.nceas.ezid.EZIDService#mintIdentifier(String, HashMap)}
*
* @param identifier to be created
* @param metadata a HashMap containing name/value pairs to be associated with the identifier
* @return String identifier that was created
* @throws EZIDException if an error occurs while creating the identifier
*/
public String createIdentifier(String identifier, HashMap metadata) throws EZIDException {
String newId = null;
String ezidEndpoint = idServiceEndpoint + "/" + identifier;
String anvl = serializeAsANVL(metadata);
byte[] response = sendRequest(PUT, ezidEndpoint, anvl);
String responseMsg = new String(response);
log.debug(responseMsg);
return parseIdentifierResponse(responseMsg);
}
/**
* Create a new, unique, opaque identifier by requesting EZID to generate the
* identifier itself within the "shoulder" prefix that is provided. Each EZID account
* is authorized to mint identifiers that start with certain prefixes, called 'shoulders'
* by EZID. The identifiers created are guaranteed unique within the EZID service. Metadata
* elements can be passed as a HashMap and will be added when the identifier is created.
* To omit setting metadata, pass 'null' as the metadata parameter.
*
* @param shoulder to be used to prefix the identifier
* @param metadata a HashMap containing name/value pairs to be associated with the identifier
* @return String identifier that was created
* @throws EZIDException if an error occurs while minting the identifier
*/
public String mintIdentifier(String shoulder, HashMap metadata) throws EZIDException {
String ezidEndpoint = mintServiceEndpoint + "/" + shoulder;
String anvl = serializeAsANVL(metadata);
byte[] response = sendRequest(POST, ezidEndpoint, anvl);
String responseMsg = new String(response);
log.debug(responseMsg);
return parseIdentifierResponse(responseMsg);
}
/**
* Return a HashMap containing the EZID metadata associated with an identifier as
* a set of name/value pairs. Each key and associated value in the HashMap
* represents a single metadata property.
* @param identifier for which metadata should be returned
* @return HashMap of name/value pairs of metadata properties
* @throws EZIDException if EZID produces an error during the service call
*/
public HashMap getMetadata(String identifier) throws EZIDException {
String ezidEndpoint = idServiceEndpoint + "/" + identifier;
byte [] response = sendRequest(GET, ezidEndpoint);
String anvl = new String(response);
HashMap metadata = new HashMap();
for (String l : anvl.split("[\\r\\n]+")) {
String[] kv = l.split(":", 2);
String key = unescape(kv[0]).trim();
String value = unescape(kv[1]).trim();
// report the error
if (key.equals(InternalProfile.ERROR.toString())) {
throw new EZIDException(value);
}
metadata.put(key, value);
}
return metadata;
}
/**
* Set a series of metadata properties for the given identifier. Metadata are
* passed in as a HashMap representing name/value pairs. EZID defines a set of
* keys to be used to associate metadata values with certain namespaces, such
* as the 'datacite', 'dc', and other namespaces. For example, the 'datacite.title'
* property contains the title of the resource that is identified.
* @param identifier of the resource for which metadata is being set
* @param metadata HashMap containing name/value metadata pairs
* @throws EZIDException if the EZID service returns an error on setting metadata
*/
public void setMetadata(String identifier, HashMap metadata) throws EZIDException {
String ezidEndpoint = idServiceEndpoint + "/" + identifier;
String anvl = serializeAsANVL(metadata);
byte[] response = sendRequest(POST, ezidEndpoint, anvl);
String responseMsg = new String(response);
log.debug(responseMsg);
String modifiedId = parseIdentifierResponse(responseMsg);
}
/**
* Delete an identifier from EZID. This should be an unusual operation, and is
* only possible for identifiers that have been reserved but not yet made public (such
* as an internal, temporary identifier). Identifiers for which the internal "_status"
* metadata field is set to "public" can not be deleted.
* @param identifier to be deleted
* @throws EZIDException if the delete operation fails with an error from EZID
*/
public void deleteIdentifier(String identifier) throws EZIDException {
String ezidEndpoint = idServiceEndpoint + "/" + identifier;
byte[] response = sendRequest(DELETE, ezidEndpoint);
String responseMsg = new String(response);
String deletedId = parseIdentifierResponse(responseMsg);
}
/**
* Generate an HTTP Client for communicating with web services that is
* thread safe and can be used in the context of a multi-threaded application.
* @return DefaultHttpClient
*/
private static CloseableHttpClient createThreadSafeClient() {
BasicCookieStore cookieStore = new BasicCookieStore();
PoolingHttpClientConnectionManager poolingConnManager = new PoolingHttpClientConnectionManager();
CloseableHttpClient client = HttpClients.custom().setConnectionManager(poolingConnManager).setDefaultCookieStore(cookieStore).build();
poolingConnManager.setMaxTotal(5);
poolingConnManager.setDefaultMaxPerRoute(CONNECTIONS_PER_ROUTE);
return client;
}
/**
* Send an HTTP request to the EZID service without a request body.
* @param requestType the type of the service as an integer
* @param uri endpoint to be accessed in the request
* @return byte[] containing the response body
*/
private byte[] sendRequest(int requestType, String uri) throws EZIDException {
return sendRequest(requestType, uri, null);
}
/**
* Send an HTTP request to the EZID service with a request body (for POST and PUT requests).
* @param requestType the type of the service as an integer
* @param uri endpoint to be accessed in the request
* @param requestBody the String body to be encoded into the body of the request
* @return byte[] containing the response body
*/
private byte[] sendRequest(int requestType, String uri, String requestBody) throws EZIDException {
HttpUriRequest request = null;
log.debug("Trying uri: " + uri);
switch (requestType) {
case GET:
request = new HttpGet(uri);
break;
case PUT:
request = new HttpPut(uri);
if (requestBody != null && requestBody.length() > 0) {
StringEntity myEntity = new StringEntity(requestBody, "UTF-8");
((HttpPut) request).setEntity(myEntity);
}
break;
case POST:
request = new HttpPost(uri);
if (requestBody != null && requestBody.length() > 0) {
StringEntity myEntity = new StringEntity(requestBody, "UTF-8");
((HttpPost) request).setEntity(myEntity);
}
break;
case DELETE:
request = new HttpDelete(uri);
break;
default:
throw new EZIDException("Unrecognized HTTP method requested.");
}
request.addHeader("Accept", "text/plain");
ResponseHandler handler = new ResponseHandler() {
public byte[] handleResponse(
HttpResponse response) throws ClientProtocolException, IOException {
HttpEntity entity = response.getEntity();
if (entity != null) {
return EntityUtils.toByteArray(entity);
} else {
return null;
}
}
};
byte[] body = null;
try {
body = httpclient.execute(request, handler);
} catch (ClientProtocolException e) {
throw new EZIDException(e.getMessage());
} catch (IOException e) {
throw new EZIDException(e.getMessage());
}
return body;
}
/**
* Parse the response from EZID and extract out the identifier that is returned
* as part of the 'success' message.
* @param responseMsg the response from EZID
* @return the identifier from the message
* @throws EZIDException if the response contains an error message
*/
private String parseIdentifierResponse(String responseMsg) throws EZIDException {
String newId;
String[] responseArray = responseMsg.split(":", 2);
String resultCode = unescape(responseArray[0]).trim();
if (resultCode.equals(InternalProfile.SUCCESS.toString())) {
String idList[] = (unescape(responseArray[1]).trim()).split("\\|");
newId = idList[0].trim();
return newId;
} else {
String msg = unescape(responseArray[1]).trim();
throw new EZIDException(msg);
}
}
/**
* Serialize a HashMap of metadata name/value pairs as an ANVL String value. If the
* HashMap is null, or if it has no entries, then return a null string.
* @param metadata the Map of metadata name/value pairs
* @return an ANVL serialize String
*/
private String serializeAsANVL(HashMap metadata) {
StringBuffer buffer = new StringBuffer();
if (metadata != null && metadata.size() > 0) {
for (Map.Entry entry : metadata.entrySet()) {
buffer.append(escape(entry.getKey()) + ": " + escape(entry.getValue()) + "\n");
}
}
String anvl = null;
if (buffer != null) {
anvl = buffer.toString();
}
return anvl;
}
/**
* Escape a string to produce it's ANVL escaped equivalent.
* @param str the string to be escaped
* @return the escaped String
*/
private String escape(String str) {
return str.replace("%", "%25").replace("\n", "%0A").replace("\r", "%0D").replace(":", "%3A");
}
/**
* Unescape a percent encoded response from the server.
* @param str the string to be unescaped
* @return the unescaped String value
*/
private String unescape (String str) {
StringBuffer buffer = new StringBuffer();
int i;
while ((i = str.indexOf("%")) >= 0) {
buffer.append(str.substring(0, i));
buffer.append((char) Integer.parseInt(str.substring(i+1, i+3), 16));
str = str.substring(i+3);
}
buffer.append(str);
return buffer.toString();
}
}