From ebeda81a2cc122a6edf6bf6fe65618db1d75a441 Mon Sep 17 00:00:00 2001 From: Jack Lynch Date: Wed, 20 May 2026 11:30:35 +0100 Subject: [PATCH 01/12] FDN-4416: Add OpenAPI 3.x import support to apibuilder Implement OpenAPI 3.x import functionality following the existing Swagger 2.0 pattern. The new openapi module provides: - Schema classification and conversion pipeline - Type mapping from OpenAPI schema types to apibuilder scalar types - Path/operation/parameter conversion - Security scheme handling - Full test coverage with FedEx OpenAPI specs Uses OriginalType.UNDEFINED("open_api_3") for import detection. Co-Authored-By: Claude Sonnet 4.6 --- api/app/lib/OriginalUtil.scala | 9 + build.sbt | 24 +- core/app/builder/OriginalValidator.scala | 2 + .../apibuilder/openapi/Classification.scala | 92 + .../apibuilder/openapi/ConversionReport.scala | 109 + .../io/apibuilder/openapi/Converter.scala | 37 + .../io/apibuilder/openapi/NamingUtils.scala | 66 + .../io/apibuilder/openapi/OpenApiParser.scala | 44 + .../openapi/OpenApiServiceValidator.scala | 15 + .../io/apibuilder/openapi/PathConverter.scala | 282 + .../apibuilder/openapi/SchemaClassifier.scala | 204 + .../apibuilder/openapi/SchemaConverter.scala | 288 + .../apibuilder/openapi/SchemaResolver.scala | 68 + .../openapi/SecurityConverter.scala | 61 + .../src/test/resources/fedex-eei-filing.json | 3583 ++++++ .../src/test/resources/fedex-ship-api.json | 9823 +++++++++++++++++ openapi/src/test/resources/fedex-track.json | 4206 +++++++ .../io/apibuilder/openapi/ConverterSpec.scala | 102 + .../apibuilder/openapi/NamingUtilsSpec.scala | 66 + .../openapi/SchemaConverterSpec.scala | 224 + .../openapi/SecurityConverterSpec.scala | 148 + 21 files changed, 19451 insertions(+), 2 deletions(-) create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/Classification.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/ConversionReport.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/Converter.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/NamingUtils.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/OpenApiParser.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/OpenApiServiceValidator.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/PathConverter.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/SchemaClassifier.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/SchemaConverter.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/SchemaResolver.scala create mode 100644 openapi/src/main/scala/io/apibuilder/openapi/SecurityConverter.scala create mode 100644 openapi/src/test/resources/fedex-eei-filing.json create mode 100644 openapi/src/test/resources/fedex-ship-api.json create mode 100644 openapi/src/test/resources/fedex-track.json create mode 100644 openapi/src/test/scala/io/apibuilder/openapi/ConverterSpec.scala create mode 100644 openapi/src/test/scala/io/apibuilder/openapi/NamingUtilsSpec.scala create mode 100644 openapi/src/test/scala/io/apibuilder/openapi/SchemaConverterSpec.scala create mode 100644 openapi/src/test/scala/io/apibuilder/openapi/SecurityConverterSpec.scala diff --git a/api/app/lib/OriginalUtil.scala b/api/app/lib/OriginalUtil.scala index 6e2f0bc9f..9b84c6d51 100644 --- a/api/app/lib/OriginalUtil.scala +++ b/api/app/lib/OriginalUtil.scala @@ -31,6 +31,8 @@ object OriginalUtil { Some(OriginalType.ApiJson) } else if (o.asOpt[Service].isDefined) { Some(OriginalType.ServiceJson) + } else if ((o \ "openapi").asOpt[JsString].exists(_.value.startsWith("3."))) { + Some(OriginalType.UNDEFINED("open_api_3")) } else if ((o \ "swagger").asOpt[JsString].isDefined) { Some(OriginalType.Swagger) } else { @@ -40,6 +42,8 @@ object OriginalUtil { case _ => { if (trimmed.indexOf("protocol ") >= 0 || trimmed.indexOf("@namespace") >= 0) { Some(OriginalType.AvroIdl) + } else if (isOpenApi3Yaml(trimmed)) { + Some(OriginalType.UNDEFINED("open_api_3")) } else if (trimmed.contains("swagger:")) { Some(OriginalType.Swagger) } else { @@ -49,6 +53,11 @@ object OriginalUtil { } } + private val OpenApi3YamlPattern = """(?m)^openapi:\s*["']?3\.""".r + + private def isOpenApi3Yaml(data: String): Boolean = + OpenApi3YamlPattern.findFirstIn(data).isDefined + private def guessApiOrServiceJson(o: JsObject): Option[OriginalType] = { // service.json has these defined as array; api.json as maps val fields = Seq("enums", "interfaces", "unions", "models") diff --git a/build.sbt b/build.sbt index 7e838b9ef..4f7a35943 100644 --- a/build.sbt +++ b/build.sbt @@ -61,12 +61,32 @@ lazy val swagger = project ) ) +lazy val openapi = project + .in(file("openapi")) + .dependsOn(lib % "compile->compile;test->test") + .aggregate(lib) + .settings( + scalacOptions ++= allScalacOptions, + resolvers += "jitpack" at "https://jitpack.io", + libraryDependencies ++= Seq( + "com.softwaremill.sttp.apispec" %% "openapi-model" % "0.11.10", + "com.softwaremill.sttp.apispec" %% "openapi-circe" % "0.11.10", + "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.10", + "com.github.apicollective" % "apibuilder-validation" % "0.5.8", + "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.1" % Test, + ), + Test / javaOptions ++= Seq( + "--add-exports=java.base/sun.security.x509=ALL-UNNAMED", + "--add-opens=java.base/sun.security.ssl=ALL-UNNAMED" + ) + ) + val circeVersion = "0.14.9" lazy val core = project .in(file("core")) .enablePlugins(PlayScala) - .dependsOn(generated, lib, avro, swagger) - .aggregate(generated, lib, avro, swagger) + .dependsOn(generated, lib, avro, swagger, openapi) + .aggregate(generated, lib, avro, swagger, openapi) .settings(commonSettings*) .settings( libraryDependencies ++= Seq( diff --git a/core/app/builder/OriginalValidator.scala b/core/app/builder/OriginalValidator.scala index 93fbb9eaf..8f3187b6c 100644 --- a/core/app/builder/OriginalValidator.scala +++ b/core/app/builder/OriginalValidator.scala @@ -5,6 +5,7 @@ import cats.data.ValidatedNec import core.{ServiceFetcher, VersionMigration} import io.apibuilder.api.v0.models.OriginalType import io.apibuilder.avro.AvroIdlServiceValidator +import io.apibuilder.openapi.OpenApiServiceValidator import io.apibuilder.spec.v0.models.Service import io.apibuilder.swagger.SwaggerServiceValidator import lib.{ServiceConfiguration, ServiceValidator} @@ -23,6 +24,7 @@ object OriginalValidator { case OriginalType.ServiceJson => ServiceJsonServiceValidator case OriginalType.Swagger => SwaggerServiceValidator(config) case OriginalType.UNDEFINED("swagger_json") => SwaggerServiceValidator(config) + case OriginalType.UNDEFINED("open_api_3") => OpenApiServiceValidator(config) case OriginalType.UNDEFINED(other) => sys.error(s"Invalid original type[$other]") } WithServiceSpecValidator(validator) diff --git a/openapi/src/main/scala/io/apibuilder/openapi/Classification.scala b/openapi/src/main/scala/io/apibuilder/openapi/Classification.scala new file mode 100644 index 000000000..76c19a79a --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/Classification.scala @@ -0,0 +1,92 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.models.Header +import io.apibuilder.validation.ScalarType +import sttp.apispec.Schema +import sttp.apispec.openapi.OpenAPI + +import scala.collection.immutable.ListMap + +sealed trait SchemaKind +object SchemaKind { + case object Object extends SchemaKind + case object StringEnum extends SchemaKind + case object Array extends SchemaKind + case object Union extends SchemaKind + case object Alias extends SchemaKind + case object Skip extends SchemaKind +} + +sealed trait FieldKind +object FieldKind { + case class Ref(typeName: String) extends FieldKind + case class AllOfRef(typeName: String) extends FieldKind + case object Number extends FieldKind + case class ArrayRef(typeName: String) extends FieldKind + case class ArrayEnum(itemsSchema: Schema) extends FieldKind + case class InlineEnum(enumSchema: Schema) extends FieldKind + case class ArraySimple(scalarType: ScalarType) extends FieldKind + case class MapType(valueType: String) extends FieldKind + case class Primitive(scalarType: ScalarType) extends FieldKind + case object DefaultedString extends FieldKind +} + +case class ClassifiedField( + schemaName: String, + fieldName: String, + kind: Option[FieldKind], + description: Option[String], + required: Boolean, + minimum: Option[Long], + maximum: Option[Long], + annotations: SchemaClassifier.FieldAnnotations, +) + +case class ClassifiedSchema( + name: String, + kind: SchemaKind, + schema: Option[Schema], + fields: Seq[ClassifiedField], +) + +case class SchemaClassification( + schemas: Seq[ClassifiedSchema], +) + +case class Classification( + classification: SchemaClassification, + modelReferences: Map[String, String], + pathResult: PathConversionResult, + securityHeaders: Seq[Header], + unsupportedFeatures: Seq[String], + openApi: OpenAPI, +) + +object Classification { + + def fromOpenApi(openApi: OpenAPI, namingConfig: NamingConfig, filterHeaders: Set[String]): Classification = { + val schemas = openApi.components + .map(_.schemas) + .getOrElse(ListMap.empty) + + val modelReferences = SchemaResolver.buildModelReferences(schemas) + val classification = SchemaClassifier.classify(schemas) + + val requestBodies = openApi.components + .map(_.requestBodies) + .getOrElse(ListMap.empty) + + val pathConverter = new PathConverter(modelReferences, namingConfig, filterHeaders, requestBodies) + val pathResult = pathConverter.convertPaths(openApi.paths) + + val securityHeaders = SecurityConverter.convertSecuritySchemes( + openApi.components + .map(_.securitySchemes) + .getOrElse(ListMap.empty), + ) + + val unsupportedFeatures = ConversionReport.detectUnsupportedFeatures(openApi) + + Classification(classification, modelReferences, pathResult, securityHeaders, unsupportedFeatures, openApi) + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/ConversionReport.scala b/openapi/src/main/scala/io/apibuilder/openapi/ConversionReport.scala new file mode 100644 index 000000000..d519c50cc --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/ConversionReport.scala @@ -0,0 +1,109 @@ +package io.apibuilder.openapi + +import sttp.apispec.openapi.OpenAPI + +case class SchemaReport(name: String, kind: SchemaKind) +case class FieldReport(schemaName: String, fieldName: String, kind: Option[FieldKind], ignoredFormat: Option[String] = None) +case class PathReport(path: String, methods: Seq[String], unsupported: Seq[String]) + +case class ConversionReport( + schemas: Seq[SchemaReport], + fields: Seq[FieldReport], + paths: Seq[PathReport], + unsupportedFeatures: Seq[String], +) { + + def models: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.Object) => n } + def enums: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.StringEnum) => n } + def arrays: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.Array) => n } + def unions: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.Union) => n } + def aliases: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.Alias) => n } + def skipped: Seq[String] = schemas.collect { case SchemaReport(n, SchemaKind.Skip) => n } + + def unmappedFields: Seq[String] = fields.collect { case FieldReport(schema, field, None, _) => s"$schema.$field" } + + def defaultedFields: Seq[String] = fields.collect { + case FieldReport(schema, field, Some(FieldKind.DefaultedString), _) => s"$schema.$field" + } + + def ignoredFormats: Seq[String] = fields.collect { case FieldReport(schema, field, _, Some(fmt)) => + s"$schema.$field (format: $fmt)" + } + + def summary: String = { + val lines = Seq.newBuilder[String] + lines += s"=== Conversion Report ===" + lines += s"Schemas: ${schemas.size} total" + lines += s" Models: ${models.size}" + lines += s" Enums: ${enums.size}" + lines += s" Arrays: ${arrays.size}" + lines += s" Unions: ${unions.size}" + lines += s" Aliases: ${aliases.size} (resolved, not emitted)" + lines += s" Skipped: ${skipped.size}" + if (unmappedFields.nonEmpty) { + lines += s"Unmapped fields: ${unmappedFields.size}" + unmappedFields.foreach(f => lines += s" - $f") + } + if (defaultedFields.nonEmpty) { + lines += s"Defaulted to string (no type in spec): ${defaultedFields.size}" + defaultedFields.foreach(f => lines += s" - $f") + } + if (ignoredFormats.nonEmpty) { + lines += s"Ignored formats: ${ignoredFormats.size}" + ignoredFormats.foreach(f => lines += s" - $f") + } + lines += s"Paths: ${paths.size}" + val pathIssues = paths.flatMap(_.unsupported) + if (pathIssues.nonEmpty) { + lines += s"Path issues: ${pathIssues.size}" + pathIssues.foreach(i => lines += s" - $i") + } + if (unsupportedFeatures.nonEmpty) { + lines += s"Unsupported features: ${unsupportedFeatures.size}" + unsupportedFeatures.foreach(f => lines += s" - $f") + } + lines.result().mkString("\n") + } +} + +object ConversionReport { + + def fromClassification(c: Classification): ConversionReport = { + val schemaReports = c.classification.schemas.map(cs => SchemaReport(cs.name, cs.kind)) + val fieldReports = c.classification.schemas.flatMap(_.fields.map { cf => + FieldReport(cf.schemaName, cf.fieldName, cf.kind, cf.annotations.ignoredFormat) + }) + ConversionReport(schemaReports, fieldReports, c.pathResult.pathReports, c.unsupportedFeatures) + } + + def detectUnsupportedFeatures(openApi: OpenAPI): Seq[String] = { + val features = Seq.newBuilder[String] + + openApi.components.foreach { c => + c.securitySchemes.foreach { + case (name, Right(scheme)) if !SecurityConverter.isConvertible(scheme) => + features += s"securityScheme '$name': type '${scheme.`type`}' (not converted)" + case (name, Left(_)) => + features += s"securityScheme '$name': reference (not resolved)" + case _ => () + } + if (c.requestBodies.nonEmpty) + features += s"requestBodies: ${c.requestBodies.size} defined (not converted as standalone)" + if (c.headers.nonEmpty) + features += s"headers: ${c.headers.size} defined (not converted)" + if (c.links.nonEmpty) + features += s"links: ${c.links.size} defined (not converted)" + if (c.callbacks.nonEmpty) + features += s"callbacks: ${c.callbacks.size} defined (not converted)" + } + + if (openApi.security.nonEmpty) + features += s"global security: ${openApi.security.size} requirements (not converted)" + if (openApi.tags.nonEmpty) + features += s"tags: ${openApi.tags.size} defined (not converted)" + if (openApi.extensions.nonEmpty) + features += s"extensions: ${openApi.extensions.size} vendor extensions (not converted)" + + features.result() + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/Converter.scala b/openapi/src/main/scala/io/apibuilder/openapi/Converter.scala new file mode 100644 index 000000000..48a6c4e84 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/Converter.scala @@ -0,0 +1,37 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.models._ +import lib.{ServiceConfiguration, UrlKey} + +object Converter { + + def convert(openApi: sttp.apispec.openapi.OpenAPI, config: ServiceConfiguration): Service = { + val namingConfig = NamingConfig() + val c = Classification.fromOpenApi(openApi, namingConfig, filterHeaders = Set.empty) + val schemaConverter = new SchemaConverter(c.modelReferences, namingConfig) + val schemaResult = schemaConverter.convert(c.classification) + + val apiName = UrlKey.generate(openApi.info.title) + + Service( + apidoc = None, + name = openApi.info.title, + organization = Organization(key = config.orgKey), + application = Application(key = apiName), + namespace = config.applicationNamespace(apiName), + version = config.version, + baseUrl = openApi.servers.headOption.map(_.url), + description = openApi.info.description, + info = Info(license = None, contact = None), + headers = c.securityHeaders, + imports = Seq.empty, + enums = schemaResult.enums, + interfaces = Seq.empty, + unions = schemaResult.unions, + models = schemaResult.models, + resources = c.pathResult.resources, + attributes = Seq.empty, + annotations = Seq.empty, + ) + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/NamingUtils.scala b/openapi/src/main/scala/io/apibuilder/openapi/NamingUtils.scala new file mode 100644 index 000000000..3a8ac6814 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/NamingUtils.scala @@ -0,0 +1,66 @@ +package io.apibuilder.openapi + +import io.apibuilder.validation.ScalarType + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +case class NamingConfig( + uniqueNames: Boolean = false, + suffixLength: Int = 4, +) + +object NamingUtils { + + val ApibuilderPrimitiveTypes: Set[String] = ScalarType.all.map(_.name).toSet + + def toSnakeCase(str: String): String = + str.trim + .replaceAll("[\\s\\-.]+", "_") + .replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") + .toLowerCase + + def uniqueSnakeCase(str: String, config: NamingConfig): String = { + if (ApibuilderPrimitiveTypes.contains(str)) str + else if (isArray(str)) arrayType(uniqueSnakeCase(extractFromArray(str), config)) + else if (isMap(str)) mapType(uniqueSnakeCase(extractFromMap(str), config)) + else if (config.uniqueNames) { + val snake = toSnakeCase(str) + s"${snake}_${hashString(snake).take(config.suffixLength)}" + } else { + toSnakeCase(str) + } + } + + def sanitizeEnumName(s: String): String = + s.trim + .replaceAll("\"", "") + .replaceAll("\\s+", "_") + + def hashString(input: String): String = { + val letters = 'a' to 'z' + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(input.getBytes(StandardCharsets.UTF_8)) + hashBytes.map { b => letters((b & 0xff) % letters.length) }.mkString + } + + def arrayType(typeName: String): String = s"[$typeName]" + def mapType(typeName: String): String = s"map[$typeName]" + + private def isArray(str: String): Boolean = + str.startsWith("[") && str.endsWith("]") + + private def extractFromArray(str: String): String = { + assert(isArray(str), s"$str is not an array (missing '[]')") + str.drop(1).dropRight(1) + } + + private def isMap(str: String): Boolean = + str.startsWith("map[") && str.endsWith("]") + + private def extractFromMap(str: String): String = { + assert(isMap(str), s"$str is not a map (missing 'map[]')") + str.drop(4).dropRight(1) + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/OpenApiParser.scala b/openapi/src/main/scala/io/apibuilder/openapi/OpenApiParser.scala new file mode 100644 index 000000000..3457525cc --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/OpenApiParser.scala @@ -0,0 +1,44 @@ +package io.apibuilder.openapi + +import io.circe.{parser => circeParser} +import io.circe.yaml.{parser => yamlParser} +import sttp.apispec.openapi.OpenAPI +import sttp.apispec.openapi.circe._ + +import scala.io.Source +import scala.util.Using + +object OpenApiParser { + + def fromString(input: String): Either[String, OpenAPI] = { + val trimmed = input.trim + if (looksLikeYaml(trimmed)) fromYaml(trimmed) else fromJson(trimmed) + } + + def fromResource(resourcePath: String): Either[String, OpenAPI] = { + val stream = Option(getClass.getClassLoader.getResourceAsStream(resourcePath)) + .toRight(s"Resource not found: $resourcePath") + stream.flatMap { is => + Using(Source.fromInputStream(is, "UTF-8"))(_.mkString).toEither + .left.map(_.getMessage) + .flatMap(fromString) + } + } + + def fromJson(jsonString: String): Either[String, OpenAPI] = + circeParser + .parse(jsonString) + .flatMap(_.as[OpenAPI]) + .left + .map(_.getMessage) + + def fromYaml(yamlString: String): Either[String, OpenAPI] = + yamlParser + .parse(yamlString) + .flatMap(_.as[OpenAPI]) + .left + .map(_.getMessage) + + private def looksLikeYaml(input: String): Boolean = + !input.startsWith("{") && !input.startsWith("[") +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/OpenApiServiceValidator.scala b/openapi/src/main/scala/io/apibuilder/openapi/OpenApiServiceValidator.scala new file mode 100644 index 000000000..663c3eaf7 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/OpenApiServiceValidator.scala @@ -0,0 +1,15 @@ +package io.apibuilder.openapi + +import cats.data.ValidatedNec +import cats.implicits._ +import io.apibuilder.spec.v0.models.Service +import lib.{ServiceConfiguration, ServiceValidator} + +case class OpenApiServiceValidator(config: ServiceConfiguration) extends ServiceValidator[Service] { + + override def validate(rawInput: String): ValidatedNec[String, Service] = + OpenApiParser.fromString(rawInput) match { + case Left(err) => err.invalidNec + case Right(openApi) => Converter.convert(openApi, config).validNec + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/PathConverter.scala b/openapi/src/main/scala/io/apibuilder/openapi/PathConverter.scala new file mode 100644 index 000000000..6c4c30a9c --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/PathConverter.scala @@ -0,0 +1,282 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.{models => ab} +import io.apibuilder.validation.ScalarType +import sttp.apispec.{ExampleSingleValue, Schema, SchemaLike} +import sttp.apispec.openapi._ + +import scala.collection.immutable.ListMap + +case class PathConversionResult( + resources: Seq[ab.Resource], + pathReports: Seq[PathReport], +) + +class PathConverter( + modelReferences: Map[String, String], + config: NamingConfig, + filterHeaders: Set[String] = Set.empty, + requestBodies: ListMap[String, Either[Reference, RequestBody]] = ListMap.empty, +) { + + private val filterHeadersLower: Set[String] = filterHeaders.map(_.toLowerCase) + + import NamingUtils._ + import SchemaResolver._ + + private val HttpMethods: Seq[(String, PathItem => Option[Operation])] = Seq( + "GET" -> (_.get), + "PUT" -> (_.put), + "POST" -> (_.post), + "DELETE" -> (_.delete), + "PATCH" -> (_.patch), + "HEAD" -> (_.head), + "OPTIONS" -> (_.options), + "TRACE" -> (_.trace), + ) + + def convertPaths(paths: Paths): PathConversionResult = { + val allResources = Seq.newBuilder[ab.Resource] + val pathReports = Seq.newBuilder[PathReport] + + paths.pathItems.toSeq.foreach { case (path, pathItem) => + val (resourceOpt, report) = convertPathItem(path, pathItem) + resourceOpt.foreach(allResources += _) + pathReports += report + } + + val merged = allResources + .result() + .filterNot(r => NamingUtils.ApibuilderPrimitiveTypes.contains(r.`type`)) + .groupBy(_.`type`) + .toSeq + .sortBy(_._1) + .map { case (_, group) => + val first = group.head + first.copy(operations = group.flatMap(_.operations)) + } + + PathConversionResult( + resources = merged, + pathReports = pathReports.result(), + ) + } + + private def convertPathItem(path: String, pathItem: PathItem): (Option[ab.Resource], PathReport) = { + val issues = Seq.newBuilder[String] + val methods = HttpMethods.flatMap { case (method, extract) => + extract(pathItem).map(method -> _) + } + + pathItem.parameters.foreach { + case Left(ref) => + issues += s"$path: parameter reference '${ref.$ref}' (not resolved)" + case Right(p) if p.in == ParameterIn.Cookie => + issues += s"$path: cookie parameter '${p.name}' (not supported)" + case _ => () + } + + val pathLevelParams = extractParams(pathItem.parameters) + + val operations = methods.flatMap { case (method, op) => + op.parameters.foreach { + case Left(ref) => + issues += s"$method $path: parameter reference '${ref.$ref}' (not resolved)" + case Right(p) if p.in == ParameterIn.Cookie => + issues += s"$method $path: cookie parameter '${p.name}' (not supported)" + case _ => () + } + + op.requestBody.foreach { + case Right(rb) => + rb.content.keys.filterNot(_ == "application/json").foreach { ct => + issues += s"$method $path: non-JSON request body content type '$ct'" + } + case _ => () + } + + op.responses.responses.foreach { case (key, respOrRef) => + respOrRef.foreach { resp => + resp.content.keys.filterNot(_ == "application/json").foreach { ct => + issues += s"$method $path response $key: non-JSON content type '$ct'" + } + } + } + + if (op.security.nonEmpty) issues += s"$method $path: security requirements (not converted)" + if (op.callbacks.nonEmpty) issues += s"$method $path: callbacks (not converted)" + + val opParams = extractParams(op.parameters) + val merged = mergeParameters(pathLevelParams, opParams) + Some(convertOperation(path, method, op, merged)) + } + + val report = PathReport( + path = path, + methods = methods.map(_._1), + unsupported = issues.result(), + ) + + val nonUnitResponses = operations.view + .flatMap(_.responses) + .filter(_.`type` != ScalarType.UnitType.name) + + val primaryResponse = nonUnitResponses + .collectFirst { case r if is2xx(r.code) => r } + .orElse(nonUnitResponses.headOption) + + val resource = primaryResponse.map { firstResponse => + ab.Resource( + `type` = firstResponse.`type`, + plural = firstResponse.`type` + "s", + path = Some(""), + description = None, + deprecation = None, + operations = operations, + attributes = Seq.empty, + ) + } + + (resource, report) + } + + private def convertOperation( + path: String, + httpMethod: String, + op: Operation, + mergedParams: Seq[Parameter], + ): ab.Operation = + ab.Operation( + method = ab.Method + .fromString(httpMethod) + .getOrElse(throw new IllegalArgumentException(s"Unknown HTTP method: $httpMethod")), + path = toApibuilderPath(path), + description = op.description, + deprecation = None, + body = op.requestBody.flatMap(extractBody), + parameters = mergedParams.flatMap(convertParameter), + responses = op.responses.responses.toSeq.map(convertResponse), + attributes = Seq.empty, + ) + + private def convertResponse(entry: (ResponsesKey, Either[Reference, Response])): ab.Response = { + val (key, responseOrRef) = entry + val code: ab.ResponseCode = key match { + case ResponsesCodeKey(c) => ab.ResponseCodeInt(c) + case ResponsesDefaultKey => ab.ResponseCodeOption.Default + case ResponsesRangeKey(r) => ab.ResponseCodeInt(r * 100) + } + val (desc, typeName) = responseOrRef match { + case Right(r) => + ( + Option(r.description).filter(_.nonEmpty), + jsonSchemaRef(r.content).map(resolve).getOrElse(ScalarType.UnitType.name), + ) + case Left(ref) => + val t = + if (ref.$ref.startsWith("#/components/schemas/")) resolve(refName(ref.$ref)) + else ScalarType.UnitType.name + (None, t) + } + ab.Response( + code = code, + `type` = sn(typeName), + headers = None, + description = desc, + deprecation = None, + attributes = None, + ) + } + + private def extractBody(bodyOrRef: Either[Reference, RequestBody]): Option[ab.Body] = + bodyOrRef match { + case Right(rb) => + jsonSchemaRef(rb.content).map(t => ab.Body(`type` = sn(resolve(t)))) + case Left(ref) => + resolveRequestBodyRef(ref.$ref).map(t => ab.Body(`type` = sn(resolve(t)))) + } + + private def resolveRequestBodyRef(ref: String, seen: Set[String] = Set.empty): Option[String] = { + if (seen.contains(ref)) return None + val prefix = "#/components/requestBodies/" + if (ref.startsWith(prefix)) { + val name = ref.stripPrefix(prefix) + requestBodies.get(name).flatMap { + case Right(rb) => jsonSchemaRef(rb.content) + case Left(nestedRef) => resolveRequestBodyRef(nestedRef.$ref, seen + ref) + } + } else if (ref.startsWith("#/components/schemas/")) { + Some(refName(ref)) + } else { + None + } + } + + private def jsonSchemaRef(content: ListMap[String, MediaType]): Option[String] = + content.get("application/json").flatMap(_.schema.flatMap(schemaRef)) + + private def schemaRef(sl: SchemaLike): Option[String] = sl match { + case s: Schema if s.$ref.isDefined => Some(refName(s.$ref.get)) + case s: Schema if s.oneOf.nonEmpty => + s.oneOf.collectFirst { case r: Schema if r.$ref.isDefined => refName(r.$ref.get) } + case _ => None + } + + private def extractParams(params: List[Either[Reference, Parameter]]): Seq[Parameter] = + params.collect { case Right(p) => p } + + private def mergeParameters(pathLevel: Seq[Parameter], opLevel: Seq[Parameter]): Seq[Parameter] = { + val opKeys = opLevel.map(p => (p.name, p.in)).toSet + val inherited = pathLevel.filterNot(p => opKeys.contains((p.name, p.in))) + inherited ++ opLevel + } + + private[openapi] def convertParameter(p: Parameter): Option[ab.Parameter] = { + if (p.in == ParameterIn.Header && filterHeadersLower.contains(p.name.toLowerCase)) return None + mapLocation(p.in).map { location => + val schemaOpt = p.schema.collect { case s: Schema => s } + + val typeName = schemaOpt + .map { s => + if (s.$ref.isDefined) resolve(refName(s.$ref.get)) + else SchemaConverter.simpleType(s).map(_.name).getOrElse(ScalarType.StringType.name) + } + .getOrElse(ScalarType.StringType.name) + + val (min, max) = schemaOpt.map(SchemaClassifier.extractBounds).getOrElse((None, None)) + val isRequired = p.required.getOrElse(p.in == ParameterIn.Path) + val example = p.example.collect { case ExampleSingleValue(v) => v.toString } + + ab.Parameter( + name = p.name, + `type` = sn(typeName), + location = location, + description = p.description, + deprecation = None, + required = isRequired, + default = None, + minimum = min, + maximum = max, + example = example, + ) + } + } + + private def mapLocation(in: ParameterIn): Option[ab.ParameterLocation] = in match { + case ParameterIn.Query => Some(ab.ParameterLocation.Query) + case ParameterIn.Header => Some(ab.ParameterLocation.Header) + case ParameterIn.Path => Some(ab.ParameterLocation.Path) + case ParameterIn.Cookie => None + } + + private def is2xx(code: ab.ResponseCode): Boolean = code match { + case ab.ResponseCodeInt(c) => c >= 200 && c < 300 + case _ => false + } + + private def toApibuilderPath(path: String): String = + path.replaceAll("\\{([^}]+)\\}", ":$1") + + private def sn(str: String): String = uniqueSnakeCase(str, config) + private def resolve(name: String): String = resolveReference(name, modelReferences) +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/SchemaClassifier.scala b/openapi/src/main/scala/io/apibuilder/openapi/SchemaClassifier.scala new file mode 100644 index 000000000..4cfa4f9b3 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/SchemaClassifier.scala @@ -0,0 +1,204 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.models.Deprecation +import io.apibuilder.validation.ScalarType +import sttp.apispec.{AnySchema, ExampleSingleValue, Schema, SchemaLike, SchemaType} + +import scala.collection.immutable.ListMap + +object SchemaClassifier { + + def classify(schemas: ListMap[String, SchemaLike]): SchemaClassification = { + val classified = schemas.toSeq.map { + case (name, s: Schema) => + val kind = classifySchema(s) + val fields = kind match { + case SchemaKind.Object => classifyFields(name, s) + case _ => Seq.empty + } + ClassifiedSchema(name, kind, Some(s), fields) + case (name, _) => + ClassifiedSchema(name, SchemaKind.Skip, None, Seq.empty) + } + SchemaClassification(classified) + } + + private[openapi] def classifySchema(s: Schema): SchemaKind = { + val members = unionMembers(s) + if (SchemaResolver.detectMapType(s).isDefined) SchemaKind.Alias + else if (s.properties.nonEmpty || hasType(s, SchemaType.Object)) SchemaKind.Object + else if (hasType(s, SchemaType.String) && s.`enum`.isDefined) SchemaKind.StringEnum + else if (hasType(s, SchemaType.Array)) SchemaKind.Array + else if (members.nonEmpty && s.properties.isEmpty && collectRefs(members).nonEmpty) SchemaKind.Union + else if ( + s.$ref.isDefined || + (s.allOf.nonEmpty && s.properties.isEmpty) || + (hasType(s, SchemaType.String) && s.`enum`.isEmpty) + ) SchemaKind.Alias + else SchemaKind.Skip + } + + private[openapi] def classifyField(s: Schema): Option[FieldKind] = { + import SchemaResolver.refName + + lazy val fromRef: Option[FieldKind] = + s.$ref.map(ref => FieldKind.Ref(refName(ref))) + + lazy val fromAllOf: Option[FieldKind] = Option + .when(s.allOf.nonEmpty) { + s.allOf + .collectFirst { case r: Schema if r.$ref.isDefined => refName(r.$ref.get) } + .map(FieldKind.AllOfRef(_)) + } + .flatten + + lazy val fromNumber: Option[FieldKind] = + Option.when(hasType(s, SchemaType.Number))(FieldKind.Number) + + lazy val fromOneOfAnyOf: Option[FieldKind] = { + val members = unionMembers(s) + Option + .when(members.nonEmpty && s.properties.isEmpty) { + collectRefs(members).headOption.map(FieldKind.Ref(_)) + } + .flatten + } + + lazy val fromArrayRef: Option[FieldKind] = + Option + .when(hasType(s, SchemaType.Array)) { + s.items.collectFirst { + case items: Schema if items.$ref.isDefined => Some(FieldKind.ArrayRef(refName(items.$ref.get))) + case items: Schema if unionMembers(items).nonEmpty => + collectRefs(unionMembers(items)).headOption.map(FieldKind.ArrayRef(_)) + }.flatten + } + .flatten + + lazy val fromArrayEnum: Option[FieldKind] = + Option + .when(hasType(s, SchemaType.Array)) { + s.items.collectFirst { + case items: Schema if hasType(items, SchemaType.String) && items.`enum`.isDefined => + FieldKind.ArrayEnum(items) + } + } + .flatten + + lazy val fromEnum: Option[FieldKind] = + Option.when(hasType(s, SchemaType.String) && s.`enum`.isDefined)(FieldKind.InlineEnum(s)) + + lazy val fromMap: Option[FieldKind] = + s.additionalProperties.map(ap => FieldKind.MapType(SchemaResolver.mapValueType(ap))) + + lazy val fromArraySimple: Option[FieldKind] = + Option + .when(hasType(s, SchemaType.Array)) { + s.items.collectFirst { + case items: Schema if items.`enum`.isEmpty => + SchemaConverter.simpleType(items).map(FieldKind.ArraySimple.apply) + }.flatten + } + .flatten + + lazy val fromSimple: Option[FieldKind] = + Option.when(s.`enum`.isEmpty)(SchemaConverter.simpleType(s).map(FieldKind.Primitive.apply)).flatten + + lazy val fromDefault: Option[FieldKind] = + Option.when(s.description.isDefined || s.properties.nonEmpty)(FieldKind.DefaultedString) + + fromRef orElse fromAllOf orElse fromOneOfAnyOf orElse fromNumber orElse fromArrayRef orElse + fromArrayEnum orElse fromEnum orElse fromMap orElse fromArraySimple orElse fromSimple orElse fromDefault + } + + case class FieldAnnotations( + default: Option[String], + deprecation: Option[Deprecation], + example: Option[String], + ignoredFormat: Option[String], + ) + + object FieldAnnotations { + val empty: FieldAnnotations = FieldAnnotations(None, None, None, None) + + def fromSchema(s: Schema): FieldAnnotations = FieldAnnotations( + default = s.default.collect { case ExampleSingleValue(v) => v.toString }, + deprecation = s.deprecated.collect { case true => Deprecation() }, + example = s.examples + .flatMap(_.collectFirst { case ExampleSingleValue(v) => v.toString }), + ignoredFormat = SchemaConverter.ignoredFormat(s), + ) + } + + def extractBounds(s: Schema): (Option[Long], Option[Long]) = { + val min = s.minimum + .map(_.toLong) + .orElse(s.minLength.map(_.toLong)) + .orElse(s.minItems.map(_.toLong)) + val max = s.maximum + .map(_.toLong) + .orElse(s.maxLength.map(_.toLong)) + .orElse(s.maxItems.map(_.toLong)) + (min, max) + } + + private[openapi] def collectRefs(schemas: List[SchemaLike]): Seq[String] = + schemas.collect { case s: Schema if s.$ref.isDefined => SchemaResolver.refName(s.$ref.get) } + + private[openapi] def unionMembers(s: Schema): List[SchemaLike] = + s.oneOf ++ s.anyOf + + private def hasType(s: Schema, st: SchemaType): Boolean = + SchemaResolver.hasType(s, st) + + private def classifyFields(schemaName: String, schema: Schema): Seq[ClassifiedField] = { + val required = schema.required + schema.properties.toSeq.map { case (fieldName, sl) => + classifyFieldEntry(schemaName, fieldName, sl, required) + } + } + + private def classifyFieldEntry( + schemaName: String, + fieldName: String, + sl: SchemaLike, + required: List[String], + ): ClassifiedField = sl match { + case s: Schema => + val kind = classifyField(s) + val (min, max) = extractBounds(s) + val ann = FieldAnnotations.fromSchema(s) + ClassifiedField( + schemaName = schemaName, + fieldName = fieldName, + kind = kind, + description = s.description, + required = required.contains(fieldName), + minimum = min, + maximum = max, + annotations = ann, + ) + case AnySchema.Anything => + ClassifiedField( + schemaName = schemaName, + fieldName = fieldName, + kind = Some(FieldKind.Primitive(ScalarType.JsonType)), + description = None, + required = required.contains(fieldName), + minimum = None, + maximum = None, + annotations = FieldAnnotations.empty, + ) + case AnySchema.Nothing => + ClassifiedField( + schemaName = schemaName, + fieldName = fieldName, + kind = None, + description = None, + required = required.contains(fieldName), + minimum = None, + maximum = None, + annotations = FieldAnnotations.empty, + ) + } +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/SchemaConverter.scala b/openapi/src/main/scala/io/apibuilder/openapi/SchemaConverter.scala new file mode 100644 index 000000000..1092a9800 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/SchemaConverter.scala @@ -0,0 +1,288 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.models.{Attribute, Enum, EnumValue, Field, Model, Union, UnionType} +import io.apibuilder.validation.ScalarType +import play.api.libs.json.Json +import sttp.apispec.{ExampleSingleValue, Schema, SchemaType} + +import scala.collection.immutable.ListMap + +class SchemaConverter( + modelReferences: Map[String, String], + config: NamingConfig, +) { + + import NamingUtils._ + import SchemaClassifier.{collectRefs, unionMembers} + import SchemaConverter._ + import SchemaResolver._ + + def convert(classification: SchemaClassification): SchemaConversionResult = { + val models = Seq.newBuilder[Model] + val enums = Seq.newBuilder[Enum] + val unions = Seq.newBuilder[Union] + + val skippedNames = classification.schemas.collect { case cs if cs.kind == SchemaKind.Skip => cs.name }.toSet + + classification.schemas.foreach { cs => + cs.kind match { + case SchemaKind.Object => + cs.schema.foreach { s => + val (model, objectEnums) = convertObjectSchema(cs.name, s, cs.fields) + model.foreach(models += _) + enums ++= objectEnums + } + case SchemaKind.StringEnum => + cs.schema.foreach(s => enums += convertTopLevelEnum(cs.name, s)) + case SchemaKind.Array => + cs.schema.foreach(s => models += convertArraySchema(cs.name, s)) + case SchemaKind.Union => + cs.schema.foreach(s => unions += convertUnionSchema(cs.name, s, skippedNames)) + case SchemaKind.Alias | SchemaKind.Skip => () + } + } + + SchemaConversionResult( + models = models.result(), + enums = enums.result(), + unions = unions.result(), + ) + } + + private def convertObjectSchema( + name: String, + schema: Schema, + fields: Seq[ClassifiedField], + ): (Option[Model], Seq[Enum]) = { + val converted = fields.flatMap(convertClassifiedField) + val model = Model( + name = sn(name), + plural = sn(name) + "s", + description = schema.description, + deprecation = None, + fields = converted.map(_._1), + attributes = Seq.empty, + interfaces = Seq.empty, + ) + (Some(model), converted.flatMap(_._2)) + } + + private def convertTopLevelEnum(name: String, schema: Schema): Enum = + Enum( + name = sn(name), + plural = sn(name) + "s", + description = schema.description, + deprecation = None, + values = enumStrings(schema).map(v => EnumValue(name = v)), + attributes = Seq.empty, + ) + + private def convertArraySchema(name: String, schema: Schema): Model = { + val itemType = schema.items + .map { + case s: Schema if s.$ref.isDefined => refName(s.$ref.get) + case s: Schema => simpleType(s).map(_.name).getOrElse(ScalarType.StringType.name) + case _ => ScalarType.StringType.name + } + .getOrElse(ScalarType.StringType.name) + + Model( + name = sn(name), + plural = sn(name) + "s", + description = schema.description, + deprecation = None, + fields = Seq( + Field( + name = sn(name) + "_items", + `type` = sn(arrayType(itemType)), + description = schema.description, + deprecation = None, + default = None, + required = true, + minimum = None, + maximum = None, + example = None, + attributes = Seq.empty, + annotations = Seq.empty, + ), + ), + attributes = Seq.empty, + interfaces = Seq.empty, + ) + } + + private def convertClassifiedField(cf: ClassifiedField): Option[(Field, Seq[Enum])] = { + cf.kind.map { + case FieldKind.Ref(target) => + (makeField(cf, resolve(target)), Nil) + + case FieldKind.AllOfRef(target) => + (makeField(cf, resolve(target)), Nil) + + case FieldKind.Number => + (makeField(cf, ScalarType.DoubleType.name), Nil) + + case FieldKind.ArrayRef(target) => + (makeField(cf, arrayType(resolve(target))), Nil) + + case FieldKind.ArrayEnum(itemsSchema) => + val (field, enumDef) = inlineEnum(cf, itemsSchema) + (field, Seq(enumDef)) + + case FieldKind.InlineEnum(enumSchema) => + val (field, enumDef) = inlineEnum(cf, enumSchema) + (field, Seq(enumDef)) + + case FieldKind.MapType(valueType) => + (makeField(cf, NamingUtils.mapType(resolve(valueType))), Nil) + + case FieldKind.ArraySimple(scalarType) => + (makeField(cf, arrayType(scalarType.name)), Nil) + + case FieldKind.Primitive(scalarType) => + (makeField(cf, scalarType.name), Nil) + + case FieldKind.DefaultedString => + (makeField(cf, ScalarType.StringType.name), Nil) + } + } + + private def inlineEnum(cf: ClassifiedField, schema: Schema): (Field, Enum) = { + val enumName = cf.schemaName + "_" + cf.fieldName + "_enum" + val enumDef = Enum( + name = sn(enumName), + plural = sn(enumName) + "s", + description = None, + deprecation = None, + values = enumStrings(schema).map(v => + EnumValue( + name = NamingUtils.sanitizeEnumName(v), + description = None, + deprecation = None, + attributes = Seq.empty, + value = None, + ), + ), + attributes = Seq.empty, + ) + (makeField(cf, enumName), enumDef) + } + + private def makeField(cf: ClassifiedField, typeName: String): Field = + SchemaConverter.makeField( + cf.fieldName, + sn(typeName), + cf.description, + cf.required, + cf.minimum, + cf.maximum, + cf.annotations, + ) + + private def convertUnionSchema(name: String, schema: Schema, skippedNames: Set[String]): Union = { + val refs = collectRefs(unionMembers(schema)).filterNot(skippedNames.contains) + val discriminatorName = schema.discriminator.map(_.propertyName) + val mapping = schema.discriminator.flatMap(_.mapping).getOrElse(ListMap.empty) + + val refToValue: Map[String, String] = mapping.map { case (value, ref) => + refName(ref) -> value + }.toMap + + val types = refs.map { typeName => + val resolved = resolve(typeName) + UnionType( + `type` = sn(resolved), + description = None, + deprecation = None, + attributes = Seq.empty, + default = None, + discriminatorValue = refToValue.get(typeName), + ) + } + + Union( + name = sn(name), + plural = sn(name) + "s", + discriminator = discriminatorName, + description = schema.description, + deprecation = None, + types = types, + attributes = Seq.empty, + interfaces = Seq.empty, + ) + } + + private def sn(str: String): String = uniqueSnakeCase(str, config) + private def resolve(name: String): String = resolveReference(name, modelReferences) +} + +case class SchemaConversionResult( + models: Seq[Model], + enums: Seq[Enum], + unions: Seq[Union], +) + +object SchemaConverter { + + def makeField( + name: String, + typeName: String, + description: Option[String], + required: Boolean, + minimum: Option[Long] = None, + maximum: Option[Long] = None, + annotations: SchemaClassifier.FieldAnnotations = SchemaClassifier.FieldAnnotations.empty, + ): Field = { + val attributes = annotations.ignoredFormat.toSeq.map { fmt => + Attribute( + name = "openapi_format", + value = Json.obj("format" -> fmt), + ) + } + Field( + name = name, + `type` = typeName, + description = description, + deprecation = annotations.deprecation, + default = annotations.default, + required = required, + minimum = minimum, + maximum = maximum, + example = annotations.example, + attributes = attributes, + annotations = Seq.empty, + ) + } + + def enumStrings(s: Schema): Seq[String] = + s.`enum`.toList.flatten.collect { case ExampleSingleValue(v) => v.toString } + + private val FormatMap: Map[String, ScalarType] = Map( + "int64" -> ScalarType.LongType, + "float" -> ScalarType.FloatType, + "double" -> ScalarType.DoubleType, + "decimal" -> ScalarType.DecimalType, + "uuid" -> ScalarType.UuidType, + "date" -> ScalarType.DateIso8601Type, + "date-time" -> ScalarType.DateTimeIso8601Type, + ) + + def simpleType(s: Schema): Option[ScalarType] = { + lazy val fromFormat: Option[ScalarType] = s.format.flatMap(FormatMap.get) + + lazy val fromType: Option[ScalarType] = Seq( + SchemaType.Boolean -> ScalarType.BooleanType, + SchemaType.String -> ScalarType.StringType, + SchemaType.Integer -> ScalarType.IntegerType, + SchemaType.Number -> ScalarType.DecimalType, + ).collectFirst { case (st, scalarType) if hasType(s, st) => scalarType } + + fromFormat.orElse(fromType) + } + + def ignoredFormat(s: Schema): Option[String] = + s.format.filterNot(FormatMap.contains) + + private def hasType(s: Schema, st: SchemaType): Boolean = + SchemaResolver.hasType(s, st) +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/SchemaResolver.scala b/openapi/src/main/scala/io/apibuilder/openapi/SchemaResolver.scala new file mode 100644 index 000000000..e6f55f998 --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/SchemaResolver.scala @@ -0,0 +1,68 @@ +package io.apibuilder.openapi + +import io.apibuilder.validation.ScalarType +import sttp.apispec.{Schema, SchemaLike, SchemaType} + +import scala.annotation.tailrec +import scala.collection.immutable.ListMap + +object SchemaResolver { + + def refName(ref: String): String = + ref.replaceAll("#/components/schemas/", "") + + def buildModelReferences(schemas: ListMap[String, SchemaLike]): Map[String, String] = { + schemas.toSeq.flatMap { + case (name, s: Schema) if s.$ref.isDefined => + Some(name -> refName(s.$ref.get)) + + case (name, s: Schema) if s.allOf.nonEmpty && s.properties.isEmpty => + findFirstRef(s.allOf).map(name -> _) + + case (name, s: Schema) if hasType(s, SchemaType.String) && s.`enum`.isEmpty && s.properties.isEmpty => + Some(name -> ScalarType.StringType.name) + + case (name, s: Schema) => + detectMapType(s).map(name -> _) + + case _ => None + }.toMap + } + + def resolveReference(name: String, refs: Map[String, String]): String = + resolveReference(name, refs, Set.empty) + + @tailrec + private def resolveReference(name: String, refs: Map[String, String], seen: Set[String]): String = { + if (seen.contains(name)) + sys.error(s"Cycle detected while resolving schema alias: ${seen.mkString(" -> ")} -> $name") + else + refs.get(name) match { + case None => name + case Some(target) => resolveReference(target, refs, seen + name) + } + } + + private[openapi] def mapValueType(ap: SchemaLike): String = ap match { + case s: Schema if s.$ref.isDefined => refName(s.$ref.get) + case s: Schema => SchemaConverter.simpleType(s).map(_.name).getOrElse(ScalarType.JsonType.name) + case _ => ScalarType.JsonType.name + } + + private[openapi] def detectMapType(s: Schema): Option[String] = { + if (hasType(s, SchemaType.Object) && s.properties.isEmpty) { + s.additionalProperties.map(ap => NamingUtils.mapType(mapValueType(ap))) + } else { + None + } + } + + private def findFirstRef(schemas: List[SchemaLike]): Option[String] = { + schemas.collectFirst { + case s: Schema if s.$ref.isDefined => refName(s.$ref.get) + } + } + + private[openapi] def hasType(schema: Schema, schemaType: SchemaType): Boolean = + schema.`type`.exists(_.contains(schemaType)) +} diff --git a/openapi/src/main/scala/io/apibuilder/openapi/SecurityConverter.scala b/openapi/src/main/scala/io/apibuilder/openapi/SecurityConverter.scala new file mode 100644 index 000000000..7ead6a5df --- /dev/null +++ b/openapi/src/main/scala/io/apibuilder/openapi/SecurityConverter.scala @@ -0,0 +1,61 @@ +package io.apibuilder.openapi + +import io.apibuilder.spec.v0.models.Header +import io.apibuilder.validation.ScalarType +import sttp.apispec.SecurityScheme +import sttp.apispec.openapi.Reference + +import scala.collection.immutable.ListMap + +object SecurityConverter { + + def convertSecuritySchemes( + schemes: ListMap[String, Either[Reference, SecurityScheme]], + ): Seq[Header] = { + schemes.toSeq + .flatMap { case (_, schemeOrRef) => + schemeOrRef match { + case Right(scheme) => convertScheme(scheme) + case Left(_) => None + } + } + .distinctBy(_.name) + } + + private def convertScheme(scheme: SecurityScheme): Option[Header] = { + scheme.`type` match { + case "apiKey" if scheme.in.contains("header") => + scheme.name.map(n => makeHeader(n, scheme.description)) + + case "http" => + val desc = scheme.description.orElse { + scheme.scheme.map { + case "bearer" => "Bearer token" + case "basic" => "Basic authentication" + case other => s"HTTP $other authentication" + } + } + Some(makeHeader("Authorization", desc)) + + case _ => None + } + } + + private def makeHeader(name: String, description: Option[String]): Header = + Header( + name = name, + `type` = ScalarType.StringType.name, + description = description, + deprecation = None, + required = true, + default = None, + attributes = Seq.empty, + ) + + def isConvertible(scheme: SecurityScheme): Boolean = + scheme.`type` match { + case "apiKey" => scheme.in.contains("header") + case "http" => true + case _ => false + } +} diff --git a/openapi/src/test/resources/fedex-eei-filing.json b/openapi/src/test/resources/fedex-eei-filing.json new file mode 100644 index 000000000..4f6b9defc --- /dev/null +++ b/openapi/src/test/resources/fedex-eei-filing.json @@ -0,0 +1,3583 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "EEI Filing API", + "version": "1.0" + }, + "servers": [ + { + "url": "https://apis-sandbox.fedex.com", + "description": "Sandbox Server" + }, + { + "url": "https://apis.fedex.com", + "description": "Production Server" + } + ], + "paths": { + "/globaltrade/v1/shipments/itn/retrieve": { + "post": { + "summary": "Retrieve ITN", + "description": "Use this endpoint to retrieve the ITN(internal tracking number) of an EEI(Electronic Export Information) which was already filed with US Customs.", + "operationId": "RetrieveITN", + "parameters": [ + { + "name": "x-locale", + "in": "header", + "description": "This indicates the combination of language code and country code.", + "schema": { + "type": "string" + }, + "example": "en_US" + }, + { + "name": "content-type", + "in": "header", + "description": "This is used to indicate the media type of the resource. The media type is a string sent along with the file indicating format of the file.", + "schema": { + "type": "string", + "example": "application/json" + } + }, + { + "name": "authorization", + "in": "header", + "description": "This indicates the authorization token for the input request", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer XXXXX" + }, + { + "name": "x-customer-transaction-id", + "in": "header", + "description": "This element allows you to assign a unique identifier to your transaction. This element is also returned in the reply and helps you match the request to the reply.", + "schema": { + "type": "string", + "example": "0e671149-016f-1000-941f-ef4dbabadd2e" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RetrieveITNInputVO" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GticResponseVO_1" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError400" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "EEI.SHIPMENTREFERENCENUMBER.INVALID", + "message": "Shipment reference number is missing or invalid. Please update and try again.", + "parameterList": { + "value": "ULTIMATE_CONSIGNEE", + "key": "EEI.SHIPMENTREFERENCENUMBER.INVALID" + } + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError401_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "LOGIN.REAUTHENTICATE.ERROR", + "message": "Your session is expired. Please enter your user ID and password to log in again" + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError403" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "FORBIDDEN.ERROR", + "message": "We could not authorize your credentials. Please check your permissions and try again." + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError404_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": { + "code": "NOT.FOUND.ERROR", + "message": "The resource you requested is no longer available. Please modify your request and try again." + } + } + } + } + }, + "500": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError500_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "INTERNAL.SERVER.ERROR", + "message": "We encountered an unexpected error and are working to resolve the issue.We apologize for any inconvenience.Please check back at a later time." + } + ] + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError503" + }, + "example": { + "transactionId": "08f37269-2fcf-4a52-8f02-01c8349d143f", + "errors": { + "code": "SERVICE.UNAVAILABLE.ERROR", + "message": "The service is currently unavailable and we are working to resolve the issue. We apologize for any inconvenience.Please check back at a later time" + } + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "C#", + "source": "var client = new RestClient(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\");\nvar request = new RestRequest(Method.POST);\nrequest.AddHeader(\"Authorization\", \"Bearer \");\nrequest.AddHeader(\"X-locale\", \"en_US\");\nrequest.AddHeader(\"Content-Type\", \"application/json\");\n// 'input' refers to JSON Payload\nrequest.AddParameter(\"application/x-www-form-urlencoded\", input, ParameterType.RequestBody);\nIRestResponse response = client.Execute(request);\n\n\n\n\n" + }, + { + "lang": "JAVA", + "source": "OkHttpClient client = new OkHttpClient();\n\nMediaType mediaType = MediaType.parse(\"application/json\");\n// 'input' refers to JSON Payload\nRequestBody body = RequestBody.create(mediaType, input);\nRequest request = new Request.Builder()\n .url(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\")\n .post(body)\n .addHeader(\"Content-Type\", \"application/json\")\n .addHeader(\"X-locale\", \"en_US\")\n .addHeader(\"Authorization\", \"Bearer \")\n .build();\n \nResponse response = client.newCall(request).execute();" + }, + { + "lang": "JAVASCRIPT", + "source": "// 'input' refers to JSON Payload\nvar data = JSON.stringify(input);\n \n var xhr = new XMLHttpRequest();\n xhr.withCredentials = true;\n \n xhr.addEventListener(\"readystatechange\", function () {\n if (this.readyState === 4) {\n console.log(this.responseText);\n }\n });\n \n xhr.open(\"POST\", \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\");\n xhr.setRequestHeader(\"Content-Type\", \"application/json\");\n xhr.setRequestHeader(\"X-locale\", \"en_US\");\n xhr.setRequestHeader(\"Authorization\", \"Bearer \");\n \n xhr.send(data);" + }, + { + "lang": "PHP", + "source": "setUrl('https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve');\n$request->setMethod(HTTP_METH_POST);\n\n$request->setHeaders(array(\n 'Authorization' => 'Bearer ',\n 'X-locale' => 'en_US',\n 'Content-Type' => 'application/json'\n));\n\n$request->setBody(input); // 'input' refers to JSON Payload\n\ntry {\n $response = $request->send();\n\n echo $response->getBody();\n} catch (HttpException $ex) {\n echo $ex;\n}" + }, + { + "lang": "PYTHON", + "source": "import requests\n\nurl = \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\"\n\npayload = input # 'input' refers to JSON Payload\nheaders = {\n 'Content-Type': \"application/json\",\n 'X-locale': \"en_US\",\n 'Authorization': \"Bearer \"\n }\n\nresponse = requests.post(url, data=payload, headers=headers)\n\nprint(response.text)" + }, + { + "lang": "RUST", + "source": "extern crate reqwest;\n\nuse std::io::Read;\n\nfn construct_headers() -> HeaderMap {\n let mut headers = HeaderMap::new();\n headers.insert(\"Content-Type\", \"application/json\");\n headers.insert(\"X-locale\", \"en_US\");\n headers.insert(\"Authorization\", \"Bearer \");\n headers\n}\n\nfn run() -> Result<()> {\n let client = reqwest::Client::new();\n let mut res = client.post(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\")\n .body(input) // 'input' refers to JSON Payload\n .headers(construct_headers())\n .send()?;\n let mut body = String::new();\n res.read_to_string(&mut body)?;\n\n println!(\"Status: {}\", res.status());\n println!(\"Headers:\\n{:#?}\", res.headers());\n println!(\"Body:\\n{}\", body);\n\n Ok(())\n}" + }, + { + "lang": "SWIFT", + "source": "import Foundation\n\nlet headers = [\n \"Content-Type\": \"application/json\",\n \"X-locale\": \"en_US\",\n \"Authorization\": \"Bearer \"\n]\nlet parameters = [\n input // 'input' refers to JSON Payload\n] as [String : Any]\n\nlet postData = JSONSerialization.data(withJSONObject: parameters, options: [])\n\nlet request = NSMutableURLRequest(url: NSURL(string: \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/itn/retrieve\")! as URL,\n cachePolicy: .useProtocolCachePolicy,\n timeoutInterval: 10.0)\nrequest.httpMethod = \"POST\"\nrequest.allHTTPHeaderFields = headers\nrequest.httpBody = postData as Data\n\nlet session = URLSession.shared\nlet dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in\n if (error != nil) {\n print(error)\n } else {\n let httpResponse = response as? HTTPURLResponse\n print(httpResponse)\n }\n})\n\ndataTask.resume()" + } + ] + } + }, + "/globaltrade/v1/shipments/eei/file": { + "post": { + "summary": "File EEI", + "description": "Use this endpoint to file an Electronic Export Information(EEI) with US Customs.", + "operationId": "FileEEI V1", + "parameters": [ + { + "name": "x-locale", + "in": "header", + "description": "This indicates the combination of language code and country code.", + "schema": { + "type": "string" + }, + "example": "en_US" + }, + { + "name": "authorization", + "in": "header", + "description": "This indicates the authorization token for the input request.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer XXXXX" + }, + { + "name": "x-customer-transaction-id", + "in": "header", + "description": "This element allows you to assign a unique identifier to your transaction. This element is also returned in the reply and helps you match the request to the reply.", + "schema": { + "type": "string", + "example": "0e671149-016f-1000-941f-ef4dbabadd2e" + } + }, + { + "in": "header", + "name": "content-type", + "description": "This is used to indicate the media type of the resource. The media type is a string sent along with the file indicating format of the file.", + "required": true, + "schema": { + "type": "string", + "example": "application/json" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/eei_file_body" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GticResponseVO_2" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError400" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "customerTransactionId": "0e671149-016f-1000-941f-ef4dbabadd2e", + "errors": [ + { + "code": "EEI.SHIPMENTREFERENCENUMBER.INVALID", + "message": "Shipment reference number is missing or invalid. Please update and try again.", + "parameterList": { + "value": "ULTIMATE_CONSIGNEE", + "key": "EEI.SHIPMENTREFERENCENUMBER.INVALID" + } + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError401_2" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "NOT.AUTHORIZED.ERROR", + "message": "RESOURCESERVER.TOKEN.EXPIRED" + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError403_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "FORBIDDEN.ERROR", + "message": "We could not authorize your credentials. Please check your permissions and try again." + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError404_2" + }, + "example": { + "error": "NOT.FOUND.ERROR", + "error_detail": "The resource you requested is no longer available. Please modify your request and try again." + } + } + } + }, + "500": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CXSError500_2" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "INTERNAL.SERVER.ERROR", + "message": "We encountered an unexpected error and are working to resolve the issue. We apologize for any inconvenience. Please check back at a later time." + } + ] + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "C#", + "source": "var client = new RestClient(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\");\nvar request = new RestRequest(Method.POST);\nrequest.AddHeader(\"Authorization\", \"Bearer \");\nrequest.AddHeader(\"X-locale\", \"en_US\");\nrequest.AddHeader(\"Content-Type\", \"application/json\");\n// 'input' refers to JSON Payload\nrequest.AddParameter(\"application/x-www-form-urlencoded\", input, ParameterType.RequestBody);\nIRestResponse response = client.Execute(request);\n\n\n\n\n" + }, + { + "lang": "JAVA", + "source": "OkHttpClient client = new OkHttpClient();\n\nMediaType mediaType = MediaType.parse(\"application/json\");\n// 'input' refers to JSON Payload\nRequestBody body = RequestBody.create(mediaType, input);\nRequest request = new Request.Builder()\n .url(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\")\n .post(body)\n .addHeader(\"Content-Type\", \"application/json\")\n .addHeader(\"X-locale\", \"en_US\")\n .addHeader(\"Authorization\", \"Bearer \")\n .build();\n \nResponse response = client.newCall(request).execute();" + }, + { + "lang": "JAVASCRIPT", + "source": "// 'input' refers to JSON Payload\nvar data = JSON.stringify(input);\n \n var xhr = new XMLHttpRequest();\n xhr.withCredentials = true;\n \n xhr.addEventListener(\"readystatechange\", function () {\n if (this.readyState === 4) {\n console.log(this.responseText);\n }\n });\n \n xhr.open(\"POST\", \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\");\n xhr.setRequestHeader(\"Content-Type\", \"application/json\");\n xhr.setRequestHeader(\"X-locale\", \"en_US\");\n xhr.setRequestHeader(\"Authorization\", \"Bearer \");\n \n xhr.send(data);" + }, + { + "lang": "PHP", + "source": "setUrl('https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file');\n$request->setMethod(HTTP_METH_POST);\n\n$request->setHeaders(array(\n 'Authorization' => 'Bearer ',\n 'X-locale' => 'en_US',\n 'Content-Type' => 'application/json'\n));\n\n$request->setBody(input); // 'input' refers to JSON Payload\n\ntry {\n $response = $request->send();\n\n echo $response->getBody();\n} catch (HttpException $ex) {\n echo $ex;\n}" + }, + { + "lang": "PYTHON", + "source": "import requests\n\nurl = \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\"\n\npayload = input # 'input' refers to JSON Payload\nheaders = {\n 'Content-Type': \"application/json\",\n 'X-locale': \"en_US\",\n 'Authorization': \"Bearer \"\n }\n\nresponse = requests.post(url, data=payload, headers=headers)\n\nprint(response.text)" + }, + { + "lang": "RUST", + "source": "extern crate reqwest;\n\nuse std::io::Read;\n\nfn construct_headers() -> HeaderMap {\n let mut headers = HeaderMap::new();\n headers.insert(\"Content-Type\", \"application/json\");\n headers.insert(\"X-locale\", \"en_US\");\n headers.insert(\"Authorization\", \"Bearer \");\n headers\n}\n\nfn run() -> Result<()> {\n let client = reqwest::Client::new();\n let mut res = client.post(\"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\")\n .body(input) // 'input' refers to JSON Payload\n .headers(construct_headers())\n .send()?;\n let mut body = String::new();\n res.read_to_string(&mut body)?;\n\n println!(\"Status: {}\", res.status());\n println!(\"Headers:\\n{:#?}\", res.headers());\n println!(\"Body:\\n{}\", body);\n\n Ok(())\n}" + }, + { + "lang": "SWIFT", + "source": "import Foundation\n\nlet headers = [\n \"Content-Type\": \"application/json\",\n \"X-locale\": \"en_US\",\n \"Authorization\": \"Bearer \"\n]\nlet parameters = [\n input // 'input' refers to JSON Payload\n] as [String : Any]\n\nlet postData = JSONSerialization.data(withJSONObject: parameters, options: [])\n\nlet request = NSMutableURLRequest(url: NSURL(string: \"https://apis-sandbox.fedex.com/globaltrade/v1/shipments/eei/file\")! as URL,\n cachePolicy: .useProtocolCachePolicy,\n timeoutInterval: 10.0)\nrequest.httpMethod = \"POST\"\nrequest.allHTTPHeaderFields = headers\nrequest.httpBody = postData as Data\n\nlet session = URLSession.shared\nlet dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in\n if (error != nil) {\n print(error)\n } else {\n let httpResponse = response as? HTTPURLResponse\n print(httpResponse)\n }\n})\n\ndataTask.resume()" + } + ] + } + }, + "/globaltrade/v1/regulatorycompliance/lookup": { + "post": { + "summary": "Regulatory Compliance Lookup", + "description": "This endpoint is to determine the compliance regulations applied for Shipment for EEI Filings.", + "operationId": "regulatoryComplianceLookup", + "parameters": [ + { + "name": "X-locale", + "in": "header", + "description": "ISO locale ", + "schema": { + "type": "string" + }, + "example": "en_US" + }, + { + "name": "content-type", + "in": "header", + "description": "This is used to indicate the media type of the resource. The media type is a string sent along with the file indicating format of the file.", + "required": true, + "schema": { + "type": "string" + }, + "example": "application/json" + }, + { + "name": "Authorization", + "in": "header", + "description": "Specifies the authorization token.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer XXX" + }, + { + "in": "header", + "name": "x-customer-transaction-id", + "description": "This element allows you to assign a unique identifier to your transaction. This element is also returned in the reply and helps you match the request to the reply.", + "required": false, + "schema": { + "type": "string", + "example": "624deea6-b709-470c-8c39-4b5511281492" + } + } + ], + "requestBody": { + "description": "Contains the Materials Input object to be applied.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestVO" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GticResponseVO_3" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseVO_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "COMMODITY.CODE.REQUIRED", + "message": "Harmonized code or scheduleBCode is required." + }, + { + "code": "REGULATORYCOMPLIANCE.COMMODITIES.NOTALLOWED", + "message": "The HTS code(s) are not allowed by AES." + }, + { + "code": "COMMODITY.CODE.INVALID", + "message": "Harmonized code or scheduleBCode is invalid. Please Verify." + }, + { + "code": "REGULATORYCOMPLIANCE.SHIPDATE.INVALID", + "message": "Shipment date can’t be greater than 120 days." + } + ] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseVO_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "NOT.AUTHORIZED.ERROR", + "message": "No access token provided. Please modify your request and try again." + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseVO_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "FORBIDDEN.ERROR", + "message": "We could not authorize your credentials. Please check your permissions and try again." + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseVO_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "errors": [ + { + "code": "NOT.FOUND.ERROR", + "message": "The resource you requested is no longer available. Please modify your request and try again." + } + ] + } + } + } + }, + "500": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseVO_1" + }, + "example": { + "transactionId": "624deea6-b709-470c-8c39-4b5511281492", + "customerTransactionId": "AnyCo_order123456789", + "errors": [ + { + "code": "INTERNAL.SERVER.ERROR", + "message": "We encountered an unexpected error and are working to resolve the issue. We apologize for any inconvenience. Please check back at a later time." + } + ] + } + } + } + } + }, + "x-code-samples": [ + { + "lang": "C#", + "source": "var client = new RestClient(\"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\");\nvar request = new RestRequest(Method.POST);\nrequest.AddHeader(\"Authorization\", \"Bearer \");\nrequest.AddHeader(\"X-locale\", \"en_US\");\nrequest.AddHeader(\"Content-Type\", \"application/json\");\n// 'input' refers to JSON Payload\nrequest.AddParameter(\"application/x-www-form-urlencoded\", input, ParameterType.RequestBody);\nIRestResponse response = client.Execute(request);\n\n\n\n\n" + }, + { + "lang": "JAVA", + "source": "OkHttpClient client = new OkHttpClient();\n\nMediaType mediaType = MediaType.parse(\"application/json\");\n// 'input' refers to JSON Payload\nRequestBody body = RequestBody.create(mediaType, input);\nRequest request = new Request.Builder()\n .url(\"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\")\n .post(body)\n .addHeader(\"Content-Type\", \"application/json\")\n .addHeader(\"X-locale\", \"en_US\")\n .addHeader(\"Authorization\", \"Bearer \")\n .build();\n \nResponse response = client.newCall(request).execute();" + }, + { + "lang": "JAVASCRIPT", + "source": "// 'input' refers to JSON Payload\nvar data = JSON.stringify(input);\n \n var xhr = new XMLHttpRequest();\n xhr.withCredentials = true;\n \n xhr.addEventListener(\"readystatechange\", function () {\n if (this.readyState === 4) {\n console.log(this.responseText);\n }\n });\n \n xhr.open(\"POST\", \"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\");\n xhr.setRequestHeader(\"Content-Type\", \"application/json\");\n xhr.setRequestHeader(\"X-locale\", \"en_US\");\n xhr.setRequestHeader(\"Authorization\", \"Bearer \");\n \n xhr.send(data);" + }, + { + "lang": "PHP", + "source": "setUrl('https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup');\n$request->setMethod(HTTP_METH_POST);\n\n$request->setHeaders(array(\n 'Authorization' => 'Bearer ',\n 'X-locale' => 'en_US',\n 'Content-Type' => 'application/json'\n));\n\n$request->setBody(input); // 'input' refers to JSON Payload\n\ntry {\n $response = $request->send();\n\n echo $response->getBody();\n} catch (HttpException $ex) {\n echo $ex;\n}" + }, + { + "lang": "PYTHON", + "source": "import requests\n\nurl = \"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\"\n\npayload = input # 'input' refers to JSON Payload\nheaders = {\n 'Content-Type': \"application/json\",\n 'X-locale': \"en_US\",\n 'Authorization': \"Bearer \"\n }\n\nresponse = requests.post(url, data=payload, headers=headers)\n\nprint(response.text)" + }, + { + "lang": "RUST", + "source": "extern crate reqwest;\n\nuse std::io::Read;\n\nfn construct_headers() -> HeaderMap {\n let mut headers = HeaderMap::new();\n headers.insert(\"Content-Type\", \"application/json\");\n headers.insert(\"X-locale\", \"en_US\");\n headers.insert(\"Authorization\", \"Bearer \");\n headers\n}\n\nfn run() -> Result<()> {\n let client = reqwest::Client::new();\n let mut res = client.post(\"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\")\n .body(input) // 'input' refers to JSON Payload\n .headers(construct_headers())\n .send()?;\n let mut body = String::new();\n res.read_to_string(&mut body)?;\n\n println!(\"Status: {}\", res.status());\n println!(\"Headers:\\n{:#?}\", res.headers());\n println!(\"Body:\\n{}\", body);\n\n Ok(())\n}" + }, + { + "lang": "SWIFT", + "source": "import Foundation\n\nlet headers = [\n \"Content-Type\": \"application/json\",\n \"X-locale\": \"en_US\",\n \"Authorization\": \"Bearer \"\n]\nlet parameters = [\n input // 'input' refers to JSON Payload\n] as [String : Any]\n\nlet postData = JSONSerialization.data(withJSONObject: parameters, options: [])\n\nlet request = NSMutableURLRequest(url: NSURL(string: \"https://apis-sandbox.fedex.com/globaltrade/v1/regulatorycompliance/lookup\")! as URL,\n cachePolicy: .useProtocolCachePolicy,\n timeoutInterval: 10.0)\nrequest.httpMethod = \"POST\"\nrequest.allHTTPHeaderFields = headers\nrequest.httpBody = postData as Data\n\nlet session = URLSession.shared\nlet dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in\n if (error != nil) {\n print(error)\n } else {\n let httpResponse = response as? HTTPURLResponse\n print(httpResponse)\n }\n})\n\ndataTask.resume()" + } + ] + } + } + }, + "components": { + "schemas": { + "GticResponseVO": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "customerTransactionId": { + "type": "string", + "description": "This element allows you to assign a unique identifier to your transaction. This element is also returned in the reply and helps you match the request to the reply.
Example: AnyCo_order123456789", + "example": "AnyCo_order123456789" + }, + "output": { + "$ref": "#/components/schemas/BaseProcessOutputVO_1" + } + }, + "description": "This is a wrapper class for outputVO." + }, + "BaseProcessOutputVO_1": { + "required": [ + "countryDetails", + "userMessages" + ], + "type": "object", + "properties": { + "userMessages": { + "type": "array", + "description": "Represents User Message", + "items": { + "$ref": "#/components/schemas/RegulatoryMessage" + } + }, + "countryDetails": { + "description": "Represents Country Details", + "allOf": [ + { + "$ref": "#/components/schemas/RegulatoryCountryDetails" + } + ] + }, + "cxsalerts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSAlert_1" + } + } + }, + "description": "ShipmentRegulatoryDetailsOutputVO Model" + }, + "ErrorResponseVO": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSError" + } + } + }, + "description": "This holds the error responses." + }, + "CXSError": { + "type": "object", + "properties": { + "code": { + "description": "Indicates the error code.
Example: ACCOUNT.NUMBER.INVALID,LOGIN.REAUTHENTICATE.ERROR,SHIPMENT.USER.UNAUTHORIZED,NOT.FOUND.ERROR,INTERNAL.SERVER.ERROR" + }, + "parameterList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Parameter_2" + } + }, + "message": { + "type": "string", + "description": "Indicates the description of API error alert message.
Example: We are unable to process this request. Please try again later or contact FedEx Customer Service." + } + }, + "description": "Indicates error alert when suspicious files, potential exploits and viruses found while scanning files , directories and user accounts. This includes code, message and parameter" + }, + "Parameter_2": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Identifies the error option to be applied." + }, + "key": { + "type": "string", + "description": "Indicates the value associated with the key." + } + }, + "description": "List of parameters which indicates the properties of the alert message." + }, + "ErrorResponseVO401": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSError401" + } + } + }, + "description": "This holds the error responses." + }, + "CXSError401": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Indicates the error code.
Example: NOT.AUTHORIZED.ERROR" + }, + "parameterList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Parameter" + } + }, + "message": { + "description": "Indicates the description of API error alert message.
Example: Access token expired. Please modify your request and try again." + } + }, + "description": "Indicates error alert when suspicious files, potential exploits and viruses found while scanning files , directories and user accounts. This includes code, message and parameter" + }, + "Parameter": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Identifies the error option to be applied." + }, + "key": { + "type": "string", + "description": "Indicates the value associated with the key." + } + }, + "description": "List of parameters which indicates the properties of the alert message." + }, + "ErrorResponseVO404": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSError404" + } + } + }, + "description": "This holds the error responses." + }, + "CXSError404": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Indicates the error code.
Example: NOT.FOUND.ERROR" + }, + "parameterList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Parameter" + } + }, + "message": { + "description": "Indicates the description of API error alert message.
Example: The resource you requested is no longer available. Please modify your request and try again." + } + }, + "description": "Indicates error alert when suspicious files, potential exploits and viruses found while scanning files , directories and user accounts. This includes code, message and parameter" + }, + "ErrorResponseVO422": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSError422" + } + } + }, + "description": "This holds the error responses." + }, + "CXSError422": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Indicates the error code.
Example: INVALID.INPUT.EXCEPTION" + }, + "parameterList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Parameter" + } + }, + "message": { + "description": "Validation failed for the object='shipmentRegulatoryDetailsInputVO'.Error count:1" + } + }, + "description": "Indicates error when mandatory elements are not passed in the request." + }, + "ErrorResponseVO500": { + "type": "object", + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID is a special set of numbers that defines each transaction.
Example: 624deea6-b709-470c-8c39-4b5511281492", + "example": "624deea6-b709-470c-8c39-4b5511281492" + }, + "customerTransactionId": { + "type": "string", + "description": "This element allows you to assign a unique identifier to your transaction. This element is also returned in the reply and helps you match the request to the reply.
Example: AnyCo_order123456789", + "format": "uuid" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CXSError500" + } + } + }, + "description": "This holds the error responses." + }, + "CXSError500": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Indicates the error code.
Example: INTERNAL.SERVER.ERROR" + }, + "parameterList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Parameter" + } + }, + "message": { + "description": "Indicates the description of API error alert message.
Example: We encountered an unexpected error and are working to resolve the issue. We apologize for any inconvenience. Please check back at a later time." + } + }, + "description": "Indicates error alert when suspicious files, potential exploits and viruses found while scanning files , directories and user accounts. This includes code, message and parameter" + }, + "FullSchema": { + "required": [ + "carrierCode", + "destinationAddress", + "originAddress" + ], + "type": "object", + "properties": { + "serviceType": { + "type": "string", + "description": "Specify the type of service that is used to ship the package.
click here to see Service Types", + "example": "FEDEX_FREIGHT_ECONOMY" + }, + "totalCommodityValue": { + "description": "Specify the total commodity value. Either customsClearenceDetail or totalCommodityValue is required.", + "allOf": [ + { + "$ref": "#/components/schemas/Money" + } + ] + }, + "originAddress": { + "description": "Provide the shipment origin address details.", + "allOf": [ + { + "$ref": "#/components/schemas/Address" + } + ] + }, + "destinationAddress": { + "description": "Provide the shipment destination address details.", + "allOf": [ + { + "$ref": "#/components/schemas/Address" + } + ] + }, + "serviceOptionType": { + "type": "array", + "description": "Specify attributes to filter location types. If more than one value is specified, only those locations that have all the specified attributes will be returned.", + "example": [ + "FEDEX_ONE_RATE", + "SATURDAY_DELIVERY" + ], + "items": { + "type": "string", + "description": "Specify attributes to filter location types. If more than one value is specified, only those locations that have all the specified attributes will be returned.
Example: [FEDEX_ONE_RATE, SATURDAY_DELIVERY]", + "enum": [ + "FEDEX_ONE_RATE", + "FREIGHT_GUARANTEE", + "SATURDAY_DELIVERY", + "SMART_POST_ALLOWED_INDICIA", + "SMART_POST_HUB_ID" + ] + } + }, + "customsClearanceDetail": { + "description": "Specify the Customs clearance details.Either customsClearenceDetail or totalCommodityValue is required.", + "allOf": [ + { + "$ref": "#/components/schemas/CustomsClearanceDetailVO" + } + ] + }, + "shipDate": { + "type": "string", + "description": "Specify shipment date.

Note : Default value is current date in case the date is not provided or a past date is provided.
Format [YYYY-MM-DD].
Example: 2021-08-05'", + "example": "2019-10-14" + }, + "carrierCode": { + "type": "string", + "description": "Specify the four letter code of a FedEx operating company that meets your requirements.

Valid values are: