diff --git a/akka-http/client/src/main/scala/endpoints4s/akkahttp/client/Urls.scala b/akka-http/client/src/main/scala/endpoints4s/akkahttp/client/Urls.scala index c9896bc19..a04f82c20 100644 --- a/akka-http/client/src/main/scala/endpoints4s/akkahttp/client/Urls.scala +++ b/akka-http/client/src/main/scala/endpoints4s/akkahttp/client/Urls.scala @@ -63,6 +63,8 @@ trait Urls extends algebra.Urls { /** a query string parameter can have zero or several values */ type QueryStringParam[A] = A => List[String] + override def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = (either:Either[A,B]) => either.fold(qspa, qspb) + implicit lazy val queryStringParamPartialInvariantFunctor : PartialInvariantFunctor[QueryStringParam] = new PartialInvariantFunctor[QueryStringParam] { diff --git a/akka-http/server/src/main/scala/endpoints4s/akkahttp/server/Urls.scala b/akka-http/server/src/main/scala/endpoints4s/akkahttp/server/Urls.scala index 1b2c549f6..6274104fb 100644 --- a/akka-http/server/src/main/scala/endpoints4s/akkahttp/server/Urls.scala +++ b/akka-http/server/src/main/scala/endpoints4s/akkahttp/server/Urls.scala @@ -126,6 +126,20 @@ trait Urls extends algebra.Urls with StatusCodes { def encode(name: String, value: T): Uri.Query } + def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = new QueryStringParam[Either[A,B]] { + def decode(name: String, params: Map[String, Seq[String]]): Validated[Either[A,B]] = { + qspa.decode(name, params) match { + case valid@Valid(_) => valid.map(Left(_)) + case Invalid(errA) => qspb.decode(name,params) match { + case valid@Valid(_) => valid.map(Right(_)) + case Invalid(errB) => Invalid(errA ++ errB) + } + } + } + + def encode(name:String, value: Either[A,B]): Uri.Query = value.fold(qspa.encode(name, _), qspb.encode(name, _)) + } + implicit lazy val queryStringParamPartialInvariantFunctor : PartialInvariantFunctor[QueryStringParam] = new PartialInvariantFunctor[QueryStringParam] { diff --git a/algebras/algebra/src/main/scala/endpoints4s/algebra/Urls.scala b/algebras/algebra/src/main/scala/endpoints4s/algebra/Urls.scala index a738dbd80..6cbadd085 100644 --- a/algebras/algebra/src/main/scala/endpoints4s/algebra/Urls.scala +++ b/algebras/algebra/src/main/scala/endpoints4s/algebra/Urls.scala @@ -91,6 +91,7 @@ trait Urls extends PartialInvariantFunctorSyntax { tupler: Tupler[A, B] ): QueryString[tupler.Out] + /** Builds a `QueryString` with one parameter. * * Examples: @@ -223,6 +224,13 @@ trait Urls extends PartialInvariantFunctorSyntax { implicit def doubleQueryString: QueryStringParam[Double] = stringQueryString.xmapWithCodec(Codec.doubleCodec) + + def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] + + implicit class QueryStringParamSyntax[A](qspa: QueryStringParam[A]) { + def orElse[B](qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = oneOfQueryStringParam(qspa, qspb) + } + /** An URL path segment codec for type `A`. * * The trait `Urls` provides implicit instances of `Segment[A]` for basic types diff --git a/fetch/client/src/main/scala/endpoints4s/fetch/Urls.scala b/fetch/client/src/main/scala/endpoints4s/fetch/Urls.scala index c5bab8988..84be2ab11 100644 --- a/fetch/client/src/main/scala/endpoints4s/fetch/Urls.scala +++ b/fetch/client/src/main/scala/endpoints4s/fetch/Urls.scala @@ -89,6 +89,10 @@ trait Urls extends algebra.Urls { def encode(a: A): List[String] } + override def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = new QueryStringParam[Either[A,B]] { + override def encode(either: Either[A,B]) = either.fold(qspa.encode, qspb.encode) + } + implicit lazy val queryStringParamPartialInvariantFunctor : PartialInvariantFunctor[QueryStringParam] = new PartialInvariantFunctor[QueryStringParam] { diff --git a/http4s/client/shared/src/main/scala/endpoints4s/http4s/client/Urls.scala b/http4s/client/shared/src/main/scala/endpoints4s/http4s/client/Urls.scala index ce0d1d672..d68e1f729 100644 --- a/http4s/client/shared/src/main/scala/endpoints4s/http4s/client/Urls.scala +++ b/http4s/client/shared/src/main/scala/endpoints4s/http4s/client/Urls.scala @@ -16,6 +16,10 @@ trait Urls extends endpoints4s.algebra.Urls with StatusCodes { def encode(a: A): List[String] } + def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = new QueryStringParam[Either[A,B]] { + def encode(either: Either[A,B]):List[String] = either.fold(qspa.encode, qspb.encode) + } + override def queryStringPartialInvariantFunctor: PartialInvariantFunctor[QueryString] = new PartialInvariantFunctor[QueryString] { override def xmapPartial[A, B]( diff --git a/http4s/server/src/main/scala/endpoints4s/http4s/server/Urls.scala b/http4s/server/src/main/scala/endpoints4s/http4s/server/Urls.scala index 934918473..1ea5e630d 100644 --- a/http4s/server/src/main/scala/endpoints4s/http4s/server/Urls.scala +++ b/http4s/server/src/main/scala/endpoints4s/http4s/server/Urls.scala @@ -28,6 +28,18 @@ trait Urls extends algebra.Urls with StatusCodes { def decode(name: String, params: Params): Validated[A] } + def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = new QueryStringParam[Either[A,B]] { + def decode(name: String, params: Params): Validated[Either[A,B]] = { + qspa.decode(name, params) match { + case valid@Valid(_) => valid.map(Left(_)) + case Invalid(errA) => qspb.decode(name,params) match { + case valid@Valid(_) => valid.map(Right(_)) + case Invalid(errB) => Invalid(errA ++ errB) + } + } + } + } + trait Url[A] { def decodeUrl(uri: http4s.Uri): Option[Validated[A]] } diff --git a/json-schema/json-schema/src/main/scala/endpoints4s/Alternative.scala b/json-schema/json-schema/src/main/scala/endpoints4s/Alternative.scala new file mode 100644 index 000000000..ee2ca5606 --- /dev/null +++ b/json-schema/json-schema/src/main/scala/endpoints4s/Alternative.scala @@ -0,0 +1,133 @@ +package endpoints4s + +import AlternativeTypes._ + +//Note this is mostly for discussion for potential syntax support in endpoints4s to deal with nested either. It can be thrown out of the MR as it has no dependencies + +//Anologous to Tupler but for Eithers +//An either is fully qualified by its inject and fold. So to give users convenient albeit slightly odd syntax that folds and inject up to some arity (currently 4) +//This can be thrown out entirely (probably tupler as well) when we're fully on scala 3 and no longer offer 2 support (so a long while from now) +trait Alternative[A,B] { + type Clauses[_] + + //These are userfriendly methods that can be used. As of right now it's very painful to deal with endpoints with lots of either nesting coming from `orElse`. This way a user doesn't need to have the sealed trait of the correct arity in scope + def inject: Clauses[Either[A,B]] + def fold[Result](either:Either[A,B])(clauses: Clauses[Result]):Result + +} + +object AlternativeTypes { + //Alternatively this could be a trait but I think it would be more annoying at usage site, requiring creation of anonymous instances with `new` or having an apply in which case it boils down to the same thing really. + case class Clauses2[A, B, Target]( + first: A => Target, + second: B => Target + ) + case class Clauses3[A, B, C, Target]( + first: A => Target, + second: B => Target, + third: C => Target, + ) + case class Clauses4[A, B, C, D, Target]( + first: A => Target, + second: B => Target, + third: C => Target, + fourth: D => Target, + ) + + //Very common fold Operations can be offered here like Embedding into Right Chained either form e.g. Either[A,Either[B,Either[C,D]] or merge to a common super type (sealed trait injeciton) +} + +trait Alternative2 { + type Aux[A,B, Clauses0[_]] = Alternative[A,B] { + type Clauses[Target] = Clauses0[Target] + } + + //We have no partial type application, this is what kind projector does under the hood. If we want to offer `Alternative` to our users we'll have to think on how to do that + implicit def alternative2[A,B]: Aux[A,B, ({type L[T] = Clauses2[A,B,T]})#L] = new Alternative[A,B] { + type Clauses[Target] = Clauses2[A,B,Target] + //type SealedTraitRepr = Either2[A,B] + + override def inject:Clauses[Either[A,B]] = Clauses2( + first = a => Left(a), + second = b => Right(b), + ) + + override def fold[Result](either:Either[A,B])(clauses: Clauses[Result]):Result = either.fold(clauses.first, clauses.second) + + } +} + +trait Alternative3 extends Alternative2 { + + implicit def alternative1Or2[A,B,C]: Aux[A,Either[B,C], ({type L[T] = Clauses3[A,B,C,T]})#L] = new Alternative[A,Either[B,C]] { + type Clauses[Target] = Clauses3[A,B,C,Target] + + override def inject:Clauses[Either[A,Either[B,C]]] = Clauses3( + first = a => Left(a), + second = b => Right(Left(b)), + third = c => Right(Right(c)) + ) + + override def fold[Result](either:Either[A,Either[B,C]])(clauses: Clauses[Result]):Result = either.fold(clauses.first, _.fold(clauses.second, clauses.third)) + + } + + implicit def alternative2Or1[A,B,C]: Aux[Either[A,B],C, ({type L[T] = Clauses3[A,B,C,T]})#L] = new Alternative[Either[A,B],C] { + type Clauses[Target] = Clauses3[A,B,C,Target] + + override def inject:Clauses[Either[Either[A,B],C]] = Clauses3( + first = a => Left(Left(a)), + second = b => Left(Right(b)), + third = c => Right(c) + ) + + override def fold[Result](either:Either[Either[A,B],C])(clauses: Clauses[Result]):Result = either.fold(_.fold(clauses.first, clauses.second), clauses.third) + + } +} + +//I didn't implement all layouts yet. 1or3 is the most common arising from chained `orElse` +trait Alternative4 extends Alternative3 { + + implicit def alternative2Or2[A,B,C,D]: Aux[Either[A,B],Either[C,D], ({type L[T] = Clauses4[A,B,C,D,T]})#L] = new Alternative[Either[A,B],Either[C,D]] { + type Clauses[Target] = Clauses4[A,B,C,D,Target] + + override def inject:Clauses[Either[Either[A,B],Either[C,D]]] = Clauses4( + first = a => Left(Left(a)), + second = b => Left(Right(b)), + third = c => Right(Left(c)), + fourth = d => Right(Right(d)) + ) + + override def fold[Result](either:Either[Either[A,B],Either[C,D]])(clauses: Clauses[Result]):Result = either.fold(_.fold(clauses.first, clauses.second), _.fold(clauses.third, clauses.fourth)) + + } + + + implicit def alternative1Or3[A,B,C,D]: Aux[A,Either[B,Either[C,D]], ({type L[T] = Clauses4[A,B,C,D,T]})#L] = new Alternative[A,Either[B,Either[C,D]]] { + type Clauses[Target] = Clauses4[A,B,C,D,Target] + + override def inject:Clauses[Either[A,Either[B,Either[C,D]]]] = Clauses4( + first = a => Left(a), + second = b => Right(Left(b)), + third = c => Right(Right(Left(c))), + fourth = d => Right(Right(Right(d))) + ) + + override def fold[Result](either:Either[A,Either[B,Either[C,D]]])(clauses: Clauses[Result]):Result = either.fold( + clauses.first, + _.fold( + clauses.second, + _.fold( + clauses.third, + clauses.fourth, + ) + ) + ) + + } + +} + +object Alternative extends Alternative4 + diff --git a/openapi/openapi/src/main/scala/endpoints4s/openapi/Urls.scala b/openapi/openapi/src/main/scala/endpoints4s/openapi/Urls.scala index b82aee334..b2435bbaa 100644 --- a/openapi/openapi/src/main/scala/endpoints4s/openapi/Urls.scala +++ b/openapi/openapi/src/main/scala/endpoints4s/openapi/Urls.scala @@ -83,6 +83,32 @@ trait Urls extends algebra.Urls { ) type QueryStringParam[A] = DocumentedQueryStringParam[A] + //TODO: This is kinda ugly. But discriminated schemas make little sense on query strings? + //Stil maybe this should be done better, but I haven't yet fully understood the openapi schema to figure out how to deal with nesting here + private def unpackSchema[A](qsp:QueryStringParam[A]):List[Schema] = qsp.schema match { + case oneOf : Schema.OneOf => oneOf.alternatives match { + case enumerated: Schema.EnumeratedAlternatives => enumerated.alternatives + case _ => List(qsp.schema) + } + case _ => List(qsp.schema) + } + + def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = { + DocumentedQueryStringParam[Either[A,B]]( + schema = Schema.OneOf( + alternatives = Schema.EnumeratedAlternatives( + unpackSchema(qspa) ::: unpackSchema(qspb) + ), + description = None, //TODO: What to put here? + example = None, //TODO: What to put here? + title = None,//TODO: What to put here? + ), + isRequired = qspa.isRequired || qspb.isRequired, //TODO: This feels wrong? + encoder = (either:Either[A,B]) => either.fold(qspa.encoder.encode, qspb.encoder.encode), + + ) + } + implicit def optionalQueryStringParam[A](implicit param: QueryStringParam[A] ): QueryStringParam[Option[A]] = diff --git a/openapi/openapi/src/test/scala/endpoints4s/openapi/EndpointsTest.scala b/openapi/openapi/src/test/scala/endpoints4s/openapi/EndpointsTest.scala index e43155486..d771f207e 100644 --- a/openapi/openapi/src/test/scala/endpoints4s/openapi/EndpointsTest.scala +++ b/openapi/openapi/src/test/scala/endpoints4s/openapi/EndpointsTest.scala @@ -1,5 +1,7 @@ package endpoints4s.openapi +import endpoints4s.Valid +import endpoints4s.Invalid import endpoints4s.algebra import endpoints4s.algebra.{ExternalDocumentationObject, Tag} import org.scalatest.OptionValues @@ -49,6 +51,24 @@ class EndpointsTest extends AnyWordSpec with Matchers with OptionValues { required = false, description = None, schema = Schema.Array(Left(Schema.simpleLong), None, None, None) + ) :: + Parameter( + "enum", + In.Query, + required = true, + description = None, + schema = Schema.OneOf( + Schema.EnumeratedAlternatives( + List( + Schema.simpleString, + Schema.simpleString, + Schema.simpleString, + ) + ), + None, + None, + None + ) ) :: Nil Fixtures.quux.item @@ -459,8 +479,36 @@ class EndpointsTest extends AnyWordSpec with Matchers with OptionValues { } } +sealed trait TestEnum +object TestEnum { + object TestEnumA extends TestEnum + object TestEnumB extends TestEnum + object TestEnumC extends TestEnum +} + trait Fixtures extends algebra.Endpoints with algebra.ChunkedEntities { + + implicit def testEnumQueryStringParam:QueryStringParam[TestEnum] = { + val testEnumA = stringQueryString.xmapPartial[TestEnum.TestEnumA.type]{ + case "enum_A" => Valid(TestEnum.TestEnumA) + case s => Invalid(Seq(s"Not TestEnum.TestEnumA, was: $s")) + }{_ => "enum_A"} + val testEnumB = stringQueryString.xmapPartial[TestEnum.TestEnumB.type]{ + case "enum_B" => Valid(TestEnum.TestEnumB) + case s => Invalid(Seq(s"Not TestEnum.TestEnumB, was: $s")) + }{_ => "enum_B"} + val testEnumC = stringQueryString.xmapPartial[TestEnum.TestEnumC.type]{ + case "enum_C" => Valid(TestEnum.TestEnumC) + case s => Invalid(Seq(s"Not TestEnum.TestEnumC, was: $s")) + }{_ => "enum_C"} + testEnumA.orElse(testEnumB).orElse(testEnumC).xmap[TestEnum]{_.left.map(_.merge).merge}{ + case TestEnum.TestEnumA => Left(Left(TestEnum.TestEnumA)) + case TestEnum.TestEnumB => Left(Right(TestEnum.TestEnumB)) + case TestEnum.TestEnumC => Right(TestEnum.TestEnumC) + } + } + val fooTag = Tag("foo") val barTag = Tag("bar").withDescription(Some("This is a bar.")) val bazTag = @@ -519,7 +567,8 @@ trait Fixtures extends algebra.Endpoints with algebra.ChunkedEntities { qs[Double]("n") & qs[Option[String]]("lang") & optQsWithDefault[Int]("page", 1) & - qs[List[Long]]("ids") + qs[List[Long]]("ids") & + qs[TestEnum]("enum") ) ), ok(emptyResponse) diff --git a/sttp/client/src/main/scala/endpoints4s/sttp/client/Urls.scala b/sttp/client/src/main/scala/endpoints4s/sttp/client/Urls.scala index ad0bb647e..6d2bde1b6 100644 --- a/sttp/client/src/main/scala/endpoints4s/sttp/client/Urls.scala +++ b/sttp/client/src/main/scala/endpoints4s/sttp/client/Urls.scala @@ -59,6 +59,8 @@ trait Urls extends algebra.Urls { /** a query string parameter can have zero or several values */ type QueryStringParam[A] = A => List[String] + override def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = (either:Either[A,B]) => either.fold(qspa, qspb) + implicit lazy val queryStringParamPartialInvariantFunctor : PartialInvariantFunctor[QueryStringParam] = new PartialInvariantFunctor[QueryStringParam] { diff --git a/xhr/client/src/main/scala/endpoints4s/xhr/Urls.scala b/xhr/client/src/main/scala/endpoints4s/xhr/Urls.scala index 3395d5517..176b23c64 100644 --- a/xhr/client/src/main/scala/endpoints4s/xhr/Urls.scala +++ b/xhr/client/src/main/scala/endpoints4s/xhr/Urls.scala @@ -86,6 +86,10 @@ trait Urls extends algebra.Urls { def encode(a: A): List[String] } + override def oneOfQueryStringParam[A,B](qspa: QueryStringParam[A], qspb: QueryStringParam[B]): QueryStringParam[Either[A,B]] = new QueryStringParam[Either[A,B]] { + override def encode(either: Either[A,B]) = either.fold(qspa.encode, qspb.encode) + } + implicit lazy val queryStringParamPartialInvariantFunctor : PartialInvariantFunctor[QueryStringParam] = new PartialInvariantFunctor[QueryStringParam] {