Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement StorageApi + validator #572

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dist/bom/identityhub-base-bom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ dependencies {
runtimeOnly(project(":core:identity-hub-participants"))
runtimeOnly(project(":core:identity-hub-keypairs"))
runtimeOnly(project(":extensions:did:local-did-publisher"))
runtimeOnly(project(":extensions:protocols:dcp:presentation-api"))
runtimeOnly(project(":extensions:protocols:dcp:dcp-identityhub:presentation-api"))
runtimeOnly(project(":extensions:protocols:dcp:dcp-identityhub:storage-api"))
runtimeOnly(project(":extensions:common:credential-watchdog"))
runtimeOnly(project(":extensions:sts:sts-account-provisioner"))
runtimeOnly(project(":extensions:api:identity-api:did-api"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class IdentityHub extends SmokeTest {
put("edc.ih.iam.publickey.path", "/some/path/to/key.pem");
put("web.http.presentation.port", valueOf(getFreePort()));
put("web.http.presentation.path", "/api/resolution");
put("web.http.storage.port", valueOf(getFreePort()));
put("web.http.storage.path", "/api/storage");
put("web.http.identity.port", valueOf(getFreePort()));
put("web.http.identity.path", "/api/identity");
put("web.http.version.port", valueOf(getFreePort()));
Expand Down Expand Up @@ -95,6 +97,8 @@ class IdentityHubWithSts extends SmokeTest {
put("edc.ih.iam.publickey.path", "/some/path/to/key.pem");
put("web.http.presentation.port", valueOf(getFreePort()));
put("web.http.presentation.path", "/api/resolution");
put("web.http.storage.port", valueOf(getFreePort()));
put("web.http.storage.path", "/api/storage");
put("web.http.identity.port", valueOf(getFreePort()));
put("web.http.identity.path", "/api/identity");
put("web.http.accounts.port", valueOf(getFreePort()));
Expand Down
2 changes: 1 addition & 1 deletion e2e-tests/runtimes/identityhub-remote-sts/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies {
runtimeOnly(project(":core:identity-hub-keypairs"))
runtimeOnly(project(":extensions:did:local-did-publisher"))
runtimeOnly(project(":extensions:common:credential-watchdog"))
runtimeOnly(project(":extensions:protocols:dcp:presentation-api"))
runtimeOnly(project(":extensions:protocols:dcp:dcp-identityhub:presentation-api"))
runtimeOnly(project(":extensions:sts:sts-account-provisioner"))
runtimeOnly(project(":extensions:sts:sts-account-service-remote"))
runtimeOnly(project(":extensions:api:identity-api:did-api"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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
*
*/

plugins {
`java-library`
`maven-publish`
id("io.swagger.core.v3.swagger-gradle-plugin")
}

dependencies {
api(project(":spi:identity-hub-spi"))
api(project(":spi:verifiable-credential-spi"))
api(project(":extensions:protocols:dcp:dcp-spi"))
api(libs.edc.spi.jsonld)
api(libs.edc.spi.jwt)
api(libs.edc.spi.core)
implementation(libs.edc.spi.validator)
implementation(libs.edc.spi.web)
implementation(libs.edc.spi.dcp)
implementation(libs.edc.lib.jerseyproviders)
implementation(libs.edc.lib.transform)
implementation(libs.edc.dcp.transform)
implementation(libs.jakarta.rsApi)
testImplementation(libs.edc.junit)
testImplementation(libs.edc.jsonld)
testImplementation(testFixtures(libs.edc.core.jersey))
testImplementation(testFixtures(project(":spi:verifiable-credential-spi")))
testImplementation(libs.nimbus.jwt)
testImplementation(libs.restAssured)
}

edcBuild {
swagger {
apiGroup.set("storage-api")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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.api;

import com.fasterxml.jackson.databind.DeserializationFeature;
import org.eclipse.edc.iam.identitytrust.transform.from.JsonObjectFromPresentationResponseMessageTransformer;
import org.eclipse.edc.iam.identitytrust.transform.to.JsonObjectToPresentationQueryTransformer;
import org.eclipse.edc.identityhub.api.storage.StorageApiController;
import org.eclipse.edc.identityhub.api.validation.CredentialMessageValidator;
import org.eclipse.edc.identityhub.spi.verification.SelfIssuedTokenVerifier;
import org.eclipse.edc.jsonld.spi.JsonLd;
import org.eclipse.edc.jsonld.spi.JsonLdNamespace;
import org.eclipse.edc.runtime.metamodel.annotation.Configuration;
import org.eclipse.edc.runtime.metamodel.annotation.Extension;
import org.eclipse.edc.runtime.metamodel.annotation.Inject;
import org.eclipse.edc.runtime.metamodel.annotation.Setting;
import org.eclipse.edc.runtime.metamodel.annotation.Settings;
import org.eclipse.edc.spi.EdcException;
import org.eclipse.edc.spi.system.ServiceExtension;
import org.eclipse.edc.spi.system.ServiceExtensionContext;
import org.eclipse.edc.spi.system.apiversion.ApiVersionService;
import org.eclipse.edc.spi.system.apiversion.VersionRecord;
import org.eclipse.edc.spi.types.TypeManager;
import org.eclipse.edc.transform.spi.TypeTransformerRegistry;
import org.eclipse.edc.transform.transformer.edc.to.JsonValueToGenericTypeTransformer;
import org.eclipse.edc.validator.spi.JsonObjectValidatorRegistry;
import org.eclipse.edc.web.jersey.providers.jsonld.ObjectMapperProvider;
import org.eclipse.edc.web.spi.WebService;
import org.eclipse.edc.web.spi.configuration.PortMapping;
import org.eclipse.edc.web.spi.configuration.PortMappingRegistry;

import java.io.IOException;
import java.util.stream.Stream;

import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_NAMESPACE_V_1_0;
import static org.eclipse.edc.iam.identitytrust.spi.DcpConstants.DSPACE_DCP_V_1_0_CONTEXT;
import static org.eclipse.edc.identityhub.api.StorageApiExtension.NAME;
import static org.eclipse.edc.identityhub.protocols.dcp.spi.DcpConstants.DCP_SCOPE_V_1_0;
import static org.eclipse.edc.identityhub.protocols.dcp.spi.model.CredentialMessage.CREDENTIAL_MESSAGE_TERM;
import static org.eclipse.edc.identityhub.spi.webcontext.IdentityHubApiContext.STORAGE;
import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD;

@Extension(value = NAME)
public class StorageApiExtension implements ServiceExtension {

public static final String NAME = "Storage API Extension";
private static final String API_VERSION_JSON_FILE = "storage-api-version.json";

@Configuration
private StorageApiConfiguration apiConfiguration;
@Inject
private TypeTransformerRegistry typeTransformer;
@Inject
private JsonObjectValidatorRegistry validatorRegistry;
@Inject
private WebService webService;
@Inject
private SelfIssuedTokenVerifier selfIssuedTokenVerifier;
@Inject
private JsonLd jsonLd;
@Inject
private TypeManager typeManager;
@Inject
private ApiVersionService apiVersionService;
@Inject
private PortMappingRegistry portMappingRegistry;

@Override
public String name() {
return NAME;
}

@Override
public void initialize(ServiceExtensionContext context) {
var contextString = STORAGE;

portMappingRegistry.register(new PortMapping(contextString, apiConfiguration.port(), apiConfiguration.path()));

validatorRegistry.register(DSPACE_DCP_NAMESPACE_V_1_0.toIri(CREDENTIAL_MESSAGE_TERM), new CredentialMessageValidator());


var controller = new StorageApiController(validatorRegistry, typeTransformer, selfIssuedTokenVerifier, jsonLd);
webService.registerResource(contextString, new ObjectMapperProvider(typeManager, JSON_LD));
webService.registerResource(contextString, controller);

jsonLd.registerContext(DSPACE_DCP_V_1_0_CONTEXT, DCP_SCOPE_V_1_0);

registerTransformers(DCP_SCOPE_V_1_0, DSPACE_DCP_NAMESPACE_V_1_0);

registerVersionInfo(getClass().getClassLoader());
}

void registerTransformers(String scope, JsonLdNamespace namespace) {
var scopedTransformerRegistry = typeTransformer.forContext(scope);
scopedTransformerRegistry.register(new JsonObjectToPresentationQueryTransformer(typeManager, JSON_LD, namespace));
scopedTransformerRegistry.register(new JsonValueToGenericTypeTransformer(typeManager, JSON_LD));
scopedTransformerRegistry.register(new JsonObjectFromPresentationResponseMessageTransformer(namespace));
}

private void registerVersionInfo(ClassLoader resourceClassLoader) {
try (var versionContent = resourceClassLoader.getResourceAsStream(API_VERSION_JSON_FILE)) {
if (versionContent == null) {
throw new EdcException("Version file not found or not readable.");
}
Stream.of(typeManager.getMapper()
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
.readValue(versionContent, VersionRecord[].class))
.forEach(vr -> apiVersionService.addRecord("presentation", vr));
} catch (IOException e) {
throw new EdcException(e);
}
}

@Settings
record StorageApiConfiguration(
@Setting(key = "web.http." + STORAGE + ".port", description = "Port for " + STORAGE + " api context", defaultValue = 14141 + "")
int port,
@Setting(key = "web.http." + STORAGE + ".path", description = "Path for " + STORAGE + " api context", defaultValue = "/api/storage")
String path
) {

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.api.storage;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.CONTEXT;
import static org.eclipse.edc.jsonld.spi.JsonLdKeywords.TYPE;

public interface ApiSchema {
@Schema(name = "ApiErrorDetail", example = ApiErrorDetailSchema.API_ERROR_EXAMPLE)
record ApiErrorDetailSchema(
String message,
String type,
String path,
String invalidValue
) {
public static final String API_ERROR_EXAMPLE = """
{
"message": "error message",
"type": "ErrorType",
"path": "object.error.path",
"invalidValue": "this value is not valid"
}
""";
}

@Schema(name = "CredentialMessage", example = CredentialMessageSchema.CREDENTIALMESSAGE_EXAMPLE)
record CredentialMessageSchema(
@Schema(name = CONTEXT, requiredMode = REQUIRED)
Object context,
@Schema(name = TYPE, requiredMode = REQUIRED)
String type,
@Schema(name = "credentials", requiredMode = REQUIRED)
List<CredentialContainerSchema> credentials,
@Schema(name = "requestId", requiredMode = REQUIRED)
String requestId
) {

public static final String CREDENTIALMESSAGE_EXAMPLE = """
{
"@context": [
"https://w3id.org/dspace-dcp/v1.0/dcp.jsonld"
],
"type": "CredentialMessage",
"credentials": [
{
"credentialType": "MembershipCredential",
"payload": "",
"format": "jwt"
},
{
"credentialType": "OrganizationCredential",
"payload": "",
"format": "json-ld"
}
],
"requestId": "requestId"
}
""";
}


@Schema(name = "CredentialContainerSchema", example = CredentialContainerSchema.EXAMPLE)
record CredentialContainerSchema() {

private static final String EXAMPLE = """
{
"credentialType": "MembershipCredential",
"payload": "",
"format": "jwt"
}
""";
}
}
Original file line number Diff line number Diff line change
@@ -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.identityhub.api.storage;


import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.json.JsonObject;
import jakarta.ws.rs.core.Response;

@OpenAPIDefinition(
info = @Info(description = "This represents the Storage API as per DCP specification. It serves endpoints to write VerifiableCredentials into storage.", title = "Storage API",
version = "1"))
@SecurityScheme(name = "Authentication",
description = "Self-Issued ID token containing an access_token",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT")
public interface StorageApi {

@Tag(name = "Storage API")
@Operation(description = "Writes a set of credentials into storage",
requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = ApiSchema.CredentialMessageSchema.class))),
responses = {
@ApiResponse(responseCode = "2xx", description = "The credentialMessage was successfully processed and stored"),
@ApiResponse(responseCode = "400", description = "Request body was malformed",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)))),
@ApiResponse(responseCode = "401", description = "No Authorization header was given.",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class)))),
@ApiResponse(responseCode = "403", description = "The given authentication token could not be validated. This can happen, when the request body " +
"calls for a broader credentialMessage scope than the granted scope in the auth token",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = ApiSchema.ApiErrorDetailSchema.class))))

}
)
Response storeCredential(String participantContextId, JsonObject credentialMessage, String authHeader);
}
Loading