Created
February 13, 2020 14:38
-
-
Save mitallast/83e4a8a311603741e0b939bdefb69453 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package mall.swagger | |
import java.lang.IllegalArgumentException | |
import java.text.SimpleDateFormat | |
import java.util.{Date, UUID} | |
import cats._, cats.effect._, cats.implicits._ | |
import io.circe._ | |
import io.circe.generic.auto._ | |
import io.circe.parser._ | |
import io.circe.syntax._ | |
import io.swagger.v3.oas.models.{OpenAPI, PathItem} | |
import io.swagger.v3.oas.models.media._ | |
import io.swagger.v3.oas.models.responses.ApiResponse | |
import io.swagger.v3.parser.OpenAPIV3Parser | |
import org.apache.http.client.methods.{HttpPost, HttpUriRequest} | |
import org.apache.http.entity.StringEntity | |
import org.apache.http.impl.client.HttpClients | |
import org.apache.logging.log4j.scala.Logging | |
import org.scalatest._ | |
import org.http4s.client.blaze._ | |
import org.http4s.client._ | |
import org.http4s.Uri | |
import scala.concurrent.ExecutionContext.global | |
import cats.effect.Blocker | |
import java.util.concurrent._ | |
import collection.JavaConverters._ | |
import scala.io.Source | |
object encoders { | |
val dateFormat = new SimpleDateFormat("yyyy-MM-dd") | |
implicit val dateEncoder: Encoder[Date] = Encoder.encodeString.contramap { date => | |
dateFormat.format(date) | |
} | |
implicit val numberEncoder: Encoder[Number] = Encoder.instance { | |
case value: java.lang.Integer => Json.fromInt(value) | |
case value: java.lang.Long => Json.fromLong(value) | |
case value: java.lang.Float => Json.fromFloatOrNull(value) | |
case value: java.lang.Double => Json.fromDoubleOrNull(value) | |
case value: java.math.BigDecimal => Json.fromBigDecimal(value) | |
case value: java.math.BigInteger => Json.fromBigInt(value) | |
} | |
implicit val objectEncoder: Encoder[Object] = Encoder.instance { | |
case value: String => value.asJson | |
} | |
} | |
object extractors { | |
object Examples { | |
def unapply[T](schema: Schema[T]): Option[Vector[T]] = Option(schema.getExample).collect { | |
case map: java.util.Map[String, T] => map.asScala.values.toVector | |
} | |
} | |
object XExamples { | |
def unapply(schema: Schema[_]): Option[Vector[Object]] = | |
(for { | |
extensions <- Option(schema.getExtensions) | |
examples <- Option(extensions.get("x-examples")) | |
_ = println(examples) | |
} yield examples) | |
.collect { | |
case list: java.util.List[Object] => list.asScala.toVector | |
case map: java.util.Map[String, Object] => map.asScala.values.toVector | |
} | |
} | |
object Example { | |
def unapply[T](schema: Schema[T]): Option[T] = Option(schema.getExample).collect { | |
case t: T => t | |
} | |
} | |
object Default { | |
def unapply[T](schema: Schema[T]): Option[T] = Option(schema.getDefault).collect { | |
case t: T => t | |
} | |
} | |
object ComponentRef { | |
private val schemaRef = "^#/components/schemas/([a-zA-Z0-9_]+)$".r | |
def unapply[T](schema: Schema[T]): Option[String] = Option(schema.get$ref()).collect { | |
case schemaRef(name) => name | |
} | |
} | |
} | |
object generators { | |
import encoders._ | |
import extractors._ | |
sealed trait Generator[S] { | |
def apply(schema: S): Vector[Json] | |
} | |
def generate(schema: Schema[_])(implicit gen: Generator[Schema[_]]): Vector[Json] = gen(schema) | |
def instance[S](f: S => Vector[Json]): Generator[S] = new Generator[S] { | |
override def apply(schema: S): Vector[Json] = f(schema) | |
} | |
implicit val integerGen: Generator[IntegerSchema] = instance { | |
case Examples(examples) => examples.map(_.asJson) | |
case XExamples(examples) => examples.map(_.asJson) | |
case Example(example) => Vector(example.asJson) | |
case Default(example) => Vector(example.asJson) | |
case _ => Vector(Json.fromInt(0), Json.fromInt(1), Json.fromInt(42)) | |
} | |
implicit val stringGen: Generator[StringSchema] = instance { | |
case Examples(examples) => examples.map(_.asJson) | |
case XExamples(examples) => examples.map(_.asJson) | |
case Example(example) => Vector(example.asJson) | |
case Default(example) => Vector(example.asJson) | |
case _ => Vector(Json.fromString("hello world")) | |
} | |
implicit val dateGen: Generator[DateSchema] = instance { | |
case Examples(examples) => examples.map(_.asJson) | |
case XExamples(examples) => examples.map(_.asJson) | |
case Example(example) => Vector(example.asJson) | |
case Default(example) => Vector(example.asJson) | |
case _ => Vector(Json.fromString("hello world")) | |
} | |
implicit val uuidGen: Generator[UUIDSchema] = instance { | |
case Examples(examples) => examples.map(_.asJson) | |
case XExamples(examples) => examples.map(_.asJson) | |
case Example(example) => Vector(example.asJson) | |
case Default(example) => Vector(example.asJson) | |
case _ => Vector(UUID.randomUUID().asJson) | |
} | |
implicit def arrayGen(implicit api: OpenAPI): Generator[ArraySchema] = instance { schema => | |
generate(schema.getItems).map(Json.arr(_)) | |
} | |
implicit def objectGen(implicit api: OpenAPI): Generator[ObjectSchema] = instance { | |
case Examples(examples) => examples.map(_.asJson) | |
case Example(example) => Vector(example.asJson) | |
case schema => | |
val fields = schema.getProperties.asScala.mapValues(generate(_)).toList | |
val builder = Vector.newBuilder[Json] | |
def flatten(gen: List[(String, Json)], fields: List[(String, Vector[Json])]): Unit = fields match { | |
case Nil => builder += gen.toMap.asJson | |
case (field, examples) :: tail => | |
for (example <- examples) { | |
val stack = (field, example) :: gen | |
flatten(stack, tail) | |
} | |
} | |
flatten(Nil, fields) | |
builder.result() | |
} | |
implicit def refGen(implicit api: OpenAPI): Generator[String] = instance { name => | |
val schema: Schema[_] = api.getComponents.getSchemas.get(name) | |
generate(schema) | |
} | |
implicit def schemaGen( | |
implicit arr: Generator[ArraySchema], | |
obj: Generator[ObjectSchema], | |
ref: Generator[String] | |
): Generator[Schema[_]] = instance { | |
case schema: IntegerSchema => integerGen(schema) | |
case schema: StringSchema => stringGen(schema) | |
case schema: DateSchema => dateGen(schema) | |
case schema: UUIDSchema => uuidGen(schema) | |
case schema: ArraySchema => arr(schema) | |
case schema: ObjectSchema => obj(schema) | |
case ComponentRef(reference) => ref(reference) | |
case schema => throw new IllegalArgumentException(s"unsupported schema: $schema") | |
} | |
} | |
object validators extends Matchers { | |
import extractors._ | |
sealed trait Validator[S] { | |
def apply(value: Json, schema: S): Unit | |
} | |
def validate(value: Json, schema: Schema[_])(implicit v: Validator[Schema[_]]): Unit = v(value, schema) | |
def instance[S](f: (Json, S) => Unit): Validator[S] = new Validator[S] { | |
override def apply(value: Json, schema: S): Unit = f(value, schema) | |
} | |
implicit val stringValidator: Validator[StringSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be string, actual boolean"), | |
_ => fail("should be string, actual number"), | |
value => { | |
if (schema.getPattern != null) value should (fullyMatch regex schema.getPattern) | |
}, // test string, | |
_ => fail("should be string, actual array"), | |
_ => fail("should be string, actual object") | |
) | |
} | |
implicit val dateValidator: Validator[DateSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be date, actual boolean"), | |
_ => fail("should be date, actual number"), | |
value => { | |
value should (fullyMatch regex "[1-9]\\d\\d\\d-[0-1]\\d-[0123]\\d") | |
}, // test string, | |
_ => fail("should be date, actual array"), | |
_ => fail("should be date, actual object") | |
) | |
} | |
implicit val uuidValidator: Validator[UUIDSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be uuid, actual boolean"), | |
_ => fail("should be uuid, actual number"), | |
value => { | |
value should (fullyMatch regex "\\b[0-9a-f]{8}\\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\\b[0-9a-f]{12}\\b") | |
}, // test string, | |
_ => fail("should be uuid, actual array"), | |
_ => fail("should be uuid, actual object") | |
) | |
} | |
implicit val integerValidator: Validator[IntegerSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be integer, actual boolean"), | |
num => { | |
val valueOpt = Option(schema.getFormat) match { | |
case Some("int32") => num.toInt.map(_.longValue()) | |
case Some("int64") => num.toLong | |
case None => num.toLong | |
} | |
valueOpt should not be (empty) | |
val value = valueOpt.get | |
if (schema.getMinimum != null) value shouldBe >=(schema.getMinimum) | |
if (schema.getExclusiveMinimum != null) value shouldBe >(schema.getMinimum) | |
if (schema.getMaximum != null) value shouldBe <=(schema.getMaximum) | |
if (schema.getExclusiveMaximum != null) value shouldBe <(schema.getMaximum) | |
}, | |
_ => fail("should be integer, actual string"), | |
_ => fail("should be integer, actual array"), | |
_ => fail("should be integer, actual object") | |
) | |
} | |
implicit val numberValidator: Validator[NumberSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be number, actual boolean"), | |
num => { | |
val value = Option(schema.getFormat) match { | |
case Some("float") => num.toFloat.toDouble | |
case Some("double") => num.toDouble | |
case None => num.toDouble | |
} | |
if (schema.getMinimum != null) value shouldBe >=(schema.getMinimum) | |
if (schema.getExclusiveMinimum != null) value shouldBe >(schema.getMinimum) | |
if (schema.getMaximum != null) value shouldBe <=(schema.getMaximum) | |
if (schema.getExclusiveMaximum != null) value shouldBe <(schema.getMaximum) | |
}, | |
_ => fail("should be number, actual string"), | |
_ => fail("should be number, actual array"), | |
_ => fail("should be number, actual object") | |
) | |
} | |
implicit def arrayValidator(implicit api: OpenAPI): Validator[ArraySchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be array, actual boolean"), | |
_ => fail("should be array, actual number"), | |
_ => fail("should be array, actual string"), | |
array => { | |
val itemSchema: Schema[_] = schema.getItems | |
for (item <- array) { | |
validate(item, itemSchema) | |
} | |
}, | |
_ => fail("should be array, actual object") | |
) | |
} | |
implicit def objectValidator(implicit api: OpenAPI): Validator[ObjectSchema] = instance { (json, schema) => | |
json.fold[Unit]( | |
() => (), // ignore null | |
_ => fail("should be array, actual boolean"), | |
_ => fail("should be array, actual number"), | |
_ => fail("should be array, actual string"), | |
_ => fail("should be array, actual array"), | |
obj => { | |
val requiredSet = Option(schema.getRequired).map(_.asScala.toSet).getOrElse(Set.empty) | |
for ((property, schema) <- schema.getProperties.asScala) { | |
obj.contains(property) || !requiredSet.contains(property) shouldBe true | |
obj(property) match { | |
case Some(value) => validate(value, schema) | |
case None => requiredSet.contains(property) shouldBe false | |
} | |
} | |
} | |
) | |
} | |
implicit def refValidator(implicit api: OpenAPI): Validator[String] = instance { (json, name) => | |
val schema: Schema[_] = api.getComponents.getSchemas.get(name) | |
validate(json, schema) | |
} | |
implicit def schemaValidator( | |
implicit arr: Validator[ArraySchema], | |
obj: Validator[ObjectSchema], | |
ref: Validator[String] | |
): Validator[Schema[_]] = instance { | |
case (json, schema: StringSchema) => stringValidator(json, schema) | |
case (json, schema: DateSchema) => dateValidator(json, schema) | |
case (json, schema: UUIDSchema) => uuidValidator(json, schema) | |
case (json, schema: IntegerSchema) => integerValidator(json, schema) | |
case (json, schema: NumberSchema) => numberValidator(json, schema) | |
case (json, schema: ArraySchema) => arr(json, schema) | |
case (json, schema: ObjectSchema) => obj(json, schema) | |
case (json, ComponentRef(reference)) => ref(json, reference) | |
case (json, schema) => throw new IllegalArgumentException(s"unsupported schema: $schema") | |
} | |
} | |
class SwaggerApiSpec extends WordSpec with Matchers with Logging { | |
import io.swagger.v3.parser.core.models.ParseOptions | |
import generators._ | |
import validators._ | |
private val options = new ParseOptions | |
options.setResolveFully(true) | |
private implicit val api: OpenAPI = new OpenAPIV3Parser().read("./petstore.yaml", null, options) | |
private val blockingPool = Executors.newFixedThreadPool(5) | |
private val blocker = Blocker.liftExecutorService(blockingPool) | |
private implicit val cs = IO.contextShift(ExecutionException.global) | |
private val httpClient: Client[IO] = JavaNetClientBuilder[IO](blocker).create | |
private val server = api.getServers.asScala.head | |
api.getTags.asScala.foreach { tag => | |
tag.getName should { | |
api.getPaths.asScala.foreach { | |
case (path, pathItem) => | |
pathItem.readOperationsMap().asScala.filter(_._2.getTags.contains(tag.getName)).foreach { | |
case (method, operation) => | |
// only request body parameters allowed | |
operation.getParameters shouldBe null | |
val requestBody = operation.getRequestBody | |
requestBody should not be (null) | |
val requestContent = requestBody.getContent | |
requestContent should not be (null) | |
val requestMedia = requestContent.get("application/json") | |
requestMedia should not be (null) | |
val requestSchema: Schema[_] = requestMedia.getSchema | |
requestSchema should not be (null) | |
val responses = operation.getResponses | |
responses should not be (null) | |
val examples = generators.generate(requestSchema) | |
examples.zipWithIndex.foreach { | |
case (example, index) => | |
s"$method $path #$index" in { | |
try { | |
val body = example.noSpaces | |
val uri = server.getUrl.replaceAll("/$", "") + path | |
logger.info(s"$method $uri $body") | |
val request: HttpUriRequest = method match { | |
case PathItem.HttpMethod.POST => | |
val request = new HttpPost(uri) | |
request.setHeader("Content-Type", "application/json") | |
request.setHeader("Accept-Encoding", "") | |
request.setEntity(new StringEntity(example.noSpaces)) | |
request | |
case _ => fail("unexpected http method") | |
} | |
logger.info(s"send request") | |
val httpResponse = http.execute(request) | |
logger.info(s"check status code: ${httpResponse.getStatusLine.getStatusCode}") | |
Option(responses.get(s"${httpResponse.getStatusLine.getStatusCode}")) | |
.orElse(Option(responses.getDefault)) | |
.foreach { response => | |
val responseContent = response.getContent | |
responseContent should not be (null) | |
val responseMedia = responseContent.get("application/json") | |
responseMedia should not be (null) | |
val responseSchema: Schema[_] = responseMedia.getSchema | |
responseSchema should not be (null) | |
logger.info(s"read contents") | |
val responseBody = Source.fromInputStream(httpResponse.getEntity.getContent).mkString | |
logger.info(s"response: $responseBody") | |
parse(responseBody) match { | |
case Left(e) => fail(e) | |
case Right(json) => validate(json, responseSchema) | |
} | |
} | |
} finally { | |
logger.info(s"complete") | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment