diff --git a/core/issuerservice/issuerservice-issuance/build.gradle.kts b/core/issuerservice/issuerservice-issuance/build.gradle.kts new file mode 100644 index 000000000..339e6cd58 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":spi:issuance-credentials-spi")) + implementation(project(":core:lib:common-lib")) + implementation(libs.edc.lib.store) + implementation(libs.edc.lib.statemachine) + testImplementation(libs.edc.lib.query) + testImplementation(libs.edc.junit) + testImplementation(testFixtures(project(":spi:issuance-credentials-spi"))) +} diff --git a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtension.java b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtension.java new file mode 100644 index 000000000..5fcf96ae4 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtension.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.IssuanceProcessManager; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.retry.IssuanceProcessRetryStrategy; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.issuerservice.issuance.process.IssuanceProcessManagerImpl; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.retry.ExponentialWaitStrategy; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.telemetry.Telemetry; +import org.eclipse.edc.statemachine.retry.EntityRetryProcessConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.time.Clock; + +import static org.eclipse.edc.issuerservice.issuance.IssuanceCoreExtension.NAME; +import static org.eclipse.edc.statemachine.AbstractStateEntityManager.DEFAULT_BATCH_SIZE; +import static org.eclipse.edc.statemachine.AbstractStateEntityManager.DEFAULT_ITERATION_WAIT; +import static org.eclipse.edc.statemachine.AbstractStateEntityManager.DEFAULT_SEND_RETRY_BASE_DELAY; +import static org.eclipse.edc.statemachine.AbstractStateEntityManager.DEFAULT_SEND_RETRY_LIMIT; + +@Extension(NAME) +public class IssuanceCoreExtension implements ServiceExtension { + + public static final String NAME = "Issuance Core Extension"; + + + @Setting(description = "the iteration wait time in milliseconds in the issuance process state machine. Default value " + DEFAULT_ITERATION_WAIT, + key = "edc.issuer.issuance.state-machine.iteration-wait-millis", + defaultValue = DEFAULT_ITERATION_WAIT + "") + private long stateMachineIterationWaitMillis; + + @Setting(description = "the batch size in the issuance process state machine. Default value " + DEFAULT_BATCH_SIZE, key = "edc.issuer.issuance.state-machine.batch-size", defaultValue = DEFAULT_BATCH_SIZE + "") + private int stateMachineBatchSize; + + @Setting(description = "how many times a specific operation must be tried before terminating the issuance with error", key = "edc.issuer.issuance.send.retry.limit", defaultValue = DEFAULT_SEND_RETRY_LIMIT + "") + private int sendRetryLimit; + + @Setting(description = "The base delay for the issuance retry mechanism in millisecond", key = "edc.issuer.issuance.send.retry.base-delay.ms", defaultValue = DEFAULT_SEND_RETRY_BASE_DELAY + "") + private long sendRetryBaseDelay; + + private IssuanceProcessManager issuanceProcessManager; + + @Inject + private IssuanceProcessStore issuanceProcessStore; + + @Inject + private Monitor monitor; + + @Inject + private Telemetry telemetry; + + @Inject + private ExecutorInstrumentation executorInstrumentation; + + @Inject(required = false) + private IssuanceProcessRetryStrategy retryStrategy; + + @Inject + private Clock clock; + + @Provider + public IssuanceProcessManager createIssuanceProcessManager() { + + if (issuanceProcessManager == null) { + var waitStrategy = retryStrategy != null ? retryStrategy : new ExponentialWaitStrategy(stateMachineIterationWaitMillis); + issuanceProcessManager = IssuanceProcessManagerImpl.Builder.newInstance() + .store(issuanceProcessStore) + .monitor(monitor) + .batchSize(stateMachineBatchSize) + .waitStrategy(waitStrategy) + .telemetry(telemetry) + .clock(clock) + .executorInstrumentation(executorInstrumentation) + .entityRetryProcessConfiguration(getEntityRetryProcessConfiguration()) + .build(); + } + return issuanceProcessManager; + } + + @Override + public void start() { + issuanceProcessManager.start(); + } + + @Override + public void shutdown() { + if (issuanceProcessManager != null) { + issuanceProcessManager.stop(); + } + } + + @NotNull + private EntityRetryProcessConfiguration getEntityRetryProcessConfiguration() { + return new EntityRetryProcessConfiguration(sendRetryLimit, () -> new ExponentialWaitStrategy(sendRetryBaseDelay)); + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtension.java b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtension.java new file mode 100644 index 000000000..5ff299572 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtension.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance.defaults; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.issuerservice.issuance.defaults.store.InMemoryIssuanceProcessStore; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.spi.query.CriterionOperatorRegistry; +import org.eclipse.edc.spi.system.ServiceExtension; + +import java.time.Clock; + +import static org.eclipse.edc.issuerservice.issuance.defaults.IssuanceDefaultServiceExtension.NAME; + +@Extension(NAME) +public class IssuanceDefaultServiceExtension implements ServiceExtension { + + public static final String NAME = "Issuance Default Services Extension"; + + @Inject + private Clock clock; + + @Inject + private CriterionOperatorRegistry criterionOperatorRegistry; + + @Provider(isDefault = true) + public IssuanceProcessStore createIssuanceProcessStore() { + return new InMemoryIssuanceProcessStore(clock, criterionOperatorRegistry); + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStore.java b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStore.java new file mode 100644 index 000000000..73383632d --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStore.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance.defaults.store; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.spi.query.CriterionOperatorRegistry; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.store.InMemoryStatefulEntityStore; + +import java.time.Clock; +import java.util.UUID; +import java.util.stream.Stream; + +public class InMemoryIssuanceProcessStore extends InMemoryStatefulEntityStore implements IssuanceProcessStore { + + public InMemoryIssuanceProcessStore(Clock clock, CriterionOperatorRegistry criterionOperatorRegistry) { + this(UUID.randomUUID().toString(), clock, criterionOperatorRegistry); + } + + public InMemoryIssuanceProcessStore(String leaserId, Clock clock, CriterionOperatorRegistry criterionOperatorRegistry) { + super(IssuanceProcess.class, leaserId, clock, criterionOperatorRegistry); + } + + @Override + public Stream query(QuerySpec querySpec) { + return super.findAll(querySpec); + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java new file mode 100644 index 000000000..cf970aefc --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/main/java/org/eclipse/edc/issuerservice/issuance/process/IssuanceProcessManagerImpl.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance.process; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.IssuanceProcessManager; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.statemachine.AbstractStateEntityManager; +import org.eclipse.edc.statemachine.StateMachineManager; + +public class IssuanceProcessManagerImpl extends AbstractStateEntityManager implements IssuanceProcessManager { + + + private IssuanceProcessManagerImpl() { + } + + @Override + protected StateMachineManager.Builder configureStateMachineManager(StateMachineManager.Builder builder) { + return builder; + } + + public static class Builder + extends AbstractStateEntityManager.Builder { + + private Builder() { + super(new IssuanceProcessManagerImpl()); + } + + public static Builder newInstance() { + return new Builder(); + } + + @Override + public Builder self() { + return this; + } + + + public IssuanceProcessManagerImpl build() { + super.build(); + + return manager; + } + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/core/issuerservice/issuerservice-issuance/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..ecbc66a5f --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2025 Cofinity-X +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Cofinity-X - initial API and implementation +# +# + +org.eclipse.edc.issuerservice.issuance.defaults.IssuanceDefaultServiceExtension \ No newline at end of file diff --git a/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtensionTest.java b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtensionTest.java new file mode 100644 index 000000000..7266f7a74 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/IssuanceCoreExtensionTest.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance; + + +import org.eclipse.edc.boot.system.injection.ObjectFactory; +import org.eclipse.edc.issuerservice.issuance.process.IssuanceProcessManagerImpl; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(DependencyInjectionExtension.class) +public class IssuanceCoreExtensionTest { + + + @Test + void verifyProviders(ServiceExtensionContext context, ObjectFactory factory) { + var extension = factory.constructInstance(IssuanceCoreExtension.class); + assertThat(extension.createIssuanceProcessManager()).isInstanceOf(IssuanceProcessManagerImpl.class); + + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtensionTest.java b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtensionTest.java new file mode 100644 index 000000000..7034c6b31 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/IssuanceDefaultServiceExtensionTest.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance.defaults; + +import org.eclipse.edc.issuerservice.issuance.defaults.store.InMemoryIssuanceProcessStore; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(DependencyInjectionExtension.class) +public class IssuanceDefaultServiceExtensionTest { + + @Test + void verifyDefaultServices(IssuanceDefaultServiceExtension extension) { + assertThat(extension.createIssuanceProcessStore()).isInstanceOf(InMemoryIssuanceProcessStore.class); + } +} diff --git a/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStoreTest.java b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStoreTest.java new file mode 100644 index 000000000..b85486da4 --- /dev/null +++ b/core/issuerservice/issuerservice-issuance/src/test/java/org/eclipse/edc/issuerservice/issuance/defaults/store/InMemoryIssuanceProcessStoreTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.issuance.defaults.store; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStoreTestBase; +import org.eclipse.edc.query.CriterionOperatorRegistryImpl; + +import java.time.Duration; + +public class InMemoryIssuanceProcessStoreTest extends IssuanceProcessStoreTestBase { + + private final InMemoryIssuanceProcessStore store = new InMemoryIssuanceProcessStore(RUNTIME_ID, clock, CriterionOperatorRegistryImpl.ofDefaults()); + + @Override + protected IssuanceProcessStore getStore() { + return store; + } + + @Override + protected void leaseEntity(String entityId, String owner, Duration duration) { + store.acquireLease(entityId, owner, duration); + } + + @Override + protected boolean isLeasedBy(String entityId, String owner) { + return store.isLeasedBy(entityId, owner); + } +} diff --git a/dist/bom/issuerservice-base-bom/build.gradle.kts b/dist/bom/issuerservice-base-bom/build.gradle.kts index cc5d19e38..8111a40bd 100644 --- a/dist/bom/issuerservice-base-bom/build.gradle.kts +++ b/dist/bom/issuerservice-base-bom/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { runtimeOnly(project(":core:issuerservice:issuerservice-participants")) runtimeOnly(project(":core:issuerservice:issuerservice-credentials")) runtimeOnly(project(":core:issuerservice:issuerservice-credential-definitions")) + runtimeOnly(project(":core:issuerservice:issuerservice-issuance")) runtimeOnly(project(":extensions:did:local-did-publisher")) // API modules runtimeOnly(project(":extensions:protocols:dcp:dcp-issuer:dcp-issuer-api")) diff --git a/extensions/store/sql/issuance-process-store-sql/build.gradle.kts b/extensions/store/sql/issuance-process-store-sql/build.gradle.kts new file mode 100644 index 000000000..e08e54439 --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Metaform Systems, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Metaform Systems, Inc. - initial API and implementation + * + */ + +plugins { + `java-library` +} + +dependencies { + api(project(":spi:issuance-credentials-spi")) + implementation(libs.edc.lib.sql) + implementation(libs.edc.sql.bootstrapper) + implementation(libs.edc.sql.lease) + implementation(libs.edc.spi.transaction.datasource) + + testImplementation(testFixtures(project(":spi:issuance-credentials-spi"))) + testImplementation(testFixtures(libs.edc.sql.test.fixtures)) + testImplementation(libs.edc.junit) +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/BaseSqlDialectStatements.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/BaseSqlDialectStatements.java new file mode 100644 index 000000000..6f419951f --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/BaseSqlDialectStatements.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess; + +import org.eclipse.edc.issuerservice.store.sql.issuanceprocess.schema.postgres.IssuanceProcessMapping; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.sql.translation.PostgresqlOperatorTranslator; +import org.eclipse.edc.sql.translation.SqlQueryStatement; + +import static java.lang.String.format; + +public class BaseSqlDialectStatements implements IssuanceProcessStoreStatements { + @Override + public String getInsertTemplate() { + return executeStatement() + .column(getIdColumn()) + .column(getStateColumn()) + .column(getStateCountColumn()) + .column(getStateTimestampColumn()) + .column(getCreatedAtColumn()) + .column(getUpdatedAtColumn()) + .jsonColumn(getTraceContextColumn()) + .column(getErrorDetailColumn()) + .jsonColumn(getClaimsColumn()) + .jsonColumn(getCredentialDefinitionsColumn()) + .insertInto(getIssuanceProcessTable()); + } + + @Override + public String getUpdateTemplate() { + return executeStatement() + .column(getStateColumn()) + .column(getStateCountColumn()) + .column(getStateTimestampColumn()) + .column(getUpdatedAtColumn()) + .jsonColumn(getTraceContextColumn()) + .column(getErrorDetailColumn()) + .jsonColumn(getClaimsColumn()) + .jsonColumn(getCredentialDefinitionsColumn()) + .update(getIssuanceProcessTable(), getIdColumn()); + } + + @Override + public String getDeleteByIdTemplate() { + return executeStatement().delete(getIssuanceProcessTable(), getIdColumn()); + } + + @Override + public String getFindByIdTemplate() { + return format("SELECT * FROM %s WHERE %s = ?", getIssuanceProcessTable(), getIdColumn()); + + } + + + @Override + public SqlQueryStatement createQuery(QuerySpec querySpec) { + var select = getSelectStatement(); + return new SqlQueryStatement(select, querySpec, new IssuanceProcessMapping(this), new PostgresqlOperatorTranslator()); + } + + @Override + public String getSelectStatement() { + return format("SELECT * FROM %s", getIssuanceProcessTable()); + } + + @Override + public String getDeleteLeaseTemplate() { + return executeStatement().delete(getLeaseTableName(), getLeaseIdColumn()); + } + + @Override + public String getInsertLeaseTemplate() { + return executeStatement() + .column(getLeaseIdColumn()) + .column(getLeasedByColumn()) + .column(getLeasedAtColumn()) + .column(getLeaseDurationColumn()) + .insertInto(getLeaseTableName()); + } + + @Override + public String getUpdateLeaseTemplate() { + return executeStatement() + .column(getLeaseIdColumn()) + .update(getIssuanceProcessTable(), getIdColumn()); + } + + @Override + public String getFindLeaseByEntityTemplate() { + return format("SELECT * FROM %s WHERE %s = (SELECT lease_id FROM %s WHERE %s=? )", + getLeaseTableName(), getLeaseIdColumn(), getIssuanceProcessTable(), getIdColumn()); + } +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/IssuanceProcessStoreStatements.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/IssuanceProcessStoreStatements.java new file mode 100644 index 000000000..25b28099c --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/IssuanceProcessStoreStatements.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.sql.lease.LeaseStatements; +import org.eclipse.edc.sql.lease.StatefulEntityStatements; +import org.eclipse.edc.sql.translation.SqlQueryStatement; + +/** + * Defines SQL-statements and column names for use with a SQL-based {@link IssuanceProcess} + */ +public interface IssuanceProcessStoreStatements extends StatefulEntityStatements, LeaseStatements { + + default String getIssuanceProcessTable() { + return "edc_issuance_process"; + } + + default String getIdColumn() { + return "id"; + } + + default String getClaimsColumn() { + return "claims"; + } + + + default String getCredentialDefinitionsColumn() { + return "credential_definitions"; + } + + + String getInsertTemplate(); + + String getUpdateTemplate(); + + String getDeleteByIdTemplate(); + + String getFindByIdTemplate(); + + + SqlQueryStatement createQuery(QuerySpec query); + + String getSelectStatement(); +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStore.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStore.java new file mode 100644 index 000000000..49fde9c8f --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStore.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.spi.persistence.EdcPersistenceException; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.lease.SqlLeaseContextBuilder; +import org.eclipse.edc.sql.store.AbstractSqlStore; +import org.eclipse.edc.transaction.datasource.spi.DataSourceRegistry; +import org.eclipse.edc.transaction.spi.TransactionContext; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Clock; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; + + +/** + * SQL-based {@link IssuanceProcess} store intended for use with PostgreSQL + */ +public class SqlIssuanceProcessStore extends AbstractSqlStore implements IssuanceProcessStore { + + private static final TypeReference> ATTESTATIONS_LIST_REF = new TypeReference<>() { + }; + private final String leaseHolderName; + private final SqlLeaseContextBuilder leaseContext; + + private final IssuanceProcessStoreStatements statements; + private final Clock clock; + + public SqlIssuanceProcessStore(DataSourceRegistry dataSourceRegistry, + String dataSourceName, + TransactionContext transactionContext, + ObjectMapper objectMapper, + QueryExecutor queryExecutor, + IssuanceProcessStoreStatements statements, + String leaseHolderName, + Clock clock) { + super(dataSourceRegistry, dataSourceName, transactionContext, objectMapper, queryExecutor); + this.statements = statements; + this.leaseHolderName = leaseHolderName; + this.clock = clock; + leaseContext = SqlLeaseContextBuilder.with(transactionContext, leaseHolderName, statements, clock, queryExecutor); + } + + @Override + public IssuanceProcess findById(String id) { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + return findByIdInternal(connection, id); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public @NotNull List nextNotLeased(int max, Criterion... criteria) { + return transactionContext.execute(() -> { + var filter = Arrays.stream(criteria).collect(toList()); + var querySpec = QuerySpec.Builder.newInstance().filter(filter).sortField("stateTimestamp").limit(max).build(); + var statement = statements.createQuery(querySpec) + .addWhereClause(statements.getNotLeasedFilter(), clock.millis()); + + try ( + var connection = getConnection(); + var stream = queryExecutor.query(connection, true, this::mapResultSet, statement.getQueryAsString(), statement.getParameters()) + ) { + var issuanceProcesses = stream.collect(Collectors.toList()); + issuanceProcesses.forEach(issuanceProcess -> leaseContext.withConnection(connection).acquireLease(issuanceProcess.getId())); + return issuanceProcesses; + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public StoreResult findByIdAndLease(String id) { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + var entity = findByIdInternal(connection, id); + if (entity == null) { + return StoreResult.notFound(format("IssuanceProcess %s not found", id)); + } + + leaseContext.withConnection(connection).acquireLease(entity.getId()); + return StoreResult.success(entity); + } catch (IllegalStateException e) { + return StoreResult.alreadyLeased(format("IssuanceProcess %s is already leased", id)); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public void save(IssuanceProcess issuanceProcess) { + try (var conn = getConnection()) { + var existing = findByIdInternal(conn, issuanceProcess.getId()); + if (existing != null) { + leaseContext.by(leaseHolderName).withConnection(conn).breakLease(issuanceProcess.getId()); + update(conn, issuanceProcess); + } else { + insert(conn, issuanceProcess); + } + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + } + + @Override + public Stream query(QuerySpec querySpec) { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + var query = statements.createQuery(querySpec); + return queryExecutor.query(connection, true, this::mapResultSet, query.getQueryAsString(), query.getParameters()); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + private void insert(Connection conn, IssuanceProcess process) { + var insertTpStatement = statements.getInsertTemplate(); + queryExecutor.execute(conn, insertTpStatement, process.getId(), + process.getState(), + process.getStateCount(), + process.getStateTimestamp(), + process.getCreatedAt(), + process.getUpdatedAt(), + toJson(process.getTraceContext()), + process.getErrorDetail(), + toJson(process.getClaims()), + toJson(process.getCredentialDefinitions()) + ); + } + + private void update(Connection conn, IssuanceProcess process) { + var updateStmt = statements.getUpdateTemplate(); + queryExecutor.execute(conn, updateStmt, + process.getState(), + process.getStateCount(), + process.getStateTimestamp(), + process.getUpdatedAt(), + toJson(process.getTraceContext()), + process.getErrorDetail(), + toJson(process.getClaims()), + toJson(process.getCredentialDefinitions()), + process.getId()); + + } + + private IssuanceProcess findByIdInternal(Connection connection, String id) { + return transactionContext.execute(() -> { + var stmt = statements.getFindByIdTemplate(); + return queryExecutor.single(connection, false, this::mapResultSet, stmt, id); + }); + } + + + private IssuanceProcess mapResultSet(ResultSet resultSet) throws Exception { + return IssuanceProcess.Builder.newInstance() + .id(resultSet.getString(statements.getIdColumn())) + .createdAt(resultSet.getLong(statements.getCreatedAtColumn())) + .updatedAt(resultSet.getLong(statements.getUpdatedAtColumn())) + .state(resultSet.getInt(statements.getStateColumn())) + .stateTimestamp(resultSet.getLong(statements.getStateTimestampColumn())) + .stateCount(resultSet.getInt(statements.getStateCountColumn())) + .traceContext(fromJson(resultSet.getString(statements.getTraceContextColumn()), getTypeRef())) + .errorDetail(resultSet.getString(statements.getErrorDetailColumn())) + .claims(fromJson(resultSet.getString(statements.getClaimsColumn()), getTypeRef())) + .credentialDefinitions(fromJson(resultSet.getString(statements.getCredentialDefinitionsColumn()), ATTESTATIONS_LIST_REF)) + .build(); + } +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreExtension.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreExtension.java new file mode 100644 index 000000000..3af6e8483 --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreExtension.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.issuerservice.store.sql.issuanceprocess.schema.postgres.PostgresDialectStatements; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.types.TypeManager; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.bootstrapper.SqlSchemaBootstrapper; +import org.eclipse.edc.transaction.datasource.spi.DataSourceRegistry; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import java.time.Clock; + +import static org.eclipse.edc.issuerservice.store.sql.issuanceprocess.SqlIssuanceProcessStoreExtension.NAME; + +@Extension(value = NAME) +public class SqlIssuanceProcessStoreExtension implements ServiceExtension { + public static final String NAME = "Issuance Process SQL Store Extension"; + + @Setting(description = "The datasource to be used", defaultValue = DataSourceRegistry.DEFAULT_DATASOURCE, key = "edc.sql.store.issuanceprocess.datasource") + private String dataSourceName; + + @Inject + private DataSourceRegistry dataSourceRegistry; + @Inject + private TransactionContext transactionContext; + @Inject + private TypeManager typemanager; + @Inject + private QueryExecutor queryExecutor; + @Inject(required = false) + private IssuanceProcessStoreStatements statements; + @Inject + private SqlSchemaBootstrapper sqlSchemaBootstrapper; + + @Inject + private Clock clock; + + @Override + public void initialize(ServiceExtensionContext context) { + sqlSchemaBootstrapper.addStatementFromResource(dataSourceName, "issuance-process-schema.sql"); + } + + @Provider + public IssuanceProcessStore createSqlStore(ServiceExtensionContext context) { + return new SqlIssuanceProcessStore(dataSourceRegistry, dataSourceName, transactionContext, typemanager.getMapper(), + queryExecutor, getStatementImpl(), context.getRuntimeId(), clock); + } + + private IssuanceProcessStoreStatements getStatementImpl() { + return statements != null ? statements : new PostgresDialectStatements(); + } + +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/IssuanceProcessMapping.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/IssuanceProcessMapping.java new file mode 100644 index 000000000..107d9a06d --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/IssuanceProcessMapping.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess.schema.postgres; + +import org.eclipse.edc.issuerservice.store.sql.issuanceprocess.IssuanceProcessStoreStatements; +import org.eclipse.edc.sql.lease.StatefulEntityMapping; + + +/** + * Provides a mapping from the canonical format to SQL column names for a {@code IssuanceProcess}. + */ +public class IssuanceProcessMapping extends StatefulEntityMapping { + + public static final String FIELD_ID = "id"; + + public IssuanceProcessMapping(IssuanceProcessStoreStatements statements) { + super(statements); + add(FIELD_ID, statements.getIdColumn()); + } +} \ No newline at end of file diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/PostgresDialectStatements.java b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/PostgresDialectStatements.java new file mode 100644 index 000000000..617c5475e --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/schema/postgres/PostgresDialectStatements.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess.schema.postgres; + +import org.eclipse.edc.issuerservice.store.sql.issuanceprocess.BaseSqlDialectStatements; +import org.eclipse.edc.sql.dialect.PostgresDialect; + +/** + * Postgres-specific specialization for creating queries based on Postgres JSON operators + */ +public class PostgresDialectStatements extends BaseSqlDialectStatements { + + @Override + public String getFormatAsJsonOperator() { + return PostgresDialect.getJsonCastOperator(); + } +} diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/store/sql/issuance-process-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 000000000..d2242e8a9 --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2025 Cofinity-X +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Cofinity-X - initial API and implementation +# +# + +org.eclipse.edc.issuerservice.store.sql.issuanceprocess.SqlIssuanceProcessStoreExtension \ No newline at end of file diff --git a/extensions/store/sql/issuance-process-store-sql/src/main/resources/issuance-process-schema.sql b/extensions/store/sql/issuance-process-store-sql/src/main/resources/issuance-process-schema.sql new file mode 100644 index 000000000..cd7735345 --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/main/resources/issuance-process-schema.sql @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +CREATE TABLE IF NOT EXISTS edc_lease +( + leased_by VARCHAR NOT NULL, + leased_at BIGINT, + lease_duration INTEGER NOT NULL, + lease_id VARCHAR NOT NULL + CONSTRAINT lease_pk + PRIMARY KEY +); + +COMMENT ON COLUMN edc_lease.leased_at IS 'posix timestamp of lease'; + +COMMENT ON COLUMN edc_lease.lease_duration IS 'duration of lease in milliseconds'; + +CREATE TABLE IF NOT EXISTS edc_issuance_process +( + id VARCHAR NOT NULL PRIMARY KEY, + state INTEGER NOT NULL, + state_count INTEGER DEFAULT 0 NOT NULL, + state_time_stamp BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + trace_context JSON, + error_detail VARCHAR, + pending BOOLEAN DEFAULT FALSE, + lease_id VARCHAR CONSTRAINT issuance_process_lease_lease_id_fk REFERENCES edc_lease ON DELETE SET NULL, + claims JSON NOT NULL, + credential_definitions JSON NOT NULL +); + + +CREATE UNIQUE INDEX IF NOT EXISTS lease_lease_id_uindex ON edc_lease (lease_id); + +-- This will help to identify states that need to be transitioned without a table scan when the entries grow +CREATE INDEX IF NOT EXISTS issuance_process_state ON edc_issuance_process (state,state_time_stamp); + diff --git a/extensions/store/sql/issuance-process-store-sql/src/test/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreTest.java b/extensions/store/sql/issuance-process-store-sql/src/test/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreTest.java new file mode 100644 index 000000000..3b402a0a6 --- /dev/null +++ b/extensions/store/sql/issuance-process-store-sql/src/test/java/org/eclipse/edc/issuerservice/store/sql/issuanceprocess/SqlIssuanceProcessStoreTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.issuerservice.store.sql.issuanceprocess; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStore; +import org.eclipse.edc.identityhub.spi.issuance.credentials.process.store.IssuanceProcessStoreTestBase; +import org.eclipse.edc.issuerservice.store.sql.issuanceprocess.schema.postgres.PostgresDialectStatements; +import org.eclipse.edc.json.JacksonTypeManager; +import org.eclipse.edc.junit.annotations.PostgresqlIntegrationTest; +import org.eclipse.edc.junit.testfixtures.TestUtils; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.testfixtures.LeaseUtil; +import org.eclipse.edc.sql.testfixtures.PostgresqlStoreSetupExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Clock; +import java.time.Duration; + +@PostgresqlIntegrationTest +@ExtendWith(PostgresqlStoreSetupExtension.class) +class SqlIssuanceProcessStoreTest extends IssuanceProcessStoreTestBase { + + private final IssuanceProcessStoreStatements statements = new PostgresDialectStatements(); + private SqlIssuanceProcessStore store; + private LeaseUtil leaseUtil; + + @BeforeEach + void setup(PostgresqlStoreSetupExtension extension, QueryExecutor queryExecutor) { + var typeManager = new JacksonTypeManager(); + store = new SqlIssuanceProcessStore(extension.getDataSourceRegistry(), extension.getDatasourceName(), + extension.getTransactionContext(), typeManager.getMapper(), queryExecutor, statements, RUNTIME_ID, Clock.systemUTC()); + + leaseUtil = new LeaseUtil(extension.getTransactionContext(), extension::getConnection, statements, clock); + + var schema = TestUtils.getResourceFileContentAsString("issuance-process-schema.sql"); + extension.runQuery(schema); + } + + @AfterEach + void tearDown(PostgresqlStoreSetupExtension extension) { + extension.runQuery("DROP TABLE " + statements.getIssuanceProcessTable() + " CASCADE"); + extension.runQuery("DROP TABLE " + statements.getLeaseTableName() + " CASCADE"); + } + + @Override + protected IssuanceProcessStore getStore() { + return store; + } + + @Override + protected void leaseEntity(String negotiationId, String owner, Duration duration) { + leaseUtil.leaseEntity(negotiationId, owner, duration); + } + + @Override + protected boolean isLeasedBy(String negotiationId, String owner) { + return leaseUtil.isLeased(negotiationId, owner); + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a78d342d4..760cfe0b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,10 +68,12 @@ edc-lib-store = { "module" = "org.eclipse.edc:store-lib", version.ref = "edc" } edc-lib-token = { module = "org.eclipse.edc:token-lib", version.ref = "edc" } edc-lib-transform = { module = "org.eclipse.edc:transform-lib", version.ref = "edc" } edc-lib-util = { module = "org.eclipse.edc:util-lib", version.ref = "edc" } +edc-lib-statemachine = { module = "org.eclipse.edc:state-machine-lib", version.ref = "edc" } # SQL dependencies edc-sql-bootstrapper = { module = "org.eclipse.edc:sql-bootstrapper", version.ref = "edc" } edc-sql-core = { module = "org.eclipse.edc:sql-core", version.ref = "edc" } +edc-sql-lease = { module = "org.eclipse.edc:sql-lease", version.ref = "edc" } edc-sql-ih-stsstore-sql = { module = "org.eclipse.edc:sts-client-store-sql", version.ref = "edc" } edc-sql-jtivdalidation = { module = "org.eclipse.edc:jti-validation-store-sql", version.ref = "edc" } edc-sql-pool = { module = "org.eclipse.edc:sql-pool-apache-commons", version.ref = "edc" } diff --git a/settings.gradle.kts b/settings.gradle.kts index d279fbc6b..77dcde7b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,6 +48,7 @@ include(":core:issuerservice:issuerservice-participants") include(":core:issuerservice:issuerservice-credentials") include(":core:issuerservice:issuerservice-credential-revocation") include(":core:issuerservice:issuerservice-credential-definitions") +include(":core:issuerservice:issuerservice-issuance") // lib modules include(":core:lib:keypair-lib") @@ -61,6 +62,7 @@ include(":extensions:store:sql:identity-hub-participantcontext-store-sql") include(":extensions:store:sql:identity-hub-keypair-store-sql") include(":extensions:store:sql:issuerservice-participant-store-sql") include(":extensions:store:sql:issuerservice-credential-definition-store-sql") +include(":extensions:store:sql:issuance-process-store-sql") include(":extensions:did:local-did-publisher") include(":extensions:common:credential-watchdog") include(":extensions:sts:sts-account-provisioner") diff --git a/spi/issuance-credentials-spi/build.gradle.kts b/spi/issuance-credentials-spi/build.gradle.kts index 2088e55f6..9260dd0ab 100644 --- a/spi/issuance-credentials-spi/build.gradle.kts +++ b/spi/issuance-credentials-spi/build.gradle.kts @@ -29,4 +29,5 @@ dependencies { testFixturesImplementation(libs.junit.jupiter.api) testFixturesImplementation(libs.edc.junit) testFixturesImplementation(libs.assertj) + testFixturesImplementation(libs.awaitility) } diff --git a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcess.java b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcess.java index ffc1290ff..5364a23de 100644 --- a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcess.java +++ b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcess.java @@ -14,11 +14,14 @@ package org.eclipse.edc.identityhub.spi.issuance.credentials.model; +import org.eclipse.edc.spi.entity.StatefulEntity; + +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; - -import static java.util.Objects.requireNonNull; +import java.util.Objects; /** * Tracks credential issuance. @@ -29,40 +32,24 @@ * If successful, an issuance process is created with claims gathered from attestations. The issuance process is then approved * asynchronously and generated credentials sent to the holder. */ -public class IssuanceProcess { - public enum State { - SUBMITTED, APPROVED, DELIVERED, ERRORED - } - - private String id; - private State state = State.SUBMITTED; - private long stateTimestamp; - private int retries; - private int errorCode; +public class IssuanceProcess extends StatefulEntity { + private final Map claims = new HashMap<>(); + private final List credentialDefinitions = new ArrayList<>(); - private long creationTime; - - private Map claims; - private List credentialDefinitions; - - public State getState() { - return state; - } - - public long getStateTimestamp() { - return stateTimestamp; - } - - public int getRetries() { - return retries; + private IssuanceProcess() { } - public int getErrorCode() { - return errorCode; + @Override + public IssuanceProcess copy() { + var builder = Builder.newInstance() + .claims(claims) + .credentialDefinitions(credentialDefinitions); + return copy(builder); } - public long getCreationTime() { - return creationTime; + @Override + public String stateAsString() { + return IssuanceProcessStates.from(state).name(); } public Map getClaims() { @@ -73,68 +60,65 @@ public List getCredentialDefinitions() { return credentialDefinitions; } - private IssuanceProcess() { + public Builder toBuilder() { + return new Builder(this); } - public static final class Builder { - private IssuanceProcess process; - public static Builder newInstance() { - return new Builder(); - } + @Override + public int hashCode() { + return Objects.hash(id); + } - public Builder id(String id) { - this.process.id = id; - return this; + @Override + public boolean equals(Object o) { + if (this == o) { + return true; } - - public Builder state(State state) { - this.process.state = state; - return this; + if (o == null || getClass() != o.getClass()) { + return false; } + var that = (IssuanceProcess) o; + return id.equals(that.id); + } - public Builder stateTimestamp(long timestamp) { - this.process.stateTimestamp = timestamp; - return this; - } + public static final class Builder extends StatefulEntity.Builder { - public Builder retries(int retries) { - this.process.retries = retries; - return this; + private Builder(IssuanceProcess process) { + super(process); } - public Builder errorCode(int errorCode) { - this.process.errorCode = errorCode; - return this; + public static Builder newInstance() { + return new Builder(new IssuanceProcess()); } - public Builder creationTime(int creationTime) { - this.process.creationTime = creationTime; + @Override + public Builder self() { return this; } public Builder claims(Map claims) { - this.process.claims.putAll(claims); + this.entity.claims.putAll(claims); return this; } public Builder credentialDefinitions(Collection definitions) { - this.process.credentialDefinitions.addAll(definitions); + this.entity.credentialDefinitions.addAll(definitions); return this; } public Builder credentialDefinitions(String id) { - this.process.credentialDefinitions.add(id); + this.entity.credentialDefinitions.add(id); return this; } public IssuanceProcess build() { - requireNonNull(process.id, "id"); - return process; - } + super.build(); - private Builder() { - process = new IssuanceProcess(); + if (entity.state == 0) { + throw new IllegalStateException("Issuance process state must be set"); + } + return entity; } } diff --git a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcessStates.java b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcessStates.java new file mode 100644 index 000000000..8284338da --- /dev/null +++ b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/model/IssuanceProcessStates.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.issuance.credentials.model; + +import java.util.Arrays; + +public enum IssuanceProcessStates { + SUBMITTED(50), + APPROVED(100), + DELIVERED(200), + ERRORED(300); + + private final int code; + + IssuanceProcessStates(int code) { + this.code = code; + } + + public static IssuanceProcessStates from(int code) { + return Arrays.stream(values()).filter(ips -> ips.code == code).findFirst().orElse(null); + } + + public int code() { + return code; + } +} diff --git a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/IssuanceProcessManager.java b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/IssuanceProcessManager.java new file mode 100644 index 000000000..49da251dd --- /dev/null +++ b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/IssuanceProcessManager.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.issuance.credentials.process; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.entity.StateEntityManager; + +/** + * Manages {@link IssuanceProcess}. + */ +@ExtensionPoint +public interface IssuanceProcessManager extends StateEntityManager { +} diff --git a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/retry/IssuanceProcessRetryStrategy.java b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/retry/IssuanceProcessRetryStrategy.java new file mode 100644 index 000000000..b9a0520bd --- /dev/null +++ b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/retry/IssuanceProcessRetryStrategy.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.issuance.credentials.process.retry; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.retry.WaitStrategy; + +/** + * Implements a wait strategy for the {@link IssuanceProcess}. + *

+ * Implementations may choose to enforce an incremental backoff period when successive errors are encountered. + */ +@ExtensionPoint +public interface IssuanceProcessRetryStrategy extends WaitStrategy { +} diff --git a/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStore.java b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStore.java new file mode 100644 index 000000000..895f7c51e --- /dev/null +++ b/spi/issuance-credentials-spi/src/main/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStore.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.issuance.credentials.process.store; + +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.persistence.StateEntityStore; +import org.eclipse.edc.spi.query.QuerySpec; + +import java.util.stream.Stream; + +/** + * Stores {@link IssuanceProcess}. + */ +@ExtensionPoint +public interface IssuanceProcessStore extends StateEntityStore { + + Stream query(QuerySpec querySpec); + +} diff --git a/spi/issuance-credentials-spi/src/testFixtures/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStoreTestBase.java b/spi/issuance-credentials-spi/src/testFixtures/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStoreTestBase.java new file mode 100644 index 000000000..cf3dbaa44 --- /dev/null +++ b/spi/issuance-credentials-spi/src/testFixtures/java/org/eclipse/edc/identityhub/spi/issuance/credentials/process/store/IssuanceProcessStoreTestBase.java @@ -0,0 +1,451 @@ +/* + * Copyright (c) 2025 Cofinity-X + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Cofinity-X - initial API and implementation + * + */ + +package org.eclipse.edc.identityhub.spi.issuance.credentials.process.store; + +import org.awaitility.Awaitility; +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcess; +import org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcessStates; +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.spi.query.QuerySpec; +import org.eclipse.edc.spi.query.SortOrder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcessStates.APPROVED; +import static org.eclipse.edc.identityhub.spi.issuance.credentials.model.IssuanceProcessStates.DELIVERED; +import static org.eclipse.edc.spi.persistence.StateEntityStore.hasState; +import static org.eclipse.edc.spi.query.Criterion.criterion; +import static org.hamcrest.Matchers.hasSize; + +public abstract class IssuanceProcessStoreTestBase { + + protected static final String RUNTIME_ID = "runtime-id"; + protected final Clock clock = Clock.systemUTC(); + + + /** + * determines the amount of time (default = 500ms) before an async test using Awaitility fails. This may be useful if using remote + * or non-self-contained databases. + */ + protected Duration getTestTimeout() { + return Duration.ofMillis(500); + } + + protected abstract IssuanceProcessStore getStore(); + + protected abstract void leaseEntity(String negotiationId, String owner, Duration duration); + + protected void leaseEntity(String negotiationId, String owner) { + leaseEntity(negotiationId, owner, Duration.ofSeconds(60)); + } + + protected abstract boolean isLeasedBy(String negotiationId, String owner); + + private IssuanceProcess createIssuanceProcess() { + return createIssuanceProcess(UUID.randomUUID().toString()); + } + + private IssuanceProcess createIssuanceProcess(String id, IssuanceProcessStates state) { + return createIssuanceProcessBuilder().id(id).state(state.code()).build(); + } + + private IssuanceProcess createIssuanceProcess(String id) { + return createIssuanceProcess(id, APPROVED); + } + + private IssuanceProcess.Builder createIssuanceProcessBuilder() { + return IssuanceProcess.Builder.newInstance(); + } + + @Nested + class Create { + + @Test + void shouldCreate() { + var issuanceProcess = createIssuanceProcess(); + getStore().save(issuanceProcess); + var retrieved = getStore().findById(issuanceProcess.getId()); + + assertThat(retrieved).isNotNull().usingRecursiveComparison().isEqualTo(issuanceProcess); + assertThat(retrieved.getCreatedAt()).isNotEqualTo(0L); + } + } + + @Nested + class FindById { + + @Test + void shouldFindEntityById() { + var issuanceProcess = createIssuanceProcess(); + getStore().save(issuanceProcess); + + var result = getStore().findById(issuanceProcess.getId()); + + assertThat(result).usingRecursiveComparison().isEqualTo(issuanceProcess); + } + + @Test + void notExist() { + var result = getStore().findById("not-exist"); + + assertThat(result).isNull(); + } + } + + @Nested + class NextNotLeased { + @Test + void shouldReturnNotLeasedItems() { + var state = APPROVED; + var all = range(0, 10) + .mapToObj(i -> createIssuanceProcess("id" + i, state)) + .peek(getStore()::save) + .toList(); + + assertThat(getStore().nextNotLeased(5, hasState(state.code()))) + .hasSize(5) + .extracting(IssuanceProcess::getId) + .isSubsetOf(all.stream().map(IssuanceProcess::getId).toList()) + .allMatch(id -> isLeasedBy(id, RUNTIME_ID)); + } + + @Test + void shouldOnlyReturnFreeItems() { + var state = APPROVED; + var all = range(0, 10) + .mapToObj(i -> createIssuanceProcess("id" + i, state)) + .peek(getStore()::save) + .toList(); + + // lease a few + var leasedProcesses = all.stream().skip(5).peek(ip -> leaseEntity(ip.getId(), RUNTIME_ID)).toList(); + + // should not contain leased IPs + assertThat(getStore().nextNotLeased(10, hasState(state.code()))) + .hasSize(5) + .isSubsetOf(all) + .doesNotContainAnyElementsOf(leasedProcesses); + } + + @Test + void noFreeItem_shouldReturnEmpty() { + var state = APPROVED; + range(0, 3) + .mapToObj(i -> createIssuanceProcess("id" + i, state)) + .forEach(getStore()::save); + + // first time works + assertThat(getStore().nextNotLeased(10, hasState(state.code()))).hasSize(3); + // second time returns empty list + assertThat(getStore().nextNotLeased(10, hasState(state.code()))).isEmpty(); + } + + @Test + void noneInDesiredState() { + range(0, 3) + .mapToObj(i -> createIssuanceProcess("id" + i, APPROVED)) + .forEach(getStore()::save); + + var nextNotLeased = getStore().nextNotLeased(10, hasState(DELIVERED.code())); + + assertThat(nextNotLeased).isEmpty(); + } + + @Test + void batchSizeLimits() { + var state = APPROVED; + range(0, 10) + .mapToObj(i -> createIssuanceProcess("id" + i, state)) + .forEach(getStore()::save); + + // first time works + var result = getStore().nextNotLeased(3, hasState(state.code())); + assertThat(result).hasSize(3); + } + + @Test + void verifyTemporalOrdering() { + var state = APPROVED; + range(0, 10) + .mapToObj(i -> createIssuanceProcess(String.valueOf(i), state)) + .peek(this::delayByTenMillis) + .forEach(getStore()::save); + + assertThat(getStore().nextNotLeased(20, hasState(state.code()))) + .extracting(IssuanceProcess::getId) + .map(Integer::parseInt) + .isSortedAccordingTo(Integer::compareTo); + } + + @Test + void verifyMostRecentlyUpdatedIsLast() throws InterruptedException { + var all = range(0, 10) + .mapToObj(i -> createIssuanceProcess("id" + i, APPROVED)) + .peek(getStore()::save) + .toList(); + + Thread.sleep(100); + + var fourth = all.get(3); + fourth.updateStateTimestamp(); + getStore().save(fourth); + + var next = getStore().nextNotLeased(20, hasState(APPROVED.code())); + assertThat(next.indexOf(fourth)).isEqualTo(9); + } + + @Test + @DisplayName("Verifies that calling nextNotLeased locks the IP for any subsequent calls") + void locksEntity() { + var issuanceProcess = createIssuanceProcess("id1", APPROVED); + getStore().save(issuanceProcess); + + getStore().nextNotLeased(100, hasState(APPROVED.code())); + + assertThat(isLeasedBy(issuanceProcess.getId(), RUNTIME_ID)).isTrue(); + } + + @Test + void expiredLease() { + var issuanceProcess = createIssuanceProcess("id1", APPROVED); + getStore().save(issuanceProcess); + + leaseEntity(issuanceProcess.getId(), RUNTIME_ID, Duration.ofMillis(100)); + + Awaitility.await().atLeast(Duration.ofMillis(100)) + .atMost(getTestTimeout()) + .until(() -> getStore().nextNotLeased(10, hasState(APPROVED.code())), hasSize(1)); + } + + @Test + void shouldLeaseEntityUntilUpdate() { + var issuanceProcess = createIssuanceProcess(); + getStore().save(issuanceProcess); + + var firstQueryResult = getStore().nextNotLeased(1, hasState(APPROVED.code())); + assertThat(firstQueryResult).hasSize(1); + + var secondQueryResult = getStore().nextNotLeased(1, hasState(APPROVED.code())); + assertThat(secondQueryResult).hasSize(0); + + var retrieved = firstQueryResult.get(0); + getStore().save(retrieved); + + var thirdQueryResult = getStore().nextNotLeased(1, hasState(APPROVED.code())); + assertThat(thirdQueryResult).hasSize(1); + } + + @Test + void avoidsStarvation() throws InterruptedException { + for (var i = 0; i < 10; i++) { + var process = createIssuanceProcess("test-process-" + i); + getStore().save(process); + } + + var list1 = getStore().nextNotLeased(5, hasState(APPROVED.code())); + Thread.sleep(50); //simulate a short delay to generate different timestamps + list1.forEach(ip -> { + ip.updateStateTimestamp(); + getStore().save(ip); + }); + var list2 = getStore().nextNotLeased(5, hasState(APPROVED.code())); + assertThat(list1).isNotEqualTo(list2).doesNotContainAnyElementsOf(list2); + } + + @Test + void shouldLeaseOrderByStateTimestamp() { + + var all = range(0, 10) + .mapToObj(i -> createIssuanceProcess("id-" + i)) + .peek(getStore()::save) + .toList(); + + all.stream().limit(5) + .peek(this::delayByTenMillis) + .sorted(Comparator.comparing(IssuanceProcess::getStateTimestamp).reversed()) + .forEach(f -> getStore().save(f)); + + var elements = getStore().nextNotLeased(10, hasState(APPROVED.code())); + assertThat(elements).hasSize(10).extracting(IssuanceProcess::getStateTimestamp).isSorted(); + } + + private void delayByTenMillis(IssuanceProcess t) { + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + // noop + } + t.updateStateTimestamp(); + } + } + + @Nested + class Update { + + @Test + void shouldUpdate() { + var issuanceProcess = createIssuanceProcess(); + getStore().save(issuanceProcess); + + issuanceProcess = issuanceProcess.toBuilder().state(DELIVERED.code()).build(); + + getStore().save(issuanceProcess); + + assertThat(getStore().query(QuerySpec.none())) + .hasSize(1) + .first().satisfies(actual -> { + assertThat(actual.getState()).isEqualTo(DELIVERED.code()); + }); + } + + @Test + @DisplayName("Verify that the lease on a IP is cleared by an update") + void shouldBreakLease() { + var issuanceProcess = createIssuanceProcess("id1"); + getStore().save(issuanceProcess); + // acquire lease + leaseEntity(issuanceProcess.getId(), RUNTIME_ID); + + issuanceProcess = issuanceProcess.toBuilder().state(DELIVERED.code()).build(); + getStore().save(issuanceProcess); + + // lease should be broken + var notLeased = getStore().nextNotLeased(10, hasState(DELIVERED.code())); + + assertThat(notLeased).usingRecursiveFieldByFieldElementComparator().containsExactly(issuanceProcess); + } + + @Test + void leasedByOther_shouldThrowException() { + var id = "id1"; + var issuanceProcess = createIssuanceProcess(id); + getStore().save(issuanceProcess); + leaseEntity(id, "someone"); + + var updatedIssuanceProcess = issuanceProcess.toBuilder().state(DELIVERED.code()).build(); + + // leased by someone else -> throw exception + assertThatThrownBy(() -> getStore().save(updatedIssuanceProcess)).isInstanceOf(IllegalStateException.class); + } + + } + + @Nested + class FindAll { + + @Test + void noQuerySpec() { + var all = range(0, 10) + .mapToObj(i -> createIssuanceProcess("id" + i)) + .peek(getStore()::save) + .toList(); + + assertThat(getStore().query(QuerySpec.none())).containsExactlyInAnyOrderElementsOf(all); + } + + @Test + void verifyFiltering() { + range(0, 10).forEach(i -> getStore().save(createIssuanceProcess("test-neg-" + i))); + var querySpec = QuerySpec.Builder.newInstance().filter(criterion("id", "=", "test-neg-3")).build(); + + var result = getStore().query(querySpec); + + assertThat(result).extracting(IssuanceProcess::getId).containsOnly("test-neg-3"); + } + + @Test + void shouldThrowException_whenInvalidOperator() { + range(0, 10).forEach(i -> getStore().save(createIssuanceProcess("test-neg-" + i))); + var querySpec = QuerySpec.Builder.newInstance().filter(criterion("id", "foobar", "other")).build(); + + assertThatThrownBy(() -> getStore().query(querySpec).toList()).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void queryByState() { + var issuanceProcess = createIssuanceProcess("testprocess1"); + getStore().save(issuanceProcess); + + var query = QuerySpec.Builder.newInstance() + .filter(List.of(new Criterion("state", "=", issuanceProcess.getState()))) + .build(); + + var result = getStore().query(query).toList(); + assertThat(result).hasSize(1).usingRecursiveFieldByFieldElementComparator().containsExactly(issuanceProcess); + } + + @Test + void verifySorting() { + range(0, 10).forEach(i -> getStore().save(createIssuanceProcess("test-neg-" + i))); + + assertThat(getStore().query(QuerySpec.Builder.newInstance().sortField("id").sortOrder(SortOrder.ASC).build())) + .hasSize(10) + .isSortedAccordingTo(Comparator.comparing(IssuanceProcess::getId)); + assertThat(getStore().query(QuerySpec.Builder.newInstance().sortField("id").sortOrder(SortOrder.DESC).build())) + .hasSize(10) + .isSortedAccordingTo((c1, c2) -> c2.getId().compareTo(c1.getId())); + } + + @Test + void verifyPaging() { + range(0, 10) + .mapToObj(i -> createIssuanceProcess(String.valueOf(i))) + .forEach(getStore()::save); + + var qs = QuerySpec.Builder.newInstance().limit(5).offset(3).build(); + assertThat(getStore().query(qs)).hasSize(5) + .extracting(IssuanceProcess::getId) + .map(Integer::parseInt) + .allMatch(id -> id >= 3 && id < 8); + } + + @Test + void verifyPaging_pageSizeLargerThanCollection() { + + range(0, 10) + .mapToObj(i -> createIssuanceProcess(String.valueOf(i))) + .forEach(getStore()::save); + + var qs = QuerySpec.Builder.newInstance().limit(20).offset(3).build(); + assertThat(getStore().query(qs)) + .hasSize(7) + .extracting(IssuanceProcess::getId) + .map(Integer::parseInt) + .allMatch(id -> id >= 3 && id < 10); + } + + @Test + void verifyPaging_pageSizeOutsideCollection() { + + range(0, 10) + .mapToObj(i -> createIssuanceProcess(String.valueOf(i))) + .forEach(getStore()::save); + + var qs = QuerySpec.Builder.newInstance().limit(10).offset(12).build(); + assertThat(getStore().query(qs)).isEmpty(); + + } + } +}