diff --git a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestConstants.java b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestConstants.java index 8b68c6d641..02b863a129 100644 --- a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestConstants.java +++ b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestConstants.java @@ -45,17 +45,26 @@ public final class MCRAccessKeyRestConstants { /** * Offset query parameter. + * + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#PARAM_OFFSET} instead. */ + @Deprecated(forRemoval = true) public static final String QUERY_PARAM_OFFSET = "offset"; /** * Limit query parameter. + * + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#PARAM_LIMIT} instead. */ + @Deprecated(forRemoval = true) public static final String QUERY_PARAM_LIMIT = "limit"; /** * Header name for total count info. + * + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#HEADER_X_TOTAL_COUNT} instead. */ + @Deprecated(forRemoval = true) public static final String HEADER_TOTAL_COUNT = "X-Total-Count"; private MCRAccessKeyRestConstants() { diff --git a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestHelper.java b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestHelper.java index 5fe34a45bc..18a2d02a03 100644 --- a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestHelper.java +++ b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestHelper.java @@ -26,6 +26,7 @@ import org.mycore.mcr.acl.accesskey.dto.MCRAccessKeyDto; import org.mycore.mcr.acl.accesskey.dto.MCRAccessKeyPartialUpdateDto; import org.mycore.mcr.acl.accesskey.service.MCRAccessKeyService; +import org.mycore.restapi.MCRRestConstants; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.core.Response; @@ -67,7 +68,7 @@ public static List findAccessKeys(String reference, String perm } else { accessKeyDtos.addAll(SERVICE.listAllAccessKeys()); } - response.setHeader(MCRAccessKeyRestConstants.HEADER_TOTAL_COUNT, Integer.toString(accessKeyDtos.size())); + response.setHeader(MCRRestConstants.HEADER_X_TOTAL_COUNT, Integer.toString(accessKeyDtos.size())); return accessKeyDtos.stream().sorted((a1, a2) -> a1.getCreated().compareTo(a2.getCreated())).skip(offset) .limit(limit).toList(); } diff --git a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestResource.java b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestResource.java index 32df91d9af..abfa5bf6bc 100644 --- a/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestResource.java +++ b/mycore-acl/src/main/java/org/mycore/mcr/acl/accesskey/restapi/v2/MCRAccessKeyRestResource.java @@ -27,6 +27,7 @@ import org.mycore.mcr.acl.accesskey.dto.MCRAccessKeyPartialUpdateDto; import org.mycore.mcr.acl.accesskey.restapi.v2.access.MCRAccessKeyRestAccessCheckStrategy; import org.mycore.mcr.acl.accesskey.service.MCRAccessKeyService; +import org.mycore.restapi.MCRRestConstants; import org.mycore.restapi.annotations.MCRApiDraft; import org.mycore.restapi.annotations.MCRRequireTransaction; import org.mycore.restapi.v2.MCRRestSchemaType; @@ -125,7 +126,7 @@ public MCRAccessKeyRestResource(MCRAccessKeyService accessKeyService) { array = @ArraySchema(schema = @Schema(implementation = MCRAccessKeyDto.class))), }, headers = { - @Header(name = MCRAccessKeyRestConstants.HEADER_TOTAL_COUNT, + @Header(name = MCRRestConstants.HEADER_X_TOTAL_COUNT, schema = @Schema(type = MCRRestSchemaType.INTEGER)) }), @ApiResponse(responseCode = UNAUTHORIZED, description = DESCRIPTION_UNAUTHORIZED, @@ -138,11 +139,11 @@ public MCRAccessKeyRestResource(MCRAccessKeyService accessKeyService) { public List findAccessKeys( @Parameter(in = ParameterIn.QUERY, description = "The offset for pagination (default is 0)", required = false, schema = @Schema(defaultValue = "0")) - @DefaultValue("0") @QueryParam(MCRAccessKeyRestConstants.QUERY_PARAM_OFFSET) int offset, + @DefaultValue("0") @QueryParam(MCRRestConstants.PARAM_OFFSET) int offset, @Parameter(in = ParameterIn.QUERY, description = "The number of results to return, defaults to 128 if not provided", required = false, schema = @Schema(defaultValue = "128")) - @DefaultValue("128") @QueryParam(MCRAccessKeyRestConstants.QUERY_PARAM_LIMIT) int limit, + @DefaultValue("128") @QueryParam(MCRRestConstants.PARAM_LIMIT) int limit, @Parameter(in = ParameterIn.QUERY, description = "The reference filter (default is all)", required = false, schema = @Schema(defaultValue = "")) @DefaultValue("") @QueryParam(MCRAccessKeyRestConstants.QUERY_PARAM_REFERENCE) String reference, @@ -162,7 +163,7 @@ public List findAccessKeys( } else { accessKeyDtos.addAll(accessKeyService.listAllAccessKeys()); } - response.setHeader(MCRAccessKeyRestConstants.HEADER_TOTAL_COUNT, Integer.toString(accessKeyDtos.size())); + response.setHeader(MCRRestConstants.HEADER_X_TOTAL_COUNT, Integer.toString(accessKeyDtos.size())); return accessKeyDtos.stream().sorted((a1, a2) -> a1.getCreated().compareTo(a2.getCreated())).skip(offset) .limit(limit).toList(); } diff --git a/mycore-base/src/main/java/org/mycore/resource/selector/MCRHighestComponentPriorityResourceSelector.java b/mycore-base/src/main/java/org/mycore/resource/selector/MCRHighestComponentPriorityResourceSelector.java index c564161dc4..d2eb419984 100644 --- a/mycore-base/src/main/java/org/mycore/resource/selector/MCRHighestComponentPriorityResourceSelector.java +++ b/mycore-base/src/main/java/org/mycore/resource/selector/MCRHighestComponentPriorityResourceSelector.java @@ -62,6 +62,13 @@ protected List doSelect(List resourceUrls, MCRHints hints, MCRResource for (MCRComponent component : componentsByComponentPriority(hints)) { int priority = component.getPriority(); tracer.trace(() -> "Testing component " + component.getName() + " with priority " + priority); + + if (component.getJarFile() == null) { + tracer.trace(() -> "Component " + component.getName() + + " has no JAR file (directory-based), skipping"); + continue; + } + if (highestPriority != -1 && highestPriority != priority) { int highestPrioritySoFar = highestPriority; tracer.trace(() -> "Found component with priority lower than " diff --git a/mycore-bom/pom.xml b/mycore-bom/pom.xml index c5c00ec651..bed9d61661 100644 --- a/mycore-bom/pom.xml +++ b/mycore-bom/pom.xml @@ -214,6 +214,11 @@ mycore-tei ${project.version} + + org.mycore + mycore-user-restapi + ${project.version} + org.mycore mycore-user2 diff --git a/mycore-meta/pom.xml b/mycore-meta/pom.xml index becc257aff..cc0645327a 100644 --- a/mycore-meta/pom.xml +++ b/mycore-meta/pom.xml @@ -174,6 +174,10 @@ org.mycore mycore-tei + + org.mycore + mycore-user-restapi + org.mycore mycore-user2 diff --git a/mycore-restapi/src/main/java/org/mycore/restapi/MCRRestConstants.java b/mycore-restapi/src/main/java/org/mycore/restapi/MCRRestConstants.java new file mode 100644 index 0000000000..0ab829eeb7 --- /dev/null +++ b/mycore-restapi/src/main/java/org/mycore/restapi/MCRRestConstants.java @@ -0,0 +1,38 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.restapi; + +/** + * This class provides constants for rest. + */ +public final class MCRRestConstants { + + /** + * Header name for total count info. + */ + public static final String HEADER_X_TOTAL_COUNT = "X-Total-Count"; + + public static final String PARAM_OFFSET = "offset"; + + public static final String PARAM_LIMIT = "limit"; + + private MCRRestConstants() { + } + +} diff --git a/mycore-restapi/src/main/java/org/mycore/restapi/v2/MCRRestObjects.java b/mycore-restapi/src/main/java/org/mycore/restapi/v2/MCRRestObjects.java index 6fb0fe783a..d7a73f1de2 100644 --- a/mycore-restapi/src/main/java/org/mycore/restapi/v2/MCRRestObjects.java +++ b/mycore-restapi/src/main/java/org/mycore/restapi/v2/MCRRestObjects.java @@ -97,6 +97,7 @@ import org.mycore.frontend.jersey.MCRCacheControl; import org.mycore.frontend.support.MCRObjectLock; import org.mycore.media.services.MCRThumbnailGenerator; +import org.mycore.restapi.MCRRestConstants; import org.mycore.restapi.annotations.MCRAccessControlExposeHeaders; import org.mycore.restapi.annotations.MCRApiDraft; import org.mycore.restapi.annotations.MCRParam; @@ -145,8 +146,16 @@ public class MCRRestObjects { public static final String PARAM_AFTER_ID = "after_id"; + /** + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#PARAM_OFFSET} instead. + */ + @Deprecated(forRemoval = true) public static final String PARAM_OFFSET = "offset"; + /** + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#PARAM_LIMIT} instead. + */ + @Deprecated(forRemoval = true) public static final String PARAM_LIMIT = "limit"; public static final String PARAM_TYPE = "type"; @@ -179,6 +188,10 @@ public class MCRRestObjects { public static final String PARAM_SORT_BY = "sort_by"; + /** + * @deprecated Use {@link org.mycore.restapi.MCRRestConstants#HEADER_X_TOTAL_COUNT} instead. + */ + @Deprecated(forRemoval = true) public static final String HEADER_X_TOTAL_COUNT = "X-Total-Count"; public static final List THUMBNAIL_GENERATORS = MCRConfiguration2 @@ -220,11 +233,11 @@ public class MCRRestObjects { description = "the id after which the results should be listed. Do not use after_id and offset " + "together."), @Parameter( - name = PARAM_OFFSET, + name = MCRRestConstants.PARAM_OFFSET, description = "dictates the number of rows to skip from the beginning of the returned data before " + "presenting the results. Do not use after_id and offset together."), @Parameter( - name = PARAM_LIMIT, + name = MCRRestConstants.PARAM_LIMIT, description = "limits the number of result returned"), @Parameter( name = PARAM_TYPE, @@ -270,11 +283,11 @@ public class MCRRestObjects { @SuppressWarnings("PMD.ExcessiveParameterList") @XmlElementWrapper(name = "mycoreobjects") @JacksonFeatures(serializationDisable = { SerializationFeature.WRITE_DATES_AS_TIMESTAMPS }) - @MCRAccessControlExposeHeaders({ HEADER_X_TOTAL_COUNT, HttpHeaders.LINK }) + @MCRAccessControlExposeHeaders({ MCRRestConstants.HEADER_X_TOTAL_COUNT, HttpHeaders.LINK }) public Response listObjects( @QueryParam(PARAM_AFTER_ID) MCRObjectID afterID, - @QueryParam(PARAM_OFFSET) Integer offset, - @QueryParam(PARAM_LIMIT) Integer limit, + @QueryParam(MCRRestConstants.PARAM_OFFSET) Integer offset, + @QueryParam(MCRRestConstants.PARAM_LIMIT) Integer limit, @QueryParam(PARAM_TYPE) String type, @QueryParam(PARAM_PROJECT) String project, @QueryParam(PARAM_NUMBER_GREATER) Integer numberGreater, @@ -342,12 +355,12 @@ public Response listObjects( nextBuilder.replaceQueryParam(PARAM_AFTER_ID, idDates.getLast().getId()); } else if (query.offset() + query.limit() < count) { nextBuilder = uriInfo.getRequestUriBuilder(); - nextBuilder.replaceQueryParam(PARAM_OFFSET, String.valueOf(query.offset() + limitInt)); + nextBuilder.replaceQueryParam(MCRRestConstants.PARAM_OFFSET, String.valueOf(query.offset() + limitInt)); } Response.ResponseBuilder responseBuilder = Response.ok(new GenericEntity<>(restIdDate) { }) - .header(HEADER_X_TOTAL_COUNT, count); + .header(MCRRestConstants.HEADER_X_TOTAL_COUNT, count); if (nextBuilder != null) { responseBuilder.link("next", nextBuilder.toString()); diff --git a/mycore-user-restapi/LICENSE.txt b/mycore-user-restapi/LICENSE.txt new file mode 100644 index 0000000000..f288702d2f --- /dev/null +++ b/mycore-user-restapi/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/mycore-user-restapi/pom.xml b/mycore-user-restapi/pom.xml new file mode 100644 index 0000000000..eaf14fc14f --- /dev/null +++ b/mycore-user-restapi/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + + org.mycore + mycore + 2026.06.0-SNAPSHOT + ../pom.xml + + mycore-user-restapi + MyCoRe User REST API + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.github.java-json-tools + json-patch + + + io.swagger.core.v3 + swagger-annotations-jakarta + + + jakarta.ws.rs + jakarta.ws.rs-api + + + org.mycore + mycore-base + + + org.mycore + mycore-restapi + + + org.mycore + mycore-user2 + + + com.h2database + h2 + test + + + jakarta.servlet + jakarta.servlet-api + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.mycore + mycore-base + test-jar + test + + + org.mycore + mycore-user2 + test-jar + test + + + diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserAlreadyExistsException.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserAlreadyExistsException.java new file mode 100644 index 0000000000..da1b37a354 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserAlreadyExistsException.java @@ -0,0 +1,39 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.exception; + +import java.io.Serial; + +/** + * Thrown when a user with the given ID already exists. + */ +public class MCRUserAlreadyExistsException extends MCRUserException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates a new exception for the given user ID. + * + * @param userId the ID of the user that already exists + */ + public MCRUserAlreadyExistsException(String userId) { + super("User " + userId + " already exists"); + } +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserException.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserException.java new file mode 100644 index 0000000000..1f0e19b3a3 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserException.java @@ -0,0 +1,51 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.exception; + +import java.io.Serial; + +import org.mycore.common.MCRException; + +/** + * Base exception for user management errors. + */ +public class MCRUserException extends MCRException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates a new exception with the given message. + * + * @param message the error message + */ + public MCRUserException(String message) { + super(message); + } + + /** + * Creates a new exception with the given message. + * + * @param message the error message + * @param cause the cause + */ + public MCRUserException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserNotFoundException.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserNotFoundException.java new file mode 100644 index 0000000000..8257a1e5c7 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserNotFoundException.java @@ -0,0 +1,39 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.exception; + +import java.io.Serial; + +/** + * Thrown when a user with the given ID does not exist. + */ +public class MCRUserNotFoundException extends MCRUserException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates a new exception for the given user ID. + * + * @param userId the ID of the user that was not found + */ + public MCRUserNotFoundException(String userId) { + super("User " + userId + " not found"); + } +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserValidationException.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserValidationException.java new file mode 100644 index 0000000000..5766d77205 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/exception/MCRUserValidationException.java @@ -0,0 +1,60 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.exception; + +import java.io.Serial; + +/** + * Thrown when user data fails validation. + */ +public class MCRUserValidationException extends MCRUserException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates a new exception for the given user ID and reason. + * + * @param userId the ID of the user that failed validation + * @param message the message why validation failed + * @param cause the cause + */ + public MCRUserValidationException(String userId, String message, Throwable cause) { + super("User '" + userId + "' validation failed: " + message, cause); + } + + /** + * Creates a new exception for reason. + * + * @param message the message why validation failed + * @param cause the cause + */ + public MCRUserValidationException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Creates a new exception with message. + * + * @param message the message why validation failed + */ + public MCRUserValidationException(String message) { + super(message); + } +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserDtoMapper.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserDtoMapper.java new file mode 100644 index 0000000000..9731d26258 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserDtoMapper.java @@ -0,0 +1,158 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; +import org.mycore.user2.MCRUser; +import org.mycore.user2.MCRUserAttribute; + +/** + * Maps between {@link MCRUser} domain objects and REST DTOs. + */ +public class MCRUserDtoMapper { + + /** + * Creates a new {@link MCRUser} domain object based on the provided request data. + * + * @param dto the request object containing the user data to be applied + * @param owner the owner or creator of the new user + * @return a new {@link MCRUser} populated with the values from the request + */ + public MCRUser toDomain(MCRCreateUserRequest dto, MCRUser owner) { + MCRUser user = new MCRUser(dto.id()); + applyCommon(user, dto.name(), dto.email(), dto.passwordHint(), dto.locked(), dto.validUntil(), dto.roles(), + dto.attributes(), owner); + return user; + } + + /** + * Applies the given update request to an existing {@link MCRUser}. + * + *

All existing roles (system and external) and attributes are removed + * and replaced with the values provided in the update request. + * + * @param user the user to update + * @param dto the update request containing the new values + * @param owner the user performing the update or acting as owner + * @return the updated {@link MCRUser} + */ + public MCRUser applyUpdate(MCRUser user, MCRUpdateUserRequest dto, MCRUser owner) { + new ArrayList<>(user.getSystemRoleIDs()).forEach(user::unassignRole); + new ArrayList<>(user.getExternalRoleIDs()).forEach(user::unassignRole); + user.getAttributes().clear(); + applyCommon(user, dto.name(), dto.email(), dto.passwordHint(), dto.locked(), dto.validUntil(), dto.roles(), + dto.attributes(), owner); + return user; + } + + /** + * Maps a {@link MCRUser} to a {@link MCRUserDetail}. + * + * @param user the user to map + * @param owns the list of users owned or managed by the given user + * @return a detailed representation of the user, including ownership information + */ + public MCRUserDetail toDetail(MCRUser user, List owns) { + return new MCRUserDetail( + user.getUserID(), + user.getRealName(), + user.getEMail(), + user.getLastLogin() != null ? user.getLastLogin().toInstant() : null, + user.isLocked(), + user.getValidUntil() != null ? user.getValidUntil().toInstant() : null, + toAttributeMap(user), + user.getOwner() != null ? user.getOwner().getUserID() : null, + getAllRoles(user), + owns.stream().map(MCRUser::getUserID).toList() + ); + } + + /** + * Maps a {@link MCRUser} to a {@link MCRUserStandard}. + * + * @param user the user to map + * @return a standard view of the user + */ + public MCRUserStandard toStandard(MCRUser user) { + return new MCRUserStandard( + user.getUserID(), + user.getRealName(), + user.getEMail(), + user.isLocked(), + user.getValidUntil() != null ? user.getValidUntil().toInstant() : null, + getAllRoles(user), + user.getOwner() != null ? user.getOwner().getUserID() : null, + toAttributeMap(user) + ); + } + + /** + * Maps a {@link MCRUser} to a {@link MCRUserSummary}. + * + * @param user the user to map + * @return a summary view of the user + */ + public MCRUserSummary toSummary(MCRUser user) { + return new MCRUserSummary(user.getUserID(), user.getRealName()); + } + + private void applyCommon(MCRUser user, String name, String email, String passwordHint, boolean locked, + Instant validUntil, List roles, Map attributes, MCRUser owner) { + user.setRealName(name); + user.setEMail(email); + user.setHint(passwordHint); + user.setLocked(locked); + user.setValidUntil(validUntil != null ? Date.from(validUntil) : null); + if (roles != null) { + roles.forEach(user::assignRole); + } + if (attributes != null) { + attributes.forEach(user::setUserAttribute); + } + user.setOwner(owner); + } + + private static List getAllRoles(MCRUser user) { + return Stream.concat( + user.getSystemRoleIDs().stream(), + user.getExternalRoleIDs().stream() + ).toList(); + } + + private Map toAttributeMap(MCRUser user) { + return user.getAttributes().stream() + .collect(Collectors.toMap( + MCRUserAttribute::getName, + MCRUserAttribute::getValue + )); + } + +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserObjectMapper.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserObjectMapper.java new file mode 100644 index 0000000000..cd7cb9ec7f --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserObjectMapper.java @@ -0,0 +1,50 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Holds the shared {@link ObjectMapper} instance for the user REST API. + * + *

The mapper is configured with: + *

    + *
  • Java time module for {@link java.time.Instant} and other {@code java.time} types
  • + *
  • ISO-8601 date format instead of Unix timestamps
  • + *
  • Null fields are excluded from serialization
  • + *
+ */ +public final class MCRUserObjectMapper { + + /** + * The shared {@link ObjectMapper} instance. + */ + public static final ObjectMapper INSTANCE = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .defaultPropertyInclusion(JsonInclude.Value.ALL_NON_NULL) + .build(); + + private MCRUserObjectMapper() { + } +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserService.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserService.java new file mode 100644 index 0000000000..23195ff77a --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/MCRUserService.java @@ -0,0 +1,309 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.mycore.common.MCRException; +import org.mycore.user.restapi.exception.MCRUserAlreadyExistsException; +import org.mycore.user.restapi.exception.MCRUserNotFoundException; +import org.mycore.user.restapi.exception.MCRUserValidationException; +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; +import org.mycore.user2.MCRRoleManager; +import org.mycore.user2.MCRUser; +import org.mycore.user2.MCRUserManager; + +/** + * Service for user management operations in the REST layer. + * + *

Provides CRUD operations for users and supports different levels of detail + * via separate view types ({@link MCRUserStandard}, {@link MCRUserDetail}, {@link MCRUserSummary}). + */ +public class MCRUserService { + + private final MCRUserDtoMapper userDtoMapper; + + /** + * Creates a new instance with the given mapper. + * + * @param userDtoMapper the user DTO mapper + */ + public MCRUserService(MCRUserDtoMapper userDtoMapper) { + this.userDtoMapper = userDtoMapper; + } + + /** + * Returns the singleton instance of {@code MCRUserService}. + * + * @return the user service instance + */ + public static MCRUserService obtainInstance() { + return LazyInstanceHolder.SHARED_INSTANCE; + } + + /** + * Returns a new instance of {@code MCRUserService}. + * + * @return the user service instance + */ + public static MCRUserService createInstance() { + return new MCRUserService(new MCRUserDtoMapper()); + } + + /** + * Creates a new user. + * + * @param createUserRequest the request containing the user data + * @return the created user as a detailed view + * @throws MCRUserAlreadyExistsException if a user with the given ID already exists + * @throws MCRUserValidationException if the user data is invalid + */ + public MCRUserDetail createUser(MCRCreateUserRequest createUserRequest) { + validateCreateRequest(createUserRequest); + MCRUser owner = MCRUserManager.getUser(createUserRequest.owner()); + MCRUser user = userDtoMapper.toDomain(createUserRequest, owner); + try { + MCRUserManager.createUser(user); + MCRUserManager.setPassword(user, createUserRequest.password()); + } catch (MCRException e) { + throw new MCRUserValidationException(e.getMessage(), e); + } + MCRUser created = getUserOrThrow(createUserRequest.id()); + return userDtoMapper.toDetail(created, getOwns(created)); + } + + /** + * Returns a summary view of the user with the given ID. + * + * @param userId the ID of the user + * @return a summary view of the user + * @throws MCRUserNotFoundException if no user with the given ID exists + */ + public MCRUserSummary getUserSummary(String userId) { + return userDtoMapper.toSummary(getUserOrThrow(userId)); + } + + /** + * Returns a detailed view of the user with the given ID. + * + * @param userId the ID of the user + * @return a detailed view of the user + * @throws MCRUserNotFoundException if no user with the given ID exists + */ + public MCRUserDetail getUserDetail(String userId) { + MCRUser user = getUserOrThrow(userId); + return userDtoMapper.toDetail(user, getOwns(user)); + } + + /** + * Returns a standard view of the user with the given ID. + * + * @param userId the ID of the user + * @return a standard view of the user + * @throws MCRUserNotFoundException if no user with the given ID exists + */ + public MCRUserStandard getUserStandard(String userId) { + return userDtoMapper.toStandard(getUserOrThrow(userId)); + } + + /** + * Returns a paginated standard view of users matching the given filter. + * + * @param filter the filter criteria, may contain null values + * @param offset the index of the first result to return + * @param limit the maximum number of results to return + * @return a paginated list of matching users as standard views + */ + public MCRUserPage listStandard(MCRUserFilter filter, int offset, int limit) { + return listPage(filter, offset, limit, userDtoMapper::toStandard); + } + + /** + * Returns a paginated detailed view of users matching the given filter. + * + * @param filter the filter criteria, may contain null values + * @param offset the index of the first result to return + * @param limit the maximum number of results to return + * @return a paginated list of matching users as detailed views + */ + public MCRUserPage listDetail(MCRUserFilter filter, int offset, int limit) { + return listPage(filter, offset, limit, user -> userDtoMapper.toDetail(user, getOwns(user))); + } + + /** + * Returns a paginated summary view of users matching the given filter. + * + * @param filter the filter criteria, may contain null values + * @param offset the index of the first result to return + * @param limit the maximum number of results to return + * @return a paginated list of matching users as summary views + */ + public MCRUserPage listSummary(MCRUserFilter filter, int offset, int limit) { + return listPage(filter, offset, limit, userDtoMapper::toSummary); + } + + /** + * Updates the user with the given ID. + * + * @param userId the ID of the user to update + * @param updateUserRequest the request containing the updated user data + * @return the updated user as a detailed view + * @throws MCRUserNotFoundException if no user with the given ID exists + * @throws MCRUserValidationException if the updated user data is invalid + */ + public MCRUserDetail updateUser(String userId, MCRUpdateUserRequest updateUserRequest) { + validateUpdateRequest(updateUserRequest); + MCRUser user = getUserOrThrow(userId); + MCRUser owner = Optional.ofNullable(updateUserRequest.owner()).map(this::getUserOrThrow).orElse(null); + MCRUser updated = userDtoMapper.applyUpdate(user, updateUserRequest, owner); + try { + MCRUserManager.updateUser(updated); + if (updateUserRequest.password() != null) { + MCRUserManager.setPassword(updated, updateUserRequest.password()); + } + } catch (MCRException e) { + throw new MCRUserValidationException(user.getUserName(), e.getMessage(), e); + } + MCRUser fresh = getUserOrThrow(updated.getUserName()); + return userDtoMapper.toDetail(fresh, getOwns(fresh)); + } + + /** + * Deletes the user with the given ID. + * + * @param userId the ID of the user to delete + * @throws MCRUserNotFoundException if no user with the given ID exists + */ + public void deleteUser(String userId) { + getUserOrThrow(userId); + MCRUserManager.deleteUser(userId); + } + + private MCRUserPage listPage(MCRUserFilter filter, int offset, int limit, Function mapper) { + List users = + MCRUserManager.listUsers(filter.idPattern, filter.realm, filter.namePattern, filter.mailPattern, null, null, + offset, limit); + List page = users.stream().map(mapper).toList(); + long size = MCRUserManager.countUsers(filter.idPattern, filter.realm, filter.namePattern, filter.mailPattern); + return new MCRUserPage<>(page, size); + } + + private MCRUser getUserOrThrow(String userId) { + return Optional.ofNullable(MCRUserManager.getUser(userId)) + .orElseThrow(() -> new MCRUserNotFoundException(userId)); + } + + private static List getOwns(MCRUser user) { + return MCRUserManager.listUsers(user); + } + + private static void validateCreateRequest(MCRCreateUserRequest request) { + if (request.id() == null || request.id().isBlank()) { + throw new MCRUserValidationException("id is required"); + } + if (MCRUserManager.exists(request.id())) { + throw new MCRUserAlreadyExistsException(request.id()); + } + validatePassword(request.password()); + validateOwner(request.owner()); + validateRoles(request.roles()); + validateEmail(request.email()); + } + + private static void validateUpdateRequest(MCRUpdateUserRequest request) { + if (request.password() != null) { + validatePassword(request.password()); + } + validateOwner(request.owner()); + validateRoles(request.roles()); + validateEmail(request.email()); + } + + private static void validatePassword(String password) { + if (password == null || password.isBlank()) { + throw new MCRUserValidationException("password is required"); + } + } + + private static void validateOwner(String id) { + if (id != null && !MCRUserManager.exists(id)) { + throw new MCRUserValidationException("user " + id + " (owner) does not exist"); + } + } + + private static void validateRoles(List roles) { + if (roles == null) { + return; + } + for (String role : roles) { + if (MCRRoleManager.getRole(role) == null) { + throw new MCRUserValidationException("role " + role + " does not exist"); + } + } + } + + private static void validateEmail(String email) { + if (email != null && !email.contains("@")) { + throw new MCRUserValidationException("email is invalid"); + } + } + + private static final class LazyInstanceHolder { + private static final MCRUserService SHARED_INSTANCE = createInstance(); + } + + /** + * Filter criteria for user search queries. + * All fields support wildcards: * for any sequence of characters, ? for a single character. + * Any field may be null to indicate no filtering on that field. + * + * @param idPattern a wildcard pattern for the login userid + * @param realm the realm the user belongs to + * @param namePattern a wildcard pattern for the person's real name + * @param mailPattern a wildcard pattern for the person's email address + */ + public record MCRUserFilter( + String idPattern, + String realm, + String namePattern, + String mailPattern + ) { + } + + /** + * A paginated result containing a subset of users along with pagination metadata. + * + * @param users the list of users on the current page + * @param total the total number of matching users across all pages + * @param the user view type, e.g. {@link MCRUserStandard}, {@link MCRUserDetail}, + * or {@link MCRUserSummary} + */ + public record MCRUserPage( + List users, + long total + ) { + } + +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRCreateUserRequest.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRCreateUserRequest.java new file mode 100644 index 0000000000..01b1091f2a --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRCreateUserRequest.java @@ -0,0 +1,51 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Request body for creating a new user. + * + * @param id the unique ID of the new user + * @param name the real name of the user + * @param email the email address of the user + * @param password the password of the user + * @param passwordHint a hint for the user in case the password is forgotten, or {@code null} + * @param locked whether the user should be locked initially + * @param validUntil the date until the user is valid, or {@code null} if unlimited + * @param attributes the user attributes, or {@code null} + * @param owner the ID of the user that owns this user, or {@code null} if independent + * @param roles the roles to assign to the user, or {@code null} + */ +public record MCRCreateUserRequest( + String id, + String name, + String email, + String password, + String passwordHint, + boolean locked, + Instant validUntil, + Map attributes, + String owner, + List roles +) { +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUpdateUserRequest.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUpdateUserRequest.java new file mode 100644 index 0000000000..c4dfcd31d5 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUpdateUserRequest.java @@ -0,0 +1,51 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Request body for updating an existing user. + * + *

All fields are required and will completely replace the existing user data. + * + * @param name the real name of the user + * @param email the email address of the user + * @param password the password of the user + * @param passwordHint a hint for the user in case the password is forgotten, or {@code null} + * @param locked whether the user should be locked + * @param validUntil the date until the user is valid, or {@code null} if unlimited + * @param attributes the user attributes, or {@code null} + * @param owner the ID of the user that owns this user, or {@code null} if independent + * @param roles the roles assigned to the user, or {@code null} + */ +public record MCRUpdateUserRequest( + String name, + String email, + String password, + String passwordHint, + boolean locked, + Instant validUntil, + Map attributes, + String owner, + List roles +) { +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserDetail.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserDetail.java new file mode 100644 index 0000000000..b888742a39 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserDetail.java @@ -0,0 +1,51 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Detailed view of a user containing all available information. + * + * @param id the unique ID of the user + * @param name the real name of the user + * @param email the email address of the user + * @param lastLogin the date of the last login or {@code null} if never logged in + * @param locked whether the user is locked + * @param validUntil the date until the user is valid, or {@code null} if unlimited + * @param attributes the user attributes, excluding system attributes + * @param owner the ID of the user that owns this user, or {@code null} if independent + * @param roles the roles assigned to the user + * @param owns the IDs of users owned by this user + */ +public record MCRUserDetail( + String id, + String name, + String email, + Instant lastLogin, + boolean locked, + Instant validUntil, + Map attributes, + String owner, + List roles, + List owns +) { +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserStandard.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserStandard.java new file mode 100644 index 0000000000..e7f82e0b4d --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserStandard.java @@ -0,0 +1,47 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Standard view of a user containing the most relevant information. + * + * @param id the unique ID of the user + * @param name the real name of the user + * @param email the email address of the user + * @param locked whether the user is locked + * @param validUntil the date until the user is valid, or {@code null} if unlimited + * @param roles the roles assigned to the user + * @param owner the ID of the user that owns this user, or {@code null} if independent + * @param attributes the user attributes, excluding system attributes + */ +public record MCRUserStandard( + String id, + String name, + String email, + boolean locked, + Instant validUntil, + List roles, + String owner, + Map attributes +) { +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserSummary.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserSummary.java new file mode 100644 index 0000000000..1365328728 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/dto/MCRUserSummary.java @@ -0,0 +1,31 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.dto; + +/** + * Minimal view of a user containing only the most essential information. + * + * @param id the unique ID of the user + * @param name the real name of the user + */ +public record MCRUserSummary( + String id, + String name +) { +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsers.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsers.java new file mode 100644 index 0000000000..12c5a33dbb --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsers.java @@ -0,0 +1,569 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.resource; + +import static org.mycore.restapi.v2.MCRRestStatusCode.BAD_REQUEST; +import static org.mycore.restapi.v2.MCRRestStatusCode.CONFLICT; +import static org.mycore.restapi.v2.MCRRestStatusCode.CREATED; +import static org.mycore.restapi.v2.MCRRestStatusCode.NOT_FOUND; +import static org.mycore.restapi.v2.MCRRestStatusCode.NO_CONTENT; +import static org.mycore.restapi.v2.MCRRestStatusCode.OK; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.mycore.frontend.jersey.MCRCacheControl; +import org.mycore.restapi.MCRRestConstants; +import org.mycore.restapi.annotations.MCRAccessControlExposeHeaders; +import org.mycore.restapi.annotations.MCRApiDraft; +import org.mycore.restapi.annotations.MCRRequireTransaction; +import org.mycore.restapi.converter.MCRDetailLevel; +import org.mycore.restapi.v2.MCRRestSchemaType; +import org.mycore.restapi.v2.annotation.MCRRestRequiredPermission; +import org.mycore.user.restapi.exception.MCRUserAlreadyExistsException; +import org.mycore.user.restapi.exception.MCRUserNotFoundException; +import org.mycore.user.restapi.exception.MCRUserValidationException; +import org.mycore.user.restapi.v2.MCRUserObjectMapper; +import org.mycore.user.restapi.v2.MCRUserService; +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.fge.jsonpatch.JsonPatch; +import com.github.fge.jsonpatch.JsonPatchException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +/** + * REST resource for user management. + * + *

Provides CRUD endpoints under {@code /users}. All endpoints require the + * {@code manage-user} permission. + * + *

The level of detail in GET responses can be controlled via the + * {@code Accept} header parameter defined in {@link MCRDetailLevel}: + *

    + *
  • {@link MCRDetailLevel#SUMMARY} -> minimal user info
  • + *
  • {@link MCRDetailLevel#DETAILED} -> full user info
  • + *
  • {@link MCRDetailLevel#NORMAL} -> standard user info (default)
  • + *
+ * + * @see MCRUserService + */ +@MCRApiDraft("MCRUsers") +@Path("/users") +public class MCRUsers { + + private static final String PERMISSION_MANAGE_USER = "manage-user"; + private static final String PARAM_USER_ID = "user_id"; + private static final String PARAM_ID = "id"; + private static final String PARAM_REALM = "realm"; + private static final String PARAM_NAME = "name"; + private static final String PARAM_EMAIL = "email"; + private static final String DEFAULT_OFFSET_STR = "0"; + private static final String DEFAULT_LIMIT_STR = "100"; + private static final String TAG_MCR_USER = "mcr_user"; + private static final String DESC_USER_NOT_FOUND = "User not found"; + private static final String DESC_INVALID_BODY_CONTENT = "Invalid body"; + private static final String DETAIL_LEVEL_DESCRIPTION = + "Controls the level of detail in the response via the detail parameter. " + + "Supported values: SUMMARY, NORMAL, DETAILED. " + + "Example: application/json; detail=SUMMARY"; + + @Context + private UriInfo uriInfo; + + @Context + ContainerRequestContext request; + + private final MCRUserService userService; + + private final ObjectMapper objectMapper; + + /** + * Creates a new instance with the default {@link MCRUserService}. + */ + public MCRUsers() { + this(MCRUserService.obtainInstance(), MCRUserObjectMapper.INSTANCE); + } + + /** + * Creates a new instance with the given service. + * + * @param userService the service to use for user management operations + * @param objectMapper the object mapper + */ + public MCRUsers(MCRUserService userService, ObjectMapper objectMapper) { + this.userService = userService; + this.objectMapper = objectMapper; + } + + /** + * Creates a new user. + * + * @param createUserDto the request body containing the user data + * @return 201 Created with the URI of the new user in the {@code Location} header + * @throws BadRequestException if the user data is invalid + * @throws ClientErrorException with status 409 if the user already exists + */ + @Operation( + summary = "Creates a new user", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = MCRCreateUserRequest.class) + ) + ), + responses = { + @ApiResponse( + responseCode = CREATED, + description = "User successfully created", + headers = @Header( + name = HttpHeaders.LOCATION, + schema = @Schema(type = "string", format = "uri"), + description = "URL of the new user" + ) + ), + @ApiResponse(responseCode = BAD_REQUEST, description = DESC_INVALID_BODY_CONTENT), + @ApiResponse(responseCode = CONFLICT, description = "User already exists") + }, + tags = TAG_MCR_USER + ) + @POST + @Consumes(MediaType.APPLICATION_JSON) + @MCRRequireTransaction + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + public Response createUser(MCRCreateUserRequest createUserDto) { + try { + String userId = userService.createUser(createUserDto).id(); + return Response.created(uriInfo.getAbsolutePathBuilder().path(userId).build()).build(); + } catch (MCRUserValidationException e) { + throw new BadRequestException(e); + } catch (MCRUserAlreadyExistsException e) { + throw new ClientErrorException(Response.Status.CONFLICT, e); + } + } + + /** + * Returns a single user by ID. + * + * @param userId the ID of the user + * @return 200 OK with the user data at the requested detail level + * @throws NotFoundException if no user with the given ID exists + * @throws BadRequestException if the detail level is unknown + */ + @Operation( + summary = "Returns a single user by ID", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + parameters = { + @Parameter( + name = "Accept", + in = ParameterIn.HEADER, + description = DETAIL_LEVEL_DESCRIPTION, + example = "application/json; detail=SUMMARY", + schema = @Schema(type = MCRRestSchemaType.STRING) + ) + }, + responses = { + @ApiResponse( + responseCode = NOT_FOUND, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_USER_NOT_FOUND + ), + @ApiResponse( + responseCode = OK, + description = "User found at the requested detail level", + content = { + @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + oneOf = { + MCRUserSummary.class, + MCRUserStandard.class, + MCRUserDetail.class + } + ) + ) + } + ) + }, + tags = TAG_MCR_USER + ) + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/{" + PARAM_USER_ID + "}") + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + public Response getUser(@PathParam(PARAM_USER_ID) String userId) { + try { + return switch (getDetailLevel()) { + case SUMMARY -> Response.ok(userService.getUserSummary(userId)).build(); + case DETAILED -> Response.ok(userService.getUserDetail(userId)).build(); + default -> Response.ok(userService.getUserStandard(userId)).build(); + }; + } catch (MCRUserNotFoundException e) { + throw new NotFoundException(e); + } + } + + /** + * Returns a paginated list of users matching the given filter criteria. + * Wildcards * and ? may be used for pattern parameters. + * + * @param idPattern a wildcard pattern for the login userid, may be null + * @param realm the realm the user belongs to, may be null + * @param namePattern a wildcard pattern for the person's real name, may be null + * @param mailPattern a wildcard pattern for the person's email, may be null + * @param offset the index of the first result to return, defaults to 0 + * @param limit the maximum number of results to return, defaults to 100 + * @return a paginated list of matching users + */ + @Operation( + summary = "Lists all users", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + parameters = { + @Parameter( + name = "Accept", + in = ParameterIn.HEADER, + description = DETAIL_LEVEL_DESCRIPTION, + example = "application/json; detail=SUMMARY", + schema = @Schema(type = MCRRestSchemaType.STRING) + ) + }, + responses = @ApiResponse( + responseCode = OK, + description = "List of all users at the requested detail level", + content = { + @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + type = "array", + oneOf = { + MCRUserSummary.class, + MCRUserStandard.class, + MCRUserDetail.class + } + ) + ) + }, + headers = { + @Header( + name = MCRRestConstants.HEADER_X_TOTAL_COUNT, + schema = @Schema(type = MCRRestSchemaType.INTEGER) + ) + }), + tags = TAG_MCR_USER + ) + @GET + @Produces(MediaType.APPLICATION_JSON) + @MCRCacheControl( + maxAge = @MCRCacheControl.Age(time = 0, unit = TimeUnit.SECONDS), + noCache = @MCRCacheControl.FieldArgument(active = true) + ) + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + @MCRAccessControlExposeHeaders({ MCRRestConstants.HEADER_X_TOTAL_COUNT }) + public Response listUsers( + @QueryParam(PARAM_ID) + @Parameter(description = "Wildcard pattern for the login user name. Supports * and ?", example = "admin*") + String idPattern, + + @QueryParam(PARAM_NAME) + @Parameter(description = "Wildcard pattern for the user's real name. Supports * and ?", example = "John*") + String namePattern, + + @QueryParam(PARAM_EMAIL) + @Parameter( + description = "Wildcard pattern for the user's email address. Supports * and ?", + example = "*@example.com" + ) + String mailPattern, + + @QueryParam(PARAM_REALM) + @Parameter(description = "Realm the user belongs to", example = "local") + String realm, + + @QueryParam(MCRRestConstants.PARAM_OFFSET) + @DefaultValue(DEFAULT_OFFSET_STR) + @Parameter( + description = "Index of the first result to return", + schema = @Schema(type = MCRRestSchemaType.INTEGER, format = "int32", defaultValue = DEFAULT_OFFSET_STR) + ) + int offset, + + @QueryParam(MCRRestConstants.PARAM_LIMIT) + @DefaultValue(DEFAULT_LIMIT_STR) + @Parameter( + description = "Maximum number of results to return", + schema = @Schema(type = MCRRestSchemaType.INTEGER, format = "int32", defaultValue = DEFAULT_LIMIT_STR) + ) + int limit + ) { + MCRUserService.MCRUserFilter filter + = new MCRUserService.MCRUserFilter(idPattern, realm, namePattern, mailPattern); + + return switch (getDetailLevel()) { + case SUMMARY -> pageResponse(userService.listSummary(filter, offset, limit)); + case DETAILED -> pageResponse(userService.listDetail(filter, offset, limit)); + default -> pageResponse(userService.listStandard(filter, offset, limit)); + }; + } + + /** + * Updates an existing user. + * + * @param userId the ID of the user to update + * @param updateUserDto the request body containing the updated user data + * @return 204 No Content + * @throws NotFoundException if no user with the given ID exists + * @throws BadRequestException if the user data is invalid + */ + @Operation( + summary = "Updates an existing user by ID", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = MCRUpdateUserRequest.class) + ) + ), + responses = { + @ApiResponse( + responseCode = NOT_FOUND, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_USER_NOT_FOUND + ), + @ApiResponse( + responseCode = BAD_REQUEST, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_INVALID_BODY_CONTENT + ), + @ApiResponse(responseCode = NO_CONTENT, description = "User successfully updated"), + }, + tags = TAG_MCR_USER + ) + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Path("/{" + PARAM_USER_ID + "}") + @MCRRequireTransaction + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + public Response updateUser(@PathParam(PARAM_USER_ID) String userId, MCRUpdateUserRequest updateUserDto) { + try { + userService.updateUser(userId, updateUserDto); + return Response.noContent().build(); + } catch (MCRUserNotFoundException e) { + throw new NotFoundException(e); + } catch (MCRUserValidationException e) { + throw new BadRequestException(e); + } + } + + /** + * Patches an existing user. + * + * @param userId the ID of the user to update + * @param patch the json patch + * @return 204 No Content + * @throws NotFoundException if no user with the given ID exists + * @throws BadRequestException if the patch is invalid or contains forbidden fields + */ + @Operation( + summary = "Patches an existing user by ID", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + schema = @Schema( + type = "array", + example = "[{\"op\":\"replace\",\"path\":\"/name\",\"value\":\"John\"}]" + ) + ) + ), + responses = { + @ApiResponse( + responseCode = NOT_FOUND, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_USER_NOT_FOUND + ), + @ApiResponse( + responseCode = BAD_REQUEST, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_INVALID_BODY_CONTENT + ), + @ApiResponse(responseCode = NO_CONTENT, description = "User successfully patched"), + }, + tags = TAG_MCR_USER + ) + @PATCH + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Path("/{" + PARAM_USER_ID + "}") + @MCRRequireTransaction + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + public Response patchUser(@PathParam(PARAM_USER_ID) String userId, JsonPatch patch) { + try { + MCRUserDetail existing = userService.getUserDetail(userId); + JsonNode userNode = objectMapper.valueToTree(existing); + JsonNode patched = patch.apply(userNode); + MCRUserPatchState state = objectMapper.treeToValue(patched, MCRUserPatchState.class); + if (state.password() != null) { + throw new MCRUserValidationException("Password cannot be patched"); + } + userService.updateUser(userId, state.toUpdateRequest()); + return Response.noContent().build(); + } catch (MCRUserNotFoundException e) { + throw new NotFoundException(e); + } catch (MCRUserValidationException e) { + throw new BadRequestException(e); + } catch (JsonProcessingException | JsonPatchException e) { + throw new BadRequestException("Cannot patch user: " + e.getMessage(), e); + } + } + + /** + * Deletes a user by ID. + * + * @param userId the ID of the user to delete + * @return 204 No Content + * @throws NotFoundException if no user with the given ID exists + */ + @Operation( + summary = "Deletes a user by ID", + security = @SecurityRequirement(name = PERMISSION_MANAGE_USER), + responses = { + @ApiResponse( + responseCode = NOT_FOUND, + content = @Content(mediaType = MediaType.TEXT_PLAIN), + description = DESC_USER_NOT_FOUND + ), + @ApiResponse(responseCode = NO_CONTENT, description = "User successfully deleted"), + }, + tags = TAG_MCR_USER + ) + @DELETE + @Path("/{" + PARAM_USER_ID + "}") + @MCRRequireTransaction + @MCRRestRequiredPermission(PERMISSION_MANAGE_USER) + public Response deleteUser(@PathParam(PARAM_USER_ID) String userId) { + try { + userService.deleteUser(userId); + return Response.noContent().build(); + } catch (MCRUserNotFoundException e) { + throw new NotFoundException(e); + } + } + + private Response pageResponse(MCRUserService.MCRUserPage page) { + return Response.ok(page.users()) + .header(MCRRestConstants.HEADER_X_TOTAL_COUNT, page.total()) + .build(); + } + + // TODO move to MCRRestUtils? + // TODO case-sensitive? + private MCRDetailLevel getDetailLevel() { + Optional detailLevelOptional = request.getAcceptableMediaTypes().stream() + .flatMap(m -> m.getParameters().entrySet().stream() + .filter(e -> MCRDetailLevel.MEDIA_TYPE_PARAMETER.equals(e.getKey()))).map(Map.Entry::getValue) + .findFirst(); + if (detailLevelOptional.isEmpty()) { + return MCRDetailLevel.NORMAL; + } + try { + return MCRDetailLevel.valueOf(detailLevelOptional.get()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Unknown detail level: " + detailLevelOptional.get(), e); + } + } + + /** + * Internal representation of a user's state after applying a JSON patch. + * + *

Used as an intermediate DTO during patch operations to deserialize the patched + * {@link org.mycore.user.restapi.v2.dto.MCRUserDetail} JSON into a structured object + * before converting it to a {@link MCRUpdateUserRequest}. + * + *

Unknown fields are ignored during deserialization to allow patching a full + * {@link org.mycore.user.restapi.v2.dto.MCRUserDetail} response (which contains + * read-only fields like {@code id} or {@code lastLogin}). + */ + @JsonIgnoreProperties(ignoreUnknown = true) + protected record MCRUserPatchState( + String name, + String email, + String passwordHint, + String password, + boolean locked, + Instant validUntil, + Map attributes, + String owner, + List roles + ) { + /** + * Converts this patch state to an {@link MCRUpdateUserRequest}. + * + * @return a new {@link MCRUpdateUserRequest} with the same field values + */ + public MCRUpdateUserRequest toUpdateRequest() { + return new MCRUpdateUserRequest( + name(), email(), null, passwordHint(), + locked(), validUntil(), attributes(), owner(), roles() + ); + } + } + +} diff --git a/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsersObjectMapperProvider.java b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsersObjectMapperProvider.java new file mode 100644 index 0000000000..f9b496b5b3 --- /dev/null +++ b/mycore-user-restapi/src/main/java/org/mycore/user/restapi/v2/resource/MCRUsersObjectMapperProvider.java @@ -0,0 +1,39 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.resource; + +import org.mycore.user.restapi.v2.MCRUserObjectMapper; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Provider; + +/** + * JAX-RS provider that supplies a configured {@link ObjectMapper} for JSON serialization + * within the user REST API. + */ +@Provider +public class MCRUsersObjectMapperProvider implements ContextResolver { + + @Override + public ObjectMapper getContext(Class type) { + return MCRUserObjectMapper.INSTANCE; + } +} diff --git a/mycore-user-restapi/src/main/resources/components/user-restapi/config/mycore.properties b/mycore-user-restapi/src/main/resources/components/user-restapi/config/mycore.properties new file mode 100644 index 0000000000..83f49b9bbb --- /dev/null +++ b/mycore-user-restapi/src/main/resources/components/user-restapi/config/mycore.properties @@ -0,0 +1,5 @@ +############################################################################## +# REST API Resources # +############################################################################## +MCR.RestApi.Draft.MCRUsers=false +MCR.RestAPI.V2.Resource.Packages=%MCR.RestAPI.V2.Resource.Packages%,org.mycore.user.restapi.v2.resource diff --git a/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserDtoMapperTest.java b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserDtoMapperTest.java new file mode 100644 index 0000000000..cee32a2893 --- /dev/null +++ b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserDtoMapperTest.java @@ -0,0 +1,257 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mycore.test.MCRJPAExtension; +import org.mycore.test.MyCoReTest; +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; +import org.mycore.user2.MCRUser; +import org.mycore.user2.MCRUserExtension; + +@MyCoReTest +@ExtendWith({ MCRJPAExtension.class, MCRUserExtension.class }) +public class MCRUserDtoMapperTest { + + private MCRUserDtoMapper mapper; + + @BeforeEach + void setUp() { + mapper = new MCRUserDtoMapper(); + } + + @Test + void toDomainShouldMapBasicFields() { + MCRCreateUserRequest request = new MCRCreateUserRequest( + "john", "John Doe", "john@example.com", + null, null, false, null, + Map.of(), null, List.of() + ); + + MCRUser result = mapper.toDomain(request, null); + + assertEquals("john", result.getUserID()); + assertEquals("John Doe", result.getRealName()); + assertEquals("john@example.com", result.getEMail()); + } + + @Test + void toDomainShouldAssignRoles() { + MCRCreateUserRequest request = new MCRCreateUserRequest( + "john", null, null, + null, null, false, null, + Map.of(), null, List.of("admin", "editor") + ); + + MCRUser result = mapper.toDomain(request, null); + + assertEquals(2, result.getSystemRoleIDs().size()); + assertTrue(result.getSystemRoleIDs().contains("admin")); + assertTrue(result.getSystemRoleIDs().contains("editor")); + } + + @Test + void toDomainShouldResolveOwner() { + MCRUser owner = buildUser("admin"); + + MCRCreateUserRequest request = new MCRCreateUserRequest( + "john", null, null, + null, null, false, null, + Map.of(), "admin", List.of() + ); + + MCRUser result = mapper.toDomain(request, owner); + + assertEquals("admin", result.getOwner().getUserID()); + } + + @Test + void toSummaryShouldMapUserIdAndRealName() { + MCRUser user = buildUser("john"); + user.setRealName("John Doe"); + + MCRUserSummary summary = mapper.toSummary(user); + + assertEquals("john", summary.id()); + assertEquals("John Doe", summary.name()); + } + + @Test + void toSummaryShouldHandleNullRealName() { + MCRUser user = buildUser("john"); + + MCRUserSummary summary = mapper.toSummary(user); + + assertNull(summary.name()); + } + + @Test + void toStandardShouldMapAllFields() { + MCRUser user = buildUser("john"); + user.setRealName("John Doe"); + user.setEMail("john@example.com"); + user.setLocked(true); + user.setValidUntil(Date.from(Instant.parse("2099-01-01T00:00:00Z"))); + user.assignRole("admin"); + + MCRUserStandard standard = mapper.toStandard(user); + + assertEquals("john", standard.id()); + assertEquals("John Doe", standard.name()); + assertEquals("john@example.com", standard.email()); + assertTrue(standard.locked()); + assertNotNull(standard.validUntil()); + assertEquals(1, standard.roles().size()); + assertTrue(standard.roles().contains("admin")); + } + + @Test + void toStandardShouldHandleNullValidUntil() { + MCRUser user = buildUser("john"); + + MCRUserStandard standard = mapper.toStandard(user); + + assertNull(standard.validUntil()); + } + + @Test + void toStandardShouldHandleNullOwner() { + MCRUser user = buildUser("john"); + + MCRUserStandard standard = mapper.toStandard(user); + + assertNull(standard.owner()); + } + + @Test + void toStandardShouldMapOwner() { + MCRUser owner = buildUser("admin"); + MCRUser user = buildUser("john"); + user.setOwner(owner); + + MCRUserStandard standard = mapper.toStandard(user); + + assertEquals("admin", standard.owner()); + } + + @Test + void toStandardShouldMapAttributes() { + MCRUser user = buildUser("john"); + user.setUserAttribute("foo", "bar"); + + MCRUserStandard standard = mapper.toStandard(user); + + assertEquals(1, standard.attributes().size()); + assertNotNull(standard.attributes().get("foo")); + assertEquals("bar", standard.attributes().get("foo")); + } + + @Test + void toDetailShouldMapAllFields() { + MCRUser user = buildUser("john"); + user.setRealName("John Doe"); + user.setEMail("john@example.com"); + user.setLocked(false); + user.setValidUntil(Date.from(Instant.parse("2099-01-01T00:00:00Z"))); + user.assignRole("editor"); + + MCRUserDetail detail = mapper.toDetail(user, List.of()); + + assertEquals("john", detail.id()); + assertEquals("John Doe", detail.name()); + assertEquals("john@example.com", detail.email()); + assertFalse(detail.locked()); + assertNotNull(detail.validUntil()); + assertEquals(1, detail.roles().size()); + assertTrue(detail.roles().contains("editor")); + } + + @Test + void toDetailShouldMapOwnedUsers() { + MCRUser user = buildUser("admin"); + MCRUser owned = buildUser("editor"); + + MCRUserDetail detail = mapper.toDetail(user, List.of(owned)); + + assertEquals(1, detail.owns().size()); + assertEquals("editor", detail.owns().getFirst()); + } + + @Test + void toDetailShouldHandleNullLastLogin() { + MCRUser user = buildUser("john"); + + MCRUserDetail detail = mapper.toDetail(user, List.of()); + + assertNull(detail.lastLogin()); + } + + @Test + void applyUpdateShouldReplaceRoles() { + MCRUser user = buildUser("john"); + user.assignRole("editor"); + + MCRUpdateUserRequest request = new MCRUpdateUserRequest( + "John Doe", null, null, null, false, null, + Map.of(), null, List.of("admin")); + + MCRUser result = mapper.applyUpdate(user, request, null); + + assertEquals(1, result.getSystemRoleIDs().size()); + assertTrue(result.getSystemRoleIDs().contains("admin")); + assertFalse(result.getSystemRoleIDs().contains("editor")); + } + + @Test + void applyUpdateShouldReplaceAttributes() { + MCRUser user = buildUser("john"); + user.setUserAttribute("old", "value"); + + MCRUpdateUserRequest request = new MCRUpdateUserRequest( + "John Doe", null, null, null, false, null, + Map.of("new", "value"), null, List.of()); + + MCRUser result = mapper.applyUpdate(user, request, null); + + assertEquals(1, result.getAttributes().size()); + assertNotNull(result.getUserAttribute("new")); + assertNull(result.getUserAttribute("old")); + } + + private MCRUser buildUser(String userId) { + return new MCRUser(userId); + } +} diff --git a/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserServiceTest.java b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserServiceTest.java new file mode 100644 index 0000000000..be93e078d9 --- /dev/null +++ b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/MCRUserServiceTest.java @@ -0,0 +1,320 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mycore.common.MCRException; +import org.mycore.user.restapi.exception.MCRUserAlreadyExistsException; +import org.mycore.user.restapi.exception.MCRUserNotFoundException; +import org.mycore.user.restapi.exception.MCRUserValidationException; +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; +import org.mycore.user2.MCRUser; +import org.mycore.user2.MCRUserManager; + +@ExtendWith(MockitoExtension.class) +class MCRUserServiceTest { + + @Mock + private MCRUserDtoMapper userDtoMapper; + + private MCRUserService userService; + + @BeforeEach + void setUp() { + userService = new MCRUserService(userDtoMapper); + } + + @Test + void createUserShouldReturnDetail() { + MCRCreateUserRequest request = buildCreateUserRequest("alice", "foo"); + MCRUser domainUser = mock(MCRUser.class); + MCRUserDetail expectedDetail = mock(MCRUserDetail.class); + List ownedUsers = List.of(); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.exists("alice")).thenReturn(false); + when(userDtoMapper.toDomain(request, null)).thenReturn(domainUser); + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(domainUser); + mgr.when(() -> MCRUserManager.listUsers(domainUser)).thenReturn(ownedUsers); + when(userDtoMapper.toDetail(domainUser, ownedUsers)).thenReturn(expectedDetail); + + MCRUserDetail result = userService.createUser(request); + + assertEquals(expectedDetail, result); + verify(userDtoMapper).toDomain(request, null); + mgr.verify(() -> MCRUserManager.createUser(domainUser)); + verify(userDtoMapper).toDetail(domainUser, ownedUsers); + } + } + + @Test + void createUserShouldThrowAlreadyExistsWhenUserExists() { + MCRCreateUserRequest request = buildCreateUserRequest("alice", "foo"); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.exists("alice")).thenReturn(true); + assertThrows(MCRUserAlreadyExistsException.class, () -> userService.createUser(request)); + mgr.verify(() -> MCRUserManager.createUser(any()), never()); + } + } + + @Test + void createUserShouldThrowValidationExceptionWhenMCRExceptionOccurs() { + MCRCreateUserRequest request = buildCreateUserRequest("alice", "foo"); + MCRUser domainUser = mock(MCRUser.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.exists("alice")).thenReturn(false); + when(userDtoMapper.toDomain(request, null)).thenReturn(domainUser); + mgr.when(() -> MCRUserManager.createUser(domainUser)).thenThrow(new MCRException("invalid data")); + + assertThrows(MCRUserValidationException.class, () -> userService.createUser(request)); + mgr.verify(() -> MCRUserManager.createUser(domainUser)); + } + } + + private MCRCreateUserRequest buildCreateUserRequest(String name, String password) { + return new MCRCreateUserRequest(name, null, null, password, + null, false, null, null, null, null); + } + + @Test + void getUserSummaryShouldReturnMappedSummary() { + MCRUser user = mock(MCRUser.class); + MCRUserSummary summary = mock(MCRUserSummary.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(user); + when(userDtoMapper.toSummary(user)).thenReturn(summary); + + assertEquals(summary, userService.getUserSummary("alice")); + verify(userDtoMapper).toSummary(user); + } + } + + @Test + void getUserSummaryShouldThrowNotFoundWhenUserMissing() { + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("unknown")).thenReturn(null); + + assertThrows(MCRUserNotFoundException.class, () -> userService.getUserSummary("unknown")); + } + } + + @Test + void getUserDetailShouldReturnMappedDetail() { + MCRUser user = mock(MCRUser.class); + MCRUserDetail detail = mock(MCRUserDetail.class); + List ownedUsers = List.of(); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(user); + mgr.when(() -> MCRUserManager.listUsers(user)).thenReturn(ownedUsers); + when(userDtoMapper.toDetail(user, ownedUsers)).thenReturn(detail); + + assertEquals(detail, userService.getUserDetail("alice")); + verify(userDtoMapper).toDetail(user, ownedUsers); + } + } + + @Test + void getUserStandardShouldReturnMappedStandard() { + MCRUser user = mock(MCRUser.class); + MCRUserStandard standard = mock(MCRUserStandard.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(user); + when(userDtoMapper.toStandard(user)).thenReturn(standard); + + assertEquals(standard, userService.getUserStandard("alice")); + verify(userDtoMapper).toStandard(user); + } + } + + @Test + void listStandardShouldReturnPagedResultsWithTotal() { + MCRUserService.MCRUserFilter filter = new MCRUserService.MCRUserFilter("a*", "local", null, null); + MCRUser u0 = mock(MCRUser.class); + MCRUser u1 = mock(MCRUser.class); + MCRUserStandard s0 = mock(MCRUserStandard.class); + MCRUserStandard s1 = mock(MCRUserStandard.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.listUsers("a*", "local", null, null, null, null, 0, 2)) + .thenReturn(List.of(u0, u1)); + mgr.when(() -> MCRUserManager.countUsers("a*", "local", null, null)) + .thenReturn(3); + when(userDtoMapper.toStandard(u0)).thenReturn(s0); + when(userDtoMapper.toStandard(u1)).thenReturn(s1); + + MCRUserService.MCRUserPage page = + userService.listStandard(filter, 0, 2); + + assertEquals(List.of(s0, s1), page.users()); + assertEquals(3L, page.total()); + + verify(userDtoMapper).toStandard(u0); + verify(userDtoMapper).toStandard(u1); + } + } + + @Test + void listStandardShouldSkipUsersBeforeOffset() { + MCRUserService.MCRUserFilter filter = new MCRUserService.MCRUserFilter(null, null, null, null); + MCRUser u2 = mock(MCRUser.class); + MCRUserStandard s2 = mock(MCRUserStandard.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.listUsers(null, null, null, null, null, null, 2, 10)) + .thenReturn(List.of(u2)); + mgr.when(() -> MCRUserManager.countUsers(null, null, null, null)) + .thenReturn(3); + when(userDtoMapper.toStandard(u2)).thenReturn(s2); + + MCRUserService.MCRUserPage page = + userService.listStandard(filter, 2, 10); + + assertEquals(List.of(s2), page.users()); + assertEquals(3L, page.total()); + + verify(userDtoMapper).toStandard(u2); + } + } + + @Test + void listSummaryShouldReturnEmptyPageWhenNoUsersExist() { + MCRUserService.MCRUserFilter filter = new MCRUserService.MCRUserFilter(null, null, null, null); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.listUsers(null, null, null, null)) + .thenReturn(List.of()); + + MCRUserService.MCRUserPage page = + userService.listSummary(filter, 0, 10); + + assertTrue(page.users().isEmpty()); + assertEquals(0, page.total()); + + verifyNoInteractions(userDtoMapper); + } + } + + @Test + void updateUserShouldApplyUpdateAndReturnDetail() { + MCRUpdateUserRequest request = mock(MCRUpdateUserRequest.class); + MCRUser existing = mock(MCRUser.class); + MCRUser updated = mock(MCRUser.class); + MCRUserDetail detail = mock(MCRUserDetail.class); + List ownedUsers = List.of(); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")) + .thenReturn(existing) + .thenReturn(updated); + when(updated.getUserName()).thenReturn("alice"); + when(request.owner()).thenReturn(null); // kein Owner + when(userDtoMapper.applyUpdate(existing, request, null)).thenReturn(updated); + mgr.when(() -> MCRUserManager.listUsers(updated)).thenReturn(ownedUsers); + when(userDtoMapper.toDetail(updated, ownedUsers)).thenReturn(detail); + + assertEquals(detail, userService.updateUser("alice", request)); + mgr.verify(() -> MCRUserManager.updateUser(updated)); + verify(userDtoMapper).applyUpdate(existing, request, null); + verify(userDtoMapper).toDetail(updated, ownedUsers); + } + } + + @Test + void updateUserShouldThrowNotFoundWhenUserMissing() { + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("ghost")).thenReturn(null); + + assertThrows(MCRUserNotFoundException.class, + () -> userService.updateUser("ghost", mock(MCRUpdateUserRequest.class))); + + verifyNoInteractions(userDtoMapper); + } + } + + @Test + void updateUserShouldThrowValidationExceptionWhenMCRExceptionOccurs() { + MCRUpdateUserRequest request = mock(MCRUpdateUserRequest.class); + MCRUser existing = mock(MCRUser.class); + MCRUser updated = mock(MCRUser.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(existing); + when(request.owner()).thenReturn(null); + when(userDtoMapper.applyUpdate(existing, request, null)).thenReturn(updated); + mgr.when(() -> MCRUserManager.updateUser(updated)) + .thenThrow(new MCRException("bad data")); + + assertThrows(MCRUserValidationException.class, () -> userService.updateUser("alice", request)); + + mgr.verify(() -> MCRUserManager.updateUser(updated)); + } + } + + @Test + void deleteUserShouldDeleteUser() { + MCRUser user = mock(MCRUser.class); + + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("alice")).thenReturn(user); + + userService.deleteUser("alice"); + + mgr.verify(() -> MCRUserManager.deleteUser("alice")); + } + } + + @Test + void deleteUserShouldDeleteExistingUser() { + try (MockedStatic mgr = mockStatic(MCRUserManager.class)) { + mgr.when(() -> MCRUserManager.getUser("ghost")).thenReturn(null); + + assertThrows(MCRUserNotFoundException.class, () -> userService.deleteUser("ghost")); + + mgr.verify(() -> MCRUserManager.deleteUser((String) any()), never()); + mgr.verify(() -> MCRUserManager.deleteUser((MCRUser) any()), never()); + } + } +} diff --git a/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/resource/MCRUsersTest.java b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/resource/MCRUsersTest.java new file mode 100644 index 0000000000..20fe28c0bf --- /dev/null +++ b/mycore-user-restapi/src/test/java/org/mycore/user/restapi/v2/resource/MCRUsersTest.java @@ -0,0 +1,249 @@ +/* + * This file is part of *** M y C o R e *** + * See https://www.mycore.de/ for details. + * + * MyCoRe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MyCoRe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MyCoRe. If not, see . + */ + +package org.mycore.user.restapi.v2.resource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mycore.restapi.MCRRestConstants; +import org.mycore.test.MyCoReTest; +import org.mycore.user.restapi.v2.MCRUserService; +import org.mycore.user.restapi.v2.dto.MCRCreateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUpdateUserRequest; +import org.mycore.user.restapi.v2.dto.MCRUserDetail; +import org.mycore.user.restapi.v2.dto.MCRUserStandard; +import org.mycore.user.restapi.v2.dto.MCRUserSummary; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.github.fge.jsonpatch.JsonPatch; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; + +@MyCoReTest +@ExtendWith(MockitoExtension.class) +class MCRUsersTest { + + @Mock + private MCRUserService userService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private UriInfo uriInfo; + + @Mock + private ContainerRequestContext request; + + private MCRUsers resource; + + @BeforeEach + void setUp() throws Exception { + resource = new MCRUsers(userService, objectMapper); + setField(resource, "uriInfo", uriInfo); + setField(resource, "request", request); + + // default detail level + lenient().when(request.getAcceptableMediaTypes()).thenReturn(List.of(MediaType.APPLICATION_JSON_TYPE)); + } + + @Test + void createUserShouldReturn201WithLocation() throws Exception { + MCRCreateUserRequest dto = buildCreateUserRequest("alice"); + MCRUserDetail detail = mock(MCRUserDetail.class); + when(detail.id()).thenReturn("alice"); + when(userService.createUser(dto)).thenReturn(detail); + when(uriInfo.getAbsolutePathBuilder()).thenReturn(UriBuilder.fromUri("http://localhost/api/v2/users")); + + Response response = resource.createUser(dto); + + assertEquals(201, response.getStatus()); + assertTrue(response.getLocation().toString().endsWith("/alice")); + } + + @Test + void getUserShouldCallGetStandardWhenNoDetailLevel() { + MCRUserStandard standard = mock(MCRUserStandard.class); + when(userService.getUserStandard("alice")).thenReturn(standard); + + Response response = resource.getUser("alice"); + + assertEquals(200, response.getStatus()); + verify(userService).getUserStandard("alice"); + } + + @Test + void getUserShouldCallGetSummaryWhenDetailLevelIsSummary() { + MCRUserSummary summary = mock(MCRUserSummary.class); + when(userService.getUserSummary("alice")).thenReturn(summary); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(buildMediaType("SUMMARY"))); + + Response response = resource.getUser("alice"); + + assertEquals(200, response.getStatus()); + verify(userService).getUserSummary("alice"); + } + + @Test + void getUserShouldCallGetDetailWhenDetailLevelIsDetailed() { + MCRUserDetail detail = mock(MCRUserDetail.class); + when(userService.getUserDetail("alice")).thenReturn(detail); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(buildMediaType("DETAILED"))); + + Response response = resource.getUser("alice"); + + assertEquals(200, response.getStatus()); + verify(userService).getUserDetail("alice"); + } + + @Test + void listUsersShouldCallListStandardWithCorrectFilter() { + MCRUserService.MCRUserPage page = new MCRUserService.MCRUserPage<>(List.of(), 0); + when(userService.listStandard(any(), eq(0), eq(100))).thenReturn(page); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(MediaType.APPLICATION_JSON_TYPE)); + + Response response = resource.listUsers("alice*", "John*", "*@example.com", "local", 0, 100); + + assertEquals(200, response.getStatus()); + verify(userService).listStandard( + eq(new MCRUserService.MCRUserFilter("alice*", "local", "John*", "*@example.com")), + eq(0), eq(100) + ); + } + + @Test + void listUsersShouldReturnTotalCountHeader() { + MCRUserService.MCRUserPage page = new MCRUserService.MCRUserPage<>(List.of(), 42); + when(userService.listStandard(any(), eq(0), eq(100))).thenReturn(page); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(MediaType.APPLICATION_JSON_TYPE)); + + Response response = resource.listUsers(null, null, null, null, 0, 100); + + assertEquals("42", response.getHeaderString(MCRRestConstants.HEADER_X_TOTAL_COUNT)); + } + + @Test + void listUsersShouldCallListSummaryWhenDetailLevelIsSummary() { + MCRUserService.MCRUserPage page = new MCRUserService.MCRUserPage<>(List.of(), 0); + when(userService.listSummary(any(), eq(0), eq(100))).thenReturn(page); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(buildMediaType("SUMMARY"))); + + Response response = resource.listUsers(null, null, null, null, 0, 100); + + assertEquals(200, response.getStatus()); + verify(userService).listSummary(any(), eq(0), eq(100)); + } + + @Test + void listUsersShouldCallListDetailWhenDetailLevelIsDetailed() { + MCRUserService.MCRUserPage page = new MCRUserService.MCRUserPage<>(List.of(), 0); + when(userService.listDetail(any(), eq(0), eq(100))).thenReturn(page); + when(request.getAcceptableMediaTypes()).thenReturn(List.of(buildMediaType("DETAILED"))); + + Response response = resource.listUsers(null, null, null, null, 0, 100); + + assertEquals(200, response.getStatus()); + verify(userService).listDetail(any(), eq(0), eq(100)); + } + + @Test + void updateUserShouldReturn204() { + MCRUpdateUserRequest dto = buildUpdateUserRequest(); + Response response = resource.updateUser("alice", dto); + + assertEquals(204, response.getStatus()); + verify(userService).updateUser("alice", dto); + } + + @Test + void patchUserShouldReturn204() throws Exception { + String patchJson = "[{\"op\":\"replace\",\"path\":\"/name\",\"value\":\"Bob\"}]"; + ObjectMapper realMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()); + JsonPatch patch = realMapper.readValue(patchJson, JsonPatch.class); + + MCRUserDetail existing = new MCRUserDetail( + "alice", "Alice", null, null, false, null, Map.of(), null, List.of(), List.of() + ); + JsonNode userNode = realMapper.valueToTree(existing); + JsonNode patchedNode = patch.apply(userNode); + + MCRUsers.MCRUserPatchState state = new MCRUsers.MCRUserPatchState( + "Bob", null, null, null, false, null, null, null, null + ); + + when(userService.getUserDetail("alice")).thenReturn(existing); + when(objectMapper.valueToTree(existing)).thenReturn(userNode); + when(objectMapper.treeToValue(patchedNode, MCRUsers.MCRUserPatchState.class)).thenReturn(state); + + Response response = resource.patchUser("alice", patch); + + assertEquals(204, response.getStatus()); + verify(userService).updateUser("alice", state.toUpdateRequest()); + } + + @Test + void deleteUserShouldReturn204() { + Response response = resource.deleteUser("alice"); + + assertEquals(204, response.getStatus()); + verify(userService).deleteUser("alice"); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private MediaType buildMediaType(String detailLevel) { + return new MediaType("application", "json", Map.of("detail", detailLevel)); + } + + private MCRUpdateUserRequest buildUpdateUserRequest() { + return new MCRUpdateUserRequest(null, null, null, null, false, + null, Map.of(), null, List.of()); + } + + private MCRCreateUserRequest buildCreateUserRequest(String name) { + return new MCRCreateUserRequest(name, null, null, null, + null, false, null, Map.of(), null, List.of()); + } +} diff --git a/mycore-user2/pom.xml b/mycore-user2/pom.xml index 2bc6d90361..a690997724 100644 --- a/mycore-user2/pom.xml +++ b/mycore-user2/pom.xml @@ -32,6 +32,30 @@ 15 + + + + org.apache.maven.plugins + maven-jar-plugin + + + test-jar + + test-jar + + + + + ${project.artifactId}-test + 16 + + + + + + + + at.favre.lib diff --git a/mycore-user2/src/main/java/org/mycore/user2/MCRUserManager.java b/mycore-user2/src/main/java/org/mycore/user2/MCRUserManager.java index 0cb9c85b83..afca66a969 100644 --- a/mycore-user2/src/main/java/org/mycore/user2/MCRUserManager.java +++ b/mycore-user2/src/main/java/org/mycore/user2/MCRUserManager.java @@ -447,7 +447,10 @@ public static List listUsers(String userPattern, String realm, String n attributeNamePattern, attributeValuePattern))) .setFirstResult(offset) .setMaxResults(limit) - .getResultList(); + .getResultList() + .stream() + .map(MCRUserManager::setRoles) + .toList(); } /** diff --git a/pom.xml b/pom.xml index 01bf296933..fdbfee5131 100644 --- a/pom.xml +++ b/pom.xml @@ -1039,6 +1039,11 @@ Applications based on MyCoRe use a common core, which provides the functionality jackson-jakarta-rs-json-provider ${jackson.version} + + com.github.java-json-tools + json-patch + 1.13 + com.google.code.gson gson @@ -1746,6 +1751,11 @@ Applications based on MyCoRe use a common core, which provides the functionality mycore-tei ${project.version} + + org.mycore + mycore-user-restapi + ${project.version} + org.mycore mycore-user2 @@ -2122,6 +2132,12 @@ Applications based on MyCoRe use a common core, which provides the functionality ${mockito.version} test + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + org.mycore mycore-base @@ -2129,6 +2145,13 @@ Applications based on MyCoRe use a common core, which provides the functionality test-jar test + + org.mycore + mycore-user2 + ${project.version} + test-jar + test + org.mycore selenium-utils @@ -2599,6 +2622,7 @@ Applications based on MyCoRe use a common core, which provides the functionality mycore-solr mycore-sword mycore-tei + mycore-user-restapi mycore-user2 mycore-viewer mycore-wcms2