ResourceSerializationService.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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.rdf4j.model.Model;
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.Metadata;
import org.entrystore.PrincipalManager;
import org.entrystore.PrincipalManager.AccessProperty;
import org.entrystore.Resource;
import org.entrystore.User;
import org.entrystore.impl.DataImpl;
import org.entrystore.impl.RDFResource;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.impl.RepositoryProperties;
import org.entrystore.impl.StringResource;
import org.entrystore.repository.util.EntryUtil;
import org.entrystore.repository.util.URISplit;
import org.entrystore.rest.springboot.model.dto.ListParams;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.service.auth.LoginAttemptService;
import org.entrystore.rest.springboot.util.EntryInfoUtil;
import org.entrystore.rest.springboot.util.GraphUtil;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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.GraphType.SystemContext;
@Slf4j
@Service
@RequiredArgsConstructor
public class ResourceSerializationService {
public final static JSONObject IMMUTABLE_EMPTY_JSONOBJECT = new JSONObject(Collections.EMPTY_MAP);
private final PrincipalManager pm;
private final RepositoryManagerImpl repositoryManager;
private final LoginAttemptService loginAttemptService;
public JSONObject serializeResourceGroup(Resource resource, String rdfFormat) {
JSONObject resourceObj = new JSONObject();
if (resource instanceof Group group) {
try {
resourceObj.put("name", group.getName());
JSONArray userArray = new JSONArray();
for (User u : group.members()) {
JSONObject childJSON = new JSONObject();
childJSON.put("entryId", u.getEntry().getId());
childJSON.put("name", u.getName());
try {
if (u.isDisabled()) {
childJSON.put("disabled", true);
}
} catch (AuthorizationException ae) {
log.debug("Not allowed to read disabled status of [{}]", u.getEntry().getEntryURI());
}
Set<AccessProperty> rights = pm.getRights(u.getEntry());
Model info = getEntryInfo(u.getEntry(), mayReadNothing(u.getEntry(), rights));
childJSON.accumulate("info", GraphUtil.serializeGraphToJson(info, rdfFormat));
childJSON.put("rights", serializeRights(rights));
try {
JSONObject childMd = GraphUtil.serializeGraphToJson(u.getEntry().getLocalMetadata().getGraph(), rdfFormat);
childJSON.accumulate(RepositoryProperties.MD_PATH, childMd);
} catch (AuthorizationException ae) {
//childJSON.accumulate("noAccessToMetadata", true);
//TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
// Relations for every user in this group.
Model childRelationsGraph = u.getEntry().getRelations();
if (childRelationsGraph != null) {
JSONObject childRelationObj = GraphUtil.serializeGraphToJson(childRelationsGraph, rdfFormat);
childJSON.accumulate(RepositoryProperties.RELATION, childRelationObj);
}
userArray.put(childJSON);
}
resourceObj.put("children", userArray);
} catch (AuthorizationException ae) {
//jdilObj.accumulate("noAccessToResource", true);
//TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
} else {
throw new IllegalArgumentException("Resource not instance of Group");
}
return resourceObj;
}
public JSONObject serializeResourceUser(Resource resource) {
JSONObject resourceObj = new JSONObject();
if (resource instanceof User user) {
try {
resourceObj.put("name", user.getName());
Context homeContext = user.getHomeContext();
if (homeContext != null) {
resourceObj.put("homecontext", homeContext.getEntry().getId());
}
String prefLang = user.getLanguage();
if (prefLang != null) {
resourceObj.put("language", prefLang);
}
if (user.isDisabled()) {
resourceObj.put("disabled", true);
}
Instant lockedUntil = loginAttemptService.getLockedUntil(user.getName().toLowerCase());
if (lockedUntil != null) {
resourceObj.put("disabledUntil", lockedUntil);
}
JSONObject customProperties = new JSONObject();
for (Map.Entry<String, String> propEntry : user.getCustomProperties().entrySet()) {
customProperties.put(propEntry.getKey(), propEntry.getValue());
}
resourceObj.put("customProperties", customProperties);
} catch (AuthorizationException ae) {
//jdilObj.accumulate("noAccessToResource", true);
// TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
} else {
throw new IllegalArgumentException("Resource not instance of User");
}
return resourceObj;
}
public JSONObject serializeResourceList(Resource resource, ListParams params, String rdfFormat) {
ContextManager cm = repositoryManager.getContextManager();
JSONObject resourceObj = new JSONObject();
if (resource instanceof org.entrystore.List list) {
int limit = Math.min(params.limit(), 100);
int offset = Math.max(params.offset(), 0);
try {
JSONArray childrenArray = new JSONArray();
int maxPos = offset + limit;
if (limit == 0) {
maxPos = Integer.MAX_VALUE;
}
List<URI> childrenURIs = list.getChildren();
Set<String> childrenIDs = new HashSet<>();
List<Entry> childrenEntries = new ArrayList<>();
for (URI uri : childrenURIs) {
String id = URISplit.getLastSegment(uri.toString());
childrenIDs.add(id);
Entry childEntry = list.getEntry().getContext().get(id);
if (childEntry != null) {
childrenEntries.add(childEntry);
} else {
log.warn("Child resource [{}] in context [{}] does not exist, but is referenced by a list.", id, cm.getURI());
}
}
if (params.sort() != null && (childrenEntries.size() < 501)) {
sortChildrenEntries(childrenEntries, params);
} else if (params.sort() != null) {
log.warn("Ignoring sort parameter for performance reasons because list has more than 500 children");
}
//for (int i = 0; i < childrenURIs.size(); i++) {
for (int i = offset; i < maxPos && i < childrenEntries.size(); i++) {
JSONObject childJSON = new JSONObject();
Entry childEntry = childrenEntries.get(i);
/*
* Children-rights
*/
Set<AccessProperty> rights = pm.getRights(childEntry);
childJSON.put("rights", serializeRights(rights));
String entryId = URISplit.getLastSegment(childEntry.getEntryURI().toString());
childJSON.put("entryId", entryId);
GraphType childGraphType = childEntry.getGraphType();
EntryType childEntryType = childEntry.getEntryType();
if ((childGraphType == GraphType.Context || childGraphType == SystemContext) && childEntryType == Local) {
childJSON.put("name", cm.getName(childEntry.getResourceURI()));
} else if (childGraphType == GraphType.List) {
Resource childResource = childEntry.getResource();
if (childResource instanceof org.entrystore.List childList) {
try {
childJSON.put("size", childList.getChildren().size());
} catch (AuthorizationException ae) {
//TODO: Should we do something here?
}
} else {
log.warn("Entry has ResourceType.List but the resource is either null or not an instance of List");
}
} else if (childGraphType == GraphType.User && childEntryType == Local) {
childJSON.put("name", ((User) childEntry.getResource()).getName());
} else if (childGraphType == GraphType.Group && childEntryType == Local) {
childJSON.put("name", ((Group) childEntry.getResource()).getName());
}
appendMetadataInfoAndRelations(childEntry, childJSON, rdfFormat, false,
mayReadNothing(childEntry, rights));
childrenArray.put(childJSON);
}
resourceObj.put("children", childrenArray);
resourceObj.put("size", childrenURIs.size());
resourceObj.put("limit", limit);
resourceObj.put("offset", offset);
JSONArray childrenIDArray = new JSONArray();
for (String id : childrenIDs) {
childrenIDArray.put(id);
}
resourceObj.put("allUnsorted", childrenIDArray);
} catch (AuthorizationException ae) {
//jdilObj.accumulate("noAccessToResource", true);
//TODO: Replaced by using "rights" in json, do something else in this catch-clause
}
} else {
throw new IllegalArgumentException("Resource not instance of List");
}
return resourceObj;
}
/**
* Appends the cached-external-metadata, local-metadata, entry-info and relations sections of an
* entry to {@code childJSON}. With {@code flagNoAccess=true} (search behavior) the two metadata
* sections share one guard that records a {@code noAccessToMetadata} flag (an
* {@link AuthorizationException} on external metadata also skips local metadata), while the
* entry-info and relations sections each record {@code noAccessToEntryInfo} /
* {@code noAccessToRelations}. With {@code flagNoAccess=false} (list behavior) a metadata
* {@link AuthorizationException} is swallowed silently, the entry info is reduced for a caller who
* {@linkplain #mayReadNothing may read nothing} of the entry, and the info/relations sections are left unguarded
* so the exception propagates to the caller. Search hits keep the full entry info, since a hit already requires
* ReadMetadata.
*/
public void appendMetadataInfoAndRelations(Entry entry, JSONObject childJSON, String rdfFormat, boolean flagNoAccess) {
appendMetadataInfoAndRelations(entry, childJSON, rdfFormat, flagNoAccess,
!flagNoAccess && mayReadNothing(entry));
}
/**
* See {@link #appendMetadataInfoAndRelations(Entry, JSONObject, String, boolean)}, with the decision whether to
* reduce the entry info already made; it is ignored with {@code flagNoAccess}.
*/
private void appendMetadataInfoAndRelations(Entry entry, JSONObject childJSON, String rdfFormat,
boolean flagNoAccess, boolean reduceInfo) {
try {
EntryType entryType = entry.getEntryType();
if (entryType == Reference || entryType == LinkReference) {
Metadata cachedExternalMetadata = entry.getCachedExternalMetadata();
if (cachedExternalMetadata != null) {
Model cachedExternalMetadataGraph = cachedExternalMetadata.getGraph();
if (cachedExternalMetadataGraph != null) {
childJSON.accumulate(RepositoryProperties.EXTERNAL_MD_PATH,
GraphUtil.serializeGraphToJson(cachedExternalMetadataGraph, rdfFormat));
}
}
}
if (entryType == Local || entryType == Link || entryType == LinkReference) {
Metadata localMetadata = entry.getLocalMetadata();
if (localMetadata != null) {
Model localMetadataGraph = localMetadata.getGraph();
if (localMetadataGraph != null) {
childJSON.accumulate(RepositoryProperties.MD_PATH,
GraphUtil.serializeGraphToJson(localMetadataGraph, rdfFormat));
}
}
}
} catch (AuthorizationException ae) {
if (flagNoAccess) {
childJSON.accumulate("noAccessToMetadata", true);
}
}
if (flagNoAccess) {
try {
JSONObject info = GraphUtil.serializeGraphToJson(entry.getGraph(), rdfFormat);
childJSON.accumulate("info", Objects.requireNonNullElseGet(info, JSONObject::new));
} catch (AuthorizationException ae) {
childJSON.accumulate("noAccessToEntryInfo", true);
}
try {
Model relationsGraph = entry.getRelations();
if (relationsGraph != null) {
childJSON.accumulate(RepositoryProperties.RELATION,
GraphUtil.serializeGraphToJson(relationsGraph, rdfFormat));
}
} catch (AuthorizationException ae) {
childJSON.accumulate("noAccessToRelations", true);
}
} else {
childJSON.accumulate("info", GraphUtil.serializeGraphToJson(getEntryInfo(entry, reduceInfo), rdfFormat));
Model relationsGraph = entry.getRelations();
if (relationsGraph != null) {
childJSON.accumulate(RepositoryProperties.RELATION,
GraphUtil.serializeGraphToJson(relationsGraph, rdfFormat));
}
}
}
/**
* Sorts {@code children} in place according to the sort/lang/prio/order list parameters.
* Applying the >500-children performance cap is the caller's decision — the two list
* serializers count children differently.
*/
public static void sortChildrenEntries(List<Entry> children, ListParams params) {
Date before = new Date();
GraphType prioritizedGraphType = null;
if (params.prio() != null) {
try {
prioritizedGraphType = GraphType.valueOf(params.prio());
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid value for parameter 'prio': " + params.prio());
}
}
String sortType = params.sort();
if ("title".equalsIgnoreCase(sortType)) {
EntryUtil.sortAfterTitle(children, params.lang(), params.ascendingOrder(), prioritizedGraphType);
} else if ("modified".equalsIgnoreCase(sortType)) {
EntryUtil.sortAfterModificationDate(children, params.ascendingOrder(), prioritizedGraphType);
} else if ("created".equalsIgnoreCase(sortType)) {
EntryUtil.sortAfterCreationDate(children, params.ascendingOrder(), prioritizedGraphType);
} else if ("size".equalsIgnoreCase(sortType)) {
EntryUtil.sortAfterFileSize(children, params.ascendingOrder(), prioritizedGraphType);
}
long sortDuration = new Date().getTime() - before.getTime();
log.debug("List sorting took {} ms", sortDuration);
}
public JSONObject serializeResourceGraph(Resource resource, String rdfFormat) {
if (resource instanceof RDFResource rdf) {
if (rdf.getGraph() != null) {
return GraphUtil.serializeGraphToJson(rdf.getGraph(), rdfFormat);
} else {
throw new IllegalArgumentException("Graph is empty in RDFResource");
}
} else {
throw new IllegalArgumentException("Resource not instance of RDFResource");
}
}
public JSONObject serializeResourcePipeline(Resource resource, String rdfFormat) {
return serializeResourceGraph(resource, rdfFormat);
}
public String serializeResourceString(Resource resource) {
if (resource instanceof StringResource string) {
return string.getString();
} else {
throw new IllegalArgumentException("Resource not instance of StringResource");
}
}
public JSONObject serializeResourceNone(Resource resource) {
JSONObject resourceObj = new JSONObject();
String digest = readDigest(resource.getEntry());
if (digest != null) {
resourceObj.put("sha256", digest);
}
return resourceObj;
}
/**
* Returns the stored SHA-256 hex digest of a local binary entry's data, or null when the resource is not a
* {@link DataImpl} or its digest file is missing or unreadable.
*
* @throws AuthorizationException if the resource is a {@link DataImpl} the current user may not read
*/
public String readDigest(Entry entry) {
String digest = entry.getResource() instanceof DataImpl data ? data.readDigest() : null;
if (digest == null) {
log.debug("No digest available for [{}]", entry.getResourceURI());
}
return digest;
}
public JSONArray serializeResourceContext(Resource resource) {
JSONArray array = new JSONArray();
if (resource instanceof Context context) {
Set<URI> uris = context.getEntries();
for (URI u : uris) {
array.put(URISplit.getLastSegment(u.toASCIIString()));
}
} else {
throw new IllegalArgumentException("Resource not instance of Context");
}
return array;
}
public JSONArray serializeResourceSystemContext(Resource resource) {
return serializeResourceContext(resource);
}
/**
* Returns the entry information graph, or, if {@code mayReadNothing}, the part of it that
* {@link EntryInfoUtil#reduceForNonReader} keeps.
*/
public Model getEntryInfo(Entry entry, boolean mayReadNothing) {
Model graph = entry.getGraph();
if (!mayReadNothing) {
return graph;
}
return EntryInfoUtil.reduceForNonReader(graph, entry.getEntryURI(), entry.getResourceURI(),
entry.getCachedExternalMetadataURI());
}
/**
* Whether the current user may read neither the entry's metadata nor its resource. Follows
* {@link PrincipalManager#checkAuthenticatedUserAuthorized}: admins, context owners, users reading their own
* principal entry and write access all count as read access.
*/
public boolean mayReadNothing(Entry entry) {
return mayReadNothing(entry, pm.getRights(entry));
}
/**
* Like {@link #mayReadNothing(Entry)}, from the rights {@link PrincipalManager#getRights} returned for the entry.
* Every right implies read access to the metadata or the resource, so only an empty set reads nothing, except
* for a user's own principal entry, which {@code getRights} does not consider.
*/
public boolean mayReadNothing(Entry entry, Set<AccessProperty> rights) {
if (!rights.isEmpty()) {
return false;
}
URI currentUser = pm.getAuthenticatedUserURI();
return currentUser == null || !currentUser.equals(entry.getResourceURI());
}
public JSONArray serializeRights(Entry entry) throws JSONException {
return serializeRights(pm.getRights(entry));
}
public JSONArray serializeRights(Set<AccessProperty> rights) throws JSONException {
JSONArray resourceObj = new JSONArray();
rights.forEach(ap -> {
switch (ap) {
case Administer -> resourceObj.put("administer");
case WriteMetadata -> resourceObj.put("writemetadata");
case WriteResource -> resourceObj.put("writeresource");
case ReadMetadata -> resourceObj.put("readmetadata");
case ReadResource -> resourceObj.put("readresource");
}
});
return resourceObj;
}
}