Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
import sttp.tapir.codegen.openapi.models.SpecificationExtensionRenderer
import sttp.tapir.codegen.security.SecurityGenerator
import sttp.tapir.codegen.util.ContentTypes
import sttp.tapir.codegen.util.JavaEscape
import sttp.tapir.codegen.util.NameHelpers
import sttp.tapir.codegen.util.NameValidation
Expand Down Expand Up @@ -243,8 +244,6 @@ object RootGenerator {
}
.mkString("\n")

val expectedTypes =
Set("text/plain", "text/html", "application/json", "application/xml", "multipart/form-data", "application/octet-stream")
val mediaType = "([^/]+)/(.+)".r
val customTypes = doc.paths
.flatMap(
Expand All @@ -255,7 +254,7 @@ object RootGenerator {
)
.distinct
.sorted
.filterNot(expectedTypes.contains)
.filterNot(ContentTypes.isNativeContentType)
.map {
case ct @ mediaType(mainType, subType) =>
s"""case class ${NameHelpers.codecFormatName(ct)}() extends CodecFormat {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import sttp.tapir.codegen.openapi.models.GenerationDirectives._
import sttp.tapir.codegen.openapi.models.OpenapiModels.{OpenapiDocument, OpenapiParameter, OpenapiPath}
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
import sttp.tapir.codegen.security.{SecurityDefn, SecurityGenerator, SecurityWrapperDefn}
import sttp.tapir.codegen.util.ContentTypes
import sttp.tapir.codegen.util.ErrUtils.bail
import sttp.tapir.codegen.util.NameHelpers.{indent, strippedToCamelCase}
import sttp.tapir.codegen.util.{JavaEscape, Location}
Expand Down Expand Up @@ -273,12 +274,12 @@ class EndpointGenerator {
.toSet
val xmlParamRefs: Seq[String] = (m.requestBody.toSeq.flatMap(_.resolve(doc).content.map(c => (c.contentType, c.schema))) ++
m.responses.flatMap(_.resolve(doc).content.map(c => (c.contentType, c.schema))))
.collect { case (contentType, schema) if contentType == "application/xml" => schema }
.collect { case (contentType, schema) if ContentTypes.isXml(contentType) => schema }
.collect { case ref: OpenapiSchemaRef if ref.isSchema => ref.stripped }
val jsonParamRefs = (m.requestBody.toSeq.flatMap(_.resolve(doc).content.map(c => (c.contentType, c.schema))) ++
m.responses.flatMap(_.resolve(doc).content.map(c => (c.contentType, c.schema))))
.filterNot(_ => m.tapirCodegenDirectives.contains(jsonBodyAsString))
.collect { case (contentType, schema) if contentType == "application/json" => schema }
.collect { case (contentType, schema) if ContentTypes.isJson(contentType) => schema }
.collect {
case ref: OpenapiSchemaRef if ref.isSchema => ref.stripped
case OpenapiSchemaArray(ref: OpenapiSchemaRef, _, _, _) if ref.isSchema => ref.stripped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{
OpenapiSchemaSimpleType,
OpenapiSchemaString
}
import sttp.tapir.codegen.util.ContentTypes
import sttp.tapir.codegen.util.ErrUtils.bail
import sttp.tapir.codegen.util.Location
import sttp.tapir.codegen.util.NameHelpers.{codecFormatName, indent, safeVariableName}
Expand All @@ -47,8 +48,6 @@ object InAndOutComponents {
case (None, Some(defn)) => Some(defn)
case (Some(defn1), Some(defn2)) => Some(defn1 + separator + defn2)
}
// These types all use 'eager' schemas, except for '*/*', which we default to eager for convenience but which has no schema mappings
private[endpoints] val eagerTypes = Set("application/json", "application/xml", "text/plain", "text/html", "multipart/form-data", "*/*")

private[endpoints] def aliases(packageReuse: PackageReuseContext, types: Seq[String], seperateFilesForModels: Boolean): String =
types.map(PackageReuseContext.enumAliasType(_, packageReuse, seperateFilesForModels)).mkString("\n")
Expand Down Expand Up @@ -96,7 +95,7 @@ object InAndOutComponents {
MappedContentType("stringBody", "String")
case "text/html" =>
MappedContentType("htmlBodyUtf8", "String")
case "application/xml" if xmlSerdeLib != XmlSerdeLib.NoSupport =>
case ct if ContentTypes.isXml(ct) && xmlSerdeLib != XmlSerdeLib.NoSupport =>
val (outT: String, maybeInline: Option[String], maybeAlias: Option[String], maybeTpe: Seq[String]) = schema match {
case st: OpenapiSchemaSimpleType =>
val (t, _) = mapSchemaSimpleTypeToType(st)
Expand All @@ -112,10 +111,10 @@ object InAndOutComponents {
def toList = if (required) ".toList" else ".map(_.toList)"
val bodyType = maybeAlias.map(a => s"xmlBody[$a].map(_.asInstanceOf[$req]$toList)(_.asInstanceOf[$a])").getOrElse(s"xmlBody[$req]")
MappedContentType(bodyType + v(required), req, maybeInline, maybeTpe)
case "application/json" if tapirCodegenDirectives.contains(jsonBodyAsString) =>
case ct if ContentTypes.isJson(ct) && tapirCodegenDirectives.contains(jsonBodyAsString) =>
if (required) MappedContentType("stringJsonBody", "String", None)
else MappedContentType("stringJsonBody.map(Option(_))(_.orNull)", "Option[String]", None)
case "application/json" =>
case ct if ContentTypes.isJson(ct) =>
val (outT, maybeInline) = schema match {
case st: OpenapiSchemaSimpleType =>
val (t, _) = mapSchemaSimpleTypeToType(st)
Expand Down Expand Up @@ -179,17 +178,17 @@ object InAndOutComponents {
def eagerBody = contentType match {
case "application/octet-stream" => "rawBinaryBody(sttp.tapir.RawBodyType.ByteArrayBody)"
case o if o.startsWith("text/") => s"stringBodyUtf8AnyFormat(${codec("String", o)})"
case "application/xml" => s"EndpointIO.Body(RawBodyType.ByteArrayBody, CodecFormat.Xml(), EndpointIO.Info.empty)"
case o if ContentTypes.isXml(o) => s"EndpointIO.Body(RawBodyType.ByteArrayBody, CodecFormat.Xml(), EndpointIO.Info.empty)"
case o => s"EndpointIO.Body(RawBodyType.ByteArrayBody, ${codec("Array[Byte]", o)}, EndpointIO.Info.empty)"
}
def streamingBody = contentType match {
case "text/plain" => "CodecFormat.TextPlain()"
case "text/html" => "CodecFormat.TextHtml()"
case "multipart/form-data" => "CodecFormat.MultipartFormData()"
case "application/grpc" => "CodecFormat.Grpc()"
case "application/json" => "CodecFormat.Json()"
case o if ContentTypes.isJson(o) => "CodecFormat.Json()"
case "application/octet-stream" => "CodecFormat.OctetStream()"
case "application/xml" => "CodecFormat.Xml()"
case o if ContentTypes.isXml(o) => "CodecFormat.Xml()"
case "application/x-www-form-urlencoded" => "CodecFormat.XWwwFormUrlencoded()"
case "application/zip" => "CodecFormat.Zip()"
case o => s"${codecFormatName(o)}()"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package sttp.tapir.codegen.endpoints

import sttp.tapir.codegen.dedup.PackageReuseContext
import sttp.tapir.codegen.endpoints.InAndOutComponents.{aliases, combine, contentTypeMapper, eagerTypes}
import sttp.tapir.codegen.endpoints.InAndOutComponents.{aliases, combine, contentTypeMapper}
import sttp.tapir.codegen.endpoints.Position.Request
import sttp.tapir.codegen.json.JsonSerdeLib.JsonSerdeLib
import sttp.tapir.codegen.openapi.models.OpenapiModels
Expand All @@ -12,7 +12,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiModels.{
OpenapiRequestBodyDefn
}
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.OpenapiSchemaRef
import sttp.tapir.codegen.util.{JavaEscape, Location}
import sttp.tapir.codegen.util.{ContentTypes, JavaEscape, Location}
import sttp.tapir.codegen.util.NameHelpers.indent
import sttp.tapir.codegen.validation.ValidationDefns
import sttp.tapir.codegen.xml.XmlSerdeLib.XmlSerdeLib
Expand Down Expand Up @@ -82,7 +82,7 @@ object InComponent {
Some((s".in($decl$d)", tpe, maybeInlineDefn))
} else {
// We cannot mix eager and streaming types when using oneOfBody
val preferEager = b.content.exists(c => eagerTypes.contains(c.contentType))
val preferEager = b.content.exists(c => ContentTypes.isEager(c.contentType))
val mapped = b.content.map(mapContent(_, b.required, preferEager))
val (decls, tpes, maybeInlineDefns) = mapped.unzip3
val distinctTypes = tpes.distinct
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import sttp.tapir.codegen.openapi.models.GenerationDirectives.{
import sttp.tapir.codegen.openapi.models.OpenapiModels.{OpenapiDocument, OpenapiResponseContent, OpenapiResponseDef}
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{OpenapiSchemaOneOf, OpenapiSchemaRef, OpenapiSchemaSimpleType}
import sttp.tapir.codegen.util.ErrUtils.bail
import sttp.tapir.codegen.util.{JavaEscape, Location, NameHelpers}
import sttp.tapir.codegen.util.{ContentTypes, JavaEscape, Location, NameHelpers}
import sttp.tapir.codegen.util.NameHelpers.indent
import sttp.tapir.codegen.validation.ValidationDefns
import sttp.tapir.codegen.xml.XmlSerdeLib.XmlSerdeLib
Expand Down Expand Up @@ -111,7 +111,7 @@ object OutComponent {
(s"$decl$d", Some(tpe), maybeInlineDefn)
case seq =>
// We cannot mix eager and streaming types when using oneOfBody
val preferEager = seq.exists(c => eagerTypes.contains(c.contentType))
val preferEager = seq.exists(c => ContentTypes.isEager(c.contentType))
val (decls, tpes, maybeInlineDefns) = seq.map(wrapContent(_, preferEager)).unzip3
val distinctTypes = tpes.distinct
// If the types are distinct, we need to produce wrappers with a common parent for oneOfBody to work. If they're
Expand Down Expand Up @@ -393,7 +393,6 @@ object OutComponent {
.map { case (k, vs) => k -> vs.map(_._2) }
.toMap
val traitName = s"${endpointName.capitalize}Body${if (isErrorPosition) "Err" else "Out"}"
val mappable = Set("application/json", "application/xml", "multipart/form-data")
val bodyIsStreaming = (!isErrorPosition && tapirCodegenDirectives.contains(forceRespStreaming)) ||
(!isErrorPosition && tapirCodegenDirectives.contains(forceStreaming))
val bodyIsEager = !bodyIsStreaming && (isErrorPosition ||
Expand All @@ -402,20 +401,20 @@ object OutComponent {
val allElemTypes = many
.flatMap(y =>
y.content.map(x =>
(x.contentType, x.schema, y.content.size > 1 && y.content.map(_.contentType).exists(!mappable.contains(_)))
(x.contentType, x.schema, y.content.size > 1 && y.content.map(_.contentType).exists(!ContentTypes.isClassMappable(_)))
)
)
.map {
case (_, _, _) if bodyIsStreaming => capabilityType(streamingImplementation)
case (_, _, true) => traitName
case (ct, _, _) if ct.startsWith("text/") && isErrorPosition => "String"
case ("text/plain" | "text/html", _, _) => "String"
case ("application/json", _, _) if tapirCodegenDirectives.contains(jsonBodyAsString) => "String"
case (ct, r: OpenapiSchemaRef, _) if mappable.contains(ct) => r.stripped
case (ct, x: OpenapiSchemaSimpleType, _) if mappable.contains(ct) => mapSchemaSimpleTypeToType(x)._1
case (ct, x, _) if mappable.contains(ct) => bail(s"Unexpected oneOf elem type $x with content type $ct")
case (_, _, _) if bodyIsEager => "Array[Byte]"
case (_, _, _) => capabilityType(streamingImplementation)
case (ct, _, _) if ContentTypes.isJson(ct) && tapirCodegenDirectives.contains(jsonBodyAsString) => "String"
case (ct, r: OpenapiSchemaRef, _) if ContentTypes.isClassMappable(ct) => r.stripped
case (ct, x: OpenapiSchemaSimpleType, _) if ContentTypes.isClassMappable(ct) => mapSchemaSimpleTypeToType(x)._1
case (ct, x, _) if ContentTypes.isClassMappable(ct) => bail(s"Unexpected oneOf elem type $x with content type $ct")
case (_, _, _) if bodyIsEager => "Array[Byte]"
case (_, _, _) => capabilityType(streamingImplementation)
}
.distinct
val commmonType = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{
OpenapiSchemaStringType
}

import sttp.tapir.codegen.util.ContentTypes

import scala.annotation.tailrec

object JsonHelpers {
Expand Down Expand Up @@ -91,12 +93,12 @@ object JsonHelpers {
m.responses
.map(_.resolve(doc))
.flatMap(_.content)
.filter(o => o.contentType == "application/json" && o.schema.isInstanceOf[OpenapiSchemaObject])
.filter(o => ContentTypes.isJson(o.contentType) && o.schema.isInstanceOf[OpenapiSchemaObject])
.map(c => (m.name(p.url).capitalize + "Response", c.schema, true)) ++
m.requestBody.toSeq
.map(_.resolve(doc))
.flatMap(_.content)
.filter(o => o.contentType == "application/json" && o.schema.isInstanceOf[OpenapiSchemaObject])
.filter(o => ContentTypes.isJson(o.contentType) && o.schema.isInstanceOf[OpenapiSchemaObject])
.map(c => (m.name(p.url).capitalize + "Request", c.schema, true))
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package sttp.tapir.codegen.util

object ContentTypes {
private val jsonContentType = "application/(.+\\+)?json".r

/** Returns if the content type is a Json content type. Accepts standard application/json, as well as extended application/foo+json
* content types
*/
def isJson(contentType: String): Boolean = jsonContentType.pattern.matcher(contentType).matches

private val xmlContentType = "application/(.+\\+)?xml".r

/** Returns if the content type is a XML content type. Accepts standard application/xml, as well as extended application/foo+xml content
* types
*/
def isXml(contentType: String): Boolean = xmlContentType.pattern.matcher(contentType).matches

private val nativeContentTypes: Set[String] = Set("text/plain", "text/html", "multipart/form-data", "application/octet-stream")

/** Returns true if the content type has a native codec in codegen, and does not require a custom codec
*/
def isNativeContentType(contentType: String): Boolean =
nativeContentTypes.contains(contentType) || isJson(contentType) || isXml(contentType)

private val classMappableContentTypes = Set("multipart/form-data")

/** Returns true if a content type can be mapped to a scala class
*/
def isClassMappable(contentType: String): Boolean =
classMappableContentTypes.contains(contentType) || ContentTypes.isJson(contentType) || ContentTypes.isXml(contentType)

// These types all use 'eager' schemas, except for '*/*', which we default to eager for convenience but which has no schema mappings
private val eagerContentTypes = Set("text/plain", "text/html", "multipart/form-data", "*/*")

/** Returns true if the content type is eager
*/
def isEager(contentType: String): Boolean =
eagerContentTypes.contains(contentType) || ContentTypes.isJson(contentType) || ContentTypes.isXml(contentType)
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,29 @@ class RootGeneratorSpec extends CompileCheckTestBase {
generated.shouldCompile()
})

VersionCheck.runTest(jsonSerdeLib)(
it should s"treat 'application/xxx+json' bodies as json using $jsonSerdeLib serdes" in {
val doc = TestHelpers.parseYamlDocument(TestHelpers.structuredSyntaxSuffixJsonYaml).fold(err => fail(err.getMessage), identity)
val generated = gen(doc, useHeadTagForObjectNames = false, jsonSerdeLib = jsonSerdeLib)

// 'application/problem+json' error body
generated should include("""errorOut(jsonBody[Problem]""")
// 'application/merge-patch+json' request body, and 'application/vnd.example.widget+json' response body
generated should include(
""" lazy val patchWidget =
| endpoint
| .name("patchWidget")
| .patch
| .in(("widgets" / path[String]("id")))
| .in(jsonBody[Widget])
| .out(jsonBody[List[Widget]].description(""))""".stripMargin
)
// '+json' types are mapped to jsonBody, so need no generated CodecFormat
generated should not include "extends CodecFormat"
generated.shouldCompile()
}
)

VersionCheck.runTest(jsonSerdeLib)(
it should s"compile endpoints with date and duration default values using ${jsonSerdeLib} serdes" in {
val doc = TestHelpers.parseYamlDocument(TestHelpers.dateAndDurationDefaultsYaml).fold(err => fail(err.getMessage), identity)
Expand All @@ -138,6 +161,34 @@ class RootGeneratorSpec extends CompileCheckTestBase {
)
}

it should "treat 'application/xxx+xml' bodies as xml" in {
val doc = TestHelpers.parseYamlDocument(TestHelpers.structuredSyntaxSuffixXmlYaml).fold(err => fail(err.getMessage), identity)
val generated = genMap(doc, useHeadTagForObjectNames = false, jsonSerdeLib = "circe")
val endpoints = generated("TapirGeneratedEndpoints")

// 'application/vnd.example.gadget+xml' request body, 'application/atom+xml' response body and
// 'application/problem+xml' error body
endpoints should include(
""" lazy val createGadget =
| endpoint
| .name("createGadget")
| .post
| .in(("gadgets"))
| .in(xmlBody[Gadget])
| .errorOut(xmlBody[Fault].description("").and(statusCode(sttp.model.StatusCode(400))))
| .out(xmlBody[Gadget].description(""))""".stripMargin
)
// '+xml' types are mapped to xmlBody, so need no generated CodecFormat
endpoints should not include "extends CodecFormat"

// xml serdes must be generated for types only ever referenced from a '+xml' body
val xmlSerdes = generated("TapirGeneratedEndpointsXmlSerdes")
xmlSerdes should include("Decoder[Gadget]")
xmlSerdes should include("Encoder[Gadget]")
xmlSerdes should include("Decoder[Fault]")
xmlSerdes should include("Encoder[Fault]")
}

it should "split models into separate files when seperateFilesForModels is true" in {
val info = RootGenerator.generateObjects(
TestHelpers.myBookshopDoc,
Expand Down
Loading
Loading