EntryService.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* 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 org.entrystore.rest.springboot.service;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.solr.common.SolrDocument;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.rio.RDFParseException;
import org.entrystore.AuthorizationException;
import org.entrystore.Context;
import org.entrystore.ContextManager;
import org.entrystore.Entry;
import org.entrystore.EntryType;
import org.entrystore.GraphType;
import org.entrystore.Group;
import org.entrystore.List;
import org.entrystore.Metadata;
import org.entrystore.PrincipalManager;
import org.entrystore.Resource;
import org.entrystore.ResourceType;
import org.entrystore.User;
import org.entrystore.exception.EntryMissingException;
import org.entrystore.exception.InvalidExternalMetadataURIException;
import org.entrystore.impl.ContextImpl;
import org.entrystore.impl.EntryImpl;
import org.entrystore.impl.RDFResource;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.impl.RepositoryProperties;
import org.entrystore.impl.StringResource;
import org.entrystore.repository.RepositoryException;
import org.entrystore.repository.util.ModelUtil;
import org.entrystore.repository.util.NS;
import org.entrystore.repository.util.SolrSearchIndex;
import org.entrystore.repository.util.URISplit;
import org.entrystore.rest.springboot.model.api.CreateEntryRequestBody;
import org.entrystore.rest.springboot.model.api.EntryResourceJson;
import org.entrystore.rest.springboot.model.api.GetEntryResponse;
import org.entrystore.rest.springboot.model.api.ListFilter;
import org.entrystore.rest.springboot.model.dto.ListParams;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.DataConflictException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.entrystore.rest.springboot.util.RDFJSON;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.stereotype.Service;
import tools.jackson.core.JacksonException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import static org.entrystore.EntryType.Link;
import static org.entrystore.EntryType.LinkReference;
import static org.entrystore.EntryType.Local;
import static org.entrystore.EntryType.Reference;
import static org.entrystore.rest.springboot.service.ResourceSerializationService.IMMUTABLE_EMPTY_JSONOBJECT;
@Slf4j
@Service
@RequiredArgsConstructor
public class EntryService {
private static final int JSON_OBJECT_TO_STRING_INDENT_SIZE = 0;
private static final Pattern ENTRY_ID_PATTERN = Pattern.compile("^[\\w\\-]+$");
private static final String NEW_ID_STAND_IN = "10";
private final PrincipalManager principalManager;
private final RepositoryManagerImpl repositoryManager;
private final ContextService contextService;
private final ReservedNamesService reservedNamesService;
private final ResourceSerializationService resourceSerializationService;
private final ObjectMapper objectMapper;
/**
* Checks whether the provided ID contains only allowed characters.
*
* @return True if supplied ID is valid.
*/
public static boolean isEntryIdValid(String id) {
return ENTRY_ID_PATTERN.matcher(id).matches();
}
/**
* @param mayReadNothing whether the caller {@linkplain #mayReadNothing may read nothing} of the entry, which
* reduces the entry information
*/
public String getEntryInRdfFormat(Entry entry, String mediaType, boolean mayReadNothing) {
return GraphUtil.serializeGraph(resourceSerializationService.getEntryInfo(entry, mayReadNothing), mediaType);
}
/**
* Whether the current user may read neither the metadata nor the resource of the entry, so that it gets the
* reduced entry information, see {@link ResourceSerializationService#mayReadNothing(Entry)}.
*/
public boolean mayReadNothing(Entry entry) {
return resourceSerializationService.mayReadNothing(entry);
}
public Entry getEntryByContextIdAndEntryId(String contextId, String entryId) {
Context context = contextService.getContext(contextId);
if (context == null) {
// throw the same exception message for missing Context and missing Entry to avoid leaking information about context existence
throw new EntityNotFoundException("No entry with id '" + entryId + "' found in context '" + contextId + "'");
}
Entry entry = context.get(entryId);
if (entry == null) {
throw new EntityNotFoundException("No entry with id '" + entryId + "' found in context '" + contextId + "'");
}
return entry;
}
/**
* Validates if the currently authenticated user has the specified access property to the given entry.
*
* @param entry The entry object for which access needs to be checked.
* @param accessProperty The access property defining the type of access to be validated.
* @throws AuthorizationException If the user does not have the required access permissions.
*/
public void checkEntryUserAccess(Entry entry, PrincipalManager.AccessProperty accessProperty) throws AuthorizationException {
principalManager.checkAuthenticatedUserAuthorized(entry, accessProperty);
}
/**
* @param mayReadNothing whether the caller {@linkplain #mayReadNothing may read nothing} of the entry, which
* reduces the entry information and omits a context's quota
*/
public GetEntryResponse getEntryInJsonFormat(Entry entry, String rdfFormat, boolean includeAll,
ListFilter listFilter, boolean mayReadNothing) {
ContextManager cm = repositoryManager.getContextManager();
var responseBuilder = GetEntryResponse.builder();
GraphType graphType = entry.getGraphType();
EntryType entryType = entry.getEntryType();
/*
* Entry id
*/
responseBuilder.entryId(entry.getId());
/*
* Context or SystemContext
*/
if ((graphType == GraphType.Context || graphType == GraphType.SystemContext) && entryType == Local) {
responseBuilder.name(cm.getName(entry.getResourceURI()));
if (entry.getRepositoryManager().hasQuotas() && !mayReadNothing) {
JSONObject quotaObj = new JSONObject();
Context c = cm.getContext(entry.getId());
if (c != null) {
quotaObj.put("quota", c.getQuota());
quotaObj.put("fillLevel", c.getQuotaFillLevel());
quotaObj.put("hasDefaultQuota", c.hasDefaultQuota());
}
responseBuilder.quota(quotaObj.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
}
}
/*
* Entry information
*/
Model entryGraph = resourceSerializationService.getEntryInfo(entry, mayReadNothing);
JSONObject entryObj = GraphUtil.serializeGraphToJson(entryGraph, rdfFormat);
responseBuilder.info(entryObj.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
/*
* If the parameter includeAll is set we must return more JDIl with
* example local metadata, cached external metadata and maybe a
* resource. If not set, return now.
*/
if (!includeAll) {
return responseBuilder.build();
}
/*
* Cached External Metadata
*/
if (entryType == LinkReference || entryType == Reference) {
try {
Metadata cachedExternalMetadata = entry.getCachedExternalMetadata();
Model cachedMetadataGraph = cachedExternalMetadata.getGraph();
if (cachedMetadataGraph != null) {
JSONObject cachedExternalMetadataJsonObject = GraphUtil.serializeGraphToJson(cachedMetadataGraph, rdfFormat);
responseBuilder.cachedExternalMetadata(cachedExternalMetadataJsonObject.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
}
} catch (AuthorizationException ae) {
//mainJsonObject.accumulate("noAccessToMetadata", true);
//TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
}
/*
* Local Metadata
*/
if (entryType == Local || entryType == Link || entryType == LinkReference) {
try {
Metadata localMetadata = entry.getLocalMetadata();
Model localMetadataGraph = localMetadata.getGraph();
if (localMetadataGraph != null) {
JSONObject localMetaDataJsonObject = GraphUtil.serializeGraphToJson(localMetadataGraph, rdfFormat);
responseBuilder.metadata(localMetaDataJsonObject.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
}
} catch (AuthorizationException ae) {
/*if (!mainJsonObject.has("noAccessToMetadata")) {
mainJsonObject.accumulate("noAccessToMetadata", true);
}*/
//TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
}
/*
* Relations
*/
Model relations = entry.getRelations();
if (relations != null) {
JSONObject relationsJsonObject = GraphUtil.serializeGraphToJson(relations, rdfFormat);
responseBuilder.relations(relationsJsonObject.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
}
/*
* Rights
*/
JSONArray rights = resourceSerializationService.serializeRights(entry);
responseBuilder.rights(rights.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE));
/*
* Local resource
*/
if (entryType == Local) {
Resource resource = entry.getResource();
if (resource == null) {
log.error("Resource is null for Context '{}', EntryId '{}', GraphType '{}', skipping resource serialization",
entry.getContext().getURI(), entry.getId(), graphType);
} else {
try {
String resourceString = serializeResourceToRawJsonString(resource, graphType, rdfFormat, listFilter);
if (resourceString != null) {
responseBuilder.resource(graphType == GraphType.String
? new EntryResourceJson.Text(resourceString)
: new EntryResourceJson.Json(resourceString));
}
} catch (IllegalArgumentException | RepositoryException | JSONException e) {
log.error("Failed to serialize resource for Context '{}', EntryId '{}', GraphType '{}'. Error: {}",
entry.getContext().getURI(), entry.getId(), graphType, e.getMessage(), e);
}
}
}
return responseBuilder.build();
}
private String serializeResourceToRawJsonString(Resource resource, GraphType graphType, String rdfFormat, ListFilter listFilter) {
if (graphType == GraphType.String) {
return resourceSerializationService.serializeResourceString(resource);
}
JSONObject jsonObject = serializeResourceToJson(resource, graphType, rdfFormat, listFilter);
return jsonObject != null ?
jsonObject.toString(JSON_OBJECT_TO_STRING_INDENT_SIZE)
: null;
}
// TODO: move this method to ResourceSerializer class?
private JSONObject serializeResourceToJson(Resource resource, GraphType graphType, String rdfFormat, ListFilter listFilter) {
if (graphType == null) {
return IMMUTABLE_EMPTY_JSONOBJECT;
}
return switch (graphType) {
case List -> resourceSerializationService.serializeResourceList(resource,
new ListParams(listFilter), rdfFormat);
case User -> resourceSerializationService.serializeResourceUser(resource);
case Group -> resourceSerializationService.serializeResourceGroup(resource, rdfFormat);
case None -> resourceSerializationService.serializeResourceNone(resource);
case Graph -> resourceSerializationService.serializeResourceGraph(resource, rdfFormat);
case Pipeline -> resourceSerializationService.serializeResourcePipeline(resource, rdfFormat);
case String -> null;
// TODO: other types, for example PrincipalManager, etc
// Not the context's entry IDs: listing them here would skip every ACL check, and 5.x returned {}.
case ResultList, PipelineResult, Context, SystemContext -> IMMUTABLE_EMPTY_JSONOBJECT;
};
}
public Entry createEntry(Context context, String entryId, EntryType entryType, GraphType graphType,
URI resourceUri, URI listUri, URI groupUri, URI cachedExternalMetadataUri,
String informationResource, URI templateUri, CreateEntryRequestBody body) {
if (entryId != null) {
if (!EntryService.isEntryIdValid(entryId)) {
throw new BadRequestException("Invalid entry ID of '" + entryId + "'");
}
Entry preExistingEntry = context.get(entryId);
if (preExistingEntry != null) {
throw new DataConflictException("Entry with provided ID already exists. EntryID: '" + entryId + "'");
}
}
checkGraphsOfRequestBody(entryId, entryType, body);
Entry entry = null; // A variable to store the new entry in.
try {
checkExternalMetadataURIOfEntryGraph(context, entryId, entryType, body);
// Local
if (entryType == null || entryType == EntryType.Local) {
entry = createLocalEntry(context, entryId, graphType, listUri, groupUri, body);
} else {
// Link
if (entryType == EntryType.Link && resourceUri != null) {
entry = createLinkEntry(context, entryId, graphType, resourceUri, listUri, body);
}
// Reference
else if (entryType == EntryType.Reference
&& resourceUri != null
&& cachedExternalMetadataUri != null) {
entry = createReferenceEntry(context, entryId, graphType, resourceUri, listUri, cachedExternalMetadataUri, body);
}
// LinkReference
else if (entryType == EntryType.LinkReference
&& resourceUri != null
&& cachedExternalMetadataUri != null) {
entry = createLinkReferenceEntry(context, entryId, graphType, resourceUri, listUri, cachedExternalMetadataUri, body);
}
}
} catch (IllegalArgumentException iae) {
throw new BadRequestException(iae.getMessage(), iae); // Core exception — message is safe to return
}
if (entry != null) {
ResourceType rt = mapToResourceType(informationResource);
entry.setResourceType(rt);
if (templateUri != null) {
Entry templateEntry = context.getByEntryURI(templateUri);
if (templateEntry != null && templateEntry.getLocalMetadata() != null) {
Model templateMD = templateEntry.getLocalMetadata().getGraph();
Model inheritedMD = new LinkedHashModel();
if (templateMD != null) {
ValueFactory vf = repositoryManager.getValueFactory();
IRI oldResURI = vf.createIRI(templateEntry.getResourceURI().toString());
IRI newResURI = vf.createIRI(entry.getResourceURI().toString());
java.util.List<IRI> predicateBlackList = new ArrayList<>();
predicateBlackList.add(vf.createIRI(NS.dc, "title"));
predicateBlackList.add(vf.createIRI(NS.dcterms, "title"));
predicateBlackList.add(vf.createIRI(NS.dc, "description"));
predicateBlackList.add(vf.createIRI(NS.dcterms, "description"));
java.util.List<Value> subjectBlackList = new ArrayList<>();
for (Statement statement : templateMD) {
if (predicateBlackList.contains(statement.getPredicate())) {
subjectBlackList.add(statement.getObject());
continue;
}
if (!subjectBlackList.contains(statement.getSubject())) {
inheritedMD.add(statement);
}
}
inheritedMD = ModelUtil.replaceSubject(inheritedMD, oldResURI, newResURI);
}
if (!inheritedMD.isEmpty() && entry.getLocalMetadata() != null) {
Model mergedGraph = new LinkedHashModel();
mergedGraph.addAll(entry.getLocalMetadata().getGraph());
mergedGraph.addAll(inheritedMD);
entry.getLocalMetadata().setGraph(mergedGraph);
}
}
}
}
if (entry == null) {
throw new BadRequestException("Cannot create an entry with provided JSON");
} else {
return entry;
}
}
private ResourceType mapToResourceType(String rt) {
if (rt == null || !rt.equals("false")) {
return ResourceType.InformationResource;
} else {
return ResourceType.NamedResource;
}
}
/**
* Creates a local entry.
*
* <p><b>Error-propagation contract:</b>
* <ul>
* <li>JSON/RDF parse failures of the user-supplied body are converted to {@link BadRequestException} (400)
* via {@code safeRollback} (logs the cleanup failure if it fires, attaches it as a suppressed exception).
* <li>Any other {@code RuntimeException} from a post-skeleton step (RDF4J {@code RepositoryException},
* ACL-copy failure, etc.) is rethrown unchanged so {@code AppExceptionHandler.handleGenericException}
* sanitises it to a generic 500.
* <li>If {@code setResource} returns {@code false} (validation rejected the body) the just-created
* skeleton is removed via {@code removeOrphan}, which throws {@link InternalServerErrorException}
* (500) if cleanup fails — so a partial store-inconsistency cannot be masked as the caller's 400.
* </ul>
* <p>Callers <b>must not</b> wrap-and-rethrow propagated {@code RuntimeException}s as
* {@code BadRequestException(e.getMessage(), e)} (or any handler that echoes the message) — the
* original messages may carry RDF4J/Jackson internals (file paths, store details) that must not
* reach the client.
*
* @return the new created entry
*/
public Entry createLocalEntry(Context context, String entryId, GraphType graphType,
URI listUri, URI groupUri, CreateEntryRequestBody body) {
Entry entry = context.createResource(entryId, graphType, null, listUri);
try {
if (setResource(context, entry, body, groupUri)) {
setLocalMetadataGraph(entry, body);
setEntryGraph(entry, body);
if (listUri != null) {
((ContextImpl) context).copyACL(listUri, entry);
}
return entry;
}
} catch (JacksonException | RDFParseException e) {
// Both fire on user-supplied content (JSON envelope or inner RDFJSON in `info`/`metadata`)
// — client-side input failure, mapped to 400. The cause chain preserves the underlying
// parser detail for server-side debugging without echoing it to the client.
safeRollback(context, entry, e);
throw new BadRequestException("Cannot create an entry with provided JSON/RDF", e);
} catch (RuntimeException e) {
safeRollback(context, entry, e);
throw e;
}
// setResource returned false (validation failure) — clean up the just-created skeleton. This
// path is outside the try/catch above so that a cleanup failure here (rethrown by removeOrphan)
// does NOT then trigger safeRollback to call context.remove a second time on the same entry.
removeOrphan(context, entry);
return null;
}
private void safeRollback(Context context, Entry entry, Throwable original) {
try {
context.remove(entry.getEntryURI());
} catch (RuntimeException cleanupEx) {
log.error("Failed to remove orphan entry {} after create failure", entry.getEntryURI(), cleanupEx);
original.addSuppressed(cleanupEx);
}
}
private void removeOrphan(Context context, Entry entry) {
try {
context.remove(entry.getEntryURI());
} catch (RuntimeException cleanupEx) {
log.error("Failed to remove orphan entry {}", entry.getEntryURI(), cleanupEx);
throw new InternalServerErrorException("Orphan cleanup failed for entry " + entry.getEntryURI(), cleanupEx);
}
}
/**
* Creates a link entry.
*
* @return the new created entry
*/
public Entry createLinkEntry(Context context, String entryId, GraphType graphType, URI resourceUri, URI listUri, CreateEntryRequestBody body) {
Entry entry = context.createLink(entryId, resourceUri, listUri);
return finishEntry(context, entry, graphType, listUri, body);
}
/**
* Creates a Reference entry.
*
* @return the new created entry
*/
public Entry createReferenceEntry(Context context, String entryId, GraphType graphType, URI resourceUri,
URI listUri, URI cachedExternalMetadataUri, CreateEntryRequestBody body) {
if (resourceUri != null &&
cachedExternalMetadataUri != null) {
Entry entry = context.createReference(entryId, resourceUri, cachedExternalMetadataUri, listUri);
return finishEntry(context, entry, graphType, listUri, body);
}
return null;
}
/**
* Creates a LinkReference entry.
*
* @return the new created entry object.
*/
public Entry createLinkReferenceEntry(Context context, String entryId, GraphType graphType, URI resourceUri,
URI listUri, URI cachedExternalMetadataUri, CreateEntryRequestBody body) {
if (resourceUri != null) {
Entry entry = context.createLinkReference(entryId, resourceUri, cachedExternalMetadataUri, listUri);
return finishEntry(context, entry, graphType, listUri, body);
}
return null;
}
/**
* Shared tail of the link/reference/link-reference create pipelines: applies the request body's
* graphs (each setter no-ops for entry types it does not apply to), the optional graph type, and
* copies the parent list's ACL. A failure removes the just-created entry, with the error-propagation
* contract of {@link #createLocalEntry}: invalid RDF/JSON that {@link #checkGraphsOfRequestBody} could not
* detect before creation becomes a {@link BadRequestException}.
*/
private Entry finishEntry(Context context, Entry entry, GraphType graphType, URI listUri, CreateEntryRequestBody body) {
if (entry == null) {
return null;
}
try {
setLocalMetadataGraph(entry, body);
setCachedMetadataGraph(entry, body);
setEntryGraph(entry, body);
if (graphType != null) {
entry.setGraphType(graphType);
}
if (listUri != null) {
((ContextImpl) context).copyACL(listUri, entry);
}
return entry;
} catch (RDFParseException e) {
safeRollback(context, entry, e);
throw new BadRequestException("Cannot create an entry with provided JSON/RDF", e);
} catch (RuntimeException e) {
safeRollback(context, entry, e);
throw e;
}
}
public Entry modifyEntry(Entry entry, String body, String mediaType, boolean applyACLtoChildren) throws AuthorizationException {
Model deserializedGraph = GraphUtil.deserializeGraph(body, mediaType);
try {
entry.setGraph(deserializedGraph);
} catch (IllegalArgumentException iae) {
throw new BadRequestException(iae.getMessage()); // core validation message; no cause, it is expected client input
}
if (applyACLtoChildren &&
GraphType.List.equals(entry.getGraphType()) &&
Local.equals(entry.getEntryType())) {
((org.entrystore.List) entry.getResource()).applyACLtoChildren(true);
}
return entry;
}
public void deleteEntry(Entry entry, boolean recursive) {
try {
if (GraphType.List.equals(entry.getGraphType()) && recursive) {
org.entrystore.List l = (org.entrystore.List) entry.getResource();
if (l != null) {
l.removeTree();
} else {
log.warn("Resource of the following list is null: {}", entry.getEntryURI());
}
} else {
entry.getContext().remove(entry.getEntryURI());
}
} catch (EntryMissingException e) {
throw new EntityNotFoundException("Entry requested for deletion was not found", e);
}
}
public String getEntryName(Entry entry) {
String name = null;
GraphType bt = entry.getGraphType();
if (GraphType.Group.equals(bt)) {
name = ((Group) entry.getResource()).getName();
} else if (GraphType.User.equals(bt)) {
name = ((User) entry.getResource()).getName();
} else if (GraphType.Context.equals(bt)) {
ContextManager cm = repositoryManager.getContextManager();
Context c = cm.getContext(entry.getId());
name = cm.getName(c.getURI());
}
return name;
}
public boolean setEntryName(Entry entry, String requestName) {
String newName;
if (StringUtils.isEmpty(requestName)) {
newName = null;
} else {
newName = requestName.trim();
}
GraphType bt = entry.getGraphType();
boolean success = false;
if (GraphType.Group.equals(bt)) {
success = ((Group) entry.getResource()).setName(newName);
} else if (GraphType.User.equals(bt)) {
// Users must always have a name
if (newName == null) {
throw new BadRequestException("User must have a name.");
}
success = ((User) entry.getResource()).setName(newName);
} else if (GraphType.Context.equals(bt)) {
if (reservedNamesService.isReservedName(StringUtils.trimToEmpty(newName).toLowerCase())) {
throw new BadRequestException("Requested name to be set of '" + newName + "' is a reserved word.");
} else {
ContextManager cm = repositoryManager.getContextManager();
Context c = cm.getContext(entry.getId());
success = cm.setName(c.getURI(), newName);
}
}
return success;
}
public Map<String, Object> getEntryIndex(Entry entry) {
SolrDocument doc = ((SolrSearchIndex) repositoryManager.getIndex()).fetchDocument(entry.getEntryURI().toString());
if (doc == null) {
throw new EntityNotFoundException("Entry Index data not found.");
}
Map<String, Object> result = new HashMap<>();
for (String field : doc.getFieldValuesMap().keySet()) {
Collection<Object> values = doc.getFieldValues(field);
if (values.size() > 1) {
result.put(field, values);
} else if (values.size() == 1) {
result.put(field, values.iterator().next());
}
}
return result;
}
/**
* Sets resource to an entry.
*
* @param context Context
* @param entry Entry to set the resource on
* @param requestBody Input data with "resource" field to be read
* @param groupUri GroupURI
* @return false if there is a resource provided, but it cannot be interpreted.
* @throws JacksonException Exception if request resource is a malformed JSON
*/
private boolean setResource(Context context, Entry entry, CreateEntryRequestBody requestBody, URI groupUri) throws JacksonException {
ContextManager cm = repositoryManager.getContextManager();
if (requestBody == null || StringUtils.isEmpty(requestBody.resource())) {
return true;
}
String resource = requestBody.resource().replaceAll("_newId", entry.getId());
switch (entry.getGraphType()) {
case User:
User user = (User) entry.getResource();
Map<String, Object> resourceMap = objectMapper.readValue(resource, new TypeReference<>() {
});
if (resourceMap.containsKey("name")) {
if (!user.setName(resourceMap.get("name").toString())) {
return false;
}
} else {
return false;
}
if (resourceMap.containsKey("homecontext")) {
Entry homeContextEntry = cm.get(resourceMap.get("homecontext").toString());
if (homeContextEntry != null) {
user.setHomeContext((Context) homeContextEntry.getResource());
}
}
if (groupUri != null) {
Entry groupEntry = cm.getEntry(groupUri);
if (groupEntry == null) {
throw new BadRequestException("No group found with URI " + groupUri);
}
Group group = (Group) groupEntry.getResource();
group.addMember(user);
}
break;
case Group:
Group group = (Group) entry.getResource();
Map<String, Object> groupResource = objectMapper.readValue(resource, new TypeReference<>() {
});
if (groupResource.containsKey("name")) {
group.setName(groupResource.get("name").toString());
}
break;
case List:
List list = (List) entry.getResource();
java.util.List<String> listResource = objectMapper.readValue(resource, new TypeReference<>() {
});
if (listResource != null) {
listResource.forEach(childUri -> {
Entry child = context.get(childUri);
if (child != null) {
list.addChild(child.getEntryURI());
}
});
}
break;
case Context:
Context cont = (Context) entry.getResource();
Map<String, Object> contResource = objectMapper.readValue(resource, new TypeReference<>() {
});
if (contResource.containsKey("name")) {
cm.setName(cont.getURI(), contResource.get("name").toString());
}
if (contResource.containsKey("quota")) {
String quotaString = contResource.get("quota").toString();
long quota;
try {
quota = Long.parseLong(quotaString);
} catch (NumberFormatException e) {
log.warn("Unable to parse new quota value '{}': {}", quotaString, e.getMessage());
throw new IllegalArgumentException("Unable to parse new quota value as Long: " + quotaString, e);
}
cont.setQuota(quota);
}
break;
case String:
StringResource stringRes = (StringResource) entry.getResource();
stringRes.setString(resource);
break;
case Graph:
case Pipeline:
RDFResource RDFRes = (RDFResource) entry.getResource();
Model g = RDFJSON.rdfJsonToGraph(resource);
RDFRes.setGraph(g);
break;
case PipelineResult:
case None:
break;
}
return true;
}
/**
* Extracts metadata from the request body and sets it as the entry's local metadata graph.
*
* @param entry The entry to set the metadata on
*/
private void setLocalMetadataGraph(Entry entry, CreateEntryRequestBody requestBody) {
if (requestBody == null || StringUtils.isEmpty(requestBody.metadata())) {
return;
}
if (EntryType.Reference.equals(entry.getEntryType())) {
return;
}
applyGraph(entry, requestBody.metadata(), "local metadata", graph -> entry.getLocalMetadata().setGraph(graph));
}
/**
* First caching of metadata.
*
* @param entry The entry to set the metadata on.
*/
private void setCachedMetadataGraph(Entry entry, CreateEntryRequestBody requestBody) {
if (requestBody == null || StringUtils.isEmpty(requestBody.cachedExternalMetadata())) {
return;
}
if (EntryType.Reference.equals(entry.getEntryType()) ||
EntryType.LinkReference.equals(entry.getEntryType())) {
applyGraph(entry, requestBody.cachedExternalMetadata(), "cached external metadata",
graph -> entry.getCachedExternalMetadata().setGraph(graph));
}
}
/**
* Rejects invalid RDF/JSON in the graphs of the request body that the entry type applies, before the entry is
* created, so that a rejected request takes no entry ID from the context's counter and leaves no deletion
* record. As in the graph setters, {@code _newId} is replaced with the given entry ID; a generated ID is not
* known yet, so it is replaced with the numeric stand-in "10". The generated ID can only make a difference
* where {@code _newId} adjoins other digits or a percent sign: a body that passes here and fails with the
* generated ID is removed again after creation, and one that fails here but would pass with it (for example
* {@code _newId} in an IPv6 literal after other digits) is rejected although no client sends such IRIs.
* Malformed JSON is left to the graph setters, which ignore it as in 5.x.
*
* @throws BadRequestException if a graph that the entry type applies is invalid RDF/JSON
*/
private void checkGraphsOfRequestBody(String entryId, EntryType entryType, CreateEntryRequestBody body) {
if (body == null) {
return;
}
String newId = entryId != null ? entryId : NEW_ID_STAND_IN;
if (entryType != Reference) {
checkGraph(body.metadata(), newId);
}
if (entryType == Reference || entryType == LinkReference) {
checkGraph(body.cachedExternalMetadata(), newId);
}
checkGraph(body.info(), newId);
}
private void checkGraph(String rawRdfJson, String newId) {
if (StringUtils.isEmpty(rawRdfJson)) {
return;
}
try {
RDFJSON.rdfJsonToGraph(new JSONObject(rawRdfJson.replaceAll("_newId", newId)));
} catch (JSONException e) {
// Ignored by the graph setters as well
} catch (RDFParseException e) {
throw new BadRequestException("Cannot create an entry with provided JSON/RDF", e);
}
}
/**
* Validates the external metadata URI that the entry graph in the request body sets, before the entry is
* created: the entry graph is applied only after the entry has been created, and a rejected URI must not leave
* a created entry behind. The entry graph may refer to the new entry as {@code _newId}; as in
* {@link #setEntryGraph}, this is replaced with the given ID, and without one the entry URI that the URI is
* validated against uses {@code _newId} as well. An entry that is not a Reference or LinkReference has no
* external metadata URI, so its entry graph must not set one.
*
* @throws InvalidExternalMetadataURIException if the entry graph sets an external metadata URI that is not
* acceptable, see {@link EntryImpl#checkExternalMetadataURI}, or
* sets one for an entry of another type
*/
private void checkExternalMetadataURIOfEntryGraph(Context context, String entryId, EntryType entryType,
CreateEntryRequestBody body) {
if (body == null || StringUtils.isEmpty(body.info())) {
return;
}
Model graph;
try {
// The entry graph as setEntryGraph applies it
String info = entryId != null ? body.info().replaceAll("_newId", entryId) : body.info();
graph = RDFJSON.rdfJsonToGraph(new JSONObject(info));
} catch (JSONException | RDFParseException e) {
return; // Malformed JSON is ignored by setEntryGraph, invalid RDF/JSON is rejected elsewhere
}
if (graph == null) {
return;
}
URL repositoryURL = repositoryManager.getRepositoryURL();
URI entryURI = URISplit.createURI(repositoryURL.toString(), context.getEntry().getId(),
RepositoryProperties.ENTRY_PATH, entryId != null ? entryId : "_newId");
IRI entryIRI = repositoryManager.getValueFactory().createIRI(entryURI.toString());
for (Statement statement : graph.filter(entryIRI, RepositoryProperties.externalMetadata, null)) {
if (statement.getObject() instanceof IRI externalMetadataIRI) {
if (entryType != Reference && entryType != LinkReference) {
throw new InvalidExternalMetadataURIException("An entry of type "
+ (entryType != null ? entryType : Local) + " has no external metadata URI");
}
EntryImpl.checkExternalMetadataURI(URI.create(externalMetadataIRI.stringValue()), entryURI,
repositoryURL);
}
}
}
/**
* Extracts entry info from the request body and sets it as the entry's graph.
* Since it assumes this is the creation step, the Entries URIs was not available
* on the client, hence the special "_newId" entryId has been used.
* Make sure this is replaced with the new entryId first.
*
* @param entry The entry to set the metadata on.
*/
private void setEntryGraph(Entry entry, CreateEntryRequestBody requestBody) {
if (requestBody == null || StringUtils.isEmpty(requestBody.info())) {
return;
}
applyGraph(entry, requestBody.info(), "entry info", entry::setGraph);
}
/**
* Parses an RDF/JSON string (with the client-side "_newId" placeholder replaced by the entry's
* id) and hands the resulting graph to {@code target}; a malformed body is logged at INFO and
* ignored, matching the graph setters' log-and-continue contract.
*/
private void applyGraph(Entry entry, String rawRdfJson, String description, Consumer<Model> target) {
try {
JSONObject jsonObj = new JSONObject(rawRdfJson.replaceAll("_newId", entry.getId()));
Model graph = RDFJSON.rdfJsonToGraph(jsonObj);
if (graph != null) {
target.accept(graph);
} else {
log.info("Could not convert {} to a graph for entry {}", description, entry.getEntryURI());
}
} catch (JSONException e) {
log.info("Failed to parse {} for entry {}: {}", description, entry.getEntryURI(), e.getMessage());
}
}
}