diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala new file mode 100644 index 0000000..3687002 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala @@ -0,0 +1,4 @@ +package works.iterative.core.auth + +trait UserRoles extends UserInfo: + def roles: Set[UserRole] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala new file mode 100644 index 0000000..3687002 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala @@ -0,0 +1,4 @@ +package works.iterative.core.auth + +trait UserRoles extends UserInfo: + def roles: Set[UserRole] diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index 150c6e6..26a5db6 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -14,6 +14,11 @@ import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router +import works.iterative.core.UserName +import works.iterative.core.auth.UserRole +import works.iterative.core.Email +import works.iterative.core.Avatar +import works.iterative.core.auth.BasicProfile trait HttpSecurity @@ -69,7 +74,19 @@ service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => def loggedInUser(p: CommonProfile): CurrentUser = - CurrentUser(UserId.unsafe(p.getUsername)) + import scala.jdk.CollectionConverters.* + CurrentUser( + BasicProfile( + UserId.unsafe(p.getUsername()), + Option(p.getDisplayName()).flatMap(UserName(_).toOption), + Option(p.getEmail()).flatMap(Email(_).toOption), + Option(p.getPictureUrl()).flatMap(Avatar(_).toOption), + Option(p.getRoles()) + .map(_.asScala.toSet) + .getOrElse(Set.empty) + .flatMap(UserRole(_).toOption) + ) + ) r.context match { case profile :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala new file mode 100644 index 0000000..3687002 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala @@ -0,0 +1,4 @@ +package works.iterative.core.auth + +trait UserRoles extends UserInfo: + def roles: Set[UserRole] diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index 150c6e6..26a5db6 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -14,6 +14,11 @@ import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router +import works.iterative.core.UserName +import works.iterative.core.auth.UserRole +import works.iterative.core.Email +import works.iterative.core.Avatar +import works.iterative.core.auth.BasicProfile trait HttpSecurity @@ -69,7 +74,19 @@ service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => def loggedInUser(p: CommonProfile): CurrentUser = - CurrentUser(UserId.unsafe(p.getUsername)) + import scala.jdk.CollectionConverters.* + CurrentUser( + BasicProfile( + UserId.unsafe(p.getUsername()), + Option(p.getDisplayName()).flatMap(UserName(_).toOption), + Option(p.getEmail()).flatMap(Email(_).toOption), + Option(p.getPictureUrl()).flatMap(Avatar(_).toOption), + Option(p.getRoles()) + .map(_.asScala.toSet) + .getOrElse(Set.empty) + .flatMap(UserRole(_).toOption) + ) + ) r.context match { case profile :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index e7d68cb..a8d4d4d 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -1,9 +1,17 @@ package works.iterative.tapir import sttp.model.Uri +import sttp.model.Uri.* case class BaseUri(value: Option[Uri]) object BaseUri: def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v.value + extension (v: BaseUri) + def toUri: Option[Uri] = v.value + def /(s: String): BaseUri = v.value match + case Some(u) => BaseUri(Some(uri"$u/$s")) + case None => BaseUri(Some(uri"$s")) + def href: String = v.value match + case Some(u) => u.toString + case None => "#" diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala new file mode 100644 index 0000000..3687002 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala @@ -0,0 +1,4 @@ +package works.iterative.core.auth + +trait UserRoles extends UserInfo: + def roles: Set[UserRole] diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index 150c6e6..26a5db6 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -14,6 +14,11 @@ import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router +import works.iterative.core.UserName +import works.iterative.core.auth.UserRole +import works.iterative.core.Email +import works.iterative.core.Avatar +import works.iterative.core.auth.BasicProfile trait HttpSecurity @@ -69,7 +74,19 @@ service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => def loggedInUser(p: CommonProfile): CurrentUser = - CurrentUser(UserId.unsafe(p.getUsername)) + import scala.jdk.CollectionConverters.* + CurrentUser( + BasicProfile( + UserId.unsafe(p.getUsername()), + Option(p.getDisplayName()).flatMap(UserName(_).toOption), + Option(p.getEmail()).flatMap(Email(_).toOption), + Option(p.getPictureUrl()).flatMap(Avatar(_).toOption), + Option(p.getRoles()) + .map(_.asScala.toSet) + .getOrElse(Set.empty) + .flatMap(UserRole(_).toOption) + ) + ) r.context match { case profile :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index e7d68cb..a8d4d4d 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -1,9 +1,17 @@ package works.iterative.tapir import sttp.model.Uri +import sttp.model.Uri.* case class BaseUri(value: Option[Uri]) object BaseUri: def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v.value + extension (v: BaseUri) + def toUri: Option[Uri] = v.value + def /(s: String): BaseUri = v.value match + case Some(u) => BaseUri(Some(uri"$u/$s")) + case None => BaseUri(Some(uri"$s")) + def href: String = v.value match + case Some(u) => u.toString + case None => "#" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala index df2cbf8..88bc5a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -2,7 +2,7 @@ package ui.components import works.iterative.core.MessageCatalogue -import works.iterative.core.auth.UserInfo +import works.iterative.core.auth.UserProfile import com.raquo.airstream.core.Signal /** Context containing services needed in all parts of the application @@ -14,7 +14,7 @@ // as it needs Env // So for now, it is everything in one place trait ComponentContext[+Env]: - def currentUser: Signal[Option[UserInfo]] + def currentUser: Signal[Option[UserProfile]] def messages: MessageCatalogue def modal: Modal def dispatcher: ZIODispatcher[Env] diff --git a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala index 4095dc2..a236978 100644 --- a/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala +++ b/codecs/src/main/scala/works/iterative/core/codecs/Codecs.scala @@ -5,6 +5,7 @@ import zio.json.* import zio.prelude.Validation import works.iterative.tapir.CustomTapir +import works.iterative.core.auth.* private[codecs] case class TextEncoding( pml: Option[PlainMultiLine], @@ -28,7 +29,25 @@ given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) given JsonCodec[Markdown] = textCodec(Markdown.apply) + given JsonCodec[UserId] = + JsonCodec.string.transform(auth.UserId.unsafe(_), _.value) + + given JsonCodec[Email] = textCodec(Email.apply) + + given JsonCodec[UserName] = textCodec(UserName.apply) + given JsonCodec[UserRole] = textCodec(UserRole.apply) + given JsonCodec[Avatar] = textCodec(Avatar.apply) + given JsonCodec[BasicProfile] = DeriveJsonCodec.gen[BasicProfile] + trait TapirCodecs extends CustomTapir: given Schema[PlainMultiLine] = Schema.string given Schema[PlainOneLine] = Schema.string given Schema[Markdown] = Schema.string + given Schema[UserId] = Schema.string + given Schema[UserRole] = Schema.string + given Schema[UserName] = Schema.string + given Schema[Avatar] = Schema.string + given Schema[Email] = Schema.string + given Schema[BasicProfile] = Schema.derived[BasicProfile] + +object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Avatar.scala b/core/shared/src/main/scala/works/iterative/core/Avatar.scala new file mode 100644 index 0000000..1ef0c7c --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Avatar.scala @@ -0,0 +1,15 @@ +package works.iterative.core + +import java.net.URI + +// TODO: validate URL - or should we go with URI? +opaque type Avatar = String + +object Avatar: + def apply(avatar: String): Validated[Avatar] = + Validated.nonEmptyString("avatar")(avatar) + def apply(avatar: URI): Validated[Avatar] = + Validated.nonNull("avatar")(avatar).flatMap(a => apply(a.toString)) + def unsafe(avatar: String): Avatar = avatar + + extension (a: Avatar) def url: String = a diff --git a/core/shared/src/main/scala/works/iterative/core/Codecs.scala b/core/shared/src/main/scala/works/iterative/core/Codecs.scala deleted file mode 100644 index 00b64a8..0000000 --- a/core/shared/src/main/scala/works/iterative/core/Codecs.scala +++ /dev/null @@ -1,16 +0,0 @@ -package works.iterative.core - -import zio.json.* - -trait Codecs: - given JsonCodec[Email] = - JsonCodec.string.transformOrFail( - Email(_).toEitherWith(_ => "Error parsing email"), - _.value - ) - given JsonCodec[PlainMultiLine] = JsonCodec.string.transformOrFail( - PlainMultiLine(_).toEitherWith(_ => "Error parsing PlainMultiLine"), - _.asString - ) - -object Codecs extends Codecs diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala index f633095..4386f6d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Validated.scala +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -10,11 +10,15 @@ */ def nonEmptyString(lkey: String)(value: String): Validated[String] = Validation - .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)( - _.trim.nonEmpty + .fromPredicateWith(UserMessage(s"error.empty.$lkey"))(value)(s => + s != null && s.trim.nonEmpty ) .map(_.trim) + def nonNull[A](lkey: String)(value: A): Validated[A] = + Validation + .fromPredicateWith(UserMessage(s"error.null.$lkey"))(value)(_ != null) + def positiveInt(lkey: String)(value: Int): Validated[Int] = Validation .fromPredicateWith(UserMessage(s"error.positive.$lkey"))(value)(_ > 0) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala new file mode 100644 index 0000000..710f0ec --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/BasicProfile.scala @@ -0,0 +1,15 @@ +package works.iterative.core +package auth + +final case class BasicProfile( + subjectId: UserId, + userName: Option[UserName], + email: Option[Email], + avatar: Option[Avatar], + roles: Set[UserRole] +) extends UserProfile + +object BasicProfile: + def apply(p: UserProfile): BasicProfile = p match + case p: BasicProfile => p + case _ => BasicProfile(p.subjectId, p.userName, p.email, p.avatar, p.roles) diff --git a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala index 68e6236..ff99535 100644 --- a/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala +++ b/core/shared/src/main/scala/works/iterative/core/auth/CurrentUser.scala @@ -1,3 +1,4 @@ package works.iterative.core.auth -final case class CurrentUser(userId: UserId) +final case class CurrentUser(userProfile: BasicProfile) extends UserProfile: + export userProfile.* diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala new file mode 100644 index 0000000..379a7da --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserProfile.scala @@ -0,0 +1,7 @@ +package works.iterative.core +package auth + +trait UserProfile extends UserRoles: + def userName: Option[UserName] + def email: Option[Email] + def avatar: Option[Avatar] diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala new file mode 100644 index 0000000..cf21353 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRole.scala @@ -0,0 +1,11 @@ +package works.iterative.core +package auth + +opaque type UserRole = String + +object UserRole: + def apply(role: String): Validated[UserRole] = + Validated.nonEmptyString("user.role")(role) + def unsafe(role: String): UserRole = role + + extension (r: UserRole) def value: String = r diff --git a/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala new file mode 100644 index 0000000..3687002 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/auth/UserRoles.scala @@ -0,0 +1,4 @@ +package works.iterative.core.auth + +trait UserRoles extends UserInfo: + def roles: Set[UserRole] diff --git a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala index 150c6e6..26a5db6 100644 --- a/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala +++ b/server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala @@ -14,6 +14,11 @@ import org.http4s.dsl.Http4sDsl import works.iterative.core.auth.UserId import org.http4s.server.Router +import works.iterative.core.UserName +import works.iterative.core.auth.UserRole +import works.iterative.core.Email +import works.iterative.core.Avatar +import works.iterative.core.auth.BasicProfile trait HttpSecurity @@ -69,7 +74,19 @@ service => Kleisli { (r: AuthedRequest[F, List[CommonProfile]]) => def loggedInUser(p: CommonProfile): CurrentUser = - CurrentUser(UserId.unsafe(p.getUsername)) + import scala.jdk.CollectionConverters.* + CurrentUser( + BasicProfile( + UserId.unsafe(p.getUsername()), + Option(p.getDisplayName()).flatMap(UserName(_).toOption), + Option(p.getEmail()).flatMap(Email(_).toOption), + Option(p.getPictureUrl()).flatMap(Avatar(_).toOption), + Option(p.getRoles()) + .map(_.asScala.toSet) + .getOrElse(Set.empty) + .flatMap(UserRole(_).toOption) + ) + ) r.context match { case profile :: _ => service(AuthedRequest(loggedInUser(profile), r.req)) diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala index e7d68cb..a8d4d4d 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/BaseUri.scala @@ -1,9 +1,17 @@ package works.iterative.tapir import sttp.model.Uri +import sttp.model.Uri.* case class BaseUri(value: Option[Uri]) object BaseUri: def apply(u: Uri): BaseUri = BaseUri(Some(u)) - extension (v: BaseUri) def toUri: Option[Uri] = v.value + extension (v: BaseUri) + def toUri: Option[Uri] = v.value + def /(s: String): BaseUri = v.value match + case Some(u) => BaseUri(Some(uri"$u/$s")) + case None => BaseUri(Some(uri"$s")) + def href: String = v.value match + case Some(u) => u.toString + case None => "#" diff --git a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala index df2cbf8..88bc5a0 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/ComponentContext.scala @@ -2,7 +2,7 @@ package ui.components import works.iterative.core.MessageCatalogue -import works.iterative.core.auth.UserInfo +import works.iterative.core.auth.UserProfile import com.raquo.airstream.core.Signal /** Context containing services needed in all parts of the application @@ -14,7 +14,7 @@ // as it needs Env // So for now, it is everything in one place trait ComponentContext[+Env]: - def currentUser: Signal[Option[UserInfo]] + def currentUser: Signal[Option[UserProfile]] def messages: MessageCatalogue def modal: Modal def dispatcher: ZIODispatcher[Env] diff --git a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala index 468333d..591f7e9 100644 --- a/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala +++ b/ui/js/src/main/scala/works/iterative/ui/scenarios/ScenarioMain.scala @@ -14,7 +14,7 @@ import scala.scalajs.js.Dictionary import works.iterative.ui.components.Modal import works.iterative.ui.components.ZIODispatcher -import works.iterative.core.auth.UserInfo +import works.iterative.core.auth.UserProfile trait ScenarioMain( prefix: String, @@ -50,7 +50,7 @@ def main(@unused args: Array[String]): Unit = given ComponentContext[Nothing] with - val currentUser: Signal[Option[UserInfo]] = Val(None) + val currentUser: Signal[Option[UserProfile]] = Val(None) val messages: MessageCatalogue = messageCatalogue val modal: Modal = new Modal: override def open(content: HtmlElement): Unit = ()