From 4b253875189c4f6b4b3247b007e0631614238cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Tue, 6 Feb 2024 15:23:26 +0100 Subject: [PATCH] refactor: Migrate `GET /admin/users/` to Tapir (#3020) --- .../admin/UsersResponderADMSpec.scala | 91 +++++++-------- .../usersmessages/UsersMessagesADM.scala | 88 +++------------ .../responders/admin/UsersResponderADM.scala | 104 ++++++++---------- .../knora/webapi/routing/Authenticator.scala | 32 ++---- .../webapi/routing/admin/UsersRouteADM.scala | 30 ----- .../knora/webapi/slice/admin/api/Codecs.scala | 8 +- .../slice/admin/api/UsersEndpoints.scala | 27 ++++- .../admin/api/UsersEndpointsHandler.scala | 15 ++- .../admin/api/service/UsersRestService.scala | 18 +++ .../slice/admin/domain/model/User.scala | 62 ++++------- .../webapi/slice/common/ValueTypes.scala | 2 +- .../knora/webapi/config/AppConfigSpec.scala | 2 +- .../slice/admin/domain/model/UserSpec.scala | 2 +- 13 files changed, 207 insertions(+), 274 deletions(-) diff --git a/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderADMSpec.scala b/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderADMSpec.scala index 465d6d807e..6df51ffd86 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderADMSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderADMSpec.scala @@ -13,7 +13,6 @@ import java.util.UUID import dsp.errors.BadRequestException import dsp.errors.DuplicateValueException import dsp.errors.ForbiddenException -import dsp.errors.NotFoundException import dsp.valueobjects.LanguageCode import org.knora.webapi.* import org.knora.webapi.messages.StringFormatter @@ -87,79 +86,73 @@ class UsersResponderADMSpec extends CoreSpec with ImplicitSender { "asked about an user identified by 'iri' " should { "return a profile if the user (root user) is known" in { - appActor ! UserGetByIriADM( - identifier = UserIri.unsafeFrom(rootUser.id), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByIri( + UserIri.unsafeFrom(rootUser.id), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(Some(rootUser.ofType(UserInformationTypeADM.Full))) + actual shouldBe Some(rootUser.ofType(UserInformationTypeADM.Full)) } "return 'None' when the user is unknown" in { - appActor ! UserGetByIriADM( - identifier = UserIri.unsafeFrom("http://rdfh.ch/users/notexisting"), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByIri( + UserIri.unsafeFrom("http://rdfh.ch/users/notexisting"), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(None) + actual shouldBe None } } "asked about an user identified by 'email'" should { "return a profile if the user (root user) is known" in { - appActor ! UserGetByEmailADM( - email = Email.unsafeFrom(rootUser.email), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser - ) - expectMsg(Some(rootUser.ofType(UserInformationTypeADM.Full))) - } - - "return 'NotFoundException' when the user is unknown" in { - appActor ! UserGetByEmailRequestADM( - email = Email.unsafeFrom("userwrong@example.com"), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByEmail( + Email.unsafeFrom(rootUser.email), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(Failure(NotFoundException(s"User 'userwrong@example.com' not found"))) + actual shouldBe Some(rootUser.ofType(UserInformationTypeADM.Full)) } "return 'None' when the user is unknown" in { - appActor ! UserGetByEmailADM( - email = Email.unsafeFrom("userwrong@example.com"), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByEmail( + Email.unsafeFrom("userwrong@example.com"), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(None) + actual shouldBe None } } "asked about an user identified by 'username'" should { "return a profile if the user (root user) is known" in { - appActor ! UserGetByUsernameADM( - username = Username.unsafeFrom(rootUser.username), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser - ) - expectMsg(Some(rootUser.ofType(UserInformationTypeADM.Full))) - } - - "return 'NotFoundException' when the user is unknown" in { - appActor ! UserGetByUsernameRequestADM( - username = Username.unsafeFrom("userwrong"), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByUsername( + Username.unsafeFrom(rootUser.username), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(Failure(NotFoundException(s"User 'userwrong' not found"))) + actual shouldBe Some(rootUser.ofType(UserInformationTypeADM.Full)) } "return 'None' when the user is unknown" in { - appActor ! UserGetByUsernameADM( - username = Username.unsafeFrom("userwrong"), - userInformationTypeADM = UserInformationTypeADM.Full, - requestingUser = KnoraSystemInstances.Users.SystemUser + val actual = UnsafeZioRun.runOrThrow( + UsersResponderADM.findUserByUsername( + Username.unsafeFrom("userwrong"), + UserInformationTypeADM.Full, + KnoraSystemInstances.Users.SystemUser + ) ) - expectMsg(None) + actual shouldBe None } } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala index 3d185f28fe..40eaf5ff9b 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala @@ -25,6 +25,8 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Permissions import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol import org.knora.webapi.slice.admin.domain.model.* +import org.knora.webapi.slice.common.ToValidation.validateOneWithFrom +import org.knora.webapi.slice.common.ToValidation.validateOptionWithFrom ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // API requests @@ -159,58 +161,6 @@ case class UserGetByIriADM( requestingUser: User ) extends UsersResponderRequestADM -/** - * A message that requests a user's profile by username. A successful response will be a [[User]]. - * - * @param username the username of the user to be queried. - * @param userInformationTypeADM the extent of the information returned. - * @param requestingUser the user initiating the request. - */ -case class UserGetByUsernameADM( - username: Username, - userInformationTypeADM: UserInformationTypeADM = UserInformationTypeADM.Short, - requestingUser: User -) extends UsersResponderRequestADM - -/** - * A message that requests a user's profile by email. A successful response will be a [[User]]. - * - * @param email the email of the user to be queried. - * @param userInformationTypeADM the extent of the information returned. - * @param requestingUser the user initiating the request. - */ -case class UserGetByEmailADM( - email: Email, - userInformationTypeADM: UserInformationTypeADM = UserInformationTypeADM.Short, - requestingUser: User -) extends UsersResponderRequestADM - -/** - * A message that requests a user's profile by email. A successful response will be a [[UserResponseADM]]. - * - * @param email the email of the user to be queried. - * @param userInformationTypeADM the extent of the information returned. - * @param requestingUser the user initiating the request. - */ -case class UserGetByEmailRequestADM( - email: Email, - userInformationTypeADM: UserInformationTypeADM = UserInformationTypeADM.Short, - requestingUser: User -) extends UsersResponderRequestADM - -/** - * A message that requests a user's profile by username. A successful response will be a [[UserResponseADM]]. - * - * @param username the username of the user to be queried. - * @param userInformationTypeADM the extent of the information returned. - * @param requestingUser the user initiating the request. - */ -case class UserGetByUsernameRequestADM( - username: Username, - userInformationTypeADM: UserInformationTypeADM = UserInformationTypeADM.Short, - requestingUser: User -) extends UsersResponderRequestADM - /** * Requests the creation of a new user. * @@ -594,10 +544,10 @@ object UserUpdateBasicInformationPayloadADM { def make(req: ChangeUserApiRequestADM): Validation[ValidationException, UserUpdateBasicInformationPayloadADM] = Validation.validateWith( - validateWithOptionOrNone(req.username, Username.validationFrom).mapError(ValidationException(_)), - validateWithOptionOrNone(req.email, Email.validationFrom).mapError(ValidationException(_)), - validateWithOptionOrNone(req.givenName, GivenName.validationFrom).mapError(ValidationException(_)), - validateWithOptionOrNone(req.familyName, FamilyName.validationFrom).mapError(ValidationException(_)), + validateOptionWithFrom(req.username, Username.from, ValidationException.apply), + validateOptionWithFrom(req.email, Email.from, ValidationException.apply), + validateOptionWithFrom(req.givenName, GivenName.from, ValidationException.apply), + validateOptionWithFrom(req.familyName, FamilyName.from, ValidationException.apply), validateWithOptionOrNone(req.lang, LanguageCode.make) )(UserUpdateBasicInformationPayloadADM.apply) } @@ -606,11 +556,12 @@ case class UserUpdatePasswordPayloadADM(requesterPassword: Password, newPassword object UserUpdatePasswordPayloadADM { def make(apiRequest: ChangeUserPasswordApiRequestADM): Validation[String, UserUpdatePasswordPayloadADM] = { val requesterPasswordValidation = apiRequest.requesterPassword - .map(Password.validationFrom) + .map(validateOneWithFrom(_, Password.from, a => a)) .getOrElse(Validation.fail("The requester's password is missing.")) - val newPasswordValidation = apiRequest.newPassword - .map(Password.validationFrom) - .getOrElse(Validation.fail("The new password is missing.")) + val newPasswordValidation = + apiRequest.newPassword + .map(validateOneWithFrom(_, Password.from, a => a)) + .getOrElse(Validation.fail("The new password is missing.")) Validation.validateWith(requesterPasswordValidation, newPasswordValidation)(UserUpdatePasswordPayloadADM.apply) } } @@ -640,19 +591,16 @@ object UserCreatePayloadADM { def make(apiRequest: CreateUserApiRequestADM): Validation[String, UserCreatePayloadADM] = Validation .validateWith( - apiRequest.id - .map(UserIri.validationFrom(_).map(Some(_)).mapError(ValidationException(_))) - .getOrElse(Validation.succeed(None)), - Username.validationFrom(apiRequest.username).mapError(ValidationException(_)), - Email.validationFrom(apiRequest.email).mapError(ValidationException(_)), - GivenName.validationFrom(apiRequest.givenName).mapError(ValidationException(_)), - FamilyName.validationFrom(apiRequest.familyName).mapError(ValidationException(_)), - Password.validationFrom(apiRequest.password).mapError(ValidationException(_)), + validateOptionWithFrom(apiRequest.id, UserIri.from, a => a), + validateOneWithFrom(apiRequest.username, Username.from, a => a), + validateOneWithFrom(apiRequest.email, Email.from, a => a), + validateOneWithFrom(apiRequest.givenName, GivenName.from, a => a), + validateOneWithFrom(apiRequest.familyName, FamilyName.from, a => a), + validateOneWithFrom(apiRequest.password, Password.from, a => a), Validation.succeed(UserStatus.from(apiRequest.status)), - LanguageCode.make(apiRequest.lang), + LanguageCode.make(apiRequest.lang).mapError(_.getMessage), Validation.succeed(SystemAdmin.from(apiRequest.systemAdmin)) )(UserCreatePayloadADM.apply) - .mapError(_.getMessage) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala index e81fe5cda5..bef1577134 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala @@ -97,6 +97,50 @@ trait UsersResponderADM { requestingUser: User, skipCache: Boolean = false ): Task[Option[User]] + + /** + * ~ CACHED ~ + * Gets information about a Knora user, and returns it as a [[User]]. + * If possible, tries to retrieve it from the cache. If not, it retrieves + * it from the triplestore, and then writes it to the cache. Writes to the + * cache are always `UserInformationTypeADM.FULL`. + * + * @param email the email of the user. + * @param userInformationType the type of the requested profile (restricted + * of full). + * @param requestingUser the user initiating the request. + * @param skipCache the flag denotes to skip the cache and instead + * get data from the triplestore + * @return a [[User]] describing the user. + */ + def findUserByEmail( + email: Email, + userInformationType: UserInformationTypeADM, + requestingUser: User, + skipCache: Boolean = false + ): Task[Option[User]] + + /** + * ~ CACHED ~ + * Gets information about a Knora user, and returns it as a [[User]]. + * If possible, tries to retrieve it from the cache. If not, it retrieves + * it from the triplestore, and then writes it to the cache. Writes to the + * cache are always `UserInformationTypeADM.FULL`. + * + * @param username the username of the user. + * @param userInformationType the type of the requested profile (restricted + * of full). + * @param requestingUser the user initiating the request. + * @param skipCache the flag denotes to skip the cache and instead + * get data from the triplestore + * @return a [[User]] describing the user. + */ + def findUserByUsername( + username: Username, + userInformationType: UserInformationTypeADM, + requestingUser: User, + skipCache: Boolean = false + ): Task[Option[User]] } final case class UsersResponderADMLive( @@ -124,14 +168,6 @@ final case class UsersResponderADMLive( getAllUserADMRequest(requestingUser) case UserGetByIriADM(identifier, userInformationTypeADM, requestingUser) => findUserByIri(identifier, userInformationTypeADM, requestingUser) - case UserGetByEmailADM(email, userInformationTypeADM, requestingUser) => - getSingleUserByEmailADM(email, userInformationTypeADM, requestingUser) - case UserGetByUsernameADM(username, userInformationTypeADM, requestingUser) => - getSingleUserByUsernameADM(username, userInformationTypeADM, requestingUser) - case UserGetByEmailRequestADM(email, userInformationTypeADM, requestingUser) => - getSingleUserByEmailADMRequest(email, userInformationTypeADM, requestingUser) - case UserGetByUsernameRequestADM(username, userInformationTypeADM, requestingUser) => - getSingleUserByUsernameADMRequest(username, userInformationTypeADM, requestingUser) case UserCreateRequestADM(userCreatePayloadADM, _, apiRequestID) => createNewUserADM(userCreatePayloadADM, apiRequestID) case UserChangeBasicInformationRequestADM( @@ -329,7 +365,7 @@ final case class UsersResponderADMLive( /** * If the requesting user is a system admin, or is requesting themselves, or is a system user, - * returns the user in the requestet format. Otherwise, returns only public information. + * returns the user in the requested format. Otherwise, returns only public information. * @param user the user to be returned * @param requestingUser the user requesting the information * @param infoType the type of information requested @@ -355,7 +391,7 @@ final case class UsersResponderADMLive( * get data from the triplestore * @return a [[User]] describing the user. */ - private def getSingleUserByEmailADM( + override def findUserByEmail( email: Email, userInformationType: UserInformationTypeADM, requestingUser: User, @@ -387,7 +423,7 @@ final case class UsersResponderADMLive( * get data from the triplestore * @return a [[User]] describing the user. */ - private def getSingleUserByUsernameADM( + override def findUserByUsername( username: Username, userInformationType: UserInformationTypeADM, requestingUser: User, @@ -405,52 +441,6 @@ final case class UsersResponderADMLive( _ <- ZIO.logDebug(s"getSingleUserByIriADM - retrieved user '${username.value}': ${finalResponse.nonEmpty}") } yield finalResponse - /** - * Gets information about a Knora user, and returns it as a [[UserResponseADM]]. - * - * @param email the IRI of the user. - * @param userInformationType the type of the requested profile (restricted of full). - * @param requestingUser the user initiating the request. - * @return a [[UserResponseADM]] - */ - private def getSingleUserByEmailADMRequest( - email: Email, - userInformationType: UserInformationTypeADM, - requestingUser: User - ): Task[UserResponseADM] = - for { - maybeUserADM <- getSingleUserByEmailADM(email, userInformationType, requestingUser) - result <- ZIO - .fromOption(maybeUserADM) - .mapBoth( - _ => NotFoundException(s"User '${email.value}' not found"), - user => UserResponseADM(user = user) - ) - } yield result - - /** - * Gets information about a Knora user, and returns it as a [[UserResponseADM]]. - * - * @param username the IRI of the user. - * @param userInformationType the type of the requested profile (restricted of full). - * @param requestingUser the user initiating the request. - * @return a [[UserResponseADM]] - */ - private def getSingleUserByUsernameADMRequest( - username: Username, - userInformationType: UserInformationTypeADM, - requestingUser: User - ): Task[UserResponseADM] = - for { - maybeUserADM <- getSingleUserByUsernameADM(username, userInformationType, requestingUser) - result <- ZIO - .fromOption(maybeUserADM) - .mapBoth( - _ => NotFoundException(s"User '${username.value}' not found"), - user => UserResponseADM(user = user) - ) - } yield result - /** * Updates an existing user. Only basic user data information (username, email, givenName, familyName, lang) * can be changed. For changing the password or user status, use the separate methods. diff --git a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala index 81bcdfebf4..12fdb434c5 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/Authenticator.scala @@ -23,7 +23,6 @@ import java.util.Base64 import dsp.errors.AuthenticationException import dsp.errors.BadCredentialsException import org.knora.webapi.config.AppConfig -import org.knora.webapi.core.MessageRelay import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.admin.responder.usersmessages.* import org.knora.webapi.messages.util.KnoraSystemInstances @@ -31,6 +30,7 @@ import org.knora.webapi.messages.v2.routing.authenticationmessages.KnoraCredenti import org.knora.webapi.messages.v2.routing.authenticationmessages.KnoraCredentialsV2.KnoraPasswordCredentialsV2 import org.knora.webapi.messages.v2.routing.authenticationmessages.KnoraCredentialsV2.KnoraSessionCredentialsV2 import org.knora.webapi.messages.v2.routing.authenticationmessages.* +import org.knora.webapi.responders.admin.UsersResponderADM import org.knora.webapi.routing.Authenticator.AUTHENTICATION_INVALIDATION_CACHE_NAME import org.knora.webapi.routing.Authenticator.BAD_CRED_NONE_SUPPLIED import org.knora.webapi.routing.Authenticator.BAD_CRED_NOT_VALID @@ -155,7 +155,7 @@ object Authenticator { final case class AuthenticatorLive( private val appConfig: AppConfig, - private val messageRelay: MessageRelay, + private val usersResponder: UsersResponderADM, private val jwtService: JwtService, private implicit val stringFormatter: StringFormatter ) extends Authenticator { @@ -653,10 +653,9 @@ final case class AuthenticatorLive( * [[BadCredentialsException]] when either the supplied email is empty or no user with such an email could be found. */ override def getUserByIri(iri: UserIri): Task[User] = - messageRelay - .ask[Option[User]](UserGetByIriADM(iri, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser)) - .flatMap(ZIO.fromOption(_)) - .orElseFail(BadCredentialsException(BAD_CRED_NOT_VALID)) + usersResponder + .findUserByIri(iri, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser) + .someOrFail(BadCredentialsException(BAD_CRED_NOT_VALID)) /** * Tries to get a [[User]]. @@ -667,12 +666,9 @@ final case class AuthenticatorLive( * [[BadCredentialsException]] when either the supplied email is empty or no user with such an email could be found. */ override def getUserByEmail(email: Email): Task[User] = - messageRelay - .ask[Option[User]]( - UserGetByEmailADM(email, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser) - ) - .flatMap(ZIO.fromOption(_)) - .orElseFail(BadCredentialsException(BAD_CRED_NOT_VALID)) + usersResponder + .findUserByEmail(email, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser) + .someOrFail(BadCredentialsException(BAD_CRED_NOT_VALID)) /** * Tries to get a [[User]]. @@ -683,12 +679,9 @@ final case class AuthenticatorLive( * [[BadCredentialsException]] when either the supplied email is empty or no user with such an email could be found. */ override def getUserByUsername(username: Username): Task[User] = - messageRelay - .ask[Option[User]]( - UserGetByUsernameADM(username, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser) - ) - .flatMap(ZIO.fromOption(_)) - .orElseFail(BadCredentialsException(BAD_CRED_NOT_VALID)) + usersResponder + .findUserByUsername(username, UserInformationTypeADM.Full, KnoraSystemInstances.Users.SystemUser) + .someOrFail(BadCredentialsException(BAD_CRED_NOT_VALID)) /** * Calculates the cookie name, where the external host and port are encoded as a base32 string @@ -707,6 +700,5 @@ final case class AuthenticatorLive( } object AuthenticatorLive { - val layer: URLayer[AppConfig & JwtService & MessageRelay & StringFormatter, AuthenticatorLive] = - ZLayer.fromFunction(AuthenticatorLive.apply _) + val layer = ZLayer.derive[AuthenticatorLive] } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala index 0e4da37e7f..a09bf54574 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala @@ -36,8 +36,6 @@ final case class UsersRouteADM()( def makeRoute: Route = addUser() ~ - getUserByEmail ~ - getUserByUsername ~ changeUserBasicInformation() ~ changeUserPassword() ~ changeUserStatus() ~ @@ -66,34 +64,6 @@ final case class UsersRouteADM()( } } - /** - * return a single user identified by email - */ - private def getUserByEmail: Route = - path(usersBasePath / "email" / Segment)(emailStr => - ctx => { - val task = for { - requestingUser <- Authenticator.getUserADM(ctx) - email <- ZIO.fromEither(Email.from(emailStr)).mapError(BadRequestException(_)) - } yield UserGetByEmailRequestADM(email, UserInformationTypeADM.Restricted, requestingUser) - runJsonRouteZ(task, ctx) - } - ) - - /** - * return a single user identified by username - */ - private def getUserByUsername: Route = - path(usersBasePath / "username" / Segment)(usernameStr => - ctx => { - val task = for { - requestingUser <- Authenticator.getUserADM(ctx) - username <- ZIO.fromEither(Username.from(usernameStr)).mapError(BadRequestException(_)) - } yield UserGetByUsernameRequestADM(username, UserInformationTypeADM.Restricted, requestingUser) - runJsonRouteZ(task, ctx) - } - ) - /** * Change existing user's basic information. */ diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala index 61043322e9..465862f9d2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala @@ -11,6 +11,7 @@ import zio.json.JsonCodec import dsp.valueobjects.V2 import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId +import org.knora.webapi.slice.admin.domain.model.Email import org.knora.webapi.slice.admin.domain.model.KnoraProject.* import org.knora.webapi.slice.admin.domain.model.ListProperties.Comments import org.knora.webapi.slice.admin.domain.model.ListProperties.Labels @@ -19,6 +20,7 @@ import org.knora.webapi.slice.admin.domain.model.ListProperties.ListName import org.knora.webapi.slice.admin.domain.model.ListProperties.Position import org.knora.webapi.slice.admin.domain.model.RestrictedViewSize import org.knora.webapi.slice.admin.domain.model.UserIri +import org.knora.webapi.slice.admin.domain.model.Username import org.knora.webapi.slice.common.Value.BooleanValue import org.knora.webapi.slice.common.Value.IntValue import org.knora.webapi.slice.common.Value.StringValue @@ -49,10 +51,14 @@ object Codecs { implicit val shortname: StringCodec[Shortname] = stringCodec(Shortname.from) implicit val sparqlEncodedString: StringCodec[SparqlEncodedString] = stringCodec(SparqlEncodedString.from) implicit val status: StringCodec[Status] = booleanCodec(Status.from) - implicit val userIri: StringCodec[UserIri] = stringCodec(UserIri.from) // list properties implicit val listIri: StringCodec[ListIri] = stringCodec(ListIri.from) + + // user value objects + implicit val userIri: StringCodec[UserIri] = stringCodec(UserIri.from) + implicit val userEmail: StringCodec[Email] = stringCodec(Email.from) + implicit val username: StringCodec[Username] = stringCodec(Username.from) } object ZioJsonCodec { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala index d994812680..1162ea4653 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala @@ -14,13 +14,23 @@ import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResp import org.knora.webapi.messages.admin.responder.usersmessages.UserResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol.* import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM -import org.knora.webapi.slice.admin.api.Codecs.TapirCodec.userIri +import org.knora.webapi.slice.admin.api.PathVars.emailPathVar +import org.knora.webapi.slice.admin.api.PathVars.usernamePathVar +import org.knora.webapi.slice.admin.domain.model.Email import org.knora.webapi.slice.admin.domain.model.UserIri +import org.knora.webapi.slice.admin.domain.model.Username import org.knora.webapi.slice.common.api.BaseEndpoints object PathVars { + import Codecs.TapirCodec.* val userIriPathVar: EndpointInput.PathCapture[UserIri] = path[UserIri].description("The user IRI. Must be URL-encoded.") + + val emailPathVar: EndpointInput.PathCapture[Email] = + path[Email].description("The user email. Must be URL-encoded.") + + val usernamePathVar: EndpointInput.PathCapture[Username] = + path[Username].description("The user name. Must be URL-encoded.") } final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { @@ -34,14 +44,25 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { val getUserByIri = baseEndpoints.withUserEndpoint.get .in(base / "iri" / PathVars.userIriPathVar) .out(sprayJsonBody[UserResponseADM]) - .description("Returns a user identified by IRI.") + .description("Returns a user identified by their IRI.") + + val getUserByEmail = baseEndpoints.withUserEndpoint.get + .in(base / "email" / emailPathVar) + .out(sprayJsonBody[UserResponseADM]) + .description("Returns a user identified by their Email.") + + val getUserByUsername = baseEndpoints.withUserEndpoint.get + .in(base / "username" / usernamePathVar) + .out(sprayJsonBody[UserResponseADM]) + .description("Returns a user identified by their Username.") val deleteUser = baseEndpoints.securedEndpoint.delete .in(base / "iri" / PathVars.userIriPathVar) .out(sprayJsonBody[UserOperationResponseADM]) .description("Delete a user identified by IRI (change status to false).") - val endpoints: Seq[AnyEndpoint] = Seq(getUsers, getUserByIri, deleteUser).map(_.endpoint.tag("Admin Users")) + val endpoints: Seq[AnyEndpoint] = + Seq(getUsers, getUserByIri, getUserByEmail, getUserByUsername, deleteUser).map(_.endpoint.tag("Admin Users")) } object UsersEndpoints { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala index 424bdfc4b4..f40f6c05c2 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala @@ -11,7 +11,9 @@ import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResp import org.knora.webapi.messages.admin.responder.usersmessages.UserResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM import org.knora.webapi.slice.admin.api.service.UsersRestService +import org.knora.webapi.slice.admin.domain.model.Email import org.knora.webapi.slice.admin.domain.model.UserIri +import org.knora.webapi.slice.admin.domain.model.Username import org.knora.webapi.slice.common.api.HandlerMapper import org.knora.webapi.slice.common.api.SecuredEndpointHandler @@ -31,13 +33,24 @@ case class UsersEndpointsHandler( requestingUser => userIri => restService.getUserByIri(requestingUser, userIri) ) + private val getUserByEmailHandler = SecuredEndpointHandler[Email, UserResponseADM]( + usersEndpoints.getUserByEmail, + requestingUser => email => restService.getUserByEmail(requestingUser, email) + ) + + private val getUserByUsernameHandler = SecuredEndpointHandler[Username, UserResponseADM]( + usersEndpoints.getUserByUsername, + requestingUser => username => restService.getUserByUsername(requestingUser, username) + ) + private val deleteUserByIriHandler = SecuredEndpointHandler[UserIri, UserOperationResponseADM]( usersEndpoints.deleteUser, requestingUser => userIri => restService.deleteUser(requestingUser, userIri) ) val allHanders = - List(getUsersHandler, getUserByIriHandler, deleteUserByIriHandler).map(mapper.mapSecuredEndpointHandler(_)) + List(getUsersHandler, getUserByIriHandler, getUserByEmailHandler, getUserByUsernameHandler, deleteUserByIriHandler) + .map(mapper.mapSecuredEndpointHandler(_)) } object UsersEndpointsHandler { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala index 821b9553a6..abf95d3cff 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala @@ -14,9 +14,11 @@ import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResp import org.knora.webapi.messages.admin.responder.usersmessages.UserResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM import org.knora.webapi.responders.admin.UsersResponderADM +import org.knora.webapi.slice.admin.domain.model.Email import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.admin.domain.model.UserStatus +import org.knora.webapi.slice.admin.domain.model.Username import org.knora.webapi.slice.common.api.KnoraResponseRenderer final case class UsersRestService( @@ -38,6 +40,22 @@ final case class UsersRestService( external <- format.toExternal(internal) } yield external + def getUserByEmail(requestingUser: User, email: Email): Task[UserResponseADM] = for { + internal <- responder + .findUserByEmail(email, UserInformationTypeADM.Restricted, requestingUser) + .someOrFail(NotFoundException(s"User with email '${email.value}' not found")) + .map(UserResponseADM.apply) + external <- format.toExternal(internal) + } yield external + + def getUserByUsername(requestingUser: User, username: Username): Task[UserResponseADM] = for { + internal <- responder + .findUserByUsername(username, UserInformationTypeADM.Restricted, requestingUser) + .someOrFail(NotFoundException(s"User with username '${username.value}' not found")) + .map(UserResponseADM.apply) + external <- format.toExternal(internal) + } yield external + def getUserByIri(requestingUser: User, userIri: UserIri): Task[UserResponseADM] = for { internal <- responder .findUserByIri(userIri, UserInformationTypeADM.Restricted, requestingUser) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala index 566b3dab9a..7d215ac73c 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala @@ -22,8 +22,10 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Permissions import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.usersmessages.UserInformationTypeADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol +import org.knora.webapi.slice.common.IntValueCompanion import org.knora.webapi.slice.common.StringValueCompanion import org.knora.webapi.slice.common.Value.BooleanValue +import org.knora.webapi.slice.common.Value.IntValue import org.knora.webapi.slice.common.Value.StringValue /** @@ -180,8 +182,9 @@ object UserIri extends StringValueCompanion[UserIri] { def validationFrom(value: String): Validation[String, UserIri] = Validation.fromEither(from(value)) } -final case class Username private (value: String) extends AnyVal -object Username { self => +final case class Username private (value: String) extends AnyVal with StringValue + +object Username extends StringValueCompanion[Username] { /** * A regex that matches a valid username @@ -202,14 +205,11 @@ object Username { self => case None => Left(UserErrorMessages.UsernameInvalid) } } - def unsafeFrom(value: String): Username = - Username.from(value).fold(e => throw new IllegalArgumentException(e), identity) - - def validationFrom(value: String): Validation[String, Username] = Validation.fromEither(from(value)) } -final case class Email private (value: String) extends AnyVal -object Email { self => +final case class Email private (value: String) extends AnyVal with StringValue + +object Email extends StringValueCompanion[Email] { private val EmailRegex: Regex = """^.+@.+$""".r def from(value: String): Either[String, Email] = @@ -221,38 +221,26 @@ object Email { self => case None => Left(UserErrorMessages.EmailInvalid) } } - - def unsafeFrom(value: String): Email = - Email.from(value).fold(e => throw new IllegalArgumentException(e), identity) - - def validationFrom(value: String): Validation[String, Email] = Validation.fromEither(from(value)) - } -final case class GivenName private (value: String) extends AnyVal -object GivenName { self => +final case class GivenName private (value: String) extends AnyVal with StringValue + +object GivenName extends StringValueCompanion[GivenName] { def from(value: String): Either[String, GivenName] = Option.when(value.nonEmpty)(GivenName(value)).toRight(UserErrorMessages.GivenNameMissing) - - def unsafeFrom(value: String): GivenName = - GivenName.from(value).fold(e => throw new IllegalArgumentException(e), identity) - - def validationFrom(value: String): Validation[String, GivenName] = Validation.fromEither(from(value)) } -final case class FamilyName private (value: String) extends AnyVal -object FamilyName { self => +final case class FamilyName private (value: String) extends AnyVal with StringValue + +object FamilyName extends StringValueCompanion[FamilyName] { def from(value: String): Either[String, FamilyName] = Option.when(value.nonEmpty)(FamilyName(value)).toRight(UserErrorMessages.FamilyNameMissing) +} - def unsafeFrom(value: String): FamilyName = - FamilyName.from(value).fold(e => throw new IllegalArgumentException(e), identity) +final case class Password private (value: String) extends AnyVal with StringValue - def validationFrom(value: String): Validation[String, FamilyName] = Validation.fromEither(from(value)) -} +object Password extends StringValueCompanion[Password] { -final case class Password private (value: String) extends AnyVal -object Password { self => private val PasswordRegex: Regex = """^[\s\S]*$""".r def from(value: String): Either[String, Password] = @@ -264,11 +252,6 @@ object Password { self => case None => Left(UserErrorMessages.PasswordInvalid) } } - - def unsafeFrom(value: String): Password = - Password.from(value).fold(e => throw new IllegalArgumentException(e), identity) - - def validationFrom(value: String): Validation[String, Password] = Validation.fromEither(from(value)) } final case class PasswordHash private (value: String, passwordStrength: PasswordStrength) { self => @@ -317,13 +300,11 @@ object PasswordHash { PasswordHash.from(value, passwordStrength).fold(e => throw new IllegalArgumentException(e), identity) } -final case class PasswordStrength private (value: Int) extends AnyVal -object PasswordStrength { +final case class PasswordStrength private (value: Int) extends AnyVal with IntValue + +object PasswordStrength extends IntValueCompanion[PasswordStrength] { def from(i: Int): Either[String, PasswordStrength] = Option.unless(i < 4 || i > 31)(PasswordStrength(i)).toRight(UserErrorMessages.PasswordStrengthInvalid) - - def unsafeMake(value: Int): PasswordStrength = PasswordStrength(value) - } final case class UserStatus private (value: Boolean) extends AnyVal with BooleanValue @@ -336,7 +317,8 @@ object UserStatus { def from(value: Boolean): UserStatus = if (value) Active else Inactive } -final case class SystemAdmin private (value: Boolean) extends AnyVal +final case class SystemAdmin private (value: Boolean) extends AnyVal with BooleanValue + object SystemAdmin { def from(value: Boolean): SystemAdmin = SystemAdmin(value) } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/ValueTypes.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/ValueTypes.scala index 832c6b3155..31dbd928a1 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/ValueTypes.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/ValueTypes.scala @@ -32,7 +32,7 @@ trait StringValueCompanion[A <: StringValue] extends WithFrom[String, A] trait IntValueCompanion[A <: IntValue] extends WithFrom[Int, A] object ToValidation { - def validateOneWithFrom[A, B <: Value[?], E <: Throwable]( + def validateOneWithFrom[A, B <: Value[?], E]( a: A, validator: A => Either[String, B], err: String => E diff --git a/webapi/src/test/scala/org/knora/webapi/config/AppConfigSpec.scala b/webapi/src/test/scala/org/knora/webapi/config/AppConfigSpec.scala index e19fb2820c..c9d1cb7016 100644 --- a/webapi/src/test/scala/org/knora/webapi/config/AppConfigSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/config/AppConfigSpec.scala @@ -29,7 +29,7 @@ object AppConfigSpec extends ZIOSpecDefault { appConfig.sipi.timeout == Duration.ofSeconds(120), appConfig.triplestore.queryTimeout == Duration.ofSeconds(20), appConfig.triplestore.gravsearchTimeout == Duration.ofSeconds(120), - appConfig.bcryptPasswordStrength == PasswordStrength.unsafeMake(12).value, + appConfig.bcryptPasswordStrength == PasswordStrength.unsafeFrom(12).value, appConfig.instrumentationServerConfig.interval == Duration.ofSeconds(5), dspIngestConfig.audience == "http://localhost:3340", dspIngestConfig.baseUrl == "http://localhost:3340", diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala index 0c18313c25..05fc6b2bf7 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala @@ -11,7 +11,7 @@ object UserSpec extends ZIOSpecDefault { private val validPassword = "pass-word" private val validGivenName = "John" private val validFamilyName = "Rambo" - private val pwStrength = PasswordStrength.unsafeMake(12) + private val pwStrength = PasswordStrength.unsafeFrom(12) private val userSuite = suite("User")()