/**
* =============================================================================
*
* ORCID (R) Open Source
* http://orcid.org
*
* Copyright (c) 2012-2014 ORCID, Inc.
* Licensed under an MIT-Style License (MIT)
* http://orcid.org/open-source-license
*
* This copyright and license information (including a link to the full license)
* shall be included in its entirety in all copies or substantial portion of
* the software.
*
* =============================================================================
*/
package org.orcid.api.common.writer.rdf;
import static org.orcid.core.api.OrcidApiConstants.APPLICATION_RDFXML;
import static org.orcid.core.api.OrcidApiConstants.TEXT_N3;
import static org.orcid.core.api.OrcidApiConstants.TEXT_TURTLE;
import static org.orcid.core.api.OrcidApiConstants.JSON_LD;
import static org.orcid.core.api.OrcidApiConstants.N_TRIPLES;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.List;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.MessageBodyWriter;
import javax.ws.rs.ext.Provider;
import javax.xml.datatype.XMLGregorianCalendar;
import org.orcid.api.common.writer.rdf.vocabs.Geonames;
import org.orcid.api.common.writer.rdf.vocabs.PAV;
import org.orcid.api.common.writer.rdf.vocabs.PROV;
import org.orcid.jaxb.model.message.Address;
import org.orcid.jaxb.model.message.Biography;
import org.orcid.jaxb.model.message.ContactDetails;
import org.orcid.jaxb.model.message.CreationMethod;
import org.orcid.jaxb.model.message.Email;
import org.orcid.jaxb.model.message.ErrorDesc;
import org.orcid.jaxb.model.message.OrcidBio;
import org.orcid.jaxb.model.message.OrcidHistory;
import org.orcid.jaxb.model.message.OrcidMessage;
import org.orcid.jaxb.model.message.OrcidProfile;
import org.orcid.jaxb.model.message.PersonalDetails;
import org.orcid.jaxb.model.message.ResearcherUrl;
import org.orcid.jaxb.model.message.ResearcherUrls;
import org.springframework.beans.factory.annotation.Value;
import org.apache.jena.datatypes.xsd.XSDDatatype;
import org.apache.jena.ontology.Individual;
import org.apache.jena.ontology.OntModel;
import org.apache.jena.rdf.model.Literal;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.rdf.model.Property;
import org.apache.jena.rdf.model.ResIterator;
/**
* @author Stian Soiland-Reyes
*/
@Provider
@Produces({ APPLICATION_RDFXML, TEXT_TURTLE, TEXT_N3, JSON_LD, N_TRIPLES })
public class RDFMessageBodyWriter implements MessageBodyWriter<OrcidMessage> {
/**
* Extension of Jena's outdated FOAF vocabulary
*
*/
public static class FOAF extends org.apache.jena.sparql.vocabulary.FOAF {
/** The RDF model that holds the vocabulary terms */
private static Model m_model = ModelFactory.createDefaultModel();
/** The namespace of the vocabulary as a string< */
public static final String NS = "http://xmlns.com/foaf/0.1/";
// The properties below are from:
// FOAF Vocabulary Specification 0.99
// http://xmlns.com/foaf/spec/20140114.html
// .. which seems to be missing from Jena's FOAF
/** Indicates an account held by this agent.< */
public static final Property account = m_model.createProperty( NS + "account" );
/** The given name of some person. */
public static final Property givenName = m_model.createProperty( "http://xmlns.com/foaf/0.1/givenName" );
/** The family_name of some person. */
public static final Property familyName = m_model.createProperty( "http://xmlns.com/foaf/0.1/familyName" );
}
private static final String COUNTRIES_TTL = "countries.ttl";
private static final String MEMBER_API = "https://api.orcid.org/";
private static final String EN = "en";
private static final List<String> URL_NAME_HOMEPAGE = Arrays.asList("homepage", "home", "home page", "personal", "personal homepage", "personal home page");
private static final String URL_NAME_FOAF = "foaf";
private static final String URL_NAME_WEBID = "webid";
private static OntModel countries;
@Value("${org.orcid.core.baseUri:http://orcid.org}")
private String baseUri = "http://orcid.org";
@Context
private UriInfo uriInfo;
/**
* Ascertain if the MessageBodyWriter supports a particular type.
*
*
* @param type
* the class of object that is to be written.
* @param genericType
* the type of object to be written, obtained either by
* reflection of a resource method return type or via inspection
* of the returned instance.
* {@link javax.ws.rs.core.GenericEntity} provides a way to
* specify this information at runtime.
* @param annotations
* an array of the annotations on the resource method that
* returns the object.
* @param mediaType
* the media type of the HTTP entity.
* @return true if the type is supported, otherwise false.
*/
@Override
public boolean isWriteable(Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {
return OrcidMessage.class.isAssignableFrom(type);
}
/**
* Called before <code>writeTo</code> to ascertain the length in bytes of
* the serialized form of <code>t</code>. A non-negative return value is
* used in a HTTP <code>Content-Length</code> header.
*
* @param message
* the instance to write
* @param type
* the class of object that is to be written.
* @param genericType
* the type of object to be written, obtained either by
* reflection of a resource method return type or by inspection
* of the returned instance.
* {@link javax.ws.rs.core.GenericEntity} provides a way to
* specify this information at runtime.
* @param annotations
* an array of the annotations on the resource method that
* returns the object.
* @param mediaType
* the media type of the HTTP entity.
* @return length in bytes or -1 if the length cannot be determined in
* advance
*/
@Override
public long getSize(OrcidMessage message, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {
// TODO: Can we calculate the size in advance?
// It would mean buffering up the actual RDF
return -1;
}
/**
* Write a type to an HTTP response. The response header map is mutable but
* any changes must be made before writing to the output stream since the
* headers will be flushed prior to writing the response body.
*
* @param message
* the instance to write.
* @param type
* the class of object that is to be written.
* @param genericType
* the type of object to be written, obtained either by
* reflection of a resource method return type or by inspection
* of the returned instance.
* {@link javax.ws.rs.core.GenericEntity} provides a way to
* specify this information at runtime.
* @param annotations
* an array of the annotations on the resource method that
* returns the object.
* @param mediaType
* the media type of the HTTP entity.
* @param httpHeaders
* a mutable map of the HTTP response headers.
* @param entityStream
* the {@link java.io.OutputStream} for the HTTP entity. The
* implementation should not close the output stream.
* @throws java.io.IOException
* if an IO error arises
* @throws javax.ws.rs.WebApplicationException
* if a specific HTTP error response needs to be produced. Only
* effective if thrown prior to the response being committed.
*/
@Override
public void writeTo(OrcidMessage xml, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders,
OutputStream entityStream) throws IOException, WebApplicationException {
OntModel m = getOntModel();
if (xml.getErrorDesc() != null) {
describeError(xml.getErrorDesc(), m);
}
OrcidProfile orcidProfile = xml.getOrcidProfile();
// System.out.println(httpHeaders);
Individual profileDoc = null;
if (orcidProfile != null) {
Individual person = describePerson(orcidProfile, m);
if (person != null) {
profileDoc = describeAccount(orcidProfile, m, person);
}
}
MediaType jsonLd = new MediaType("application", "ld+json");
MediaType nTriples = new MediaType("application", "n-triples");
MediaType rdfXml = new MediaType("application", "rdf+xml");
String base = null;
if (getUriInfo() != null) {
getUriInfo().getAbsolutePath().toASCIIString();
}
if (mediaType.isCompatible(nTriples)) {
// NOTE: N-Triples requires absolute URIs
m.write(entityStream, "N-TRIPLES");
}
else if (mediaType.isCompatible(jsonLd)) {
m.write(entityStream, "JSON-LD", base);
}
else if (mediaType.isCompatible(rdfXml)) {
m.write(entityStream, "RDF/XML", base);
} else {
// Turtle is the safest default
m.write(entityStream, "TURTLE", base);
}
}
protected void describeError(ErrorDesc errorDesc, OntModel m) {
String error = errorDesc.getContent();
Individual root = m.createIndividual(m.createResource());
root.setLabel("Error", EN);
root.setComment(error, EN);
}
private Individual describeAccount(OrcidProfile orcidProfile, OntModel m, Individual person) {
String orcidURI = orcidProfile.getOrcidIdentifier().getUri();
String orcidPublicationsUri = orcidURI + "#workspace-works";
Individual publications = m.createIndividual(orcidPublicationsUri, FOAF.Document);
// list of publications
// (anchor in the HTML rendering - foaf:publications goes to a foaf:Document - not to an
// RDF list of publications - although we should probably also have that)
person.addProperty(FOAF.publications, publications);
String orcidAccountUri = orcidURI + "#orcid-id";
Individual account = m.createIndividual(orcidAccountUri, FOAF.OnlineAccount);
person.addProperty(FOAF.account, account);
Individual webSite = null;
if (baseUri != null) {
webSite = m.createIndividual(baseUri, null);
account.addProperty(FOAF.accountServiceHomepage, webSite);
}
String orcId = orcidProfile.getOrcidIdentifier().getPath();
account.addProperty(FOAF.accountName, orcId);
account.addLabel(orcId, null);
// The current page is the foaf:PersonalProfileDocument - this assumes
// we have done a 303 See Other redirect to the RDF resource, so that it
// differs from the ORCID uri.
// for example:
//
// GET http://orcid.org/0000-0003-4654-1403
// Accept: text/turtle
//
// HTTP/1.1 303 See Other
// Location: https://pub.orcid.org/experimental_rdf_v1/0000-0001-9842-9718
String profileUri;
if (getUriInfo() != null) {
profileUri = getUriInfo().getAbsolutePath().toASCIIString();
} else {
// Some kind of fallback, although the PersonalProfiledocument should be an
// information resource without #anchor
profileUri = orcidURI + "#personalProfileDocument";
}
Individual profileDoc = m.createIndividual(profileUri,
FOAF.PersonalProfileDocument);
profileDoc.addProperty(FOAF.primaryTopic, person);
OrcidHistory history = orcidProfile.getOrcidHistory();
if (history != null) {
if (history.isClaimed().booleanValue()) {
// Set account as PersonalProfileDocument
profileDoc.addProperty(FOAF.maker, person);
}
// Who made the profile?
switch (history.getCreationMethod()) {
case DIRECT:
case MEMBER_REFERRED:
case WEBSITE:
profileDoc.addProperty(PAV.createdBy, person);
profileDoc.addProperty(PROV.wasAttributedTo, person);
if (webSite != null &&
(history.getCreationMethod() == CreationMethod.WEBSITE || history.getCreationMethod() == CreationMethod.DIRECT)) {
profileDoc.addProperty(PAV.createdWith, webSite);
}
break;
case API:
Individual api = m.createIndividual(MEMBER_API, PROV.SoftwareAgent);
profileDoc.addProperty(PAV.importedBy, api);
if (history.isClaimed().booleanValue()) {
profileDoc.addProperty(PAV.curatedBy, person);
}
break;
default:
// Some unknown agent!
profileDoc.addProperty(PAV.createdWith, m.createIndividual(null, PROV.Agent));
}
if (history.getLastModifiedDate() != null) {
Literal when = calendarAsLiteral(history.getLastModifiedDate().getValue(), m);
profileDoc.addLiteral(PAV.lastUpdateOn, when);
profileDoc.addLiteral(PROV.generatedAtTime, when);
}
if (history.getSubmissionDate() != null) {
profileDoc.addLiteral(PAV.createdOn, calendarAsLiteral(history.getSubmissionDate().getValue(), m));
}
if (history.getCompletionDate() != null) {
profileDoc.addLiteral(PAV.contributedOn, calendarAsLiteral(history.getCompletionDate().getValue(), m));
}
if (history.getDeactivationDate() != null) {
profileDoc.addLiteral(PROV.invalidatedAtTime, calendarAsLiteral(history.getDeactivationDate().getValue(), m));
}
}
return profileDoc;
}
private Literal calendarAsLiteral(XMLGregorianCalendar cal, OntModel m) {
return m.createTypedLiteral(cal.toXMLFormat(), XSDDatatype.XSDdateTime);
}
private Individual describePerson(OrcidProfile orcidProfile, OntModel m) {
String orcidUri = orcidProfile.getOrcidIdentifier().getUri();
Individual person = m.createIndividual(orcidUri, FOAF.Person);
person.addRDFType(PROV.Person);
if (orcidProfile.getOrcidBio() == null) {
return person;
}
OrcidBio orcidBio = orcidProfile.getOrcidBio();
if (orcidBio == null) {
return person;
}
describePersonalDetails(orcidBio.getPersonalDetails(), person, m);
describeContactDetails(orcidBio.getContactDetails(), person, m);
describeBiography(orcidBio.getBiography(), person, m);
describeResearcherUrls(orcidBio.getResearcherUrls(), person, m);
return person;
}
private void describeResearcherUrls(ResearcherUrls researcherUrls, Individual person, OntModel m) {
if (researcherUrls == null || researcherUrls.getResearcherUrl() == null) {
return;
}
for (ResearcherUrl url : researcherUrls.getResearcherUrl()) {
Individual page = m.createIndividual(url.getUrl().getValue(), null);
String urlName = getUrlName(url);
if (isHomePage(urlName)) {
person.addProperty(FOAF.homepage, page);
} else if (isFoaf(urlName)) {
// TODO: What if we want to link to the URL of the other FOAF
// *Profile*?
// Note: We don't dear here to do owl:sameAs or
// prov:specializationOf as we don't know the extent of the
// other FOAF profile - we'll
// suffice to say it's an alternate view of the same person
person.addProperty(PROV.alternateOf, page);
page.addRDFType(FOAF.Person);
page.addRDFType(PROV.Person);
person.addSeeAlso(page);
} else if (isWebID(urlName)) {
person.addSameAs(page);
} else {
// It's some other foaf:page which might not be about
// this person
person.addProperty(FOAF.page, page);
}
}
}
private String getUrlName(ResearcherUrl url) {
if (url.getUrlName() == null) {
return null;
}
return url.getUrlName().getContent().toLowerCase();
}
private boolean isFoaf(String urlName) {
if (urlName == null) {
return false;
}
return urlName.equals(URL_NAME_FOAF);
}
private boolean isWebID(String urlName) {
if (urlName == null) {
return false;
}
return urlName.equals(URL_NAME_WEBID);
}
/**
* There's no indication in ORCID if the URL is a homepage or some other
* page, so we'll guess based on it's name, it be something similar to
* "home page".
*/
private boolean isHomePage(String urlName) {
if (urlName == null) {
return false;
}
return URL_NAME_HOMEPAGE.contains(urlName);
}
private void describeBiography(Biography biography, Individual person, OntModel m) {
if (biography != null) {
// FIXME: Which language is the biography written in? Can't assume
// EN
person.addProperty(FOAF.plan, biography.getContent());
}
}
private void describeContactDetails(ContactDetails contactDetails, Individual person, OntModel m) {
if (contactDetails == null) {
return;
}
List<Email> emails = contactDetails.getEmail();
if (emails != null) {
for (Email email : emails) {
if (email.isCurrent()) {
Individual mbox = m.createIndividual("mailto:" + email.getValue(), null);
person.addProperty(FOAF.mbox, mbox);
}
}
}
Address addr = contactDetails.getAddress();
if (addr != null) {
if (addr.getCountry() != null) {
String countryCode = addr.getCountry().getValue().name();
Individual position = m.createIndividual(Geonames.Feature);
position.addProperty(Geonames.countryCode, countryCode);
person.addProperty(FOAF.based_near, position);
Individual country = getCountry(countryCode);
if (country != null) {
country = addToModel(position.getOntModel(), country);
position.addProperty(Geonames.parentCountry, country);
}
// TODO: Include URI and (a) full name of country
// Potential source: geonames.org
// See https://gist.github.com/stain/7566375
}
}
}
private Individual addToModel(OntModel ontModel, Individual country) {
// ontModel.addSubModel(country.getModel());
ontModel.add(country.listProperties().toList());
return country;
}
private Individual getCountry(String countryCode) {
ResIterator hasCountryCode = getCountries().listSubjectsWithProperty(Geonames.countryCode, countryCode);
if (hasCountryCode.hasNext()) {
return getCountries().getIndividual(hasCountryCode.next().getURI());
}
return null;
}
private void describePersonalDetails(PersonalDetails personalDetails, Individual person, OntModel m) {
if (personalDetails == null) {
return;
}
if (personalDetails.getCreditName() != null) {
// User has provided full name
String creditName = personalDetails.getCreditName().getContent();
person.addProperty(FOAF.name, creditName);
person.addLabel(creditName, null);
} else if (personalDetails.getGivenNames() != null && personalDetails.getFamilyName() != null) {
//@formatter:off
// Naive fallback assuming givenNames ~= first name and familyName ~= lastName
// See http://www.w3.org/International/questions/qa-personal-names for further
// considerations -- we don't report this as foaf:name as we can't be sure of the ordering.
//@formatter:on
// NOTE: ORCID gui is westernized asking for "First name" and
// "Last name" and assuming the above mapping
String label = personalDetails.getGivenNames().getContent() + " " + personalDetails.getFamilyName().getContent();
person.addLabel(label, null);
}
if (personalDetails.getGivenNames() != null) {
person.addProperty(FOAF.givenName, personalDetails.getGivenNames().getContent());
}
if (personalDetails.getFamilyName() != null) {
person.addProperty(FOAF.familyName, personalDetails.getFamilyName().getContent());
}
}
protected OntModel getOntModel() {
OntModel ontModel = ModelFactory.createOntologyModel();
ontModel.setNsPrefix("foaf", FOAF.NS);
ontModel.setNsPrefix("prov", PROV.NS);
ontModel.setNsPrefix("pav", PAV.NS);
ontModel.setNsPrefix("gn", Geonames.NS);
// ontModel.getDocumentManager().loadImports(foaf.getOntModel());
return ontModel;
}
protected OntModel getCountries() {
if (countries != null) {
// Check for a static cache
return countries;
}
// Load list of countries
InputStream countriesStream = getClass().getResourceAsStream(COUNTRIES_TTL);
if (countriesStream == null) {
throw new IllegalStateException("Can't find country resource on classpath: " + COUNTRIES_TTL);
}
OntModel ontModel = ModelFactory.createOntologyModel();
ontModel.read(countriesStream, "http://example.com/", "TURTLE");
// Note: We should not need to synchronize(this) to cache,
// as the odd concurrent duplicate load is not harmful
// and can be thrown away
countries = ontModel;
return countries;
}
public UriInfo getUriInfo() {
return uriInfo;
}
public void setUriInfo(UriInfo uriInfo) {
this.uriInfo = uriInfo;
}
}