diff --git a/extensions/core/deployment/pom.xml b/extensions/core/deployment/pom.xml index 08d75c9..a5b3044 100644 --- a/extensions/core/deployment/pom.xml +++ b/extensions/core/deployment/pom.xml @@ -17,6 +17,10 @@ dev.cloudeko external-authentication-extension-deployment + + dev.cloudeko + zenei-user-account-extension-deployment + io.quarkus diff --git a/extensions/core/runtime/pom.xml b/extensions/core/runtime/pom.xml index 18841e7..0627c7e 100644 --- a/extensions/core/runtime/pom.xml +++ b/extensions/core/runtime/pom.xml @@ -17,6 +17,10 @@ dev.cloudeko external-authentication-extension + + dev.cloudeko + zenei-user-account-extension + io.quarkus diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java index 1b1bfcf..2af03a0 100644 --- a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java +++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java @@ -12,6 +12,9 @@ import dev.cloudeko.zenei.extension.core.model.user.UserPassword; import dev.cloudeko.zenei.extension.core.repository.UserPasswordRepository; import dev.cloudeko.zenei.extension.core.repository.UserRepository; +import dev.cloudeko.zenei.user.UserAccountManager; +import dev.cloudeko.zenei.user.runtime.BasicUserAccount; +import dev.cloudeko.zenei.user.runtime.BasicUserAccountManager; import jakarta.enterprise.context.ApplicationScoped; import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; @@ -33,6 +36,8 @@ public class CreateUserImpl implements CreateUser { private final UserRepository userRepository; private final UserPasswordRepository userPasswordRepository; + private final BasicUserAccountManager userAccountManager; + @Override @Transactional public User handle(CreateUserInput createUserInput) { @@ -53,6 +58,8 @@ public User handle(CreateUserInput createUserInput) { checkExistingUsername(user.getUsername()); checkExistingEmail(user.getPrimaryEmailAddress().getEmail()); + userAccountManager.createUserBlocking(new BasicUserAccount(user.getUsername(), "test")); + userRepository.createUser(user); if (createUserInput.isPasswordEnabled()) { diff --git a/extensions/pom.xml b/extensions/pom.xml index ffedcf0..febf1ef 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -17,6 +17,7 @@ external-authentication core + zenei-user-account rest jdbc-panache diff --git a/extensions/zenei-user-account/deployment/pom.xml b/extensions/zenei-user-account/deployment/pom.xml new file mode 100644 index 0000000..131ad13 --- /dev/null +++ b/extensions/zenei-user-account/deployment/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + dev.cloudeko + zenei-user-account-extension-parent + 0.0.1 + + + Zenei - Extensions - Rest - Deployment + zenei-user-account-extension-deployment + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-vertx-deployment + + + io.quarkus + quarkus-security-deployment + + + io.quarkus + quarkus-devservices-deployment + + + io.quarkus + quarkus-jsonp-deployment + + + + dev.cloudeko + zenei-user-account-extension + + + + io.quarkus + quarkus-junit5-internal + test + + + + + + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java new file mode 100644 index 0000000..3454cb5 --- /dev/null +++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java @@ -0,0 +1,137 @@ +package dev.cloudeko.zenei.user.deployment; + +import dev.cloudeko.zenei.user.UserAccountRepositoryBase; +import dev.cloudeko.zenei.user.runtime.BasicUserAccountInitializer; +import dev.cloudeko.zenei.user.runtime.BasicUserAccountManager; +import dev.cloudeko.zenei.user.runtime.BasicUserAccountRecorder; +import dev.cloudeko.zenei.user.runtime.BasicUserAccountRepository; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Consume; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.runtime.configuration.ConfigurationException; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Singleton; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static io.quarkus.deployment.Capability.*; +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; +import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; + +public class BasicUserAccountManagerProcessor { + + private static final String[] SUPPORTED_REACTIVE_CLIENTS = new String[] { REACTIVE_PG_CLIENT, REACTIVE_MYSQL_CLIENT, + REACTIVE_MSSQL_CLIENT, REACTIVE_DB2_CLIENT, REACTIVE_ORACLE_CLIENT }; + + @BuildStep + @Record(STATIC_INIT) + SyntheticBeanBuildItem createDbTokenStateInitializerProps(BasicUserAccountRecorder recorder, Capabilities capabilities) { + final String reactiveClient = capabilities.getCapabilities().stream() + .filter(c -> Arrays.asList(SUPPORTED_REACTIVE_CLIENTS).contains(c)) + .findFirst() + .orElseThrow(() -> new RuntimeException("No supported reactive SQL client found")); + + final String createTableDdl; + final boolean supportsIfTableNotExists = switch (reactiveClient) { + case REACTIVE_PG_CLIENT -> { + createTableDdl = "CREATE TABLE IF NOT EXISTS zenei_user_account (" + + "id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, " + + "username VARCHAR(100) NOT NULL, " + + "image VARCHAR(1000) NULL, " + + "created_at TIMESTAMP NOT NULL, " + + "updated_at TIMESTAMP NOT NULL)"; + yield true; + } + case REACTIVE_MYSQL_CLIENT -> { + createTableDdl = "CREATE TABLE IF NOT EXISTS zenei_user_account (" + + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + + "username VARCHAR(100) NOT NULL, " + + "image VARCHAR(1000) NULL, " + + "created_at TIMESTAMP NOT NULL, " + + "updated_at TIMESTAMP NOT NULL)"; + yield true; + } + case REACTIVE_MSSQL_CLIENT -> { + createTableDdl = "CREATE TABLE zenei_user_account (" + + "id BIGINT IDENTITY(1,1) PRIMARY KEY, " + + "username NVARCHAR(100) NOT NULL, " + + "image NVARCHAR(1000) NULL, " + + "created_at DATETIME2 NOT NULL, " + + "updated_at DATETIME2 NOT NULL)"; + yield false; // MSSQL doesn't support `IF NOT EXISTS` directly + } + case REACTIVE_DB2_CLIENT -> { + createTableDdl = "CREATE TABLE zenei_user_account (" + + "id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY (START WITH 1, INCREMENT BY 1), " + + "username VARCHAR(100) NOT NULL, " + + "image VARCHAR(1000), " + + "created_at TIMESTAMP NOT NULL, " + + "updated_at TIMESTAMP NOT NULL, " + + "PRIMARY KEY (id))"; + yield false; // DB2 doesn't support `IF NOT EXISTS` directly + } + case REACTIVE_ORACLE_CLIENT -> { + createTableDdl = "CREATE TABLE zenei_user_account (" + + "id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, " + + "username VARCHAR2(100) NOT NULL, " + + "image VARCHAR2(1000), " + + "created_at TIMESTAMP NOT NULL, " + + "updated_at TIMESTAMP NOT NULL)"; + yield true; + } + default -> throw new ConfigurationException("Unknown Reactive Sql Client " + reactiveClient); + }; + + return SyntheticBeanBuildItem + .configure(BasicUserAccountInitializer.UserAccountInitializerProperties.class) + .supplier(recorder.createUserAccountInitializerProps(createTableDdl, supportsIfTableNotExists)) + .unremovable() + .scope(Dependent.class) + .done(); + } + + @BuildStep + AdditionalBeanBuildItem createBasicUserAccountInitializerBean() { + return new AdditionalBeanBuildItem(BasicUserAccountInitializer.class); + } + + @BuildStep + @Record(STATIC_INIT) + SyntheticBeanBuildItem syntheticBeanBuildItem(BasicUserAccountRecorder recorder, Capabilities capabilities) { + final String reactiveClient = capabilities.getCapabilities().stream() + .filter(c -> Arrays.asList(SUPPORTED_REACTIVE_CLIENTS).contains(c)) + .findFirst() + .orElseThrow(() -> new RuntimeException("No supported reactive SQL client found")); + + final Map config = Arrays.stream(DefaultQuery.values()) + .collect(Collectors.toMap(defaultQuery -> defaultQuery.getMetadata().queryKey(), + defaultQuery -> defaultQuery.getMetadata().getQuery(reactiveClient))); + + return SyntheticBeanBuildItem + .configure(BasicUserAccountRepository.class) + .addType(UserAccountRepositoryBase.class) + .unremovable() + .scope(Singleton.class) + .supplier(recorder.createBasicUserAccountRepository(config)) + .done(); + } + + @BuildStep + AdditionalBeanBuildItem createBasicUserAccountManagerBean() { + return new AdditionalBeanBuildItem(BasicUserAccountManager.class); + } + + @BuildStep + @Record(RUNTIME_INIT) + @Consume(SyntheticBeansRuntimeInitBuildItem.class) + void setSqlClientPool(BasicUserAccountRecorder recorder, BeanContainerBuildItem beanContainer) { + recorder.setSqlClientPool(beanContainer.getValue()); + } +} diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java new file mode 100644 index 0000000..b0ed0de --- /dev/null +++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java @@ -0,0 +1,51 @@ +package dev.cloudeko.zenei.user.deployment; + +import dev.cloudeko.zenei.user.QueryRegistry; + +public enum DefaultQuery { + USER_ACCOUNT_FIND_BY_IDENTIFIER(QueryMetadata.of( + "SELECT id, username, image, created_at, updated_at FROM user_account WHERE id = ?", + 1, + QueryRegistry.USER_ACCOUNT_FIND_BY_IDENTIFIER + )), + USER_ACCOUNT_FIND_BY_USERNAME(QueryMetadata.of( + "SELECT id, username, image, created_at, updated_at FROM user_account WHERE username = ?", + 1, + QueryRegistry.USER_ACCOUNT_FIND_BY_USERNAME + )), + USER_ACCOUNT_LIST(QueryMetadata.of( + "SELECT id, username, image, created_at, updated_at FROM user_account", + 0, + QueryRegistry.USER_ACCOUNT_LIST + )), + USER_ACCOUNT_LIST_PAGINATED(QueryMetadata.of( + "SELECT id, username, image, created_at, updated_at FROM user_account LIMIT ? OFFSET ?", + 2, + QueryRegistry.USER_ACCOUNT_LIST_PAGINATED + )), + USER_ACCOUNT_CREATE(QueryMetadata.of( + "INSERT INTO user_account (username, image, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id, username, image, created_at, updated_at", + 2, + QueryRegistry.USER_ACCOUNT_CREATE + )), + USER_ACCOUNT_UPDATE(QueryMetadata.of( + "UPDATE user_account SET username = ?, image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? RETURNING id, username, image, created_at, updated_at", + 3, + QueryRegistry.USER_ACCOUNT_UPDATE + )), + USER_ACCOUNT_DELETE(QueryMetadata.of( + "DELETE FROM user_account WHERE id = ? RETURNING id, username, image, created_at, updated_at", + 1, + QueryRegistry.USER_ACCOUNT_DELETE + )); + + private final QueryMetadata metadata; + + DefaultQuery(QueryMetadata metadata) { + this.metadata = metadata; + } + + public QueryMetadata getMetadata() { + return metadata; + } +} diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java new file mode 100644 index 0000000..5354c59 --- /dev/null +++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java @@ -0,0 +1,23 @@ +package dev.cloudeko.zenei.user.deployment; + +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.FeatureBuildItem; + +public class ExternalAuthBuildSteps { + + private static final String FEATURE = "zenei-user-account"; + + @BuildStep + public FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + /*@BuildStep + public RouteBuildItem route(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + return nonApplicationRootPathBuildItem.routeBuilder() + .route("user") + .handler(new UserInfoHandler()) + .displayOnNotFoundPage() + .build(); + }*/ +} diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java new file mode 100644 index 0000000..a4a7554 --- /dev/null +++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java @@ -0,0 +1,33 @@ +package dev.cloudeko.zenei.user.deployment; + +import io.quarkus.deployment.Capability; + +public record QueryMetadata(String query, int parameters, String queryKey) { + + public QueryMetadata { + if (query == null || query.isBlank()) { + throw new IllegalArgumentException("Query cannot be null or empty"); + } + if (queryKey == null || queryKey.isBlank()) { + throw new IllegalArgumentException("Query key cannot be null or empty"); + } + } + + public static QueryMetadata of(String query, int parameters, String queryKey) { + return new QueryMetadata(query, parameters, queryKey); + } + + public String getQuery(String client) { + final String[] placeholders = new String[parameters]; + for (int i = 0; i < parameters; i++) { + placeholders[i] = switch (client) { + case Capability.REACTIVE_PG_CLIENT -> "$" + (i + 1); + case Capability.REACTIVE_MSSQL_CLIENT -> "@p" + (i + 1); + case Capability.REACTIVE_DB2_CLIENT, Capability.REACTIVE_ORACLE_CLIENT, Capability.REACTIVE_MYSQL_CLIENT -> "?"; + default -> throw new IllegalArgumentException("Unknown Reactive Sql Client " + client); + }; + } + + return String.format(query, (Object[]) placeholders); + } +} diff --git a/extensions/zenei-user-account/pom.xml b/extensions/zenei-user-account/pom.xml new file mode 100644 index 0000000..5b3744e --- /dev/null +++ b/extensions/zenei-user-account/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + dev.cloudeko + extensions + 0.0.1 + + + Zenei - Extensions - User Account - Parent + zenei-user-account-extension-parent + pom + + + deployment + runtime + + diff --git a/extensions/zenei-user-account/runtime/pom.xml b/extensions/zenei-user-account/runtime/pom.xml new file mode 100644 index 0000000..8170fc0 --- /dev/null +++ b/extensions/zenei-user-account/runtime/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + + dev.cloudeko + zenei-user-account-extension-parent + 0.0.1 + + + Zenei - Extensions - User Account - Runtime + zenei-user-account-extension + + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-vertx + + + io.quarkus + quarkus-vertx-http + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-jsonp + + + jakarta.annotation + jakarta.annotation-api + + + io.vertx + vertx-sql-client + + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + -parameters + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java new file mode 100644 index 0000000..01bbcd0 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java @@ -0,0 +1,11 @@ +package dev.cloudeko.zenei.user; + +public class QueryRegistry { + public static final String USER_ACCOUNT_FIND_BY_IDENTIFIER = "user-account-find-by-identifier"; + public static final String USER_ACCOUNT_FIND_BY_USERNAME = "user-account-find-by-username"; + public static final String USER_ACCOUNT_LIST = "user-account-list"; + public static final String USER_ACCOUNT_LIST_PAGINATED = "user-account-list-paginated"; + public static final String USER_ACCOUNT_CREATE = "user-account-create"; + public static final String USER_ACCOUNT_UPDATE = "user-account-update"; + public static final String USER_ACCOUNT_DELETE = "user-account-delete"; +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java new file mode 100644 index 0000000..53a58ef --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java @@ -0,0 +1,78 @@ +package dev.cloudeko.zenei.user; + +import java.time.LocalDateTime; + +public abstract class UserAccount { + + private ID id; + private String username; + private String image; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public UserAccount() { + } + + public UserAccount(String username, String image) { + this(null, username, image); + } + + public UserAccount(ID id, String username, String image) { + this(id, username, image, null, null); + } + + public UserAccount(ID id, String username, String image, LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = id; + this.username = username; + this.image = image; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public ID getId() { + return id; + } + + public void setId(ID id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getImage() { + return image; + } + + public void setImage(String image) { + this.image = image; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public record EmailAddress(String email, boolean verified, boolean primary) { + } + + public record PhoneNumber(String number, boolean verified, boolean primary) { + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java new file mode 100644 index 0000000..1ff3176 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java @@ -0,0 +1,4 @@ +package dev.cloudeko.zenei.user; + +public interface UserAccountEmailAddressRepositoryBase { +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java new file mode 100644 index 0000000..80f269b --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java @@ -0,0 +1,63 @@ +package dev.cloudeko.zenei.user; + +import io.smallrye.mutiny.Uni; + +import java.util.List; +import java.util.Optional; + +public interface UserAccountManager { + + Uni findUserByIdentifier(ID id); + + default Optional findUserByIdentifierBlocking(ID id) { + return Optional.ofNullable(findUserByIdentifier(id).await().indefinitely()); + } + + Uni findUserByPrimaryEmailAddress(String email); + + default Optional findUserByPrimaryEmailAddressBlocking(String email) { + return Optional.ofNullable(findUserByPrimaryEmailAddress(email).await().indefinitely()); + } + + Uni findUserByPrimaryPhoneNumber(String phoneNumber); + + default Optional findUserByPrimaryPhoneNumberBlocking(String phoneNumber) { + return Optional.ofNullable(findUserByPrimaryPhoneNumber(phoneNumber).await().indefinitely()); + } + + Uni findUserByUsername(String username); + + default Optional findUserByUsernameBlocking(String username) { + return Optional.ofNullable(findUserByUsername(username).await().indefinitely()); + } + + Uni> listUsers(); + + default List listUsersBlocking() { + return listUsers().await().indefinitely(); + } + + Uni> listUsers(int page, int pageSize); + + default List listUsersBlocking(int page, int pageSize) { + return listUsers(page, pageSize).await().indefinitely(); + } + + Uni createUser(ENTITY entity); + + default ENTITY createUserBlocking(ENTITY entity) { + return createUser(entity).await().indefinitely(); + } + + Uni updateUser(ENTITY entity); + + default ENTITY updateUserBlocking(ENTITY entity) { + return updateUser(entity).await().indefinitely(); + } + + Uni deleteUser(ENTITY entity); + + default boolean deleteUserBlocking(ENTITY entity) { + return deleteUser(entity).await().indefinitely(); + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java new file mode 100644 index 0000000..54a2411 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java @@ -0,0 +1,4 @@ +package dev.cloudeko.zenei.user; + +public interface UserAccountPhoneNumberRepositoryBase { +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java new file mode 100644 index 0000000..47cf558 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java @@ -0,0 +1,22 @@ +package dev.cloudeko.zenei.user; + +import io.smallrye.mutiny.Uni; + +import java.util.List; + +public interface UserAccountRepositoryBase { + + Uni findUserByIdentifier(ID identifier); + + Uni findUserByUsername(String username); + + Uni> listUsers(); + + Uni> listUsers(int page, int pageSize); + + Uni createUser(ENTITY entity); + + Uni updateUser(ENTITY entity); + + Uni deleteUser(ENTITY entity); +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java new file mode 100644 index 0000000..7ffd501 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java @@ -0,0 +1,23 @@ +package dev.cloudeko.zenei.user.runtime; + +import dev.cloudeko.zenei.user.UserAccount; +import io.vertx.sqlclient.Row; + +public class BasicUserAccount extends UserAccount { + + public BasicUserAccount() { + super(); + } + + public BasicUserAccount(String username, String image) { + super(username, image); + } + + public BasicUserAccount(Row row) { + super(row.getLong("id"), + row.getString("username"), + row.getString("image"), + row.getLocalDateTime("created_at"), + row.getLocalDateTime("updated_at")); + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java new file mode 100644 index 0000000..327ffbc --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java @@ -0,0 +1,58 @@ +package dev.cloudeko.zenei.user.runtime; + +import io.quarkus.runtime.StartupEvent; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Vertx; +import io.vertx.sqlclient.Pool; +import jakarta.enterprise.event.Observes; +import org.jboss.logging.Logger; + +public class BasicUserAccountInitializer { + + private static final Logger log = Logger.getLogger(BasicUserAccountInitializer.class); + private static final String FAILED_TO_CREATE_DB_TABLE = "unknown reason, please report the issue and create table manually"; + + void initialize(@Observes StartupEvent event, Vertx vertx, Pool pool, UserAccountInitializerProperties initializerProps) { + createDatabaseTable(pool, initializerProps.createTableDdl, initializerProps.supportsIfTableNotExists); + } + + private static void createDatabaseTable(Pool pool, String createTableDdl, boolean supportsIfTableNotExists) { + log.debugf("Creating database table with query: %s", createTableDdl); + + Uni tableCreationResult = Uni.createFrom() + .completionStage(pool.query(createTableDdl).execute().toCompletionStage()) + .onItemOrFailure() + .transformToUni((rows, throwable) -> { + if (throwable != null) { + return supportsIfTableNotExists + ? Uni.createFrom().item(throwable.getMessage()) + : Uni.createFrom().nullItem(); + } + + return verifyTableExists(pool); + }); + + String errMsg = tableCreationResult.await().indefinitely(); + if (errMsg != null) { + throw new RuntimeException("OIDC Token State Manager failed to create database table: " + errMsg); + } + } + + private static Uni verifyTableExists(Pool pool) { + return Uni.createFrom() + .completionStage(pool.query("SELECT MAX(id) FROM zenei_user_account").execute().toCompletionStage()) + .map(rows -> { + if (rows != null && rows.columnsNames().size() == 1) { + return null; // Table exists + } + return FAILED_TO_CREATE_DB_TABLE; + }) + .onFailure().recoverWithItem(throwable -> { + log.error("Create database query failed with: ", throwable); + return FAILED_TO_CREATE_DB_TABLE; + }); + } + + public record UserAccountInitializerProperties(String createTableDdl, boolean supportsIfTableNotExists) { + } +} \ No newline at end of file diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java new file mode 100644 index 0000000..d7b30a5 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java @@ -0,0 +1,64 @@ +package dev.cloudeko.zenei.user.runtime; + +import dev.cloudeko.zenei.user.UserAccountManager; +import dev.cloudeko.zenei.user.UserAccountRepositoryBase; +import io.smallrye.mutiny.Uni; +import org.jboss.logging.Logger; + +import java.util.List; + +public class BasicUserAccountManager implements UserAccountManager { + + private static final Logger log = Logger.getLogger(BasicUserAccountManager.class); + + private final UserAccountRepositoryBase userAccountRepository; + + public BasicUserAccountManager(UserAccountRepositoryBase userAccountRepository) { + this.userAccountRepository = userAccountRepository; + } + + @Override + public Uni findUserByIdentifier(Long identifier) { + return userAccountRepository.findUserByIdentifier(identifier); + } + + @Override + public Uni findUserByPrimaryEmailAddress(String email) { + return null; + } + + @Override + public Uni findUserByPrimaryPhoneNumber(String phoneNumber) { + return null; + } + + @Override + public Uni findUserByUsername(String username) { + return userAccountRepository.findUserByUsername(username); + } + + @Override + public Uni> listUsers() { + return userAccountRepository.listUsers(); + } + + @Override + public Uni> listUsers(int page, int pageSize) { + return userAccountRepository.listUsers(page, pageSize); + } + + @Override + public Uni createUser(BasicUserAccount basicUserAccount) { + return userAccountRepository.createUser(basicUserAccount); + } + + @Override + public Uni updateUser(BasicUserAccount basicUserAccount) { + return userAccountRepository.updateUser(basicUserAccount); + } + + @Override + public Uni deleteUser(BasicUserAccount basicUserAccount) { + return userAccountRepository.deleteUser(basicUserAccount); + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java new file mode 100644 index 0000000..9b912ac --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java @@ -0,0 +1,26 @@ +package dev.cloudeko.zenei.user.runtime; + +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.runtime.annotations.Recorder; +import io.vertx.sqlclient.Pool; + +import java.util.Map; +import java.util.function.Supplier; + +@Recorder +public class BasicUserAccountRecorder { + + public void setSqlClientPool(BeanContainer container) { + container.beanInstance(BasicUserAccountRepository.class).setSqlClientPool(container.beanInstance(Pool.class)); + } + + public Supplier createBasicUserAccountRepository(Map config) { + return () -> new BasicUserAccountRepository(config); + } + + public Supplier createUserAccountInitializerProps( + String createTableDdl, + boolean supportsIfTableNotExists) { + return () -> new BasicUserAccountInitializer.UserAccountInitializerProperties(createTableDdl, supportsIfTableNotExists); + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java new file mode 100644 index 0000000..c69dae1 --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java @@ -0,0 +1,132 @@ +package dev.cloudeko.zenei.user.runtime; + +import dev.cloudeko.zenei.user.QueryRegistry; +import dev.cloudeko.zenei.user.UserAccountRepositoryBase; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.vertx.sqlclient.*; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Map; + +public class BasicUserAccountRepository implements UserAccountRepositoryBase { + + private static final Logger log = Logger.getLogger(BasicUserAccountRepository.class); + private static final String FAILED_TO_FIND_USER_BY_IDENTIFIER = "Failed to find user by identifier"; + + private final Map queries; + private Pool pool; + + public BasicUserAccountRepository(Map queries) { + this.queries = queries; + } + + public void setSqlClientPool(Pool pool) { + this.pool = pool; + } + + @Override + public Uni findUserByIdentifier(Long identifier) { + return Uni.createFrom() + .completionStage(pool + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_FIND_BY_IDENTIFIER)) + .execute(Tuple.of(identifier)) + .toCompletionStage()) + .onItem().transformToUni(this::processNullableRow) + .onItem().ifNotNull().transform(BasicUserAccount::new) + .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable)); + } + + @Override + public Uni findUserByUsername(String username) { + return Uni.createFrom() + .completionStage(pool + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_FIND_BY_USERNAME)) + .execute(Tuple.of(username)) + .toCompletionStage()) + .onItem().transformToUni(this::processNullableRow) + .onItem().ifNotNull().transform(BasicUserAccount::new) + .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable)); + } + + @Override + public Uni> listUsers() { + return Uni.createFrom() + .completionStage(pool + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_LIST)) + .execute() + .toCompletionStage()) + .onItem().transformToUni(rows -> Uni.createFrom().item(rows) + .onItem().transformToMulti(rowSet -> Multi.createFrom().iterable(rowSet)) + .onItem().transform(BasicUserAccount::new) + .collect().asList()) + .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable)); + } + + @Override + public Uni> listUsers(int page, int pageSize) { + return Uni.createFrom() + .completionStage(pool + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_LIST_PAGINATED)) + .execute(Tuple.of(page, pageSize)) + .toCompletionStage()) + .onItem().transformToUni(rows -> Uni.createFrom().item(rows) + .onItem().transformToMulti(rowSet -> Multi.createFrom().iterable(rowSet)) + .onItem().transform(BasicUserAccount::new) + .collect().asList()) + .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable)); + } + + @Override + public Uni createUser(BasicUserAccount basicUserAccount) { + return Uni.createFrom() + .completionStage(pool + .withTransaction(client -> client + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_CREATE)) + .execute(Tuple.of(basicUserAccount.getUsername(), basicUserAccount.getImage()))) + .toCompletionStage()) + .onItem().transformToUni(this::processNullableRow) + .onItem().ifNotNull().transform(BasicUserAccount::new) + .onFailure().transform(throwable -> new RuntimeException("Failed to create user", throwable)); + } + + @Override + public Uni updateUser(BasicUserAccount basicUserAccount) { + return Uni.createFrom() + .completionStage(pool + .withTransaction(client -> client + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_UPDATE)) + .execute(Tuple.of(basicUserAccount.getUsername(), basicUserAccount.getImage(), + basicUserAccount.getId()))) + .toCompletionStage()) + .onItem().transformToUni(this::processNullableRow) + .onItem().ifNotNull().transform(BasicUserAccount::new) + .onFailure().transform(throwable -> new RuntimeException("Failed to update user", throwable)); + } + + @Override + public Uni deleteUser(BasicUserAccount basicUserAccount) { + return Uni.createFrom() + .completionStage(pool + .withTransaction(client -> client + .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_DELETE)) + .execute(Tuple.of(basicUserAccount.getId()))) + .toCompletionStage()) + .onItem().transformToUni(rows -> Uni.createFrom().item(rows.rowCount() == 1)) + .onFailure().transform(throwable -> new RuntimeException("Failed to delete user", throwable)); + } + + private Uni processNullableRow(RowSet rows) { + if (rows.size() == 0) { + return Uni.createFrom().nullItem(); + } + + final RowIterator iterator = rows.iterator(); + if (iterator.hasNext()) { + return Uni.createFrom().item(iterator.next()); + } + + return Uni.createFrom().nullItem(); + } +} diff --git a/extensions/zenei-user-account/runtime/src/main/resources/META-INF/beans.xml b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000..cedb67d --- /dev/null +++ b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,9 @@ +name: Zenei Quarkus Rest Extension +#description: Do something useful. +metadata: +# keywords: +# - "key-auth" +# guide: ... # To create and publish this guide, see https://github.com/quarkiverse/quarkiverse/wiki#documenting-your-extension +# categories: +# - "miscellaneous" +# status: "preview" diff --git a/platform/pom.xml b/platform/pom.xml index 51aaafb..4665cfb 100644 --- a/platform/pom.xml +++ b/platform/pom.xml @@ -76,6 +76,10 @@ io.quarkus quarkus-jdbc-postgresql + + io.quarkus + quarkus-reactive-pg-client + io.quarkus quarkus-hibernate-validator diff --git a/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java b/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java index a612af2..39bdac5 100644 --- a/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java +++ b/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; +import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.ConfigProvider; diff --git a/pom.xml b/pom.xml index f2a0659..96fd2a9 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,17 @@ ${project.version} + + dev.cloudeko + zenei-user-account-extension + ${project.version} + + + dev.cloudeko + zenei-user-account-extension-deployment + ${project.version} + + dev.cloudeko rest-extension