diff --git a/docs/docusaurus/credentials/issue.md b/docs/docusaurus/credentials/issue.md index 796f196256..f74c6f4d97 100644 --- a/docs/docusaurus/credentials/issue.md +++ b/docs/docusaurus/credentials/issue.md @@ -31,10 +31,9 @@ The protocol consists of the following main parts: 2. The Holder can then retrieve the offer using the [`/issue-credentials/records`](/agent-api/#tag/Issue-Credentials-Protocol/operation/getCredentialRecords) endpoint and accept the offer using the [`/issue-credentials/records/{recordId}/accept-offer`](/agent-api/#tag/Issue-Credentials-Protocol/operation/acceptCredentialOffer) endpoint. 3. The Issuer then uses the [`/issue-credentials/records/{recordId}/issue-credential`](/agent-api/#tag/Issue-Credentials-Protocol/operation/issueCredential) endpoint to issue the credential, which gets sent to the Holder via [DIDComm](/docs/concepts/glossary#didcomm). The Holder receives the credential, and the protocol is complete. -The schema identifier defines the structure and the credential type issued, -while the claims provide specific information about the individual, such as their name or qualifications. +The claims provide specific information about the individual, such as their name or qualifications. -This protocol applies in various real-life scenarios, such as educational credentialing, employment verification, etc. +This protocol is applicable in various real-life scenarios, such as educational credentialing, employment verification, and more. In these scenarios, the Issuer could be a school, an employer, etc., and the Holder could be a student or an employee. The VCs issued during this protocol could represent a diploma, a certificate of employment, etc. @@ -62,8 +61,15 @@ This section describes the Issuer role's available interactions with the PRISM A To start the process, the issuer needs to create a credential offer. To do this, make a `POST` request to the [`/issue-credentials/credential-offers`](/agent-api/#tag/Issue-Credentials-Protocol/operation/createCredentialOffer) endpoint with a JSON payload that includes the following information: -1. `schemaId`: This is an identifier for a schema, which defines the structure and format of the data in a verifiable credential. The schema identifier must be unique and typically a URL or a URN. -2. `claims`: are the data stored in a verifiable credential. Claims get expressed in a key-value format and must conform to the structure and format defined in the schema. Claims contain the data that the issuer attests to, such as name, address, date of birth, etc. +1. `claims`: The data stored in a verifiable credential. Claims get expressed in a key-value format. The claims contain the data that the issuer attests to, such as name, address, date of birth, and so on. +2. `issuingDID`: The DID referring to the issuer to issue this credential from +3. `connectionId`: The unique ID of the connection between the holder and the issuer to offer this credential over. + +:::note + +The issuingDID and connectionId properties come from completing the pre-requisite steps of listed above + +::: Once the request initiates, a new credential record for the issuer gets created with a unique ID. The state of this record is now `OfferPending`. @@ -75,14 +81,14 @@ curl -X 'POST' \ -H 'Content-Type: application/json' \ -H "apiKey: $API_KEY" \ -d '{ - "schemaId": "schema:1234", - "subjectId": "did:prism:subjectIdentifier", "claims": { "firstname": "Alice", "lastname": "Wonderland", "birthdate": "01/01/2000" - } - }' + }, + "issuingDID": "did:prism:9f847f8bbb66c112f71d08ab39930d468ccbfe1e0e1d002be53d46c431212c26", + "connectionId": "9d075518-f97e-4f11-9d10-d7348a7a0fda" + }' ``` ### Sending the Offer to the Holder @@ -192,4 +198,4 @@ stateDiagram-v2 The following diagram shows the end-to-end flow for an issuer to issue a VC to a holder. -![](issue-flow.png) \ No newline at end of file +![](issue-flow.png) diff --git a/infrastructure/shared/apisix/conf/apisix.yaml b/infrastructure/shared/apisix/conf/apisix.yaml index 78848b21e0..7c159eb06b 100644 --- a/infrastructure/shared/apisix/conf/apisix.yaml +++ b/infrastructure/shared/apisix/conf/apisix.yaml @@ -32,6 +32,11 @@ routes: plugins: proxy-rewrite: uri: "/connection-invitations" + - uri: /prism-agent/issue-credentials/* + upstream_id: 4 + plugins: + proxy-rewrite: + regex_uri: ["^/prism-agent/issue-credentials/(.*)?", "/issue-credentials/$1"] - uri: /didcomm upstream_id: 3 plugins: diff --git a/prism-agent/service/api/http/pollux/parameters.yaml b/prism-agent/service/api/http/pollux/parameters.yaml index cc53bcc382..8785ae1d97 100644 --- a/prism-agent/service/api/http/pollux/parameters.yaml +++ b/prism-agent/service/api/http/pollux/parameters.yaml @@ -1,13 +1,5 @@ components: parameters: - issueCredentialRecordIdInPath: - in: path - name: recordId - required: true - description: The unique identifier of the issue credential record. - schema: - type: string - format: uuid presentationRecordIdInPath: in: path name: recordId diff --git a/prism-agent/service/api/http/pollux/schemas.yaml b/prism-agent/service/api/http/pollux/schemas.yaml index 243025625d..d4258411db 100644 --- a/prism-agent/service/api/http/pollux/schemas.yaml +++ b/prism-agent/service/api/http/pollux/schemas.yaml @@ -405,130 +405,6 @@ components: items: $ref: "#/components/schemas/VerificationPolicy" - # Issue Credential Protocol - - IssueCredentialRecordBase: - required: - - claims - properties: - schemaId: - type: string - description: The unique identifier of the schema used for this credential offer. - subjectId: - type: string - description: The identifier (e.g DID) of the subject to which the verifiable credential will be issued. - example: did:prism:subjectofverifiablecredentials - validityPeriod: - type: number - description: The validity period in seconds of the verifiable credential that will be issued. - example: 3600 - claims: - type: object - description: The claims that will be associated with the issued verifiable credential. - additionalProperties: - type: string - automaticIssuance: - description: | - Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. - If set to `false`, a manual approval by the issuer via API call will be required for the VC to be issued. - type: boolean - default: true - - CreateIssueCredentialRecordRequest: - description: A request to create a new "issue credential record". - type: object - allOf: - - $ref: "#/components/schemas/IssueCredentialRecordBase" - - type: object - required: - - issuingDID - - connectionId - properties: - issuingDID: - type: string - description: The issuer DID of the verifiable credential object. - example: did:prism:issuerofverifiablecredentials - connectionId: - type: string - description: The unique identifier of a DIDComm connection that already exists between the issuer and the holder, and that will be used to execute the issue credential protocol. - - AcceptCredentialOfferRequest: - description: A request to accept a credential offer received from an issuer. - type: object - required: - - subjectId - properties: - subjectId: - type: string - description: The short-form subject Prism DID to which the verifiable credential should be issued. - example: did:prism:3bb0505d13fcb04d28a48234edb27b0d4e6d7e18a81e2c1abab58f3bbc21ce6f - - IssueCredentialRecord: - description: An issue credential record that stores the state of the protocol execution. - type: object - allOf: - - $ref: "#/components/schemas/IssueCredentialRecordBase" - - type: object - required: - - recordId - - createdAt - - role - - protocolState - properties: - recordId: - description: The unique identifier of the issue credential record. - type: string - # format: uuid - createdAt: - description: The date and time when the issue credential record was created. - type: string - format: date-time - updatedAt: - description: The date and time when the issue credential record was last updated. - type: string - format: date-time - role: - description: The role played by the Prism agent in the credential issuance flow. - type: string - enum: - - Issuer - - Holder - protocolState: - description: The current state of the issue credential protocol execution. - type: string - enum: - - OfferPending - - OfferSent - - OfferReceived - - RequestPending - - RequestGenerated - - RequestSent - - RequestReceived - - ProblemReportPending - - ProblemReportSent - - ProblemReportReceived - - CredentialPending - - CredentialSent - - CredentialReceived - jwtCredential: - description: The base64-encoded JWT verifiable credential that has been sent by the issuer. - type: string - issuingDID: - type: string - description: Issuer DID of the verifiable credential object. - example: did:prism:issuerofverifiablecredentials - - IssueCredentialRecordPage: - allOf: - - $ref: "../shared/schemas.yaml#/components/schemas/Pagination" - - type: object - required: [contents] - properties: - contents: - type: array - items: - $ref: "#/components/schemas/IssueCredentialRecord" - RevocationStatus: description: Revocation status record properties: diff --git a/prism-agent/service/api/http/prism-agent-openapi-spec.yaml b/prism-agent/service/api/http/prism-agent-openapi-spec.yaml index dee2ee44ff..6b8281a5cd 100644 --- a/prism-agent/service/api/http/prism-agent-openapi-spec.yaml +++ b/prism-agent/service/api/http/prism-agent-openapi-spec.yaml @@ -389,125 +389,19 @@ paths: ## Issue Credential Protocol /issue-credentials/credential-offers: - post: - operationId: createCredentialOffer - tags: ["Issue Credentials Protocol"] - summary: As a credential issuer, create a new credential offer to be sent to a holder. - description: Creates a new credential offer in the database - requestBody: - description: The credential offer object. - required: true - content: - application/json: - schema: - $ref: "./pollux/schemas.yaml#/components/schemas/CreateIssueCredentialRecordRequest" - responses: - "201": - description: The issue credential record was created successfully, and is returned in the response body. - content: - application/json: - schema: - $ref: "./pollux/schemas.yaml#/components/schemas/IssueCredentialRecord" - "422": - description: The issue credential record creation failed. More information on the error can be found in the response body. - content: - application/json: - schema: - $ref: "./shared/schemas.yaml#/components/schemas/ErrorResponse" + $ref: "./tapir-generated.yaml#/paths/~1issue-credentials~1credential-offers" /issue-credentials/records: - get: - operationId: getCredentialRecords - tags: ["Issue Credentials Protocol"] - summary: Gets the list of issue credential records. - description: Get the list of issue credential records paginated - parameters: - - $ref: "./shared/parameters.yaml#/components/parameters/offset" - - $ref: "./shared/parameters.yaml#/components/parameters/limit" - - name: thid - in: query - description: The thid of a DIDComm communication. - required: false - schema: - type: string - responses: - "200": - description: The list of issue credential records. - content: - application/json: - schema: - $ref: "./pollux/schemas.yaml#/components/schemas/IssueCredentialRecordPage" - "500": - $ref: "./shared/responses.yaml#/components/responses/InternalServerError" + $ref: "./tapir-generated.yaml#/paths/~1issue-credentials~1records" /issue-credentials/records/{recordId}: - get: - operationId: getCredentialRecord - tags: ["Issue Credentials Protocol"] - summary: Gets an existing issue credential record by its unique identifier. - description: Gets issue credential records by record id - parameters: - - $ref: "./pollux/parameters.yaml#/components/parameters/issueCredentialRecordIdInPath" - responses: - "200": - description: The issue credential record. - content: - application/json: - schema: - $ref: "./pollux/schemas.yaml#/components/schemas/IssueCredentialRecord" - "404": - $ref: "./shared/responses.yaml#/components/responses/NotFound" - "500": - $ref: "./shared/responses.yaml#/components/responses/InternalServerError" + $ref: "./tapir-generated.yaml#/paths/~1issue-credentials~1records~1{recordId}" /issue-credentials/records/{recordId}/accept-offer: - post: - operationId: acceptCredentialOffer - tags: ["Issue Credentials Protocol"] - summary: As a holder, accepts a credential offer received from an issuer. - description: Accepts a credential offer received from a VC issuer and sends back a credential request. - parameters: - - $ref: "./pollux/parameters.yaml#/components/parameters/issueCredentialRecordIdInPath" - requestBody: - description: The accept credential offer request object. - required: true - content: - application/json: - schema: - $ref: ./pollux/schemas.yaml#/components/schemas/AcceptCredentialOfferRequest - responses: - "200": - description: The issue credential offer was successfully accepted. - content: - application/json: - schema: - $ref: ./pollux/schemas.yaml#/components/schemas/IssueCredentialRecord - "404": - $ref: "./shared/responses.yaml#/components/responses/NotFound" - "500": - $ref: "./shared/responses.yaml#/components/responses/InternalServerError" + $ref: "./tapir-generated.yaml#/paths/~1issue-credentials~1records~1{recordId}~1accept-offer" /issue-credentials/records/{recordId}/issue-credential: - post: - operationId: issueCredential - tags: ["Issue Credentials Protocol"] - summary: As an issuer, issues the verifiable credential related to the specified record. - description: | - Sends credential to a holder (holder DID is specified in credential as subjectDid). - Credential is constructed from the credential records found by credential id. - parameters: - - $ref: ./pollux/parameters.yaml#/components/parameters/issueCredentialRecordIdInPath - responses: - "200": - description: The request was processed successfully and the credential will be issued asynchronously. - content: - application/json: - schema: - $ref: ./pollux/schemas.yaml#/components/schemas/IssueCredentialRecord - "404": - $ref: "./shared/responses.yaml#/components/responses/NotFound" - "500": - $ref: "./shared/responses.yaml#/components/responses/InternalServerError" + $ref: "./tapir-generated.yaml#/paths/~1issue-credentials~1records~1{recordId}~1issue-credential" ## Present proof diff --git a/prism-agent/service/api/http/tapir-generated.yaml b/prism-agent/service/api/http/tapir-generated.yaml index 2b4db8ef36..a5a11380a3 100644 --- a/prism-agent/service/api/http/tapir-generated.yaml +++ b/prism-agent/service/api/http/tapir-generated.yaml @@ -886,6 +886,12 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + "409": + description: Cannot process due to conflict with current state of the resource + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" "422": description: Unable to process the request content: @@ -948,6 +954,219 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + /issue-credentials/credential-offers: + post: + tags: + - Issue Credentials Protocol + summary: + As a credential issuer, create a new credential offer to be sent to + a holder. + description: Creates a new credential offer in the database + operationId: createCredentialOffer + requestBody: + description: The credential offer object. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateIssueCredentialRecordRequest" + required: true + responses: + "201": + description: The issue credential record. + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCredentialRecord" + "400": + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /issue-credentials/records: + get: + tags: + - Issue Credentials Protocol + summary: Gets the list of issue credential records. + description: Get the list of issue credential records paginated + operationId: getCredentialRecords + parameters: + - name: offset + in: query + required: false + schema: + type: integer + format: int32 + - name: limit + in: query + required: false + schema: + type: integer + format: int32 + - name: thid + in: query + description: The thid of a DIDComm communication. + required: false + schema: + type: string + responses: + "200": + description: The list of issue credential records. + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCredentialRecordPage" + "400": + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /issue-credentials/records/{recordId}: + get: + tags: + - Issue Credentials Protocol + summary: Gets an existing issue credential record by its unique identifier. + description: Gets issue credential records by record id + operationId: getCredentialRecord + parameters: + - name: recordId + in: path + description: The unique identifier of the issue credential record. + required: true + schema: + type: string + responses: + "200": + description: The issue credential record. + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCredentialRecord" + "400": + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Resource could not be found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /issue-credentials/records/{recordId}/accept-offer: + post: + tags: + - Issue Credentials Protocol + summary: As a holder, accepts a credential offer received from an issuer. + description: + Accepts a credential offer received from a VC issuer and sends + back a credential request. + operationId: acceptCredentialOffer + parameters: + - name: recordId + in: path + description: The unique identifier of the issue credential record. + required: true + schema: + type: string + requestBody: + description: The accept credential offer request object. + content: + application/json: + schema: + $ref: "#/components/schemas/AcceptCredentialOfferRequest" + required: true + responses: + "200": + description: The issue credential offer was successfully accepted. + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCredentialRecord" + "400": + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Resource could not be found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /issue-credentials/records/{recordId}/issue-credential: + post: + tags: + - Issue Credentials Protocol + summary: + As an issuer, issues the verifiable credential related to the specified + record. + description: + Sends credential to a holder (holder DID is specified in credential + as subjectDid). Credential is constructed from the credential records found + by credential id. + operationId: issueCredential + parameters: + - name: recordId + in: path + description: The unique identifier of the issue credential record. + required: true + schema: + type: string + responses: + "200": + description: + The request was processed successfully and the credential will + be issued asynchronously. + content: + application/json: + schema: + $ref: "#/components/schemas/IssueCredentialRecord" + "400": + description: Invalid request parameters + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Resource could not be found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" components: schemas: AcceptConnectionInvitationRequest: @@ -959,6 +1178,17 @@ components: type: string description: The base64-encoded raw invitation. example: eyJAaWQiOiIzZmE4NWY2NC01NzE3LTQ1NjItYjNmYy0yYzk2M2Y2NmFmYTYiLCJAdHlwZSI6Imh0dHBzOi8vZGlkY29tbS5vcmcvbXktZmFtaWx5LzEuMC9teS1tZXNzYWdlLXR5cGUiLCJkaWQiOiJXZ1d4cXp0ck5vb0c5MlJYdnhTVFd2IiwiaW1hZ2VVcmwiOiJodHRwOi8vMTkyLjE2OC41Ni4xMDEvaW1nL2xvZ28uanBnIiwibGFiZWwiOiJCb2IiLCJyZWNpcGllbnRLZXlzIjpbIkgzQzJBVnZMTXY2Z21NTmFtM3VWQWpacGZrY0pDd0R3blpuNnozd1htcVBWIl0sInJvdXRpbmdLZXlzIjpbIkgzQzJBVnZMTXY2Z21NTmFtM3VWQWpacGZrY0pDd0R3blpuNnozd1htcVBWIl0sInNlcnZpY2VFbmRwb2ludCI6Imh0dHA6Ly8xOTIuMTY4LjU2LjEwMTo4MDIwIn0= + AcceptCredentialOfferRequest: + required: + - subjectId + type: object + properties: + subjectId: + type: string + description: + The short-form subject Prism DID to which the verifiable credential + should be issued. + example: did:prism:3bb0505d13fcb04d28a48234edb27b0d4e6d7e18a81e2c1abab58f3bbc21ce6f ActionType: type: string enum: @@ -1117,6 +1347,40 @@ components: type: string description: A human readable alias for the connection. example: Peter + CreateIssueCredentialRecordRequest: + required: + - claims + - issuingDID + - connectionId + type: object + properties: + validityPeriod: + type: number + description: + The validity period in seconds of the verifiable credential + that will be issued. + format: double + example: 3600.0 + claims: + $ref: "#/components/schemas/Map_String" + automaticIssuance: + type: boolean + description: + Specifies whether or not the credential should be automatically + generated and issued when receiving the `CredentialRequest` from the holder. + If set to `false`, a manual approval by the issuer via API call will be + required for the VC to be issued. + example: true + issuingDID: + type: string + description: The issuer DID of the verifiable credential object. + example: did:prism:issuerofverifiablecredentials + connectionId: + type: string + description: + The unique identifier of a DIDComm connection that already + exists between the issuer and the holder, and that will be used to execute + the issue credential protocol. CreateManagedDIDResponse: required: - longFormDid @@ -1562,6 +1826,115 @@ components: A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. example: The received '{}à!è@!.b}' email does not conform to the email format + IssueCredentialRecord: + required: + - claims + - recordId + - createdAt + - role + - protocolState + type: object + properties: + subjectId: + type: string + description: + The identifier (e.g DID) of the subject to which the verifiable + credential will be issued. + example: did:prism:subjectofverifiablecredentials + validityPeriod: + type: number + description: + The validity period in seconds of the verifiable credential + that will be issued. + format: double + example: 3600.0 + claims: + $ref: "#/components/schemas/Map_String" + automaticIssuance: + type: boolean + description: + Specifies whether or not the credential should be automatically + generated and issued when receiving the `CredentialRequest` from the holder. + If set to `false`, a manual approval by the issuer via API call will be + required for the VC to be issued. + example: true + recordId: + type: string + description: The unique identifier of the issue credential record. + example: 80d612dc-0ded-4ac9-90b4-1b8eabb04545 + createdAt: + type: string + description: The date and time when the issue credential record was created. + format: date-time + example: "2023-05-03T11:10:34.234040+01:00" + updatedAt: + type: string + description: + The date and time when the issue credential record was last + updated. + format: date-time + role: + type: string + description: + The role played by the Prism agent in the credential issuance + flow. + example: Issuer + protocolState: + type: string + description: The current state of the issue credential protocol execution. + example: OfferPending + jwtCredential: + type: string + description: + The base64-encoded JWT verifiable credential that has been + sent by the issuer. + issuingDID: + type: string + description: Issuer DID of the verifiable credential object. + example: did:prism:issuerofverifiablecredentials + IssueCredentialRecordPage: + required: + - self + - kind + - pageOf + type: object + properties: + self: + type: string + description: A string field containing the URL of the current API endpoint + example: /prism-agent/schema-registry/schemas?skip=10&limit=10 + kind: + type: string + description: A string field containing the URL of the current API endpoint + example: /prism-agent/schema-registry/schemas?skip=10&limit=10 + pageOf: + type: string + description: + A string field indicating the type of resource that the contents + field contains + example: /prism-agent/schema-registry/schemas + next: + type: string + description: + An optional string field containing the URL of the next page + of results. If the API response does not contain any more pages, this + field should be set to None. + example: /prism-agent/schema-registry/schemas?skip=20&limit=10 + previous: + type: string + description: + An optional string field containing the URL of the previous + page of results. If the API response is the first page of results, this + field should be set to None. + example: /prism-agent/schema-registry/schemas?skip=0&limit=10 + contents: + type: array + items: + $ref: "#/components/schemas/IssueCredentialRecord" + description: + A sequence of IssueCredentialRecord objects representing the + list of credential records that the API response contains + examples: [] ManagedDID: required: - did @@ -1618,6 +1991,12 @@ components: type: array items: $ref: "#/components/schemas/ManagedDID" + Map_String: + type: object + description: The claims that will be associated with the issued verifiable credential. + example: (firstname,Alice) + additionalProperties: + type: string Proof: required: - type diff --git a/prism-agent/service/server/src/main/resources/application.conf b/prism-agent/service/server/src/main/resources/application.conf index 71fbe88b8d..59857da312 100644 --- a/prism-agent/service/server/src/main/resources/application.conf +++ b/prism-agent/service/server/src/main/resources/application.conf @@ -36,7 +36,7 @@ pollux { database { host = "localhost" host = ${?POLLUX_DB_HOST} - port = 5433 + port = 5432 port = ${?POLLUX_DB_PORT} databaseName = "pollux" databaseName = ${?POLLUX_DB_NAME} @@ -56,7 +56,7 @@ connect { database { host = "localhost" host = ${?CONNECT_DB_HOST} - port = 5433 + port = 5432 port = ${?CONNECT_DB_PORT} databaseName = "connect" databaseName = ${?CONNECT_DB_NAME} @@ -82,7 +82,7 @@ agent { database { host = "localhost" host = ${?AGENT_DB_HOST} - port = 5433 + port = 5432 port = ${?AGENT_DB_PORT} databaseName = "agent" databaseName = ${?AGENT_DB_NAME} @@ -100,7 +100,7 @@ agent { leeway = 0 seconds verifySignature = ${?CREDENTIAL_VERIFY_SIGNATURE} verifyDates = ${?CREDENTIAL_VERIFY_DATES} - leeway = ${?CREDENTIAL_LEEWAY} + leeway = ${?CREDENTIAL_LEEWAY} } presentation { verifySignature = true @@ -110,7 +110,7 @@ agent { verifySignature = ${?PRESENTATION_VERIFY_SIGNATURE} verifyDates = ${?PRESENTATION_VERIFY_DATES} verifyHoldersBinding = ${?PRESENTATION_VERIFY_HOLDER_BINDING} - leeway = ${?PRESENTATION_LEEWAY} + leeway = ${?PRESENTATION_LEEWAY} } } } diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala index 444a98b805..8b651b9b20 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Main.scala @@ -28,6 +28,7 @@ import io.circe.parser.* import io.circe.syntax.* import io.iohk.atala.agent.server.health.HealthInfo import io.iohk.atala.connect.controller.ConnectionControllerImpl +import io.iohk.atala.issue.controller.IssueControllerImpl import io.iohk.atala.castor.controller.DIDControllerImpl import io.iohk.atala.castor.controller.DIDRegistrarControllerImpl @@ -143,7 +144,8 @@ object AgentApp extends ZIOAppDefault { RepoModule.verificationPolicyServiceLayer, ConnectionControllerImpl.layer, DIDControllerImpl.layer, - DIDRegistrarControllerImpl.layer, + IssueControllerImpl.layer, + DIDRegistrarControllerImpl.layer ) } yield app diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala index 0af7a6878a..409eae8c0e 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/Modules.scala @@ -24,6 +24,7 @@ import io.iohk.atala.agent.walletapi.sql.{JdbcDIDNonSecretStorage, JdbcDIDSecret import io.iohk.atala.castor.core.service.{DIDService, DIDServiceImpl} import io.iohk.atala.castor.core.util.DIDOperationValidator import io.iohk.atala.connect.controller.{ConnectionController, ConnectionControllerImpl, ConnectionServerEndpoints} +import io.iohk.atala.issue.controller.{IssueController, IssueControllerImpl, IssueEndpoints, IssueServerEndpoints} import io.iohk.atala.connect.core.model.error.ConnectionServiceError import io.iohk.atala.connect.core.repository.ConnectionRepository import io.iohk.atala.connect.core.service.{ConnectionService, ConnectionServiceImpl} @@ -77,8 +78,7 @@ import io.iohk.atala.castor.controller.{ object Modules { def app(port: Int): RIO[ - DidOps & DidAgent & ManagedDIDService & AppConfig & IssueCredentialsProtocolApi & PresentProofApi & - ActorSystem[Nothing], + DidOps & DidAgent & ManagedDIDService & AppConfig & PresentProofApi & ActorSystem[Nothing], Unit ] = { val httpServerApp = HttpRoutes.routes.flatMap(HttpServer.start(port, _)) @@ -88,17 +88,18 @@ object Modules { lazy val zioApp: RIO[ CredentialSchemaController & VerificationPolicyController & ConnectionController & DIDController & - DIDRegistrarController & AppConfig, + DIDRegistrarController & IssueController & AppConfig, Unit ] = { val zioHttpServerApp = for { allSchemaRegistryEndpoints <- SchemaRegistryServerEndpoints.all allVerificationPolicyEndpoints <- VerificationPolicyServerEndpoints.all allConnectionEndpoints <- ConnectionServerEndpoints.all + allIssueEndpoints <- IssueServerEndpoints.all allDIDEndpoints <- DIDServerEndpoints.all allDIDRegistrarEndpoints <- DIDRegistrarServerEndpoints.all allEndpoints = ZHttpEndpoints.withDocumentations[Task]( - allSchemaRegistryEndpoints ++ allVerificationPolicyEndpoints ++ allConnectionEndpoints ++ allDIDEndpoints ++ allDIDRegistrarEndpoints + allSchemaRegistryEndpoints ++ allVerificationPolicyEndpoints ++ allConnectionEndpoints ++ allDIDEndpoints ++ allDIDRegistrarEndpoints ++ allIssueEndpoints ) appConfig <- ZIO.service[AppConfig] httpServer <- ZHttp4sBlazeServer.start(allEndpoints, port = appConfig.agent.httpEndpoint.http.port) @@ -496,16 +497,6 @@ object GrpcModule { } object HttpModule { - val issueCredentialsProtocolApiLayer: RLayer[ - DidOps & DidAgent & ManagedDIDService & ConnectionService & AppConfig & JwtDidResolver, - IssueCredentialsProtocolApi - ] = { - val serviceLayer = AppModule.credentialServiceLayer - val apiServiceLayer = serviceLayer >>> IssueCredentialsProtocolApiServiceImpl.layer - val apiMarshallerLayer = IssueCredentialsProtocolApiMarshallerImpl.layer - (apiServiceLayer ++ apiMarshallerLayer) >>> ZLayer.fromFunction(new IssueCredentialsProtocolApi(_, _)) - } - val presentProofProtocolApiLayer: RLayer[DidOps & DidAgent, PresentProofApi] = { val serviceLayer = AppModule.presentationServiceLayer ++ AppModule.connectionServiceLayer val apiServiceLayer = serviceLayer >>> PresentProofApiServiceImpl.layer @@ -513,7 +504,7 @@ object HttpModule { (apiServiceLayer ++ apiMarshallerLayer) >>> ZLayer.fromFunction(new PresentProofApi(_, _)) } - val layers = issueCredentialsProtocolApiLayer ++ presentProofProtocolApiLayer + val layers = presentProofProtocolApiLayer } object RepoModule { diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/HttpRoutes.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/HttpRoutes.scala index aa7284afdd..8a23537024 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/HttpRoutes.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/HttpRoutes.scala @@ -3,20 +3,18 @@ package io.iohk.atala.agent.server.http import akka.http.scaladsl.model.ContentType import akka.http.scaladsl.server.Directives.* import akka.http.scaladsl.server.Route -import io.iohk.atala.agent.openapi.api.{DIDRegistrarApi, IssueCredentialsProtocolApi, PresentProofApi} +import io.iohk.atala.agent.openapi.api.PresentProofApi import zio.* object HttpRoutes { def routes: URIO[ - IssueCredentialsProtocolApi & PresentProofApi, + PresentProofApi, Route ] = for { - issueCredentialsProtocolApi <- ZIO.service[IssueCredentialsProtocolApi] presentProofApi <- ZIO.service[PresentProofApi] - } yield issueCredentialsProtocolApi.route ~ - presentProofApi.route ~ + } yield presentProofApi.route ~ additionalRoute private def additionalRoute: Route = { diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/IssueCredentialsProtocolApiMarshallerImpl.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/IssueCredentialsProtocolApiMarshallerImpl.scala deleted file mode 100644 index eaa2cc5a56..0000000000 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/IssueCredentialsProtocolApiMarshallerImpl.scala +++ /dev/null @@ -1,34 +0,0 @@ -package io.iohk.atala.agent.server.http.marshaller - -import zio.* -import io.iohk.atala.agent.openapi.api.IssueCredentialsProtocolApiMarshaller -import spray.json.RootJsonFormat -import io.iohk.atala.pollux.core.service.CredentialService -import akka.http.scaladsl.marshalling.ToEntityMarshaller -import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller -import io.iohk.atala.agent.openapi.model.* - -object IssueCredentialsProtocolApiMarshallerImpl extends JsonSupport { - val layer: ULayer[IssueCredentialsProtocolApiMarshaller] = ZLayer.succeed { - new IssueCredentialsProtocolApiMarshaller { - - implicit def fromEntityUnmarshallerCreateIssueCredentialRecordRequest - : FromEntityUnmarshaller[CreateIssueCredentialRecordRequest] = - summon[RootJsonFormat[CreateIssueCredentialRecordRequest]] - - implicit def fromEntityUnmarshallerAcceptCredentialOfferRequest - : FromEntityUnmarshaller[AcceptCredentialOfferRequest] = - summon[RootJsonFormat[AcceptCredentialOfferRequest]] - - implicit def toEntityMarshallerIssueCredentialRecord: ToEntityMarshaller[IssueCredentialRecord] = - summon[RootJsonFormat[IssueCredentialRecord]] - - implicit def toEntityMarshallerIssueCredentialRecordPage: ToEntityMarshaller[IssueCredentialRecordPage] = - summon[RootJsonFormat[IssueCredentialRecordPage]] - - implicit def toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] = - summon[RootJsonFormat[ErrorResponse]] - - } - } -} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/JsonSupport.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/JsonSupport.scala index 45eede8d77..42ade64154 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/JsonSupport.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/marshaller/JsonSupport.scala @@ -41,11 +41,6 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol { } } } - // Issue - given RootJsonFormat[CreateIssueCredentialRecordRequest] = jsonFormat7(CreateIssueCredentialRecordRequest.apply) - given RootJsonFormat[AcceptCredentialOfferRequest] = jsonFormat1(AcceptCredentialOfferRequest.apply) - given RootJsonFormat[IssueCredentialRecord] = jsonFormat12(IssueCredentialRecord.apply) - given RootJsonFormat[IssueCredentialRecordPage] = jsonFormat6(IssueCredentialRecordPage.apply) // Presentation given RootJsonFormat[Options] = jsonFormat2(Options.apply) diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/model/OASDomainModelHelper.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/model/OASDomainModelHelper.scala index 56b52d4416..ccb33eccc8 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/model/OASDomainModelHelper.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/model/OASDomainModelHelper.scala @@ -42,27 +42,6 @@ trait OASDomainModelHelper { } } - extension (domain: polluxdomain.IssueCredentialRecord) { - def toOAS: IssueCredentialRecord = IssueCredentialRecord( - recordId = domain.id.value, - createdAt = domain.createdAt.atOffset(ZoneOffset.UTC), - updatedAt = domain.updatedAt.map(_.atOffset(ZoneOffset.UTC)), - role = domain.role.toString, - subjectId = domain.subjectId, - claims = domain.offerCredentialData - .map(offer => offer.body.credential_preview.attributes.map(attr => (attr.name -> attr.value)).toMap) - .getOrElse(Map.empty), - schemaId = domain.schemaId, - validityPeriod = domain.validityPeriod, - automaticIssuance = domain.automaticIssuance, - protocolState = domain.protocolState.toString(), - jwtCredential = domain.issueCredentialData.flatMap(issueCredential => { - issueCredential.attachments.collectFirst { case AttachmentDescriptor(_, _, Base64(jwt), _, _, _, _) => - jwt - } - }) - ) - } extension (domain: polluxdomain.PresentationRecord) { def toOAS: PresentationStatus = { val connectionId = domain.connectionId diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/service/IssueCredentialsProtocolApiServiceImpl.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/service/IssueCredentialsProtocolApiServiceImpl.scala deleted file mode 100644 index fda3da604a..0000000000 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/service/IssueCredentialsProtocolApiServiceImpl.scala +++ /dev/null @@ -1,233 +0,0 @@ -package io.iohk.atala.agent.server.http.service - -import akka.http.scaladsl.marshalling.ToEntityMarshaller -import akka.http.scaladsl.server.Directives.* -import akka.http.scaladsl.server.Route -import io.iohk.atala.agent.openapi.api.IssueCredentialsProtocolApiService -import io.iohk.atala.agent.openapi.model.* -import io.iohk.atala.agent.server.http.model.HttpServiceError -import io.iohk.atala.agent.server.http.model.HttpServiceError.InvalidPayload -import io.iohk.atala.agent.server.http.model.OASDomainModelHelper -import io.iohk.atala.agent.server.http.model.OASErrorModelHelper -import io.iohk.atala.pollux.core.model.DidCommID -import io.iohk.atala.pollux.core.model.error.CredentialServiceError -import io.iohk.atala.pollux.core.service.CredentialService -import zio.* - -import java.util.UUID -import scala.util.Try -import io.iohk.atala.agent.walletapi.service.ManagedDIDService -import io.iohk.atala.agent.server.config.AgentConfig -import io.iohk.atala.agent.server.config.AppConfig -import io.iohk.atala.mercury.model.DidId -import io.iohk.atala.connect.core.service.ConnectionService -import io.iohk.atala.connect.core.model.error.ConnectionServiceError -import io.iohk.atala.connect.core.model.ConnectionRecord -import io.iohk.atala.connect.core.model.ConnectionRecord.Role -import io.iohk.atala.connect.core.model.ConnectionRecord.ProtocolState -import io.iohk.atala.castor.core.model.did.PrismDID - -class IssueCredentialsProtocolApiServiceImpl( - credentialService: CredentialService, - managedDIDService: ManagedDIDService, - connectionService: ConnectionService, - agentConfig: AgentConfig -)(using runtime: zio.Runtime[Any]) - extends IssueCredentialsProtocolApiService, - AkkaZioSupport, - OASDomainModelHelper, - OASErrorModelHelper { - - private[this] case class DidIdPair(myDID: DidId, theirDid: DidId) - - override def createCredentialOffer(request: CreateIssueCredentialRecordRequest)(implicit - toEntityMarshallerCreateIssueCredentialRecordResponse: ToEntityMarshaller[IssueCredentialRecord], - toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] - ): Route = { - val result = for { - didIdPair <- getPairwiseDIDs(request.connectionId) - issuingDID <- ZIO - .fromEither(PrismDID.fromString(request.issuingDID)) - .mapError(HttpServiceError.InvalidPayload.apply) - .mapError(_.toOAS) - outcome <- credentialService - .createIssueCredentialRecord( - pairwiseIssuerDID = didIdPair.myDID, - pairwiseHolderDID = didIdPair.theirDid, - thid = DidCommID(), - schemaId = request.schemaId, - claims = request.claims, - validityPeriod = request.validityPeriod, - automaticIssuance = request.automaticIssuance.orElse(Some(true)), - awaitConfirmation = Some(false), - issuingDID = Some(issuingDID.asCanonical) - ) - .mapError(HttpServiceError.DomainError[CredentialServiceError].apply) - .mapError(_.toOAS) - } yield outcome - - onZioSuccess(result.map(_.toOAS).either) { - case Left(error) => complete(error.status -> error) - case Right(result) => createCredentialOffer201(result) - } - } - - override def getCredentialRecords( - offset: Option[Int], - limit: Option[Int], - thid: Option[String], - )(implicit - toEntityMarshallerIssueCredentialRecordCollection: ToEntityMarshaller[IssueCredentialRecordPage], - toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] - ): Route = { - val result = for { - records <- credentialService - .getIssueCredentialRecords() - .mapError(HttpServiceError.DomainError[CredentialServiceError].apply) - outcome = thid match - case None => records - case Some(value) => records.filter(_.thid.value == value) // this logic should be moved to the DB - } yield outcome - - onZioSuccess(result.mapBoth(_.toOAS, _.map(_.toOAS)).either) { - case Left(error) => complete(error.status -> error) - case Right(result) => - getCredentialRecords200( - IssueCredentialRecordPage( - self = "/issue-credentials/records", - kind = "Collection", - pageOf = "1", - next = None, - previous = None, - contents = result - ) - ) - } - } - - override def getCredentialRecord(recordId: String)(implicit - toEntityMarshallerIssueCredentialRecord: ToEntityMarshaller[IssueCredentialRecord], - toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] - ): Route = { - val result = for { - id <- recordId.toDidCommID - outcome <- credentialService - .getIssueCredentialRecord(id) - .mapError(HttpServiceError.DomainError[CredentialServiceError].apply) - } yield outcome - - onZioSuccess(result.mapBoth(_.toOAS, _.map(_.toOAS)).either) { - case Left(error) => complete(error.status -> error) - case Right(Some(result)) => getCredentialRecord200(result) - case Right(None) => getCredentialRecord404(notFoundErrorResponse(Some("Issue credential record not found"))) - } - } - - override def acceptCredentialOffer(recordId: String, request: AcceptCredentialOfferRequest)(implicit - toEntityMarshallerIssueCredentialRecord: ToEntityMarshaller[IssueCredentialRecord], - toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] - ): Route = { - val result = for { - id <- recordId.toDidCommID - prismDID <- ZIO.fromEither(PrismDID.fromString(request.subjectId)).mapError(HttpServiceError.InvalidPayload.apply) - outcome <- credentialService - .acceptCredentialOffer(id, request.subjectId) - .mapError(HttpServiceError.DomainError[CredentialServiceError].apply) - } yield outcome - - onZioSuccess(result.mapBoth(_.toOAS, _.toOAS).either) { - case Left(error) => complete(error.status -> error) - case Right(result) => acceptCredentialOffer200(result) - // case Right(None) => getCredentialRecord404(notFoundErrorResponse(Some("Issue credential record not found"))) // TODO this is now Left - } - } - - override def issueCredential(recordId: String)(implicit - toEntityMarshallerIssueCredentialRecord: ToEntityMarshaller[IssueCredentialRecord], - toEntityMarshallerErrorResponse: ToEntityMarshaller[ErrorResponse] - ): Route = { - val result = for { - id <- recordId.toDidCommID - outcome <- credentialService - .acceptCredentialRequest(id) - .mapError(HttpServiceError.DomainError[CredentialServiceError].apply) - } yield outcome - - onZioSuccess(result.mapBoth(_.toOAS, _.toOAS).either) { - case Left(error) => complete(error.status -> error) - case Right(result) => issueCredential200(result) - // case Right(None) => getCredentialRecord404(notFoundErrorResponse(Some("Issue credential record not found"))) // TODO this is now Left - } - } - - private[this] def getPairwiseDIDs(connectionId: String): ZIO[Any, ErrorResponse, DidIdPair] = { - for { - maybeConnection <- connectionService - .getConnectionRecord(UUID.fromString(connectionId)) - .mapError(HttpServiceError.DomainError[ConnectionServiceError].apply) - .mapError(_.toOAS) - connection <- ZIO - .fromOption(maybeConnection) - .mapError(_ => notFoundErrorResponse(Some("Connection not found"))) - connectionResponse <- ZIO - .fromOption(connection.connectionResponse) - .mapError(_ => notFoundErrorResponse(Some("ConnectionResponse not found in record"))) - didIdPair <- connection match - case ConnectionRecord( - _, - _, - _, - _, - _, - Role.Inviter, - ProtocolState.ConnectionResponseSent, - _, - _, - Some(resp), - _, // metaRetries: Int, - _, // metaLastFailure: Option[String] - ) => - ZIO.succeed(DidIdPair(resp.from, resp.to)) - case ConnectionRecord( - _, - _, - _, - _, - _, - Role.Invitee, - ProtocolState.ConnectionResponseReceived, - _, - _, - Some(resp), - _, // metaRetries: Int, - _, // metaLastFailure: Option[String] - ) => - ZIO.succeed(DidIdPair(resp.to, resp.from)) - case _ => - ZIO.fail(badRequestErrorResponse(Some("Invalid connection record state for operation"))) - } yield didIdPair - - } - -} - -object IssueCredentialsProtocolApiServiceImpl { - val layer: URLayer[ - CredentialService & ManagedDIDService & ConnectionService & AppConfig, - IssueCredentialsProtocolApiService - ] = - ZLayer.fromZIO { - for { - rt <- ZIO.runtime[Any] - credentialService <- ZIO.service[CredentialService] - managedDIDService <- ZIO.service[ManagedDIDService] - connectionService <- ZIO.service[ConnectionService] - appConfig <- ZIO.service[AppConfig] - } yield IssueCredentialsProtocolApiServiceImpl( - credentialService, - managedDIDService, - connectionService, - appConfig.agent - )(using rt) - } -} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionController.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionController.scala index 43af50779a..55a9ff09a6 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionController.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionController.scala @@ -33,7 +33,7 @@ trait ConnectionController { } object ConnectionController { - def toHttpError(error: ConnectionServiceError) = + def toHttpError(error: ConnectionServiceError): ErrorResponse = error match case ConnectionServiceError.RepositoryError(cause) => ErrorResponse.internalServerError(title = "RepositoryError", detail = Some(cause.toString)) diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/http/Connection.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/http/Connection.scala index 36f9ed0763..ace4775662 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/http/Connection.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/http/Connection.scala @@ -49,8 +49,8 @@ case class Connection( @encodedExample(annotations.kind.example) kind: String = "Connection" ) { - def withBaseUri(base: Uri) = withSelf(base.addPath(connectionId.toString).toString) - def withSelf(self: String) = copy(self = self) + def withBaseUri(base: Uri): Connection = withSelf(base.addPath(connectionId.toString).toString) + def withSelf(self: String): Connection = copy(self = self) } object Connection { diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueController.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueController.scala new file mode 100644 index 0000000000..2664912608 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueController.scala @@ -0,0 +1,58 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.api.http.model.PaginationInput +import io.iohk.atala.issue.controller.http.{ + AcceptCredentialOfferRequest, + CreateIssueCredentialRecordRequest, + IssueCredentialRecord, + IssueCredentialRecordPage +} +import io.iohk.atala.pollux.core.model.error.CredentialServiceError +import zio.{IO, ZIO} + +trait IssueController { + def createCredentialOffer(request: CreateIssueCredentialRecordRequest)(implicit + rc: RequestContext + ): IO[ErrorResponse, IssueCredentialRecord] + + def getCredentialRecords(paginationInput: PaginationInput, thid: Option[String])(implicit + rc: RequestContext + ): IO[ErrorResponse, IssueCredentialRecordPage] + + def getCredentialRecord(recordId: String)(implicit rc: RequestContext): IO[ErrorResponse, IssueCredentialRecord] + + def acceptCredentialOffer(recordId: String, request: AcceptCredentialOfferRequest)(implicit + rc: RequestContext + ): IO[ErrorResponse, IssueCredentialRecord] + + def issueCredential(recordId: String)(implicit rc: RequestContext): IO[ErrorResponse, IssueCredentialRecord] + +} + +object IssueController { + def toHttpError(error: CredentialServiceError): ErrorResponse = + error match + case CredentialServiceError.RepositoryError(cause) => + ErrorResponse.internalServerError(title = "RepositoryError", detail = Some(cause.toString)) + case CredentialServiceError.RecordIdNotFound(recordId) => + ErrorResponse.notFound(detail = Some(s"Record Id not found: $recordId")) + case CredentialServiceError.OperationNotExecuted(recordId, info) => + ErrorResponse.internalServerError(title = "Operation Not Executed", detail = Some(s"${recordId}-${info}")) + case CredentialServiceError.ThreadIdNotFound(thid) => + ErrorResponse.notFound(detail = Some(s"Thread Id not found: $thid")) + case CredentialServiceError.UnexpectedError(msg) => + ErrorResponse.internalServerError(detail = Some(msg)) + case CredentialServiceError.InvalidFlowStateError(msg) => + ErrorResponse.badRequest(title = "InvalidFlowState", detail = Some(msg)) + case CredentialServiceError.UnsupportedDidFormat(did) => + ErrorResponse.badRequest("Unsupported DID format", Some(s"The following DID is not supported: ${did}")) + case CredentialServiceError.CreateCredentialPayloadFromRecordError(msg) => + ErrorResponse.badRequest(title = "Create Credential Payload From Record Error", detail = Some(msg.getMessage)) + case CredentialServiceError.CredentialRequestValidationError(msg) => + ErrorResponse.badRequest(title = "Create Request Validation Error", detail = Some(msg)) + case CredentialServiceError.CredentialIdNotDefined(msg) => + ErrorResponse.badRequest(title = "Credential ID not defined one request", detail = Some(msg.toString)) + case CredentialServiceError.IrisError(msg) => + ErrorResponse.internalServerError(title = "VDR Error", detail = Some(msg.toString)) +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueControllerImpl.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueControllerImpl.scala new file mode 100644 index 0000000000..33cbb916f8 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueControllerImpl.scala @@ -0,0 +1,178 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.agent.server.config.AppConfig +import io.iohk.atala.agent.server.http.model.HttpServiceError +import io.iohk.atala.agent.server.http.model.HttpServiceError.InvalidPayload +import io.iohk.atala.agent.walletapi.service.ManagedDIDService +import io.iohk.atala.api.http.model.{Pagination, PaginationInput} +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.castor.core.model.did.PrismDID +import io.iohk.atala.connect.controller.ConnectionController +import io.iohk.atala.connect.core.model.ConnectionRecord +import io.iohk.atala.connect.core.model.ConnectionRecord.{ProtocolState, Role} +import io.iohk.atala.connect.core.model.error.ConnectionServiceError +import io.iohk.atala.connect.core.service.ConnectionService +import io.iohk.atala.issue.controller.IssueController.toHttpError +import io.iohk.atala.issue.controller.http.{ + AcceptCredentialOfferRequest, + CreateIssueCredentialRecordRequest, + IssueCredentialRecord, + IssueCredentialRecordPage +} +import io.iohk.atala.mercury.model.DidId +import io.iohk.atala.pollux.core.service.CredentialService +import io.iohk.atala.pollux.core.model.DidCommID +import io.iohk.atala.pollux.core.model.error.CredentialServiceError +import zio.{IO, URLayer, ZIO, ZLayer} + +import java.util.UUID +import scala.util.Try + +class IssueControllerImpl( + credentialService: CredentialService, + connectionService: ConnectionService, + appConfig: AppConfig +) extends IssueController { + override def createCredentialOffer( + request: CreateIssueCredentialRecordRequest + )(implicit rc: RequestContext): IO[ErrorResponse, IssueCredentialRecord] = { + val result: IO[ConnectionServiceError | CredentialServiceError | InvalidPayload, IssueCredentialRecord] = for { + didIdPair <- getPairwiseDIDs(request.connectionId) + issuingDID <- extractPrismDIDFromString(request.issuingDID) + outcome <- credentialService + .createIssueCredentialRecord( + pairwiseIssuerDID = didIdPair.myDID, + pairwiseHolderDID = didIdPair.theirDid, + thid = DidCommID(), + schemaId = None, + claims = request.claims, + validityPeriod = request.validityPeriod, + automaticIssuance = request.automaticIssuance.orElse(Some(true)), + awaitConfirmation = Some(false), + issuingDID = Some(issuingDID.asCanonical) + ) + } yield IssueCredentialRecord.fromDomain(outcome) + mapIssueErrors(result) + } + + // TODO - Tech Debt - Do not filter this in memory - need to filter at the database level + // TODO - Tech Debt - Implement pagination + override def getCredentialRecords(paginationInput: PaginationInput, thid: Option[String])(implicit + rc: RequestContext + ): IO[ErrorResponse, IssueCredentialRecordPage] = { + val result = for { + records <- credentialService.getIssueCredentialRecords() + outcome = thid match + case None => records + case Some(value) => records.filter(_.thid.value == value) // this logic should be moved to the DB + } yield IssueCredentialRecordPage( + self = "/issue-credentials/records", + kind = "Collection", + pageOf = "1", + next = None, + previous = None, + contents = (outcome map IssueCredentialRecord.fromDomain) // TODO - Tech Debt - Optimise this transformation - each time we get a list of things we iterate it once here + ) + mapIssueErrors(result) + } + + override def getCredentialRecord( + recordId: String + )(implicit rc: RequestContext): IO[ErrorResponse, IssueCredentialRecord] = { + val result: IO[CredentialServiceError | InvalidPayload, Option[IssueCredentialRecord]] = for { + id <- extractDidCommIdFromString(recordId) + outcome <- credentialService.getIssueCredentialRecord(id) + } yield (outcome map IssueCredentialRecord.fromDomain) + mapIssueErrors(result) someOrFail toHttpError( + CredentialServiceError.RecordIdNotFound(DidCommID(recordId)) + ) // TODO - Tech Debt - Review if this is safe. Currently is because DidCommID is opaque type => string with no validation + } + + override def acceptCredentialOffer(recordId: String, request: AcceptCredentialOfferRequest)(implicit + rc: RequestContext + ): IO[ErrorResponse, IssueCredentialRecord] = { + val result: IO[CredentialServiceError | InvalidPayload, IssueCredentialRecord] = for { + id <- extractDidCommIdFromString(recordId) + outcome <- credentialService.acceptCredentialOffer(id, request.subjectId) + } yield IssueCredentialRecord.fromDomain(outcome) + mapIssueErrors(result) + } + + override def issueCredential( + recordId: String + )(implicit rc: RequestContext): IO[ErrorResponse, IssueCredentialRecord] = { + val result: IO[InvalidPayload | CredentialServiceError, IssueCredentialRecord] = for { + id <- extractDidCommIdFromString(recordId) + outcome <- credentialService.acceptCredentialRequest(id) + } yield IssueCredentialRecord.fromDomain(outcome) + mapIssueErrors(result) + } + + private def mapIssueErrors[T]( + result: IO[CredentialServiceError | ConnectionServiceError | InvalidPayload, T] + ): IO[ErrorResponse, T] = { + result mapError { + case invalidPayload: InvalidPayload => + ErrorResponse( + status = 422, + `type` = "InvalidPayload", + title = "error-title", + detail = Some(invalidPayload.msg), + instance = "error-instance" + ) + case connError: ConnectionServiceError => + ConnectionController.toHttpError(connError) + case credError: CredentialServiceError => + toHttpError(credError) + } + } + + private[this] case class DidIdPair(myDID: DidId, theirDid: DidId) + + private[this] def extractDidCommIdFromString( + maybeDidCommId: String + ): IO[InvalidPayload, io.iohk.atala.pollux.core.model.DidCommID] = { + ZIO + .fromTry(Try(io.iohk.atala.pollux.core.model.DidCommID(maybeDidCommId))) + .mapError(e => HttpServiceError.InvalidPayload(s"Error parsing string as DidCommID: ${e.getMessage}")) + } + + private[this] def extractPrismDIDFromString(maybeDid: String): IO[InvalidPayload, PrismDID] = { + ZIO + .fromEither(PrismDID.fromString(maybeDid)) + .mapError(e => HttpServiceError.InvalidPayload(s"Error parsing string as PrismDID: ${e}")) + } + + private[this] def extractDidIdPairFromValidConnection(connRecord: ConnectionRecord): Option[DidIdPair] = { + (connRecord.protocolState, connRecord.connectionResponse, connRecord.role) match { + case (ProtocolState.ConnectionResponseReceived, Some(resp), Role.Invitee) => + // If Invitee, myDid is the target + Some(DidIdPair(resp.to, resp.from)) + case (ProtocolState.ConnectionResponseSent, Some(resp), Role.Inviter) => + // If Inviter, myDid is the source + Some(DidIdPair(resp.from, resp.to)) + case _ => None + } + } + + private[this] def getPairwiseDIDs(connectionId: String): IO[ConnectionServiceError, DidIdPair] = { + val lookupId = UUID.fromString(connectionId) + for { + maybeConnection <- connectionService.getConnectionRecord(lookupId) + didIdPair <- maybeConnection match + case Some(connRecord: ConnectionRecord) => + extractDidIdPairFromValidConnection(connRecord) match { + case Some(didIdPair: DidIdPair) => ZIO.succeed(didIdPair) + case None => + ZIO.fail(ConnectionServiceError.UnexpectedError("Invalid connection record state for operation")) + } + case _ => ZIO.fail(ConnectionServiceError.RecordIdNotFound(lookupId)) + } yield didIdPair + } + +} + +object IssueControllerImpl { + val layer: URLayer[CredentialService & ConnectionService & AppConfig, IssueController] = + ZLayer.fromFunction(IssueControllerImpl(_, _, _)) +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueEndpoints.scala new file mode 100644 index 0000000000..8807254435 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueEndpoints.scala @@ -0,0 +1,138 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.api.http.EndpointOutputs.* +import io.iohk.atala.api.http.model.PaginationInput +import io.iohk.atala.api.http.{ErrorResponse, RequestContext} +import io.iohk.atala.connect.controller.http.{Connection, CreateConnectionRequest} +import io.iohk.atala.issue.controller.http.* +import sttp.model.StatusCode +import sttp.tapir.json.zio.jsonBody +import sttp.tapir.{ + Endpoint, + EndpointInfo, + EndpointInput, + PublicEndpoint, + endpoint, + extractFromRequest, + oneOf, + oneOfDefaultVariant, + oneOfVariant, + path, + query, + statusCode, + stringToPath +} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder} + +import java.util.UUID + +object IssueEndpoints { + + private val paginationInput: EndpointInput[PaginationInput] = EndpointInput.derived[PaginationInput] + + val createCredentialOffer: PublicEndpoint[ + (RequestContext, CreateIssueCredentialRecordRequest), + ErrorResponse, + IssueCredentialRecord, + Any + ] = + endpoint.post + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in("issue-credentials" / "credential-offers") + .in(jsonBody[CreateIssueCredentialRecordRequest].description("The credential offer object.")) + .errorOut(basicFailures) + .out(statusCode(StatusCode.Created)) + .out(jsonBody[IssueCredentialRecord].description("The issue credential record.")) + .tag("Issue Credentials Protocol") + .summary("As a credential issuer, create a new credential offer to be sent to a holder.") + .description("Creates a new credential offer in the database") + .name("createCredentialOffer") + + val getCredentialRecords: PublicEndpoint[ + (RequestContext, PaginationInput, Option[String]), + ErrorResponse, + IssueCredentialRecordPage, + Any + ] = + endpoint.get + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in("issue-credentials" / "records") + .in(paginationInput) + .in(query[Option[String]]("thid").description("The thid of a DIDComm communication.")) + .errorOut(basicFailures) + .out(jsonBody[IssueCredentialRecordPage].description("The list of issue credential records.")) + .tag("Issue Credentials Protocol") + .summary("Gets the list of issue credential records.") + .description("Get the list of issue credential records paginated") + .name("getCredentialRecords") + + val getCredentialRecord: PublicEndpoint[ + (RequestContext, String), + ErrorResponse, + IssueCredentialRecord, + Any + ] = + endpoint.get + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in( + "issue-credentials" / "records" / path[String]("recordId").description( + "The unique identifier of the issue credential record." + ) + ) + .errorOut(basicFailuresAndNotFound) + .out(jsonBody[IssueCredentialRecord].description("The issue credential record.")) + .tag("Issue Credentials Protocol") + .summary("Gets an existing issue credential record by its unique identifier.") + .description("Gets issue credential records by record id") + .name("getCredentialRecord") + + val acceptCredentialOffer: PublicEndpoint[ + (RequestContext, String, AcceptCredentialOfferRequest), + ErrorResponse, + IssueCredentialRecord, + Any + ] = + endpoint.post + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in( + "issue-credentials" / "records" / path[String]("recordId").description( + "The unique identifier of the issue credential record." + ) + ) + .in("accept-offer") + .in(jsonBody[AcceptCredentialOfferRequest].description("The accept credential offer request object.")) + .errorOut(basicFailuresAndNotFound) + .out(jsonBody[IssueCredentialRecord].description("The issue credential offer was successfully accepted.")) + .tag("Issue Credentials Protocol") + .summary("As a holder, accepts a credential offer received from an issuer.") + .description("Accepts a credential offer received from a VC issuer and sends back a credential request.") + .name("acceptCredentialOffer") + + val issueCredential: PublicEndpoint[ + (RequestContext, String), + ErrorResponse, + IssueCredentialRecord, + Any + ] = + endpoint.post + .in(extractFromRequest[RequestContext](RequestContext.apply)) + .in( + "issue-credentials" / "records" / path[String]("recordId").description( + "The unique identifier of the issue credential record." + ) + ) + .in("issue-credential") + .errorOut(basicFailuresAndNotFound) + .out( + jsonBody[IssueCredentialRecord].description( + "The request was processed successfully and the credential will be issued asynchronously." + ) + ) + .tag("Issue Credentials Protocol") + .summary("As an issuer, issues the verifiable credential related to the specified record.") + .description( + "Sends credential to a holder (holder DID is specified in credential as subjectDid). Credential is constructed from the credential records found by credential id." + ) + .name("issueCredential") + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueServerEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueServerEndpoints.scala new file mode 100644 index 0000000000..938ece10e6 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/IssueServerEndpoints.scala @@ -0,0 +1,56 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.api.http.RequestContext +import io.iohk.atala.api.http.model.PaginationInput +import io.iohk.atala.issue.controller.IssueEndpoints.* +import io.iohk.atala.issue.controller.http.{AcceptCredentialOfferRequest, CreateIssueCredentialRecordRequest} +import sttp.tapir.ztapir.* +import zio.{URIO, ZIO} + +class IssueServerEndpoints(issueController: IssueController) { + + val createCredentialOfferEndpoint: ZServerEndpoint[Any, Any] = + createCredentialOffer.zServerLogic { case (ctx: RequestContext, request: CreateIssueCredentialRecordRequest) => + issueController.createCredentialOffer(request)(ctx) + } + + val getCredentialRecordsEndpoint: ZServerEndpoint[Any, Any] = + getCredentialRecords.zServerLogic { + case (ctx: RequestContext, paginationInput: PaginationInput, thid: Option[String]) => + issueController.getCredentialRecords(paginationInput, thid)(ctx) + } + + val getCredentialRecordEndpoint: ZServerEndpoint[Any, Any] = + getCredentialRecord.zServerLogic { case (ctx: RequestContext, recordId: String) => + issueController.getCredentialRecord(recordId)(ctx) + } + + val acceptCredentialOfferEndpoint: ZServerEndpoint[Any, Any] = + acceptCredentialOffer.zServerLogic { + case (ctx: RequestContext, recordId: String, request: AcceptCredentialOfferRequest) => + issueController.acceptCredentialOffer(recordId, request)(ctx) + } + + val issueCredentialEndpoint: ZServerEndpoint[Any, Any] = + issueCredential.zServerLogic { case (ctx: RequestContext, recordId: String) => + issueController.issueCredential(recordId)(ctx) + } + + val all: List[ZServerEndpoint[Any, Any]] = List( + createCredentialOfferEndpoint, + getCredentialRecordsEndpoint, + getCredentialRecordEndpoint, + acceptCredentialOfferEndpoint, + issueCredentialEndpoint + ) + +} + +object IssueServerEndpoints { + def all: URIO[IssueController, List[ZServerEndpoint[Any, Any]]] = { + for { + issueController <- ZIO.service[IssueController] + issueEndpoints = new IssueServerEndpoints(issueController) + } yield issueEndpoints.all + } +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/AcceptCredentialOfferRequest.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/AcceptCredentialOfferRequest.scala new file mode 100644 index 0000000000..a28b895b72 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/AcceptCredentialOfferRequest.scala @@ -0,0 +1,41 @@ +package io.iohk.atala.issue.controller.http + +import io.iohk.atala.api.http.Annotation +import io.iohk.atala.issue.controller.http.AcceptCredentialOfferRequest.annotations +import sttp.tapir.Schema.annotations.{description, encodedExample} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} +import sttp.tapir.{Schema, Validator} +import sttp.tapir.Schema.annotations.{description, encodedExample, validate} + +/** A request to accept a credential offer received from an issuer. + * + * @param subjectId + * The short-form subject Prism DID to which the verifiable credential should be issued. for example: + * ''did:prism:3bb0505d13fcb04d28a48234edb27b0d4e6d7e18a81e2c1abab58f3bbc21ce6f'' + */ +final case class AcceptCredentialOfferRequest( + @description(annotations.subjectId.description) + @encodedExample(annotations.subjectId.example) + subjectId: String +) + +object AcceptCredentialOfferRequest { + + object annotations { + object subjectId + extends Annotation[String]( + description = "The short-form subject Prism DID to which the verifiable credential should be issued.", + example = "did:prism:3bb0505d13fcb04d28a48234edb27b0d4e6d7e18a81e2c1abab58f3bbc21ce6f" + ) + + } + + given encoder: JsonEncoder[AcceptCredentialOfferRequest] = + DeriveJsonEncoder.gen[AcceptCredentialOfferRequest] + + given decoder: JsonDecoder[AcceptCredentialOfferRequest] = + DeriveJsonDecoder.gen[AcceptCredentialOfferRequest] + + given schema: Schema[AcceptCredentialOfferRequest] = Schema.derived + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/CreateIssueCredentialRecordRequest.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/CreateIssueCredentialRecordRequest.scala new file mode 100644 index 0000000000..1de1952303 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/CreateIssueCredentialRecordRequest.scala @@ -0,0 +1,97 @@ +package io.iohk.atala.issue.controller.http + +import io.iohk.atala.api.http.Annotation +import io.iohk.atala.issue.controller.http.CreateIssueCredentialRecordRequest.annotations +import io.iohk.atala.mercury.model.{AttachmentDescriptor, Base64} +import io.iohk.atala.pollux.core.model.IssueCredentialRecord as PolluxIssueCredentialRecord +import sttp.tapir.Schema +import sttp.tapir.Schema.annotations.{description, encodedExample} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} + +import java.time.{OffsetDateTime, ZoneOffset} + +/** A class to represent an incoming request to create a new credential offer. + * + * @param validityPeriod + * The validity period in seconds of the verifiable credential that will be issued. for example: ''3600'' + * @param claims + * The claims that will be associated with the issued verifiable credential. for example: ''null'' + * @param automaticIssuance + * Specifies whether or not the credential should be automatically generated and issued when receiving the + * `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via API call will be + * required for the VC to be issued. for example: ''null'' + * + * @param issuingDID + * The issuer DID of the verifiable credential object. for example: ''did:prism:issuerofverifiablecredentials'' + * @param connectionId + * The unique identifier of a DIDComm connection that already exists between the issuer and the holder, and that will + * be used to execute the issue credential protocol. for example: ''null'' + */ +final case class CreateIssueCredentialRecordRequest( + @description(annotations.validityPeriod.description) + @encodedExample(annotations.validityPeriod.example) + validityPeriod: Option[Double] = None, + @description(annotations.claims.description) + @encodedExample(annotations.claims.example) + claims: Map[String, String], + @description(annotations.automaticIssuance.description) + @encodedExample(annotations.automaticIssuance.example) + automaticIssuance: Option[Boolean] = None, + @description(annotations.issuingDID.description) + @encodedExample(annotations.issuingDID.example) + issuingDID: String, + @description(annotations.connectionId.description) + @encodedExample(annotations.connectionId.example) + connectionId: String +) + +object CreateIssueCredentialRecordRequest { + + object annotations { + + object validityPeriod + extends Annotation[Double]( + description = "The validity period in seconds of the verifiable credential that will be issued.", + example = 3600 + ) + + object claims + extends Annotation[Map[String, String]]( + description = "The claims that will be associated with the issued verifiable credential.", + example = Map( + "firstname" -> "Alice", + "lastname" -> "Wonderland" + ) + ) + + object automaticIssuance + extends Annotation[Boolean]( + description = + "Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via API call will be required for the VC to be issued.", + example = true + ) + + object issuingDID + extends Annotation[String]( + description = "The issuer DID of the verifiable credential object.", + example = "did:prism:issuerofverifiablecredentials" + ) + + object connectionId + extends Annotation[String]( + description = + "The unique identifier of a DIDComm connection that already exists between the issuer and the holder, and that will be used to execute the issue credential protocol.", + example = "null" + ) + + } + + given encoder: JsonEncoder[CreateIssueCredentialRecordRequest] = + DeriveJsonEncoder.gen[CreateIssueCredentialRecordRequest] + + given decoder: JsonDecoder[CreateIssueCredentialRecordRequest] = + DeriveJsonDecoder.gen[CreateIssueCredentialRecordRequest] + + given schema: Schema[CreateIssueCredentialRecordRequest] = Schema.derived + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecord.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecord.scala new file mode 100644 index 0000000000..ad44e933b8 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecord.scala @@ -0,0 +1,182 @@ +package io.iohk.atala.issue.controller.http + +import io.iohk.atala.api.http.Annotation +import io.iohk.atala.issue.controller.http.IssueCredentialRecord.annotations +import io.iohk.atala.pollux.core.model.IssueCredentialRecord as PolluxIssueCredentialRecord +import sttp.tapir.Schema.annotations.{description, encodedExample} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} +import io.iohk.atala.mercury.model.AttachmentDescriptor +import io.iohk.atala.mercury.model.Base64 +import sttp.tapir.Schema + +import java.time.{OffsetDateTime, ZoneOffset} + +/** A class to represent an an outgoing response for a created credential offer. + * + * @param subjectId + * The identifier (e.g DID) of the subject to which the verifiable credential will be issued. for example: + * ''did:prism:subjectofverifiablecredentials'' + * @param validityPeriod + * The validity period in seconds of the verifiable credential that will be issued. for example: ''3600'' + * @param claims + * The claims that will be associated with the issued verifiable credential. for example: ''null'' + * @param automaticIssuance + * Specifies whether or not the credential should be automatically generated and issued when receiving the + * `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via API call will be + * required for the VC to be issued. for example: ''null'' + * @param recordId + * The unique identifier of the issue credential record. for example: ''null'' + * @param createdAt + * The date and time when the issue credential record was created. for example: ''null'' + * @param updatedAt + * The date and time when the issue credential record was last updated. for example: ''null'' + * @param role + * The role played by the Prism agent in the credential issuance flow. for example: ''null'' + * @param protocolState + * The current state of the issue credential protocol execution. for example: ''null'' + * @param jwtCredential + * The base64-encoded JWT verifiable credential that has been sent by the issuer. for example: ''null'' + * @param issuingDID + * Issuer DID of the verifiable credential object. for example: ''did:prism:issuerofverifiablecredentials'' + */ +final case class IssueCredentialRecord( + @description(annotations.subjectId.description) + @encodedExample(annotations.subjectId.example) + subjectId: Option[String] = None, + @description(annotations.validityPeriod.description) + @encodedExample(annotations.validityPeriod.example) + validityPeriod: Option[Double] = None, + @description(annotations.claims.description) + @encodedExample(annotations.claims.example) + claims: Map[String, String], + @description(annotations.automaticIssuance.description) + @encodedExample(annotations.automaticIssuance.example) + automaticIssuance: Option[Boolean] = None, + @description(annotations.recordId.description) + @encodedExample(annotations.recordId.example) + recordId: String, + @description(annotations.createdAt.description) + @encodedExample(annotations.createdAt.example) + createdAt: OffsetDateTime, + @description(annotations.updatedAt.description) + @encodedExample(annotations.updatedAt.example) + updatedAt: Option[OffsetDateTime] = None, + @description(annotations.role.description) + @encodedExample(annotations.role.example) + role: String, + @description(annotations.protocolState.description) + @encodedExample(annotations.protocolState.example) + protocolState: String, + @description(annotations.jwtCredential.description) + @encodedExample(annotations.jwtCredential.example) + jwtCredential: Option[String] = None, + @description(annotations.issuingDID.description) + @encodedExample(annotations.issuingDID.example) + issuingDID: Option[String] = None +) + +object IssueCredentialRecord { + + def fromDomain(domain: PolluxIssueCredentialRecord): IssueCredentialRecord = + IssueCredentialRecord( + recordId = domain.id.value, + createdAt = domain.createdAt.atOffset(ZoneOffset.UTC), + updatedAt = domain.updatedAt.map(_.atOffset(ZoneOffset.UTC)), + role = domain.role.toString, + subjectId = domain.subjectId, + claims = domain.offerCredentialData + .map(offer => offer.body.credential_preview.attributes.map(attr => (attr.name -> attr.value)).toMap) + .getOrElse(Map.empty), + validityPeriod = domain.validityPeriod, + automaticIssuance = domain.automaticIssuance, + protocolState = domain.protocolState.toString, + jwtCredential = domain.issueCredentialData.flatMap(issueCredential => { + issueCredential.attachments.collectFirst { case AttachmentDescriptor(_, _, Base64(jwt), _, _, _, _) => + jwt + } + }) + ) + + object annotations { + + object subjectId + extends Annotation[String]( + description = "The identifier (e.g DID) of the subject to which the verifiable credential will be issued.", + example = "did:prism:subjectofverifiablecredentials" + ) + + object validityPeriod + extends Annotation[Double]( + description = "The validity period in seconds of the verifiable credential that will be issued.", + example = 3600 + ) + + object claims + extends Annotation[Map[String, String]]( + description = "The claims that will be associated with the issued verifiable credential.", + example = Map( + "firstname" -> "Alice", + "lastname" -> "Wonderland" + ) + ) + + object automaticIssuance + extends Annotation[Boolean]( + description = + "Specifies whether or not the credential should be automatically generated and issued when receiving the `CredentialRequest` from the holder. If set to `false`, a manual approval by the issuer via API call will be required for the VC to be issued.", + example = true + ) + + object recordId + extends Annotation[String]( + description = "The unique identifier of the issue credential record.", + example = "80d612dc-0ded-4ac9-90b4-1b8eabb04545" + ) + + object createdAt + extends Annotation[OffsetDateTime]( + description = "The date and time when the issue credential record was created.", + example = OffsetDateTime.now() + ) + + object updatedAt + extends Annotation[Option[OffsetDateTime]]( + description = "The date and time when the issue credential record was last updated.", + example = None + ) + + object role + extends Annotation[String]( + description = "The role played by the Prism agent in the credential issuance flow.", + example = "Issuer" + ) + + object protocolState + extends Annotation[String]( // TODO Support Enum + description = "The current state of the issue credential protocol execution.", + example = "OfferPending" + ) + + object jwtCredential + extends Annotation[Option[String]]( + description = "The base64-encoded JWT verifiable credential that has been sent by the issuer.", + example = None + ) + + object issuingDID + extends Annotation[Option[String]]( + description = "Issuer DID of the verifiable credential object.", + example = Some("did:prism:issuerofverifiablecredentials") + ) + + } + + given encoder: JsonEncoder[IssueCredentialRecord] = + DeriveJsonEncoder.gen[IssueCredentialRecord] + + given decoder: JsonDecoder[IssueCredentialRecord] = + DeriveJsonDecoder.gen[IssueCredentialRecord] + + given schema: Schema[IssueCredentialRecord] = Schema.derived + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecordPage.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecordPage.scala new file mode 100644 index 0000000000..6d1f97d004 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/issue/controller/http/IssueCredentialRecordPage.scala @@ -0,0 +1,97 @@ +package io.iohk.atala.issue.controller.http + +import sttp.tapir.Schema +import io.iohk.atala.issue.controller.http.IssueCredentialRecordPage.annotations +import sttp.tapir.Schema.annotations.{description, encodedExample} +import zio.json.{DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder} +import io.iohk.atala.api.http.Annotation + +/** @param self + * The reference to the connection collection itself. for example: ''https://atala-prism-products.io/dids'' + * @param kind + * The type of object returned. In this case a `Collection`. for example: ''Collection'' + * @param pageOf + * Page number within the context of paginated response. for example: ''null'' + * @param next + * URL of the next page (if available) for example: ''null'' + * @param previous + * URL of the previous page (if available) for example: ''null'' + * @param contents + * for example: ''null'' + */ +final case class IssueCredentialRecordPage( + @description(annotations.self.description) + @encodedExample(annotations.self.example) + self: String, + @description(annotations.self.description) + @encodedExample(annotations.self.example) + kind: String, + @description(annotations.pageOf.description) + @encodedExample(annotations.pageOf.example) + pageOf: String, + @description(annotations.next.description) + @encodedExample(annotations.next.example) + next: Option[String] = None, + @description(annotations.previous.description) + @encodedExample(annotations.previous.example) + previous: Option[String] = None, + @description(annotations.contents.description) + @encodedExample(annotations.contents.example) + contents: Seq[IssueCredentialRecord] // TODO Tech Debt ticket - deduplicate page response schema +) + +object IssueCredentialRecordPage { + + object annotations { + + object contents + extends Annotation[Seq[IssueCredentialRecord]]( + description = + "A sequence of IssueCredentialRecord objects representing the list of credential records that the API response contains", + example = Seq.empty + ) + + object kind + extends Annotation[String]( + description = + "A string field indicating the type of the API response. In this case, it will always be set to `Collection`", + example = "Collection" + ) + + object self + extends Annotation[String]( + description = "A string field containing the URL of the current API endpoint", + example = + "/prism-agent/schema-registry/schemas?skip=10&limit=10" // TODO Tech Debt - make these generic / specific to issue + ) + + object pageOf + extends Annotation[String]( + description = "A string field indicating the type of resource that the contents field contains", + example = "/prism-agent/schema-registry/schemas" + ) + + object next + extends Annotation[String]( + description = "An optional string field containing the URL of the next page of results. " + + "If the API response does not contain any more pages, this field should be set to None.", + example = "/prism-agent/schema-registry/schemas?skip=20&limit=10" + ) + + object previous + extends Annotation[String]( + description = "An optional string field containing the URL of the previous page of results. " + + "If the API response is the first page of results, this field should be set to None.", + example = "/prism-agent/schema-registry/schemas?skip=0&limit=10" + ) + } + + given encoder: JsonEncoder[IssueCredentialRecordPage] = + DeriveJsonEncoder.gen[IssueCredentialRecordPage] + + given decoder: JsonDecoder[IssueCredentialRecordPage] = + DeriveJsonDecoder.gen[IssueCredentialRecordPage] + + given schema: Schema[IssueCredentialRecordPage] = Schema.derived + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/pollux/credentialschema/http/CredentialSchemaResponsePage.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/pollux/credentialschema/http/CredentialSchemaResponsePage.scala index 37e1368578..310dac52d5 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/pollux/credentialschema/http/CredentialSchemaResponsePage.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/pollux/credentialschema/http/CredentialSchemaResponsePage.scala @@ -59,7 +59,7 @@ object CredentialSchemaResponsePage { description = "A string field indicating the type of the API response. In this case, it will always be set to `CredentialSchemaPage`", example = "CredentialSchemaPage" - ) + ) // TODO Tech Debt ticket - the kind in a collection should be collection, not the underlying record type object self extends Annotation[String]( diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/MigrationAspect.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/MigrationAspect.scala similarity index 95% rename from prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/MigrationAspect.scala rename to prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/MigrationAspect.scala index 20321a7e65..aea750f16f 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/MigrationAspect.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/MigrationAspect.scala @@ -1,4 +1,4 @@ -package io.iohk.atala.pollux.test.container +package io.iohk.atala.container.util import com.dimafeng.testcontainers.PostgreSQLContainer import org.flywaydb.core.Flyway diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresLayer.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresLayer.scala similarity index 98% rename from prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresLayer.scala rename to prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresLayer.scala index defbbbce09..5edffd9fce 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresLayer.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresLayer.scala @@ -1,4 +1,4 @@ -package io.iohk.atala.pollux.test.container +package io.iohk.atala.container.util import cats.Functor import cats.effect.std.Dispatcher diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresTestContainer.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresTestContainer.scala similarity index 98% rename from prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresTestContainer.scala rename to prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresTestContainer.scala index d196e104cc..274eaad78d 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/test/container/PostgresTestContainer.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/container/util/PostgresTestContainer.scala @@ -1,4 +1,4 @@ -package io.iohk.atala.pollux.test.container +package io.iohk.atala.container.util import cats.Functor import cats.effect.std.Dispatcher diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerImplSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerImplSpec.scala new file mode 100644 index 0000000000..de64b273ac --- /dev/null +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerImplSpec.scala @@ -0,0 +1,65 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.agent.server.http.ZHttp4sBlazeServer +import io.iohk.atala.api.http.ErrorResponse +import io.iohk.atala.container.util.MigrationAspects.migrate +import io.iohk.atala.issue.controller.http.AcceptCredentialOfferRequest +import sttp.client3.testing.SttpBackendStub +import sttp.client3.ziojson.* +import sttp.client3.{DeserializationException, Response, ResponseException, SttpBackend, UriContext, basicRequest} +import sttp.model.{StatusCode, Uri} +import sttp.monad.MonadError +import sttp.tapir.server.interceptor.CustomiseInterceptors +import sttp.tapir.server.interceptor.RequestResult.Response +import sttp.tapir.server.stub.TapirStubInterpreter +import sttp.tapir.ztapir.RIOMonadError +import zio.* +import zio.json.ast.Json.* +import zio.json.{DecoderOps, EncoderOps, JsonDecoder} +import zio.stream.ZSink +import zio.stream.ZSink.* +import zio.stream.ZStream.unfold +import zio.test.* +import zio.test.Assertion.* +import zio.test.Gen.* +import zio.test.TestAspect.{nondeterministic, sequential} + +import java.time.{OffsetDateTime, ZoneOffset} +import java.util.UUID + +object IssueControllerImplSpec extends ZIOSpecDefault with IssueControllerTestTools { + + def spec = (httpErrorResponses @@ migrate( + schema = "public", + paths = "classpath:sql/pollux" + )).provideSomeLayerShared(testEnvironmentLayer) + + private val httpErrorResponses = suite("IssueControllerImp http failure cases") { + test("provide incorrect subjectId to endpoint") { + for { + issueControllerService <- ZIO.service[IssueController] + backend = httpBackend(issueControllerService) + response: IssueCredentialBadRequestResponse <- basicRequest + .post(uri"${issueUriBase}/records/12345/accept-offer") + .body(AcceptCredentialOfferRequest("subjectId").toJsonPretty) + .response(asJsonAlways[ErrorResponse]) + .send(backend) + + isItABadRequestStatusCode = assert(response.code)(equalTo(StatusCode.BadRequest)) + theBodyWasParsedFromJsonAsABadRequest = assert(response.body)( + isRight( + isSubtype[ErrorResponse]( + equalTo( + ErrorResponse.badRequest( + "Unsupported DID format", + Some(s"The following DID is not supported: subjectId") + ) + ) + ) + ) + ) + } yield isItABadRequestStatusCode && theBodyWasParsedFromJsonAsABadRequest + } + } + +} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala new file mode 100644 index 0000000000..e270584282 --- /dev/null +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerSpec.scala @@ -0,0 +1,136 @@ +package io.iohk.atala.issue.controller + +import io.iohk.atala.api.http.ErrorResponse +import io.iohk.atala.issue.controller.IssueController +import io.iohk.atala.pollux.core.model.DidCommID +import io.iohk.atala.pollux.core.model.error.CredentialServiceError +import io.iohk.atala.pollux.vc.jwt.W3cCredentialPayload +import zio.* +import zio.test.* +import zio.test.Assertion.* +import io.iohk.atala.pollux.vc.jwt.* +import io.iohk.atala.pollux.vc.jwt.CredentialPayload.Implicits.* +import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim} +import io.circe.* +import io.circe.generic.auto.* +import io.circe.parser.decode +import io.circe.syntax.* + +import java.security.* +import java.security.spec.* +import java.time.{Instant, ZonedDateTime} + +object IssueControllerSpec extends ZIOSpecDefault { + + override def spec = suite("IssueControllerSpec")(httpErrorSpec) + + private val httpErrorSpec = suite("testHttpErrors")( + test("return internal server error if repository error") { + val cse = CredentialServiceError.RepositoryError(new Throwable("test throw")) + val httpError = IssueController.toHttpError(cse) + val errorResponse = + ErrorResponse.internalServerError(title = "RepositoryError", detail = Some(cse.cause.toString)) + assert(httpError)(equalTo(errorResponse)) + }, + test("return not found error if record id not found") { + val cse = CredentialServiceError.RecordIdNotFound(DidCommID("12345")) + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.notFound(detail = Some(s"Record Id not found: 12345")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return internal server error if operation not executed") { + val cse = CredentialServiceError.OperationNotExecuted(DidCommID("12345"), "info") + val httpError = IssueController.toHttpError(cse) + val errorResponse = + ErrorResponse.internalServerError(title = "Operation Not Executed", detail = Some(s"12345-info")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return not found error if thread Id not found") { + val cse = CredentialServiceError.ThreadIdNotFound(DidCommID("12345")) + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.notFound(detail = Some(s"Thread Id not found: 12345")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return internal server error if unexpected error") { + val cse = CredentialServiceError.UnexpectedError("message") + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.internalServerError(detail = Some("message")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return bad request error if invalid flow state error") { + val cse = CredentialServiceError.InvalidFlowStateError("message") + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.badRequest(title = "InvalidFlowState", detail = Some("message")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return bad request error if unsupported did format error") { + val cse = CredentialServiceError.UnsupportedDidFormat("12345") + val httpError = IssueController.toHttpError(cse) + val errorResponse = + ErrorResponse.badRequest("Unsupported DID format", Some(s"The following DID is not supported: 12345")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return bad request error if create credential payload from record error") { + val cse = CredentialServiceError.CreateCredentialPayloadFromRecordError(new Throwable("message")) + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse + .badRequest(title = "Create Credential Payload From Record Error", detail = Some(cse.cause.getMessage)) + assert(httpError)(equalTo(errorResponse)) + }, + test("return bad request error if create request validation error") { + val cse = CredentialServiceError.CredentialRequestValidationError("message") + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.badRequest(title = "Create Request Validation Error", detail = Some("message")) + assert(httpError)(equalTo(errorResponse)) + }, + test("return bad request error if credential id not defined error") { + val w3cCredentialPayload = W3cCredentialPayload( + `@context` = Set("https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1"), + maybeId = Some("http://example.edu/credentials/3732"), + `type` = Set("VerifiableCredential", "UniversityDegreeCredential"), + issuer = DID("https://example.edu/issuers/565049"), + issuanceDate = Instant.parse("2010-01-01T00:00:00Z"), + maybeExpirationDate = Some(Instant.parse("2010-01-12T00:00:00Z")), + maybeCredentialSchema = Some( + CredentialSchema( + id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", + `type` = "JsonSchemaValidator2018" + ) + ), + credentialSubject = Json.obj( + "userName" -> Json.fromString("Bob"), + "age" -> Json.fromInt(42), + "email" -> Json.fromString("email") + ), + maybeCredentialStatus = Some( + CredentialStatus( + id = "did:work:MDP8AsFhHzhwUvGNuYkX7T;id=06e126d1-fa44-4882-a243-1e326fbe21db;version=1.0", + `type` = "CredentialStatusList2017" + ) + ), + maybeRefreshService = Some( + RefreshService( + id = "https://example.edu/refresh/3732", + `type` = "ManualRefreshService2018" + ) + ), + maybeEvidence = Option.empty, + maybeTermsOfUse = Option.empty + ) + val cse = CredentialServiceError.CredentialIdNotDefined(w3cCredentialPayload) + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.badRequest( + title = "Credential ID not defined one request", + detail = Some(w3cCredentialPayload.toString) + ) + assert(httpError)(equalTo(errorResponse)) + }, + test("return internal server error if iris error") { + val cse = CredentialServiceError.IrisError(new Throwable("message")) + val httpError = IssueController.toHttpError(cse) + val errorResponse = ErrorResponse.internalServerError(title = "VDR Error", detail = Some(cse.cause.toString)) + assert(httpError)(equalTo(errorResponse)) + } + ) + +} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala new file mode 100644 index 0000000000..a7a79d8641 --- /dev/null +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/issue/controller/IssueControllerTestTools.scala @@ -0,0 +1,183 @@ +package io.iohk.atala.issue.controller + +import com.typesafe.config.ConfigFactory +import io.grpc.ManagedChannelBuilder +import io.iohk.atala.agent.server.config.AppConfig +import io.iohk.atala.api.http.ErrorResponse +import io.iohk.atala.connect.core.repository.ConnectionRepositoryInMemory +import io.iohk.atala.connect.core.service.ConnectionServiceImpl +import io.iohk.atala.connect.sql.repository.JdbcConnectionRepository +import io.iohk.atala.pollux.core.repository.{CredentialRepositoryInMemory, CredentialSchemaRepository} +import io.iohk.atala.pollux.core.service.{CredentialSchemaServiceImpl, CredentialServiceImpl} +import io.iohk.atala.pollux.credentialschema.SchemaRegistryServerEndpoints +import io.iohk.atala.pollux.credentialschema.controller.{CredentialSchemaController, CredentialSchemaControllerImpl} +import io.iohk.atala.pollux.credentialschema.http.{ + CredentialSchemaInput, + CredentialSchemaResponse, + CredentialSchemaResponsePage +} +import io.iohk.atala.pollux.sql.repository.JdbcCredentialSchemaRepository +import io.iohk.atala.container.util.MigrationAspects.* +import io.iohk.atala.container.util.PostgresLayer.* +import io.iohk.atala.iris.proto.service.IrisServiceGrpc +import io.iohk.atala.issue.controller.http.{ + CreateIssueCredentialRecordRequest, + IssueCredentialRecord, + IssueCredentialRecordPage +} +import io.iohk.atala.pollux.vc.jwt.* +import sttp.client3.testing.SttpBackendStub +import sttp.client3.ziojson.* +import sttp.client3.{DeserializationException, Response, ResponseException, SttpBackend, UriContext, basicRequest} +import sttp.model.{StatusCode, Uri} +import sttp.monad.MonadError +import sttp.tapir.server.interceptor.CustomiseInterceptors +import sttp.tapir.server.stub.TapirStubInterpreter +import sttp.tapir.ztapir.RIOMonadError +import zio.config.{ReadError, read} +import zio.config.typesafe.TypesafeConfigSource +import zio.json.ast.Json.* +import zio.json.{DecoderOps, EncoderOps, JsonDecoder} +import zio.stream.ZSink +import zio.stream.ZSink.* +import zio.stream.ZStream.unfold +import zio.test.TestAspect.* +import zio.test.{Gen, Spec, ZIOSpecDefault} +import zio.{Layer, RIO, Task, URLayer, ZIO, ZLayer} + +import java.time.OffsetDateTime + +trait IssueControllerTestTools { + self: ZIOSpecDefault => + + type IssueCredentialBadRequestResponse = + Response[Either[DeserializationException[String], ErrorResponse]] + type IssueCredentialResponse = + Response[Either[DeserializationException[String], IssueCredentialRecord]] + type IssueCredentialPageResponse = + Response[ + Either[DeserializationException[String], IssueCredentialRecordPage] + ] + + val irisStubLayer = ZLayer.fromZIO( + ZIO.succeed(IrisServiceGrpc.stub(ManagedChannelBuilder.forAddress("localhost", 9999).usePlaintext.build)) + ) + val didResolverLayer = ZLayer.fromZIO(ZIO.succeed(makeResolver(Map.empty))) + + val configLayer: Layer[ReadError[String], AppConfig] = ZLayer.fromZIO { + read( + AppConfig.descriptor.from( + TypesafeConfigSource.fromTypesafeConfig( + ZIO.attempt(ConfigFactory.load()) + ) + ) + ) + } + + private[this] def makeResolver(lookup: Map[String, DIDDocument]): DidResolver = (didUrl: String) => { + lookup + .get(didUrl) + .fold( + ZIO.succeed(DIDResolutionFailed(NotFound(s"DIDDocument not found for $didUrl"))) + )((didDocument: DIDDocument) => { + ZIO.succeed( + DIDResolutionSucceeded( + didDocument, + DIDDocumentMetadata() + ) + ) + }) + } + + private val pgLayer = postgresLayer(verbose = false) + private val transactorLayer = pgLayer >>> hikariConfigLayer >>> transactor + private val controllerLayer = transactorLayer >+> + configLayer >+> + irisStubLayer >+> + didResolverLayer >+> + CredentialRepositoryInMemory.layer >+> + CredentialServiceImpl.layer >+> + ConnectionRepositoryInMemory.layer >+> + ConnectionServiceImpl.layer >+> + IssueControllerImpl.layer + + val testEnvironmentLayer = zio.test.testEnvironment ++ + pgLayer ++ + transactorLayer ++ + controllerLayer + + val issueUriBase = uri"http://test.com/issue-credentials/" + + def bootstrapOptions[F[_]](monadError: MonadError[F]): CustomiseInterceptors[F, Any] = { + import sttp.tapir.server.interceptor.RequestResult.Response + new CustomiseInterceptors[F, Any](_ => ()) + .defaultHandlers(ErrorResponse.failureResponseHandler) + } + + def httpBackend(controller: IssueController) = { + val issueEndpoints = IssueServerEndpoints(controller) + + val backend = + TapirStubInterpreter( + bootstrapOptions(new RIOMonadError[Any]), + SttpBackendStub(new RIOMonadError[Any]) + ) + .whenServerEndpoint(issueEndpoints.createCredentialOfferEndpoint) + .thenRunLogic() + .whenServerEndpoint(issueEndpoints.getCredentialRecordsEndpoint) + .thenRunLogic() + .whenServerEndpoint(issueEndpoints.getCredentialRecordEndpoint) + .thenRunLogic() + .whenServerEndpoint(issueEndpoints.acceptCredentialOfferEndpoint) + .thenRunLogic() + .whenServerEndpoint(issueEndpoints.issueCredentialEndpoint) + .thenRunLogic() + .backend() + backend + } + +} + +trait IssueGen { + self: ZIOSpecDefault with IssueControllerTestTools => + object Generator { + val gValidityPeriod: Gen[Any, Double] = Gen.double + val gClaims: Gen[Any, Map[String, String]] = + Gen.mapOf(Gen.alphaNumericStringBounded(5, 20), Gen.alphaNumericStringBounded(5, 20)) + val gAutomaticIssuance: Gen[Any, Boolean] = Gen.boolean + val gIssuingDID: Gen[Any, String] = Gen.alphaNumericStringBounded(5, 20) // TODO Make a DID generator + val gConnectionId: Gen[Any, String] = Gen.alphaNumericStringBounded(5, 20) + + val schemaInput = for { + validityPeriod <- gValidityPeriod + claims <- gClaims + automaticIssuance <- gAutomaticIssuance + issuingDID <- gIssuingDID + connectionId <- gConnectionId + } yield CreateIssueCredentialRecordRequest( + validityPeriod = Some(validityPeriod), + claims = claims, + automaticIssuance = Some(automaticIssuance), + issuingDID = issuingDID, + connectionId = connectionId + ) + } + +// def generateCreateIssueCredentialRecordRequetN( +// count: Int +// ): ZIO[IssueController, Throwable, List[CreateIssueCredentialRecordRequest]] = +// for { +// controller <- ZIO.service[IssueController] +// backend = httpBackend(controller) +// inputs <- Generator.schemaInput.runCollectN(count) +// _ <- inputs +// .map(in => +// basicRequest +// .post(uri"${issueUriBase}/credential-offers") +// .body(in.toJsonPretty) +// .response(asJsonAlways[IssueCredentialRecord]) +// .send(backend) +// ) +// .reduce((l, r) => l.flatMap(_ => r)) +// } yield inputs +} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaBasicSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaBasicSpec.scala index ce09a37191..17d2e531b5 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaBasicSpec.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaBasicSpec.scala @@ -11,8 +11,8 @@ import io.iohk.atala.pollux.credentialschema.http.{ CredentialSchemaResponsePage } import io.iohk.atala.pollux.sql.repository.JdbcCredentialSchemaRepository -import io.iohk.atala.pollux.test.container.MigrationAspects.* -import io.iohk.atala.pollux.test.container.PostgresLayer.* +import io.iohk.atala.container.util.MigrationAspects.* +import io.iohk.atala.container.util.PostgresLayer.* import sttp.client3.testing.SttpBackendStub import sttp.client3.ziojson.* import sttp.client3.{DeserializationException, ResponseException, SttpBackend, UriContext, basicRequest, Response as R} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaFailureSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaFailureSpec.scala index 693b396111..10f7a1192e 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaFailureSpec.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaFailureSpec.scala @@ -9,7 +9,7 @@ import io.iohk.atala.pollux.credentialschema.http.{ CredentialSchemaResponse, CredentialSchemaResponsePage } -import io.iohk.atala.pollux.test.container.MigrationAspects.migrate +import io.iohk.atala.container.util.MigrationAspects.migrate import sttp.client3.testing.SttpBackendStub import sttp.client3.ziojson.* import sttp.client3.{DeserializationException, Response, ResponseException, SttpBackend, UriContext, basicRequest} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaLookupAndPaginationSpec.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaLookupAndPaginationSpec.scala index a003d5b6d2..ff0c5a0ce4 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaLookupAndPaginationSpec.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaLookupAndPaginationSpec.scala @@ -9,7 +9,7 @@ import io.iohk.atala.pollux.credentialschema.http.{ CredentialSchemaResponse, CredentialSchemaResponsePage } -import io.iohk.atala.pollux.test.container.MigrationAspects.migrate +import io.iohk.atala.container.util.MigrationAspects.migrate import sttp.client3.testing.SttpBackendStub import sttp.client3.ziojson.* import sttp.client3.{DeserializationException, Response, ResponseException, SttpBackend, UriContext, basicRequest} diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaTestTools.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaTestTools.scala index 6cfd4d4b19..9567bd0ff8 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaTestTools.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/pollux/CredentialSchemaTestTools.scala @@ -11,8 +11,8 @@ import io.iohk.atala.pollux.credentialschema.http.{ CredentialSchemaResponsePage } import io.iohk.atala.pollux.sql.repository.JdbcCredentialSchemaRepository -import io.iohk.atala.pollux.test.container.MigrationAspects.* -import io.iohk.atala.pollux.test.container.PostgresLayer.* +import io.iohk.atala.container.util.MigrationAspects.* +import io.iohk.atala.container.util.PostgresLayer.* import sttp.client3.testing.SttpBackendStub import sttp.client3.ziojson.* import sttp.client3.{DeserializationException, Response, ResponseException, SttpBackend, UriContext, basicRequest}