From bfac5650ba5fbad3569a89a8ca8710eb44130787 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 09:41:17 +0100 Subject: [PATCH 01/15] support some inline schema definitions on endpoints --- .../sttp/tapir/codegen/BasicGenerator.scala | 6 +- .../codegen/ClassDefinitionGenerator.scala | 161 ++++++++++-------- .../tapir/codegen/EndpointGenerator.scala | 127 ++++++++++---- .../sttp/tapir/codegen/EnumGenerator.scala | 114 +++++++++++++ .../tapir/codegen/JsonSerdeGenerator.scala | 2 +- .../sttp/tapir/codegen/SchemaGenerator.scala | 30 ++-- .../ClassDefinitionGeneratorSpec.scala | 32 +++- .../tapir/codegen/EndpointGeneratorSpec.scala | 12 +- .../caching/project/build.properties | 2 +- .../minimal/project/build.properties | 2 +- .../option-overrides/project/build.properties | 2 +- 11 files changed, 359 insertions(+), 131 deletions(-) create mode 100644 openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala index 6e34c92d23..9e0ba6e6ee 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala @@ -48,7 +48,8 @@ object BasicGenerator { JsonSerdeLib.Circe } - val EndpointDefs(endpointsByTag, queryParamRefs, jsonParamRefs) = endpointGenerator.endpointDefs(doc, useHeadTagForObjectNames) + val EndpointDefs(endpointsByTag, queryParamRefs, jsonParamRefs, enumsDefinedOnEndpointParams) = + endpointGenerator.endpointDefs(doc, useHeadTagForObjectNames, targetScala3, normalisedJsonLib) val GeneratedClassDefinitions(classDefns, jsonSerdes, schemas) = classGenerator .classDefs( @@ -59,7 +60,8 @@ object BasicGenerator { jsonParamRefs = jsonParamRefs, fullModelPath = s"$packagePath.$objName", validateNonDiscriminatedOneOfs = validateNonDiscriminatedOneOfs, - maxSchemasPerFile = maxSchemasPerFile + maxSchemasPerFile = maxSchemasPerFile, + enumsDefinedOnEndpointParams = enumsDefinedOnEndpointParams ) .getOrElse(GeneratedClassDefinitions("", None, Nil)) val hasJsonSerdes = jsonSerdes.nonEmpty diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index 2fd3dc2395..a1df6a4106 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -19,12 +19,13 @@ class ClassDefinitionGenerator { jsonParamRefs: Set[String] = Set.empty, fullModelPath: String = "", validateNonDiscriminatedOneOfs: Boolean = true, - maxSchemasPerFile: Int = 400 + maxSchemasPerFile: Int = 400, + enumsDefinedOnEndpointParams: Boolean = false ): Option[GeneratedClassDefinitions] = { val allSchemas: Map[String, OpenapiSchemaType] = doc.components.toSeq.flatMap(_.schemas).toMap val allOneOfSchemas = allSchemas.collect { case (name, oneOf: OpenapiSchemaOneOf) => name -> oneOf }.toSeq val adtInheritanceMap: Map[String, Seq[String]] = mkMapParentsByChild(allOneOfSchemas) - val generatesQueryParamEnums = + val generatesQueryParamEnums = enumsDefinedOnEndpointParams || allSchemas .collect { case (name, _: OpenapiSchemaEnum) => name } .exists(queryParamRefs.contains) @@ -54,9 +55,9 @@ class ClassDefinitionGenerator { val defns = doc.components .map(_.schemas.flatMap { case (name, obj: OpenapiSchemaObject) => - generateClass(allSchemas, name, obj, allTransitiveJsonParamRefs, adtInheritanceMap) + generateClass(allSchemas, name, obj, allTransitiveJsonParamRefs, adtInheritanceMap, jsonSerdeLib, targetScala3) case (name, obj: OpenapiSchemaEnum) => - generateEnum(name, obj, targetScala3, queryParamRefs, jsonSerdeLib, allTransitiveJsonParamRefs) + EnumGenerator.generateEnum(name, obj, targetScala3, queryParamRefs, jsonSerdeLib, allTransitiveJsonParamRefs) case (name, OpenapiSchemaMap(valueSchema, _)) => generateMap(name, valueSchema) case (_, _: OpenapiSchemaOneOf) => Nil case (n, x) => throw new NotImplementedError(s"Only objects, enums and maps supported! (for $n found ${x})") @@ -102,13 +103,14 @@ class ClassDefinitionGenerator { | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e | ) | - |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = + |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) | sttp.tapir.Codec | .listHead[String, String, sttp.tapir.CodecFormat.TextPlain] | .mapDecode(s => | // Case-insensitive mapping | scala.util - | .Try(enumMap[T](using enumextensions.EnumMirror[T])(s.toUpperCase)) + | .Try(eMap(s.toUpperCase)) | .fold( | _ => | sttp.tapir.DecodeResult.Error( @@ -120,6 +122,25 @@ class ClassDefinitionGenerator { | sttp.tapir.DecodeResult.Value(_) | ) | )(_.name) + |} + | + |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec + | .list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => + | // Case-insensitive mapping + | scala.util + | .Try(values.map(s => eMap(s.toUpperCase))) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | values.mkString(","), + | new NoSuchElementException( + | s"Could not find all values $values for enum ${enumextensions.EnumMirror[ + | T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}")), + | sttp.tapir.DecodeResult.Value(_)))(_.map(_.name)) + |} |""".stripMargin else """def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = @@ -138,6 +159,23 @@ class ClassDefinitionGenerator { | sttp.tapir.DecodeResult.Value(_) | ) | )(_.entryName) + | + |def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = + | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => + | // Case-insensitive mapping + | scala.util.Try(values.map(s => T.upperCaseNameValuesToMap(s.toUpperCase))) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | values.mkString(","), + | new NoSuchElementException( + | s"Could not find all values $values for enum ${enumName}, available values: ${T.values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) + | )(_.map(_.entryName)) |""".stripMargin @tailrec @@ -191,63 +229,14 @@ class ClassDefinitionGenerator { Seq(s"""type $name = Map[String, $valueSchemaName]""") } - // Uses enumeratum for scala 2, but generates scala 3 enums instead where it can - private[codegen] def generateEnum( - name: String, - obj: OpenapiSchemaEnum, - targetScala3: Boolean, - queryParamRefs: Set[String], - jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, - jsonParamRefs: Set[String] - ): Seq[String] = if (targetScala3) { - val maybeCompanion = - if (queryParamRefs contains name) - s""" - |object $name { - | given stringList${name}Codec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum[$name] - |}""".stripMargin - else "" - val maybeCodecExtensions = jsonSerdeLib match { - case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" - case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror" - case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec" - case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror" - case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" - case JsonSerdeLib.Jsoniter => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" - } - s"""$maybeCompanion - |enum $name$maybeCodecExtensions { - | case ${obj.items.map(_.value).mkString(", ")} - |}""".stripMargin :: Nil - } else { - val uncapitalisedName = BasicGenerator.uncapitalise(name) - val members = obj.items.map { i => s"case object ${i.value} extends $name" } - val maybeCodecExtension = jsonSerdeLib match { - case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" - case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" - case JsonSerdeLib.Jsoniter => "" - } - val maybeQueryCodecDefn = - if (queryParamRefs contains name) - s""" - | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], ${name}, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum("${name}", ${name})""".stripMargin - else "" - s""" - |sealed trait $name extends enumeratum.EnumEntry - |object $name extends enumeratum.Enum[$name]$maybeCodecExtension { - | val values = findValues - |${indent(2)(members.mkString("\n"))}$maybeQueryCodecDefn - |}""".stripMargin :: Nil - } - private[codegen] def generateClass( allSchemas: Map[String, OpenapiSchemaType], name: String, obj: OpenapiSchemaObject, jsonParamRefs: Set[String], - adtInheritanceMap: Map[String, Seq[String]] + adtInheritanceMap: Map[String, Seq[String]], + jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, + targetScala3: Boolean ): Seq[String] = { val isJson = jsonParamRefs contains name def rec(name: String, obj: OpenapiSchemaObject, acc: List[String]): Seq[String] = { @@ -268,24 +257,25 @@ class ClassDefinitionGenerator { .flatten .toList - val properties = obj.properties.map { case (key, OpenapiSchemaField(schemaType, maybeDefault)) => - val tpe = mapSchemaTypeToType(name, key, obj.required.contains(key), schemaType, isJson) + val (properties, maybeEnums) = obj.properties.map { case (key, OpenapiSchemaField(schemaType, maybeDefault)) => + val (tpe, maybeEnum) = mapSchemaTypeToType(name, key, obj.required.contains(key), schemaType, isJson, jsonSerdeLib, targetScala3) val fixedKey = fixKey(key) val optional = schemaType.nullable || !obj.required.contains(key) val maybeExplicitDefault = maybeDefault.map(" = " + DefaultValueRenderer.render(allModels = allSchemas, thisType = schemaType, optional)(_)) val default = maybeExplicitDefault getOrElse (if (optional) " = None" else "") - s"$fixedKey: $tpe$default" - } + s"$fixedKey: $tpe$default" -> maybeEnum + }.unzip val parents = adtInheritanceMap.getOrElse(name, Nil) match { case Nil => "" case ps => ps.mkString(" extends ", " with ", "") } + val enumDefn = maybeEnums.collect { case Some(defn) => defn }.toList s"""|case class $name ( |${indent(2)(properties.mkString(",\n"))} - |)$parents""".stripMargin :: innerClasses ::: acc + |)$parents""".stripMargin :: innerClasses ::: enumDefn ::: acc } rec(addName("", name), obj, Nil) @@ -296,28 +286,53 @@ class ClassDefinitionGenerator { key: String, required: Boolean, schemaType: OpenapiSchemaType, - isJson: Boolean - ): String = { - val (tpe, optional) = schemaType match { + isJson: Boolean, + jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, + targetScala3: Boolean + ): (String, Option[String]) = { + val ((tpe, optional), maybeEnum) = schemaType match { case simpleType: OpenapiSchemaSimpleType => - mapSchemaSimpleTypeToType(simpleType, multipartForm = !isJson) + mapSchemaSimpleTypeToType(simpleType, multipartForm = !isJson) -> None case objectType: OpenapiSchemaObject => - addName(parentName, key) -> objectType.nullable + (addName(parentName, key) -> objectType.nullable, None) case mapType: OpenapiSchemaMap => - val innerType = mapSchemaTypeToType(addName(parentName, key), "item", required = true, mapType.items, isJson = isJson) - s"Map[String, $innerType]" -> mapType.nullable + val (innerType, maybeEnum) = + mapSchemaTypeToType(addName(parentName, key), "item", required = true, mapType.items, isJson = isJson, jsonSerdeLib, targetScala3) + (s"Map[String, $innerType]" -> mapType.nullable, maybeEnum) case arrayType: OpenapiSchemaArray => - val innerType = mapSchemaTypeToType(addName(parentName, key), "item", required = true, arrayType.items, isJson = isJson) - s"Seq[$innerType]" -> arrayType.nullable + val (innerType, maybeEnum) = + mapSchemaTypeToType( + addName(parentName, key), + "item", + required = true, + arrayType.items, + isJson = isJson, + jsonSerdeLib, + targetScala3 + ) + (s"Seq[$innerType]" -> arrayType.nullable, maybeEnum) + + case e: OpenapiSchemaEnum => + val enumName = addName(parentName.capitalize, key) + val enumDefn = EnumGenerator.generateEnum( + enumName, + e, + targetScala3, + Set.empty, + jsonSerdeLib, + if (isJson) Set(enumName) else Set.empty, + false + ) + (enumName -> e.nullable, Some(enumDefn.mkString("\n"))) case _ => throw new NotImplementedError(s"We can't serialize some of the properties yet! $parentName $key $schemaType") } - if (optional || !required) s"Option[$tpe]" else tpe + (if (optional || !required) s"Option[$tpe]" else tpe, maybeEnum) } private def addName(parentName: String, key: String) = parentName + key.replace('_', ' ').replace('-', ' ').capitalize.replace(" ", "") diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index 69df3a0f83..6aa1169af1 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -1,11 +1,14 @@ package sttp.tapir.codegen import io.circe.Json import sttp.tapir.codegen.BasicGenerator.{indent, mapSchemaSimpleTypeToType, strippedToCamelCase} +import sttp.tapir.codegen.JsonSerdeLib.JsonSerdeLib import sttp.tapir.codegen.openapi.models.OpenapiModels.{OpenapiDocument, OpenapiParameter, OpenapiPath, OpenapiRequestBody, OpenapiResponse} import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{ OpenapiSchemaAny, OpenapiSchemaArray, OpenapiSchemaBinary, + OpenapiSchemaEnum, + OpenapiSchemaMap, OpenapiSchemaRef, OpenapiSchemaSimpleType } @@ -17,36 +20,48 @@ case class Location(path: String, method: String) { } case class GeneratedEndpoints( - namesAndBodies: Seq[(Option[String], Seq[(String, String)])], + namesBodiesAndEnums: Seq[(Option[String], Seq[(String, String, Option[String])])], queryParamRefs: Set[String], - jsonParamRefs: Set[String] + jsonParamRefs: Set[String], + definesEnumQueryParam: Boolean ) { def merge(that: GeneratedEndpoints): GeneratedEndpoints = GeneratedEndpoints( - (namesAndBodies ++ that.namesAndBodies).groupBy(_._1).mapValues(_.map(_._2).reduce(_ ++ _)).toSeq, + (namesBodiesAndEnums ++ that.namesBodiesAndEnums).groupBy(_._1).mapValues(_.map(_._2).reduce(_ ++ _)).toSeq, queryParamRefs ++ that.queryParamRefs, - jsonParamRefs ++ that.jsonParamRefs + jsonParamRefs ++ that.jsonParamRefs, + definesEnumQueryParam || that.definesEnumQueryParam ) } -case class EndpointDefs(endpointDecls: Map[Option[String], String], queryParamRefs: Set[String], jsonParamRefs: Set[String]) +case class EndpointDefs( + endpointDecls: Map[Option[String], String], + queryParamRefs: Set[String], + jsonParamRefs: Set[String], + enumsDefinedOnEndpointParams: Boolean +) class EndpointGenerator { private def bail(msg: String)(implicit location: Location): Nothing = throw new NotImplementedError(s"$msg at $location") private[codegen] def allEndpoints: String = "generatedEndpoints" - def endpointDefs(doc: OpenapiDocument, useHeadTagForObjectNames: Boolean): EndpointDefs = { + def endpointDefs( + doc: OpenapiDocument, + useHeadTagForObjectNames: Boolean, + targetScala3: Boolean, + jsonSerdeLib: JsonSerdeLib + ): EndpointDefs = { val components = Option(doc.components).flatten - val GeneratedEndpoints(geMap, queryParamRefs, jsonParamRefs) = + val GeneratedEndpoints(geMap, queryParamRefs, jsonParamRefs, definesEnumQueryParam) = doc.paths - .map(generatedEndpoints(components, useHeadTagForObjectNames)) - .foldLeft(GeneratedEndpoints(Nil, Set.empty, Set.empty))(_ merge _) + .map(generatedEndpoints(components, useHeadTagForObjectNames, targetScala3, jsonSerdeLib)) + .foldLeft(GeneratedEndpoints(Nil, Set.empty, Set.empty, false))(_ merge _) val endpointDecls = geMap.map { case (k, ge) => val definitions = ge - .map { case (name, definition) => - s"""|lazy val $name = - |${indent(2)(definition)} - |""".stripMargin + .map { case (name, definition, maybeEnums) => + s"""lazy val $name = + |${indent(2)(definition)}${maybeEnums.fold("")("\n" + _)} + |""".stripMargin } .mkString("\n") val allEP = s"lazy val $allEndpoints = List(${ge.map(_._1).mkString(", ")})" @@ -56,16 +71,19 @@ class EndpointGenerator { |$allEP |""".stripMargin }.toMap - EndpointDefs(endpointDecls, queryParamRefs, jsonParamRefs) + EndpointDefs(endpointDecls, queryParamRefs, jsonParamRefs, definesEnumQueryParam) } - private[codegen] def generatedEndpoints(components: Option[OpenapiComponent], useHeadTagForObjectNames: Boolean)( - p: OpenapiPath - ): GeneratedEndpoints = { + private[codegen] def generatedEndpoints( + components: Option[OpenapiComponent], + useHeadTagForObjectNames: Boolean, + targetScala3: Boolean, + jsonSerdeLib: JsonSerdeLib + )(p: OpenapiPath): GeneratedEndpoints = { val parameters = components.map(_.parameters).getOrElse(Map.empty) val securitySchemes = components.map(_.securitySchemes).getOrElse(Map.empty) - val (fileNamesAndParams, unflattenedParamRefs) = p.methods + val (fileNamesAndParams, unflattenedParamRefs, definesParams) = p.methods .map(_.withResolvedParentParameters(parameters, p.parameters)) .map { m => implicit val location: Location = Location(p.url, m.methodType) @@ -81,18 +99,19 @@ class EndpointGenerator { } } + val name = strippedToCamelCase(m.operationId.getOrElse(m.methodType + p.url.capitalize)) + val (inParams, maybeLocalEnums) = ins(m.resolvedParameters, m.requestBody, name, targetScala3, jsonSerdeLib) val definition = s"""|endpoint | .${m.methodType} | ${urlMapper(p.url, m.resolvedParameters)} |${indent(2)(security(securitySchemes, m.security))} - |${indent(2)(ins(m.resolvedParameters, m.requestBody))} + |${indent(2)(inParams)} |${indent(2)(outs(m.responses))} |${indent(2)(tags(m.tags))} |$attributeString |""".stripMargin.linesIterator.filterNot(_.trim.isEmpty).mkString("\n") - val name = strippedToCamelCase(m.operationId.getOrElse(m.methodType + p.url.capitalize)) val maybeTargetFileName = if (useHeadTagForObjectNames) m.tags.flatMap(_.headOption) else None val queryParamRefs = m.resolvedParameters .collect { case queryParam: OpenapiParameter if queryParam.in == "query" => queryParam.schema } @@ -111,11 +130,14 @@ class EndpointGenerator { s"List[$name]" case simple: OpenapiSchemaSimpleType => BasicGenerator.mapSchemaSimpleTypeToType(simple)._1 + case OpenapiSchemaMap(simple: OpenapiSchemaSimpleType, _) => + val name = BasicGenerator.mapSchemaSimpleTypeToType(simple)._1 + s"Map[String, $name]" } .toSet - ((maybeTargetFileName, (name, definition)), (queryParamRefs, jsonParamRefs)) + ((maybeTargetFileName, (name, definition, maybeLocalEnums)), (queryParamRefs, jsonParamRefs), maybeLocalEnums.isDefined) } - .unzip + .unzip3 val (unflattenedQueryParamRefs, unflattenedJsonParamRefs) = unflattenedParamRefs.unzip val namesAndParamsByFile = fileNamesAndParams .groupBy(_._1) @@ -124,7 +146,8 @@ class EndpointGenerator { GeneratedEndpoints( namesAndParamsByFile, unflattenedQueryParamRefs.foldLeft(Set.empty[String])(_ ++ _), - unflattenedJsonParamRefs.foldLeft(Set.empty[String])(_ ++ _) + unflattenedJsonParamRefs.foldLeft(Set.empty[String])(_ ++ _), + definesParams.contains(true) ) } @@ -173,10 +196,32 @@ class EndpointGenerator { } } - private def ins(parameters: Seq[OpenapiParameter], requestBody: Option[OpenapiRequestBody])(implicit location: Location): String = { + private def ins( + parameters: Seq[OpenapiParameter], + requestBody: Option[OpenapiRequestBody], + endpointName: String, + targetScala3: Boolean, + jsonSerdeLib: JsonSerdeLib + )(implicit location: Location): (String, Option[String]) = { + def getEnumParamDefn(param: OpenapiParameter, e: OpenapiSchemaEnum, isArray: Boolean) = { + val enumName = endpointName.capitalize + strippedToCamelCase(param.name).capitalize + val queryParamRefs = if (param.in == "query") Set(enumName) else Set.empty[String] + val enumDefn = EnumGenerator.generateEnum( + enumName, + e, + targetScala3, + queryParamRefs, + jsonSerdeLib, + Set.empty, + isArray + ) + val tpe = if (isArray) s"List[$enumName]" else enumName + val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") + s""".in(${param.in}[$tpe]("${param.name}")$desc)""" -> Some(enumDefn) + } // .in(query[Limit]("limit").description("Maximum number of books to retrieve")) // .in(header[AuthToken]("X-Auth-Token")) - val params = parameters + val (params, maybeEnumDefns) = parameters .filter(_.in != "path") .map { param => param.schema match { @@ -184,18 +229,29 @@ class EndpointGenerator { val (t, _) = mapSchemaSimpleTypeToType(st) val req = if (param.required.getOrElse(true)) t else s"Option[$t]" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") - s""".in(${param.in}[$req]("${param.name}")$desc)""" - case x => bail(s"Can't create non-simple params to input - found $x") + s""".in(${param.in}[$req]("${param.name}")$desc)""" -> None + case OpenapiSchemaArray(st: OpenapiSchemaSimpleType, _) => + val (t, _) = mapSchemaSimpleTypeToType(st) + val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") + s""".in(${param.in}[List[$t]]("${param.name}")$desc)""" -> None + case e @ OpenapiSchemaEnum(_, _, _) => getEnumParamDefn(param, e, isArray = false) + case OpenapiSchemaArray(e: OpenapiSchemaEnum, _) => getEnumParamDefn(param, e, isArray = true) + case x => bail(s"Can't create non-simple params to input - found $x") } } + .unzip val rqBody = requestBody.flatMap { b => if (b.content.isEmpty) None - else if (b.content.size != 1) bail("We can handle only one requestBody content!") + else if (b.content.size != 1) bail(s"We can handle only one requestBody content! Saw ${b.content.map(_.contentType)}") else Some(s".in(${contentTypeMapper(b.content.head.contentType, b.content.head.schema, b.required)})") } - (params ++ rqBody).mkString("\n") + (params ++ rqBody).mkString("\n") -> maybeEnumDefns.foldLeft(Option.empty[String]) { + case (acc, None) => acc + case (None, Some(nxt)) => Some(nxt.mkString("\n")) + case (Some(acc), Some(nxt)) => Some(acc + "\n" + nxt) + } } private def tags(openapiTags: Option[Seq[String]]): String = { @@ -222,6 +278,7 @@ class EndpointGenerator { private def outs(responses: Seq[OpenapiResponse])(implicit location: Location) = { // .errorOut(stringBody) // .out(jsonBody[List[Book]]) + responses .map { resp => val d = s""".description("${JavaEscape.escapeString(resp.description)}")""" @@ -265,6 +322,9 @@ class EndpointGenerator { case OpenapiSchemaArray(st: OpenapiSchemaSimpleType, _) => val (t, _) = mapSchemaSimpleTypeToType(st) s"List[$t]" + case OpenapiSchemaMap(st: OpenapiSchemaSimpleType, _) => + val (t, _) = mapSchemaSimpleTypeToType(st) + s"Map[String, $t]" case x => bail(s"Can't create non-simple or array params as output (found $x)") } val req = if (required) outT else s"Option[$outT]" @@ -279,6 +339,15 @@ class EndpointGenerator { s"multipartBody[$t]" case x => bail(s"$contentType only supports schema ref or binary. Found $x") } + case "application/octet-stream" => + schema match { + case _: OpenapiSchemaBinary => + "multipartBody" + case schemaRef: OpenapiSchemaRef => + val (t, _) = mapSchemaSimpleTypeToType(schemaRef, multipartForm = true) + s"multipartBody[$t]" + case x => bail(s"$contentType only supports schema ref or binary. Found $x") + } case x => bail(s"Not all content types supported! Found $x") } diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala new file mode 100644 index 0000000000..24cdb8bbeb --- /dev/null +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -0,0 +1,114 @@ +package sttp.tapir.codegen + +import sttp.tapir.codegen.BasicGenerator.indent +import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.OpenapiSchemaEnum + +object EnumGenerator { + + // Uses enumeratum for scala 2, but generates scala 3 enums instead where it can + private[codegen] def generateEnum( + name: String, + obj: OpenapiSchemaEnum, + targetScala3: Boolean, + queryParamRefs: Set[String], + jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, + jsonParamRefs: Set[String], + isArray: Boolean = false + ): Seq[String] = { + def helperName = if (isArray) "makeQuerySeqCodecForEnum" else "makeQueryCodecForEnum" + def highLevelType = if (isArray) s"List[$name]" else name + if (targetScala3) { + val maybeCompanion = + if (queryParamRefs contains name) { + s""" + |object $name { + | given stringList${name}Codec: sttp.tapir.Codec[List[String], $highLevelType, sttp.tapir.CodecFormat.TextPlain] = + | $helperName[$name] + |}""".stripMargin + } else "" + val maybeCodecExtensions = jsonSerdeLib match { + case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" + case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror" + case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec" + case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror" + case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" + case JsonSerdeLib.Jsoniter => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" + } + s"""$maybeCompanion + |enum $name$maybeCodecExtensions { + | case ${obj.items.map(_.value).mkString(", ")} + |}""".stripMargin :: Nil + } else { + val uncapitalisedName = BasicGenerator.uncapitalise(name) + val members = obj.items.map { i => s"case object ${i.value} extends $name" } + val maybeCodecExtension = jsonSerdeLib match { + case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" + case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" + case JsonSerdeLib.Jsoniter => "" + } + val maybeQueryCodecDefn = + if (queryParamRefs contains name) + s""" + | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], ${highLevelType}, sttp.tapir.CodecFormat.TextPlain] = + | $helperName("${name}", ${name})""".stripMargin + else "" + s""" + |sealed trait $name extends enumeratum.EnumEntry + |object $name extends enumeratum.Enum[$name]$maybeCodecExtension { + | val values = findValues + |${indent(2)(members.mkString("\n"))}$maybeQueryCodecDefn + |}""".stripMargin :: Nil + } + } + /* + // Uses enumeratum for scala 2, but generates scala 3 enums instead where it can + private[codegen] def generateEnum( + name: String, + obj: OpenapiSchemaEnum, + targetScala3: Boolean, + queryParamRefs: Set[String], + jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, + jsonParamRefs: Set[String] + ): Seq[String] = if (targetScala3) { + val maybeCompanion = + if (queryParamRefs contains name) + s""" + |object $name { + | given stringList${name}Codec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = + | makeQueryCodecForEnum[$name] + |}""".stripMargin + else "" + val maybeCodecExtensions = jsonSerdeLib match { + case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" + case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror" + case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec" + case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror" + case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" + case JsonSerdeLib.Jsoniter => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" + } + s"""$maybeCompanion + |enum $name$maybeCodecExtensions { + | case ${obj.items.map(_.value).mkString(", ")} + |}""".stripMargin :: Nil + } else { + val uncapitalisedName = BasicGenerator.uncapitalise(name) + val members = obj.items.map { i => s"case object ${i.value} extends $name" } + val maybeCodecExtension = jsonSerdeLib match { + case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" + case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" + case JsonSerdeLib.Jsoniter => "" + } + val maybeQueryCodecDefn = + if (queryParamRefs contains name) + s""" + | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], ${name}, sttp.tapir.CodecFormat.TextPlain] = + | makeQueryCodecForEnum("${name}", ${name})""".stripMargin + else "" + s""" + |sealed trait $name extends enumeratum.EnumEntry + |object $name extends enumeratum.Enum[$name]$maybeCodecExtension { + | val values = findValues + |${indent(2)(members.mkString("\n"))}$maybeQueryCodecDefn + |}""".stripMargin :: Nil + }*/ +} diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala index 213113b725..1b6587317b 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala @@ -240,7 +240,7 @@ object JsonSerdeGenerator { val additionalExplicitSerdes = jsonParamRefs.toSeq .filter(x => !allSchemas.contains(x)) .map { s => - val name = s.replace("[", "_").replace("]", "_").replace(".", "_") + "JsonCodec" + val name = s.replace(" ","").replace(",","_").replace("[", "_").replace("]", "_").replace(".", "_") + "JsonCodec" s"""implicit lazy val $name: $jsoniterPkgCore.JsonValueCodec[$s] = | $jsoniterPkgMacros.JsonCodecMaker.make[$s]""".stripMargin } diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala index 53a3b44c3e..e96c75056c 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala @@ -152,40 +152,40 @@ object SchemaGenerator { } res.toSeq } - // find all simple reference loops starting at a a single schema (e.g. A -> B -> C -> A) + // find all simple reference loops starting at a single schema (e.g. A -> B -> C -> A) private def getReferencesToXInY( allSchemas: Map[String, OpenapiSchemaType], - referrent: String, // The stripped ref of the schema we're looking for references to - referenceCandidate: OpenapiSchemaType, // candidate for mutually-recursive referrence + referent: String, // The stripped ref of the schema we're looking for references to + referenceCandidate: OpenapiSchemaType, // candidate for mutually-recursive reference checked: Set[String], // refs we've already checked - maybeRefs: Seq[String] // chain of refs from referrent -> [...maybeRefs] -> referenceCandidate + maybeRefs: Seq[String] // chain of refs from referent -> [...maybeRefs] -> referenceCandidate ): Set[String] = referenceCandidate match { case ref: OpenapiSchemaRef => val stripped = ref.stripped - // in this case, we have a chain of referrences from referrent -> [...maybeRefs] -> referrent, creating a mutually-recursive loop - if (stripped == referrent) maybeRefs.toSet + // in this case, we have a chain of references from referent -> [...maybeRefs] -> referent, creating a mutually-recursive loop + if (stripped == referent) maybeRefs.toSet // if already checked, skip else if (checked contains stripped) Set.empty // else add the ref to 'maybeRefs' chain and descend else { allSchemas .get(ref.stripped) - .map(getReferencesToXInY(allSchemas, referrent, _, checked + stripped, maybeRefs :+ stripped)) + .map(getReferencesToXInY(allSchemas, referent, _, checked + stripped, maybeRefs :+ stripped)) .toSet .flatten } - // these types cannot contain a referrence + // these types cannot contain a reference case _: OpenapiSchemaSimpleType | _: OpenapiSchemaEnum | _: OpenapiSchemaConstantString => Set.empty // descend into the sole child type - case OpenapiSchemaArray(items, _) => getReferencesToXInY(allSchemas, referrent, items, checked, maybeRefs) - case OpenapiSchemaNot(items) => getReferencesToXInY(allSchemas, referrent, items, checked, maybeRefs) - case OpenapiSchemaMap(items, _) => getReferencesToXInY(allSchemas, referrent, items, checked, maybeRefs) + case OpenapiSchemaArray(items, _) => getReferencesToXInY(allSchemas, referent, items, checked, maybeRefs) + case OpenapiSchemaNot(items) => getReferencesToXInY(allSchemas, referent, items, checked, maybeRefs) + case OpenapiSchemaMap(items, _) => getReferencesToXInY(allSchemas, referent, items, checked, maybeRefs) // descend into all child types - case OpenapiSchemaOneOf(items, _) => items.flatMap(getReferencesToXInY(allSchemas, referrent, _, checked, maybeRefs)).toSet - case OpenapiSchemaAllOf(items) => items.flatMap(getReferencesToXInY(allSchemas, referrent, _, checked, maybeRefs)).toSet - case OpenapiSchemaAnyOf(items) => items.flatMap(getReferencesToXInY(allSchemas, referrent, _, checked, maybeRefs)).toSet + case OpenapiSchemaOneOf(items, _) => items.flatMap(getReferencesToXInY(allSchemas, referent, _, checked, maybeRefs)).toSet + case OpenapiSchemaAllOf(items) => items.flatMap(getReferencesToXInY(allSchemas, referent, _, checked, maybeRefs)).toSet + case OpenapiSchemaAnyOf(items) => items.flatMap(getReferencesToXInY(allSchemas, referent, _, checked, maybeRefs)).toSet case OpenapiSchemaObject(kvs, _, _) => - kvs.values.flatMap(v => getReferencesToXInY(allSchemas, referrent, v.`type`, checked, maybeRefs)).toSet + kvs.values.flatMap(v => getReferencesToXInY(allSchemas, referent, v.`type`, checked, maybeRefs)).toSet } private def schemaForObject(name: String, schema: OpenapiSchemaObject): String = { diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index 537e280354..8cff3f1f29 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -304,13 +304,14 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | Map.from( | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e | ) - |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = + |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) | sttp.tapir.Codec | .listHead[String, String, sttp.tapir.CodecFormat.TextPlain] | .mapDecode(s => | // Case-insensitive mapping | scala.util - | .Try(enumMap[T](using enumextensions.EnumMirror[T])(s.toUpperCase)) + | .Try(eMap(s.toUpperCase)) | .fold( | _ => | sttp.tapir.DecodeResult.Error( @@ -322,6 +323,24 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | sttp.tapir.DecodeResult.Value(_) | ) | )(_.name) + |} + |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec + | .list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => + | // Case-insensitive mapping + | scala.util + | .Try(values.map(s => eMap(s.toUpperCase))) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | values.mkString(","), + | new NoSuchElementException( + | s"Could not find all values $values for enum ${enumextensions.EnumMirror[ + | T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}")), + | sttp.tapir.DecodeResult.Value(_)))(_.map(_.name)) + |} |object Test { | given stringListTestCodec: sttp.tapir.Codec[List[String], Test, sttp.tapir.CodecFormat.TextPlain] = | makeQueryCodecForEnum[Test] @@ -388,7 +407,10 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { val res: String = parserRes match { case Left(value) => throw new Exception(value) - case Right(doc) => new EndpointGenerator().endpointDefs(doc, useHeadTagForObjectNames = false).endpointDecls(None) + case Right(doc) => + new EndpointGenerator() + .endpointDefs(doc, useHeadTagForObjectNames = false, targetScala3 = false, jsonSerdeLib = JsonSerdeLib.Circe) + .endpointDecls(None) } val compileUnit = @@ -490,7 +512,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { |""".stripMargin val gen = new ClassDefinitionGenerator() def testOK(doc: OpenapiDocument) = { - val GeneratedClassDefinitions(res, jsonSerdes, schemas) = + val GeneratedClassDefinitions(res, jsonSerdes, _) = gen .classDefs( doc, @@ -523,7 +545,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { |""".stripMargin val gen = new ClassDefinitionGenerator() def testOK(doc: OpenapiDocument) = { - val GeneratedClassDefinitions(res, jsonSerdes, schemas) = + val GeneratedClassDefinitions(res, jsonSerdes, _) = gen.classDefs(doc, false, jsonSerdeLib = JsonSerdeLib.Circe, jsonParamRefs = Set("ReqWithVariants")).get val fullRes = (res + "\n" + jsonSerdes.get) diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala index 68e42db635..36ae285574 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala @@ -61,7 +61,9 @@ class EndpointGeneratorSpec extends CompileCheckTestBase { null ) val generatedCode = BasicGenerator.imports(JsonSerdeLib.Circe) ++ - new EndpointGenerator().endpointDefs(doc, useHeadTagForObjectNames = false).endpointDecls(None) + new EndpointGenerator() + .endpointDefs(doc, useHeadTagForObjectNames = false, targetScala3 = false, jsonSerdeLib = JsonSerdeLib.Circe) + .endpointDecls(None) generatedCode should include("val getTestAsdId =") generatedCode should include(""".in(query[Option[String]]("fgh-id"))""") generatedCode should include(""".in(header[Option[String]]("jkl-id"))""") @@ -138,7 +140,9 @@ class EndpointGeneratorSpec extends CompileCheckTestBase { ) ) BasicGenerator.imports(JsonSerdeLib.Circe) ++ - new EndpointGenerator().endpointDefs(doc, useHeadTagForObjectNames = false).endpointDecls(None) shouldCompile () + new EndpointGenerator() + .endpointDefs(doc, useHeadTagForObjectNames = false, targetScala3 = false, jsonSerdeLib = JsonSerdeLib.Circe) + .endpointDecls(None) shouldCompile () } it should "handle status codes" in { @@ -182,7 +186,9 @@ class EndpointGeneratorSpec extends CompileCheckTestBase { null ) val generatedCode = BasicGenerator.imports(JsonSerdeLib.Circe) ++ - new EndpointGenerator().endpointDefs(doc, useHeadTagForObjectNames = false).endpointDecls(None) + new EndpointGenerator() + .endpointDefs(doc, useHeadTagForObjectNames = false, targetScala3 = false, jsonSerdeLib = JsonSerdeLib.Circe) + .endpointDecls(None) generatedCode should include( """.out(stringBody.description("Processing").and(statusCode(sttp.model.StatusCode(202))))""" ) // status code with body diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/caching/project/build.properties b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/caching/project/build.properties index 0837f7a132..081fdbbc76 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/caching/project/build.properties +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/caching/project/build.properties @@ -1 +1 @@ -sbt.version=1.3.13 +sbt.version=1.10.0 diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/minimal/project/build.properties b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/minimal/project/build.properties index 0837f7a132..081fdbbc76 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/minimal/project/build.properties +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/minimal/project/build.properties @@ -1 +1 @@ -sbt.version=1.3.13 +sbt.version=1.10.0 diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/option-overrides/project/build.properties b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/option-overrides/project/build.properties index 0837f7a132..081fdbbc76 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/option-overrides/project/build.properties +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/option-overrides/project/build.properties @@ -1 +1 @@ -sbt.version=1.3.13 +sbt.version=1.10.0 From bd5b1b728a8a4289aec79e71b519a6e257cdc2d3 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 10:36:50 +0100 Subject: [PATCH 02/15] add json enum support for zio-json in scala 2.12, and add json enum tests for various libs to scripted tests --- .../codegen/ClassDefinitionGenerator.scala | 3 +- .../sttp/tapir/codegen/EnumGenerator.scala | 57 +------------------ .../tapir/codegen/JsonSerdeGenerator.scala | 24 ++++++-- .../Expected.scala.txt | 10 +++- .../ExpectedJsonSerdes.scala.txt | 4 ++ .../ExpectedSchemas.scala.txt | 3 +- .../oneOf-json-roundtrip-zio/build.sbt | 2 +- .../src/test/scala/JsonRoundtrip.scala | 10 ++-- .../oneOf-json-roundtrip-zio/swagger.yaml | 13 +++-- .../oneOf-json-roundtrip/Expected.scala.txt | 10 +++- .../ExpectedSchemas.scala.txt | 3 +- .../oneOf-json-roundtrip/build.sbt | 2 +- .../src/test/scala/JsonRoundtrip.scala | 10 ++-- .../oneOf-json-roundtrip/swagger.yaml | 13 +++-- .../Expected.scala.txt | 9 ++- .../src/test/scala/JsonRoundtrip.scala | 10 ++-- .../swagger.yaml | 13 +++-- .../Expected.scala.txt | 5 +- .../src/test/scala/JsonRoundtrip.scala | 10 ++-- .../oneOf-json-roundtrip_scala3/swagger.yaml | 11 +++- 20 files changed, 116 insertions(+), 106 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index a1df6a4106..85099787a0 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -50,7 +50,8 @@ class ClassDefinitionGenerator { allTransitiveJsonParamRefs, fullModelPath, validateNonDiscriminatedOneOfs, - adtInheritanceMap + adtInheritanceMap, + targetScala3 ) val defns = doc.components .map(_.schemas.flatMap { diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index 24cdb8bbeb..8ec5a29d1c 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -31,8 +31,8 @@ object EnumGenerator { case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror" case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec" case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror" - case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" - case JsonSerdeLib.Jsoniter => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" + case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" + case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" } s"""$maybeCompanion |enum $name$maybeCodecExtensions { @@ -44,7 +44,7 @@ object EnumGenerator { val maybeCodecExtension = jsonSerdeLib match { case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" - case JsonSerdeLib.Jsoniter => "" + case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio => "" } val maybeQueryCodecDefn = if (queryParamRefs contains name) @@ -60,55 +60,4 @@ object EnumGenerator { |}""".stripMargin :: Nil } } - /* - // Uses enumeratum for scala 2, but generates scala 3 enums instead where it can - private[codegen] def generateEnum( - name: String, - obj: OpenapiSchemaEnum, - targetScala3: Boolean, - queryParamRefs: Set[String], - jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, - jsonParamRefs: Set[String] - ): Seq[String] = if (targetScala3) { - val maybeCompanion = - if (queryParamRefs contains name) - s""" - |object $name { - | given stringList${name}Codec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum[$name] - |}""".stripMargin - else "" - val maybeCodecExtensions = jsonSerdeLib match { - case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" - case _ if !jsonParamRefs.contains(name) => " derives enumextensions.EnumMirror" - case JsonSerdeLib.Circe if !queryParamRefs.contains(name) => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec" - case JsonSerdeLib.Circe => " derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror" - case JsonSerdeLib.Jsoniter if !queryParamRefs.contains(name) => s" extends java.lang.Enum[$name]" - case JsonSerdeLib.Jsoniter => s" extends java.lang.Enum[$name] derives enumextensions.EnumMirror" - } - s"""$maybeCompanion - |enum $name$maybeCodecExtensions { - | case ${obj.items.map(_.value).mkString(", ")} - |}""".stripMargin :: Nil - } else { - val uncapitalisedName = BasicGenerator.uncapitalise(name) - val members = obj.items.map { i => s"case object ${i.value} extends $name" } - val maybeCodecExtension = jsonSerdeLib match { - case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" - case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" - case JsonSerdeLib.Jsoniter => "" - } - val maybeQueryCodecDefn = - if (queryParamRefs contains name) - s""" - | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], ${name}, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum("${name}", ${name})""".stripMargin - else "" - s""" - |sealed trait $name extends enumeratum.EnumEntry - |object $name extends enumeratum.Enum[$name]$maybeCodecExtension { - | val values = findValues - |${indent(2)(members.mkString("\n"))}$maybeQueryCodecDefn - |}""".stripMargin :: Nil - }*/ } diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala index 1b6587317b..89892578a6 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/JsonSerdeGenerator.scala @@ -27,7 +27,8 @@ object JsonSerdeGenerator { allTransitiveJsonParamRefs: Set[String], fullModelPath: String, validateNonDiscriminatedOneOfs: Boolean, - adtInheritanceMap: Map[String, Seq[String]] + adtInheritanceMap: Map[String, Seq[String]], + targetScala3: Boolean ): Option[String] = { val allSchemas: Map[String, OpenapiSchemaType] = doc.components.toSeq.flatMap(_.schemas).toMap @@ -43,7 +44,7 @@ object JsonSerdeGenerator { if (fullModelPath.isEmpty) None else Some(fullModelPath), validateNonDiscriminatedOneOfs ) - case JsonSerdeLib.Zio => genZioSerdes(doc, allSchemas, allTransitiveJsonParamRefs, validateNonDiscriminatedOneOfs) + case JsonSerdeLib.Zio => genZioSerdes(doc, allSchemas, allTransitiveJsonParamRefs, validateNonDiscriminatedOneOfs, targetScala3) } } @@ -240,7 +241,7 @@ object JsonSerdeGenerator { val additionalExplicitSerdes = jsonParamRefs.toSeq .filter(x => !allSchemas.contains(x)) .map { s => - val name = s.replace(" ","").replace(",","_").replace("[", "_").replace("]", "_").replace(".", "_") + "JsonCodec" + val name = s.replace(" ", "").replace(",", "_").replace("[", "_").replace("]", "_").replace(".", "_") + "JsonCodec" s"""implicit lazy val $name: $jsoniterPkgCore.JsonValueCodec[$s] = | $jsoniterPkgMacros.JsonCodecMaker.make[$s]""".stripMargin } @@ -377,12 +378,14 @@ object JsonSerdeGenerator { doc: OpenapiDocument, allSchemas: Map[String, OpenapiSchemaType], allTransitiveJsonParamRefs: Set[String], - validateNonDiscriminatedOneOfs: Boolean + validateNonDiscriminatedOneOfs: Boolean, + targetScala3: Boolean ): Option[String] = { doc.components .map(_.schemas.flatMap { - // Enum serdes are generated at the declaration site - case (_, _: OpenapiSchemaEnum) => None + // Only enumeratum (scala 2) enum types currently supported for zio-json + case (name, _: OpenapiSchemaEnum) if !targetScala3 && allTransitiveJsonParamRefs.contains(name) => + Some(genZioEnumSerde(name)) // We generate the serde if it's referenced in any json model case (name, schema: OpenapiSchemaObject) if allTransitiveJsonParamRefs.contains(name) => Some(genZioObjectSerde(name, schema)) @@ -420,6 +423,15 @@ object JsonSerdeGenerator { subs.fold("")("\n" + _) } + private def genZioEnumSerde(name: String): String = { + val uncapitalisedName = BasicGenerator.uncapitalise(name) + s""" + |implicit lazy val ${uncapitalisedName}JsonCodec: zio.json.JsonCodec[$name] = zio.json.JsonCodec[$name]( + | zio.json.JsonEncoder[String].contramap[$name](_.entryName), + | zio.json.JsonDecoder[String].mapOrFail(name => $name.withNameEither(name).left.map(error => error.getMessage)), + |)""".stripMargin + } + private def genZioAdtSerde( allSchemas: Map[String, OpenapiSchemaType], schema: OpenapiSchemaOneOf, diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt index 26474edf5c..a549ccc303 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt @@ -29,7 +29,7 @@ object TapirGeneratedEndpoints { case class SubtypeWithoutD3 ( s: String, i: Option[Int] = None, - d: Option[Double] = None, + e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator case class SubtypeWithoutD2 ( @@ -41,7 +41,13 @@ object TapirGeneratedEndpoints { a: Option[Seq[String]] = None ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping - + sealed trait AnEnum extends enumeratum.EnumEntry + object AnEnum extends enumeratum.Enum[AnEnum] { + val values = findValues + case object Foo extends AnEnum + case object Bar extends AnEnum + case object Baz extends AnEnum + } lazy val putAdtTest = endpoint diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt index 2949c34956..73e7df64d0 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedJsonSerdes.scala.txt @@ -45,6 +45,10 @@ object TapirGeneratedEndpointsJsonSerdes { implicit lazy val subtypeWithoutD2JsonEncoder: zio.json.JsonEncoder[SubtypeWithoutD2] = zio.json.DeriveJsonEncoder.gen[SubtypeWithoutD2] implicit lazy val subtypeWithD2JsonDecoder: zio.json.JsonDecoder[SubtypeWithD2] = zio.json.DeriveJsonDecoder.gen[SubtypeWithD2] implicit lazy val subtypeWithD2JsonEncoder: zio.json.JsonEncoder[SubtypeWithD2] = zio.json.DeriveJsonEncoder.gen[SubtypeWithD2] + implicit lazy val anEnumJsonCodec: zio.json.JsonCodec[AnEnum] = zio.json.JsonCodec[AnEnum]( + zio.json.JsonEncoder[String].contramap[AnEnum](_.entryName), + zio.json.JsonDecoder[String].mapOrFail(name => AnEnum.withNameEither(name).left.map(error => error.getMessage)), + ) implicit lazy val aDTWithoutDiscriminatorJsonEncoder: zio.json.JsonEncoder[ADTWithoutDiscriminator] = new zio.json.JsonEncoder[ADTWithoutDiscriminator] { override def unsafeEncode(v: ADTWithoutDiscriminator, indent: Option[Int], out: zio.json.internal.Write): Unit = { v match { diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt index dd0c6da56c..1f2448d5a9 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/ExpectedSchemas.scala.txt @@ -3,11 +3,11 @@ package sttp.tapir.generated object TapirGeneratedEndpointsSchemas { import sttp.tapir.generated.TapirGeneratedEndpoints._ import sttp.tapir.generic.auto._ + implicit lazy val anEnumTapirSchema: sttp.tapir.Schema[AnEnum] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD1TapirSchema: sttp.tapir.Schema[SubtypeWithD1] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD2TapirSchema: sttp.tapir.Schema[SubtypeWithD2] = sttp.tapir.Schema.derived implicit lazy val subtypeWithoutD1TapirSchema: sttp.tapir.Schema[SubtypeWithoutD1] = sttp.tapir.Schema.derived implicit lazy val subtypeWithoutD2TapirSchema: sttp.tapir.Schema[SubtypeWithoutD2] = sttp.tapir.Schema.derived - implicit lazy val subtypeWithoutD3TapirSchema: sttp.tapir.Schema[SubtypeWithoutD3] = sttp.tapir.Schema.derived implicit lazy val aDTWithDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithDiscriminator] = { val derived = implicitly[sttp.tapir.generic.Derived[sttp.tapir.Schema[ADTWithDiscriminator]]].value derived.schemaType match { @@ -36,5 +36,6 @@ object TapirGeneratedEndpointsSchemas { case _ => throw new IllegalStateException("Derived schema for ADTWithDiscriminatorNoMapping should be a coproduct") } } + implicit lazy val subtypeWithoutD3TapirSchema: sttp.tapir.Schema[SubtypeWithoutD3] = sttp.tapir.Schema.derived implicit lazy val aDTWithoutDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithoutDiscriminator] = sttp.tapir.Schema.derived } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt index 839f1966b4..7af56a1ecb 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/build.sbt @@ -25,7 +25,7 @@ TaskKey[Unit]("check") := { generatedCode.linesIterator.zipWithIndex.filterNot(_._1.forall(_.isWhitespace)).map { case (a, i) => a.trim -> i }.toSeq val expectedTrimmed = expectedCode.linesIterator.filterNot(_.forall(_.isWhitespace)).map(_.trim).toSeq if (generatedTrimmed.size != expectedTrimmed.size) - sys.error(s"expected ${expectedTrimmed.size} non-empty lines, found ${generatedTrimmed.size}") + sys.error(s"expected ${expectedTrimmed.size} non-empty lines in ${generatedFileName}, found ${generatedTrimmed.size}") generatedTrimmed.zip(expectedTrimmed).foreach { case ((a, i), b) => if (a != b) sys.error(s"Generated code in file $generatedCode did not match (expected '$b' on line $i, found '$a')") } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala index 392e52b514..f203634a46 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/src/test/scala/JsonRoundtrip.scala @@ -18,7 +18,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD1(foo.s + "+SubtypeWithoutD1", foo.i, foo.a)) case foo: SubtypeWithoutD2 => Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD2(foo.a :+ "+SubtypeWithoutD2")) case foo: SubtypeWithoutD3 => - Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.d)) + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.e)) }) val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) @@ -67,12 +67,12 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } locally { - val reqBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string", Some(123), Some(23.4)) + val reqBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string", Some(123), Some(AnEnum.Foo)) val reqJsonBody = reqBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) - val respBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(23.4)) + val respBody: ADTWithoutDiscriminator = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(AnEnum.Foo)) val respJsonBody = respBody.toJson(TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder) - reqJsonBody shouldEqual """{"s":"a string","i":123,"d":23.4}""" - respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD3","i":123,"d":23.4}""" + reqJsonBody shouldEqual """{"s":"a string","i":123,"e":"Foo"}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD3","i":123,"e":"Foo"}""" Await.result( sttp.client3.basicRequest .put(uri"http://test.com/adt/test") diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml index 590ba46952..4e6672311f 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/swagger.yaml @@ -126,8 +126,13 @@ components: type: string i: type: integer - d: - type: number - format: double + e: + $ref: '#/components/schemas/AnEnum' absent: - type: string \ No newline at end of file + type: string + AnEnum: + type: string + enum: + - Foo + - Bar + - Baz \ No newline at end of file diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index 21d39490ba..6769ab2d78 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -28,7 +28,7 @@ object TapirGeneratedEndpoints { case class SubtypeWithoutD3 ( s: String, i: Option[Int] = None, - d: Option[Double] = None, + e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator case class SubtypeWithoutD2 ( @@ -40,7 +40,13 @@ object TapirGeneratedEndpoints { a: Option[Seq[String]] = None ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping - + sealed trait AnEnum extends enumeratum.EnumEntry + object AnEnum extends enumeratum.Enum[AnEnum] with enumeratum.CirceEnum[AnEnum] { + val values = findValues + case object Foo extends AnEnum + case object Bar extends AnEnum + case object Baz extends AnEnum + } lazy val putAdtTest = endpoint diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt index dd0c6da56c..1f2448d5a9 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt @@ -3,11 +3,11 @@ package sttp.tapir.generated object TapirGeneratedEndpointsSchemas { import sttp.tapir.generated.TapirGeneratedEndpoints._ import sttp.tapir.generic.auto._ + implicit lazy val anEnumTapirSchema: sttp.tapir.Schema[AnEnum] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD1TapirSchema: sttp.tapir.Schema[SubtypeWithD1] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD2TapirSchema: sttp.tapir.Schema[SubtypeWithD2] = sttp.tapir.Schema.derived implicit lazy val subtypeWithoutD1TapirSchema: sttp.tapir.Schema[SubtypeWithoutD1] = sttp.tapir.Schema.derived implicit lazy val subtypeWithoutD2TapirSchema: sttp.tapir.Schema[SubtypeWithoutD2] = sttp.tapir.Schema.derived - implicit lazy val subtypeWithoutD3TapirSchema: sttp.tapir.Schema[SubtypeWithoutD3] = sttp.tapir.Schema.derived implicit lazy val aDTWithDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithDiscriminator] = { val derived = implicitly[sttp.tapir.generic.Derived[sttp.tapir.Schema[ADTWithDiscriminator]]].value derived.schemaType match { @@ -36,5 +36,6 @@ object TapirGeneratedEndpointsSchemas { case _ => throw new IllegalStateException("Derived schema for ADTWithDiscriminatorNoMapping should be a coproduct") } } + implicit lazy val subtypeWithoutD3TapirSchema: sttp.tapir.Schema[SubtypeWithoutD3] = sttp.tapir.Schema.derived implicit lazy val aDTWithoutDiscriminatorTapirSchema: sttp.tapir.Schema[ADTWithoutDiscriminator] = sttp.tapir.Schema.derived } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/build.sbt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/build.sbt index 5514449fe0..7abec94012 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/build.sbt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/build.sbt @@ -27,7 +27,7 @@ TaskKey[Unit]("check") := { generatedCode.linesIterator.zipWithIndex.filterNot(_._1.forall(_.isWhitespace)).map { case (a, i) => a.trim -> i }.toSeq val expectedTrimmed = expectedCode.linesIterator.filterNot(_.forall(_.isWhitespace)).map(_.trim).toSeq if (generatedTrimmed.size != expectedTrimmed.size) - sys.error(s"expected ${expectedTrimmed.size} non-empty lines, found ${generatedTrimmed.size}") + sys.error(s"expected ${expectedTrimmed.size} non-empty lines in ${generatedFileName}, found ${generatedTrimmed.size}") generatedTrimmed.zip(expectedTrimmed).foreach { case ((a, i), b) => if (a != b) sys.error(s"Generated code in file $generatedCode did not match (expected '$b' on line $i, found '$a')") } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala index 4d134a405e..dd79dff7cc 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala @@ -18,7 +18,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD1(foo.s + "+SubtypeWithoutD1", foo.i, foo.a)) case foo: SubtypeWithoutD2 => Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD2(foo.a :+ "+SubtypeWithoutD2")) case foo: SubtypeWithoutD3 => - Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.d)) + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.e)) }) val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) @@ -68,12 +68,12 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } locally { - val reqBody = SubtypeWithoutD3("a string", Some(123), Some(23.4)) + val reqBody = SubtypeWithoutD3("a string", Some(123), Some(AnEnum.Foo)) val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder(reqBody).noSpacesSortKeys - val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(23.4)) + val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(AnEnum.Foo)) val respJsonBody = TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder(respBody).noSpacesSortKeys - reqJsonBody shouldEqual """{"absent":null,"d":23.4,"i":123,"s":"a string"}""" - respJsonBody shouldEqual """{"absent":null,"d":23.4,"i":123,"s":"a string+SubtypeWithoutD3"}""" + reqJsonBody shouldEqual """{"absent":null,"e":"Foo","i":123,"s":"a string"}""" + respJsonBody shouldEqual """{"absent":null,"e":"Foo","i":123,"s":"a string+SubtypeWithoutD3"}""" Await.result( sttp.client3.basicRequest .put(uri"http://test.com/adt/test") diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml index 590ba46952..4e6672311f 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml @@ -126,8 +126,13 @@ components: type: string i: type: integer - d: - type: number - format: double + e: + $ref: '#/components/schemas/AnEnum' absent: - type: string \ No newline at end of file + type: string + AnEnum: + type: string + enum: + - Foo + - Bar + - Baz \ No newline at end of file diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt index d093903e80..88703005a5 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt @@ -29,7 +29,7 @@ object TapirGeneratedEndpoints { case class SubtypeWithoutD3 ( s: String, i: Option[Int] = None, - d: Option[Double] = None, + e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator case class SubtypeWithoutD2 ( @@ -42,6 +42,13 @@ object TapirGeneratedEndpoints { ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping + sealed trait AnEnum extends enumeratum.EnumEntry + object AnEnum extends enumeratum.Enum[AnEnum] { + val values = findValues + case object Foo extends AnEnum + case object Bar extends AnEnum + case object Baz extends AnEnum + } lazy val putAdtTest = endpoint diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/src/test/scala/JsonRoundtrip.scala index 3828758f3d..a101cd1e1a 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/src/test/scala/JsonRoundtrip.scala @@ -20,7 +20,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD1(foo.s + "+SubtypeWithoutD1", foo.i, foo.a)) case foo: SubtypeWithoutD2 => Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD2(foo.a :+ "+SubtypeWithoutD2")) case foo: SubtypeWithoutD3 => - Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.d)) + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.e)) }) val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) @@ -70,12 +70,12 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } locally { - val reqBody = SubtypeWithoutD3("a string", Some(123), Some(23.4)) + val reqBody = SubtypeWithoutD3("a string", Some(123), Some(AnEnum.Foo)) val reqJsonBody = writeToString(reqBody) - val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(23.4)) + val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(AnEnum.Foo)) val respJsonBody = writeToString(respBody) - reqJsonBody shouldEqual """{"s":"a string","i":123,"d":23.4}""" - respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD3","i":123,"d":23.4}""" + reqJsonBody shouldEqual """{"s":"a string","i":123,"e":"Foo"}""" + respJsonBody shouldEqual """{"s":"a string+SubtypeWithoutD3","i":123,"e":"Foo"}""" Await.result( sttp.client3.basicRequest .put(uri"http://test.com/adt/test") diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/swagger.yaml index 2c3b00b87b..d6be825dd7 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/swagger.yaml @@ -126,8 +126,13 @@ components: type: string i: type: integer - d: - type: number - format: double + e: + $ref: '#/components/schemas/AnEnum' absent: - type: string \ No newline at end of file + type: string + AnEnum: + type: string + enum: + - Foo + - Bar + - Baz \ No newline at end of file diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt index c8b9dbf7e5..2facc0fff5 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt @@ -28,7 +28,7 @@ object TapirGeneratedEndpoints { case class SubtypeWithoutD3 ( s: String, i: Option[Int] = None, - d: Option[Double] = None, + e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator case class SubtypeWithoutD2 ( @@ -40,6 +40,9 @@ object TapirGeneratedEndpoints { a: Option[Seq[String]] = None ) extends ADTWithDiscriminator with ADTWithDiscriminatorNoMapping + enum AnEnum derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec { + case Foo, Bar, Baz + } lazy val putAdtTest = diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala index 4d134a405e..dd79dff7cc 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala @@ -18,7 +18,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD1(foo.s + "+SubtypeWithoutD1", foo.i, foo.a)) case foo: SubtypeWithoutD2 => Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD2(foo.a :+ "+SubtypeWithoutD2")) case foo: SubtypeWithoutD3 => - Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.d)) + Future successful Right[Unit, ADTWithoutDiscriminator](SubtypeWithoutD3(foo.s + "+SubtypeWithoutD3", foo.i, foo.e)) }) val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) @@ -68,12 +68,12 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } locally { - val reqBody = SubtypeWithoutD3("a string", Some(123), Some(23.4)) + val reqBody = SubtypeWithoutD3("a string", Some(123), Some(AnEnum.Foo)) val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder(reqBody).noSpacesSortKeys - val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(23.4)) + val respBody = SubtypeWithoutD3("a string+SubtypeWithoutD3", Some(123), Some(AnEnum.Foo)) val respJsonBody = TapirGeneratedEndpointsJsonSerdes.aDTWithoutDiscriminatorJsonEncoder(respBody).noSpacesSortKeys - reqJsonBody shouldEqual """{"absent":null,"d":23.4,"i":123,"s":"a string"}""" - respJsonBody shouldEqual """{"absent":null,"d":23.4,"i":123,"s":"a string+SubtypeWithoutD3"}""" + reqJsonBody shouldEqual """{"absent":null,"e":"Foo","i":123,"s":"a string"}""" + respJsonBody shouldEqual """{"absent":null,"e":"Foo","i":123,"s":"a string+SubtypeWithoutD3"}""" Await.result( sttp.client3.basicRequest .put(uri"http://test.com/adt/test") diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml index 95b2fe981b..9d0b4e4518 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml @@ -128,8 +128,13 @@ components: type: string i: type: integer - d: - type: number - format: double + e: + $ref: '#/components/schemas/AnEnum' absent: type: string + AnEnum: + type: string + enum: + - Foo + - Bar + - Baz From a526b76114e830f0a3053324e99de412b84545cd Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 11:11:54 +0100 Subject: [PATCH 03/15] add tests for inline enums, make them pass --- .../tapir/codegen/EndpointGenerator.scala | 11 +-- .../sttp/tapir/codegen/SchemaGenerator.scala | 8 ++ .../oneOf-json-roundtrip/Expected.scala.txt | 82 ++++++++++++++++++- .../ExpectedJsonSerdes.scala.txt | 2 + .../ExpectedSchemas.scala.txt | 2 + .../oneOf-json-roundtrip/swagger.yaml | 55 ++++++++++++- 6 files changed, 144 insertions(+), 16 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index 6aa1169af1..8302daec7d 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -250,7 +250,7 @@ class EndpointGenerator { (params ++ rqBody).mkString("\n") -> maybeEnumDefns.foldLeft(Option.empty[String]) { case (acc, None) => acc case (None, Some(nxt)) => Some(nxt.mkString("\n")) - case (Some(acc), Some(nxt)) => Some(acc + "\n" + nxt) + case (Some(acc), Some(nxt)) => Some(acc + "\n" + nxt.mkString("\n")) } } @@ -339,15 +339,6 @@ class EndpointGenerator { s"multipartBody[$t]" case x => bail(s"$contentType only supports schema ref or binary. Found $x") } - case "application/octet-stream" => - schema match { - case _: OpenapiSchemaBinary => - "multipartBody" - case schemaRef: OpenapiSchemaRef => - val (t, _) = mapSchemaSimpleTypeToType(schemaRef, multipartForm = true) - s"multipartBody[$t]" - case x => bail(s"$contentType only supports schema ref or binary. Found $x") - } case x => bail(s"Not all content types supported! Found $x") } diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala index e96c75056c..1ce9d39dda 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala @@ -195,6 +195,11 @@ object SchemaGenerator { schemaForObject(s"$name${k.capitalize}Item", `type`) case (k, OpenapiSchemaField(OpenapiSchemaMap(`type`: OpenapiSchemaObject, _), _)) => schemaForObject(s"$name${k.capitalize}Item", `type`) + case (k, OpenapiSchemaField(_: OpenapiSchemaEnum, _)) => schemaForEnum(s"$name${k.capitalize}") + case (k, OpenapiSchemaField(OpenapiSchemaArray(_: OpenapiSchemaEnum, _), _)) => + schemaForEnum(s"$name${k.capitalize}Item") + case (k, OpenapiSchemaField(OpenapiSchemaMap(_: OpenapiSchemaEnum, _), _)) => + schemaForEnum(s"$name${k.capitalize}Item") } match { case Nil => "" case s => s.mkString("", "\n", "\n") @@ -208,6 +213,9 @@ object SchemaGenerator { } subs.fold("")("\n" + _) } + private def schemaForEnum(name: String): String = + s"""implicit lazy val ${BasicGenerator.uncapitalise(name)}TapirSchema: sttp.tapir.Schema[$name] = sttp.tapir.Schema.derived""" + private def genADTSchema(name: String, schema: OpenapiSchemaOneOf, fullModelPath: Option[String]): String = { val schemaImpl = schema match { case OpenapiSchemaOneOf(_, None) => "sttp.tapir.Schema.derived" diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index 6769ab2d78..2c9628af06 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -11,6 +11,38 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ + def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(s => + // Case-insensitive mapping + scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + s, + new NoSuchElementException( + s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) + )(_.entryName) + def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = + sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => + // Case-insensitive mapping + scala.util.Try(values.map(s => T.upperCaseNameValuesToMap(s.toUpperCase))) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + values.mkString(","), + new NoSuchElementException( + s"Could not find all values $values for enum ${enumName}, available values: ${T.values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) + )(_.map(_.entryName)) sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -31,6 +63,19 @@ object TapirGeneratedEndpoints { e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator + case class ObjectWithInlineEnum ( + id: java.util.UUID, + inlineEnum: ObjectWithInlineEnumInlineEnum + ) + + sealed trait ObjectWithInlineEnumInlineEnum extends enumeratum.EnumEntry + object ObjectWithInlineEnumInlineEnum extends enumeratum.Enum[ObjectWithInlineEnumInlineEnum] with enumeratum.CirceEnum[ObjectWithInlineEnumInlineEnum] { + val values = findValues + case object foo1 extends ObjectWithInlineEnumInlineEnum + case object foo2 extends ObjectWithInlineEnumInlineEnum + case object foo3 extends ObjectWithInlineEnumInlineEnum + case object foo4 extends ObjectWithInlineEnumInlineEnum + } case class SubtypeWithoutD2 ( a: Seq[String], absent: Option[String] = None @@ -54,15 +99,44 @@ object TapirGeneratedEndpoints { .in(("adt" / "test")) .in(jsonBody[ADTWithoutDiscriminator]) .out(jsonBody[ADTWithoutDiscriminator].description("successful operation")) - + lazy val postAdtTest = endpoint .post .in(("adt" / "test")) .in(jsonBody[ADTWithDiscriminatorNoMapping]) .out(jsonBody[ADTWithDiscriminator].description("successful operation")) - - - lazy val generatedEndpoints = List(putAdtTest, postAdtTest) + + lazy val postInlineEnumTest = + endpoint + .post + .in(("inline" / "enum" / "test")) + .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) + .in(query[PostInlineEnumTestQuerySeqEnum]("query-seq-enum").description("A sequence of enums, inline, in a query string")) + .in(jsonBody[ObjectWithInlineEnum]) + .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) + + sealed trait PostInlineEnumTestQueryEnum extends enumeratum.EnumEntry + object PostInlineEnumTestQueryEnum extends enumeratum.Enum[PostInlineEnumTestQueryEnum] with enumeratum.CirceEnum[PostInlineEnumTestQueryEnum] { + val values = findValues + case object bar1 extends PostInlineEnumTestQueryEnum + case object bar2 extends PostInlineEnumTestQueryEnum + case object bar3 extends PostInlineEnumTestQueryEnum + implicit val postInlineEnumTestQueryEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + } + + sealed trait PostInlineEnumTestQuerySeqEnum extends enumeratum.EnumEntry + object PostInlineEnumTestQuerySeqEnum extends enumeratum.Enum[PostInlineEnumTestQuerySeqEnum] with enumeratum.CirceEnum[PostInlineEnumTestQuerySeqEnum] { + val values = findValues + case object baz1 extends PostInlineEnumTestQuerySeqEnum + case object baz2 extends PostInlineEnumTestQuerySeqEnum + case object baz3 extends PostInlineEnumTestQuerySeqEnum + implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + } + + + lazy val generatedEndpoints = List(putAdtTest, postAdtTest, postInlineEnumTest) } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedJsonSerdes.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedJsonSerdes.scala.txt index d688bbf900..95d20031ee 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedJsonSerdes.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedJsonSerdes.scala.txt @@ -35,6 +35,8 @@ object TapirGeneratedEndpointsJsonSerdes { } implicit lazy val subtypeWithoutD3JsonDecoder: io.circe.Decoder[SubtypeWithoutD3] = io.circe.generic.semiauto.deriveDecoder[SubtypeWithoutD3] implicit lazy val subtypeWithoutD3JsonEncoder: io.circe.Encoder[SubtypeWithoutD3] = io.circe.generic.semiauto.deriveEncoder[SubtypeWithoutD3] + implicit lazy val objectWithInlineEnumJsonDecoder: io.circe.Decoder[ObjectWithInlineEnum] = io.circe.generic.semiauto.deriveDecoder[ObjectWithInlineEnum] + implicit lazy val objectWithInlineEnumJsonEncoder: io.circe.Encoder[ObjectWithInlineEnum] = io.circe.generic.semiauto.deriveEncoder[ObjectWithInlineEnum] implicit lazy val subtypeWithoutD2JsonDecoder: io.circe.Decoder[SubtypeWithoutD2] = io.circe.generic.semiauto.deriveDecoder[SubtypeWithoutD2] implicit lazy val subtypeWithoutD2JsonEncoder: io.circe.Encoder[SubtypeWithoutD2] = io.circe.generic.semiauto.deriveEncoder[SubtypeWithoutD2] implicit lazy val subtypeWithD2JsonDecoder: io.circe.Decoder[SubtypeWithD2] = io.circe.generic.semiauto.deriveDecoder[SubtypeWithD2] diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt index 1f2448d5a9..ef904c9e4b 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/ExpectedSchemas.scala.txt @@ -4,6 +4,8 @@ object TapirGeneratedEndpointsSchemas { import sttp.tapir.generated.TapirGeneratedEndpoints._ import sttp.tapir.generic.auto._ implicit lazy val anEnumTapirSchema: sttp.tapir.Schema[AnEnum] = sttp.tapir.Schema.derived + implicit lazy val objectWithInlineEnumInlineEnumTapirSchema: sttp.tapir.Schema[ObjectWithInlineEnumInlineEnum] = sttp.tapir.Schema.derived + implicit lazy val objectWithInlineEnumTapirSchema: sttp.tapir.Schema[ObjectWithInlineEnum] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD1TapirSchema: sttp.tapir.Schema[SubtypeWithD1] = sttp.tapir.Schema.derived implicit lazy val subtypeWithD2TapirSchema: sttp.tapir.Schema[SubtypeWithD2] = sttp.tapir.Schema.derived implicit lazy val subtypeWithoutD1TapirSchema: sttp.tapir.Schema[SubtypeWithoutD1] = sttp.tapir.Schema.derived diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml index 4e6672311f..26130d70fe 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml @@ -5,7 +5,7 @@ info: description: File for testing json roundtripping of oneOf defns in scala 2.x with circe version: 1.0.20-SNAPSHOT title: OneOf Json test for scala 2 -tags: [] +tags: [ ] paths: '/adt/test': post: @@ -38,6 +38,40 @@ paths: application/json: schema: $ref: '#/components/schemas/ADTWithoutDiscriminator' + '/inline/enum/test': + post: + parameters: + - name: query-enum + in: query + description: An enum, inline, in a query string + required: true + schema: + type: string + enum: + - bar1 + - bar2 + - bar3 + - name: query-seq-enum + in: query + description: A sequence of enums, inline, in a query string + required: true + schema: + type: string + enum: + - baz1 + - baz2 + - baz3 + default: baz2 + responses: + '204': + description: No Content + requestBody: + required: true + description: Check inline enums + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectWithInlineEnum' components: schemas: @@ -135,4 +169,21 @@ components: enum: - Foo - Bar - - Baz \ No newline at end of file + - Baz + ObjectWithInlineEnum: + title: ObjectWithInlineEnum + required: + - id + - inlineEnum + type: object + properties: + id: + type: string + format: uuid + inlineEnum: + type: string + enum: + - foo1 + - foo2 + - foo3 + - foo4 \ No newline at end of file From 1f83cbcd192ebc3a5cb67dccfd7f1d1e73d6b836 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 11:17:27 +0100 Subject: [PATCH 04/15] fix test (should've been a seq) --- .../oneOf-json-roundtrip/Expected.scala.txt | 6 +++--- .../oneOf-json-roundtrip/swagger.yaml | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index 2c9628af06..bbcef54d44 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -112,7 +112,7 @@ object TapirGeneratedEndpoints { .post .in(("inline" / "enum" / "test")) .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) - .in(query[PostInlineEnumTestQuerySeqEnum]("query-seq-enum").description("A sequence of enums, inline, in a query string")) + .in(query[List[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").description("A sequence of enums, inline, in a query string")) .in(jsonBody[ObjectWithInlineEnum]) .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) @@ -132,8 +132,8 @@ object TapirGeneratedEndpoints { case object baz1 extends PostInlineEnumTestQuerySeqEnum case object baz2 extends PostInlineEnumTestQuerySeqEnum case object baz3 extends PostInlineEnumTestQuerySeqEnum - implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml index 26130d70fe..b4f485575f 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml @@ -56,12 +56,14 @@ paths: description: A sequence of enums, inline, in a query string required: true schema: - type: string - enum: - - baz1 - - baz2 - - baz3 - default: baz2 + type: array + items: + type: string + enum: + - baz1 + - baz2 + - baz3 + default: baz2 responses: '204': description: No Content From 9c2e68c1c8922d23206c86fa0e8453ed00a3332b Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 13:27:39 +0100 Subject: [PATCH 05/15] optional support --- .../codegen/ClassDefinitionGenerator.scala | 110 ++++++++---------- .../tapir/codegen/EndpointGenerator.scala | 10 +- .../sttp/tapir/codegen/EnumGenerator.scala | 27 +++-- .../ClassDefinitionGeneratorSpec.scala | 60 +++++----- 4 files changed, 97 insertions(+), 110 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index 85099787a0..e3251eea24 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -104,79 +104,62 @@ class ClassDefinitionGenerator { | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e | ) | + |// Case-insensitive mapping + |def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = + | scala.util + | .Try(eMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec - | .listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(s => - | // Case-insensitive mapping - | scala.util - | .Try(eMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - | )(_.name) + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(decodeEnum[T](eMap))(_.name) + |} + |def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) |} - | |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec - | .list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => - | // Case-insensitive mapping - | scala.util - | .Try(values.map(s => eMap(s.toUpperCase))) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | values.mkString(","), - | new NoSuchElementException( - | s"Could not find all values $values for enum ${enumextensions.EnumMirror[ - | T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}")), - | sttp.tapir.DecodeResult.Value(_)))(_.map(_.name)) + | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name)) |} |""".stripMargin else - """def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = + """ + |// Case-insensitive mapping + |def decodeEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T])(s: String): sttp.tapir.DecodeResult[T] = + | scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) + |def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(s => - | // Case-insensitive mapping - | scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - | )(_.entryName) + | .mapDecode(decodeEnum[T](enumName, T))(_.entryName) + | + |def makeQueryOptCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.toSeq.map(decodeEnum[T](enumName, T))).map(_.headOption))(_.map(_.entryName)) | |def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = - | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => - | // Case-insensitive mapping - | scala.util.Try(values.map(s => T.upperCaseNameValuesToMap(s.toUpperCase))) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | values.mkString(","), - | new NoSuchElementException( - | s"Could not find all values $values for enum ${enumName}, available values: ${T.values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - | )(_.map(_.entryName)) + | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName)) |""".stripMargin @tailrec @@ -324,8 +307,7 @@ class ClassDefinitionGenerator { targetScala3, Set.empty, jsonSerdeLib, - if (isJson) Set(enumName) else Set.empty, - false + if (isJson) Set(enumName) else Set.empty ) (enumName -> e.nullable, Some(enumDefn.mkString("\n"))) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index 8302daec7d..7d600843a6 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -212,12 +212,12 @@ class EndpointGenerator { targetScala3, queryParamRefs, jsonSerdeLib, - Set.empty, - isArray + Set.empty ) val tpe = if (isArray) s"List[$enumName]" else enumName + val req = if (param.required.getOrElse(true)) tpe else s"Option[$tpe]" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") - s""".in(${param.in}[$tpe]("${param.name}")$desc)""" -> Some(enumDefn) + s""".in(${param.in}[$req]("${param.name}")$desc)""" -> Some(enumDefn) } // .in(query[Limit]("limit").description("Maximum number of books to retrieve")) // .in(header[AuthToken]("X-Auth-Token")) @@ -232,8 +232,10 @@ class EndpointGenerator { s""".in(${param.in}[$req]("${param.name}")$desc)""" -> None case OpenapiSchemaArray(st: OpenapiSchemaSimpleType, _) => val (t, _) = mapSchemaSimpleTypeToType(st) + val arr = s"List[$t]" + val req = if (param.required.getOrElse(true)) arr else s"Option[$arr]" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") - s""".in(${param.in}[List[$t]]("${param.name}")$desc)""" -> None + s""".in(${param.in}[$req]("${param.name}")$desc)""" -> None case e @ OpenapiSchemaEnum(_, _, _) => getEnumParamDefn(param, e, isArray = false) case OpenapiSchemaArray(e: OpenapiSchemaEnum, _) => getEnumParamDefn(param, e, isArray = true) case x => bail(s"Can't create non-simple params to input - found $x") diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index 8ec5a29d1c..781a0b10d9 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -12,18 +12,21 @@ object EnumGenerator { targetScala3: Boolean, queryParamRefs: Set[String], jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, - jsonParamRefs: Set[String], - isArray: Boolean = false + jsonParamRefs: Set[String] ): Seq[String] = { - def helperName = if (isArray) "makeQuerySeqCodecForEnum" else "makeQueryCodecForEnum" - def highLevelType = if (isArray) s"List[$name]" else name if (targetScala3) { val maybeCompanion = if (queryParamRefs contains name) { + def helperImpls = + s""" given plainList${name}Codec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = + | makeQueryCodecForEnum[$name] + | given plainListOpt${name}Codec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptCodecForEnum[$name] + | given plainListList${name}Codec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = + | makeQuerySeqCodecForEnum[$name]""".stripMargin s""" |object $name { - | given stringList${name}Codec: sttp.tapir.Codec[List[String], $highLevelType, sttp.tapir.CodecFormat.TextPlain] = - | $helperName[$name] + |$helperImpls |}""".stripMargin } else "" val maybeCodecExtensions = jsonSerdeLib match { @@ -47,11 +50,15 @@ object EnumGenerator { case JsonSerdeLib.Jsoniter | JsonSerdeLib.Zio => "" } val maybeQueryCodecDefn = - if (queryParamRefs contains name) + if (queryParamRefs contains name) { s""" - | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], ${highLevelType}, sttp.tapir.CodecFormat.TextPlain] = - | $helperName("${name}", ${name})""".stripMargin - else "" + | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = + | makeQueryCodecForEnum("${name}", ${name}) + | implicit val ${uncapitalisedName}OptQueryCodec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptCodecForEnum("${name}", ${name}) + | implicit val ${uncapitalisedName}SeqQueryCodec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = + | makeQuerySeqCodecForEnum("${name}", ${name})""".stripMargin + } else "" s""" |sealed trait $name extends enumeratum.EnumEntry |object $name extends enumeratum.Enum[$name]$maybeCodecExtension { diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index 8cff3f1f29..e943fb8131 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -304,46 +304,42 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | Map.from( | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e | ) + |// Case-insensitive mapping + |def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = + | scala.util + | .Try(eMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec - | .listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(s => - | // Case-insensitive mapping - | scala.util - | .Try(eMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - | )(_.name) + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(decodeEnum[T](eMap))(_.name) + |} + |def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) |} |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec - | .list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => - | // Case-insensitive mapping - | scala.util - | .Try(values.map(s => eMap(s.toUpperCase))) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | values.mkString(","), - | new NoSuchElementException( - | s"Could not find all values $values for enum ${enumextensions.EnumMirror[ - | T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}")), - | sttp.tapir.DecodeResult.Value(_)))(_.map(_.name)) + | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name)) |} |object Test { - | given stringListTestCodec: sttp.tapir.Codec[List[String], Test, sttp.tapir.CodecFormat.TextPlain] = + | given plainListTestCodec: sttp.tapir.Codec[List[String], Test, sttp.tapir.CodecFormat.TextPlain] = | makeQueryCodecForEnum[Test] + | given plainListOptTestCodec: sttp.tapir.Codec[List[String], Option[Test], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptCodecForEnum[Test] + | given plainListListTestCodec: sttp.tapir.Codec[List[String], List[Test], sttp.tapir.CodecFormat.TextPlain] = + | makeQuerySeqCodecForEnum[Test] |} |enum Test derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror { | case enum1, enum2 From 90423b7a5c3c56fad09b6284931ff70d7c8fef41 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 14:17:29 +0100 Subject: [PATCH 06/15] make enum query params unexploded for now, add and fix tests --- .../codegen/ClassDefinitionGenerator.scala | 24 +++- .../sttp/tapir/codegen/EnumGenerator.scala | 8 +- .../ClassDefinitionGeneratorSpec.scala | 14 ++- .../tapir/codegen/EndpointGeneratorSpec.scala | 3 +- .../oneOf-json-roundtrip/Expected.scala.txt | 103 ++++++++++----- .../src/test/scala/JsonRoundtrip.scala | 70 +++++++++++ .../oneOf-json-roundtrip/swagger.yaml | 23 ++++ .../Expected.scala.txt | 118 +++++++++++++++++- .../src/test/scala/JsonRoundtrip.scala | 70 +++++++++++ .../oneOf-json-roundtrip_scala3/swagger.yaml | 76 +++++++++++ 10 files changed, 469 insertions(+), 40 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index e3251eea24..3b806d7671 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -130,8 +130,16 @@ class ClassDefinitionGenerator { |} |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name)) + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) + |} + |def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode{ + | case None => DecodeResult.Value(None) + | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) + | }(_.map(_.map(_.name).mkString(","))) |} |""".stripMargin else @@ -158,8 +166,16 @@ class ClassDefinitionGenerator { | .mapDecode(values => DecodeResult.sequence(values.toSeq.map(decodeEnum[T](enumName, T))).map(_.headOption))(_.map(_.entryName)) | |def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = - | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName)) + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName).mkString(",")) + | + |def makeQueryOptSeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode{ + | case None => DecodeResult.Value(None) + | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(r => Some(r.toList)) + | }(_.map(_.map(_.entryName).mkString(","))) + |} |""".stripMargin @tailrec diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index 781a0b10d9..c95b2e5052 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -23,7 +23,9 @@ object EnumGenerator { | given plainListOpt${name}Codec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = | makeQueryOptCodecForEnum[$name] | given plainListList${name}Codec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQuerySeqCodecForEnum[$name]""".stripMargin + | makeQuerySeqCodecForEnum[$name] + | given plainListOptList${name}Codec: sttp.tapir.Codec[List[String], Option[List[$name]], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptSeqCodecForEnum[$name]""".stripMargin s""" |object $name { |$helperImpls @@ -57,7 +59,9 @@ object EnumGenerator { | implicit val ${uncapitalisedName}OptQueryCodec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = | makeQueryOptCodecForEnum("${name}", ${name}) | implicit val ${uncapitalisedName}SeqQueryCodec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQuerySeqCodecForEnum("${name}", ${name})""".stripMargin + | makeQuerySeqCodecForEnum("${name}", ${name}) + | implicit val ${uncapitalisedName}OptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[$name]], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptSeqCodecForEnum("${name}", ${name})""".stripMargin } else "" s""" |sealed trait $name extends enumeratum.EnumEntry diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index e943fb8131..ba5ec7a0df 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -330,8 +330,16 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { |} |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name)) + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) + |} + |def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode{ + | case None => DecodeResult.Value(None) + | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) + | }(_.map(_.map(_.name).mkString(","))) |} |object Test { | given plainListTestCodec: sttp.tapir.Codec[List[String], Test, sttp.tapir.CodecFormat.TextPlain] = @@ -340,6 +348,8 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | makeQueryOptCodecForEnum[Test] | given plainListListTestCodec: sttp.tapir.Codec[List[String], List[Test], sttp.tapir.CodecFormat.TextPlain] = | makeQuerySeqCodecForEnum[Test] + | given plainListOptListTestCodec: sttp.tapir.Codec[List[String], Option[List[Test]], sttp.tapir.CodecFormat.TextPlain] = + | makeQueryOptSeqCodecForEnum[Test] |} |enum Test derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror { | case enum1, enum2 diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala index 36ae285574..491a5eb894 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/EndpointGeneratorSpec.scala @@ -42,7 +42,8 @@ class EndpointGeneratorSpec extends CompileCheckTestBase { parameters = Seq( Resolved(OpenapiParameter("asd-id", "path", Some(true), None, OpenapiSchemaString(false))), Resolved(OpenapiParameter("fgh-id", "query", Some(false), None, OpenapiSchemaString(false))), - Resolved(OpenapiParameter("jkl-id", "header", Some(false), None, OpenapiSchemaString(false)))), + Resolved(OpenapiParameter("jkl-id", "header", Some(false), None, OpenapiSchemaString(false))) + ), responses = Seq( OpenapiResponse( "200", diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index bbcef54d44..5dc289f129 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -11,38 +11,35 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ + // Case-insensitive mapping + def decodeEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T])(s: String): sttp.tapir.DecodeResult[T] = + scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + s, + new NoSuchElementException( + s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(s => - // Case-insensitive mapping - scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) - .fold( - _ => - sttp.tapir.DecodeResult.Error( - s, - new NoSuchElementException( - s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" - ) - ), - sttp.tapir.DecodeResult.Value(_) - ) - )(_.entryName) + .mapDecode(decodeEnum[T](enumName, T))(_.entryName) + def makeQueryOptCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.toSeq.map(decodeEnum[T](enumName, T))).map(_.headOption))(_.map(_.entryName)) def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = - sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => - // Case-insensitive mapping - scala.util.Try(values.map(s => T.upperCaseNameValuesToMap(s.toUpperCase))) - .fold( - _ => - sttp.tapir.DecodeResult.Error( - values.mkString(","), - new NoSuchElementException( - s"Could not find all values $values for enum ${enumName}, available values: ${T.values.mkString(", ")}" - ) - ), - sttp.tapir.DecodeResult.Value(_) - ) - )(_.map(_.entryName)) + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName).mkString(",")) + def makeQueryOptSeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode{ + case None => DecodeResult.Value(None) + case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(r => Some(r.toList)) + }(_.map(_.map(_.entryName).mkString(","))) + } sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -112,7 +109,9 @@ object TapirGeneratedEndpoints { .post .in(("inline" / "enum" / "test")) .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) + .in(query[Option[PostInlineEnumTestQueryOptEnum]]("query-opt-enum").description("An optional enum, inline, in a query string")) .in(query[List[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").description("A sequence of enums, inline, in a query string")) + .in(query[Option[List[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").description("An optional sequence of enums, inline, in a query string")) .in(jsonBody[ObjectWithInlineEnum]) .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) @@ -124,6 +123,28 @@ object TapirGeneratedEndpoints { case object bar3 extends PostInlineEnumTestQueryEnum implicit val postInlineEnumTestQueryEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryEnum, sttp.tapir.CodecFormat.TextPlain] = makeQueryCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + implicit val postInlineEnumTestQueryEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + implicit val postInlineEnumTestQueryEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + implicit val postInlineEnumTestQueryEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + } + + sealed trait PostInlineEnumTestQueryOptEnum extends enumeratum.EnumEntry + object PostInlineEnumTestQueryOptEnum extends enumeratum.Enum[PostInlineEnumTestQueryOptEnum] with enumeratum.CirceEnum[PostInlineEnumTestQueryOptEnum] { + val values = findValues + case object bar1 extends PostInlineEnumTestQueryOptEnum + case object bar2 extends PostInlineEnumTestQueryOptEnum + case object bar3 extends PostInlineEnumTestQueryOptEnum + implicit val postInlineEnumTestQueryOptEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) + implicit val postInlineEnumTestQueryOptEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) + implicit val postInlineEnumTestQueryOptEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) + implicit val postInlineEnumTestQueryOptEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) } sealed trait PostInlineEnumTestQuerySeqEnum extends enumeratum.EnumEntry @@ -132,8 +153,30 @@ object TapirGeneratedEndpoints { case object baz1 extends PostInlineEnumTestQuerySeqEnum case object baz2 extends PostInlineEnumTestQuerySeqEnum case object baz3 extends PostInlineEnumTestQuerySeqEnum - implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = + implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + implicit val postInlineEnumTestQuerySeqEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + implicit val postInlineEnumTestQuerySeqEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = makeQuerySeqCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + implicit val postInlineEnumTestQuerySeqEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQuerySeqEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + } + + sealed trait PostInlineEnumTestQueryOptSeqEnum extends enumeratum.EnumEntry + object PostInlineEnumTestQueryOptSeqEnum extends enumeratum.Enum[PostInlineEnumTestQueryOptSeqEnum] with enumeratum.CirceEnum[PostInlineEnumTestQueryOptSeqEnum] { + val values = findValues + case object baz1 extends PostInlineEnumTestQueryOptSeqEnum + case object baz2 extends PostInlineEnumTestQueryOptSeqEnum + case object baz3 extends PostInlineEnumTestQueryOptSeqEnum + implicit val postInlineEnumTestQueryOptSeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptSeqEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) + implicit val postInlineEnumTestQueryOptSeqEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) + implicit val postInlineEnumTestQueryOptSeqEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) + implicit val postInlineEnumTestQueryOptSeqEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptSeqEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala index dd79dff7cc..c7784496ed 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/src/test/scala/JsonRoundtrip.scala @@ -7,6 +7,7 @@ import sttp.tapir.generated.{TapirGeneratedEndpoints, TapirGeneratedEndpointsJso import sttp.tapir.generated.TapirGeneratedEndpoints._ import sttp.tapir.server.stub.TapirStubInterpreter +import java.util.UUID import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, Future} import scala.concurrent.ExecutionContext.Implicits.global @@ -141,4 +142,73 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } } + + "enum query param support" in { + var lastValues: ( + PostInlineEnumTestQueryEnum, + Option[PostInlineEnumTestQueryOptEnum], + List[PostInlineEnumTestQuerySeqEnum], + Option[List[PostInlineEnumTestQueryOptSeqEnum]], + ObjectWithInlineEnum + ) = null + val route = TapirGeneratedEndpoints.postInlineEnumTest.serverLogic[Future]({ case (a, b, c, d, e) => + lastValues = (a, b, c, d, e) + Future successful Right[Unit, Unit](()) + }) + + val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) + .whenServerEndpoint(route) + .thenRunLogic() + .backend() + + locally { + val id = UUID.randomUUID() + val reqBody = ObjectWithInlineEnum(id, ObjectWithInlineEnumInlineEnum.foo3) + val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.objectWithInlineEnumJsonEncoder(reqBody).noSpacesSortKeys + reqJsonBody shouldEqual s"""{"id":"$id","inlineEnum":"foo3"}""" + Await.result( + sttp.client3.basicRequest + .post( + uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1,baz2&query-opt-seq-enum=baz1,baz2" + ) + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code === 200 + resp.body shouldEqual Right("") + }, + 1.second + ) + val (a, b, c, d, e) = lastValues + a shouldEqual PostInlineEnumTestQueryEnum.bar1 + b shouldEqual Some(PostInlineEnumTestQueryOptEnum.bar2) + c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) + d shouldEqual Some(Seq(PostInlineEnumTestQueryOptSeqEnum.baz1, PostInlineEnumTestQueryOptSeqEnum.baz2)) + e shouldEqual reqBody + } + + locally { + val id = UUID.randomUUID() + val reqBody = ObjectWithInlineEnum(id, ObjectWithInlineEnumInlineEnum.foo3) + val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.objectWithInlineEnumJsonEncoder(reqBody).noSpacesSortKeys + reqJsonBody shouldEqual s"""{"id":"$id","inlineEnum":"foo3"}""" + Await.result( + sttp.client3.basicRequest + .post(uri"http://test.com/inline/enum/test?query-enum=bar1&query-seq-enum=baz1,baz2") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code === 200 + resp.body shouldEqual Right("") + }, + 1.second + ) + val (a, b, c, d, e) = lastValues + a shouldEqual PostInlineEnumTestQueryEnum.bar1 + b shouldEqual None + c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) + d shouldEqual None + e shouldEqual reqBody + } + } } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml index b4f485575f..a7e4746f0a 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml @@ -51,6 +51,16 @@ paths: - bar1 - bar2 - bar3 + - name: query-opt-enum + in: query + description: An optional enum, inline, in a query string + required: false + schema: + type: string + enum: + - bar1 + - bar2 + - bar3 - name: query-seq-enum in: query description: A sequence of enums, inline, in a query string @@ -64,6 +74,19 @@ paths: - baz2 - baz3 default: baz2 + - name: query-opt-seq-enum + in: query + description: An optional sequence of enums, inline, in a query string + required: false + schema: + type: array + items: + type: string + enum: + - baz1 + - baz2 + - baz3 + default: baz2 responses: '204': description: No Content diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt index 2facc0fff5..ca4cd4711d 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt @@ -11,6 +11,47 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ + def enumMap[E: enumextensions.EnumMirror]: Map[String, E] = + Map.from( + for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e + ) + // Case-insensitive mapping + def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = + scala.util + .Try(eMap(s.toUpperCase)) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + s, + new NoSuchElementException( + s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) + def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(decodeEnum[T](eMap))(_.name) + } + def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) + } + def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { + val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) + } + def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode{ + case None => DecodeResult.Value(None) + case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) + }(_.map(_.map(_.name).mkString(","))) + } sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -31,6 +72,14 @@ object TapirGeneratedEndpoints { e: Option[AnEnum] = None, absent: Option[String] = None ) extends ADTWithoutDiscriminator + case class ObjectWithInlineEnum ( + id: java.util.UUID, + inlineEnum: ObjectWithInlineEnumInlineEnum + ) + + enum ObjectWithInlineEnumInlineEnum derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec { + case foo1, foo2, foo3, foo4 + } case class SubtypeWithoutD2 ( a: Seq[String], absent: Option[String] = None @@ -59,7 +108,74 @@ object TapirGeneratedEndpoints { .in(jsonBody[ADTWithDiscriminatorNoMapping]) .out(jsonBody[ADTWithDiscriminator].description("successful operation")) + lazy val postInlineEnumTest = + endpoint + .post + .in(("inline" / "enum" / "test")) + .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) + .in(query[Option[PostInlineEnumTestQueryOptEnum]]("query-opt-enum").description("An optional enum, inline, in a query string")) + .in(query[List[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").description("A sequence of enums, inline, in a query string")) + .in(query[Option[List[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").description("An optional sequence of enums, inline, in a query string")) + .in(jsonBody[ObjectWithInlineEnum]) + .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) + + object PostInlineEnumTestQueryEnum { + given plainListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum[PostInlineEnumTestQueryEnum] + given plainListOptPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum[PostInlineEnumTestQueryEnum] + given plainListListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum[PostInlineEnumTestQueryEnum] + given plainListOptListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryEnum] + } + enum PostInlineEnumTestQueryEnum derives enumextensions.EnumMirror { + case bar1, bar2, bar3 + } + + object PostInlineEnumTestQueryOptEnum { + given plainListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum[PostInlineEnumTestQueryOptEnum] + given plainListOptPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum[PostInlineEnumTestQueryOptEnum] + given plainListListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum[PostInlineEnumTestQueryOptEnum] + given plainListOptListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryOptEnum] + } + enum PostInlineEnumTestQueryOptEnum derives enumextensions.EnumMirror { + case bar1, bar2, bar3 + } + + object PostInlineEnumTestQuerySeqEnum { + given plainListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum[PostInlineEnumTestQuerySeqEnum] + given plainListOptPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum[PostInlineEnumTestQuerySeqEnum] + given plainListListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum[PostInlineEnumTestQuerySeqEnum] + given plainListOptListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQuerySeqEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum[PostInlineEnumTestQuerySeqEnum] + } + enum PostInlineEnumTestQuerySeqEnum derives enumextensions.EnumMirror { + case baz1, baz2, baz3 + } + + object PostInlineEnumTestQueryOptSeqEnum { + given plainListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptSeqEnum, sttp.tapir.CodecFormat.TextPlain] = + makeQueryCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] + given plainListOptPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] + given plainListListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = + makeQuerySeqCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] + given plainListOptListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptSeqEnum]], sttp.tapir.CodecFormat.TextPlain] = + makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] + } + enum PostInlineEnumTestQueryOptSeqEnum derives enumextensions.EnumMirror { + case baz1, baz2, baz3 + } + - lazy val generatedEndpoints = List(putAdtTest, postAdtTest) + lazy val generatedEndpoints = List(putAdtTest, postAdtTest, postInlineEnumTest) } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala index dd79dff7cc..1b92581ad9 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala @@ -7,6 +7,7 @@ import sttp.tapir.generated.{TapirGeneratedEndpoints, TapirGeneratedEndpointsJso import sttp.tapir.generated.TapirGeneratedEndpoints._ import sttp.tapir.server.stub.TapirStubInterpreter +import java.util.UUID import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, Future} import scala.concurrent.ExecutionContext.Implicits.global @@ -141,4 +142,73 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { } } + + "enum query param support" in { + var lastValues: ( + PostInlineEnumTestQueryEnum, + Option[PostInlineEnumTestQueryOptEnum], + List[PostInlineEnumTestQuerySeqEnum], + Option[List[PostInlineEnumTestQueryOptSeqEnum]], + ObjectWithInlineEnum + ) = null + val route = TapirGeneratedEndpoints.postInlineEnumTest.serverLogic[Future]({ case (a, b, c, d, e) => + lastValues = (a, b, c, d, e) + Future successful Right[Unit, Unit](()) + }) + + val stub = TapirStubInterpreter(SttpBackendStub.asynchronousFuture) + .whenServerEndpoint(route) + .thenRunLogic() + .backend() + + locally { + val id = UUID.randomUUID() + val reqBody = ObjectWithInlineEnum(id, ObjectWithInlineEnumInlineEnum.foo3) + val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.objectWithInlineEnumJsonEncoder(reqBody).noSpacesSortKeys + reqJsonBody shouldEqual s"""{"id":"$id","inlineEnum":"foo3"}""" + Await.result( + sttp.client3.basicRequest + .post( + uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1,baz2&query-opt-seq-enum=baz1,baz2" + ) + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code === 200 + resp.body shouldEqual Right("") + }, + 1.second + ) + val (a, b, c, d, e) = lastValues + a shouldEqual PostInlineEnumTestQueryEnum.bar1 + b shouldEqual Some(PostInlineEnumTestQueryOptEnum.bar2) + c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) + d shouldEqual Some(Seq(PostInlineEnumTestQueryOptSeqEnum.baz1, PostInlineEnumTestQueryOptSeqEnum.baz2)) + e shouldEqual reqBody + } + + locally { + val id = UUID.randomUUID() + val reqBody = ObjectWithInlineEnum(id, ObjectWithInlineEnumInlineEnum.foo3) + val reqJsonBody = TapirGeneratedEndpointsJsonSerdes.objectWithInlineEnumJsonEncoder(reqBody).noSpacesSortKeys + reqJsonBody shouldEqual s"""{"id":"$id","inlineEnum":"foo3"}""" + Await.result( + sttp.client3.basicRequest + .post(uri"http://test.com/inline/enum/test?query-enum=bar1&query-seq-enum=baz1,baz2") + .body(reqJsonBody) + .send(stub) + .map { resp => + resp.code.code === 200 + resp.body shouldEqual Right("") + }, + 1.second + ) + val (a, b, c, d, e) = lastValues + a shouldEqual PostInlineEnumTestQueryEnum.bar1 + b shouldEqual None + c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) + d shouldEqual None + e shouldEqual reqBody + } + } } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml index 9d0b4e4518..f665518a29 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml @@ -38,6 +38,65 @@ paths: application/json: schema: $ref: '#/components/schemas/ADTWithoutDiscriminator' + '/inline/enum/test': + post: + parameters: + - name: query-enum + in: query + description: An enum, inline, in a query string + required: true + schema: + type: string + enum: + - bar1 + - bar2 + - bar3 + - name: query-opt-enum + in: query + description: An optional enum, inline, in a query string + required: false + schema: + type: string + enum: + - bar1 + - bar2 + - bar3 + - name: query-seq-enum + in: query + description: A sequence of enums, inline, in a query string + required: true + schema: + type: array + items: + type: string + enum: + - baz1 + - baz2 + - baz3 + default: baz2 + - name: query-opt-seq-enum + in: query + description: An optional sequence of enums, inline, in a query string + required: false + schema: + type: array + items: + type: string + enum: + - baz1 + - baz2 + - baz3 + default: baz2 + responses: + '204': + description: No Content + requestBody: + required: true + description: Check inline enums + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectWithInlineEnum' externalDocs: description: Find out more about Swagger url: 'http://swagger.io' @@ -138,3 +197,20 @@ components: - Foo - Bar - Baz + ObjectWithInlineEnum: + title: ObjectWithInlineEnum + required: + - id + - inlineEnum + type: object + properties: + id: + type: string + format: uuid + inlineEnum: + type: string + enum: + - foo1 + - foo2 + - foo3 + - foo4 From 71a2e9b1a2009a568fc01c7b239a0f116466b1e8 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 16:50:11 +0100 Subject: [PATCH 07/15] support exploded types for enums, unexploded types for non-enums --- .../sttp/tapir/codegen/BasicGenerator.scala | 37 +++++ .../codegen/ClassDefinitionGenerator.scala | 129 +++++++----------- .../tapir/codegen/EndpointGenerator.scala | 19 ++- .../sttp/tapir/codegen/EnumGenerator.scala | 21 +-- .../openapi/models/OpenapiModels.scala | 7 +- .../ClassDefinitionGeneratorSpec.scala | 64 +++------ 6 files changed, 128 insertions(+), 149 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala index 9e0ba6e6ee..b3fb401eb9 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala @@ -142,6 +142,41 @@ object BasicGenerator { .mkString("\n") val extraImports = if (endpointsInMain.nonEmpty) s"$maybeJsonImport$maybeSchemaImport" else "" + val queryParamSupport = + """ + |case class CommaSeparatedValues[T](values: List[T]) + |case class ExplodedValues[T](values: List[T]) + |trait QueryParamSupport[T] { + | def decode(s: String): sttp.tapir.DecodeResult[T] + | def encode(t: T): String + |} + |implicit def makeQueryCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(support.decode)(support.encode) + |} + |implicit def makeQueryOptCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(support.decode)).map(_.headOption))(_.map(support.encode)) + |} + |implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + |} + |implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode{ + | case None => DecodeResult.Value(None) + | case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + | }(_.map(_.values.map(support.encode).mkString(","))) + |} + |implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + | sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + | .mapDecode(values => DecodeResult.sequence(values.map(support.decode)).map(s => ExplodedValues(s.toList)))(_.values.map(support.encode)) + |} + |implicit def makeExplodedQuerySeqCodecFromListSeq[T](implicit support: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + | support.mapDecode(l => DecodeResult.Value(ExplodedValues(l)))(_.values) + |} + |""".stripMargin val mainObj = s""" |package $packagePath | @@ -149,6 +184,8 @@ object BasicGenerator { | |${indent(2)(imports(normalisedJsonLib) + extraImports)} | + |$queryParamSupport + | |${indent(2)(classDefns)} | |${indent(2)(maybeSpecificationExtensionKeys)} diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index 3b806d7671..15eab99b47 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -97,86 +97,55 @@ class ClassDefinitionGenerator { .groupBy(_._1) .mapValues(_.map(_._2)) - private def enumQuerySerdeHelperDefn(targetScala3: Boolean): String = if (targetScala3) - """ - |def enumMap[E: enumextensions.EnumMirror]: Map[String, E] = - | Map.from( - | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e - | ) - | - |// Case-insensitive mapping - |def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = - | scala.util - | .Try(eMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(decodeEnum[T](eMap))(_.name) - |} - |def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) - |} - |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) - |} - |def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode{ - | case None => DecodeResult.Value(None) - | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) - | }(_.map(_.map(_.name).mkString(","))) - |} - |""".stripMargin - else - """ - |// Case-insensitive mapping - |def decodeEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T])(s: String): sttp.tapir.DecodeResult[T] = - | scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - |def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(decodeEnum[T](enumName, T))(_.entryName) - | - |def makeQueryOptCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.toSeq.map(decodeEnum[T](enumName, T))).map(_.headOption))(_.map(_.entryName)) - | - |def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName).mkString(",")) - | - |def makeQueryOptSeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode{ - | case None => DecodeResult.Value(None) - | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(r => Some(r.toList)) - | }(_.map(_.map(_.entryName).mkString(","))) - |} - |""".stripMargin + private def enumQuerySerdeHelperDefn(targetScala3: Boolean): String = { + if (targetScala3) + """ + |def enumMap[E: enumextensions.EnumMirror]: Map[String, E] = + | Map.from( + | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e + | ) + |case class EnumQueryParamSupport[T: enumextensions.EnumMirror](eMap: Map[String, T]) extends QueryParamSupport[T] { + | // Case-insensitive mapping + | def decode(s: String): sttp.tapir.DecodeResult[T] = + | scala.util + | .Try(eMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) + | def encode(t: T): String = t.name + |} + |def queryCodecSupport[T: enumextensions.EnumMirror]: QueryParamSupport[T] = + | EnumQueryParamSupport(enumMap[T](using enumextensions.EnumMirror[T])) + |""".stripMargin + else + """ + |case class EnumQueryParamSupport[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]) extends QueryParamSupport[T] { + | // Case-insensitive mapping + | def decode(s: String): sttp.tapir.DecodeResult[T] = + | scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) + | def encode(t: T): String = t.entryName + |} + |def queryCodecSupport[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): QueryParamSupport[T] = + | EnumQueryParamSupport(enumName, T) + |""".stripMargin + } @tailrec final def recursiveFindAllReferencedSchemaTypes( diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index 7d600843a6..f3b3e6b601 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -214,10 +214,14 @@ class EndpointGenerator { jsonSerdeLib, Set.empty ) - val tpe = if (isArray) s"List[$enumName]" else enumName - val req = if (param.required.getOrElse(true)) tpe else s"Option[$tpe]" + def arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues" + val tpe = if (isArray) s"$arrayType[$enumName]" else enumName + val required = param.required.getOrElse(true) + val req = if (required) tpe else s"Option[$tpe]" + def mapToList = + if (!isArray) "" else if (required) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") - s""".in(${param.in}[$req]("${param.name}")$desc)""" -> Some(enumDefn) + s""".in(${param.in}[$req]("${param.name}")$mapToList$desc)""" -> Some(enumDefn) } // .in(query[Limit]("limit").description("Maximum number of books to retrieve")) // .in(header[AuthToken]("X-Auth-Token")) @@ -232,10 +236,13 @@ class EndpointGenerator { s""".in(${param.in}[$req]("${param.name}")$desc)""" -> None case OpenapiSchemaArray(st: OpenapiSchemaSimpleType, _) => val (t, _) = mapSchemaSimpleTypeToType(st) - val arr = s"List[$t]" - val req = if (param.required.getOrElse(true)) arr else s"Option[$arr]" + val arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues" + val arr = s"$arrayType[$t]" + val required = param.required.getOrElse(true) + val req = if (required) arr else s"Option[$arr]" + def mapToList = if (required) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") - s""".in(${param.in}[$req]("${param.name}")$desc)""" -> None + s""".in(${param.in}[$req]("${param.name}")$mapToList$desc)""" -> None case e @ OpenapiSchemaEnum(_, _, _) => getEnumParamDefn(param, e, isArray = false) case OpenapiSchemaArray(e: OpenapiSchemaEnum, _) => getEnumParamDefn(param, e, isArray = true) case x => bail(s"Can't create non-simple params to input - found $x") diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index c95b2e5052..026057e1c0 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -18,14 +18,8 @@ object EnumGenerator { val maybeCompanion = if (queryParamRefs contains name) { def helperImpls = - s""" given plainList${name}Codec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum[$name] - | given plainListOpt${name}Codec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptCodecForEnum[$name] - | given plainListList${name}Codec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQuerySeqCodecForEnum[$name] - | given plainListOptList${name}Codec: sttp.tapir.Codec[List[String], Option[List[$name]], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptSeqCodecForEnum[$name]""".stripMargin + s""" given enumCodecSupport${name.capitalize}: QueryParamSupport[$name] = + | queryCodecSupport[$name](enumMap[$name])""".stripMargin s""" |object $name { |$helperImpls @@ -44,7 +38,6 @@ object EnumGenerator { | case ${obj.items.map(_.value).mkString(", ")} |}""".stripMargin :: Nil } else { - val uncapitalisedName = BasicGenerator.uncapitalise(name) val members = obj.items.map { i => s"case object ${i.value} extends $name" } val maybeCodecExtension = jsonSerdeLib match { case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" @@ -54,14 +47,8 @@ object EnumGenerator { val maybeQueryCodecDefn = if (queryParamRefs contains name) { s""" - | implicit val ${uncapitalisedName}QueryCodec: sttp.tapir.Codec[List[String], $name, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum("${name}", ${name}) - | implicit val ${uncapitalisedName}OptQueryCodec: sttp.tapir.Codec[List[String], Option[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptCodecForEnum("${name}", ${name}) - | implicit val ${uncapitalisedName}SeqQueryCodec: sttp.tapir.Codec[List[String], List[$name], sttp.tapir.CodecFormat.TextPlain] = - | makeQuerySeqCodecForEnum("${name}", ${name}) - | implicit val ${uncapitalisedName}OptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[$name]], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptSeqCodecForEnum("${name}", ${name})""".stripMargin + | implicit val enumCodecSupport${name.capitalize}: QueryParamSupport[$name] = + | queryCodecSupport[$name]("${name}", ${name})""".stripMargin } else "" s""" |sealed trait $name extends enumeratum.EnumEntry diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala index 1978120eb3..388776958f 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala @@ -71,8 +71,11 @@ object OpenapiModels { in: String, required: Option[Boolean], description: Option[String], - schema: OpenapiSchemaType - ) + schema: OpenapiSchemaType, + explode: Option[Boolean] = None + ) { + def isExploded: Boolean = explode.contains(true) // default is false + } case class OpenapiResponse( code: String, diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index ba5ec7a0df..aed462ef43 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -304,52 +304,28 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | Map.from( | for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e | ) - |// Case-insensitive mapping - |def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = - | scala.util - | .Try(eMap(s.toUpperCase)) - | .fold( - | _ => - | sttp.tapir.DecodeResult.Error( - | s, - | new NoSuchElementException( - | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" - | ) - | ), - | sttp.tapir.DecodeResult.Value(_) - | ) - |def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(decodeEnum[T](eMap))(_.name) - |} - |def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) - |} - |def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) - |} - |def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { - | val eMap = enumMap[T](using enumextensions.EnumMirror[T]) - | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode{ - | case None => DecodeResult.Value(None) - | case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) - | }(_.map(_.map(_.name).mkString(","))) + |case class EnumQueryParamSupport[T: enumextensions.EnumMirror](eMap: Map[String, T]) extends QueryParamSupport[T] { + | // Case-insensitive mapping + | def decode(s: String): sttp.tapir.DecodeResult[T] = + | scala.util + | .Try(eMap(s.toUpperCase)) + | .fold( + | _ => + | sttp.tapir.DecodeResult.Error( + | s, + | new NoSuchElementException( + | s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + | ) + | ), + | sttp.tapir.DecodeResult.Value(_) + | ) + | def encode(t: T): String = t.name |} + |def queryCodecSupport[T: enumextensions.EnumMirror]: QueryParamSupport[T] = + | EnumQueryParamSupport(enumMap[T](using enumextensions.EnumMirror[T])) |object Test { - | given plainListTestCodec: sttp.tapir.Codec[List[String], Test, sttp.tapir.CodecFormat.TextPlain] = - | makeQueryCodecForEnum[Test] - | given plainListOptTestCodec: sttp.tapir.Codec[List[String], Option[Test], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptCodecForEnum[Test] - | given plainListListTestCodec: sttp.tapir.Codec[List[String], List[Test], sttp.tapir.CodecFormat.TextPlain] = - | makeQuerySeqCodecForEnum[Test] - | given plainListOptListTestCodec: sttp.tapir.Codec[List[String], Option[List[Test]], sttp.tapir.CodecFormat.TextPlain] = - | makeQueryOptSeqCodecForEnum[Test] + | given enumCodecSupportTest: QueryParamSupport[Test] = + | queryCodecSupport[Test](enumMap[Test]) |} |enum Test derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror { | case enum1, enum2 From c0eb06377180154e81162f9eae1fe99585139439 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 21:46:21 +0100 Subject: [PATCH 08/15] fix various tests --- .../sttp/tapir/codegen/BasicGenerator.scala | 2 +- .../sttp/tapir/codegen/EnumGenerator.scala | 2 +- .../openapi/models/OpenapiModels.scala | 2 +- .../ClassDefinitionGeneratorSpec.scala | 2 +- .../Expected.scala.txt | 37 ++++++ .../oneOf-json-roundtrip/Expected.scala.txt | 112 ++++++++-------- .../oneOf-json-roundtrip/swagger.yaml | 2 + .../Expected.scala.txt | 37 ++++++ .../Expected.scala.txt | 123 +++++++++--------- .../src/test/scala/JsonRoundtrip.scala | 4 +- .../oneOf-json-roundtrip_scala3/swagger.yaml | 1 + 11 files changed, 197 insertions(+), 127 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala index b3fb401eb9..b215883997 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala @@ -184,7 +184,7 @@ object BasicGenerator { | |${indent(2)(imports(normalisedJsonLib) + extraImports)} | - |$queryParamSupport + |${indent(2)(queryParamSupport)} | |${indent(2)(classDefns)} | diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index 026057e1c0..3ab18d1db9 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -19,7 +19,7 @@ object EnumGenerator { if (queryParamRefs contains name) { def helperImpls = s""" given enumCodecSupport${name.capitalize}: QueryParamSupport[$name] = - | queryCodecSupport[$name](enumMap[$name])""".stripMargin + | queryCodecSupport[$name]""".stripMargin s""" |object $name { |$helperImpls diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala index 388776958f..391c80427a 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala @@ -74,7 +74,7 @@ object OpenapiModels { schema: OpenapiSchemaType, explode: Option[Boolean] = None ) { - def isExploded: Boolean = explode.contains(true) // default is false + def isExploded: Boolean = !explode.contains(false) // default is true } case class OpenapiResponse( diff --git a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala index aed462ef43..62d7d26562 100644 --- a/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala +++ b/openapi-codegen/core/src/test/scala/sttp/tapir/codegen/ClassDefinitionGeneratorSpec.scala @@ -325,7 +325,7 @@ class ClassDefinitionGeneratorSpec extends CompileCheckTestBase { | EnumQueryParamSupport(enumMap[T](using enumextensions.EnumMirror[T])) |object Test { | given enumCodecSupportTest: QueryParamSupport[Test] = - | queryCodecSupport[Test](enumMap[Test]) + | queryCodecSupport[Test] |} |enum Test derives org.latestbit.circe.adt.codec.JsonTaggedAdt.PureCodec, enumextensions.EnumMirror { | case enum1, enum2 diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt index a549ccc303..981cb832a4 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt @@ -12,6 +12,41 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ + + case class CommaSeparatedValues[T](values: List[T]) + case class ExplodedValues[T](values: List[T]) + trait QueryParamSupport[T] { + def decode(s: String): sttp.tapir.DecodeResult[T] + def encode(t: T): String + } + implicit def makeQueryCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(support.decode)(support.encode) + } + implicit def makeQueryOptCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(support.decode)).map(_.headOption))(_.map(support.encode)) + } + implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + } + implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode{ + case None => DecodeResult.Value(None) + case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + }(_.map(_.values.map(support.encode).mkString(","))) + } + implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.map(support.decode)).map(s => ExplodedValues(s.toList)))(_.values.map(support.encode)) + } + implicit def makeExplodedQuerySeqCodecFromListSeq[T](implicit support: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + support.mapDecode(l => DecodeResult.Value(ExplodedValues(l)))(_.values) + } + + sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -49,6 +84,8 @@ object TapirGeneratedEndpoints { case object Baz extends AnEnum } + + lazy val putAdtTest = endpoint .put diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index 5dc289f129..cf3ae7804d 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -11,35 +11,59 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ - // Case-insensitive mapping - def decodeEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T])(s: String): sttp.tapir.DecodeResult[T] = - scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) - .fold( - _ => - sttp.tapir.DecodeResult.Error( - s, - new NoSuchElementException( - s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" - ) - ), - sttp.tapir.DecodeResult.Value(_) - ) - def makeQueryCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = + + case class CommaSeparatedValues[T](values: List[T]) + case class ExplodedValues[T](values: List[T]) + trait QueryParamSupport[T] { + def decode(s: String): sttp.tapir.DecodeResult[T] + def encode(t: T): String + } + implicit def makeQueryCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(decodeEnum[T](enumName, T))(_.entryName) - def makeQueryOptCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = + .mapDecode(support.decode)(support.encode) + } + implicit def makeQueryOptCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.toSeq.map(decodeEnum[T](enumName, T))).map(_.headOption))(_.map(_.entryName)) - def makeQuerySeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = + .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(support.decode)).map(_.headOption))(_.map(support.encode)) + } + implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(_.toList))(_.map(_.entryName).mkString(",")) - def makeQueryOptSeqCodecForEnum[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { + .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + } + implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](enumName, T))).map(r => Some(r.toList)) - }(_.map(_.map(_.entryName).mkString(","))) + case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + }(_.map(_.values.map(support.encode).mkString(","))) + } + implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.map(support.decode)).map(s => ExplodedValues(s.toList)))(_.values.map(support.encode)) + } + implicit def makeExplodedQuerySeqCodecFromListSeq[T](implicit support: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + support.mapDecode(l => DecodeResult.Value(ExplodedValues(l)))(_.values) + } + + + case class EnumQueryParamSupport[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]) extends QueryParamSupport[T] { + // Case-insensitive mapping + def decode(s: String): sttp.tapir.DecodeResult[T] = + scala.util.Try(T.upperCaseNameValuesToMap(s.toUpperCase)) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + s, + new NoSuchElementException( + s"Could not find value $s for enum ${enumName}, available values: ${T.values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) + def encode(t: T): String = t.entryName } + def queryCodecSupport[T <: enumeratum.EnumEntry](enumName: String, T: enumeratum.Enum[T]): QueryParamSupport[T] = + EnumQueryParamSupport(enumName, T) sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -110,8 +134,8 @@ object TapirGeneratedEndpoints { .in(("inline" / "enum" / "test")) .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) .in(query[Option[PostInlineEnumTestQueryOptEnum]]("query-opt-enum").description("An optional enum, inline, in a query string")) - .in(query[List[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").description("A sequence of enums, inline, in a query string")) - .in(query[Option[List[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").description("An optional sequence of enums, inline, in a query string")) + .in(query[CommaSeparatedValues[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").map(_.values)(CommaSeparatedValues(_)).description("A sequence of enums, inline, in a query string")) + .in(query[Option[CommaSeparatedValues[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").map(_.map(_.values))(_.map(CommaSeparatedValues(_))).description("An optional sequence of enums, inline, in a query string")) .in(jsonBody[ObjectWithInlineEnum]) .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) @@ -121,14 +145,8 @@ object TapirGeneratedEndpoints { case object bar1 extends PostInlineEnumTestQueryEnum case object bar2 extends PostInlineEnumTestQueryEnum case object bar3 extends PostInlineEnumTestQueryEnum - implicit val postInlineEnumTestQueryEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) - implicit val postInlineEnumTestQueryEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) - implicit val postInlineEnumTestQueryEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) - implicit val postInlineEnumTestQueryEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) + implicit val enumCodecSupportPostInlineEnumTestQueryEnum: QueryParamSupport[PostInlineEnumTestQueryEnum] = + queryCodecSupport[PostInlineEnumTestQueryEnum]("PostInlineEnumTestQueryEnum", PostInlineEnumTestQueryEnum) } sealed trait PostInlineEnumTestQueryOptEnum extends enumeratum.EnumEntry @@ -137,14 +155,8 @@ object TapirGeneratedEndpoints { case object bar1 extends PostInlineEnumTestQueryOptEnum case object bar2 extends PostInlineEnumTestQueryOptEnum case object bar3 extends PostInlineEnumTestQueryOptEnum - implicit val postInlineEnumTestQueryOptEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) - implicit val postInlineEnumTestQueryOptEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) - implicit val postInlineEnumTestQueryOptEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) - implicit val postInlineEnumTestQueryOptEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) + implicit val enumCodecSupportPostInlineEnumTestQueryOptEnum: QueryParamSupport[PostInlineEnumTestQueryOptEnum] = + queryCodecSupport[PostInlineEnumTestQueryOptEnum]("PostInlineEnumTestQueryOptEnum", PostInlineEnumTestQueryOptEnum) } sealed trait PostInlineEnumTestQuerySeqEnum extends enumeratum.EnumEntry @@ -153,14 +165,8 @@ object TapirGeneratedEndpoints { case object baz1 extends PostInlineEnumTestQuerySeqEnum case object baz2 extends PostInlineEnumTestQuerySeqEnum case object baz3 extends PostInlineEnumTestQuerySeqEnum - implicit val postInlineEnumTestQuerySeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) - implicit val postInlineEnumTestQuerySeqEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) - implicit val postInlineEnumTestQuerySeqEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) - implicit val postInlineEnumTestQuerySeqEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQuerySeqEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) + implicit val enumCodecSupportPostInlineEnumTestQuerySeqEnum: QueryParamSupport[PostInlineEnumTestQuerySeqEnum] = + queryCodecSupport[PostInlineEnumTestQuerySeqEnum]("PostInlineEnumTestQuerySeqEnum", PostInlineEnumTestQuerySeqEnum) } sealed trait PostInlineEnumTestQueryOptSeqEnum extends enumeratum.EnumEntry @@ -169,14 +175,8 @@ object TapirGeneratedEndpoints { case object baz1 extends PostInlineEnumTestQueryOptSeqEnum case object baz2 extends PostInlineEnumTestQueryOptSeqEnum case object baz3 extends PostInlineEnumTestQueryOptSeqEnum - implicit val postInlineEnumTestQueryOptSeqEnumQueryCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptSeqEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) - implicit val postInlineEnumTestQueryOptSeqEnumOptQueryCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) - implicit val postInlineEnumTestQueryOptSeqEnumSeqQueryCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) - implicit val postInlineEnumTestQueryOptSeqEnumOptSeqQueryCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptSeqEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) + implicit val enumCodecSupportPostInlineEnumTestQueryOptSeqEnum: QueryParamSupport[PostInlineEnumTestQueryOptSeqEnum] = + queryCodecSupport[PostInlineEnumTestQueryOptSeqEnum]("PostInlineEnumTestQueryOptSeqEnum", PostInlineEnumTestQueryOptSeqEnum) } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml index a7e4746f0a..ad72a7f738 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/swagger.yaml @@ -65,6 +65,7 @@ paths: in: query description: A sequence of enums, inline, in a query string required: true + explode: false schema: type: array items: @@ -78,6 +79,7 @@ paths: in: query description: An optional sequence of enums, inline, in a query string required: false + explode: false schema: type: array items: diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt index 88703005a5..23beea5bc0 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt @@ -12,6 +12,41 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ + + case class CommaSeparatedValues[T](values: List[T]) + case class ExplodedValues[T](values: List[T]) + trait QueryParamSupport[T] { + def decode(s: String): sttp.tapir.DecodeResult[T] + def encode(t: T): String + } + implicit def makeQueryCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(support.decode)(support.encode) + } + implicit def makeQueryOptCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(support.decode)).map(_.headOption))(_.map(support.encode)) + } + implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + } + implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode{ + case None => DecodeResult.Value(None) + case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + }(_.map(_.values.map(support.encode).mkString(","))) + } + implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.map(support.decode)).map(s => ExplodedValues(s.toList)))(_.values.map(support.encode)) + } + implicit def makeExplodedQuerySeqCodecFromListSeq[T](implicit support: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + support.mapDecode(l => DecodeResult.Value(ExplodedValues(l)))(_.values) + } + + sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -50,6 +85,8 @@ object TapirGeneratedEndpoints { case object Baz extends AnEnum } + + lazy val putAdtTest = endpoint .put diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt index ca4cd4711d..6aa7987309 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt @@ -11,47 +11,64 @@ object TapirGeneratedEndpoints { import sttp.tapir.generated.TapirGeneratedEndpointsJsonSerdes._ import TapirGeneratedEndpointsSchemas._ - def enumMap[E: enumextensions.EnumMirror]: Map[String, E] = - Map.from( - for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e - ) - // Case-insensitive mapping - def decodeEnum[T: enumextensions.EnumMirror](eMap: Map[String, T])(s: String): sttp.tapir.DecodeResult[T] = - scala.util - .Try(eMap(s.toUpperCase)) - .fold( - _ => - sttp.tapir.DecodeResult.Error( - s, - new NoSuchElementException( - s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" - ) - ), - sttp.tapir.DecodeResult.Value(_) - ) - def makeQueryCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { - val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + + case class CommaSeparatedValues[T](values: List[T]) + case class ExplodedValues[T](values: List[T]) + trait QueryParamSupport[T] { + def decode(s: String): sttp.tapir.DecodeResult[T] + def encode(t: T): String + } + implicit def makeQueryCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(decodeEnum[T](eMap))(_.name) + .mapDecode(support.decode)(support.encode) } - def makeQueryOptCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { - val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + implicit def makeQueryOptCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], Option[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(decodeEnum[T](eMap))).map(_.headOption))(_.map(_.name)) + .mapDecode(maybeV => DecodeResult.sequence(maybeV.toSeq.map(support.decode)).map(_.headOption))(_.map(support.encode)) } - def makeQuerySeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain] = { - val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(_.toList))(_.map(_.name).mkString(",")) + .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) } - def makeQueryOptSeqCodecForEnum[T: enumextensions.EnumMirror]: sttp.tapir.Codec[List[String], Option[List[T]], sttp.tapir.CodecFormat.TextPlain] = { - val eMap = enumMap[T](using enumextensions.EnumMirror[T]) + implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(decodeEnum[T](eMap))).map(r => Some(r.toList)) - }(_.map(_.map(_.name).mkString(","))) + case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + }(_.map(_.values.map(support.encode).mkString(","))) + } + implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + sttp.tapir.Codec.list[String, String, sttp.tapir.CodecFormat.TextPlain] + .mapDecode(values => DecodeResult.sequence(values.map(support.decode)).map(s => ExplodedValues(s.toList)))(_.values.map(support.encode)) + } + implicit def makeExplodedQuerySeqCodecFromListSeq[T](implicit support: sttp.tapir.Codec[List[String], List[T], sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { + support.mapDecode(l => DecodeResult.Value(ExplodedValues(l)))(_.values) + } + + + def enumMap[E: enumextensions.EnumMirror]: Map[String, E] = + Map.from( + for e <- enumextensions.EnumMirror[E].values yield e.name.toUpperCase -> e + ) + case class EnumQueryParamSupport[T: enumextensions.EnumMirror](eMap: Map[String, T]) extends QueryParamSupport[T] { + // Case-insensitive mapping + def decode(s: String): sttp.tapir.DecodeResult[T] = + scala.util + .Try(eMap(s.toUpperCase)) + .fold( + _ => + sttp.tapir.DecodeResult.Error( + s, + new NoSuchElementException( + s"Could not find value $s for enum ${enumextensions.EnumMirror[T].mirroredName}, available values: ${enumextensions.EnumMirror[T].values.mkString(", ")}" + ) + ), + sttp.tapir.DecodeResult.Value(_) + ) + def encode(t: T): String = t.name } + def queryCodecSupport[T: enumextensions.EnumMirror]: QueryParamSupport[T] = + EnumQueryParamSupport(enumMap[T](using enumextensions.EnumMirror[T])) sealed trait ADTWithoutDiscriminator sealed trait ADTWithDiscriminator sealed trait ADTWithDiscriminatorNoMapping @@ -114,62 +131,38 @@ object TapirGeneratedEndpoints { .in(("inline" / "enum" / "test")) .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) .in(query[Option[PostInlineEnumTestQueryOptEnum]]("query-opt-enum").description("An optional enum, inline, in a query string")) - .in(query[List[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").description("A sequence of enums, inline, in a query string")) - .in(query[Option[List[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").description("An optional sequence of enums, inline, in a query string")) + .in(query[ExplodedValues[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").map(_.values)(ExplodedValues(_)).description("A sequence of enums, inline, in a query string")) + .in(query[Option[CommaSeparatedValues[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").map(_.map(_.values))(_.map(CommaSeparatedValues(_))).description("An optional sequence of enums, inline, in a query string")) .in(jsonBody[ObjectWithInlineEnum]) .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) object PostInlineEnumTestQueryEnum { - given plainListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum[PostInlineEnumTestQueryEnum] - given plainListOptPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum[PostInlineEnumTestQueryEnum] - given plainListListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum[PostInlineEnumTestQueryEnum] - given plainListOptListPostInlineEnumTestQueryEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryEnum] + given enumCodecSupportPostInlineEnumTestQueryEnum: QueryParamSupport[PostInlineEnumTestQueryEnum] = + queryCodecSupport[PostInlineEnumTestQueryEnum] } enum PostInlineEnumTestQueryEnum derives enumextensions.EnumMirror { case bar1, bar2, bar3 } object PostInlineEnumTestQueryOptEnum { - given plainListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum[PostInlineEnumTestQueryOptEnum] - given plainListOptPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum[PostInlineEnumTestQueryOptEnum] - given plainListListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum[PostInlineEnumTestQueryOptEnum] - given plainListOptListPostInlineEnumTestQueryOptEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryOptEnum] + given enumCodecSupportPostInlineEnumTestQueryOptEnum: QueryParamSupport[PostInlineEnumTestQueryOptEnum] = + queryCodecSupport[PostInlineEnumTestQueryOptEnum] } enum PostInlineEnumTestQueryOptEnum derives enumextensions.EnumMirror { case bar1, bar2, bar3 } object PostInlineEnumTestQuerySeqEnum { - given plainListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQuerySeqEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum[PostInlineEnumTestQuerySeqEnum] - given plainListOptPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum[PostInlineEnumTestQuerySeqEnum] - given plainListListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQuerySeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum[PostInlineEnumTestQuerySeqEnum] - given plainListOptListPostInlineEnumTestQuerySeqEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQuerySeqEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum[PostInlineEnumTestQuerySeqEnum] + given enumCodecSupportPostInlineEnumTestQuerySeqEnum: QueryParamSupport[PostInlineEnumTestQuerySeqEnum] = + queryCodecSupport[PostInlineEnumTestQuerySeqEnum] } enum PostInlineEnumTestQuerySeqEnum derives enumextensions.EnumMirror { case baz1, baz2, baz3 } object PostInlineEnumTestQueryOptSeqEnum { - given plainListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], PostInlineEnumTestQueryOptSeqEnum, sttp.tapir.CodecFormat.TextPlain] = - makeQueryCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] - given plainListOptPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], Option[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] - given plainListListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], List[PostInlineEnumTestQueryOptSeqEnum], sttp.tapir.CodecFormat.TextPlain] = - makeQuerySeqCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] - given plainListOptListPostInlineEnumTestQueryOptSeqEnumCodec: sttp.tapir.Codec[List[String], Option[List[PostInlineEnumTestQueryOptSeqEnum]], sttp.tapir.CodecFormat.TextPlain] = - makeQueryOptSeqCodecForEnum[PostInlineEnumTestQueryOptSeqEnum] + given enumCodecSupportPostInlineEnumTestQueryOptSeqEnum: QueryParamSupport[PostInlineEnumTestQueryOptSeqEnum] = + queryCodecSupport[PostInlineEnumTestQueryOptSeqEnum] } enum PostInlineEnumTestQueryOptSeqEnum derives enumextensions.EnumMirror { case baz1, baz2, baz3 diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala index 1b92581ad9..9888b24a61 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala @@ -169,7 +169,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Await.result( sttp.client3.basicRequest .post( - uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1,baz2&query-opt-seq-enum=baz1,baz2" + uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1&query-seq-enum=baz2&query-opt-seq-enum=baz1,baz2" ) .body(reqJsonBody) .send(stub) @@ -194,7 +194,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { reqJsonBody shouldEqual s"""{"id":"$id","inlineEnum":"foo3"}""" Await.result( sttp.client3.basicRequest - .post(uri"http://test.com/inline/enum/test?query-enum=bar1&query-seq-enum=baz1,baz2") + .post(uri"http://test.com/inline/enum/test?query-enum=bar1&query-seq-enum=baz1&query-seq-enum=baz2") .body(reqJsonBody) .send(stub) .map { resp => diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml index f665518a29..2040404fce 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml @@ -78,6 +78,7 @@ paths: in: query description: An optional sequence of enums, inline, in a query string required: false + explode: false schema: type: array items: From 4d3e05afd9f9a432c7f3e3cde3ead618a5ea6741 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 22:12:54 +0100 Subject: [PATCH 09/15] fix 'copyArrayToImmutableIndexedSeq is deprecated' warning on split(...).map --- .../src/main/scala/sttp/tapir/codegen/BasicGenerator.scala | 4 ++-- .../oneOf-json-roundtrip-zio/Expected.scala.txt | 4 ++-- .../oneOf-json-roundtrip/Expected.scala.txt | 4 ++-- .../oneOf-json-roundtrip_jsoniter/Expected.scala.txt | 4 ++-- .../oneOf-json-roundtrip_scala3/Expected.scala.txt | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala index b215883997..62b0a6c1b4 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/BasicGenerator.scala @@ -160,13 +160,13 @@ object BasicGenerator { |} |implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { | sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - | .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + | .mapDecode(values => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) |} |implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { | sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] | .mapDecode{ | case None => DecodeResult.Value(None) - | case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + | case Some(values) => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) | }(_.map(_.values.map(support.encode).mkString(","))) |} |implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt index 981cb832a4..0037ea27e8 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip-zio/Expected.scala.txt @@ -29,13 +29,13 @@ object TapirGeneratedEndpoints { } implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + .mapDecode(values => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) } implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + case Some(values) => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) }(_.map(_.values.map(support.encode).mkString(","))) } implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt index cf3ae7804d..c284b31eb6 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip/Expected.scala.txt @@ -28,13 +28,13 @@ object TapirGeneratedEndpoints { } implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + .mapDecode(values => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) } implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + case Some(values) => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) }(_.map(_.values.map(support.encode).mkString(","))) } implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt index 23beea5bc0..d604380965 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_jsoniter/Expected.scala.txt @@ -29,13 +29,13 @@ object TapirGeneratedEndpoints { } implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + .mapDecode(values => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) } implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + case Some(values) => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) }(_.map(_.values.map(support.encode).mkString(","))) } implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt index 6aa7987309..b0d95557c0 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt @@ -28,13 +28,13 @@ object TapirGeneratedEndpoints { } implicit def makeUnexplodedQuerySeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], CommaSeparatedValues[T], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHead[String, String, sttp.tapir.CodecFormat.TextPlain] - .mapDecode(values => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) + .mapDecode(values => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(s => CommaSeparatedValues(s.toList)))(_.values.map(support.encode).mkString(",")) } implicit def makeUnexplodedQueryOptSeqCodecFromListHead[T](implicit support: sttp.tapir.Codec[List[String], T, sttp.tapir.CodecFormat.TextPlain]): sttp.tapir.Codec[List[String], Option[CommaSeparatedValues[T]], sttp.tapir.CodecFormat.TextPlain] = { sttp.tapir.Codec.listHeadOption[String, String, sttp.tapir.CodecFormat.TextPlain] .mapDecode{ case None => DecodeResult.Value(None) - case Some(values) => DecodeResult.sequence(values.split(',').map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) + case Some(values) => DecodeResult.sequence(values.split(',').toSeq.map(e => support.rawDecode(List(e)))).map(r => Some(CommaSeparatedValues(r.toList))) }(_.map(_.values.map(support.encode).mkString(","))) } implicit def makeExplodedQuerySeqCodecFromSupport[T](implicit support: QueryParamSupport[T]): sttp.tapir.Codec[List[String], ExplodedValues[T], sttp.tapir.CodecFormat.TextPlain] = { From bda50203d25462d1b4cc1461a91920dad9fdcf76 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Wed, 19 Jun 2024 23:07:55 +0100 Subject: [PATCH 10/15] handle wider range of enum names --- .../main/scala/sttp/tapir/codegen/EnumGenerator.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index 3ab18d1db9..e7d54cd1bc 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -14,6 +14,11 @@ object EnumGenerator { jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, jsonParamRefs: Set[String] ): Seq[String] = { + val legalRegex = "([a-zA-Z][a-zA-Z0-9_]*)".r + def maybeEscaped(s: String) = s match { + case legalRegex(l) => l + case illegal => s"`$illegal`" + } if (targetScala3) { val maybeCompanion = if (queryParamRefs contains name) { @@ -35,10 +40,10 @@ object EnumGenerator { } s"""$maybeCompanion |enum $name$maybeCodecExtensions { - | case ${obj.items.map(_.value).mkString(", ")} + | case ${obj.items.map(i => maybeEscaped(i.value)).mkString(", ")} |}""".stripMargin :: Nil } else { - val members = obj.items.map { i => s"case object ${i.value} extends $name" } + val members = obj.items.map { i => s"case object ${maybeEscaped(i.value)} extends $name" } val maybeCodecExtension = jsonSerdeLib match { case _ if !jsonParamRefs.contains(name) && !queryParamRefs.contains(name) => "" case JsonSerdeLib.Circe => s" with enumeratum.CirceEnum[$name]" From 1a8689c8de1f6ab84abf425f6071c8241bcc1919 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Thu, 20 Jun 2024 08:44:12 +0100 Subject: [PATCH 11/15] Handle optional explode = true query param lists, and add test --- .../scala/sttp/tapir/codegen/EndpointGenerator.scala | 12 ++++++++---- .../oneOf-json-roundtrip_scala3/Expected.scala.txt | 2 +- .../src/test/scala/JsonRoundtrip.scala | 12 ++++++------ .../oneOf-json-roundtrip_scala3/swagger.yaml | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index f3b3e6b601..5aa90fffbf 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -217,9 +217,11 @@ class EndpointGenerator { def arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues" val tpe = if (isArray) s"$arrayType[$enumName]" else enumName val required = param.required.getOrElse(true) - val req = if (required) tpe else s"Option[$tpe]" + // 'exploded' params have no distinction between an empty list and an absent value, so don't wrap in 'Option' for them + val noOptionWrapper = required || (isArray && param.isExploded) + val req = if (noOptionWrapper) tpe else s"Option[$tpe]" def mapToList = - if (!isArray) "" else if (required) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" + if (!isArray) "" else if (noOptionWrapper) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") s""".in(${param.in}[$req]("${param.name}")$mapToList$desc)""" -> Some(enumDefn) } @@ -239,8 +241,10 @@ class EndpointGenerator { val arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues" val arr = s"$arrayType[$t]" val required = param.required.getOrElse(true) - val req = if (required) arr else s"Option[$arr]" - def mapToList = if (required) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" + // 'exploded' params have no distinction between an empty list and an absent value, so don't wrap in 'Option' for them + val noOptionWrapper = required || param.isExploded + val req = if (noOptionWrapper) arr else s"Option[$arr]" + def mapToList = if (noOptionWrapper) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))" val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""") s""".in(${param.in}[$req]("${param.name}")$mapToList$desc)""" -> None case e @ OpenapiSchemaEnum(_, _, _) => getEnumParamDefn(param, e, isArray = false) diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt index b0d95557c0..c47e048307 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/Expected.scala.txt @@ -132,7 +132,7 @@ object TapirGeneratedEndpoints { .in(query[PostInlineEnumTestQueryEnum]("query-enum").description("An enum, inline, in a query string")) .in(query[Option[PostInlineEnumTestQueryOptEnum]]("query-opt-enum").description("An optional enum, inline, in a query string")) .in(query[ExplodedValues[PostInlineEnumTestQuerySeqEnum]]("query-seq-enum").map(_.values)(ExplodedValues(_)).description("A sequence of enums, inline, in a query string")) - .in(query[Option[CommaSeparatedValues[PostInlineEnumTestQueryOptSeqEnum]]]("query-opt-seq-enum").map(_.map(_.values))(_.map(CommaSeparatedValues(_))).description("An optional sequence of enums, inline, in a query string")) + .in(query[ExplodedValues[PostInlineEnumTestQueryOptSeqEnum]]("query-opt-seq-enum").map(_.values)(ExplodedValues(_)).description("An optional sequence of enums, inline, in a query string")) .in(jsonBody[ObjectWithInlineEnum]) .out(statusCode(sttp.model.StatusCode(204)).description("No Content")) diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala index 9888b24a61..b526cc9bf4 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/src/test/scala/JsonRoundtrip.scala @@ -145,12 +145,12 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { "enum query param support" in { var lastValues: ( - PostInlineEnumTestQueryEnum, + PostInlineEnumTestQueryEnum, Option[PostInlineEnumTestQueryOptEnum], List[PostInlineEnumTestQuerySeqEnum], - Option[List[PostInlineEnumTestQueryOptSeqEnum]], + List[PostInlineEnumTestQueryOptSeqEnum], ObjectWithInlineEnum - ) = null + ) = null val route = TapirGeneratedEndpoints.postInlineEnumTest.serverLogic[Future]({ case (a, b, c, d, e) => lastValues = (a, b, c, d, e) Future successful Right[Unit, Unit](()) @@ -169,7 +169,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { Await.result( sttp.client3.basicRequest .post( - uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1&query-seq-enum=baz2&query-opt-seq-enum=baz1,baz2" + uri"http://test.com/inline/enum/test?query-enum=bar1&query-opt-enum=bar2&query-seq-enum=baz1&query-seq-enum=baz2&query-opt-seq-enum=baz1&query-opt-seq-enum=baz2" ) .body(reqJsonBody) .send(stub) @@ -183,7 +183,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { a shouldEqual PostInlineEnumTestQueryEnum.bar1 b shouldEqual Some(PostInlineEnumTestQueryOptEnum.bar2) c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) - d shouldEqual Some(Seq(PostInlineEnumTestQueryOptSeqEnum.baz1, PostInlineEnumTestQueryOptSeqEnum.baz2)) + d shouldEqual Seq(PostInlineEnumTestQueryOptSeqEnum.baz1, PostInlineEnumTestQueryOptSeqEnum.baz2) e shouldEqual reqBody } @@ -207,7 +207,7 @@ class JsonRoundtrip extends AnyFreeSpec with Matchers { a shouldEqual PostInlineEnumTestQueryEnum.bar1 b shouldEqual None c shouldEqual Seq(PostInlineEnumTestQuerySeqEnum.baz1, PostInlineEnumTestQuerySeqEnum.baz2) - d shouldEqual None + d shouldEqual Nil e shouldEqual reqBody } } diff --git a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml index 2040404fce..1e68b3950b 100644 --- a/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml +++ b/openapi-codegen/sbt-plugin/src/sbt-test/sbt-openapi-codegen/oneOf-json-roundtrip_scala3/swagger.yaml @@ -78,7 +78,7 @@ paths: in: query description: An optional sequence of enums, inline, in a query string required: false - explode: false + explode: true schema: type: array items: From 361b82b51f264e96bc15c1b49029fb826d0aa713 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Thu, 20 Jun 2024 08:50:55 +0100 Subject: [PATCH 12/15] header arrays must always be comma-separated --- .../sttp/tapir/codegen/openapi/models/OpenapiModels.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala index 391c80427a..cbd3a47d0f 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/openapi/models/OpenapiModels.scala @@ -74,7 +74,8 @@ object OpenapiModels { schema: OpenapiSchemaType, explode: Option[Boolean] = None ) { - def isExploded: Boolean = !explode.contains(false) // default is true + // default is true for query params, but headers must always be 'simple' style -- see https://swagger.io/docs/specification/serialization/ + def isExploded: Boolean = in != "header" && !explode.contains(false) } case class OpenapiResponse( From 63b7f96cdb82748a91d5fa1829fefa826c3f1c96 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Fri, 21 Jun 2024 15:33:43 +0100 Subject: [PATCH 13/15] address some comments --- .../codegen/ClassDefinitionGenerator.scala | 2 +- .../tapir/codegen/EndpointGenerator.scala | 26 +++++++++++++------ .../sttp/tapir/codegen/EnumGenerator.scala | 6 ++--- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala index 15eab99b47..89cc14d95a 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala @@ -241,7 +241,7 @@ class ClassDefinitionGenerator { case ps => ps.mkString(" extends ", " with ", "") } - val enumDefn = maybeEnums.collect { case Some(defn) => defn }.toList + val enumDefn = maybeEnums.flatten.toList s"""|case class $name ( |${indent(2)(properties.mkString(",\n"))} |)$parents""".stripMargin :: innerClasses ::: enumDefn ::: acc diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index 5aa90fffbf..dbec1d459b 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -19,15 +19,21 @@ case class Location(path: String, method: String) { override def toString: String = s"${method.toUpperCase} ${path}" } +case class GeneratedEndpoint(name: String, definition: String, maybeLocalEnums: Option[String]) +case class GeneratedEndpointsForFile(maybeFileName: Option[String], generatedEndpoints: Seq[GeneratedEndpoint]) + case class GeneratedEndpoints( - namesBodiesAndEnums: Seq[(Option[String], Seq[(String, String, Option[String])])], + namesBodiesAndEnums: Seq[GeneratedEndpointsForFile], queryParamRefs: Set[String], jsonParamRefs: Set[String], definesEnumQueryParam: Boolean ) { def merge(that: GeneratedEndpoints): GeneratedEndpoints = GeneratedEndpoints( - (namesBodiesAndEnums ++ that.namesBodiesAndEnums).groupBy(_._1).mapValues(_.map(_._2).reduce(_ ++ _)).toSeq, + (namesBodiesAndEnums ++ that.namesBodiesAndEnums) + .groupBy(_.maybeFileName) + .map { case (fileName, endpoints) => GeneratedEndpointsForFile(fileName, endpoints.map(_.generatedEndpoints).reduce(_ ++ _)) } + .toSeq, queryParamRefs ++ that.queryParamRefs, jsonParamRefs ++ that.jsonParamRefs, definesEnumQueryParam || that.definesEnumQueryParam @@ -52,19 +58,19 @@ class EndpointGenerator { jsonSerdeLib: JsonSerdeLib ): EndpointDefs = { val components = Option(doc.components).flatten - val GeneratedEndpoints(geMap, queryParamRefs, jsonParamRefs, definesEnumQueryParam) = + val GeneratedEndpoints(endpointsByFile, queryParamRefs, jsonParamRefs, definesEnumQueryParam) = doc.paths .map(generatedEndpoints(components, useHeadTagForObjectNames, targetScala3, jsonSerdeLib)) .foldLeft(GeneratedEndpoints(Nil, Set.empty, Set.empty, false))(_ merge _) - val endpointDecls = geMap.map { case (k, ge) => + val endpointDecls = endpointsByFile.map { case GeneratedEndpointsForFile(k, ge) => val definitions = ge - .map { case (name, definition, maybeEnums) => + .map { case GeneratedEndpoint(name, definition, maybeEnums) => s"""lazy val $name = |${indent(2)(definition)}${maybeEnums.fold("")("\n" + _)} |""".stripMargin } .mkString("\n") - val allEP = s"lazy val $allEndpoints = List(${ge.map(_._1).mkString(", ")})" + val allEP = s"lazy val $allEndpoints = List(${ge.map(_.name).mkString(", ")})" k -> s"""|$definitions | @@ -135,14 +141,18 @@ class EndpointGenerator { s"Map[String, $name]" } .toSet - ((maybeTargetFileName, (name, definition, maybeLocalEnums)), (queryParamRefs, jsonParamRefs), maybeLocalEnums.isDefined) + ( + (maybeTargetFileName, GeneratedEndpoint(name, definition, maybeLocalEnums)), + (queryParamRefs, jsonParamRefs), + maybeLocalEnums.isDefined + ) } .unzip3 val (unflattenedQueryParamRefs, unflattenedJsonParamRefs) = unflattenedParamRefs.unzip val namesAndParamsByFile = fileNamesAndParams .groupBy(_._1) .toSeq - .map { case (maybeTargetFileName, defns) => maybeTargetFileName -> defns.map(_._2) } + .map { case (maybeTargetFileName, defns) => GeneratedEndpointsForFile(maybeTargetFileName, defns.map(_._2)) } GeneratedEndpoints( namesAndParamsByFile, unflattenedQueryParamRefs.foldLeft(Set.empty[String])(_ ++ _), diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala index e7d54cd1bc..e794d2e889 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EnumGenerator.scala @@ -4,6 +4,7 @@ import sttp.tapir.codegen.BasicGenerator.indent import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.OpenapiSchemaEnum object EnumGenerator { + val legalEnumName = "([a-zA-Z][a-zA-Z0-9_]*)".r // Uses enumeratum for scala 2, but generates scala 3 enums instead where it can private[codegen] def generateEnum( @@ -14,10 +15,9 @@ object EnumGenerator { jsonSerdeLib: JsonSerdeLib.JsonSerdeLib, jsonParamRefs: Set[String] ): Seq[String] = { - val legalRegex = "([a-zA-Z][a-zA-Z0-9_]*)".r def maybeEscaped(s: String) = s match { - case legalRegex(l) => l - case illegal => s"`$illegal`" + case legalEnumName(l) => l + case illegal => s"`$illegal`" } if (targetScala3) { val maybeCompanion = From 7baae5eb8d090c999263a473cd02225b0a08d9b7 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Fri, 21 Jun 2024 15:35:43 +0100 Subject: [PATCH 14/15] rename field --- .../src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala index dbec1d459b..d896f50364 100644 --- a/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala +++ b/openapi-codegen/core/src/main/scala/sttp/tapir/codegen/EndpointGenerator.scala @@ -23,14 +23,14 @@ case class GeneratedEndpoint(name: String, definition: String, maybeLocalEnums: case class GeneratedEndpointsForFile(maybeFileName: Option[String], generatedEndpoints: Seq[GeneratedEndpoint]) case class GeneratedEndpoints( - namesBodiesAndEnums: Seq[GeneratedEndpointsForFile], + namesAndParamsByFile: Seq[GeneratedEndpointsForFile], queryParamRefs: Set[String], jsonParamRefs: Set[String], definesEnumQueryParam: Boolean ) { def merge(that: GeneratedEndpoints): GeneratedEndpoints = GeneratedEndpoints( - (namesBodiesAndEnums ++ that.namesBodiesAndEnums) + (namesAndParamsByFile ++ that.namesAndParamsByFile) .groupBy(_.maybeFileName) .map { case (fileName, endpoints) => GeneratedEndpointsForFile(fileName, endpoints.map(_.generatedEndpoints).reduce(_ ++ _)) } .toSeq, From c384b9d2b58403f5f1a0caff990a010604be19e9 Mon Sep 17 00:00:00 2001 From: Hugh Simpson Date: Mon, 24 Jun 2024 12:03:47 +0100 Subject: [PATCH 15/15] rm enum caveat --- doc/generator/sbt-openapi-codegen.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/generator/sbt-openapi-codegen.md b/doc/generator/sbt-openapi-codegen.md index 39887ffd0a..900e7aeeb1 100644 --- a/doc/generator/sbt-openapi-codegen.md +++ b/doc/generator/sbt-openapi-codegen.md @@ -106,7 +106,6 @@ jsoniter "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala Currently, string-like enums in Scala 2 depend upon the enumeratum library (`"com.beachape" %% "enumeratum"`). For Scala 3 we derive native enums, and depend on `"io.github.bishabosha" %% "enum-extensions"` for generating query param serdes. -Other forms of OpenApi enum are not currently supported. Models containing binary data cannot be re-used between json and multi-part form endpoints, due to having different representation types for the binary data