From e8208ec8bd6f75befde392680a0b800eb67bed99 Mon Sep 17 00:00:00 2001 From: Oguzhan Unlu Date: Thu, 24 Aug 2023 16:27:33 +0300 Subject: [PATCH] Disallow non-sequential version (close #135) --- .../iglu/server/model/IgluResponse.scala | 23 +++++++++------ .../iglu/server/model/VersionCursor.scala | 26 +++++++++++------ .../iglu/server/service/SchemaService.scala | 2 ++ .../iglu/server/model/VersionCursorSpec.scala | 28 ++++++++++++------- 4 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/main/scala/com/snowplowanalytics/iglu/server/model/IgluResponse.scala b/src/main/scala/com/snowplowanalytics/iglu/server/model/IgluResponse.scala index f2d7342..96bb7a4 100644 --- a/src/main/scala/com/snowplowanalytics/iglu/server/model/IgluResponse.scala +++ b/src/main/scala/com/snowplowanalytics/iglu/server/model/IgluResponse.scala @@ -34,15 +34,17 @@ trait IgluResponse extends Product with Serializable { object IgluResponse { - val NotFoundSchema = "The schema is not found" - val NotAuthorized = "Authentication error: not authorized" - val Mismatch = "Mismatch: the schema metadata does not match the payload and URI" - val DecodeError = "Cannot decode JSON schema" - val SchemaInvalidationMessage = "The schema does not conform to a JSON Schema v4 specification" - val DataInvalidationMessage = "The data for a field instance is invalid against its schema" - val NotFoundEndpoint = "The endpoint does not exist" + val NotFoundSchema = "The schema is not found" + val NotAuthorized = "Authentication error: not authorized" + val Mismatch = "Mismatch: the schema metadata does not match the payload and URI" + val DecodeError = "Cannot decode JSON schema" + val SchemaInvalidationMessage = "The schema does not conform to a JSON Schema v4 specification" + val DataInvalidationMessage = "The data for a field instance is invalid against its schema" + val NotFoundEndpoint = "The endpoint does not exist" + val NonSequentialSchemaVersion = "The schema version is not sequential" case object SchemaNotFound extends IgluResponse + case object SchemaNonSequential extends IgluResponse case object EndpointNotFound extends IgluResponse case object InvalidSchema extends IgluResponse case class SchemaMismatch(uriSchemaKey: SchemaKey, payloadSchemaKey: SchemaKey) extends IgluResponse @@ -96,6 +98,8 @@ object IgluResponse { ) case InvalidSchema => Json.fromFields(List("message" -> Json.fromString(DecodeError))) + case SchemaNonSequential => + Json.fromFields(List("message" -> Json.fromString(NonSequentialSchemaVersion))) case SchemaValidationReport(report) => Json.fromFields( List( @@ -129,8 +133,9 @@ object IgluResponse { implicit val responsesDecoder: Decoder[IgluResponse] = Decoder.instance { cur => cur.downField("message").as[String] match { - case Right(NotFoundSchema) => SchemaNotFound.asRight - case Right(NotAuthorized) => Forbidden.asRight + case Right(NotFoundSchema) => SchemaNotFound.asRight + case Right(NonSequentialSchemaVersion) => SchemaNonSequential.asRight + case Right(NotAuthorized) => Forbidden.asRight case Right(Mismatch) => for { uriSchemaKey <- cur.downField("uriSchemaKey").as[SchemaKey] diff --git a/src/main/scala/com/snowplowanalytics/iglu/server/model/VersionCursor.scala b/src/main/scala/com/snowplowanalytics/iglu/server/model/VersionCursor.scala index 1a68f5f..876bbb7 100644 --- a/src/main/scala/com/snowplowanalytics/iglu/server/model/VersionCursor.scala +++ b/src/main/scala/com/snowplowanalytics/iglu/server/model/VersionCursor.scala @@ -40,6 +40,7 @@ object VersionCursor { case object PreviousMissing extends Inconsistency case object SupersededMissing extends Inconsistency case object SupersededInvalid extends Inconsistency + case object NextRevisionExists extends Inconsistency case object AlreadyExists extends Inconsistency case class Availability(isPublic: Boolean, previousPublic: Boolean) extends Inconsistency @@ -51,6 +52,8 @@ object VersionCursor { s"Superseded schema version(s) do not exist" case SupersededInvalid => s"Superseded schema version(s) must be below the superseding version" + case NextRevisionExists => + "Next revision in the group exists, check that schemas are published sequentially" case AlreadyExists => "Schema already exists" case Availability(isPublic, previousPublic) => @@ -65,7 +68,7 @@ object VersionCursor { supersedes: List[SchemaVer.Full] ): Either[Inconsistency, Unit] = if (existing.contains(version) && !patchesAllowed) Inconsistency.AlreadyExists.asLeft - else if (!previousExists(existing, get(version))) Inconsistency.PreviousMissing.asLeft + else if (isVersionAllowed(existing, get(version)).isLeft) isVersionAllowed(existing, get(version)) else if (supersedes.diff(existing).nonEmpty) Inconsistency.SupersededMissing.asLeft else if (supersedes.exists(Ordering[SchemaVer.Full].gt(_, version))) Inconsistency.SupersededInvalid.asLeft else ().asRight @@ -81,18 +84,25 @@ object VersionCursor { * Check if existing state allows new schema * It makes an assumption that `existing` is entirely consistent list without `current` schema */ - private[model] def previousExists(existing: List[SchemaVer.Full], current: VersionCursor) = + private[model] def isVersionAllowed( + existing: List[SchemaVer.Full], + current: VersionCursor + ): Either[Inconsistency, Unit] = current match { case Initial => // We can always create a new schema group (vendor/name/1-0-0) - true + ().asRight case StartModel(m) => - existing.map(_.model).contains(m - 1) + Either.cond(existing.map(_.model).contains(m - 1), (), Inconsistency.PreviousMissing) case StartRevision(m, r) => val thisModel = existing.filter(_.model == m) - thisModel.map(_.revision).contains(r - 1) + Either.cond(thisModel.map(_.revision).contains(r - 1), (), Inconsistency.PreviousMissing) case NonInitial(version) => - val thisModel = existing.filter(_.model == version.model) - val thisRevision = thisModel.filter(_.revision == version.revision) - thisRevision.map(_.addition).contains(version.addition - 1) + val thisModel = existing.filter(_.model == version.model) + val thisRevision = thisModel.filter(_.revision == version.revision) + val sequentialAddition = thisRevision.map(_.addition).contains(version.addition - 1) + val nextRevision = thisModel.map(_.revision).contains(version.revision + 1) + if (nextRevision) Inconsistency.NextRevisionExists.asLeft + else if (!sequentialAddition) Inconsistency.PreviousMissing.asLeft + else ().asRight } } diff --git a/src/main/scala/com/snowplowanalytics/iglu/server/service/SchemaService.scala b/src/main/scala/com/snowplowanalytics/iglu/server/service/SchemaService.scala index 9161840..03ca8aa 100644 --- a/src/main/scala/com/snowplowanalytics/iglu/server/service/SchemaService.scala +++ b/src/main/scala/com/snowplowanalytics/iglu/server/service/SchemaService.scala @@ -220,6 +220,8 @@ class SchemaService[F[+_]: Sync]( _ <- webhooks.schemaPublished(schema.self.schemaKey, existing) response <- if (existing) Ok(payload) else Created(payload) } yield response + case Left(Inconsistency.NextRevisionExists) => + Conflict(IgluResponse.SchemaNonSequential: IgluResponse) case Left(error) => Conflict(IgluResponse.Message(error.show): IgluResponse) } diff --git a/src/test/scala/com/snowplowanalytics/iglu/server/model/VersionCursorSpec.scala b/src/test/scala/com/snowplowanalytics/iglu/server/model/VersionCursorSpec.scala index dbb07f9..55ac4d7 100644 --- a/src/test/scala/com/snowplowanalytics/iglu/server/model/VersionCursorSpec.scala +++ b/src/test/scala/com/snowplowanalytics/iglu/server/model/VersionCursorSpec.scala @@ -15,48 +15,50 @@ package com.snowplowanalytics.iglu.server.model import com.snowplowanalytics.iglu.core.SchemaVer +import com.snowplowanalytics.iglu.server.model.VersionCursor.Inconsistency class VersionCursorSpec extends org.specs2.Specification { def is = s2""" - previousExists validates new revision $e1 - previousExists validates new model if no schemas were created for this model yet $e2 - previousExists rejects new model if previous model does not exist yet $e3 - previousExists validates new addition $e4 - previousExists rejects new addition if previous does not exist $e5 + isVersionAllowed validates new revision $e1 + isVersionAllowed validates new model if no schemas were created for this model yet $e2 + isVersionAllowed rejects new model if previous model does not exist yet $e3 + isVersionAllowed validates new addition $e4 + isVersionAllowed rejects new addition if previous does not exist $e5 isAllowed allows overriding schema if patchesAllowed set to true $e6 isAllowed forbids overriding schema if patchesAllowed set to false $e7 isAllowed forbids superseding nonexistent schema versions $e8 isAllowed forbids superseding superior schema versions $e9 + isVersionAllowed rejects new addition if next revision exists $e10 """ def e1 = { val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 0, 1)) val current = VersionCursor.get(SchemaVer.Full(1, 1, 0)) - VersionCursor.previousExists(existing, current) must beTrue + VersionCursor.isVersionAllowed(existing, current) must beRight } def e2 = { val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 1, 0), SchemaVer.Full(1, 0, 1)) val current = VersionCursor.get(SchemaVer.Full(2, 0, 0)) - VersionCursor.previousExists(existing, current) must beTrue + VersionCursor.isVersionAllowed(existing, current) must beRight } def e3 = { val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 1, 0)) val current = VersionCursor.get(SchemaVer.Full(3, 0, 0)) - VersionCursor.previousExists(existing, current) must beFalse + VersionCursor.isVersionAllowed(existing, current) must beLeft } def e4 = { val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 1, 0), SchemaVer.Full(1, 1, 1)) val current = VersionCursor.get(SchemaVer.Full(1, 1, 2)) - VersionCursor.previousExists(existing, current) must beTrue + VersionCursor.isVersionAllowed(existing, current) must beRight } def e5 = { val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 1, 0), SchemaVer.Full(1, 1, 1)) val current = VersionCursor.get(SchemaVer.Full(1, 1, 3)) - VersionCursor.previousExists(existing, current) must beFalse + VersionCursor.isVersionAllowed(existing, current) must beLeft } def e6 = @@ -86,4 +88,10 @@ class VersionCursorSpec extends org.specs2.Specification { ) must beLeft( VersionCursor.Inconsistency.SupersededInvalid ) + + def e10 = { + val existing = List(SchemaVer.Full(1, 0, 0), SchemaVer.Full(1, 1, 0)) + val current = VersionCursor.get(SchemaVer.Full(1, 0, 1)) + VersionCursor.isVersionAllowed(existing, current) must beLeft(Inconsistency.NextRevisionExists) + } }