├── .gitignore ├── src ├── test │ ├── resources │ │ └── com │ │ │ └── kennycason │ │ │ └── struktural │ │ │ ├── json │ │ │ └── person_sample_response.json │ │ │ └── yaml │ │ │ ├── resource_structure_test.yml │ │ │ ├── resource_types_test.yml │ │ │ ├── resource_values_test.yml │ │ │ ├── url_sample_test.yml │ │ │ ├── url_sample2_test.yml │ │ │ ├── resource_all_test.yml │ │ │ └── sample_test.yml │ └── kotlin │ │ └── com │ │ └── kennycason │ │ └── struktural │ │ ├── yaml │ │ └── YamlBackedValidatorTest.kt │ │ ├── JsonStructureValidatorTest.kt │ │ ├── JsonTypeAssertionTest.kt │ │ ├── JsonValueValidatorTest.kt │ │ └── JsonTypeValidatorTest.kt └── main │ └── kotlin │ └── com │ └── kennycason │ └── struktural │ ├── yaml │ ├── Config.kt │ ├── transform │ │ ├── ValueTransform.kt │ │ ├── IdentityValueTransform.kt │ │ ├── TypeValueTransform.kt │ │ └── ExpectsMapTransform.kt │ ├── TestCase.kt │ ├── YamlBackedValidator.kt │ └── TestsParser.kt │ ├── data │ ├── web │ │ ├── HttpMethod.kt │ │ ├── Request.kt │ │ ├── HttpJsonLoader.kt │ │ └── HttpRequests.kt │ ├── JsonLoader.kt │ ├── FileJsonLoader.kt │ └── InputStreamJsonLoader.kt │ ├── json │ ├── Nullable.kt │ ├── JsonNodeTypeValidator.kt │ └── JsonNodeValueValidator.kt │ ├── exception │ ├── StructuralException.kt │ └── InvalidInputException.kt │ ├── ValidationResult.kt │ ├── error │ └── Error.kt │ ├── Mode.kt │ ├── Structural.kt │ ├── JsonStructureValidator.kt │ ├── JsonValueValidator.kt │ ├── JsonTypeValidator.kt │ └── JsonTypeAssertion.kt ├── pom.xml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.DS_store 3 | .classpath 4 | .project 5 | .metadata 6 | .settings 7 | .settings/* 8 | bin 9 | /target 10 | *.log 11 | .idea/* 12 | *.iml 13 | dependency-reduced-pom.xml 14 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/json/person_sample_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kenny", 3 | "age": 30, 4 | "job": { 5 | "id": 123456, 6 | "title": "Software Engineer" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/Config.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml 2 | 3 | /** 4 | * Created by kenny on 5/25/17. 5 | */ 6 | data class Config( 7 | val baseUrl: String = "", 8 | val port: Int = 0 9 | ) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/transform/ValueTransform.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml.transform 2 | 3 | /** 4 | * Created by kenny on 5/26/17. 5 | */ 6 | interface ValueTransform { 7 | fun transform(value: Any): Any 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/web/HttpMethod.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data.web 2 | 3 | /** 4 | * Created by kenny on 5/29/17. 5 | */ 6 | enum class HttpMethod { 7 | GET, 8 | POST, 9 | PATCH, 10 | DELETE 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/json/Nullable.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.json 2 | 3 | import kotlin.reflect.KClass 4 | 5 | /** 6 | * Created by kenny on 5/30/17. 7 | */ 8 | data class Nullable( 9 | val clazz: KClass<*> 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/exception/StructuralException.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.exception 2 | 3 | /** 4 | * The root Exception for the Struktural library. 5 | */ 6 | open class StrukturalException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/JsonLoader.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | 5 | /** 6 | * Created by kenny on 5/25/17. 7 | */ 8 | interface JsonLoader { 9 | fun load(): JsonNode 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/transform/IdentityValueTransform.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml.transform 2 | 3 | /** 4 | * Created by kenny on 5/26/17. 5 | */ 6 | class IdentityValueTransform : ValueTransform { 7 | override fun transform(value: Any) = value 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/ValidationResult.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.kennycason.struktural.error.Error 4 | 5 | /** 6 | * Created by kenny on 5/24/17. 7 | */ 8 | data class ValidationResult( 9 | val valid: Boolean, 10 | val errors: List 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/exception/InvalidInputException.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.exception 2 | 3 | /** 4 | * Functionally equivalent to IllegalArgumentException. 5 | * 6 | * Created to make exception handling more convenient. 7 | */ 8 | class InvalidInputException(message: String) : StrukturalException(message) -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/resource_structure_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tests: 3 | - 4 | mode: structure 5 | data: 6 | resource: /com/kennycason/struktural/json/person_sample_response.json 7 | 8 | expects: 9 | - name 10 | - age 11 | - job: 12 | - id 13 | - title 14 | 15 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/resource_types_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tests: 3 | - 4 | mode: type 5 | data: 6 | resource: /com/kennycason/struktural/json/person_sample_response.json 7 | 8 | expects: 9 | - name: string 10 | - age: int 11 | - job: 12 | id: int 13 | title: string 14 | 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/error/Error.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.error 2 | 3 | import com.kennycason.struktural.Mode 4 | 5 | /** 6 | * Created by kenny on 5/24/17. 7 | */ 8 | data class Error( 9 | val mode: Mode, 10 | val message: String 11 | ) { 12 | override fun toString() = "Error in test. Mode: $mode, Reason: $message" 13 | } 14 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/resource_values_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tests: 3 | - 4 | mode: value 5 | data: 6 | resource: /com/kennycason/struktural/json/person_sample_response.json 7 | 8 | expects: 9 | - name: Kenny 10 | - age: 30 11 | - job: 12 | id: 123456 13 | title: Software Engineer 14 | 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/TestCase.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml 2 | 3 | import com.kennycason.struktural.Mode 4 | import com.kennycason.struktural.data.JsonLoader 5 | 6 | /** 7 | * Created by kenny on 5/25/17. 8 | */ 9 | data class TestCase( 10 | val config: Config, 11 | val mode: Mode, 12 | val jsonLoader: JsonLoader, 13 | val expects: Iterable 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/Mode.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | /** 4 | * Created by kenny on 5/25/17. 5 | */ 6 | enum class Mode { 7 | /** 8 | * The field is missing in the json block 9 | */ 10 | STRUCTURE, 11 | /** 12 | * The asserted type of the field is different. 13 | */ 14 | TYPE, 15 | /** 16 | * The asserted value does not Equal. 17 | */ 18 | VALUE 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/FileJsonLoader.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import java.io.File 5 | 6 | /** 7 | * Created by kenny on 5/25/17. 8 | */ 9 | class FileJsonLoader( 10 | private val file: File, 11 | private val objectMapper: ObjectMapper = ObjectMapper() 12 | ) : JsonLoader { 13 | 14 | override fun load() = objectMapper.readTree(file)!! 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/web/Request.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data.web 2 | 3 | import org.apache.http.Header 4 | 5 | /** 6 | * Created by kenny on 5/25/17. 7 | */ 8 | data class Request( 9 | val uri: String, // may be path or full uri. Depends on whether base_url is set or not. 10 | val method: HttpMethod = HttpMethod.GET, 11 | val parameters: List = listOf(), 12 | val body: String? = null, 13 | val headers: List
= listOf() 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/InputStreamJsonLoader.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | 6 | import java.io.InputStream 7 | 8 | /** 9 | * Created by kenny on 5/25/17. 10 | */ 11 | class InputStreamJsonLoader(private val inputStream: InputStream) : JsonLoader { 12 | private val objectMapper = ObjectMapper() 13 | 14 | override fun load(): JsonNode { 15 | return objectMapper.readTree(inputStream)!! 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/url_sample_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | config: 3 | base_url: http://langley.prod.pdx.intsm.net 4 | port: 8080 5 | 6 | tests: 7 | - 8 | mode: type 9 | data: 10 | request: 11 | uri: /detection 12 | method: POST 13 | body: '{"items":[{"id":"1","text":"I am a happy person"}]}' 14 | params: 15 | 16 | headers: 17 | - 'Content-Type: application/json' 18 | 19 | expects: 20 | - items: 21 | id: string 22 | language: 23 | name: string 24 | code: string 25 | score: int 26 | is_reliable: bool 27 | 28 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/url_sample2_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | tests: 4 | - 5 | mode: type 6 | data: 7 | request: 8 | uri: https://api.apisrus.com/labels 9 | method: GET 10 | params: 11 | - 'include_inactive=true' 12 | headers: 13 | - 'Authorization: Bearer ' 14 | - 'Content-Type: application/json' 15 | 16 | expects: 17 | - data: 18 | type: string 19 | id: string 20 | attributes: 21 | account_id: string 22 | name: string 23 | color: string 24 | created_at: string 25 | created_by: string 26 | updated_at: string 27 | updated_by: string 28 | active: bool 29 | 30 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/transform/TypeValueTransform.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml.transform 2 | 3 | import com.kennycason.struktural.exception.InvalidInputException 4 | 5 | /** 6 | * Created by kenny on 5/26/17. 7 | */ 8 | class TypeValueTransform : ValueTransform { 9 | 10 | override fun transform(value: Any) = when (value) { 11 | "string" -> String::class 12 | "int" -> Int::class 13 | "long" -> Long::class 14 | "float" -> Float::class 15 | "double" -> Double::class 16 | "number" -> Number::class 17 | "bool" -> Boolean::class 18 | "object" -> Any::class 19 | else -> throw InvalidInputException("Invalid value for type. Must be one of string,int,long,float,double,number,bool,object") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/resource_all_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | tests: 3 | # - 4 | # mode: structure 5 | # data: 6 | # resource: /com/kennycason/struktural/json/person_sample_response.json 7 | # 8 | # expects: 9 | # - name 10 | # - age 11 | # - job: 12 | # id 13 | # title 14 | 15 | - 16 | mode: type 17 | data: 18 | resource: /com/kennycason/struktural/json/person_sample_response.json 19 | 20 | expects: 21 | - name: string 22 | - age: int 23 | - job: 24 | id: int 25 | title: string 26 | 27 | - 28 | mode: value 29 | data: 30 | resource: /com/kennycason/struktural/json/person_sample_response.json 31 | 32 | expects: 33 | - name: Kenny 34 | - age: 30 35 | - job: 36 | id: 123456 37 | title: Software Engineer -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/web/HttpJsonLoader.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data.web 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.kennycason.struktural.data.JsonLoader 5 | import com.kennycason.struktural.yaml.Config 6 | import java.net.URI 7 | 8 | /** 9 | * Created by kenny on 5/25/17. 10 | */ 11 | class HttpJsonLoader( 12 | val config: Config = Config(), 13 | val request: Request 14 | ) : JsonLoader { 15 | override fun load(): JsonNode { 16 | val httpRequests = HttpRequests() 17 | val headers = request.headers.toTypedArray() 18 | 19 | return when (request.method) { 20 | HttpMethod.GET -> httpRequests.get(buildUri(request), headers) 21 | HttpMethod.POST -> httpRequests.post(buildUri(request), headers, request.body) 22 | HttpMethod.PATCH -> httpRequests.patch(buildUri(request), headers, request.body) 23 | HttpMethod.DELETE -> httpRequests.delete(buildUri(request), headers) 24 | } 25 | } 26 | 27 | private fun buildUri(request: Request): URI { 28 | val parameters = if (request.parameters.isEmpty()) { "" } 29 | else { "?" + request.parameters.joinToString("&") } 30 | return URI(buildBaseURI() + request.uri + parameters) 31 | } 32 | 33 | private fun buildBaseURI(): String { 34 | if (config.port == 0) { 35 | return config.baseUrl 36 | } 37 | return config.baseUrl + ':' + config.port 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/resources/com/kennycason/struktural/yaml/sample_test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # config block provides section for global configs 3 | config: 4 | # base_url is an optional field to remove some verbosity when testing apis. 5 | # it is prepended to data.request.uri if set. 6 | base_url: https://api.foobar.com 7 | port: 8080 8 | 9 | tests: 10 | - # array of tests 11 | # pick one of three modes for testing 12 | # structure = assert fields not missing 13 | # type = assert fields not missing and field types 14 | # value = assert fields not missing and field values 15 | mode: structure | type | value 16 | # the data block provides methods for providing data 17 | data: 18 | # 1. configuration for url requests 19 | request: 20 | uri: /v2/foo/bar 21 | method: POST 22 | body: '{"foo":"bar"}' 23 | params: 24 | - 'field=value' 25 | - 'field2=value2' 26 | headers: 27 | - 'Authorization: key' 28 | 29 | # 2. configuration for loading json from resource, great for unit tests 30 | resource: /path/to/resource/food.json 31 | # 3. configuration for loading file from file system 32 | file: /path/to/file.json 33 | 34 | expects: 35 | # example for mode: structure 36 | # - name 37 | # - age 38 | # - job: 39 | # - id 40 | # - title 41 | 42 | # example for mode: types 43 | # - name: string 44 | # - age: int 45 | # - job: 46 | # id: int 47 | # title: string 48 | 49 | # example for mode: values 50 | - name: kenny 51 | - age: 30 52 | - job: 53 | id: 123456 54 | title: Software Engineer 55 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/json/JsonNodeTypeValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.json 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.node.JsonNodeType 5 | import kotlin.reflect.KClass 6 | 7 | /** 8 | * Created by kenny on 5/24/17. 9 | */ 10 | class JsonNodeTypeValidator { 11 | 12 | fun validate(jsonNode: JsonNode, type: KClass<*>): Boolean { 13 | val jsonNodeType = jsonNode.nodeType!! 14 | when (jsonNodeType) { 15 | JsonNodeType.ARRAY -> { 16 | if (type == Array::class) { return true } 17 | } 18 | JsonNodeType.BOOLEAN -> { 19 | if (type == Boolean::class) { return true } 20 | } 21 | JsonNodeType.NUMBER -> { 22 | // simple checks 23 | if (type == Number::class) { return true } 24 | if (jsonNode.isInt && type == Int::class) { return true } 25 | if (jsonNode.isLong && type == Long::class) { return true } 26 | if (jsonNode.isDouble && type == Double::class) { return true } 27 | 28 | // Jackson can't seem to default to double. 29 | // I guess that whether it's a double or float is not relevant. 30 | // TODO consider stronger assertions/checks 31 | if ((jsonNode.isDouble || jsonNode.isDouble) 32 | && type == Float::class) { return true } 33 | 34 | } 35 | JsonNodeType.OBJECT -> { 36 | if (type == Any::class) { return true } 37 | } 38 | JsonNodeType.STRING, 39 | JsonNodeType.BINARY-> { 40 | if (type == String::class) { return true } 41 | } 42 | JsonNodeType.POJO, 43 | JsonNodeType.MISSING, 44 | JsonNodeType.NULL -> { 45 | return false 46 | // throw IllegalStateException("An illegal state occurred in Struktural. Unknown Json Node Type [$jsonNodeType] found for field") 47 | } 48 | } 49 | return false 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/kennycason/struktural/yaml/YamlBackedValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml 2 | 3 | import com.kennycason.struktural.Struktural 4 | import org.junit.Ignore 5 | import org.junit.Test 6 | 7 | /** 8 | * Created by kenny on 5/25/17. 9 | */ 10 | class YamlBackedValidatorTest { 11 | private val resourcePath = "/com/kennycason/struktural/yaml/" 12 | private val validator = YamlBackedValidator() 13 | 14 | @Test 15 | fun resourceSingleTest() { 16 | validator.assert(javaClass.getResourceAsStream("resource_structure_test.yml")) 17 | validator.assert(javaClass.getResourceAsStream("resource_types_test.yml")) 18 | validator.assert(javaClass.getResourceAsStream("resource_values_test.yml")) 19 | } 20 | 21 | @Test 22 | fun resourceManyTests() { 23 | validator.assert(javaClass.getResourceAsStream("resource_all_test.yml")) 24 | } 25 | 26 | @Ignore 27 | fun urlTest() { 28 | validator.assert(javaClass.getResourceAsStream("url_sample_test.yml")) 29 | validator.assert(javaClass.getResourceAsStream("url_sample2_test.yml")) 30 | } 31 | 32 | @Test 33 | fun strukturalInputStreamHelper() { 34 | Struktural.assertYaml(javaClass.getResourceAsStream("resource_structure_test.yml")) 35 | Struktural.assertYaml(javaClass.getResourceAsStream("resource_types_test.yml")) 36 | Struktural.assertYaml(javaClass.getResourceAsStream("resource_values_test.yml")) 37 | Struktural.assertYaml(javaClass.getResourceAsStream("resource_all_test.yml")) 38 | } 39 | 40 | @Test 41 | fun strukturalRawStringHelper() { 42 | val yaml = """ 43 | |--- 44 | |tests: 45 | | - 46 | | mode: type 47 | | data: 48 | | resource: /com/kennycason/struktural/json/person_sample_response.json 49 | | 50 | | expects: 51 | | - name: string 52 | | - age: int 53 | | - job: 54 | | id: int 55 | | title: string 56 | """.trimMargin() 57 | Struktural.assertYaml(yaml) 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/transform/ExpectsMapTransform.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml.transform 2 | 3 | import com.kennycason.struktural.Mode 4 | 5 | /** 6 | * This class transforms data from: 7 | * List> to (User friendly) List> 8 | * 9 | * This is used because the Yaml to Map loader defaults to this format, 10 | * however Struktural provides a different interface 11 | * 12 | * TODO this model interface needs to be cleaned up. 13 | */ 14 | class ExpectsMapTransform(mode: Mode) { 15 | private val valueTransform = selectValueTransform(mode) 16 | 17 | // for use in "structure" mode 18 | fun transformToAny(from: Iterable): Iterable { 19 | val transformed = mutableListOf() 20 | from.forEach { item -> 21 | if (item is String) { 22 | transformed.add(item) 23 | } else if (item is Map<*, *>) { 24 | item.entries.forEach { entry -> 25 | transformed.add(Pair(entry.key, transformToAny(entry.value as Iterable))) 26 | } 27 | } 28 | } 29 | return transformed 30 | } 31 | 32 | // for use in "type" and "value" mode 33 | fun transformToPairs(from: Iterable>): Iterable> { 34 | val transformed = mutableListOf>() 35 | from.forEach { item -> 36 | transformed.addAll(transformToPairs(item)) 37 | } 38 | return transformed 39 | } 40 | 41 | private fun transformToPairs(from: Map): Iterable> { 42 | val map = mutableListOf>() 43 | from.entries.forEach { entry -> 44 | if (entry.value is Map<*, *>) { 45 | map.add(Pair(entry.key, transformToPairs(entry.value as Map))) 46 | } else { 47 | map.add(Pair(entry.key, valueTransform.transform(entry.value))) 48 | } 49 | } 50 | return map 51 | } 52 | 53 | private fun selectValueTransform(mode: Mode) = when (mode) { 54 | Mode.TYPE -> TypeValueTransform() 55 | else -> IdentityValueTransform() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/data/web/HttpRequests.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.data.web 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import org.apache.http.Header 6 | import org.apache.http.client.methods.HttpDelete 7 | import org.apache.http.client.methods.HttpGet 8 | import org.apache.http.client.methods.HttpPatch 9 | import org.apache.http.client.methods.HttpPost 10 | import org.apache.http.entity.StringEntity 11 | import org.apache.http.impl.client.HttpClientBuilder 12 | import java.net.URI 13 | 14 | /** 15 | * Created by kenny on 5/25/17. 16 | */ 17 | class HttpRequests() { 18 | private val objectMapper = ObjectMapper() 19 | 20 | fun get(uri: URI, 21 | headers: Array
): JsonNode { 22 | val request = HttpGet(uri) 23 | request.setHeaders(headers) 24 | 25 | println("GET $uri") 26 | return objectMapper.readTree( 27 | HttpClientBuilder.create() 28 | .build() 29 | .execute(request) 30 | .entity.content 31 | ) 32 | } 33 | 34 | fun post(uri: URI, 35 | headers: Array
, 36 | data: String?): JsonNode { 37 | val request = HttpPost(uri) 38 | request.setHeaders(headers) 39 | if (data != null) { 40 | request.entity = StringEntity(data) 41 | } 42 | 43 | println("POST $uri") 44 | return objectMapper.readTree( 45 | HttpClientBuilder.create() 46 | .build() 47 | .execute(request) 48 | .entity.content 49 | ) 50 | } 51 | 52 | fun patch(uri: URI, 53 | headers: Array
, 54 | data: String?): JsonNode { 55 | val request = HttpPatch(uri) 56 | request.setHeaders(headers) 57 | if (data != null) { 58 | request.entity = StringEntity(data) 59 | } 60 | 61 | println("PATCH $uri") 62 | return objectMapper.readTree( 63 | HttpClientBuilder.create() 64 | .build() 65 | .execute(request) 66 | .entity.content 67 | ) 68 | } 69 | 70 | fun delete(uri: URI, 71 | headers: Array
): JsonNode { 72 | val request = HttpDelete(uri) 73 | request.setHeaders(headers) 74 | 75 | println("DELETE $uri") 76 | return objectMapper.readTree( 77 | HttpClientBuilder.create() 78 | .build() 79 | .execute(request) 80 | .entity.content 81 | ) 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/Structural.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.kennycason.struktural.yaml.YamlBackedValidator 6 | import java.io.InputStream 7 | 8 | /** 9 | * A transform class for using library 10 | */ 11 | object Struktural { 12 | private val jsonMissingValidator = JsonStructureValidator() 13 | private val jsonTypeValidator = JsonTypeValidator() 14 | private val jsonValueValidator = JsonValueValidator() 15 | private val yamlBackedValidator = YamlBackedValidator() 16 | private var defaultObjectMapper = ObjectMapper() 17 | 18 | fun setObjectMapper(objectMapper: ObjectMapper) { 19 | defaultObjectMapper = objectMapper 20 | jsonMissingValidator.setObjectMapper(objectMapper) 21 | jsonTypeValidator.setObjectMapper(objectMapper) 22 | jsonValueValidator.setObjectMapper(objectMapper) 23 | yamlBackedValidator.setObjectMapper(objectMapper) 24 | } 25 | 26 | fun getObjectMapper() = defaultObjectMapper 27 | 28 | fun assertStructure(jsonString: String, fields: Iterable) = jsonMissingValidator.assert(jsonString, fields) 29 | fun assertStructure(json: JsonNode, fields: Iterable) = jsonMissingValidator.assert(json, fields) 30 | fun validateStructure(jsonString: String, fields: Iterable) = jsonMissingValidator.validate(jsonString, fields) 31 | fun validateStructure(json: JsonNode, fields: Iterable) = jsonMissingValidator.validate(json, fields) 32 | 33 | fun assertTypes(jsonString: String, fields: Iterable>) = jsonTypeValidator.assert(jsonString, fields) 34 | fun assertTypes(json: JsonNode, fields: Iterable>) = jsonTypeValidator.assert(json, fields) 35 | fun validateTypes(jsonString: String, fields: Iterable>) = jsonTypeValidator.validate(jsonString, fields) 36 | fun validateTypes(json: JsonNode, fields: Iterable>) = jsonTypeValidator.validate(json, fields) 37 | 38 | fun assertValues(jsonString: String, fields: Iterable>) = jsonValueValidator.assert(jsonString, fields) 39 | fun assertValues(json: JsonNode, fields: Iterable>) = jsonValueValidator.assert(json, fields) 40 | fun validateValues(jsonString: String, fields: Iterable>) = jsonValueValidator.validate(jsonString, fields) 41 | fun validateValues(json: JsonNode, fields: Iterable>) = jsonValueValidator.validate(json, fields) 42 | 43 | fun assertYaml(yamlString: String) = yamlBackedValidator.assert(yamlString) 44 | fun assertYaml(yamlInputStream: InputStream) = yamlBackedValidator.assert(yamlInputStream) 45 | fun validateYaml(yamlString: String) = yamlBackedValidator.validate(yamlString) 46 | fun validateYaml(yamlInputStream: InputStream) = yamlBackedValidator.validate(yamlInputStream) 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/com/kennycason/struktural/JsonStructureValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.kennycason.struktural.exception.StrukturalException 4 | import org.junit.Test 5 | 6 | /** 7 | * Created by kenny on 5/23/17. 8 | */ 9 | class JsonStructureValidatorTest { 10 | private val validator = JsonStructureValidator() 11 | 12 | @Test 13 | fun emptyTest() { 14 | validator.assert("{}", emptyList()) 15 | } 16 | 17 | @Test(expected = StrukturalException::class) 18 | fun emptyJson() { 19 | validator.assert("{}", listOf("foo")) 20 | } 21 | 22 | @Test 23 | fun singleField() { 24 | validator.assert("""{"foo": "bar"}""", listOf("foo")) 25 | } 26 | 27 | @Test 28 | fun singleNestedField() { 29 | val json = """ 30 | { 31 | "foo": "bar", 32 | "nested": {"foo2": "bar2"} 33 | } 34 | """ 35 | validator.assert(json, 36 | listOf("foo", 37 | "nested" to listOf("foo2") 38 | )) 39 | } 40 | 41 | @Test 42 | fun complexNestedField() { 43 | val json = """ 44 | { 45 | "name": "kenny", 46 | "age": 64, 47 | "job": { 48 | "id": 123456, 49 | "title": "Software Engineer" 50 | } 51 | } 52 | """ 53 | validator.assert(json, 54 | listOf("name", 55 | "age", 56 | "job" to listOf("id", 57 | "title") 58 | )) 59 | } 60 | 61 | @Test 62 | fun nestedArrayField() { 63 | val json = """ 64 | { 65 | "id": "sample", 66 | "numbers": [1,2,3,4,5,6] 67 | } 68 | """ 69 | validator.assert(json, 70 | listOf("id", 71 | "numbers")) 72 | } 73 | 74 | @Test(expected = StrukturalException::class) 75 | fun accessNestedArrayField() { 76 | val json = """ 77 | { 78 | "id": "sample", 79 | "numbers": [1,2,3,4,5,6] 80 | } 81 | """ 82 | validator.assert(json, 83 | listOf("id", 84 | "numbers" to listOf("does_not_exist") 85 | )) 86 | } 87 | 88 | @Test 89 | fun nestedArrayObject() { 90 | val json = """ 91 | { 92 | "languages": [ 93 | { 94 | "name": "kotlin", 95 | "coolness": 100 96 | }, 97 | { 98 | "name": "java", 99 | "coolness": 50 100 | }, 101 | { 102 | "name": "javascript", 103 | "coolness": 25 104 | } 105 | ] 106 | } 107 | """ 108 | validator.assert(json, 109 | listOf( 110 | "languages" to listOf("name", 111 | "coolness") 112 | )) 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/YamlBackedValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 6 | import com.kennycason.struktural.Mode 7 | import com.kennycason.struktural.Struktural 8 | import com.kennycason.struktural.ValidationResult 9 | import com.kennycason.struktural.error.Error 10 | import com.kennycason.struktural.exception.StrukturalException 11 | import java.io.InputStream 12 | 13 | /** 14 | * Created by kenny on 5/25/17. 15 | */ 16 | class YamlBackedValidator { 17 | private var yamlObjectMapper = ObjectMapper(YAMLFactory()) 18 | private val testsParser = TestsParser() 19 | 20 | fun setObjectMapper(objectMapper: ObjectMapper) { 21 | this.yamlObjectMapper = ObjectMapper(YAMLFactory()) 22 | } 23 | 24 | fun assert(yamlInputStream: InputStream) { 25 | val validationResult = validate(yamlInputStream) 26 | if (!validationResult.valid) { 27 | throw StrukturalException(validationResult.errors.joinToString("\n")) 28 | } 29 | } 30 | 31 | fun assert(yamlString: String) { 32 | val validationResult = validate(yamlString) 33 | if (!validationResult.valid) { 34 | throw StrukturalException(validationResult.errors.joinToString("\n")) 35 | } 36 | } 37 | 38 | fun validate(yamlString: String): ValidationResult { 39 | val testModel: Map = yamlObjectMapper.readValue(yamlString, object : TypeReference>() {}) 40 | return validate(testModel) 41 | } 42 | 43 | fun validate(yamlInputStream: InputStream): ValidationResult { 44 | val testModel: Map = yamlObjectMapper.readValue(yamlInputStream, object : TypeReference>() {}) 45 | return validate(testModel) 46 | } 47 | 48 | private fun validate(testModel: Map): ValidationResult { 49 | val config = parseConfig(testModel) 50 | val tests = testsParser.parse(testModel, config) 51 | 52 | val errors = mutableListOf() 53 | tests.forEach { test -> 54 | val response = test.jsonLoader.load() 55 | when (test.mode) { 56 | Mode.STRUCTURE -> { 57 | val result = Struktural.validateStructure(response, test.expects) 58 | if (!result.valid) { 59 | errors.addAll(result.errors) 60 | } 61 | } 62 | 63 | Mode.TYPE -> { 64 | val result = Struktural.validateTypes(response, test.expects as Iterable>) 65 | if (!result.valid) { 66 | errors.addAll(result.errors) 67 | } 68 | } 69 | 70 | Mode.VALUE -> { 71 | val result = Struktural.validateValues(response, test.expects as Iterable>) 72 | if (!result.valid) { 73 | errors.addAll(result.errors) 74 | } 75 | } 76 | } 77 | } 78 | return ValidationResult(errors.isEmpty(), errors) 79 | } 80 | 81 | private fun parseConfig(testModel: Map): Config { 82 | if (!testModel.contains("config")) { 83 | return Config() 84 | } 85 | val configMap = testModel.get("config") as Map 86 | return Config( 87 | baseUrl = if (configMap.contains("base_url")) { 88 | configMap.get("base_url") as String 89 | } else { 90 | "" 91 | }, 92 | port = if (configMap.contains("port")) { 93 | configMap.get("port") as Int 94 | } else { 95 | 0 96 | } 97 | ) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/json/JsonNodeValueValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.json 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.node.JsonNodeType 5 | import com.kennycason.struktural.exception.StrukturalException 6 | import org.hamcrest.Matcher 7 | 8 | /** 9 | * Created by kenny on 5/24/17. 10 | */ 11 | class JsonNodeValueValidator { 12 | 13 | fun validate(jsonNode: JsonNode, value: Any): Boolean { 14 | if (value is Matcher<*>) { 15 | return validateMatcher(jsonNode, value) 16 | } 17 | 18 | val jsonNodeType = jsonNode.nodeType!! 19 | when (jsonNodeType) { 20 | JsonNodeType.ARRAY -> { 21 | if (value is Array<*>) { 22 | if (value.size != jsonNode.size()) { return false } 23 | value.forEachIndexed { i, v -> 24 | // recur since this function already handles node value comparison 25 | if (!validate(jsonNode.get(i), v!!)) { return false } 26 | } 27 | return true 28 | } 29 | } 30 | JsonNodeType.BOOLEAN -> { 31 | if (value is Boolean) { 32 | return value == jsonNode.asBoolean() 33 | } 34 | } 35 | JsonNodeType.NUMBER -> { 36 | if (value is Number) { 37 | if (jsonNode.isInt) { 38 | return value == jsonNode.intValue() 39 | } 40 | if (jsonNode.isLong) { 41 | return value == jsonNode.longValue() 42 | } 43 | if (jsonNode.isFloat) { 44 | return value == jsonNode.floatValue() 45 | } 46 | if (jsonNode.isDouble) { 47 | return value == jsonNode.doubleValue() 48 | } 49 | } 50 | } 51 | JsonNodeType.OBJECT -> { 52 | // TODO consider supporting this 53 | throw StrukturalException("Can not test equality for Json Objects") 54 | } 55 | JsonNodeType.STRING, 56 | JsonNodeType.BINARY -> { 57 | if (value is String) { 58 | return jsonNode.asText() == value 59 | } 60 | } 61 | JsonNodeType.POJO, 62 | JsonNodeType.MISSING, 63 | JsonNodeType.NULL -> { 64 | return false 65 | // throw IllegalStateException("An illegal state occurred in Struktural. Unknown Json Node Type. Found $jsonNodeType") 66 | } 67 | } 68 | return false 69 | } 70 | 71 | private fun validateMatcher(jsonNode: JsonNode, matcher: Matcher<*>): Boolean { 72 | return matcher.matches(extractValue(jsonNode)) 73 | } 74 | 75 | private fun extractValue(jsonNode: JsonNode): Any? = 76 | when (jsonNode.nodeType!!) { 77 | JsonNodeType.ARRAY -> throw StrukturalException("Can not use matchers to test equality for Json Arrays") 78 | JsonNodeType.BOOLEAN -> jsonNode.asBoolean() 79 | JsonNodeType.NUMBER -> 80 | if (jsonNode.isInt) { jsonNode.intValue() } 81 | else if (jsonNode.isLong) { jsonNode.longValue() } 82 | else if (jsonNode.isFloat) { jsonNode.floatValue() } 83 | else if (jsonNode.isDouble) { jsonNode.doubleValue() } 84 | else { throw StrukturalException("Failed to parse Json Number as an Int, Short, Float, or Double") } 85 | JsonNodeType.OBJECT -> throw StrukturalException("Can not use matchers to test equality for Json Objects") 86 | JsonNodeType.STRING, 87 | JsonNodeType.BINARY -> jsonNode.asText() 88 | JsonNodeType.POJO, 89 | JsonNodeType.MISSING, 90 | JsonNodeType.NULL -> null 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/JsonStructureValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 6 | import com.kennycason.struktural.error.Error 7 | import com.kennycason.struktural.exception.InvalidInputException 8 | import com.kennycason.struktural.exception.StrukturalException 9 | 10 | /** 11 | * Test fields not missing 12 | */ 13 | class JsonStructureValidator { 14 | private var objectMapper: ObjectMapper = ObjectMapper() 15 | 16 | fun setObjectMapper(objectMapper: ObjectMapper) { 17 | this.objectMapper = objectMapper 18 | } 19 | 20 | fun assert(jsonString: String, fields: Iterable) = assert(objectMapper.readTree(jsonString), fields) 21 | 22 | fun assert(json: JsonNode, fields: Iterable) { 23 | val result = validate(json, fields) 24 | if (result.valid) { return } 25 | throw StrukturalException(result.errors.joinToString("\n")) 26 | } 27 | 28 | fun validate(jsonString: String, fields: Iterable) = validate(objectMapper.readTree(jsonString), fields) 29 | 30 | fun validate(json: JsonNode, fields: Iterable): ValidationResult { 31 | // keep track of all errors 32 | val errors = mutableListOf() 33 | // keep track of the path for logging convenience 34 | val path = "" 35 | walkFields(json, fields, path, errors) 36 | 37 | return ValidationResult(errors.isEmpty(), errors) 38 | } 39 | 40 | private fun walkFields(json: JsonNode, fields: Iterable, path: String, errors: MutableList) { 41 | fields.forEach { field -> 42 | if (field is String) { 43 | if (!json.has(field)) { 44 | errors.add(Error(Mode.STRUCTURE, "Field [${normalizeFieldPath(path, field)}] missing.")) 45 | } 46 | 47 | } else if (field is Pair<*, *>) { 48 | // nest object, validate and recur 49 | validateNestedField(field) 50 | val fieldName = field.first!! as String 51 | val nestedFields = field.second as Iterable<*> 52 | if (!json.has(fieldName)) { 53 | errors.add(Error(Mode.STRUCTURE, "Field [${normalizeFieldPath(path, fieldName)}] missing.")) 54 | return@forEach 55 | } 56 | 57 | val nestedJsonNode = json.get(fieldName) 58 | if (nestedJsonNode.isArray) { // walk over each item in the array and apply the nested checks 59 | nestedJsonNode.forEach { node -> 60 | walkFields(node, nestedFields.requireNoNulls(), "$path/$fieldName", errors) 61 | } 62 | } else { // is nested object, recur 63 | walkFields(nestedJsonNode, nestedFields.requireNoNulls(), "$path/$fieldName", errors) 64 | } 65 | } else { 66 | throw InvalidInputException("Input must either be a String field name, or a Iterable of fields. " + 67 | "Found [${field::class.simpleName?.lowercase()}]") 68 | } 69 | } 70 | } 71 | 72 | private fun validateNestedField(field: Pair<*, *>) { 73 | if (field.first == null) { 74 | throw InvalidInputException("First value for nested input must be a String. Found null") 75 | } 76 | if (field.second == null) { 77 | throw InvalidInputException("Second value for nested input must be a Iterable. Found null") 78 | } 79 | // test structure of Pair 80 | if (field.first !is String) { 81 | throw InvalidInputException("First value for nested input must be a String. Found [${field.first!!::class.simpleName?.lowercase()}]") 82 | } 83 | if (field.second !is Iterable<*>) { 84 | throw InvalidInputException("First value for nested input must be a Iterable. Found [${field.second!!::class.simpleName?.lowercase()}]") 85 | } 86 | } 87 | 88 | private fun normalizeFieldPath(path: String, field: String) = ("$path/$field").replace(Regex("^/"), "") 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/test/kotlin/com/kennycason/struktural/JsonTypeAssertionTest.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import org.junit.Test 4 | 5 | class JsonTypeAssertionTest { 6 | 7 | @Test 8 | fun `assert complex Super Metroid json`() { 9 | val json = """ 10 | { 11 | "game_title": "Super Metroid", 12 | "release_year": 1994, 13 | "characters": [ 14 | { 15 | "name": "Samus Aran", 16 | "abilities": ["Morph Ball", "Missiles", "Power Bombs"], 17 | "health": 99, 18 | "weapon_power": 100.5 19 | }, 20 | { 21 | "name": "Ridley", 22 | "abilities": ["Fire Breath", "Tail Whip"], 23 | "health": 5000, 24 | "weapon_power": 200.0 25 | } 26 | ], 27 | "settings": { 28 | "planet": "Zebes", 29 | "areas": [ 30 | { 31 | "name": "Brinstar", 32 | "environment": "Jungle" 33 | }, 34 | { 35 | "name": "Norfair", 36 | "environment": "Lava" 37 | } 38 | ] 39 | }, 40 | "game_modes": ["SINGLE_PLAYER"], 41 | "game_mode": "SINGLE_PLAYER", 42 | "is_remake": null 43 | } 44 | """ 45 | 46 | assertJsonTypes(json) { 47 | string("game_title") 48 | integer("release_year") 49 | nullableBoolean("is_remake") 50 | 51 | array("characters") { 52 | string("name") 53 | array("abilities") { 54 | string("") 55 | } 56 | integer("health") 57 | decimal("weapon_power") 58 | } 59 | 60 | objectField("settings") { 61 | string("planet") 62 | array("areas") { 63 | string("name") 64 | string("environment") 65 | } 66 | } 67 | 68 | array("game_modes") { } 69 | 70 | custom("release_year", "a valid SNES release year") { 71 | it.isInt && it.asInt() in 1990..2000 72 | } 73 | 74 | enum("game_mode") { arrayOf("SINGLE_PLAYER", "MULTIPLAYER", "CO-OP") } 75 | } 76 | } 77 | 78 | @Test 79 | fun `assert types`() { 80 | val json = """ 81 | { 82 | "languages": [ 83 | { 84 | "name": "kotlin", 85 | "coolness": 100 86 | }, 87 | { 88 | "name": "java", 89 | "coolness": 50 90 | } 91 | ], 92 | "details": { 93 | "author": "kenny", 94 | "year": 2024 95 | } 96 | } 97 | """ 98 | 99 | assertJsonTypes(json) { 100 | array("languages") { 101 | string("name") 102 | number("coolness") 103 | } 104 | objectField("details") { 105 | string("author") 106 | number("year") 107 | } 108 | } 109 | } 110 | 111 | @Test 112 | fun `validate date fields`() { 113 | val json = """ 114 | { 115 | "game_release_date": "1994-03-19", 116 | "last_updated": "2024-12-18T15:30:00" 117 | } 118 | """ 119 | 120 | assertJsonTypes(json) { 121 | date("game_release_date", "yyyy-MM-dd") 122 | dateTime("last_updated", "yyyy-MM-dd'T'HH:mm:ss") 123 | } 124 | } 125 | 126 | @Test 127 | fun `validate regex fields`() { 128 | val json = """ 129 | { 130 | "player_name": "Samus_Aran", 131 | "player_code": "SM12345" 132 | } 133 | """ 134 | 135 | assertJsonTypes(json) { 136 | matchesRegex("player_name", "^[a-zA-Z0-9_]+$".toRegex()) // Alphanumeric with underscores 137 | matchesRegex("player_code", "^SM[0-9]{5}$".toRegex()) // Starts with 'SM' followed by 5 digits 138 | } 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/JsonValueValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 6 | import com.kennycason.struktural.error.Error 7 | import com.kennycason.struktural.exception.InvalidInputException 8 | import com.kennycason.struktural.exception.StrukturalException 9 | import com.kennycason.struktural.json.JsonNodeValueValidator 10 | 11 | 12 | class JsonValueValidator { 13 | 14 | private var objectMapper: ObjectMapper = ObjectMapper() 15 | 16 | fun setObjectMapper(objectMapper: ObjectMapper) { 17 | this.objectMapper = objectMapper 18 | } 19 | 20 | 21 | private val jsonNodeValueValidator = JsonNodeValueValidator() 22 | 23 | fun assert(jsonString: String, fieldTypes: Iterable>) = assert(objectMapper.readTree(jsonString), fieldTypes) 24 | 25 | fun assert(json: JsonNode, fieldTypes: Iterable>) { 26 | val result = validate(json, fieldTypes) 27 | if (result.valid) { return } 28 | throw StrukturalException(result.errors.joinToString("\n")) 29 | } 30 | 31 | fun validate(jsonString: String, fieldTypes: Iterable>) = assert(objectMapper.readTree(jsonString), fieldTypes) 32 | 33 | fun validate(json: JsonNode, fieldTypes: Iterable>): ValidationResult { 34 | // keep track of all errors 35 | val errors = mutableListOf() 36 | // keep track of the path for logging convenience 37 | val path = "" 38 | walkFields(json, fieldTypes, path, errors) 39 | 40 | return ValidationResult(errors.isEmpty(), errors) 41 | } 42 | 43 | private fun walkFields( 44 | json: JsonNode, 45 | fields: Iterable, 46 | path: String, 47 | errors: MutableList 48 | ) { 49 | fields.forEach { field -> 50 | // could also be a Pair> which is a nested field 51 | // could be a Pair which is a value to test equality 52 | if (field is Pair<*, *>) { 53 | // nest object, validate and recur 54 | validateNestedField(field) 55 | 56 | // set variables here so smart casting works later 57 | val fieldName = field.first!! as String 58 | val value = field.second!! 59 | 60 | if (!json.has(fieldName)) { 61 | errors.add(Error(Mode.STRUCTURE, "Field [${normalizeFieldPath(path, fieldName)}] missing.")) 62 | return@forEach 63 | } 64 | 65 | val nestedJsonNode = json.get(fieldName) 66 | if (value is Iterable<*>) { 67 | if (nestedJsonNode.isArray) { // walk over each item in the array and apply the nested checks 68 | // it is rather odd to assert values across all items in an array of objects, 69 | // but perhaps useful for asserting a subset of the fields 70 | nestedJsonNode.forEach { node -> 71 | walkFields(node, value.requireNoNulls(), "$path/$fieldName", errors) 72 | } 73 | } else { 74 | walkFields(nestedJsonNode, value.requireNoNulls(), "$path/$fieldName", errors) 75 | } 76 | 77 | } else { // is nested object, recur 78 | val jsonNode = json.get(fieldName) 79 | if (!jsonNodeValueValidator.validate(jsonNode, value)) { 80 | errors.add(Error(Mode.VALUE, "Field [${normalizeFieldPath(path, fieldName)}] value did not equal expected value:" + 81 | " [$value], actual value : [$nestedJsonNode]")) 82 | } 83 | } 84 | 85 | } else { 86 | throw InvalidInputException("Input must either be a Pair, where * can be Iterable for nested objects," + 87 | " or Any type to test equality. Found [${field::class}]") 88 | } 89 | } 90 | } 91 | 92 | private fun validateNestedField(field: Pair<*, *>) { 93 | val key = field.first 94 | val value = field.second 95 | if (key == null) { 96 | throw InvalidInputException("First value for nested input must be a String. Found null") 97 | } 98 | if (value == null) { 99 | throw InvalidInputException("Second value for nested input must be a Iterable. Found null") 100 | } 101 | // test structure of Pair 102 | if (key !is String) { 103 | throw InvalidInputException("First value for nested input must be a String. Found [${key::class.simpleName?.lowercase()}]") 104 | } 105 | // the value can be any type since it's comparing equality, so don't check 106 | } 107 | 108 | private fun normalizeFieldPath(path: String, field: String) = ("$path/$field").replace(Regex("^/"), "") 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/test/kotlin/com/kennycason/struktural/JsonValueValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.kennycason.struktural.exception.StrukturalException 4 | import org.hamcrest.Matchers 5 | import org.junit.Test 6 | 7 | /** 8 | * Created by kenny on 5/24/17. 9 | */ 10 | class JsonValueValidatorTest { 11 | private val validator = JsonValueValidator() 12 | 13 | @Test 14 | fun emptyTest() { 15 | validator.assert("{}", emptyList()) 16 | } 17 | 18 | @Test(expected = StrukturalException::class) 19 | fun emptyJson() { 20 | validator.assert("{}", listOf("foo" to 23)) 21 | } 22 | 23 | @Test 24 | fun singleField() { 25 | validator.assert("""{"foo": "bar"}""", listOf("foo" to "bar")) 26 | } 27 | 28 | @Test(expected = StrukturalException::class) 29 | fun singleFieldInvalidValue() { 30 | validator.assert("""{"foo": "bar"}""", listOf("foo" to "rab")) 31 | } 32 | 33 | @Test 34 | fun singleNestedField() { 35 | val json = """ 36 | { 37 | "foo": "bar", 38 | "nested": {"foo2": "bar2"} 39 | } 40 | """ 41 | validator.assert(json, 42 | listOf( 43 | "foo" to "bar", 44 | "nested" to listOf("foo2" to "bar2") 45 | )) 46 | } 47 | 48 | @Test 49 | fun complexNestedField() { 50 | val json = """ 51 | { 52 | "name": "kenny", 53 | "age": 64, 54 | "shoe_size": 10.5, 55 | "favorite_number": 2.718281828459045235, 56 | "long_number": 1223235345342348, 57 | "job": { 58 | "id": 123456, 59 | "title": "Software Engineer" 60 | } 61 | } 62 | """ 63 | validator.assert(json, 64 | listOf( 65 | "name" to "kenny", 66 | "age" to 64, 67 | "shoe_size" to 10.5, 68 | "favorite_number" to 2.718281828459045235, 69 | "long_number" to 1223235345342348, 70 | "job" to listOf( 71 | "id" to 123456, 72 | "title" to "Software Engineer" 73 | ) 74 | )) 75 | 76 | // only match partial 77 | validator.assert(json, 78 | listOf( 79 | "name" to "kenny", 80 | "favorite_number" to 2.718281828459045235 81 | )) 82 | } 83 | 84 | @Test 85 | fun nestedArrayField() { 86 | val json = """ 87 | { 88 | "numbers": [1,2,3,4,5,6] 89 | } 90 | """ 91 | validator.assert(json, 92 | listOf("numbers" to arrayOf(1, 2, 3, 4, 5, 6))) 93 | } 94 | 95 | @Test(expected = StrukturalException::class) 96 | fun nestedArrayFieldNotEqual() { 97 | val json = """ 98 | { 99 | "numbers": [1,2,3,4,5,6] 100 | } 101 | """ 102 | validator.assert(json, 103 | listOf("numbers" to arrayOf(1, 2, 3, 4, 5, 6, 7))) 104 | } 105 | 106 | @Test 107 | fun nestedArrayObject() { 108 | val json = """ 109 | { 110 | "people": [ 111 | { 112 | "name": "kenny", 113 | "favorite_language": "kotlin", 114 | "age": 64 115 | }, 116 | { 117 | "name": "martin", 118 | "favorite_language": "kotlin", 119 | "age": 92 120 | }, 121 | { 122 | "name": "andrew", 123 | "favorite_language": "kotlin", 124 | "age": 13180 125 | } 126 | ] 127 | } 128 | """ 129 | validator.assert(json, 130 | listOf("people" to listOf("favorite_language" to "kotlin"))) 131 | } 132 | 133 | 134 | @Test(expected = StrukturalException::class) 135 | fun nestedArrayObjectDifferentValues() { 136 | val json = """ 137 | { 138 | "people": [ 139 | { 140 | "name": "kenny", 141 | "favorite_language": "kotlin", 142 | "age": 64 143 | }, 144 | { 145 | "name": "martin", 146 | "favorite_language": "kotlin", 147 | "age": 92 148 | } 149 | ] 150 | } 151 | """ 152 | validator.assert(json, 153 | listOf("people" to listOf("age" to 64))) 154 | } 155 | 156 | @Test 157 | fun usingMatchers() { 158 | val json = """ 159 | { 160 | "people": [ 161 | { 162 | "name": "kenny", 163 | "favorite_language": "Kotlin", 164 | "age": 64 165 | }, 166 | { 167 | "name": "martin", 168 | "favorite_language": "Kotlin", 169 | "age": 92 170 | } 171 | ] 172 | } 173 | """ 174 | validator.assert(json, 175 | listOf( 176 | "people" to listOf( 177 | "name" to Matchers.notNullValue(), 178 | "favorite_language" to Matchers.equalToIgnoringCase("kotlin"), 179 | "age" to Matchers.greaterThan(50) 180 | ) 181 | )) 182 | } 183 | 184 | } 185 | -------------------------------------------------------------------------------- /src/test/kotlin/com/kennycason/struktural/JsonTypeValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.kennycason.struktural.exception.StrukturalException 4 | import com.kennycason.struktural.json.Nullable 5 | import org.junit.Test 6 | // 7 | /** 8 | * Created by kenny on 5/24/17. 9 | */ 10 | class JsonTypeValidatorTest { 11 | private val validator = JsonTypeValidator() 12 | 13 | @Test 14 | fun emptyTest() { 15 | validator.assert("{}", emptyList()) 16 | } 17 | 18 | @Test(expected = StrukturalException::class) 19 | fun emptyJson() { 20 | validator.assert("{}", listOf("foo" to Int::class)) 21 | } 22 | 23 | @Test 24 | fun singleField() { 25 | validator.assert("""{"foo": "bar"}""", listOf("foo" to String::class)) 26 | } 27 | 28 | @Test(expected = StrukturalException::class) 29 | fun singleFieldInvalidType() { 30 | validator.assert("""{"foo": "bar"}""", listOf("foo" to Int::class)) 31 | } 32 | 33 | @Test 34 | fun singleNestedField() { 35 | val json = """ 36 | { 37 | "foo": "bar", 38 | "nested": {"foo2": "bar2"} 39 | } 40 | """ 41 | validator.assert(json, 42 | listOf( 43 | "foo" to String::class, 44 | "nested" to listOf("foo2" to String::class) 45 | )) 46 | } 47 | 48 | @Test 49 | fun complexNestedField() { 50 | val json = """ 51 | { 52 | "name": "kenny", 53 | "age": 64, 54 | "shoe_size": 10.5, 55 | "favorite_number": 2.718281828459045235, 56 | "long_number": 1223235345342348, 57 | "random": [1,2,3,4,5,6], 58 | "job": { 59 | "id": 123456, 60 | "title": "Software Engineer" 61 | } 62 | } 63 | """ 64 | // strict number types 65 | validator.assert(json, 66 | listOf( 67 | "name" to String::class, 68 | "age" to Int::class, 69 | "shoe_size" to Float::class, 70 | "favorite_number" to Double::class, 71 | "long_number" to Long::class, 72 | "random" to Array::class, 73 | "job" to Object::class, 74 | "job" to listOf( 75 | "id" to Int::class, 76 | "title" to String::class 77 | ) 78 | )) 79 | 80 | // relaxed number types 81 | validator.assert(json, 82 | listOf( 83 | "name" to String::class, 84 | "age" to Number::class, 85 | "shoe_size" to Number::class, 86 | "favorite_number" to Number::class, 87 | "long_number" to Number::class, 88 | "random" to Array::class, 89 | "job" to Object::class, 90 | "job" to listOf( 91 | "id" to Number::class, 92 | "title" to String::class 93 | ) 94 | )) 95 | } 96 | 97 | @Test 98 | fun nestedArrayField() { 99 | val json = """ 100 | { 101 | "id": "sample", 102 | "numbers": [1,2,3,4,5,6] 103 | } 104 | """ 105 | validator.assert(json, 106 | listOf( 107 | "id" to String::class, 108 | "numbers" to Array::class 109 | )) 110 | } 111 | 112 | @Test(expected = StrukturalException::class) 113 | fun accessNestedArrayField() { 114 | val json = """ 115 | { 116 | "id": "sample", 117 | "numbers": [1,2,3,4,5,6] 118 | } 119 | """ 120 | validator.assert(json, 121 | listOf( 122 | "id" to String::class, 123 | "numbers" to listOf("does_not_exist") 124 | )) 125 | } 126 | 127 | @Test 128 | fun nestedArrayObject() { 129 | val json = """ 130 | { 131 | "languages": [ 132 | { 133 | "name": "kotlin", 134 | "coolness": 100 135 | }, 136 | { 137 | "name": "java", 138 | "coolness": 50 139 | }, 140 | { 141 | "name": "javascript", 142 | "coolness": 25 143 | } 144 | ] 145 | } 146 | """ 147 | validator.assert(json, 148 | listOf( 149 | "languages" to listOf( 150 | "name" to String::class, 151 | "coolness" to Number::class 152 | ) 153 | )) 154 | } 155 | 156 | @Test 157 | fun nullTest() { 158 | val json = """ 159 | { 160 | "foo": null 161 | } 162 | """ 163 | validator.assert(json, 164 | listOf("foo" to Nullable(String::class))) 165 | 166 | val json2 = """ 167 | { 168 | "foo": "bar" 169 | } 170 | """ 171 | validator.assert(json2, 172 | listOf("foo" to Nullable(String::class))) 173 | } 174 | 175 | @Test(expected = StrukturalException::class) 176 | fun unexpectedNullValue() { 177 | val json = """ 178 | { 179 | "foo": null 180 | } 181 | """ 182 | validator.assert(json, 183 | listOf("foo" to String::class)) 184 | } 185 | 186 | 187 | } 188 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/yaml/TestsParser.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural.yaml 2 | 3 | import com.kennycason.struktural.Mode 4 | import com.kennycason.struktural.data.FileJsonLoader 5 | import com.kennycason.struktural.data.InputStreamJsonLoader 6 | import com.kennycason.struktural.data.JsonLoader 7 | import com.kennycason.struktural.data.web.HttpJsonLoader 8 | import com.kennycason.struktural.data.web.HttpMethod 9 | import com.kennycason.struktural.data.web.Request 10 | import com.kennycason.struktural.exception.InvalidInputException 11 | import com.kennycason.struktural.yaml.transform.ExpectsMapTransform 12 | import org.apache.http.Header 13 | import org.apache.http.message.BasicHeader 14 | import java.io.File 15 | import java.util.regex.Pattern 16 | 17 | /** 18 | * Created by kenny on 5/26/17. 19 | */ 20 | class TestsParser { 21 | private val colonPattern = Pattern.compile(":") 22 | 23 | fun parse(testModel: Map, config: Config): List { 24 | if (!testModel.contains("tests")) { 25 | throw InvalidInputException("Test Yaml must have 'tests' block") 26 | } 27 | if (testModel["tests"] !is List<*>) { 28 | throw InvalidInputException("Test Yaml 'tests' block must be list") 29 | } 30 | val testsList = testModel["tests"] as List> 31 | 32 | val tests = mutableListOf() 33 | testsList.forEach { node -> 34 | tests.add(parseTest(node, config)) 35 | } 36 | return tests 37 | } 38 | 39 | private fun parseTest(test: Map, config: Config): TestCase { 40 | val mode = parseMode(test) 41 | val expectsMapTransform = ExpectsMapTransform(mode) 42 | return TestCase( 43 | config = config, 44 | mode = mode, 45 | jsonLoader = parseJsonLoader(test, config), 46 | expects = when (mode) { 47 | Mode.STRUCTURE -> expectsMapTransform.transformToAny(test["expects"] as Iterable) 48 | Mode.TYPE, Mode.VALUE -> expectsMapTransform.transformToPairs(test["expects"] as Iterable>) 49 | }) 50 | } 51 | 52 | private fun parseMode(test: Map): Mode { 53 | if (!test.contains("mode")) { 54 | throw InvalidInputException("Test must have 'mode' block. Possible values: (missing, type, value)") 55 | } 56 | return when (test["mode"]) { 57 | "structure" -> Mode.STRUCTURE 58 | "type" -> Mode.TYPE 59 | "value" -> Mode.VALUE 60 | else -> throw InvalidInputException("Invalid mode value. Possible values: (missing, type, value)") 61 | } 62 | } 63 | 64 | private fun parseJsonLoader(test: Map, config: Config): JsonLoader { 65 | if (!test.contains("data")) { 66 | throw InvalidInputException("Test must have 'data' block.") 67 | } 68 | val dataNode = test["data"] as Map 69 | if (dataNode.contains("resource")) { 70 | val resource = javaClass.getResourceAsStream(dataNode["resource"] as String) 71 | if (resource == null) { 72 | throw InvalidInputException("Provided resource $resource is null") 73 | } 74 | return InputStreamJsonLoader(resource) 75 | } 76 | if (dataNode.contains("file")) { 77 | return FileJsonLoader(File(dataNode["file"] as String)) 78 | } 79 | if (dataNode.contains("request")) { 80 | return HttpJsonLoader(config, parseRequest(dataNode["request"] as Map)) 81 | } 82 | throw InvalidInputException("'data' block must contain either 'resource', 'file', or 'request' block.") 83 | } 84 | 85 | private fun parseRequest(requestNode: Map): Request { 86 | if (!requestNode.contains("uri")) { 87 | throw InvalidInputException("Request block must have 'uri' block.") 88 | } 89 | if (!requestNode.contains("method")) { 90 | throw InvalidInputException("Request block must have 'method' block.") 91 | } 92 | return Request( 93 | uri = requestNode["uri"] as String, 94 | method = parseHttpMethod(requestNode), 95 | parameters = parseParameters(requestNode), 96 | body = if (requestNode.contains("body")) { requestNode["body"] as String } else { null }, 97 | headers = parseHeaders(requestNode)) 98 | } 99 | 100 | private fun parseHttpMethod(requestNode: Map): HttpMethod { 101 | if (!requestNode.contains("method")) { return HttpMethod.GET } 102 | 103 | val method = requestNode["method"] as String 104 | return when (method) { 105 | "GET" -> HttpMethod.GET 106 | "POST" -> HttpMethod.POST 107 | "PATCH" -> HttpMethod.PATCH 108 | "DELETE" -> HttpMethod.DELETE 109 | else -> throw InvalidInputException("Invalid Request method provided. Found: [$method]. Valid values are: ${HttpMethod.values()}") 110 | } 111 | } 112 | 113 | private fun parseHeaders(requestNode: Map): List
{ 114 | if (!requestNode.contains("headers")) { 115 | return emptyList() 116 | } 117 | return (requestNode["headers"] as List) 118 | .map {header -> 119 | if (!header.contains(':')) { 120 | throw InvalidInputException("Invalid header format. Must contain ':' between key: value") 121 | } 122 | val keyValue = header.split(colonPattern, 2) 123 | BasicHeader(keyValue[0], keyValue[1]) 124 | } 125 | .toList() 126 | } 127 | 128 | private fun parseParameters(requestNode: Map): List { 129 | if (!requestNode.contains("params") 130 | || requestNode["params"] == null) { 131 | return emptyList() 132 | } 133 | return requestNode["params"] as List 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/JsonTypeValidator.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.node.JsonNodeType 6 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 7 | import com.kennycason.struktural.error.Error 8 | import com.kennycason.struktural.exception.InvalidInputException 9 | import com.kennycason.struktural.exception.StrukturalException 10 | import com.kennycason.struktural.json.JsonNodeTypeValidator 11 | import com.kennycason.struktural.json.Nullable 12 | import kotlin.reflect.KClass 13 | 14 | /** 15 | * Test fields not missing and field types. 16 | * 17 | */ 18 | class JsonTypeValidator { 19 | private var objectMapper: ObjectMapper = ObjectMapper() 20 | private val jsonNodeTypeValidator = JsonNodeTypeValidator() 21 | 22 | fun setObjectMapper(objectMapper: ObjectMapper) { 23 | this.objectMapper = objectMapper 24 | } 25 | 26 | 27 | fun assert(jsonString: String, fieldTypes: Iterable>) = assert(objectMapper.readTree(jsonString), fieldTypes) 28 | 29 | fun assert(json: JsonNode, fieldTypes: Iterable>) { 30 | val result = validate(json, fieldTypes) 31 | if (result.valid) { 32 | return 33 | } 34 | throw StrukturalException(result.errors.joinToString("\n")) 35 | } 36 | 37 | fun validate( 38 | jsonString: String, 39 | fieldTypes: Iterable> 40 | ) = assert(objectMapper.readTree(jsonString), fieldTypes) 41 | 42 | fun validate(json: JsonNode, fieldTypes: Iterable>): ValidationResult { 43 | // keep track of all errors 44 | val errors = mutableListOf() 45 | // keep track of the path for logging convenience 46 | val path = "" 47 | walkFields(json, fieldTypes, path, errors) 48 | 49 | return ValidationResult(errors.isEmpty(), errors) 50 | } 51 | 52 | private fun walkFields( 53 | json: JsonNode, 54 | fields: Iterable, 55 | path: String, 56 | errors: MutableList 57 | ) { 58 | fields.forEach { field -> 59 | // could be a Pair which is a field type assert 60 | // could also be a Pair> which is a nested field 61 | if (field is Pair<*, *>) { 62 | // nest object, validate and recur 63 | validateNestedField(field) 64 | // set variables here so smart casting works later 65 | val fieldName = field.first!! as String 66 | val value = field.second!! 67 | if (!json.has(fieldName)) { 68 | errors.add(Error(Mode.STRUCTURE, "Field [${normalizeFieldPath(path, fieldName)}] missing.")) 69 | return@forEach 70 | } 71 | 72 | // validate field type 73 | if (value is KClass<*>) { 74 | val jsonNode = json.get(fieldName) 75 | val jsonNodeType = jsonNode.nodeType!! 76 | if (jsonNodeType == JsonNodeType.NULL) { 77 | errors.add( 78 | Error( 79 | Mode.TYPE, 80 | "Field [${normalizeFieldPath(path, fieldName)}] is null, expected [${value.simpleName!!.lowercase()}]." 81 | ) 82 | ) 83 | return@forEach 84 | } 85 | if (!jsonNodeTypeValidator.validate(jsonNode, value)) { 86 | errors.add( 87 | Error( 88 | Mode.TYPE, 89 | "Field [${normalizeFieldPath(path, fieldName)}] is not of type [${value.simpleName!!.lowercase()}]. " + 90 | "Found [${jsonNodeType.toString().lowercase()}]" 91 | ) 92 | ) 93 | } 94 | } else if (value is Nullable) { 95 | val jsonNode = json.get(fieldName) 96 | val jsonNodeType = jsonNode.nodeType!! 97 | // only validate if not null since null is allowed. 98 | if (jsonNodeType != JsonNodeType.NULL) { 99 | if (!jsonNodeTypeValidator.validate(jsonNode, value.clazz)) { 100 | errors.add( 101 | Error( 102 | Mode.TYPE, 103 | "Field [${normalizeFieldPath(path, fieldName)}] is not of type [${value.clazz.simpleName!!.lowercase()}] or null. " + 104 | "Found [${jsonNodeType.toString().lowercase()}]" 105 | ) 106 | ) 107 | } 108 | } 109 | } else if (value is Iterable<*>) { 110 | val nestedFields = value 111 | val nestedJsonNode = json.get(fieldName) 112 | if (nestedJsonNode.isArray) { // walk over each item in the array and apply the nested checks 113 | nestedJsonNode.forEach { node -> 114 | walkFields(node, nestedFields.requireNoNulls(), "$path/$fieldName", errors) 115 | } 116 | } else { // is nested object 117 | walkFields(nestedJsonNode, nestedFields.requireNoNulls(), "$path/$fieldName", errors) 118 | } 119 | } else { 120 | throw IllegalStateException("An illegal state occurred in Struktural. Unknown second value of Pair. Found [${value::class}]") 121 | } 122 | } else { 123 | throw InvalidInputException( 124 | "Input must either be a Pair, where * can be a KClass type assert oor an Iterable " + 125 | "for nested objects. Found [${field::class.simpleName?.lowercase()}]" 126 | ) 127 | } 128 | } 129 | } 130 | 131 | private fun validateNestedField(field: Pair<*, *>) { 132 | val key = field.first 133 | val value = field.second 134 | if (key == null) { 135 | throw InvalidInputException("First value for nested input must be a String. Found null") 136 | } 137 | if (value == null) { 138 | throw InvalidInputException("Second value for nested input must be a Iterable. Found null") 139 | } 140 | // test structure of Pair 141 | if (key !is String) { 142 | throw InvalidInputException("First value for nested input must be a String. Found [${key::class.simpleName?.lowercase()}]") 143 | } 144 | if (!(value is Iterable<*> 145 | || value is KClass<*> 146 | || value is Nullable)) { 147 | throw InvalidInputException( 148 | "Input must either be a Pair, " + 149 | "where VALUE can be a KClass for type assertion, Iterable for pairs of nested objects, or a Nullable value, " + 150 | "Found Pair<${key::class.simpleName?.lowercase()}, ${value::class.simpleName?.lowercase()}>" 151 | ) 152 | } 153 | } 154 | 155 | private fun normalizeFieldPath(path: String, field: String) = ("$path/$field").replace(Regex("^/"), "") 156 | } 157 | -------------------------------------------------------------------------------- /src/main/kotlin/com/kennycason/struktural/JsonTypeAssertion.kt: -------------------------------------------------------------------------------- 1 | package com.kennycason.struktural 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.node.JsonNodeType 6 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 7 | import com.kennycason.struktural.error.Error 8 | import com.kennycason.struktural.exception.InvalidInputException 9 | import com.kennycason.struktural.exception.StrukturalException 10 | import com.kennycason.struktural.json.JsonNodeTypeValidator 11 | import com.kennycason.struktural.json.Nullable 12 | import java.text.SimpleDateFormat 13 | import kotlin.reflect.KClass 14 | 15 | fun assertJsonTypes( 16 | jsonString: String, 17 | mapper: ObjectMapper = Struktural.getObjectMapper(), 18 | block: JsonTypeAssertion.JsonValidatorScope.() -> Unit 19 | ) { 20 | val validator = JsonTypeAssertion() 21 | validator.validateJson(jsonString, mapper, block) 22 | } 23 | 24 | class JsonTypeAssertion { 25 | 26 | fun validateJson( 27 | jsonString: String, 28 | mapper: ObjectMapper = Struktural.getObjectMapper(), 29 | block: JsonValidatorScope.() -> Unit 30 | ) { 31 | val json = mapper.readTree(jsonString) 32 | val scope = JsonValidatorScope(json) 33 | scope.block() 34 | 35 | if (scope.errors.isNotEmpty()) { 36 | throw StrukturalException(scope.errors.joinToString("\n")) 37 | } 38 | } 39 | 40 | inner class JsonValidatorScope(private val json: JsonNode) { 41 | val errors = mutableListOf() 42 | 43 | fun string(fieldName: String) { 44 | validateField(fieldName, String::class) 45 | } 46 | 47 | fun number(fieldName: String) { 48 | validateField(fieldName, Number::class) 49 | } 50 | 51 | fun boolean(fieldName: String) { 52 | validateField(fieldName, Boolean::class) 53 | } 54 | 55 | fun integer(fieldName: String) { 56 | validateFieldWithPredicate(fieldName, "integer") { it.isInt } 57 | } 58 | 59 | fun long(fieldName: String) { 60 | validateFieldWithPredicate(fieldName, "long") { it.isLong } 61 | } 62 | 63 | fun decimal(fieldName: String) { 64 | validateFieldWithPredicate(fieldName, "decimal") { it.isFloat || it.isDouble } 65 | } 66 | 67 | fun nullableString(fieldName: String) { 68 | validateField(fieldName, String::class, allowNull = true) 69 | } 70 | 71 | fun nullableNumber(fieldName: String) { 72 | validateField(fieldName, Number::class, allowNull = true) 73 | } 74 | 75 | fun nullableBoolean(fieldName: String) { 76 | validateFieldWithPredicate(fieldName, "nullable boolean") { 77 | it.isNull || it.isBoolean 78 | } 79 | } 80 | 81 | fun nullableInteger(fieldName: String) { 82 | validateFieldWithPredicate(fieldName, "nullable integer") { 83 | it.isNull || it.isInt 84 | } 85 | } 86 | 87 | fun nullableLong(fieldName: String) { 88 | validateFieldWithPredicate(fieldName, "nullable long") { 89 | it.isNull || it.isLong 90 | } 91 | } 92 | 93 | fun nullableDecimal(fieldName: String) { 94 | validateFieldWithPredicate(fieldName, "nullable decimal") { 95 | it.isNull || it.isFloat || it.isDouble 96 | } 97 | } 98 | 99 | // Date/Time Types 100 | fun date(fieldName: String, pattern: String = "yyyy-MM-dd") { 101 | validateFieldWithPredicate(fieldName, "date") { 102 | it.isTextual && isValidDate(it.asText(), pattern) 103 | } 104 | } 105 | 106 | fun dateTime(fieldName: String, pattern: String = "yyyy-MM-dd'T'HH:mm:ss") { 107 | validateFieldWithPredicate(fieldName, "date-time") { 108 | it.isTextual && isValidDate(it.asText(), pattern) 109 | } 110 | } 111 | 112 | fun > enum(fieldName: String, enumClass: KClass) { 113 | val allowedValues = enumClass.java.enumConstants.map { it.name } 114 | validateFieldWithPredicate(fieldName, "enum of ${enumClass.simpleName}") { 115 | it.isTextual && it.asText() in allowedValues 116 | } 117 | } 118 | 119 | fun enum(fieldName: String, block: () -> Array) { 120 | val allowedValues = block().toList() 121 | validateFieldWithPredicate(fieldName, "enum of ${allowedValues.joinToString()}") { 122 | it.isTextual && it.asText() in allowedValues 123 | } 124 | } 125 | 126 | 127 | fun matchesRegex(fieldName: String, regex: Regex) { 128 | validateFieldWithPredicate(fieldName, "matching regex $regex") { 129 | it.isTextual && it.asText().matches(regex) 130 | } 131 | } 132 | 133 | fun custom(fieldName: String, description: String, validation: (JsonNode) -> Boolean) { 134 | validateFieldWithPredicate(fieldName, description, validation) 135 | } 136 | 137 | fun array(fieldName: String, block: JsonValidatorScope.() -> Unit) { 138 | val node = json[fieldName] 139 | if (node == null || !node.isArray) { 140 | errors.add("Expected an array at field '$fieldName'.") 141 | return 142 | } 143 | node.forEach { element -> 144 | JsonValidatorScope(element).block() 145 | } 146 | } 147 | 148 | fun objectField(fieldName: String, block: JsonValidatorScope.() -> Unit) { 149 | val node = json[fieldName] 150 | if (node == null || !node.isObject) { 151 | errors.add("Expected an object at field '$fieldName'.") 152 | return 153 | } 154 | JsonValidatorScope(node).block() 155 | } 156 | 157 | private fun validateField(fieldName: String, type: KClass<*>, allowNull: Boolean = false) { 158 | val node = json[fieldName] 159 | if (node == null) { 160 | errors.add("Field '$fieldName' is missing.") 161 | return 162 | } 163 | if (node.isNull && allowNull) return 164 | 165 | val isValidType = when (type) { 166 | String::class -> node.isTextual 167 | Number::class -> node.isNumber 168 | Boolean::class -> node.isBoolean 169 | else -> false 170 | } 171 | if (!isValidType) { 172 | errors.add("Field '$fieldName' is not of type '${type.simpleName}'. Found '${node.nodeType}'.") 173 | } 174 | } 175 | 176 | private fun validateFieldWithPredicate( 177 | fieldName: String, 178 | description: String, 179 | predicate: (JsonNode) -> Boolean 180 | ) { 181 | val node = json[fieldName] 182 | if (node == null) { 183 | errors.add("Field '$fieldName' is missing.") 184 | return 185 | } 186 | if (!predicate(node)) { 187 | errors.add("Field '$fieldName' is not $description.") 188 | } 189 | } 190 | 191 | private fun isValidDate(value: String, pattern: String): Boolean { 192 | return try { 193 | SimpleDateFormat(pattern).parse(value) 194 | true 195 | } catch (e: Exception) { 196 | false 197 | } 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.kennycason 8 | struktural 9 | 1.0.4 10 | Struktural 11 | Struktural - Spec style API/JSON Testing 12 | https://github.com/kennycason/struktural 13 | 14 | 15 | 2.1.0 16 | 17 | 18 | 19 | 20 | MIT License 21 | http://www.opensource.org/licenses/mit-license.php 22 | repo 23 | 24 | 25 | 26 | 27 | 3.3.0 28 | 29 | 30 | 31 | 32 | kennycason 33 | Kenny Cason 34 | 35 | Developer 36 | 37 | https://github.com/kennycason/struktural 38 | 39 | 40 | 41 | 42 | github 43 | https://github.com/kennycason/struktural/issues 44 | 45 | 46 | 47 | scm:git:git@github.com:kennycason/struktural.git 48 | scm:git:git@github.com:kennycason/struktural.git 49 | https://github.com/kennycason/struktural 50 | 51 | 52 | 53 | 54 | org.jetbrains.kotlin 55 | kotlin-stdlib 56 | ${kotlin.version} 57 | 58 | 59 | org.jetbrains.kotlin 60 | kotlin-reflect 61 | ${kotlin.version} 62 | 63 | 64 | 65 | 66 | org.apache.httpcomponents 67 | httpclient 68 | 4.5.14 69 | 70 | 71 | 72 | 73 | com.fasterxml.jackson.core 74 | jackson-core 75 | 2.18.2 76 | 77 | 78 | com.fasterxml.jackson.core 79 | jackson-databind 80 | 2.18.2 81 | 82 | 83 | com.fasterxml.jackson.dataformat 84 | jackson-dataformat-yaml 85 | 2.18.2 86 | 87 | 88 | 89 | 90 | org.yaml 91 | snakeyaml 92 | 2.3 93 | 94 | 95 | 96 | 97 | org.hamcrest 98 | hamcrest-all 99 | 1.3 100 | 101 | 102 | 103 | 104 | org.jetbrains.kotlin 105 | kotlin-test-junit 106 | ${kotlin.version} 107 | test 108 | 109 | 110 | junit 111 | junit 112 | 4.13.2 113 | test 114 | 115 | 116 | 117 | 118 | 119 | ${project.basedir}/src/main/kotlin 120 | ${project.basedir}/src/test/kotlin 121 | 122 | 123 | 124 | org.jetbrains.kotlin 125 | kotlin-maven-plugin 126 | ${kotlin.version} 127 | 128 | 129 | 130 | compile 131 | 132 | compile 133 | 134 | 135 | 136 | 137 | test-compile 138 | 139 | test-compile 140 | 141 | 142 | 143 | 144 | 145 | org.apache.maven.plugins 146 | maven-source-plugin 147 | 3.3.1 148 | 149 | 150 | attach-sources 151 | 152 | jar-no-fork 153 | 154 | 155 | 156 | 157 | 158 | 159 | org.apache.maven.plugins 160 | maven-jar-plugin 161 | 162 | 163 | empty-javadoc-jar 164 | package 165 | 166 | jar 167 | 168 | 169 | javadoc 170 | ${basedir}/javadoc 171 | 172 | 173 | 174 | 175 | 176 | org.sonatype.plugins 177 | nexus-staging-maven-plugin 178 | 1.7.0 179 | true 180 | 181 | ossrh 182 | https://oss.sonatype.org/ 183 | true 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | release 192 | 193 | 194 | 195 | org.apache.maven.plugins 196 | maven-gpg-plugin 197 | 3.2.7 198 | 199 | 200 | sign-artifacts 201 | verify 202 | 203 | sign 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | ossrh 216 | https://oss.sonatype.org/content/repositories/snapshots 217 | 218 | 219 | ossrh 220 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 221 | 222 | 223 | 224 | 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Struktural 2 | 3 | ## What 4 | Struktural is a suite of tools written in Kotlin designed to make testing of APIs easier in Java/Kotlin. 5 | 6 | Struktural is designed to give flexible control over the level of desired testing. 7 | 8 | | Features | Description | 9 | | ---------------------- | ----------------------------------------------------------------- | 10 | | Assert Json Structure | A lightweight test to assert presence of fields. | 11 | | Assert Json Types | A middleweight test to assert presence of fields and their types. | 12 | | Assert Json Values | A heavyweight test to assert presence of fields and their values.
Supports exact values as well as matchers via Hamcrest. | 13 | 14 | Struktural provides two interfaces. 15 | 1. A native Kotlin interface for running tests. (Interfaces natively with Java) 16 | 2. A YAML driven test format. Place tests in a YAML format and don't type any Java/Kotlin at all! 17 | - There are also plans to build a Maven plugin for this. Initially there will be a helper class to load and run all the YAML tests. 18 | 19 | There are two libraries that influenced me and my desire to build this library. 20 | 1. Ruby's [Airborne](https://github.com/skyscreamer/JSONassert) library which when combined with RSpec make beautiful and lightweight API testing. 21 | 2. Java's [Skyscreamer's JSONAsert](https://github.com/skyscreamer/JSONassert) library which made api testing pain a bit easier. 22 | 23 | ## Why 24 | Testing APIs in Java/Kotlin often involves verbose methodologies following on of three patterns: 25 | 1. Assert each field individually from a raw JSON or Map object. 26 | 2. Compare expected JSON from a resource like sample_expected.json with a response 27 | 3. Mapping responses to POJOs and peforming `equals` checks or checking field by field. 28 | 29 | This is an attempt to make writing integration tests more fun and remove some of the pain often associated with writing API integration tests in Java. 30 | I think this is especially important as the world continues to adopt Service Oriented Architectures. 31 | 32 | ## Where 33 | Struktural is available on Maven Central. (Or will be very soon) 34 | 35 | ```xml 36 | 37 | com.kennycason 38 | struktural 39 | 1.0.4 40 | 41 | ``` 42 | 43 | ## How 44 | 45 | ### Kotlin API 46 | 47 | 48 | #### Assert Json Types 49 | 50 | ```json 51 | { 52 | "game_title": "Super Metroid", 53 | "release_year": 1994, 54 | "characters": [ 55 | { 56 | "name": "Samus Aran", 57 | "abilities": ["Morph Ball", "Missiles", "Power Bombs"], 58 | "health": 99, 59 | "weapon_power": 100.5 60 | }, 61 | { 62 | "name": "Ridley", 63 | "abilities": ["Fire Breath", "Tail Whip"], 64 | "health": 5000, 65 | "weapon_power": 200.0 66 | } 67 | ], 68 | "settings": { 69 | "planet": "Zebes", 70 | "areas": [ 71 | { 72 | "name": "Brinstar", 73 | "environment": "Jungle" 74 | }, 75 | { 76 | "name": "Norfair", 77 | "environment": "Lava" 78 | } 79 | ] 80 | }, 81 | "game_modes": ["SINGLE_PLAYER"], 82 | "game_mode": "SINGLE_PLAYER", 83 | "is_remake": null 84 | } 85 | ``` 86 | 87 | Lambda syntax (new) 88 | 89 | ```kotlin 90 | assertJsonTypes(json) { 91 | string("game_title") 92 | integer("release_year") 93 | nullableBoolean("is_remake") 94 | 95 | array("characters") { 96 | string("name") 97 | array("abilities") { 98 | string("") 99 | } 100 | integer("health") 101 | decimal("weapon_power") 102 | } 103 | 104 | objectField("settings") { 105 | string("planet") 106 | array("areas") { 107 | string("name") 108 | string("environment") 109 | } 110 | } 111 | 112 | array("game_modes") { } 113 | 114 | custom("release_year", "a valid SNES release year") { 115 | it.isInt && it.asInt() in 1990..2000 116 | } 117 | 118 | enum("game_mode") { arrayOf("SINGLE_PLAYER", "MULTIPLAYER", "CO-OP") } 119 | } 120 | ``` 121 | 122 | ```kotlin 123 | val json = """ 124 | { 125 | "name": "kenny", 126 | "age": 64, 127 | "shoe_size": 10.5, 128 | "favorite_number": 2.718281828459045235, 129 | "long_number": 1223235345342348, 130 | "random": [1,2,3,4,5,6], 131 | "job": { 132 | "id": 123456, 133 | "title": "Software Engineer" 134 | } 135 | } 136 | """ 137 | ``` 138 | 139 | 140 | Strict number types 141 | 142 | ```kotlin 143 | validator.assert(json, 144 | listOf( 145 | "name" to String::class, 146 | "age" to Int::class, 147 | "shoe_size" to Float::class, 148 | "favorite_number" to Double::class, 149 | "long_number" to Long::class, 150 | "random" to Array::class, 151 | "job" to Object::class, 152 | "job" to listOf( 153 | "id" to Int::class, 154 | "title" to String::class 155 | ) 156 | )) 157 | ``` 158 | 159 | 160 | Relaxed number types 161 | 162 | ```kotlin 163 | validator.assert(json, 164 | listOf( 165 | "name" to String::class, 166 | "age" to Number::class, 167 | "shoe_size" to Number::class, 168 | "favorite_number" to Number::class, 169 | "long_number" to Number::class, 170 | "random" to Array::class, 171 | "job" to Object::class, 172 | "job" to listOf( 173 | "id" to Number::class, 174 | "title" to String::class 175 | ) 176 | )) 177 | ``` 178 | 179 | 180 | Nested array of objects 181 | 182 | ```kotlin 183 | val json = """ 184 | { 185 | "languages": [ 186 | { 187 | "name": "kotlin", 188 | "coolness": 100 189 | }, 190 | { 191 | "name": "java", 192 | "coolness": 50 193 | } 194 | ] 195 | } 196 | 197 | """ 198 | ``` 199 | 200 | Lambda syntax (new) 201 | 202 | ```kotlin 203 | assertJsonTypes(json) { 204 | array("languages") { 205 | string("name") 206 | number("coolness") 207 | } 208 | } 209 | ``` 210 | 211 | Alternative syntax 212 | 213 | ```kotlin 214 | validator.assert(json, 215 | listOf( 216 | "languages" to listOf( 217 | "name" to String::class, 218 | "coolness" to Number::class 219 | ) 220 | )) 221 | ``` 222 | 223 | Nullable values 224 | 225 | ```kotlin 226 | val json = """ 227 | { 228 | "foo": null 229 | } 230 | """ 231 | Struktural.assertTypes(json, 232 | listOf("foo" to Nullable(String::class))) 233 | ``` 234 | 235 | 236 | 237 | Date Time 238 | 239 | ```json 240 | { 241 | "game_release_date": "1994-03-19", 242 | "last_updated": "2024-12-18T15:30:00" 243 | } 244 | ``` 245 | 246 | ```kotlin 247 | assertJsonTypes(json) { 248 | date("game_release_date", "yyyy-MM-dd") 249 | dateTime("last_updated", "yyyy-MM-dd'T'HH:mm:ss") 250 | } 251 | ``` 252 | 253 | Regex 254 | 255 | ```json 256 | { 257 | "player_name": "Samus_Aran", 258 | "player_code": "SM12345" 259 | } 260 | ``` 261 | 262 | ```kotlin 263 | assertJsonTypes(json) { 264 | matchesRegex("player_name", "^[a-zA-Z0-9_]+$".toRegex()) // Alphanumeric with underscores 265 | matchesRegex("player_code", "^SM[0-9]{5}$".toRegex()) // Starts with 'SM' followed by 5 digits 266 | } 267 | ``` 268 | 269 | 270 | 271 | #### Assert Field Structure 272 | 273 | ```kotlin 274 | val json = """ 275 | { 276 | "name": "kenny", 277 | "age": 64, 278 | "job": { 279 | "id": 123456, 280 | "title": "Software Engineer" 281 | } 282 | } 283 | """ 284 | Struktural.assertStructure(json, 285 | listOf("name", 286 | "age", 287 | "job" to listOf("id", "title"))) 288 | ``` 289 | 290 | Nested array of objects 291 | 292 | ```kotlin 293 | val json = """ 294 | { 295 | "languages": [ 296 | { 297 | "name": "kotlin", 298 | "coolness": 100 299 | }, 300 | { 301 | "name": "java", 302 | "coolness": 50 303 | } 304 | ] 305 | } 306 | """ 307 | Struktural.assertStructure(json, 308 | listOf("languages" to listOf("name", "coolness"))) 309 | ``` 310 | 311 | 312 | 313 | 314 | 315 | #### Assert Field Values 316 | 317 | ```kotlin 318 | val json = """ 319 | { 320 | "name": "kenny", 321 | "age": 64, 322 | "shoe_size": 10.5, 323 | "favorite_number": 2.718281828459045235, 324 | "long_number": 1223235345342348, 325 | "job": { 326 | "id": 123456, 327 | "title": "Software Engineer" 328 | } 329 | } 330 | """ 331 | validator.assert(json, 332 | listOf( 333 | "name" to "kenny", 334 | "age" to 64, 335 | "shoe_size" to 10.5, 336 | "favorite_number" to 2.718281828459045235, 337 | "long_number" to 1223235345342348, 338 | "job" to listOf( 339 | "id" to 123456, 340 | "title" to "Software Engineer" 341 | ) 342 | )) 343 | ``` 344 | 345 | Only match partial 346 | 347 | ```kotlin 348 | Struktural.assertValues(json, 349 | listOf("name" to "kenny", 350 | "favorite_number" to 2.718281828459045235)) 351 | ``` 352 | 353 | Simple array example 354 | 355 | ```kotlin 356 | val json = """ 357 | { 358 | "numbers": [1,2,3,4,5,6] 359 | } 360 | """ 361 | Struktural.assertValues(json, 362 | listOf("numbers" to arrayOf(1, 2, 3, 4, 5, 6))) 363 | 364 | ``` 365 | 366 | Nested array example 367 | 368 | ```kotlin 369 | val json = """ 370 | { 371 | "people": [ 372 | { 373 | "name": "kenny", 374 | "favorite_language": "kotlin", 375 | "age": 64 376 | }, 377 | { 378 | "name": "martin", 379 | "favorite_language": "kotlin", 380 | "age": 92 381 | } 382 | ] 383 | } 384 | """ 385 | Struktural.assertValues(json, 386 | listOf("people" to ("favorite_language" to "kotlin"))) 387 | ``` 388 | 389 | Using Hamcrest matchers 390 | 391 | ```kotlin 392 | val json = """ 393 | { 394 | "people": [ 395 | { 396 | "name": "kenny", 397 | "favorite_language": "Kotlin", 398 | "age": 64 399 | }, 400 | { 401 | "name": "martin", 402 | "favorite_language": "Kotlin", 403 | "age": 92 404 | } 405 | ] 406 | } 407 | """ 408 | validator.assert(json, 409 | listOf( 410 | "people" to listOf( 411 | "name" to Matchers.notNullValue(), 412 | "favorite_language" to Matchers.equalToIgnoringCase("kotlin"), 413 | "age" to Matchers.greaterThan(50) 414 | ) 415 | )) 416 | ``` 417 | 418 | 419 | ### YAML API 420 | 421 | In addition to the native Kotlin/Java API Unit tests can also be configured via YAML files. 422 | 423 | Example API Test #1 424 | ```yaml 425 | --- 426 | config: 427 | base_url: http://api.company.com 428 | 429 | tests: 430 | - 431 | mode: type 432 | data: 433 | request: 434 | uri: /language/detection 435 | method: POST 436 | body: '{"data":[{"id":"1","text":"I am an english comment"}]}' 437 | headers: 438 | - 'Content-Type: application/json' 439 | 440 | expects: 441 | - data: 442 | id: string 443 | language: 444 | name: string 445 | code: string 446 | score: int 447 | is_reliable: bool 448 | ``` 449 | 450 | Example API Test #2 451 | ```yaml 452 | --- 453 | tests: 454 | - 455 | mode: type 456 | data: 457 | request: 458 | uri: https://api.company.com/labels 459 | method: GET 460 | params: 461 | - 'include_inactive=true' 462 | headers: 463 | - 'Authorization: Bearer ' 464 | - 'Content-Type: application/json' 465 | 466 | expects: 467 | - data: 468 | type: string 469 | id: string 470 | attributes: 471 | account_id: string 472 | name: string 473 | color: string 474 | created_at: string 475 | created_by: string 476 | updated_at: string 477 | updated_by: string 478 | active: bool 479 | ``` 480 | 481 | The YAML format also provides options for validating json files, resources, as well as a variety of configurations. 482 | The YAML format and description of properties can be found below: 483 | ```yaml 484 | --- 485 | # config block provides section for global configs 486 | config: 487 | # base_url is an optional field to remove some verbosity when testing apis. 488 | # it is prepended to data.request.uri if set. 489 | base_url: https://api.foobar.com 490 | port: 8080 491 | 492 | tests: 493 | - # array of tests 494 | # pick one of three modes for testing 495 | # structure = assert fields not missing 496 | # type = assert fields not missing and field types 497 | # value = assert fields not missing and field values 498 | mode: structure | type | value 499 | # the data block provides methods for providing data 500 | data: 501 | # 1. configuration for url requests 502 | request: 503 | uri: /v2/foo/bar 504 | method: POST 505 | body: '{"foo":"bar"}' 506 | params: 507 | - 'field=value' 508 | - 'field2=value2' 509 | headers: 510 | - 'Authorization: key' 511 | 512 | # 2. configuration for loading json from resource, great for unit tests 513 | resource: /path/to/resource/food.json 514 | # 3. configuration for loading file from file system 515 | file: /path/to/file.json 516 | 517 | expects: 518 | # note that you must choose ONE of the below formats 519 | # example for mode: structure 520 | - name 521 | - age 522 | - job: 523 | - id 524 | - title 525 | 526 | # example for mode: types 527 | - name: string 528 | - age: int 529 | - job: 530 | id: int 531 | title: string 532 | 533 | # example for mode: values 534 | - name: kenny 535 | - age: 30 536 | - job: 537 | id: 123456 538 | title: Software Engineer 539 | ``` 540 | 541 | #### Yaml Test Examples 542 | 543 | Assert tests in YAML file are valid 544 | ```kotlin 545 | Struktural.assertYaml(javaClass.getResourceAsStream("/path/to/resource/my_test.yml")) 546 | ``` 547 | 548 | 549 | Test against raw YAML String 550 | ```kotlin 551 | val yaml = """ 552 | --- 553 | tests: 554 | - 555 | mode: type 556 | data: 557 | resource: /com/kennycason/struktural/json/person_sample_response.json 558 | 559 | expects: 560 | - name: string 561 | - age: int 562 | - job: 563 | id: int 564 | title: string 565 | """ 566 | 567 | Struktural.assertYaml(yaml) 568 | ``` 569 | 570 | ### Kotlin + Struktural + Spek 571 | 572 | A small example using JetBrain's [Spek Framework](http://spekframework.org/) 573 | 574 | ```kotlin 575 | class LanguageClassifierApiTests : Spek( { 576 | 577 | describe("Language Classification API Tests") { 578 | 579 | it("Response structure & types") { 580 | val json = HttpJsonLoader( 581 | request = Request(uri = "http://api.service.com/language/detection", 582 | method = HttpMethod.POST, 583 | body = """{"items":[{"id":"1","text":"I am a happy person"}]}""", 584 | headers = listOf
(BasicHeader("Content-Type", "application/json")))) 585 | .load() 586 | Struktural.assertTypes(json, 587 | listOf(Pair("items", listOf( 588 | Pair("id", String::class), 589 | Pair("language", listOf( 590 | Pair("name", String::class), 591 | Pair("code", String::class), 592 | Pair("score", Int::class), 593 | Pair("is_reliable", Boolean::class))))))) 594 | } 595 | } 596 | 597 | }) 598 | ``` 599 | 600 | 601 | ## Notes 602 | - Pass context from test-to-test. Allow a response form one test to drive the next test. 603 | - Better error handling/logging to come. 604 | - I'm looking for ideas on more features. e.g. 605 | - Maven Plugin to automatically scan resource for yaml test files, or some similar concept to further facility configuring of tests 606 | - extra validation functions 607 | - Currently the project has a hard dependency on Apache Http Client and Jackson Json parsing. Eventually these *may* be extracted out so that you can choose your library. 608 | - Much of the inernal code will be cleaned up and better organized in time. This was a few day proof-of-concept project. 609 | --------------------------------------------------------------------------------