diff --git a/app/models/wallet.py b/app/models/wallet.py index b514d80b2..fdced1aec 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -1,10 +1,12 @@ from typing import List, Optional +from aries_cloudcontroller import DIDCreate as DIDCreateAcaPy +from aries_cloudcontroller.models.did_create_options import DIDCreateOptions from aries_cloudcontroller.models.indy_cred_info import ( IndyCredInfo as IndyCredInfoAcaPy, ) from aries_cloudcontroller.models.vc_record import VCRecord as VCRecordAcaPy -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StrictStr, model_validator class SetDidEndpointRequest(BaseModel): @@ -41,3 +43,74 @@ class IndyCredInfo(IndyCredInfoAcaPy): class CredInfoList(BaseModel): results: Optional[List[IndyCredInfo]] = None + + +class DIDCreate(DIDCreateAcaPy): + """ + Extends the AcapyDIDCreate model with smart defaults and a simplified interface. + Handles deprecated `options` field from client requests by populating `key_type` and `did`. + Downstream processes should use the appropriate `options` structure based on the model's fields. + """ + + method: Optional[StrictStr] = Field( + default="sov", + description=( + "Method for the requested DID. Supported methods are 'sov', 'key', 'web', 'did:peer:2', or 'did:peer:4'." + ), + examples=["sov", "key", "web", "did:peer:2", "did:peer:4"], + ) + options: Optional[DIDCreateOptions] = Field( + default=None, + deprecated=True, + description="(Deprecated) Define a key type and/or a DID depending on the chosen DID method.", + examples=[{"key_type": "ed25519", "did": "did:peer:2"}], + ) + seed: Optional[StrictStr] = Field( + default=None, + description="Optional seed to use for DID. Must be enabled in configuration before use.", + ) + key_type: Optional[StrictStr] = Field( + default="ed25519", + description="Key type to use for the DID key_pair. Validated with the chosen DID method's supported key types.", + examples=["ed25519", "bls12381g2"], + ) + did: Optional[StrictStr] = Field( + default=None, + description="Specify the final value of DID (including `did::` prefix) if the method supports it.", + ) + + @model_validator(mode="before") + @classmethod + def handle_deprecated_options(cls, values: dict) -> dict: + """ + Handle deprecated `options` field from client requests. + Populate `key_type` and `did` fields based on `options` if they aren't explicitly provided. + Do not duplicate data by setting `options` based on `key_type` and `did`. + + Args: + values: Dictionary containing the model fields + + Returns: + Updated values dict with `key_type` and `did` populated from `options` if necessary + """ + options = values.get("options") + + if options: + # Populate `key_type` from `options` if not explicitly provided + if not values.get("key_type"): + values["key_type"] = options.get("key_type", "ed25519") + + # Populate `did` from `options` if not explicitly provided + if not values.get("did"): + values["did"] = options.get("did") + + return values + + def to_acapy_options(self) -> DIDCreateOptions: + """ + Convert the model's fields into the `DIDCreateOptions` structure expected by ACA-Py. + + Returns: + An instance of `DIDCreateOptions` populated with `key_type` and `did`. + """ + return DIDCreateOptions(key_type=self.key_type, did=self.did) diff --git a/app/routes/wallet/dids.py b/app/routes/wallet/dids.py index e2ae406b1..46bfc0b9c 100644 --- a/app/routes/wallet/dids.py +++ b/app/routes/wallet/dids.py @@ -1,6 +1,8 @@ from typing import List, Optional -from aries_cloudcontroller import DID, DIDCreate, DIDEndpoint, DIDEndpointWithType +from aries_cloudcontroller import DID +from aries_cloudcontroller import DIDCreate as DIDCreateAcaPy +from aries_cloudcontroller import DIDEndpoint, DIDEndpointWithType from fastapi import APIRouter, Depends from app.dependencies.acapy_clients import client_from_auth @@ -10,7 +12,7 @@ handle_acapy_call, handle_model_with_validation, ) -from app.models.wallet import SetDidEndpointRequest +from app.models.wallet import DIDCreate, SetDidEndpointRequest from app.services import acapy_wallet from shared.log_config import get_logger @@ -19,30 +21,72 @@ router = APIRouter(prefix="/v1/wallet/dids", tags=["wallet"]) -@router.post("", response_model=DID) +@router.post("", response_model=DID, summary="Create Local DID") async def create_did( did_create: Optional[DIDCreate] = None, auth: AcaPyAuth = Depends(acapy_auth_from_header), -): - """Create Local DID.""" - logger.debug("POST request received: Create DID") +) -> DID: + """ + Create Local DID + --- + + This endpoint allows you to create a new DID in the wallet. + The `method` parameter is optional and can be set to + 'sov', 'key', 'web', 'did:peer:2', or 'did:peer:4'. + + The `options` field is deprecated and has been flattened, such that `did` and + `key_type` are now top-level fields. The `options` field will still + take precedence over the top-level fields if it is present. + + Request Body: + --- + DIDCreate (Optional): + method (str, optional): Method for the requested DID. + options (DIDCreateOptions, optional): Deprecated. + seed (str, optional): Optional seed for DID. + key_type (str, optional): Key type for the DID. + did (str, optional): Specific DID value. + + Response: + --- + Returns the created DID object. + """ + logger.debug("POST request received: Create DID with data: %s", did_create) + + if not did_create: + did_create = DIDCreate() + + # Convert the custom DIDCreate model to Acapy's DIDCreateOptions + did_create_options = did_create.to_acapy_options() + + # Initialize the Acapy DIDCreate model with necessary fields + acapy_did_create = DIDCreateAcaPy( + method=did_create.method, options=did_create_options, seed=did_create.seed + ) async with client_from_auth(auth) as aries_controller: - logger.debug("Creating DID") + logger.debug("Creating DID with request: %s", acapy_did_create) result = await acapy_wallet.create_did( - did_create=did_create, controller=aries_controller + did_create=acapy_did_create, controller=aries_controller ) logger.debug("Successfully created DID.") return result -@router.get("", response_model=List[DID]) +@router.get("", response_model=List[DID], summary="List DIDs") async def list_dids( auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> List[DID]: """ - Retrieve list of DIDs. + Retrieve List of DIDs + --- + + This endpoint allows you to retrieve a list of DIDs in the wallet. + + Response: + --- + Returns a list of DID objects. """ logger.debug("GET request received: Retrieve list of DIDs") @@ -60,12 +104,20 @@ async def list_dids( return did_result.results -@router.get("/public", response_model=DID) +@router.get("/public", response_model=DID, summary="Fetch Public DID") async def get_public_did( auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> DID: """ - Fetch the current public DID. + Fetch the Current Public DID + --- + + This endpoint allows you to fetch the current public DID. + By default, only issuers will have public DIDs. + + Response: + --- + Returns the public DID. """ logger.debug("GET request received: Fetch public DID") @@ -82,12 +134,29 @@ async def get_public_did( return result.result -@router.put("/public", response_model=DID) +@router.put("/public", response_model=DID, summary="Set Public DID") async def set_public_did( did: str, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> DID: - """Set the current public DID.""" + """ + Set the Current Public DID + --- + + This endpoint allows you to set the current public DID. + + **Notes:** + - Requires an active endorser connection to make a DID public. + - By default, only issuers can have and update public DIDs. + + Parameters: + --- + did: str + + Response: + --- + Returns the public DID. + """ logger.debug("PUT request received: Set public DID") async with client_from_auth(auth) as aries_controller: @@ -98,15 +167,29 @@ async def set_public_did( return result -@router.patch("/{did}/rotate-keypair", status_code=204) +@router.patch("/{did}/rotate-keypair", status_code=204, summary="Rotate Key Pair") async def rotate_keypair( did: str, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> None: + """ + Rotate Key Pair for DID + --- + + This endpoint allows you to rotate the key pair for a DID. + + Parameters: + --- + did: str + + Response: + --- + 204 No Content + """ bound_logger = logger.bind(body={"did": did}) bound_logger.debug("PATCH request received: Rotate keypair for DID") async with client_from_auth(auth) as aries_controller: - bound_logger.debug("Rotating keypair") + bound_logger.debug("Rotating key pair") await handle_acapy_call( logger=logger, acapy_call=aries_controller.wallet.rotate_keypair, did=did ) @@ -114,12 +197,25 @@ async def rotate_keypair( bound_logger.debug("Successfully rotated keypair.") -@router.get("/{did}/endpoint", response_model=DIDEndpoint) +@router.get("/{did}/endpoint", response_model=DIDEndpoint, summary="Get DID Endpoint") async def get_did_endpoint( did: str, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> DIDEndpoint: - """Get DID endpoint.""" + """ + Get DID Endpoint + --- + + This endpoint allows you to fetch the endpoint for a DID. + + Parameters: + --- + did: str + + Response: + --- + Returns the endpoint for the DID. + """ bound_logger = logger.bind(body={"did": did}) bound_logger.debug("GET request received: Get endpoint for DID") async with client_from_auth(auth) as aries_controller: @@ -132,14 +228,27 @@ async def get_did_endpoint( return result -@router.post("/{did}/endpoint", status_code=204) +@router.post("/{did}/endpoint", status_code=204, summary="Set DID Endpoint") async def set_did_endpoint( did: str, body: SetDidEndpointRequest, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> None: - """Update Endpoint in wallet and on ledger if posted to it.""" + """ + Update Endpoint of DID in Wallet (and on Ledger, if it is a Public DID) + --- + This endpoint allows you to update the endpoint for a DID. + + Parameters: + --- + did: str + + Request Body: + --- + SetDidEndpointRequest: + endpoint: str + """ # "Endpoint" type is for making connections using public indy DIDs bound_logger = logger.bind(body={"did": did, "body": body}) bound_logger.debug("POST request received: Get endpoint for DID") diff --git a/app/tests/routes/wallet/dids/test_create_did.py b/app/tests/routes/wallet/dids/test_create_did.py index aef8ba728..aaae4b198 100644 --- a/app/tests/routes/wallet/dids/test_create_did.py +++ b/app/tests/routes/wallet/dids/test_create_did.py @@ -1,21 +1,98 @@ from unittest.mock import AsyncMock, patch import pytest -from aries_cloudcontroller import DIDCreate +from aries_cloudcontroller import DIDCreate as DIDCreateAcaPy from aries_cloudcontroller.exceptions import ( ApiException, BadRequestException, NotFoundException, ) +from app.models.wallet import DIDCreate from app.routes.wallet.dids import create_did @pytest.mark.anyio @pytest.mark.parametrize( - "request_body", [None, DIDCreate(method="key"), DIDCreate(method="sov")] + "request_body, create_body", + [ + ( + None, + DIDCreateAcaPy(method="sov", options={"key_type": "ed25519"}), + ), + ( + DIDCreate(method="key"), + DIDCreateAcaPy( + method="key", + options={"key_type": "ed25519"}, + ), + ), + ( + DIDCreate(method="sov"), + DIDCreateAcaPy( + method="sov", + options={"key_type": "ed25519"}, + ), + ), + ( + DIDCreate(method="did:peer:2"), + DIDCreateAcaPy( + method="did:peer:2", + options={"key_type": "ed25519"}, + ), + ), + ( + DIDCreate(method="did:peer:4"), + DIDCreateAcaPy( + method="did:peer:4", + options={"key_type": "ed25519"}, + ), + ), + ( + DIDCreate(method="key", key_type="bls12381g2"), + DIDCreateAcaPy( + method="key", + options={"key_type": "bls12381g2"}, + ), + ), + ( + DIDCreate(method="sov", key_type="bls12381g2"), + DIDCreateAcaPy( + method="sov", + options={"key_type": "bls12381g2"}, + ), + ), + ( + DIDCreate(method="did:peer:2", key_type="bls12381g2"), + DIDCreateAcaPy( + method="did:peer:2", + options={"key_type": "bls12381g2"}, + ), + ), + ( + DIDCreate(method="did:peer:4", key_type="bls12381g2"), + DIDCreateAcaPy( + method="did:peer:4", + options={"key_type": "bls12381g2"}, + ), + ), + ( + DIDCreate(method="web", did="did:web:1234"), + DIDCreateAcaPy( + method="web", + options={"key_type": "ed25519", "did": "did:web:1234"}, + ), + ), + ( + DIDCreate(method="web", key_type="bls12381g2", did="did:web:1234"), + DIDCreateAcaPy( + method="web", + options={"key_type": "bls12381g2", "did": "did:web:1234"}, + ), + ), + ], ) -async def test_create_did_success(request_body): +async def test_create_did_success(request_body, create_body): mock_aries_controller = AsyncMock() mock_create_did = AsyncMock() @@ -32,7 +109,7 @@ async def test_create_did_success(request_body): await create_did(did_create=request_body, auth="mocked_auth") mock_create_did.assert_awaited_once_with( - did_create=request_body, controller=mock_aries_controller + did_create=create_body, controller=mock_aries_controller )