Skip to content

Commit

Permalink
feat(prism-agent): align DID document service handling with the spec (#…
Browse files Browse the repository at this point in the history
…582)

* Rebase only castor

* make prismNodeClient compile [skip ci]

* make castor compile [skip ci]

* resolve conflict [skip ci]

* fix serviceEndpoint model and make validator compiled

* make tests compile [skip ci]

* feat(castor): validate max id size

* test(castor): max id validation tests [skip ci]

* context validation and tests [skip ci]

* serviceType and serviceEndpoint length validation

* Fix compile issue

* it works!

* implement encoder / decoder / schema for service type

* Fix incorrect swagger doc

* update service type parsing logic

* update tests for new logic

* serviceType serviceEndpoitn encode tests

* tests: update e2e scenario for service type ABNF

* chore: pr cleanup

* ci: disable coverage for generated code

* chore: pr cleanup

* fix: remove URI normalization check

* chore: resolve merge conflict

* chore: fix warnings

* ci: update node version in docker-compose

* chore: address pr review

* chore: update node version
  • Loading branch information
patlo-iog committed Jul 5, 2023
1 parent 3c2f7a0 commit c9e69f6
Show file tree
Hide file tree
Showing 28 changed files with 1,124 additions and 463 deletions.
27 changes: 22 additions & 5 deletions build.sbt
Expand Up @@ -67,11 +67,9 @@ lazy val V = new {
val flyway = "9.8.3"
val logback = "1.4.5"

val prismNodeClient = "0.4.0"
val prismSdk = "v1.4.1" // scala-steward:off
val scalaUri = "4.0.3"

val circeVersion = "0.14.3"
val jwtCirceVersion = "9.1.2"
val zioPreludeVersion = "1.0.0-RC16"

Expand Down Expand Up @@ -112,6 +110,7 @@ lazy val D = new {
val jwk: ModuleID = "com.nimbusds" % "nimbus-jose-jwt" % "9.25.4"

val typesafeConfig: ModuleID = "com.typesafe" % "config" % V.typesafeConfig
val scalaPbRuntime: ModuleID = "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf"
val scalaPbGrpc: ModuleID = "com.thesamet.scalapb" %% "scalapb-runtime-grpc" % scalapb.compiler.Version.scalapbVersion
// TODO we are adding test stuff to the main dependencies
val testcontainersPostgres: ModuleID = "com.dimafeng" %% "testcontainers-scala-postgresql" % V.testContainersScala
Expand Down Expand Up @@ -159,7 +158,6 @@ lazy val D_Connect = new {
lazy val D_Castor = new {

val scalaUri = "io.lemonlabs" %% "scala-uri" % V.scalaUri
val prismNodeClient = "io.iohk.atala" %% "prism-node-client" % V.prismNodeClient

// We have to exclude bouncycastle since for some reason bitcoinj depends on bouncycastle jdk15to18
// (i.e. JDK 1.5 to 1.8), but we are using JDK 11
Expand All @@ -176,9 +174,11 @@ lazy val D_Castor = new {
D.zioTest,
D.zioTestSbt,
D.zioTestMagnolia,
D.circeCore,
D.circeGeneric,
D.circeParser,
prismCrypto,
prismIdentity,
prismNodeClient,
scalaUri
)

Expand Down Expand Up @@ -554,6 +554,23 @@ lazy val agentCliDidcommx = project
// .settings(skip / publish := true)
// .dependsOn(agent)

// ####################
// ### prismNode ####
// ####################
val prismNodeClient = project
.in(file("prism-node/client/scala-client"))
.settings(
name := "prism-node-client",
libraryDependencies ++= Seq(D.scalaPbGrpc, D.scalaPbRuntime),
coverageEnabled := false,
// gRPC settings
Compile / PB.targets := Seq(scalapb.gen() -> (Compile / sourceManaged).value / "scalapb"),
Compile / PB.protoSources := Seq(
baseDirectory.value / "api" / "grpc",
(Compile / resourceDirectory).value // includes scalapb codegen package wide config
)
)

// #####################
// ##### castor ######
// #####################
Expand All @@ -574,7 +591,7 @@ lazy val castorCore = project
name := "castor-core",
libraryDependencies ++= D_Castor.coreDependencies
)
.dependsOn(shared)
.dependsOn(shared, prismNodeClient)

// #####################
// ##### pollux ######
Expand Down
@@ -1,7 +1,8 @@
package io.iohk.atala.castor.core.model

import java.time.Instant
import com.google.protobuf.ByteString
import io.circe.Json
import io.iohk.atala.castor.core.model.did.ServiceEndpoint.UriOrJsonEndpoint
import io.iohk.atala.castor.core.model.did.{
DIDData,
EllipticCurve,
Expand All @@ -14,6 +15,7 @@ import io.iohk.atala.castor.core.model.did.{
ScheduledDIDOperationDetail,
ScheduledDIDOperationStatus,
Service,
ServiceEndpoint,
ServiceType,
SignedPrismDIDOperation,
UpdateDIDAction,
Expand All @@ -22,10 +24,11 @@ import io.iohk.atala.castor.core.model.did.{
import io.iohk.atala.prism.protos.common_models.OperationStatus
import io.iohk.atala.prism.protos.node_models.KeyUsage
import io.iohk.atala.prism.protos.node_models.PublicKey.KeyData
import io.iohk.atala.prism.protos.{common_models, node_api, node_models}
import io.iohk.atala.shared.models.Base64UrlString
import io.iohk.atala.shared.utils.Traverse.*
import io.iohk.atala.prism.protos.{common_models, node_api, node_models}
import io.lemonlabs.uri.Uri
import java.time.Instant
import scala.language.implicitConversions
import zio.*

object ProtoModelHelper extends ProtoModelHelper
Expand Down Expand Up @@ -100,12 +103,12 @@ private[castor] trait ProtoModelHelper {
node_models.UpdateDIDAction.Action.AddService(node_models.AddServiceAction(Some(service.toProto)))
case UpdateDIDAction.RemoveService(id) =>
node_models.UpdateDIDAction.Action.RemoveService(node_models.RemoveServiceAction(id))
case UpdateDIDAction.UpdateService(serviceId, serviceType, endpoints) =>
case UpdateDIDAction.UpdateService(serviceId, serviceType, endpoint) =>
node_models.UpdateDIDAction.Action.UpdateService(
node_models.UpdateServiceAction(
serviceId = serviceId,
`type` = serviceType.fold("")(_.name),
serviceEndpoints = endpoints.map(_.toString)
`type` = serviceType.fold("")(_.toProto),
serviceEndpoints = endpoint.fold("")(_.toProto)
)
)
case UpdateDIDAction.PatchContext(context) =>
Expand Down Expand Up @@ -171,13 +174,44 @@ private[castor] trait ProtoModelHelper {
}

extension (service: Service) {
def toProto: node_models.Service = node_models.Service(
id = service.id,
`type` = service.`type`.name,
serviceEndpoint = service.serviceEndpoint.map(_.toString),
addedOn = None,
deletedOn = None
)
def toProto: node_models.Service = {
node_models.Service(
id = service.id,
`type` = service.`type`.toProto,
serviceEndpoint = service.serviceEndpoint.toProto,
addedOn = None,
deletedOn = None
)
}
}

extension (serviceType: ServiceType) {
def toProto: String = {
serviceType match {
case ServiceType.Single(name) => name.value
case ts: ServiceType.Multiple =>
val names = ts.values.map(_.value).map(Json.fromString)
Json.arr(names: _*).noSpaces
}
}
}

extension (serviceEndpoint: ServiceEndpoint) {
def toProto: String = {
serviceEndpoint match {
case ServiceEndpoint.Single(value) =>
value match {
case UriOrJsonEndpoint.Uri(uri) => uri.value
case UriOrJsonEndpoint.Json(json) => Json.fromJsonObject(json).noSpaces
}
case endpoints: ServiceEndpoint.Multiple =>
val uris = endpoints.values.map {
case UriOrJsonEndpoint.Uri(uri) => Json.fromString(uri.value)
case UriOrJsonEndpoint.Json(json) => Json.fromJsonObject(json)
}
Json.arr(uris: _*).noSpaces
}
}
}

extension (resp: node_api.GetOperationInfoResponse) {
Expand Down Expand Up @@ -247,16 +281,12 @@ private[castor] trait ProtoModelHelper {
extension (service: node_models.Service) {
def toDomain: Either[String, Service] = {
for {
uris <- service.serviceEndpoint.traverse(s =>
Uri.parseTry(s).toEither.left.map(_ => s"unable to parse serviceEndpoint $s as URI")
)
serviceType <- ServiceType
.parseString(service.`type`)
.toRight(s"unable to parse ${service.`type`} as service type")
serviceType <- parseServiceType(service.`type`)
serviceEndpoint <- parseServiceEndpoint(service.serviceEndpoint)
} yield Service(
id = service.id,
`type` = serviceType,
serviceEndpoint = uris
serviceEndpoint = serviceEndpoint
)
}
}
Expand Down Expand Up @@ -323,4 +353,68 @@ private[castor] trait ProtoModelHelper {
}
}

def parseServiceType(s: String): Either[String, ServiceType] = {
// The type field MUST be a string or a non-empty JSON array of strings.
val parsedJson: Option[Either[String, ServiceType.Multiple]] = io.circe.parser
.parse(s)
.toOption // it's OK to let parsing fail (e.g. LinkedDomains without quote is not a JSON string)
.flatMap(_.asArray)
.map { jsonArr =>
jsonArr
.traverse(_.asString.toRight("the service type is not a JSON array of strings"))
.flatMap(_.traverse(ServiceType.Name.fromString))
.map(_.toList)
.flatMap {
case head :: tail => Right(ServiceType.Multiple(head, tail))
case Nil => Left("the service type cannot be an empty JSON array")
}
.filterOrElse(
_ => s == io.circe.Json.arr(jsonArr: _*).noSpaces,
"the service type is a valid JSON array of strings, but not conform to the ABNF"
)
}

parsedJson match {
// serviceType is a valid JSON array of strings
case Some(Right(parsed)) => Right(parsed)
// serviceType is a valid JSON array but contains invalid items
case Some(Left(error)) => Left(error)
// serviceType is a string (raw string, not JSON quoted string)
case None => ServiceType.Name.fromString(s).map(name => ServiceType.Single(name))
}
}

def parseServiceEndpoint(s: String): Either[String, ServiceEndpoint] = {
/* The service_endpoint field MUST contain one of:
* 1. a URI
* 2. a JSON object
* 3. a non-empty JSON array of URIs and/or JSON objects
*/
val parsedJson: Option[Either[String, ServiceEndpoint]] = io.circe.parser
.parse(s)
.toOption // it's OK to let parsing fail (e.g. http://example.com without quote is not a JSON string)
.flatMap { json =>
val parsedObject = json.asObject.map(obj => Right(ServiceEndpoint.Single(obj)))
val parsedArray = json.asArray.map(_.traverse[String, UriOrJsonEndpoint] { js =>
val obj = js.asObject.map(obj => Right(obj: UriOrJsonEndpoint))
val str = js.asString.map(str => ServiceEndpoint.UriValue.fromString(str).map[UriOrJsonEndpoint](i => i))
obj.orElse(str).getOrElse(Left("the service endpoint is not a JSON array of URIs and/or JSON objects"))
}.map(_.toList).flatMap {
case head :: tail => Right(ServiceEndpoint.Multiple(head, tail))
case Nil => Left("the service endpoint cannot be an empty JSON array")
})

parsedObject.orElse(parsedArray)
}

parsedJson match {
// serviceEndpoint is a valid JSON object or array
case Some(Right(parsed)) => Right(parsed)
// serviceEndpoint is a valid JSON but contains invalid values
case Some(Left(error)) => Left(error)
// serviceEndpoint is a string (raw string, not JSON quoted string)
case None => ServiceEndpoint.UriValue.fromString(s).map(ServiceEndpoint.Single(_))
}
}

}
Expand Up @@ -6,8 +6,6 @@ import io.iohk.atala.prism.crypto.Sha256
import scala.collection.compat.immutable.ArraySeq
import io.iohk.atala.prism.protos.node_models

import io.lemonlabs.uri.Uri

sealed trait PrismDIDOperation {
def did: CanonicalPrismDID
def toAtalaOperation: node_models.AtalaOperation
Expand Down Expand Up @@ -69,8 +67,10 @@ object UpdateDIDAction {
final case class RemoveKey(id: String) extends UpdateDIDAction
final case class AddService(service: Service) extends UpdateDIDAction
final case class RemoveService(id: String) extends UpdateDIDAction
final case class UpdateService(id: String, `type`: Option[ServiceType] = None, endpoints: Seq[Uri] = Nil)
extends UpdateDIDAction

final case class UpdateService(
id: String,
`type`: Option[ServiceType] = None,
endpoint: Option[ServiceEndpoint] = None
) extends UpdateDIDAction
final case class PatchContext(context: Seq[String]) extends UpdateDIDAction
}
@@ -1,19 +1,11 @@
package io.iohk.atala.castor.core.model.did

import io.iohk.atala.castor.core.util.UriUtils
import io.lemonlabs.uri.Uri

final case class Service(
id: String,
`type`: ServiceType,
serviceEndpoint: Seq[Uri]
serviceEndpoint: ServiceEndpoint
) {
def normalizeServiceEndpoint(): Service = {
val normalizedUris = serviceEndpoint.flatMap { uri =>
UriUtils.normalizeUri(uri.toString).map { normalizedUri =>
Uri.parse(normalizedUri)
}
}
this.copy(serviceEndpoint = normalizedUris)
}

def normalizeServiceEndpoint(): Service = copy(serviceEndpoint = serviceEndpoint.normalize())

}
@@ -0,0 +1,61 @@
package io.iohk.atala.castor.core.model.did

import io.circe.JsonObject
import io.iohk.atala.castor.core.util.UriUtils

sealed trait ServiceEndpoint {
def normalize(): ServiceEndpoint
}

object ServiceEndpoint {

opaque type UriValue = String

object UriValue {
def fromString(uri: String): Either[String, UriValue] = {
val isUri = UriUtils.isValidUriString(uri)
if (isUri) Right(uri) else Left(s"unable to parse service endpoint URI: \"$uri\"")
}
}

extension (uri: UriValue) {
def value: String = uri

def normalize(): UriValue = {
UriUtils.normalizeUri(uri).get // uri is already validated
}
}

sealed trait UriOrJsonEndpoint {
def normalize(): UriOrJsonEndpoint
}

object UriOrJsonEndpoint {
final case class Uri(uri: UriValue) extends UriOrJsonEndpoint {
override def normalize(): UriOrJsonEndpoint = copy(uri = uri.normalize())
}

final case class Json(json: JsonObject) extends UriOrJsonEndpoint {
override def normalize(): UriOrJsonEndpoint = this
}

given Conversion[UriValue, UriOrJsonEndpoint] = Uri(_)
given Conversion[JsonObject, UriOrJsonEndpoint] = Json(_)
}

final case class Single(value: UriOrJsonEndpoint) extends ServiceEndpoint {
override def normalize(): Single = copy(value = value.normalize())
}

final case class Multiple(head: UriOrJsonEndpoint, tail: Seq[UriOrJsonEndpoint]) extends ServiceEndpoint {
def values: Seq[UriOrJsonEndpoint] = head +: tail

override def normalize(): Multiple = {
Multiple(
head = head.normalize(),
tail = tail.map(_.normalize())
)
}
}

}

0 comments on commit c9e69f6

Please sign in to comment.