View Javadoc
1   /*
2    * This file is part of ***  M y C o R e  ***
3    * See http://www.mycore.de/ for details.
4    *
5    * MyCoRe is free software: you can redistribute it and/or modify
6    * it under the terms of the GNU General Public License as published by
7    * the Free Software Foundation, either version 3 of the License, or
8    * (at your option) any later version.
9    *
10   * MyCoRe is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with MyCoRe.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package org.mycore.restapi.v2;
20  
21  import static org.mycore.common.MCRConstants.XSI_NAMESPACE;
22  import static org.mycore.restapi.v2.MCRRestAuthorizationFilter.PARAM_MCRID;
23  
24  import java.awt.image.BufferedImage;
25  import java.io.FileNotFoundException;
26  import java.io.IOException;
27  import java.io.InputStream;
28  import java.io.UncheckedIOException;
29  import java.lang.annotation.Annotation;
30  import java.net.URI;
31  import java.nio.file.Files;
32  import java.nio.file.attribute.FileTime;
33  import java.util.Collection;
34  import java.util.Collections;
35  import java.util.Date;
36  import java.util.List;
37  import java.util.Map;
38  import java.util.Objects;
39  import java.util.Optional;
40  import java.util.concurrent.TimeUnit;
41  import java.util.function.Predicate;
42  import java.util.stream.Collectors;
43  
44  import jakarta.ws.rs.DefaultValue;
45  import jakarta.ws.rs.QueryParam;
46  import jakarta.ws.rs.core.UriBuilder;
47  import org.apache.commons.lang3.reflect.TypeUtils;
48  import org.apache.logging.log4j.LogManager;
49  import org.apache.logging.log4j.Logger;
50  import org.jdom2.Document;
51  import org.jdom2.Element;
52  import org.jdom2.JDOMException;
53  import org.mycore.access.MCRAccessException;
54  import org.mycore.access.MCRAccessManager;
55  import org.mycore.common.MCRCoreVersion;
56  import org.mycore.common.MCRException;
57  import org.mycore.common.MCRPersistenceException;
58  import org.mycore.common.config.MCRConfiguration2;
59  import org.mycore.common.content.MCRContent;
60  import org.mycore.common.content.MCRJDOMContent;
61  import org.mycore.common.content.MCRStreamContent;
62  import org.mycore.common.content.MCRStringContent;
63  import org.mycore.common.xml.MCRXMLParserFactory;
64  import org.mycore.datamodel.classifications2.MCRCategoryDAOFactory;
65  import org.mycore.datamodel.classifications2.MCRCategoryID;
66  import org.mycore.datamodel.common.MCRAbstractMetadataVersion;
67  import org.mycore.datamodel.common.MCRActiveLinkException;
68  import org.mycore.datamodel.common.MCRObjectIDDate;
69  import org.mycore.datamodel.common.MCRXMLMetadataManager;
70  import org.mycore.datamodel.metadata.MCRMetadataManager;
71  import org.mycore.datamodel.metadata.MCRObject;
72  import org.mycore.datamodel.metadata.MCRObjectID;
73  import org.mycore.datamodel.niofs.MCRPath;
74  import org.mycore.datamodel.objectinfo.MCRObjectQuery;
75  import org.mycore.datamodel.objectinfo.MCRObjectQueryResolver;
76  import org.mycore.frontend.jersey.MCRCacheControl;
77  import org.mycore.media.services.MCRThumbnailGenerator;
78  import org.mycore.restapi.annotations.MCRAccessControlExposeHeaders;
79  import org.mycore.restapi.annotations.MCRApiDraft;
80  import org.mycore.restapi.annotations.MCRParam;
81  import org.mycore.restapi.annotations.MCRParams;
82  import org.mycore.restapi.annotations.MCRRequireTransaction;
83  import org.mycore.restapi.converter.MCRContentAbstractWriter;
84  import org.mycore.restapi.v2.model.MCRRestObjectIDDate;
85  import org.xml.sax.SAXException;
86  import org.xml.sax.SAXParseException;
87  
88  import com.fasterxml.jackson.databind.SerializationFeature;
89  
90  import com.fasterxml.jackson.jakarta.rs.annotation.JacksonFeatures;
91  import io.swagger.v3.oas.annotations.OpenAPIDefinition;
92  import io.swagger.v3.oas.annotations.Operation;
93  import io.swagger.v3.oas.annotations.Parameter;
94  import io.swagger.v3.oas.annotations.headers.Header;
95  import io.swagger.v3.oas.annotations.media.ArraySchema;
96  import io.swagger.v3.oas.annotations.media.Content;
97  import io.swagger.v3.oas.annotations.media.ExampleObject;
98  import io.swagger.v3.oas.annotations.media.Schema;
99  import io.swagger.v3.oas.annotations.parameters.RequestBody;
100 import io.swagger.v3.oas.annotations.responses.ApiResponse;
101 import io.swagger.v3.oas.annotations.tags.Tag;
102 import jakarta.servlet.ServletContext;
103 import jakarta.ws.rs.BadRequestException;
104 import jakarta.ws.rs.Consumes;
105 import jakarta.ws.rs.DELETE;
106 import jakarta.ws.rs.ForbiddenException;
107 import jakarta.ws.rs.GET;
108 import jakarta.ws.rs.InternalServerErrorException;
109 import jakarta.ws.rs.NotFoundException;
110 import jakarta.ws.rs.POST;
111 import jakarta.ws.rs.PUT;
112 import jakarta.ws.rs.Path;
113 import jakarta.ws.rs.PathParam;
114 import jakarta.ws.rs.Produces;
115 import jakarta.ws.rs.core.Context;
116 import jakarta.ws.rs.core.GenericEntity;
117 import jakarta.ws.rs.core.HttpHeaders;
118 import jakarta.ws.rs.core.MediaType;
119 import jakarta.ws.rs.core.Request;
120 import jakarta.ws.rs.core.Response;
121 import jakarta.ws.rs.core.UriInfo;
122 import jakarta.xml.bind.annotation.XmlElementWrapper;
123 
124 @Path("/objects")
125 @OpenAPIDefinition(tags = {
126     @Tag(name = MCRRestUtils.TAG_MYCORE_OBJECT, description = "Operations on metadata objects"),
127     @Tag(name = MCRRestUtils.TAG_MYCORE_DERIVATE,
128         description = "Operations on derivates belonging to metadata objects"),
129     @Tag(name = MCRRestUtils.TAG_MYCORE_FILE, description = "Operations on files in derivates"),
130 })
131 public class MCRRestObjects {
132 
133     public static final String PARAM_AFTER_ID = "after_id";
134 
135     public static final String PARAM_OFFSET = "offset";
136 
137     public static final String PARAM_LIMIT = "limit";
138 
139     public static final String PARAM_TYPE = "type";
140 
141     public static final String PARAM_PROJECT = "project";
142 
143     public static final String PARAM_NUMBER_GREATER = "number_greater";
144 
145     public static final String PARAM_NUMBER_LESS = "number_less";
146 
147     public static final String PARAM_CREATED_AFTER = "created_after";
148 
149     public static final String PARAM_CREATED_BEFORE = "created_before";
150 
151     public static final String PARAM_MODIFIED_AFTER = "modified_after";
152 
153     public static final String PARAM_MODIFIED_BEFORE = "modified_before";
154 
155     public static final String PARAM_DELETED_AFTER = "deleted_after";
156 
157     public static final String PARAM_DELETED_BEFORE = "deleted_before";
158 
159     public static final String PARAM_CREATED_BY = "created_by";
160 
161     public static final String PARAM_MODIFIED_BY = "modified_by";
162 
163     public static final String PARAM_DELETED_BY = "deleted_by";
164 
165     public static final String PARAM_SORT_ORDER = "sort_order";
166 
167     public static final String PARAM_SORT_BY = "sort_by";
168 
169     public static final List<MCRThumbnailGenerator> THUMBNAIL_GENERATORS = Collections
170         .unmodifiableList(MCRConfiguration2
171             .getOrThrow("MCR.Media.Thumbnail.Generators", MCRConfiguration2::splitValue)
172             .map(MCRConfiguration2::instantiateClass)
173             .map(MCRThumbnailGenerator.class::cast)
174             .collect(Collectors.toList()));
175 
176     private static final String PARAM_CATEGORIES = "category";
177 
178     private static final Logger LOGGER = LogManager.getLogger();
179 
180     private static final int PAGE_SIZE_MAX = MCRConfiguration2.getInt("MCR.RestAPI.V2.ListObjects.PageSize.Max")
181         .orElseThrow();
182 
183     private static final int PAGE_SIZE_DEFAULT = MCRConfiguration2
184         .getInt("MCR.RestAPI.V2.ListObjects.PageSize.Default")
185         .orElse(1000);
186 
187     @Context
188     Request request;
189 
190     @Context
191     ServletContext context;
192 
193     @Context
194     UriInfo uriInfo;
195 
196     @GET
197     @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON + ";charset=UTF-8" })
198     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.HOURS),
199         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.HOURS))
200     @Operation(
201         summary = "Lists all objects in this repository",
202         parameters = {
203             @Parameter(
204                 name = PARAM_AFTER_ID,
205                 description = "the id after which the results should be listed. Do not use after_id and offset " +
206                     "together."),
207             @Parameter(
208                 name = PARAM_OFFSET,
209                 description = "dictates the number of rows to skip from the beginning of the returned data before " +
210                     "presenting the results. Do not use after_id and offset together."),
211             @Parameter(
212                 name = PARAM_LIMIT,
213                 description = "limits the number of result returned"),
214             @Parameter(
215                 name = PARAM_TYPE,
216                 description = "objects with have the type in the id"),
217             @Parameter(
218                 name = PARAM_PROJECT,
219                 description = "only objects that have the project in the id"),
220             @Parameter(
221                 name = PARAM_NUMBER_GREATER,
222                 description = "only objects which id have a number greater than this"),
223             @Parameter(
224                 name = PARAM_NUMBER_LESS,
225                 description = "only objects which id have a number less than this"),
226             @Parameter(
227                 name = PARAM_CREATED_AFTER,
228                 description = "objects created after this date"),
229             @Parameter(
230                 name = PARAM_CREATED_BEFORE,
231                 description = "objects created before this date"),
232             @Parameter(
233                 name = PARAM_MODIFIED_AFTER,
234                 description = "objects last modified after this date"),
235             @Parameter(
236                 name = PARAM_MODIFIED_BEFORE,
237                 description = "objects last modified before this date"),
238             @Parameter(
239                 name = PARAM_MODIFIED_AFTER,
240                 description = "objects last modified after this date"),
241             @Parameter(
242                 name = PARAM_MODIFIED_BEFORE,
243                 description = "objects last modified before this date"),
244             @Parameter(
245                 name = PARAM_SORT_ORDER,
246                 description = "sort results 'asc' or 'desc'"),
247             @Parameter(
248                 name = PARAM_SORT_BY,
249                 description = "sort objects by 'id', 'created' (default) or 'modified'")
250         },
251         responses = @ApiResponse(
252             content = @Content(array = @ArraySchema(schema = @Schema(implementation = MCRObjectIDDate.class)))),
253         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
254     @XmlElementWrapper(name = "mycoreobjects")
255     @JacksonFeatures(serializationDisable = { SerializationFeature.WRITE_DATES_AS_TIMESTAMPS })
256     public Response listObjects(
257         @QueryParam(PARAM_AFTER_ID) MCRObjectID afterID,
258         @QueryParam(PARAM_OFFSET) Integer offset,
259         @QueryParam(PARAM_LIMIT) Integer limit,
260         @QueryParam(PARAM_TYPE) String type,
261         @QueryParam(PARAM_PROJECT) String project,
262         @QueryParam(PARAM_NUMBER_GREATER) Integer numberGreater,
263         @QueryParam(PARAM_NUMBER_LESS) Integer numberLess,
264         @QueryParam(PARAM_CREATED_AFTER) Date createdAfter,
265         @QueryParam(PARAM_CREATED_BEFORE) Date createdBefore,
266         @QueryParam(PARAM_MODIFIED_AFTER) Date modifiedAfter,
267         @QueryParam(PARAM_MODIFIED_BEFORE) Date modifiedBefore,
268         @QueryParam(PARAM_DELETED_AFTER) Date deletedAfter,
269         @QueryParam(PARAM_DELETED_BEFORE) Date deletedBefore,
270         @QueryParam(PARAM_CREATED_BY) String createdBy,
271         @QueryParam(PARAM_MODIFIED_BY) String modifiedBy,
272         @QueryParam(PARAM_DELETED_BY) String deletedBy,
273         @QueryParam(PARAM_SORT_BY) @DefaultValue("id") MCRObjectQuery.SortBy sortBy,
274         @QueryParam(PARAM_SORT_ORDER) @DefaultValue("asc") MCRObjectQuery.SortOrder sortOrder,
275         @QueryParam(PARAM_CATEGORIES) List<String> categories) throws IOException {
276 
277         MCRObjectQuery query = new MCRObjectQuery();
278         int limitInt = Optional.ofNullable(limit)
279             .map(l -> Integer.min(l, PAGE_SIZE_MAX))
280             .orElse(PAGE_SIZE_DEFAULT);
281 
282         query.limit(limitInt);
283 
284         Optional.ofNullable(offset).ifPresent(query::offset);
285         Optional.ofNullable(afterID).ifPresent(query::afterId);
286 
287         Optional.ofNullable(type).ifPresent(query::type);
288         Optional.ofNullable(project).ifPresent(query::project);
289 
290         Optional.ofNullable(modifiedAfter).map(Date::toInstant).ifPresent(query::modifiedAfter);
291         Optional.ofNullable(modifiedBefore).map(Date::toInstant).ifPresent(query::modifiedBefore);
292 
293         Optional.ofNullable(createdAfter).map(Date::toInstant).ifPresent(query::createdAfter);
294         Optional.ofNullable(createdBefore).map(Date::toInstant).ifPresent(query::createdBefore);
295 
296         Optional.ofNullable(deletedAfter).map(Date::toInstant).ifPresent(query::deletedAfter);
297         Optional.ofNullable(deletedBefore).map(Date::toInstant).ifPresent(query::deletedBefore);
298 
299         Optional.ofNullable(createdBy).ifPresent(query::createdBy);
300         Optional.ofNullable(modifiedBy).ifPresent(query::modifiedBy);
301         Optional.ofNullable(deletedBy).ifPresent(query::deletedBy);
302 
303         Optional.ofNullable(numberGreater).ifPresent(query::numberGreater);
304         Optional.ofNullable(numberLess).ifPresent(query::numberLess);
305 
306         List<String> includeCategories = query.getIncludeCategories();
307         categories.stream()
308             .filter(Predicate.not(String::isBlank))
309             .forEach(includeCategories::add);
310 
311         query.sort(sortBy, sortOrder);
312 
313         MCRObjectQueryResolver queryResolver = MCRObjectQueryResolver.getInstance();
314 
315         List<MCRObjectIDDate> idDates = limitInt == 0 ? Collections.emptyList() : queryResolver.getIdDates(query);
316 
317         List<MCRRestObjectIDDate> restIdDate = idDates.stream().map(MCRRestObjectIDDate::new)
318             .collect(Collectors.toList());
319 
320         int count = queryResolver.count(query);
321         UriBuilder nextBuilder = null;
322         if (query.afterId() != null && idDates.size() == limitInt) {
323             nextBuilder = uriInfo.getRequestUriBuilder();
324             nextBuilder.replaceQueryParam(PARAM_AFTER_ID, idDates.get(idDates.size() - 1).getId());
325         } else {
326             if (query.offset() + query.limit() < count) {
327                 nextBuilder = uriInfo.getRequestUriBuilder();
328                 nextBuilder.replaceQueryParam(PARAM_OFFSET, String.valueOf(query.offset() + limitInt));
329             }
330         }
331 
332         Response.ResponseBuilder responseBuilder = Response.ok(new GenericEntity<>(restIdDate) {
333         })
334             .header("X-Total-Count", count);
335 
336         if (nextBuilder != null) {
337             responseBuilder.link("next", nextBuilder.toString());
338         }
339 
340         return responseBuilder.build();
341     }
342 
343     @POST
344     @Operation(
345         summary = "Create a new MyCoRe Object",
346         responses = @ApiResponse(responseCode = "201",
347             headers = @Header(name = HttpHeaders.LOCATION, description = "URL of the new MyCoRe Object")),
348         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
349     @Consumes(MediaType.APPLICATION_XML)
350     @RequestBody(required = true,
351         content = @Content(mediaType = MediaType.APPLICATION_XML))
352     @MCRRequireTransaction
353     @MCRAccessControlExposeHeaders(HttpHeaders.LOCATION)
354     public Response createObject(String xml) {
355         try {
356             Document doc = MCRXMLParserFactory.getNonValidatingParser().parseXML(new MCRStringContent(xml));
357             Element eMCRObj = doc.getRootElement();
358             if (eMCRObj.getAttributeValue("ID") != null) {
359                 MCRObjectID id = MCRObjectID.getInstance(eMCRObj.getAttributeValue("ID"));
360                 MCRObjectID newID = MCRObjectID.getNextFreeId(id.getBase());
361                 eMCRObj.setAttribute("ID", newID.toString());
362                 if (eMCRObj.getAttribute("label") == null) {
363                     eMCRObj.setAttribute("label", newID.toString());
364                 }
365                 if (eMCRObj.getAttribute("version") == null) {
366                     eMCRObj.setAttribute("version", MCRCoreVersion.getVersion());
367                 }
368                 eMCRObj.setAttribute("noNamespaceSchemaLocation", "datamodel-" + newID.getTypeId() + ".xsd",
369                     XSI_NAMESPACE);
370             } else {
371                 //TODO error handling
372                 throw new BadRequestException("Please provide an object with ID");
373             }
374 
375             MCRObject mcrObj = new MCRObject(new MCRJDOMContent(doc).asByteArray(), true);
376             LOGGER.debug("Create new MyCoRe Object");
377             MCRMetadataManager.create(mcrObj);
378             return Response.created(uriInfo.getAbsolutePathBuilder().path(mcrObj.getId().toString()).build()).build();
379         } catch (MCRPersistenceException | SAXParseException | IOException e) {
380             throw new InternalServerErrorException(e);
381         } catch (MCRAccessException e) {
382             throw new ForbiddenException(e);
383         }
384     }
385 
386     @GET
387     @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON + ";charset=UTF-8" })
388     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS),
389         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS))
390     @Path("/{" + PARAM_MCRID + "}")
391     @Operation(
392         summary = "Returns MCRObject with the given " + PARAM_MCRID + ".",
393         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
394     public Response getObject(@Parameter(example = "mir_mods_00004711") @PathParam(PARAM_MCRID) MCRObjectID id)
395         throws IOException {
396         long modified = MCRXMLMetadataManager.instance().getLastModified(id);
397         if (modified < 0) {
398             throw new NotFoundException("MCRObject " + id + " not found");
399         }
400         Date lastModified = new Date(modified);
401         Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lastModified);
402         if (cachedResponse.isPresent()) {
403             return cachedResponse.get();
404         }
405         MCRContent mcrContent = MCRXMLMetadataManager.instance().retrieveContent(id);
406         return Response.ok()
407             .entity(mcrContent,
408                 new Annotation[] { MCRParams.Factory
409                     .get(MCRParam.Factory.get(MCRContentAbstractWriter.PARAM_OBJECTTYPE, id.getTypeId())) })
410             .lastModified(lastModified)
411             .build();
412     }
413 
414     @GET
415     @Produces({ MediaType.APPLICATION_XML })
416     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS),
417         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS))
418     @Path("/{" + PARAM_MCRID + "}/metadata")
419     @Operation(
420         summary = "Returns metadata section MCRObject with the given " + PARAM_MCRID + ".",
421         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
422     public Response getObjectMetadata(@Parameter(example = "mir_mods_00004711") @PathParam(PARAM_MCRID) MCRObjectID id)
423         throws IOException {
424         long modified = MCRXMLMetadataManager.instance().getLastModified(id);
425         if (modified < 0) {
426             throw new NotFoundException("MCRObject " + id + " not found");
427         }
428         Date lastModified = new Date(modified);
429         Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lastModified);
430         if (cachedResponse.isPresent()) {
431             return cachedResponse.get();
432         }
433         MCRObject mcrObj = MCRMetadataManager.retrieveMCRObject(id);
434         MCRContent mcrContent = new MCRJDOMContent(mcrObj.getMetadata().createXML());
435         return Response.ok()
436             .entity(mcrContent,
437                 new Annotation[] { MCRParams.Factory
438                     .get(MCRParam.Factory.get(MCRContentAbstractWriter.PARAM_OBJECTTYPE, id.getTypeId())) })
439             .lastModified(lastModified)
440             .build();
441     }
442 
443     @GET
444     @Path("{" + PARAM_MCRID + "}/thumb-{size}.{ext : (jpg|jpeg|png)}")
445     @Produces({ "image/png", "image/jpeg" })
446     @Operation(
447         summary = "Returns thumbnail of MCRObject with the given " + PARAM_MCRID + ".",
448         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
449     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS),
450         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS))
451     public Response getThumbnailWithSize(@PathParam(PARAM_MCRID) String id, @PathParam("size") int size,
452         @PathParam("ext") String ext) {
453         return getThumbnail(id, size, ext);
454     }
455 
456     @GET
457     @Path("{" + PARAM_MCRID + "}/thumb.{ext : (jpg|jpeg|png)}")
458     @Produces({ "image/png", "image/jpeg" })
459     @Operation(
460         summary = "Returns thumbnail of MCRObject with the given " + PARAM_MCRID + ".",
461         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
462     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS),
463         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS))
464     public Response getThumbnail(@PathParam(PARAM_MCRID) String id, @PathParam("ext") String ext) {
465         int defaultSize = MCRConfiguration2.getOrThrow("MCR.Media.Thumbnail.DefaultSize", Integer::parseInt);
466         return getThumbnail(id, defaultSize, ext);
467     }
468 
469     private Response getThumbnail(String id, int size, String ext) {
470         List<MCRPath> mainDocs = MCRMetadataManager.getDerivateIds(MCRObjectID.getInstance(id), 1, TimeUnit.MINUTES)
471             .stream()
472             .filter(d -> MCRAccessManager.checkDerivateContentPermission(d, MCRAccessManager.PERMISSION_READ))
473             .map(d -> {
474                 String nameOfMainFile = MCRMetadataManager.retrieveMCRDerivate(d).getDerivate().getInternals()
475                     .getMainDoc();
476                 return nameOfMainFile.isEmpty() ? null : MCRPath.getPath(d.toString(), '/' + nameOfMainFile);
477             })
478             .filter(Objects::nonNull)
479             .collect(Collectors.toList());
480 
481         if (mainDocs.isEmpty()) {
482             throw new NotFoundException();
483         }
484 
485         for (MCRPath mainDoc : mainDocs) {
486             Optional<MCRThumbnailGenerator> thumbnailGenerator = THUMBNAIL_GENERATORS.stream()
487                 .filter(g -> g.matchesFileType(context.getMimeType(mainDoc.getFileName().toString()), mainDoc))
488                 .findFirst();
489             if (thumbnailGenerator.isPresent()) {
490                 try {
491                     FileTime lastModified = Files.getLastModifiedTime(mainDoc);
492                     Date lastModifiedDate = new Date(lastModified.toMillis());
493                     Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lastModifiedDate);
494                     return cachedResponse.orElseGet(() -> {
495                         Optional<BufferedImage> thumbnail = null;
496                         try {
497                             thumbnail = thumbnailGenerator.get().getThumbnail(mainDoc, size);
498                         } catch (IOException e) {
499                             throw new UncheckedIOException(e);
500                         }
501                         return thumbnail.map(b -> {
502                             String type = "image/png";
503                             if ("jpg".equals(ext) || "jpeg".equals(ext)) {
504                                 type = "image/jpeg";
505                             }
506                             return Response.ok(b)
507                                 .lastModified(lastModifiedDate)
508                                 .type(type)
509                                 .build();
510                         }).orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
511                     });
512                 } catch (FileNotFoundException e) {
513                     continue; //try another mainDoc if present
514                 } catch (IOException e) {
515                     throw new InternalServerErrorException(e);
516                 } catch (UncheckedIOException e) {
517                     throw new InternalServerErrorException(e.getCause());
518                 }
519             }
520         }
521 
522         throw new NotFoundException();
523     }
524 
525     @GET
526     @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON + ";charset=UTF-8" })
527     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS),
528         sMaxAge = @MCRCacheControl.Age(time = 1, unit = TimeUnit.DAYS))
529     @Path("/{" + PARAM_MCRID + "}/versions")
530     @Operation(
531         summary = "Returns MCRObject with the given " + PARAM_MCRID + ".",
532         responses = @ApiResponse(content = @Content(
533             array = @ArraySchema(uniqueItems = true,
534                 schema = @Schema(implementation = MCRAbstractMetadataVersion.class)))),
535         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
536     @JacksonFeatures(serializationDisable = { SerializationFeature.WRITE_DATES_AS_TIMESTAMPS })
537     @XmlElementWrapper(name = "versions")
538     public Response getObjectVersions(@Parameter(example = "mir_mods_00004711") @PathParam(PARAM_MCRID) MCRObjectID id)
539         throws IOException {
540         long modified = MCRXMLMetadataManager.instance().getLastModified(id);
541         if (modified < 0) {
542             throw MCRErrorResponse.fromStatus(Response.Status.NOT_FOUND.getStatusCode())
543                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NOT_FOUND)
544                 .withMessage("MCRObject " + id + " not found")
545                 .toException();
546         }
547         Date lastModified = new Date(modified);
548         Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lastModified);
549         if (cachedResponse.isPresent()) {
550             return cachedResponse.get();
551         }
552         List<? extends MCRAbstractMetadataVersion<?>> versions = MCRXMLMetadataManager.instance().listRevisions(id);
553         return Response.ok()
554             .entity(new GenericEntity<>(versions, TypeUtils.parameterize(List.class, MCRAbstractMetadataVersion.class)))
555             .lastModified(lastModified)
556             .build();
557     }
558 
559     @GET
560     @Produces({ MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON + ";charset=UTF-8" })
561     @MCRCacheControl(maxAge = @MCRCacheControl.Age(time = 1000, unit = TimeUnit.DAYS),
562         sMaxAge = @MCRCacheControl.Age(time = 1000, unit = TimeUnit.DAYS)) //will never expire actually
563     @Path("/{" + PARAM_MCRID + "}/versions/{revision}")
564     @Operation(
565         summary = "Returns MCRObject with the given " + PARAM_MCRID + " and revision.",
566         tags = MCRRestUtils.TAG_MYCORE_OBJECT)
567     public Response getObjectVersion(@Parameter(example = "mir_mods_00004711") @PathParam(PARAM_MCRID) MCRObjectID id,
568         @PathParam("revision") String revision)
569         throws IOException {
570         MCRContent mcrContent = MCRXMLMetadataManager.instance().retrieveContent(id, revision);
571         if (mcrContent == null) {
572             throw MCRErrorResponse.fromStatus(Response.Status.NOT_FOUND.getStatusCode())
573                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_REVISION_NOT_FOUND)
574                 .withMessage("MCRObject " + id + " has no revision " + revision + ".")
575                 .toException();
576         }
577         long modified = mcrContent.lastModified();
578         Date lastModified = new Date(modified);
579         Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lastModified);
580         if (cachedResponse.isPresent()) {
581             return cachedResponse.get();
582         }
583         LogManager.getLogger().info("OK: {}", mcrContent.getETag());
584         return Response.ok()
585             .entity(mcrContent,
586                 new Annotation[] { MCRParams.Factory
587                     .get(MCRParam.Factory.get(MCRContentAbstractWriter.PARAM_OBJECTTYPE, id.getTypeId())) })
588             .lastModified(lastModified)
589             .build();
590     }
591 
592     @PUT
593     @Consumes(MediaType.APPLICATION_XML)
594     @Path("/{" + PARAM_MCRID + "}")
595     @Operation(summary = "Creates or updates MCRObject with the body of this request",
596         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
597         responses = {
598             @ApiResponse(responseCode = "400",
599                 content = { @Content(mediaType = MediaType.TEXT_PLAIN) },
600                 description = "'Invalid body content' or 'MCRObjectID mismatch'"),
601             @ApiResponse(responseCode = "201", description = "MCRObject successfully created"),
602             @ApiResponse(responseCode = "204", description = "MCRObject successfully updated"),
603         })
604     @MCRRequireTransaction
605     public Response updateObject(@PathParam(PARAM_MCRID) MCRObjectID id,
606         @Parameter(required = true,
607             description = "MCRObject XML",
608             examples = @ExampleObject("<mycoreobject ID=\"{mcrid}\" ..>\n...\n</mycorobject>")) InputStream xmlSource)
609         throws IOException {
610         //check preconditions
611         try {
612             long lastModified = MCRXMLMetadataManager.instance().getLastModified(id);
613             if (lastModified >= 0) {
614                 Date lmDate = new Date(lastModified);
615                 Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lmDate);
616                 if (cachedResponse.isPresent()) {
617                     return cachedResponse.get();
618                 }
619             }
620         } catch (Exception e) {
621             //ignore errors as PUT is idempotent
622         }
623         MCRStreamContent inputContent = new MCRStreamContent(xmlSource, null, MCRObject.ROOT_NAME);
624         MCRObject updatedObject;
625         try {
626             updatedObject = new MCRObject(inputContent.asXML());
627             updatedObject.validate();
628         } catch (JDOMException | SAXException | MCRException e) {
629             throw MCRErrorResponse.fromStatus(Response.Status.BAD_REQUEST.getStatusCode())
630                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_INVALID)
631                 .withMessage("MCRObject " + id + " is not valid")
632                 .withDetail(e.getMessage())
633                 .withCause(e)
634                 .toException();
635         }
636         if (!id.equals(updatedObject.getId())) {
637             throw MCRErrorResponse.fromStatus(Response.Status.BAD_REQUEST.getStatusCode())
638                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_ID_MISMATCH)
639                 .withMessage("MCRObject " + id + " cannot be overwritten by " + updatedObject.getId() + ".")
640                 .toException();
641         }
642         try {
643             if (MCRMetadataManager.exists(id)) {
644                 MCRMetadataManager.update(updatedObject);
645                 return Response.status(Response.Status.NO_CONTENT).build();
646             } else {
647                 MCRMetadataManager.create(updatedObject);
648                 return Response.status(Response.Status.CREATED).build();
649             }
650         } catch (MCRAccessException e) {
651             throw MCRErrorResponse.fromStatus(Response.Status.FORBIDDEN.getStatusCode())
652                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NO_PERMISSION)
653                 .withMessage("You may not modify or create MCRObject " + id + ".")
654                 .withDetail(e.getMessage())
655                 .withCause(e)
656                 .toException();
657         }
658     }
659 
660     @PUT
661     @Consumes(MediaType.APPLICATION_XML)
662     @Path("/{" + PARAM_MCRID + "}/metadata")
663     @Operation(summary = "Updates the metadata section of a MCRObject with the body of this request",
664         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
665         responses = {
666             @ApiResponse(responseCode = "400",
667                 content = { @Content(mediaType = MediaType.TEXT_PLAIN) },
668                 description = "'Invalid body content' or 'MCRObjectID mismatch'"),
669             @ApiResponse(responseCode = "204", description = "MCRObject metadata successfully updated"),
670         })
671     @MCRRequireTransaction
672     public Response updateObjectMetadata(@PathParam(PARAM_MCRID) MCRObjectID id,
673         @Parameter(required = true,
674             description = "MCRObject XML",
675             examples = @ExampleObject("<metadata>\n...\n</metadata>")) InputStream xmlSource)
676         throws IOException {
677         //check preconditions
678         try {
679             long lastModified = MCRXMLMetadataManager.instance().getLastModified(id);
680             if (lastModified >= 0) {
681                 Date lmDate = new Date(lastModified);
682                 Optional<Response> cachedResponse = MCRRestUtils.getCachedResponse(request, lmDate);
683                 if (cachedResponse.isPresent()) {
684                     return cachedResponse.get();
685                 }
686             }
687         } catch (Exception e) {
688             //ignore errors as PUT is idempotent
689         }
690         MCRStreamContent inputContent = new MCRStreamContent(xmlSource, null);
691         MCRObject updatedObject;
692         try {
693             updatedObject = MCRMetadataManager.retrieveMCRObject(id);
694             updatedObject.getMetadata().setFromDOM(inputContent.asXML().getRootElement().detach());
695             updatedObject.validate();
696         } catch (JDOMException | SAXException | MCRException e) {
697             throw MCRErrorResponse.fromStatus(Response.Status.BAD_REQUEST.getStatusCode())
698                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_INVALID)
699                 .withMessage("MCRObject " + id + " is not valid")
700                 .withDetail(e.getMessage())
701                 .withCause(e)
702                 .toException();
703         }
704         try {
705             MCRMetadataManager.update(updatedObject);
706             return Response.status(Response.Status.NO_CONTENT).build();
707         } catch (MCRAccessException e) {
708             throw MCRErrorResponse.fromStatus(Response.Status.FORBIDDEN.getStatusCode())
709                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NO_PERMISSION)
710                 .withMessage("You may not modify or create metadata of MCRObject " + id + ".")
711                 .withDetail(e.getMessage())
712                 .withCause(e)
713                 .toException();
714         }
715     }
716 
717     @DELETE
718     @Path("/{" + PARAM_MCRID + "}")
719     @Operation(summary = "Deletes MCRObject {" + PARAM_MCRID + "}",
720         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
721         responses = {
722             @ApiResponse(responseCode = "204", description = "MCRObject successfully deleted"),
723             @ApiResponse(responseCode = "409",
724                 description = "MCRObject could not be deleted as it is referenced.",
725                 content = @Content(schema = @Schema(
726                     description = "Map<String, <Collection<String>> of source (key) to targets (value)",
727                     implementation = Map.class))),
728         })
729     @MCRRequireTransaction
730     public Response deleteObject(@PathParam(PARAM_MCRID) MCRObjectID id) {
731         //check preconditions
732         if (!MCRMetadataManager.exists(id)) {
733             throw MCRErrorResponse.fromStatus(Response.Status.NOT_FOUND.getStatusCode())
734                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NOT_FOUND)
735                 .withMessage("MCRObject " + id + " not found")
736                 .toException();
737         }
738         try {
739             MCRMetadataManager.deleteMCRObject(id);
740         } catch (MCRActiveLinkException e) {
741             Map<String, Collection<String>> activeLinks = e.getActiveLinks();
742             throw MCRErrorResponse.fromStatus(Response.Status.CONFLICT.getStatusCode())
743                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_STILL_LINKED)
744                 .withMessage("MCRObject " + id + " is still linked by other objects.")
745                 .withDetail(activeLinks.toString())
746                 .withCause(e)
747                 .toException();
748         } catch (MCRAccessException e) {
749             //usually handled before
750             throw MCRErrorResponse.fromStatus(Response.Status.FORBIDDEN.getStatusCode())
751                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NO_PERMISSION)
752                 .withMessage("You may not delete MCRObject " + id + ".")
753                 .withDetail(e.getMessage())
754                 .withCause(e)
755                 .toException();
756         }
757         return Response.noContent().build();
758     }
759 
760     @PUT
761     @Path("/{" + PARAM_MCRID + "}/try")
762     @Operation(summary = "pre-flight target to test write operation on {" + PARAM_MCRID + "}",
763         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
764         responses = {
765             @ApiResponse(responseCode = "202", description = "You have write permission"),
766             @ApiResponse(responseCode = "401",
767                 description = "You do not have write permission and need to authenticate first"),
768             @ApiResponse(responseCode = "403", description = "You do not have write permission"),
769         })
770     public Response testUpdateObject(@PathParam(PARAM_MCRID) MCRObjectID id)
771         throws IOException {
772         return Response.status(Response.Status.ACCEPTED).build();
773     }
774 
775     @DELETE
776     @Path("/{" + PARAM_MCRID + "}/try")
777     @Operation(summary = "pre-flight target to test delete operation on {" + PARAM_MCRID + "}",
778         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
779         responses = {
780             @ApiResponse(responseCode = "202", description = "You have delete permission"),
781             @ApiResponse(responseCode = "401",
782                 description = "You do not have delete permission and need to authenticate first"),
783             @ApiResponse(responseCode = "403", description = "You do not have delete permission"),
784         })
785     public Response testDeleteObject(@PathParam(PARAM_MCRID) MCRObjectID id)
786         throws IOException {
787         return Response.status(Response.Status.ACCEPTED).build();
788     }
789 
790     @PUT
791     @Consumes(MediaType.TEXT_PLAIN)
792     @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
793     @Path("/{" + PARAM_MCRID + "}/service/state")
794     @Operation(summary = "change state of object {" + PARAM_MCRID + "}",
795         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
796         responses = {
797             @ApiResponse(responseCode = "204", description = "operation was successful"),
798             @ApiResponse(responseCode = "400", description = "Invalid state"),
799             @ApiResponse(responseCode = "404", description = "object is not found"),
800         })
801     @MCRRequireTransaction
802     @MCRApiDraft("MCRObjectState")
803     public Response setState(@PathParam(PARAM_MCRID) MCRObjectID id, String state) {
804         //check preconditions
805         if (!MCRMetadataManager.exists(id)) {
806             throw MCRErrorResponse.fromStatus(Response.Status.NOT_FOUND.getStatusCode())
807                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NOT_FOUND)
808                 .withMessage("MCRObject " + id + " not found")
809                 .toException();
810         }
811         if (!state.isEmpty()) {
812             MCRCategoryID categState = new MCRCategoryID(
813                 MCRConfiguration2.getString("MCR.Metadata.Service.State.Classification.ID").orElse("state"), state);
814             if (!MCRCategoryDAOFactory.getInstance().exist(categState)) {
815                 throw MCRErrorResponse.fromStatus(Response.Status.BAD_REQUEST.getStatusCode())
816                     .withErrorCode(MCRErrorCodeConstants.MCROBJECT_INVALID_STATE)
817                     .withMessage("Category " + categState + " not found")
818                     .toException();
819             }
820         }
821         final MCRObject mcrObject = MCRMetadataManager.retrieveMCRObject(id);
822         if (state.isEmpty()) {
823             mcrObject.getService().removeState();
824         } else {
825             mcrObject.getService().setState(state);
826         }
827         try {
828             MCRMetadataManager.update(mcrObject);
829             return Response.status(Response.Status.NO_CONTENT).build();
830         } catch (MCRAccessException e) {
831             throw MCRErrorResponse.fromStatus(Response.Status.FORBIDDEN.getStatusCode())
832                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NO_PERMISSION)
833                 .withMessage("You may not modify or create metadata of MCRObject " + id + ".")
834                 .withDetail(e.getMessage())
835                 .withCause(e)
836                 .toException();
837         }
838     }
839 
840     @GET
841     @Produces({ MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML })
842     @Path("/{" + PARAM_MCRID + "}/service/state")
843     @Operation(summary = "get state of object {" + PARAM_MCRID + "}",
844         tags = MCRRestUtils.TAG_MYCORE_OBJECT,
845         responses = {
846             @ApiResponse(responseCode = "307", description = "redirect to state category"),
847             @ApiResponse(responseCode = "204", description = "no state is set"),
848             @ApiResponse(responseCode = "404", description = "object is not found"),
849         })
850     @MCRApiDraft("MCRObjectState")
851     public Response getState(@PathParam(PARAM_MCRID) MCRObjectID id) {
852         //check preconditions
853         if (!MCRMetadataManager.exists(id)) {
854             throw MCRErrorResponse.fromStatus(Response.Status.NOT_FOUND.getStatusCode())
855                 .withErrorCode(MCRErrorCodeConstants.MCROBJECT_NOT_FOUND)
856                 .withMessage("MCRObject " + id + " not found")
857                 .toException();
858         }
859         final MCRCategoryID state = MCRMetadataManager.retrieveMCRObject(id).getService().getState();
860         if (state == null) {
861             return Response.noContent().build();
862         }
863         return Response.temporaryRedirect(
864             uriInfo.resolve(URI.create("classifications/" + state.getRootID() + "/" + state.getID())))
865             .build();
866     }
867 
868 }