
com.metreeca.rdf4j.assets.GraphEngine Maven / Gradle / Ivy
/*
* Copyright © 2013-2021 Metreeca srl
*
* 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 com.metreeca.rdf4j.assets;
import com.metreeca.json.Query;
import com.metreeca.json.Shape;
import com.metreeca.json.queries.*;
import com.metreeca.rdf4j.assets.GraphFacts.Options;
import com.metreeca.rest.*;
import com.metreeca.rest.assets.Engine;
import com.metreeca.rest.formats.JSONLDFormat;
import org.eclipse.rdf4j.model.*;
import java.util.*;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import static com.metreeca.json.Values.IRIPattern;
import static com.metreeca.json.Values.format;
import static com.metreeca.json.Values.iri;
import static com.metreeca.json.shapes.All.all;
import static com.metreeca.json.shapes.And.and;
import static com.metreeca.json.shapes.Field.field;
import static com.metreeca.json.shapes.Guard.Convey;
import static com.metreeca.json.shapes.Guard.Mode;
import static com.metreeca.rdf4j.assets.Graph.graph;
import static com.metreeca.rdf4j.assets.Graph.txn;
import static com.metreeca.rest.Context.asset;
import static com.metreeca.rest.MessageException.status;
import static com.metreeca.rest.Response.*;
import static com.metreeca.rest.assets.Engine.StatsShape;
import static com.metreeca.rest.assets.Engine.TermsShape;
import static com.metreeca.rest.formats.JSONLDFormat.*;
import static java.lang.String.format;
import static java.util.Objects.requireNonNull;
/**
* Model-driven graph engine.
*
* Manages graph transactions and handles model-driven CRUD actions on linked data resources stored in the shared
* {@linkplain Graph graph}.
*/
public final class GraphEngine implements Engine {
/**
* Maximum number of resources returned by items queries.
*
* @return an {@linkplain #set(Supplier, Object) option} with a default value of {@code 1000}
*/
public static Supplier items() {
return () -> 1_000;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private final Map, Object> options=new LinkedHashMap<>();
private final Graph graph=asset(graph());
/**
* Retrieves an engine option.
*
* @param option the option to be retrieved; must return a non-null default value
* @param the type of the option to be retrieved
*
* @return the value previously {@linkplain #set(Supplier, Object) configured} for {@code option} or its default
* value, if no custom value was configured; in the latter case the returned value is cached
*
* @throws NullPointerException if {@code option} is null or returns a null value
*/
@SuppressWarnings("unchecked")
private V get(final Supplier option) {
if ( option == null ) {
throw new NullPointerException("null option");
}
return (V)options.computeIfAbsent(option, key ->
requireNonNull(key.get(), "null option return value")
);
}
/**
* Configures an engine option.
*
* @param option the option to be configured; must return a non-null default value
* @param value the value to be configured for {@code option}
* @param the type of the option to be configured
*
* @return this engine
*
* @throws NullPointerException if either {@code option} or {@code value} is null
*/
public GraphEngine set(final Supplier option, final V value) {
if ( option == null ) {
throw new NullPointerException("null option");
}
if ( value == null ) {
throw new NullPointerException("null value");
}
options.put(option, value);
return this;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@Override public Handler wrap(final Handler handler) {
if ( handler == null ) {
throw new NullPointerException("null task");
}
return request -> consumer -> graph.exec(txn(connection -> {
handler.handle(request).accept(consumer);
}));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Creates a linked data resource.
*
* Handles creation requests on the linked data container identified by the request {@linkplain Request#item()
* focus item}:
*
*
*
* - the request is expected to include a resource {@linkplain JSONLDFormat#shape() shape};
*
* - the request {@link JSONLDFormat JSON-LD} body is expected to contain a description of the resource to be
* created matching the shape using the request {@linkplain Request#item() item} as subject;
*
* - the resource to be created is assigned a unique IRI based on the stem of the the request IRI and the value
* of the {@code Slug} request header, if one is found, or a random id, otherwise;
*
* - the request body is rewritten to the assigned IRI and stored into the shared {@linkplain Graph graph};
*
* - the target container identified by the request item is connected to the newly created resource as
* {@linkplain Shape#outline(Value...) outlined} in the filtering constraints in the request shape;
*
* - the operation is completed with a {@value Response#Created} status code;
*
* - the IRI of the newly created resource is advertised through the {@code Location} HTTP response header.
*
*
*
* @param request the request to be handled
*
* @return a lazy response generated for the managed linked data resource in reaction to {@code request}
*
* @throws NullPointerException if {@code request} is null
*/
@Override public Future create(final Request request) {
if ( request == null ) {
throw new NullPointerException("null request");
}
final IRI item=iri(request.item());
final Shape shape=request.attribute(shape());
return request.body(jsonld()).fold(request::reply, model ->
request.reply(response -> graph.exec(txn(connection -> {
final boolean clashing=connection.hasStatement(item, null, null, true)
|| connection.hasStatement(null, null, item, true);
if ( clashing ) { // report clash
return response.map(status(InternalServerError,
new IllegalStateException(format("clashing resource identifier %s", format(item)))
));
} else { // store model
connection.add(shape.outline(item));
connection.add(model);
final String location=item.stringValue();
return response.status(Created)
.header("Location", Optional // root-relative to support relocation
.of(item.stringValue())
.map(IRIPattern::matcher)
.filter(Matcher::matches)
.map(matcher -> matcher.group("pathall"))
.orElse(location)
);
}
})))
);
}
/**
* Retrieves a linked data resource.
*
* Handles retrieval requests on the linked data resource identified by the request {@linkplain Request#item()
* focus item}.
*
* If the shared {@linkplain Graph graph} actually contains a resource matching the request focus item IRI:
*
*
*
* - the request is expected to include a resource {@linkplain JSONLDFormat#shape() shape};
*
* - the response includes the derived shape actually used in the retrieval process;
*
* - the response {@link JSONLDFormat JSON-LD} body contains a description of the request item retrieved from the
* shared {@linkplain Graph graph} and matching the response shape;
*
* - the operation is completed with a {@value Response#OK} status code.
*
*
*
* Otherwise:
*
*
*
* - the operation is reported as unsuccessful with a {@value Response#NotFound} status code.
*
*
*
* @param request the request to be handled
*
* @return a lazy response generated for the managed linked data resource in reaction to {@code request}
*
* @throws NullPointerException if {@code request} is null
*/
@Override public Future relate(final Request request) {
if ( request == null ) {
throw new NullPointerException("null request");
}
final IRI item=iri(request.item());
final Shape shape=and(all(item), request.attribute(shape()));
return query(item, shape, request.query()).fold(request::reply, query ->
request.reply(response -> Optional
.of(query.map(new QueryProbe(item, this::get)))
.filter(model -> !model.isEmpty())
.map(model -> response.status(OK)
.attribute(shape(), query.map(new ShapeProbe(false)))
.body(jsonld(), model)
)
.orElseGet(() -> response.status(NotFound)) // !!! 410 Gone if previously known
)
);
}
/**
* Browses a linked data container.
*
* Handles browsing requests on the linked data container identified by the request {@linkplain Request#item()
* focus item}:
*
*
*
* - the request is expected to include a resource {@linkplain JSONLDFormat#shape() shape};
*
* - the response includes the derived shape actually used in the retrieval process;
*
* - the response {@link JSONLDFormat JSON-LD} body contains a description of member linked data resources
* retrieved from the shared {@linkplain Graph graph} according to the filtering constraints in the request shape
* and matching the response shape; the IRI of the target container is connected to the IRIs of the member
* resources using the {@link Shape#Contains ldp:contains} property;
*
* - the operation is completed with a {@value Response#OK} status code.
*
*
*
* @param request the request to be handled
*
* @return a lazy response generated for the managed linked data resource in reaction to {@code request}
*
* @throws NullPointerException if {@code request} is null
*/
@Override public Future browse(final Request request) {
if ( request == null ) {
throw new NullPointerException("null request");
}
final IRI item=iri(request.item());
final Shape shape=request.attribute(shape());
return query(item, shape, request.query()).fold(request::reply, query ->
request.reply(response -> response.status(OK) // containers are virtual and respond always with 200 OK
.attribute(shape(), query.map(new ShapeProbe(true)))
.body(jsonld(), query.map(new QueryProbe(item, this::get)))
)
);
}
/**
* Updates a linked data resource.
*
* Handles updating requests on the linked data resource identified by the request {@linkplain Request#item()
* item}.
*
* If the shared {@linkplain Graph graph} actually contains a resource matching the request focus item IRI:
*
*
*
* - the request is expected to include a resource {@linkplain JSONLDFormat#shape() shape};
*
* - the request {@link JSONLDFormat JSON-LD} body is expected to contain a description of the resource to be
* updated matching by the shape;
*
* - the existing description of the resource matching the request shape is replaced in the shared
* {@linkplain Graph graph} with the request body;
*
* - the operation is completed with a {@value Response#NoContent} status code.
*
*
*
* Otherwise:
*
*
*
* - the operation is reported with a {@value Response#NotFound} status code.
*
*
*
* @param request the request to be handled
*
* @return a lazy response generated for the managed linked data resource in reaction to {@code request}
*
* @throws NullPointerException if {@code request} is null
*/
@Override public Future update(final Request request) {
if ( request == null ) {
throw new NullPointerException("null request");
}
final IRI item=iri(request.item());
final Shape shape=request.attribute(shape());
return request.body(jsonld()).fold(request::reply, model ->
request.reply(response -> graph.exec(txn(connection -> {
return Optional
.of(Items.items(shape).map(new QueryProbe(item, this::get)))
.filter(current -> !current.isEmpty())
.map(current -> {
connection.remove(current);
connection.add(model);
return response.status(NoContent);
})
.orElseGet(() -> response.status(NotFound)); // !!! 410 Gone if previously known
})))
);
}
/**
* Deletes a linked data resource.
*
* Handles deletion requests on the linked data resource identified by the request {@linkplain Request#item()
* item}.
*
* If the shared {@linkplain Graph graph} actually contains a resource matching the request focus item IRI:
*
*
*
* - the request is expected to include a resource {@linkplain JSONLDFormat#shape() shape};
*
* - the existing description of the resource matching the request shape is removed from the shared
* {@linkplain Graph graph};
*
* - the operation is completed with a {@value Response#NoContent} status code.
*
*
*
* Otherwise:
*
*
*
* - the operation is reported with a {@value Response#NotFound} status code.
*
*
*
* @param request the request to be handled
*
* @return a lazy response generated for the managed linked data resource in reaction to {@code request}
*
* @throws NullPointerException if {@code request} is null
*/
@Override public Future delete(final Request request) {
if ( request == null ) {
throw new NullPointerException("null request");
}
final IRI item=iri(request.item());
final Shape shape=request.attribute(shape());
return request.reply(response -> graph.exec(txn(connection -> {
return Optional
.of(Items.items(shape).map(new QueryProbe(item, this::get)))
.filter(current -> !current.isEmpty())
.map(current -> {
connection.remove(shape.outline(item));
connection.remove(current);
return response.status(NoContent);
})
.orElseGet(() -> response.status(NotFound)); // !!! 410 Gone if previously known
})));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private static final class QueryProbe extends Query.Probe> {
private final IRI resource;
private final Options options;
QueryProbe(final IRI resource, final Options options) {
this.resource=resource;
this.options=options;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@Override public Collection probe(final Items items) {
return new GraphItems(options).process(resource, items);
}
@Override public Collection probe(final Terms terms) {
return new GraphTerms(options).process(resource, terms);
}
@Override public Collection probe(final Stats stats) {
return new GraphStats(options).process(resource, stats);
}
}
private static final class ShapeProbe extends Query.Probe {
private final boolean container;
private ShapeProbe(final boolean container) {
this.container=container;
}
@Override public Shape probe(final Items items) { // !!! add Shape.Contains if items.path is not empty
return (container ?
field(Shape.Contains, items.shape()) : items.shape()
).redact(Mode, Convey); // remove filters
}
@Override public Shape probe(final Stats stats) {
return StatsShape(stats);
}
@Override public Shape probe(final Terms terms) {
return TermsShape(terms);
}
}
}