openapi.openapi.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of otoroshi_2.12 Show documentation
Show all versions of otoroshi_2.12 Show documentation
Lightweight api management on top of a modern http reverse proxy
The newest version!
package otoroshi.openapi
import akka.http.scaladsl.model.{HttpProtocol, HttpProtocols}
import io.github.classgraph._
import otoroshi.env.Env
import otoroshi.models.Entity
import otoroshi.utils.RegexPool
import otoroshi.utils.cache.types.UnboundedTrieMap
import otoroshi.utils.syntax.implicits._
import play.api.Logger
import play.api.libs.json._
import java.io.File
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicLong
import scala.collection.JavaConverters._
import scala.collection.concurrent.TrieMap
case class OpenApiGeneratorConfig(filePath: String, raw: JsValue) {
lazy val add_schemas = raw.select("add_schemas").asOpt[JsObject].getOrElse(Json.obj())
lazy val merge_schemas = raw.select("merge_schemas").asOpt[JsObject].getOrElse(Json.obj())
lazy val fields_rename = raw.select("fields_rename").asOpt[JsObject].getOrElse(Json.obj())
lazy val add_fields = raw.select("add_fields").asOpt[JsObject].getOrElse(Json.obj())
lazy val bulkControllerMethods = raw
.select("bulkControllerMethods")
.asOpt[Seq[String]]
.getOrElse(
Seq(
"bulkUpdateAction",
"bulkCreateAction",
"bulkPatchAction",
"bulkDeleteAction"
)
)
lazy val crudControllerMethods = raw
.select("crudControllerMethods")
.asOpt[Seq[String]]
.getOrElse(
Seq(
"createAction",
"findAllEntitiesAction",
"findEntityByIdAction",
"updateEntityAction",
"patchEntityAction",
"deleteEntityAction",
"deleteEntitiesAction"
)
)
lazy val banned: Seq[String] = raw
.select("banned")
.asOpt[Seq[String]]
.getOrElse(
Seq(
"otoroshi.controllers.BackOfficeController$SearchedService",
"otoroshi.models.EntityLocationSupport",
//"otoroshi.auth.AuthModuleConfig",
"otoroshi.auth.OAuth2ModuleConfig",
"otoroshi.ssl.ClientCertificateValidator",
"otoroshi.next.models.KvStoredNgBackendDataStore",
"otoroshi.next.models.KvStoredNgTargetDataStore",
"otoroshi.next.models.KvNgRouteDataStore",
"otoroshi.next.models.KvNgServiceDataStore",
"otoroshi.storage.RedisLikeWrapper",
"otoroshi.plugins.log4j.Log4jExpressionPart",
"otoroshi.plugins.jobs.kubernetes.KubernetesSecret",
"otoroshi.plugins.jobs.kubernetes.KubernetesIngressClassParameters",
"otoroshi.plugins.jobs.kubernetes.KubernetesNamespace",
"otoroshi.plugins.jobs.kubernetes.KubernetesPod",
"otoroshi.plugins.jobs.kubernetes.KubernetesOpenshiftDnsOperatorServer",
"otoroshi.plugins.jobs.kubernetes.KubernetesCertSecret",
"otoroshi.plugins.jobs.kubernetes.KubernetesOtoroshiResource",
"otoroshi.plugins.jobs.kubernetes.KubernetesEndpoint",
"otoroshi.plugins.jobs.kubernetes.KubernetesValidatingWebhookConfiguration",
"otoroshi.plugins.jobs.kubernetes.KubernetesConfigMap",
"otoroshi.plugins.jobs.kubernetes.KubernetesOpenshiftDnsOperator",
"otoroshi.plugins.jobs.kubernetes.KubernetesIngressClass",
"otoroshi.plugins.jobs.kubernetes.KubernetesDeployment",
"otoroshi.plugins.jobs.kubernetes.KubernetesService",
"otoroshi.plugins.jobs.kubernetes.OtoResHolder",
"otoroshi.plugins.jobs.kubernetes.KubernetesMutatingWebhookConfiguration",
"otoroshi.plugins.jobs.kubernetes.KubernetesIngress",
"otoroshi.next.plugins.api.*"
)
)
lazy val descriptions: Map[String, String] =
raw.select("descriptions").asOpt[Map[String, String]].getOrElse(Map.empty)
// lazy val old_descriptions: Map[String, String] = raw.select("old_descriptions").asOpt[Map[String, String]].getOrElse(Map.empty)
// lazy val old_examples: Map[String, String] = raw.select("old_examples").asOpt[Map[String, String]].getOrElse(Map.empty)
def write(): Unit = {
val config = Json.obj(
"banned" -> JsArray(banned.map(JsString.apply)),
"descriptions" -> JsObject(descriptions.mapValues(JsString.apply)),
// "old_descriptions" -> JsObject(old_descriptions.mapValues(JsString.apply)),
// "old_examples" -> JsObject(old_examples.mapValues(JsString.apply)),
"bulkControllerMethods" -> JsArray(bulkControllerMethods.map(JsString.apply)),
"crudControllerMethods" -> JsArray(crudControllerMethods.map(JsString.apply)),
"add_schemas" -> add_schemas,
"merge_schemas" -> merge_schemas,
"fields_rename" -> fields_rename,
"add_fields" -> add_fields
)
val f = new File(filePath)
if (!f.exists()) {
f.createNewFile()
}
val descs = descriptions
.mapValues(JsString.apply)
.toSeq
.sortWith((a, b) => a._1.compareTo(b._1) < 0)
.map(t => s" ${JsString(t._1).stringify}: ${t._2.stringify}")
.mkString(",\n")
// val olddescs = old_descriptions.mapValues(JsString.apply).toSeq.sortWith((a, b) => a._1.compareTo(b._1) < 0).map(t => s" ${JsString(t._1).stringify}: ${t._2.stringify}").mkString(",\n")
// "banned": ${JsArray(banned.map(JsString.apply)).prettify},
val fileContent = s"""{
"banned": [\n ${banned.map(JsString.apply).map(_.stringify).mkString(",\n ")}\n ],
"descriptions": {
$descs
},
"add_schemas": ${add_schemas.prettify.split("\n").map(v => " " + v).mkString("\n")},
"merge_schemas": ${merge_schemas.prettify.split("\n").map(v => " " + v).mkString("\n")},
"fields_rename": ${fields_rename.prettify.split("\n").map(v => " " + v).mkString("\n")},
"add_fields": ${add_fields.prettify.split("\n").map(v => " " + v).mkString("\n")}
}"""
println(s"write config file: '${f.getAbsolutePath}'")
Files.write(f.toPath, fileContent.split("\n").toList.asJava, StandardCharsets.UTF_8)
}
}
// TODO: validate weird generated stuff
// TODO: handle all Unknown data type
// TODO: handle all ???
// TODO: handle adt with type field
class OpenApiGenerator(
routerPath: String,
configFilePath: String,
specFiles: Seq[String] = Seq.empty,
write: Boolean = false,
scanResult: ScanResult
) {
lazy val logger = Logger("otoroshi-openapi-generator").logger
val nullType =
// Json.obj("$ref" -> s"#/components/schemas/Null") // Json.obj("type" -> "null") needs openapi 3.1.0 support :(
Json.obj("type" -> "string", "nullable" -> true, "description" -> "null type")
val openApiVersion = JsString("3.0.3")
val unknownValue = "???"
val world = scanResult.getAllClassesAsMap.asScala
val entities: Seq[ClassInfo] = (scanResult.getClassesImplementing(classOf[Entity].getName).asScala ++
scanResult.getSubclasses(classOf[Entity].getName).asScala ++
world.get("otoroshi.models.GlobalConfig") ++
world.get("otoroshi.ssl.pki.models.GenKeyPairQuery") ++
world.get("otoroshi.ssl.pki.models.GenCsrQuery") ++
world.get("otoroshi.ssl.pki.models.GenCertResponse") ++
world.get("otoroshi.ssl.pki.models.GenCsrResponse") ++
world.get("otoroshi.ssl.pki.models.SignCertResponse") ++
world.get("otoroshi.ssl.pki.models.GenKeyPairResponse") ++
world.get("otoroshi.models.ErrorTemplate") ++
world.get("otoroshi.models.Outage") ++
world.get("otoroshi.models.RemainingQuotas") ++
world.get("otoroshi.events.HealthCheckEvent") ++
world
.filter(p =>
p._1.startsWith("otoroshi.next.plugins") ||
p._1.startsWith("otoroshi.next.models") || p._1.startsWith("otoroshi.plugins")
)
.values).toSeq.distinct
var adts = Seq.empty[JsObject]
val foundDescriptions = new UnboundedTrieMap[String, String]()
val found = new AtomicLong(0L)
val notFound = new AtomicLong(0L)
val resFound = new AtomicLong(0L)
val resNotFound = new AtomicLong(0L)
val inFound = new AtomicLong(0L)
val inNotFound = new AtomicLong(0L)
def getFieldDescription(
clazz: ClassInfo,
name: String,
typ: TypeSignature,
config: OpenApiGeneratorConfig
): JsValue = {
val finalPath = s"${clazz.getName}.$name"
config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None =>
notFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
unknownValue.json
case Some(value) =>
found.incrementAndGet()
foundDescriptions.put(finalPath, value)
value.json
}
}
def entityDescription(clazz: String, config: OpenApiGeneratorConfig): JsValue = {
val finalPath = s"entity_description.$clazz"
config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None =>
notFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
unknownValue.json
case Some(value) =>
found.incrementAndGet()
foundDescriptions.put(finalPath, value)
value.json
}
}
def getTagDescription(tagName: String, config: OpenApiGeneratorConfig): JsValue = {
val finalPath = s"tags.$tagName"
config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None =>
notFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
unknownValue.json
case Some(value) =>
found.incrementAndGet()
foundDescriptions.put(finalPath, value)
value.json
}
}
def getOperationDescription(
verb: String,
path: String,
operationId: String,
tag: String,
controllerName: String,
controllerMethod: String,
isCrud: Boolean,
isBulk: Boolean,
entity: Option[String],
rawTag: String,
config: OpenApiGeneratorConfig
): JsValue = {
def foundDescription(finalPath: String, value: String): JsValue = {
found.incrementAndGet()
foundDescriptions.put(finalPath, value)
value.json
}
def singular = entity.map(_.split("\\.").last).get
def plural = s"${singular}s"
val finalPath = s"operations.$controllerName.${controllerMethod}_$tag"
config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None if isBulk && controllerMethod == "bulkUpdateAction" =>
foundDescription(finalPath, s"Update multiple $plural at the same time")
case None if isBulk && controllerMethod == "bulkCreateAction" =>
foundDescription(finalPath, s"Create multiple $plural at the same time")
case None if isBulk && controllerMethod == "bulkPatchAction" =>
foundDescription(finalPath, s"Update (using json-patch) multiple $plural at the same time")
case None if isBulk && controllerMethod == "bulkDeleteAction" =>
foundDescription(finalPath, s"Delete multiple $plural at the same time")
case None if isCrud && controllerMethod == "createAction" => foundDescription(finalPath, s"Creates a $singular")
case None if isCrud && controllerMethod == "findAllEntitiesAction" =>
foundDescription(finalPath, s"Find all possible $plural entities")
case None if isCrud && controllerMethod == "findEntityByIdAction" =>
foundDescription(finalPath, s"Find a specific $singular using its id")
case None if isCrud && controllerMethod == "updateEntityAction" =>
foundDescription(finalPath, s"Updates a specific $singular using its id")
case None if isCrud && controllerMethod == "patchEntityAction" =>
foundDescription(finalPath, s"Updates (using json-patch) a specific $singular using its id")
case None if isCrud && controllerMethod == "deleteEntityAction" =>
foundDescription(finalPath, s"Deletes a specific $singular using its id")
case None if isCrud && controllerMethod == "deleteEntitiesAction" =>
foundDescription(finalPath, s"Deletes all $plural entities")
case None if controllerName.endsWith("TemplatesController") && controllerMethod.startsWith("initiate") =>
foundDescription(
finalPath,
s"Creates a new ${controllerMethod.replace("initiate", "").split("_")(0)} from a template"
)
case None if controllerName.endsWith("TemplatesController") && controllerMethod.startsWith("createFrom") =>
foundDescription(
finalPath,
s"Creates a new ${controllerMethod.replace("createFrom", "").split("_")(0)} from a template"
)
case None =>
notFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
unknownValue.json
case Some(value) =>
found.incrementAndGet()
foundDescriptions.put(finalPath, value)
value.json
}
}
def visitEntity(
clazz: ClassInfo,
parent: Option[ClassInfo],
result: TrieMap[String, JsValue],
config: OpenApiGeneratorConfig
): Unit = {
if (
clazz.getName.contains("$") || config.banned.contains(clazz.getName) || config.banned
.filter(_.contains("*"))
.map(RegexPool.apply)
.exists(_.matches(clazz.getName))
) {
return ()
}
if (clazz.getName.startsWith("otoroshi.")) {
if (clazz.isInterface) {
val children = scanResult.getClassesImplementing(clazz.getName).asScala.map(_.getName)
children
.flatMap(cl => world.get(cl))
.foreach(cl => visitEntity(cl, clazz.some, result, config))
adts = adts :+ Json.obj(
clazz.getName -> Json.obj(
"oneOf" -> JsArray(children.map(c => Json.obj("$ref" -> s"#/components/schemas/$c")))
)
)
}
}
if (!result.contains(clazz.getName)) {
val ctrInfo = clazz.getDeclaredConstructorInfo.asScala.headOption
val params = ctrInfo.map(_.getParameterInfo.toSeq).getOrElse(Seq.empty)
val paramNames = params.map { param =>
param.getName
}
var fields = (clazz.getFieldInfo.asScala ++ clazz.getDeclaredFieldInfo.asScala).toSet
.filter(_.isFinal)
.filter(i => paramNames.contains(i.getName))
if (clazz.getName.startsWith("otoroshi.next.plugins")) {
try {
val c = Class.forName(clazz.getName)
val m = c.getDeclaredMethod("defaultConfigObject")
m.setAccessible(true)
val res = m.invoke(c.getDeclaredConstructor().newInstance())
res match {
case value: Option[_] =>
world.get(value.get.getClass.getName) match {
case Some(newClazz) =>
fields = (newClazz.getFieldInfo.asScala ++ newClazz.getDeclaredFieldInfo.asScala).toSet
.filter(_.isFinal)
case _ => ()
}
case _ =>
}
} catch {
case _: Throwable => ()
// logger.debug(s"failed for ${clazz.getName}")
}
}
var properties = Json.obj()
var required = Json.arr()
def handleType(name: String, valueName: String, typ: TypeSignature): Option[JsObject] = {
valueName match {
case "otoroshi.storage.RedisLike" => None
case "java.security.KeyPair" => None
case "play.api.Logger" => None
case "byte" => None
case "java.security.cert.X509Certificate[]" => None
case _ if typ.toString == "byte" => None
case _ if typ.toString == "java.security.cert.X509Certificate[]" => None
case "int" => Json.obj("type" -> "integer", "format" -> "int32").some
case "long" => Json.obj("type" -> "integer", "format" -> "int64").some
case "double" => Json.obj("type" -> "number", "format" -> "double").some
case "float" => Json.obj("type" -> "number", "format" -> "float").some
case "java.math.BigInteger" => Json.obj("type" -> "integer", "format" -> "int64").some
case "java.math.BigDecimal" => Json.obj("type" -> "number", "format" -> "double").some
case "java.lang.Integer" => Json.obj("type" -> "integer", "format" -> "int32").some
case "java.lang.Long" => Json.obj("type" -> "integer", "format" -> "int64").some
case "java.lang.Double" => Json.obj("type" -> "number", "format" -> "double").some
case "java.lang.Float" => Json.obj("type" -> "number", "format" -> "float").some
case "scala.math.BigInt" => Json.obj("type" -> "integer", "format" -> "int64").some
case "scala.math.BigDecimal" => Json.obj("type" -> "number", "format" -> "double").some
case "scala.Int" => Json.obj("type" -> "integer", "format" -> "int32").some
case "scala.Long" => Json.obj("type" -> "integer", "format" -> "int64").some
case "scala.Double" => Json.obj("type" -> "number", "format" -> "double").some
case "scala.Float" => Json.obj("type" -> "number", "format" -> "float").some
case "boolean" => Json.obj("type" -> "boolean").some
case "java.lang.Boolean" => Json.obj("type" -> "boolean").some
case "scala.Boolean" => Json.obj("type" -> "boolean").some
case "java.lang.String" => Json.obj("type" -> "string").some
case "org.joda.time.DateTime" => Json.obj("type" -> "number").some
case "scala.concurrent.duration.FiniteDuration" => Json.obj("type" -> "number").some
case "org.joda.time.LocalTime" => Json.obj("type" -> "string").some
case "play.api.libs.json.JsValue" => Json.obj("type" -> "object").some
case "play.api.libs.json.JsObject" => Json.obj("type" -> "object").some
case "play.api.libs.json.JsArray" => Json.obj("type" -> "array").some
case "akka.http.scaladsl.model.HttpProtocol" =>
Json
.obj(
"type" -> "string",
"enum" -> Json
.arr(HttpProtocols.`HTTP/1.0`.value, HttpProtocols.`HTTP/1.1`.value, HttpProtocols.`HTTP/2.0`.value)
)
.some
case "otoroshi.models.HttpProtocol" =>
Json
.obj(
"type" -> "string",
"enum" -> Json.arr(
otoroshi.models.HttpProtocols.HTTP_1_0.value,
otoroshi.models.HttpProtocols.HTTP_1_1.value,
otoroshi.models.HttpProtocols.HTTP_2_0.value,
otoroshi.models.HttpProtocols.HTTP_3_0.value
)
)
.some
case "java.security.cert.X509Certificate" =>
Json.obj("type" -> "string", "description" -> "pem encoded X509 certificate").some
case "java.security.PrivateKey" =>
Json.obj("type" -> "string", "description" -> "pem encoded private key").some
case "java.security.PublicKey" =>
Json.obj("type" -> "string", "description" -> "pem encoded private key").some
case "org.bouncycastle.pkcs.PKCS10CertificationRequest" =>
Json.obj("type" -> "string", "description" -> "pem encoded csr").some
case "com.nimbusds.jose.jwk.KeyType" => Json.obj("type" -> "string", "description" -> "key type").some
case _ if typ.toString.startsWith("scala.Option<") => {
world.get(valueName).map(cl => visitEntity(cl, None, result, config))
result.get(valueName) match {
case None
if valueName == "java.lang.Object" && (name == "maxJwtLifespanSecs" || name == "existingSerialNumber") =>
Json.obj("type" -> "integer", "format" -> "int64").some
case Some(v) => Json.obj("$ref" -> s"#/components/schemas/$valueName").some
case _ =>
logger.debug(s"fuuuuu opt, $name, $valueName")
None
}
}
case vn if valueName.startsWith("otoroshi") => {
world.get(valueName).map(cl => visitEntity(cl, None, result, config))
Json.obj("$ref" -> s"#/components/schemas/$valueName").some
}
case _ =>
logger.debug(s"${clazz.getName}.$name: $typ (unexpected 1)")
None
}
}
fields.foreach { field =>
val name = field.getName
val typ = field.getTypeSignatureOrTypeDescriptor
typ match {
case c: BaseTypeSignature =>
val valueName = c.getTypeStr
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, c.getTypeStr, typ).foreach { r =>
properties = properties ++ Json.obj(
finalFieldName -> r.deepMerge(
Json.obj(
"description" -> getFieldDescription(clazz, name, typ, config)
)
)
)
}
case c: ClassRefTypeSignature
if c.getTypeArguments.size() > 0 && c.getBaseClassName == "scala.collection.immutable.Map" =>
val valueName = c.getTypeArguments.asScala.tail.head.toString
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, valueName, typ).foreach { r =>
properties = properties ++ Json.obj(
finalFieldName -> Json.obj(
"type" -> "object",
"additionalProperties" -> r,
"description" -> getFieldDescription(clazz, name, typ, config)
)
)
}
case c: ClassRefTypeSignature
if c.getTypeArguments.size() > 0 && c.getBaseClassName == "scala.collection.Seq" =>
val valueName = c.getTypeArguments.asScala.head.toString
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, valueName, typ).foreach { r =>
properties = properties ++ Json.obj(
finalFieldName -> Json.obj(
"type" -> "array",
"items" -> r,
"description" -> getFieldDescription(clazz, name, typ, config)
)
)
}
case c: ClassRefTypeSignature
if c.getTypeArguments.size() > 0 && c.getBaseClassName == "scala.collection.immutable.List" =>
val valueName = c.getTypeArguments.asScala.head.toString
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, valueName, typ).foreach { r =>
properties = properties ++ Json.obj(
finalFieldName -> Json.obj(
"type" -> "array",
"items" -> r,
"description" -> getFieldDescription(clazz, name, typ, config)
)
)
}
case c: ClassRefTypeSignature if c.getTypeArguments.size() > 0 && c.getBaseClassName == "scala.Option" =>
val valueName = c.getTypeArguments.asScala.head.toString
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, valueName, typ).foreach { r =>
properties = properties ++ Json.obj(
finalFieldName -> Json.obj(
"oneOf" -> Json.arr(
nullType,
r
),
"description" -> getFieldDescription(clazz, name, typ, config)
)
)
}
case c: ClassRefTypeSignature =>
val valueName = c.getBaseClassName
val fieldName = config.fields_rename
.select(s"$name:$valueName")
.asOpt[String]
.orElse(config.fields_rename.select(s"${clazz.getName}.$name:$valueName").asOpt[String])
.orElse(config.fields_rename.select(s"${clazz.getName}.$name").asOpt[String])
.getOrElse(name)
val finalFieldName =
if (field.getClassInfo.getPackageName.startsWith("otoroshi.next")) fieldName.camelToSnake else fieldName
handleType(name, valueName, typ).map { r =>
properties = properties ++ Json.obj(
finalFieldName -> r.deepMerge(Json.obj("description" -> getFieldDescription(clazz, name, typ, config)))
)
}
// case c: TypeVariableSignature => logger.debug(s" $name: $typ ${c.toStringWithTypeBound} (var)")
// case c: ArrayTypeSignature => logger.debug(s" $name: $typ ${c.getTypeSignatureStr} ${c.getElementTypeSignature.toString} (arr)")
// case c: ReferenceTypeSignature => logger.debug(s" $name: $typ (ref)")
case _ =>
logger.debug(s"${clazz.getName}.$name: $typ (unexpected 2)")
}
}
val toMergeSelf = config.merge_schemas.select(clazz.getName).asOpt[JsObject].getOrElse(Json.obj())
val toMergeParent =
parent.flatMap(p => config.merge_schemas.select(p.getName).asOpt[JsObject]).getOrElse(Json.obj())
val toMergeParents = clazz.getInterfaces.asScala
.filter(_.getName.startsWith("otoroshi."))
.flatMap(c => config.merge_schemas.select(c.getName).asOpt[JsObject])
.foldLeft(Json.obj())((a, b) => a.deepMerge(b))
val toMerge = toMergeParent.deepMerge(toMergeParents.deepMerge(toMergeSelf))
if (clazz.getName.startsWith("otoroshi.auth")) {
// logger.debug(clazz.getName + " - " + parent.map(p => p.getName).getOrElse("") + " - " + clazz.getInterfaces.asScala.map(_.getName).mkString(", "))
}
if (toMerge != Json.obj()) {
//logger.debug(s"found some stuff to merge for ${clazz.getName} - ${parent.map(p => p.getName).getOrElse("")}")//- ${toMerge.prettify}")
}
result.put(
clazz.getName,
toMerge.deepMerge(
Json.obj(
"type" -> "object",
"description" -> entityDescription(clazz.getName, config),
"properties" -> properties
)
)
)
}
}
def getConfig(): OpenApiGeneratorConfig = {
val f = new File(configFilePath)
if (f.exists()) {
OpenApiGeneratorConfig(configFilePath, Json.parse(Files.readAllLines(f.toPath).asScala.mkString("\n")))
} else {
OpenApiGeneratorConfig(configFilePath, Json.obj())
}
}
def extractParameters(path: String, entity: Option[String], isBulk: Boolean, isCrud: Boolean): JsValue = {
JsArray(
path
.split("/")
.toSeq
.filter(_.contains(":"))
.map { param =>
val paramName = param.replace(":", "")
if (isBulk || isCrud) {
Json.obj(
"name" -> paramName,
"in" -> "path",
"schema" -> Json.obj(
"type" -> "string"
),
"required" -> true,
"description" -> s"The ${paramName} param of the target entity"
)
} else {
Json.obj(
"name" -> paramName,
"in" -> "path",
"schema" -> Json.obj(
"type" -> "string"
),
"required" -> true,
"description" -> s"the ${paramName} parameter"
)
}
}
)
}
def matchSpecialCases(verb: String, controllerMethod: String): Boolean = {
verb == "GET" && controllerMethod == "sessions"
}
def extractResBody(
verb: String,
path: String,
operationId: String,
tag: String,
controllerName: String,
controllerMethod: String,
isCrud: Boolean,
isBulk: Boolean,
entity: Option[String],
rawTag: String,
config: OpenApiGeneratorConfig
): (String, JsValue) = {
def foundDescription(finalPath: String, value: String): String = {
resFound.incrementAndGet()
foundDescriptions.put(finalPath, value)
value
}
var resStatus = "200"
if (isCrud && controllerMethod == "createAction") {
resStatus = "201"
}
var multiple = false
if ((isCrud && controllerMethod == "findAllEntitiesAction") || matchSpecialCases(verb, controllerMethod)) {
multiple = true
}
val finalPath = s"operations_response_entity.$controllerName.${controllerMethod}_$tag"
val resBody = (config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None if isBulk && controllerMethod == "bulkUpdateAction" => foundDescription(finalPath, "BulkResponseBody")
case None if isBulk && controllerMethod == "bulkCreateAction" => foundDescription(finalPath, "BulkResponseBody")
case None if isBulk && controllerMethod == "bulkPatchAction" => foundDescription(finalPath, "BulkResponseBody")
case None if isBulk && controllerMethod == "bulkDeleteAction" => foundDescription(finalPath, "BulkResponseBody")
case None if isCrud && controllerMethod == "createAction" =>
resStatus = "201"
foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "findAllEntitiesAction" =>
multiple = true
foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "findEntityByIdAction" => foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "updateEntityAction" => foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "patchEntityAction" => foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "deleteEntityAction" => foundDescription(finalPath, entity.get)
case None if isCrud && controllerMethod == "deleteEntitiesAction" => foundDescription(finalPath, entity.get)
case None =>
resNotFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
unknownValue
case Some(value) =>
resFound.incrementAndGet()
foundDescriptions.put(finalPath, value)
value
}) match {
case v if v == unknownValue =>
//Json.obj("$ref" -> "#/components/schemas/Unknown")
Json.obj(
"description" -> "unknown type",
"type" -> "object"
)
case v if multiple => Json.obj("type" -> "array", "items" -> Json.obj("$ref" -> s"#/components/schemas/$v"))
case v =>
if (v.contains(" ")) {
println(s"bad response entity at: ${finalPath}")
}
Json.obj("$ref" -> s"#/components/schemas/$v")
}
(resStatus, resBody)
}
def extractReqBody(
verb: String,
path: String,
operationId: String,
tag: String,
controllerName: String,
controllerMethod: String,
isCrud: Boolean,
isBulk: Boolean,
entity: Option[String],
rawTag: String,
config: OpenApiGeneratorConfig
): Option[JsValue] = {
val shouldHaveBody =
verb.toLowerCase() != "get" && verb.toLowerCase() != "delete" && verb.toLowerCase() != "options"
if (shouldHaveBody) {
def foundDescription(finalPath: String, value: String): String = {
inFound.incrementAndGet()
foundDescriptions.put(finalPath, value)
value
}
val finalPath = s"operations_input_entity.$controllerName.${controllerMethod}_$tag"
val reqBody = (config.descriptions.get(finalPath).filterNot(_ == unknownValue) match {
case None if isBulk && controllerMethod == "bulkUpdateAction" => (true, foundDescription(finalPath, "BulkBody"))
case None if isBulk && controllerMethod == "bulkCreateAction" => (true, foundDescription(finalPath, "BulkBody"))
case None if isBulk && controllerMethod == "bulkPatchAction" =>
(true, foundDescription(finalPath, "BulkPatchBody"))
case None if isBulk && controllerMethod == "bulkDeleteAction" => (true, foundDescription(finalPath, "BulkBody"))
case None if isCrud && controllerMethod == "createAction" => (false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "findAllEntitiesAction" =>
(false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "findEntityByIdAction" =>
(false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "updateEntityAction" =>
(false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "patchEntityAction" =>
(false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "deleteEntityAction" =>
(false, foundDescription(finalPath, entity.get))
case None if isCrud && controllerMethod == "deleteEntitiesAction" =>
(false, foundDescription(finalPath, entity.get))
case None =>
inNotFound.incrementAndGet()
foundDescriptions.put(finalPath, unknownValue)
(false, unknownValue)
case Some(value) =>
inFound.incrementAndGet()
foundDescriptions.put(finalPath, value)
if (isBulk && value == "BulkBody") {
(true, value)
} else {
(false, value)
}
}) match {
case (_, v) if v == unknownValue =>
Json.obj(
"description" -> "unknown type",
"type" -> "object"
)
//Json.obj("$ref" -> "#/components/schemas/Unknown")
case (true, v) if controllerMethod == "bulkPatchAction" => {
Json.obj("$ref" -> s"#/components/schemas/BulkPatchBody")
}
case (true, v) =>
Json.obj(
"type" -> "array",
"items" -> Json.obj("$ref" -> s"#/components/schemas/${entity.get}")
)
case (_, v) =>
if (v.contains(" ")) {
println(s"bad request body at: ${finalPath}")
}
Json.obj("$ref" -> s"#/components/schemas/$v")
}
reqBody.some
} else {
None
}
}
def scanPaths(config: OpenApiGeneratorConfig): (JsValue, JsValue) = {
val f = new File(routerPath)
if (f.exists()) {
var tags = Seq.empty[String]
val lines = Files
.readAllLines(f.toPath, StandardCharsets.UTF_8)
.asScala
.toSeq
.map(_.trim)
.filterNot(_.startsWith("#"))
.filterNot(_.isEmpty);
val pathes: JsObject = lines
.map { line =>
val parts = line.split(" ").toSeq.map(_.trim).filterNot(_.isEmpty).toList
parts match {
case verb :: path :: rest
if path.startsWith("/api/") && !path.startsWith("/api/client-validators") && !path.startsWith(
"/api/swagger"
) && !path.startsWith("/api/openapi") && !path.startsWith("/api/services/:serviceId/apikeys") && !path
.startsWith("/api/groups/:groupId/apikeys") => {
val name = rest.mkString(" ").split("\\(").head
val methodName = name.split("\\.").reverse.head
val controllerName = name.split("\\.").reverse.tail.reverse.mkString(".")
val controller = world.getOrElse(controllerName, null)
if (controller == null) {
return (Json.obj(), Json.obj())
}
val method = controller.getMethodInfo(methodName)
val isCrud = controller.implementsInterface("otoroshi.utils.controllers.CrudControllerHelper")
val isBulk = controller.implementsInterface("otoroshi.utils.controllers.BulkControllerHelper")
val entity = if (isCrud || isBulk) {
controller
.getMethodInfo("extractId")
.asScala
.head
.getParameterInfo
.toSeq
.head
.getTypeDescriptor
.toString
.some
} else {
None
}
val pathParts = path.split("/").toList
val rawTag = pathParts.tail.tail.head
// TODO: from config
val tag = rawTag match {
case "data-exporter-configs" => "data-exporters"
case "tenants" => "organizations"
case "verifiers" => "jwt-verifiers"
case "import" => "import-export"
case "otoroshi.json" => "import-export"
case ":entity" => "templates"
case "new" => "templates"
case "auths" => "auth-modules"
case "stats" => "analytics"
case "events" => "analytics"
case "status" => "analytics"
case "audit" => "events"
case "alert" => "events"
case v => v
}
// TODO: from config
val operationId = s"$controllerName.$methodName" match {
case "otoroshi.controllers.adminapi.StatsController.serviceLiveStats" =>
s"$controllerName.${methodName}_${tag}"
case "otoroshi.controllers.adminapi.TemplatesController.initiateTcpService" =>
s"$controllerName.${methodName}_${tag}"
case "otoroshi.controllers.adminapi.TemplatesController.initiateApiKey" =>
s"$controllerName.${methodName}_${tag}"
case "otoroshi.controllers.adminapi.TemplatesController.initiateService" =>
s"$controllerName.${methodName}_${tag}"
case "otoroshi.controllers.adminapi.TemplatesController.initiateServiceGroup" =>
s"$controllerName.${methodName}_${tag}"
case "otoroshi.controllers.adminapi.TemplatesController.createFromTemplate" if tag == "admins" =>
s"$controllerName.${methodName}_${pathParts.apply(3)}"
case "otoroshi.controllers.adminapi.TemplatesController.createFromTemplate" =>
s"$controllerName.${methodName}_${tag}"
case v => v
}
tags = tags :+ tag
val (resCode, resBody) = extractResBody(
verb,
path,
operationId,
tag,
controllerName,
methodName,
isCrud,
isBulk,
entity,
rawTag,
config
)
val reqBodyOpt = extractReqBody(
verb,
path,
operationId,
tag,
controllerName,
methodName,
isCrud,
isBulk,
entity,
rawTag,
config
)
val customizedPath = path
.split("/")
.map {
case part if part.startsWith(":") => s"{${part.substring(1)}}"
case part => part
}
.mkString("/")
Json.obj(
customizedPath -> Json.obj(
verb.toLowerCase() -> Json
.obj(
"tags" -> Json.arr(tag),
"summary" -> getOperationDescription(
verb,
path,
operationId,
tag,
controllerName,
methodName,
isCrud,
isBulk,
entity,
rawTag,
config
),
"operationId" -> operationId,
"parameters" -> extractParameters(path, entity, isBulk, isCrud),
"security" -> Json.arr(Json.obj("otoroshi_auth" -> Json.arr())),
"responses" -> Json.obj(
"401" -> Json.obj(
"description" -> "You have to provide an Api Key. Api Key can be passed with 'Otoroshi-Client-Id' and 'Otoroshi-Client-Secret' headers, or use basic http authentication",
"content" -> Json.obj(
"application/json" -> Json.obj(
"schema" -> Json.obj(
"$ref" -> "#/components/schemas/ErrorResponse"
)
)
)
),
"400" -> Json.obj(
"description" -> "Bad resource format. Take another look to the swagger, or open an issue",
"content" -> Json.obj(
"application/json" -> Json.obj(
"schema" -> Json.obj(
"$ref" -> "#/components/schemas/ErrorResponse"
)
)
)
),
"404" -> Json.obj(
"description" -> "Resource not found or does not exist",
"content" -> Json.obj(
"application/json" -> Json.obj(
"schema" -> Json.obj(
"$ref" -> "#/components/schemas/ErrorResponse"
)
)
)
),
resCode -> Json.obj(
"description" -> "Successful operation",
"content" -> Json.obj(
(if (isBulk) "application/x-ndjson" else "application/json") -> Json.obj(
"schema" -> resBody
)
)
)
)
)
.applyOnIf(
verb.toLowerCase() != "get" && verb.toLowerCase() != "delete" && verb.toLowerCase() != "options"
) { c =>
c ++ Json.obj(
"requestBody" -> Json.obj(
"description" -> (if (isBulk)
"the request body in nd-json format (1 stringified entity per line)"
else "the request body"),
"required" -> true,
"content" -> Json.obj(
(if (isBulk) "application/x-ndjson" else "application/json") -> Json.obj(
"schema" -> reqBodyOpt.get
)
)
)
)
}
)
)
}
case _ =>
// logger.debug(s"bad definition: $line")
Json.obj()
}
}
.foldLeft(Json.obj())((a, b) => a.deepMerge(b))
(
pathes,
JsArray(
tags.distinct
.sortWith((a, b) => a.compareTo(b) < 0)
.map(t =>
Json.obj("name" -> t).applyOn { o =>
o ++ Json.obj("description" -> getTagDescription(o.select("name").asString, config))
}
)
)
)
} else {
(Json.obj(), Json.obj())
}
}
def run(): JsValue = runAndMaybeWrite()._1
def discoverEntities(
result: TrieMap[String, JsValue],
entity: (String, JsValue),
out: TrieMap[String, JsValue]
): Any = {
out.put(entity._1, entity._2)
Json
.prettyPrint(entity._2)
.split("\n")
.filter(p => p.contains("$ref"))
.map(p => p.replace("\"", "").trim().split("/").last)
.foreach(name => {
result.get(name) match {
case Some(entity) =>
if (!out.contains(name))
discoverEntities(result, (name, entity), out)
out.put(name, entity)
case None =>
}
})
}
def discoverEntitiesFromPaths(result: JsValue) = {
Json
.prettyPrint(result)
.split("\n")
.filter(p => p.contains("$ref"))
.map(p => p.replace("\"", "").trim().split("/").last)
}
def runAndMaybeWrite(): (JsValue, Boolean) = {
val config = getConfig()
val result = new UnboundedTrieMap[String, JsValue]()
config.add_schemas.value.map { case (key, value) =>
result.put(key, value)
}
result.put(
"ErrorResponse",
Json.obj(
"type" -> "object",
"description" -> "error response"
)
)
result.put(
"BulkResponseBody",
Json.obj(
"type" -> "object",
"description" -> "BulkResponseBody object"
)
)
result.put(
"BulkPatchBody",
Json.obj(
"type" -> "object",
"description" -> "BulkPatchBody object"
)
)
val (paths, tags) = scanPaths(config)
val usedEntities = paths
.as[JsObject]
.value
.flatMap { case (_, endpoints) =>
Seq("get", "post", "delete", "put", "patch", "head")
.flatMap(verb => {
endpoints.as[JsObject] \ verb match {
case JsDefined(value: JsObject) =>
Seq("200", "201", "400", "404", "500")
.flatMap(status => {
value \ "responses" \ status \ "content" match {
case JsDefined(value: JsObject) =>
Seq("application/json", "application/x-ndjson")
.flatMap(contentType => {
val ref: Option[String] = (value \ contentType \ "schema") match {
case JsDefined(value: JsObject) =>
value \ "$ref" match {
case JsDefined(JsString(r)) => Some(r)
case _: JsUndefined =>
None
value \ "item" \ "$ref" match {
case JsDefined(JsString(r)) => Some(r)
case _: JsUndefined => None
}
}
case _: JsUndefined => None
}
ref match {
case Some(value) => Some(value.replace("#/components/schemas/", ""))
case None => None
}
})
case _: JsUndefined => None
}
})
case _: JsUndefined => None
}
})
}
.toSeq
.distinct
entities
// .filter(f => usedEntities.contains(f.getName))
.foreach { clazz =>
if (
!config.banned.contains(clazz.getName) && !config.banned
.filter(_.contains("*"))
.map(RegexPool.apply)
.exists(_.matches(clazz.getName))
) {
visitEntity(clazz, None, result, config)
}
}
logger.debug("")
logger.debug(s"found ${found.get()} descriptions, not found ${notFound.get()} descriptions")
logger.debug(s"found ${resFound.get()} response types, not found ${resNotFound.get()} response types")
logger.debug(s"found ${inFound.get()} input types, not found ${inNotFound.get()} input types")
logger.debug("")
logger.debug(s"total found ${found.get() + resFound.get() + inFound
.get()}, not found ${notFound.get() + resNotFound.get() + inNotFound.get()}")
val returnEntities = discoverEntitiesFromPaths(paths)
val filteredEntities = result.filter(p => returnEntities.contains(p._1))
val openApiEntities = new UnboundedTrieMap[String, JsValue]()
filteredEntities.foreach(ent => {
discoverEntities(result, ent, openApiEntities)
})
// build spec with only used entities
val spec = getSpec(tags, paths, openApiEntities)
var hasWritten = false
if (write) {
logger.debug("")
val cfg = OpenApiGeneratorConfig(
config.filePath,
config.raw.asObject ++ Json.obj(
"descriptions" -> JsObject(foundDescriptions.mapValues(JsString.apply))
// "add_schemas" -> (config.add_schemas ++ adts.foldLeft(Json.obj())(_ ++ _))
)
)
specFiles.foreach { specFile =>
val prettyJson = spec.prettify.trim
val file = new File(specFile)
logger.debug(s"writing spec to: '${file.getAbsolutePath}'")
if (file.exists()) {
val jsonRaw = Files.readString(file.toPath).trim
if (prettyJson.sha256 != jsonRaw.sha256) {
Files.writeString(file.toPath, prettyJson, StandardCharsets.UTF_8)
cfg.write()
hasWritten = true
}
} else {
Files.writeString(file.toPath, prettyJson, StandardCharsets.UTF_8)
cfg.write()
hasWritten = true
}
}
// cfg.write()
logger.debug("")
}
// return spec with full entities (cauz it used by other generators like FormsGenerator)
(getSpec(tags, paths, result), hasWritten)
}
def getSpec(tags: JsValue, paths: JsValue, schemas: TrieMap[String, JsValue]) = {
Json.obj(
"openapi" -> openApiVersion,
"info" -> Json.obj(
"title" -> "Otoroshi Admin API",
"description" -> "Admin API of the Otoroshi reverse proxy",
"version" -> "16.22.0",
"contact" -> Json.obj(
"name" -> "Otoroshi Team",
"email" -> "[email protected]"
),
"license" -> Json.obj(
"name" -> "Apache 2.0",
"url" -> "http://www.apache.org/licenses/LICENSE-2.0.html"
)
),
"externalDocs" -> Json.obj("url" -> "https://www.otoroshi.io", "description" -> "everything about otoroshi"),
"servers" -> Json.arr(
Json.obj(
"url" -> "http://otoroshi-api.oto.tools:8080",
"description" -> "your local otoroshi server"
)
),
"tags" -> tags,
"paths" -> paths,
"components" -> Json.obj(
"schemas" -> JsObject(schemas),
"securitySchemes" -> Json.obj(
"otoroshi_auth" -> Json.obj(
"type" -> "http",
"scheme" -> "basic"
)
)
)
)
}
def readOldSpec(oldSpecPath: String): Unit = {
val config = getConfig()
val f = new File(oldSpecPath)
if (f.exists()) {
val oldSpec = Json.parse(Files.readAllLines(f.toPath).asScala.mkString("\n"))
val descriptions = new UnboundedTrieMap[String, String]()
val examples = new UnboundedTrieMap[String, String]()
oldSpec.select("components").select("schemas").asObject.value.map {
case (key, value) => {
val path = s"old.${key}"
value
.select("properties")
.asOpt[JsObject]
.map(_.value.map {
case (field, component) => {
val example = component.select("example").asOpt[String]
val description = component.select("description").asOpt[String]
example.foreach(v => examples.put(s"$path.$field", v))
description.foreach(v => descriptions.put(s"$path.$field", v))
}
})
}
}
OpenApiGeneratorConfig(
config.filePath,
config.raw.asObject ++ Json.obj(
"old_descriptions" -> JsObject(descriptions.mapValues(JsString.apply)),
"old_examples" -> JsObject(examples.mapValues(JsString.apply))
)
).write()
} else {
logger.debug("No old spec file found !!!!")
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy