001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018
019package org.fcrepo.http.api.services;
020
021import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
022import static org.apache.jena.rdf.model.ResourceFactory.createProperty;
023import static org.apache.jena.riot.RDFLanguages.contentTypeToLang;
024import static org.fcrepo.config.ServerManagedPropsMode.STRICT;
025import static org.fcrepo.kernel.api.RdfLexicon.isManagedPredicate;
026import static org.fcrepo.kernel.api.RdfLexicon.restrictedType;
027import static org.fcrepo.kernel.api.utils.RelaxedPropertiesHelper.checkTripleForDisallowed;
028import static org.slf4j.LoggerFactory.getLogger;
029
030import java.io.InputStream;
031import java.util.ArrayList;
032import java.util.List;
033
034import javax.inject.Inject;
035import javax.ws.rs.BadRequestException;
036import javax.ws.rs.core.MediaType;
037
038import org.fcrepo.config.FedoraPropsConfig;
039import org.fcrepo.http.commons.api.rdf.HttpIdentifierConverter;
040import org.fcrepo.kernel.api.RdfStream;
041import org.fcrepo.kernel.api.exception.ConstraintViolationException;
042import org.fcrepo.kernel.api.exception.MalformedRdfException;
043import org.fcrepo.kernel.api.exception.MultipleConstraintViolationException;
044import org.fcrepo.kernel.api.exception.RelaxableServerManagedPropertyException;
045import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
046import org.fcrepo.kernel.api.exception.ServerManagedPropertyException;
047import org.fcrepo.kernel.api.exception.ServerManagedTypeException;
048import org.fcrepo.kernel.api.exception.UnsupportedMediaTypeException;
049import org.fcrepo.kernel.api.identifiers.FedoraId;
050import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
051
052import org.apache.jena.atlas.RuntimeIOException;
053import org.apache.jena.graph.Node;
054import org.apache.jena.graph.NodeFactory;
055import org.apache.jena.graph.Triple;
056import org.apache.jena.rdf.model.Model;
057import org.apache.jena.rdf.model.RDFNode;
058import org.apache.jena.rdf.model.Statement;
059import org.apache.jena.rdf.model.StmtIterator;
060import org.apache.jena.rdf.model.impl.StatementImpl;
061import org.apache.jena.riot.Lang;
062import org.apache.jena.riot.RiotException;
063import org.apache.jena.update.Update;
064import org.apache.jena.update.UpdateFactory;
065import org.apache.jena.update.UpdateRequest;
066import org.slf4j.Logger;
067import org.springframework.stereotype.Component;
068
069import com.fasterxml.jackson.core.JsonParseException;
070
071/**
072 * A service that will translate the resourceURI to Fedora ID in the Rdf InputStream
073 *
074 * @author bseeger
075 * @author bbpennel
076 * @since 2019-11-07
077 */
078@Component
079public class HttpRdfService {
080
081    private static final Logger log = getLogger(HttpRdfService.class);
082
083    @Inject
084    private FedoraPropsConfig fedoraPropsConfig;
085
086    /**
087     * Convert internal IDs to external URIs
088     * @param extResourceId The external URI of the resource.
089     * @param stream The RDF stream to be translated.
090     * @param idTranslator The identifier converter.
091     * @return a converted RDF Stream.
092     */
093    public RdfStream bodyToExternalStream(final String extResourceId, final RdfStream stream,
094                                     final HttpIdentifierConverter idTranslator) {
095        return new DefaultRdfStream(NodeFactory.createURI(extResourceId), stream.map(t -> {
096            final Node subject = makeExternalNode(t.getSubject(), idTranslator);
097            final Node object = makeExternalNode(t.getObject(), idTranslator);
098            return new Triple(subject, t.getPredicate(), object);
099        }));
100    }
101
102    /**
103     * Return a converted or original resource.
104     * @param resource The Node to be checked.
105     * @param identifierConverter A identifier converter.
106     * @return The resulting node.
107     */
108    private Node makeExternalNode(final Node resource, final HttpIdentifierConverter identifierConverter) {
109        if (resource.isURI() && identifierConverter.inInternalDomain(resource.toString())) {
110            return NodeFactory.createURI(identifierConverter.toExternalId(resource.toString()));
111        } else {
112            return resource;
113        }
114    }
115
116    /**
117     * Parse the request body to a Model, with the URI to Fedora ID translations done.
118     *
119     * @param extResourceId the external ID of the Fedora resource
120     * @param stream the input stream containing the RDF
121     * @param contentType the media type of the RDF
122     * @param idTranslator the identifier convert
123     * @param lenientHandling whether the request included a handling=lenient prefer header.
124     * @return RdfStream containing triples from request body, with Fedora IDs in them
125     * @throws MalformedRdfException in case rdf json cannot be parsed
126     * @throws BadRequestException in the case where the RDF syntax is bad
127     */
128    public Model bodyToInternalModel(final FedoraId extResourceId, final InputStream stream,
129                                     final MediaType contentType, final HttpIdentifierConverter idTranslator,
130                                     final boolean lenientHandling)
131                                     throws RepositoryRuntimeException, BadRequestException {
132        final List<ConstraintViolationException> exceptions = new ArrayList<>();
133        final String externalURI = idTranslator.toExternalId(extResourceId.getFullId());
134        final Model model = parseBodyAsModel(stream, contentType, externalURI);
135        final List<Statement> insertStatements = new ArrayList<>();
136        final StmtIterator stmtIterator = model.listStatements();
137
138        while (stmtIterator.hasNext()) {
139            final Statement stmt = stmtIterator.nextStatement();
140            if (lenientHandling && stmtIsServerManaged(stmt) &&
141                    fedoraPropsConfig.getServerManagedPropsMode().equals(STRICT)) {
142                // Remove any statement that touches a server managed property or namespace.
143                stmtIterator.remove();
144            } else {
145                try {
146                    checkForDisallowedRdf(stmt);
147                } catch (final RelaxableServerManagedPropertyException exc) {
148                    if (fedoraPropsConfig.getServerManagedPropsMode().equals(STRICT)) {
149                        exceptions.add(exc);
150                        continue;
151                    }
152                } catch (final ServerManagedTypeException | ServerManagedPropertyException exc) {
153                    if (lenientHandling) {
154                        // Remove the invalid statement because client specified lenient handling.
155                        stmtIterator.remove();
156                    } else {
157                        exceptions.add(exc);
158                    }
159                    continue;
160                }
161                if (stmt.getSubject().isURIResource()) {
162                    final String originalSubj = stmt.getSubject().getURI();
163                    final String subj = idTranslator.translateUri(originalSubj);
164
165                    RDFNode obj = stmt.getObject();
166                    if (stmt.getObject().isURIResource()) {
167                        final String objString = stmt.getObject().asResource().getURI();
168                        final String objUri = idTranslator.translateUri(objString);
169                        obj = model.getResource(objUri);
170                    }
171
172                    if (!subj.equals(originalSubj) || !obj.equals(stmt.getObject())) {
173                        insertStatements.add(new StatementImpl(model.getResource(subj), stmt.getPredicate(), obj));
174
175                        stmtIterator.remove();
176                    }
177                } else {
178                    log.debug("Subject {} is not a URI resource, skipping", stmt.getSubject());
179                }
180            }
181        }
182
183        if (!exceptions.isEmpty()) {
184            throw new MultipleConstraintViolationException(exceptions);
185        }
186
187        model.add(insertStatements);
188
189        log.debug("Model: {}", model);
190        return model;
191    }
192
193    /**
194     * Takes a PATCH request body and translates any subjects and objects that are in the domain of the repository
195     * to use internal IDs.
196     * @param resourceId the internal ID of the current resource.
197     * @param requestBody the request body.
198     * @param idTranslator an ID converter for the current context.
199     * @return the converted PATCH request.
200     */
201    public String patchRequestToInternalString(final FedoraId resourceId, final String requestBody,
202                                               final HttpIdentifierConverter idTranslator) {
203        final String externalURI = idTranslator.toExternalId(resourceId.getFullId());
204        final UpdateRequest request = UpdateFactory.create(requestBody, externalURI);
205        final List<Update> updates = request.getOperations();
206        final SparqlTranslateVisitor visitor = new SparqlTranslateVisitor(idTranslator, fedoraPropsConfig);
207        for (final Update update : updates) {
208            update.visit(visitor);
209        }
210        return visitor.getTranslatedRequest().toString();
211    }
212
213    /**
214     * Parse the request body as a Model.
215     *
216     * @param requestBodyStream rdf request body
217     * @param contentType content type of body
218     * @param extResourceId the external ID of the Fedora resource
219     * @return Model containing triples from request body
220     * @throws MalformedRdfException in case rdf json cannot be parsed
221     * @throws BadRequestException in the case where the RDF syntax is bad
222     */
223    protected static Model parseBodyAsModel(final InputStream requestBodyStream,
224                                         final MediaType contentType,
225                                         final String extResourceId) throws BadRequestException,
226                                         RepositoryRuntimeException {
227
228        if (requestBodyStream == null) {
229            return null;
230        }
231
232        // The 'contentTypeToLang()' method will not accept 'charset' parameters
233        String contentTypeWithoutCharset = contentType.toString();
234        if (contentType.getParameters().containsKey("charset")) {
235            contentTypeWithoutCharset = contentType.getType() + "/" + contentType.getSubtype();
236        }
237
238        final Lang format = contentTypeToLang(contentTypeWithoutCharset);
239        if (format == null) {
240            // No valid RDF format for the mimeType.
241            throw new UnsupportedMediaTypeException("Media type " + contentTypeWithoutCharset + " is not a valid RDF " +
242                    "format");
243        }
244        try {
245            final Model inputModel = createDefaultModel();
246            inputModel.read(requestBodyStream, extResourceId, format.getName().toUpperCase());
247            return inputModel;
248        } catch (final RiotException e) {
249            throw new BadRequestException("RDF was not parsable: " + e.getMessage(), e);
250
251        } catch (final RuntimeIOException e) {
252            if (e.getCause() instanceof JsonParseException) {
253                final var cause = e.getCause();
254                throw new MalformedRdfException(cause.getMessage(), cause);
255            }
256            throw new RepositoryRuntimeException(e.getMessage(), e);
257        }
258    }
259
260    /**
261     * Checks if the RDF contains any disallowed statements.
262     * @param statement a statement from the incoming RDF.
263     */
264    private static void checkForDisallowedRdf(final Statement statement) {
265        checkTripleForDisallowed(statement.asTriple());
266    }
267
268    /**
269     * Does the statement's triple touch any server managed properties / namespaces.
270     * ie.
271     * - has a rdf:type with an object which is in a managed namespace
272     * - has a predicate which is in a managed namespace.
273     *
274     * @param statement the statement to check
275     * @return Return true if this does touch a server managed property or namespace.
276     */
277    private boolean stmtIsServerManaged(final Statement statement) {
278        final Triple triple = statement.asTriple();
279        return restrictedType.test(triple) || isManagedPredicate.test(createProperty(triple.getPredicate().getURI()));
280    }
281}