All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.specmatic.core.HttpRequest.kt Maven / Gradle / Ivy

Go to download

Turn your contracts into executable specifications. Contract Driven Development - Collaboratively Design & Independently Deploy MicroServices & MicroFrontends.

There is a newer version: 2.0.37
Show newest version
package io.specmatic.core

import io.specmatic.conversions.guessType
import io.specmatic.core.GherkinSection.When
import io.specmatic.core.pattern.*
import io.specmatic.core.utilities.URIUtils.parseQuery
import io.specmatic.core.value.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.specmatic.core.utilities.Flags.Companion.SPECMATIC_PRETTY_PRINT
import io.specmatic.core.utilities.Flags.Companion.getBooleanValue
import org.apache.http.client.utils.URLEncodedUtils
import org.apache.http.message.BasicNameValuePair
import java.io.File
import java.io.UnsupportedEncodingException
import java.net.*
import java.nio.charset.StandardCharsets

const val FORM_FIELDS_JSON_KEY = "form-fields"
const val MULTIPART_FORMDATA_JSON_KEY = "multipart-formdata"

fun urlToQueryParams(uri: URI): Map {
    if (uri.query == null)
        return emptyMap()

    return uri.query.split("&").associate {
        val parts = it.split("=".toRegex(), 2)
        Pair(parts[0], parts[1])
    }
}

data class HttpRequest(
    val method: String? = null,
    val path: String? = null,
    val headers: Map = emptyMap(),
    val body: Value = EmptyString,
    val queryParams: QueryParameters = QueryParameters(),
    val formFields: Map = emptyMap(),
    val multiPartFormData: List = emptyList()
) {
    constructor(method: String, uri: URI) : this(method, uri.path, queryParametersMap = urlToQueryParams(uri))
    constructor(
        method: String? = null,
        path: String? = null,
        headers: Map = emptyMap(),
        body: Value = EmptyString,
        queryParametersMap: Map = emptyMap(),
        formFields: Map = emptyMap(),
        multiPartFormData: List = emptyList(),
        marker: String = "Dummy"
    ) : this(
        method = method,
        path = path,
        headers = headers,
        body = body,
        queryParams = QueryParameters(queryParametersMap),
        formFields = formFields,
        multiPartFormData = multiPartFormData,
    )

    fun updateQueryParams(otherQueryParams: Map): HttpRequest =
        copy(queryParams = queryParams.plus(otherQueryParams))

    fun withHost(host: String) = this.copy(headers = this.headers.plus("Host" to host))

    fun updatePath(path: String): HttpRequest {
        return try {
            val urlParam = URI(path)
            updateWith(urlParam)
        } catch (e: URISyntaxException) {
            val pieces = path.split("?", limit = 2)
            updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1))
//            copy(path = path)
        } catch (e: UnsupportedEncodingException) {
            val pieces = path.split("?", limit = 2)
            updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1))
//            copy(path = path)
        }
    }

    fun updateQueryParam(key: String, value: String): HttpRequest = copy(queryParams = queryParams.plus(key to value))

    fun updateBody(body: Value): HttpRequest = copy(body = body)

    fun updateBody(body: String?): HttpRequest = copy(body = parsedValue(body))

    fun updateWith(url: URI): HttpRequest {
//        val path = url.path
//        val queryParams = parseQuery(url.query)
//        return copy(path = path, queryParams = QueryParameters(queryParams))

        return updateWithPathAndQuery(url.path, url.query)
    }

    fun updateWithPathAndQuery(path: String, query: String?): HttpRequest {
        val queryParams = parseQuery(query)
        return copy(path = path, queryParams = QueryParameters(queryParams))
    }

    fun updateMethod(name: String): HttpRequest = copy(method = name.uppercase())

    fun updateHeader(key: String, value: String): HttpRequest = copy(headers = headers.plus(key to value))

    val bodyString: String
        get() = body.toString()

    fun getURL(baseURL: String?): String {
        val cleanBase = baseURL?.removeSuffix("/")
        val cleanPath = path?.removePrefix("/")
        val fullUrl = URLParts(concatNonNulls(cleanBase, cleanPath, "/")).withEncodedPathSegments()
        val queryPart = URLEncodedUtils.format(queryParams.paramPairs.map { BasicNameValuePair(it.first, it.second) }, Charsets.UTF_8)
        return concatNonNulls(fullUrl, queryPart, "?")
    }

    private fun concatNonNulls(first: String?, second: String?, separator: String) =
        listOf(first, second).filterNot { it.isNullOrBlank() }.joinToString(separator)

    fun toJSON(): JSONObjectValue {
        val requestMap = mutableMapOf()

        requestMap["path"] = path?.let { StringValue(it) } ?: StringValue("/")
        method?.let { requestMap["method"] = StringValue(it) }
            ?: throw ContractException("Can't serialise the request without a method.")

        setIfNotEmpty(requestMap, "query", queryParams.asMap())
        setIfNotEmpty(requestMap, "headers", headers)

        when {
            formFields.isNotEmpty() -> requestMap[FORM_FIELDS_JSON_KEY] =
                JSONObjectValue(formFields.mapValues { StringValue(it.value) })

            multiPartFormData.isNotEmpty() -> requestMap[MULTIPART_FORMDATA_JSON_KEY] =
                JSONArrayValue(multiPartFormData.map { it.toJSONObject() })

            else -> requestMap["body"] = body
        }

        return JSONObjectValue(requestMap)
    }

    fun setHeaders(addedHeaders: Map): HttpRequest = copy(headers = headers.plus(addedHeaders))

    fun toLogString(prefix: String = ""): String {
        val methodString = method ?: "NO_METHOD"

        val pathString = path ?: "NO_PATH"
        val queryParamString =
            queryParams.paramPairs.map { "${it.first}=${it.second}" }.joinToString("&").let { if (it.isNotEmpty()) "?$it" else it }
        val urlString = "$pathString$queryParamString"

        val firstLine = "$methodString $urlString"
        val headerString = headers.map { "${it.key}: ${it.value}" }.joinToString("\n")
        val bodyString = when {
            formFields.isNotEmpty() -> formFields.map { "${it.key}=${it.value}" }.joinToString("&")
            multiPartFormData.isNotEmpty() -> {
                multiPartFormData.joinToString("\n") { part -> part.toDisplayableValue() }
            }

            else -> body.toString()
        }.let { formatJson(it) }

        val firstPart = listOf(firstLine, headerString).joinToString("\n").trim()
        val requestString = listOf(firstPart, "", bodyString).joinToString("\n")
        return startLinesWith(requestString, prefix)
    }

    fun toPattern(): HttpRequestPattern {
        val pathForPattern = path ?: "/"

        return HttpRequestPattern(
            headersPattern = HttpHeadersPattern(mapToPattern(headers)),
            httpPathPattern = HttpPathPattern(pathToPattern(pathForPattern), pathForPattern),
            httpQueryParamPattern = HttpQueryParamPattern(mapToQueryParameterPattern(queryParams)),
            method = this.method,
            body = this.body.exactMatchElseType(),
            formFieldsPattern = mapToPattern(formFields),
            multiPartFormDataPattern = multiPartFormData.map { it.inferType() }
        )
    }

    private fun mapToPattern(map: Map): Map {
        return map.mapValues { (_, value) ->
            if (isPatternToken(value))
                parsedPattern(value)
            else
                ExactValuePattern(StringValue(value))
        }
    }

    private fun mapToQueryParameterPattern(queryParams: QueryParameters): Map {
        val queryParamGroups = queryParams.paramPairs.groupBy { it.first }
            .mapValues { (_, keyValuePairs) ->
                keyValuePairs.map { (_,value) ->
                    if (isPatternToken(value))
                        parsedPattern(value)
                    else
                        ExactValuePattern(StringValue(value))
                }
            }
        return queryParamGroups.map { (parameterKey, parameterPatterns) ->
            if(parameterPatterns.size > 1) {
                parameterKey to QueryParameterArrayPattern(parameterPatterns, parameterKey)
            }
            else {
                parameterKey to QueryParameterScalarPattern(parameterPatterns.single())
            }
        }.toMap()
    }

    fun buildKTORRequest(httpRequestBuilder: HttpRequestBuilder, url: URL?) {
        httpRequestBuilder.method = HttpMethod.parse(method as String)

        val listOfExcludedHeaders: List = listOfExcludedHeaders()

        withoutDuplicateHostHeader(headers, url)
            .map { Triple(it.key.trim(), it.key.trim().lowercase(), it.value.trim()) }
            .filter { (_, loweredKey, _) -> loweredKey !in listOfExcludedHeaders }
            .forEach { (key, _, value) ->
                httpRequestBuilder.header(key, value)
            }

        httpRequestBuilder.url.let {
            if (it.port == DEFAULT_PORT || it.port == it.protocol.defaultPort)
                httpRequestBuilder.header("Host", it.authority)
        }

        if(body !is NoBodyValue) {
            httpRequestBuilder.setBody(
                when {
                    formFields.isNotEmpty() -> {
                        val parameters = formFields.mapValues { listOf(it.value) }.toList()
                        FormDataContent(parametersOf(*parameters.toTypedArray()))
                    }

                    multiPartFormData.isNotEmpty() -> {
                        MultiPartFormDataContent(formData {
                            multiPartFormData.forEach { value ->
                                value.addTo(this)
                            }
                        })
                    }

                    else -> {
                        when {
                            headers.containsKey(CONTENT_TYPE) -> TextContent(
                                bodyString,
                                ContentType.parse(headers[CONTENT_TYPE] as String)
                            )

                            else -> TextContent(bodyString, ContentType.parse(body.httpContentType))
                        }
                    }
                }
            )
        }
    }

    private fun withoutDuplicateHostHeader(headers: Map, url: URL?): Map {
        if (url === null)
            return headers

        if (isNotIPAddress(url.host))
            return headers - "Host"

        return headers
    }

    private fun isNotIPAddress(host: String): Boolean {
        return !isIPAddress(host) && host != "localhost"
    }

    private fun isIPAddress(host: String): Boolean {
        return try {
            host.split(".").map { it.toInt() }.isNotEmpty()
        } catch (e: Throwable) {
            false
        }
    }

    fun loadFileContentIntoParts(): HttpRequest {
        val parts = multiPartFormData

        val newMultiPartFormData: List = parts.map { part ->
            when (part) {
                is MultiPartContentValue -> part
                is MultiPartFileValue -> {
                    val partFile = File(part.filename.removePrefix("@"))
                    val binaryContent = if (partFile.exists()) {
                        MultiPartContent(partFile)
                    } else {
                        MultiPartContent(StringPattern().generate(Resolver()).toStringLiteral())
                    }
                    part.copy(content = binaryContent)
                }
            }
        }

        return copy(multiPartFormData = newMultiPartFormData)
    }

    interface RequestNotRecognizedMessages {
        fun soap(soapActionHeaderValue: String, path: String): String

        fun xmlOverHttp(method: String, path: String): String

        fun restful(method: String, path: String): String
    }

    class LenientRequestNotRecognizedMessages : RequestNotRecognizedMessages {
        override fun soap(soapActionHeaderValue: String, path: String): String {
            return "No matching SOAP stub or contract found for SOAPAction $soapActionHeaderValue and path $path"
        }

        override fun xmlOverHttp(method: String, path: String): String {
            return "No matching XML-REST stub or contract found for method $method and path $path"
        }

        override fun restful(method: String, path: String): String {
            return "No matching REST stub or contract found for method $method and path $path"
        }
    }

    class StrictRequestNotRecognizedMessages : RequestNotRecognizedMessages {
        override fun soap(soapActionHeaderValue: String, path: String): String {
            return "No matching SOAP stub (strict mode) found for SOAPAction $soapActionHeaderValue and path $path"
        }

        override fun xmlOverHttp(method: String, path: String): String {
            return "No matching XML-REST stub (strict mode) found for method $method and path $path"
        }

        override fun restful(method: String, path: String): String {
            return "No matching REST stub (strict mode) found for method $method and path $path"
        }
    }

    fun requestNotRecognized(requestNotRecognizedMessages: RequestNotRecognizedMessages): String {
        val soapActionHeader = "SOAPAction"

        val method = this.method!!
        val path = this.path ?: "/"

        return when {
            this.headers.containsKey(soapActionHeader) ->
                requestNotRecognizedMessages.soap(this.headers.getValue(soapActionHeader), path)

            this.body is XMLNode ->
                requestNotRecognizedMessages.xmlOverHttp(method, path)

            else ->
                requestNotRecognizedMessages.restful(method, path)
        }
    }

    fun requestNotRecognized(): String {
        return requestNotRecognized(LenientRequestNotRecognizedMessages())
    }

    fun requestNotRecognizedInStrictMode(): String {
        return requestNotRecognized(StrictRequestNotRecognizedMessages())
    }

    fun withoutDynamicHeaders(): HttpRequest = copy(headers = headers.withoutDynamicHeaders())

    fun substituteDictionaryValues(dictionary: Dictionary, forceSubstitution: Boolean = false, httpPathPattern: HttpPathPattern? = null): HttpRequest {
        val updatedHeaders = dictionary.substituteDictionaryValues(this.headers, forceSubstitution = forceSubstitution)
        val queryParams = queryParams.substituteDictionaryValues(dictionary, forceSubstitution)
        val updatedBody = dictionary.substituteDictionaryValues(this.body, forceSubstitution = forceSubstitution)
        val updatedPath = when {
            this.path != null  && httpPathPattern != null -> substituteDictionaryValuesInPath(dictionary, httpPathPattern)
            else -> this.path
        }

        return this.copy(headers = updatedHeaders, body= updatedBody, queryParams = queryParams, path = updatedPath)
    }

    private fun substituteDictionaryValuesInPath(dictionary: Dictionary, httpPathPattern: HttpPathPattern): String {
        if (this.path !is String)
            throw ContractException("Expected path to be a string value")

        val prefix = "/".takeIf { this.path.startsWith("/") }.orEmpty()
        val postfix = "/".takeIf { this.path.endsWith("/") }.orEmpty()
        val actualPathSegments = this.path.trim('/').split("/").filter { it.isNotEmpty() }

        return httpPathPattern.pathSegmentPatterns.zip(actualPathSegments).map { (segmentPattern, actual) ->
            if (segmentPattern.key != null && dictionary.contains(segmentPattern.key)) {
                dictionary.substituteDictionaryValues(segmentPattern.key, "(${segmentPattern.pattern.typeName})")
            } else {
                actual
            }
        }.joinToString("/", prefix = prefix, postfix = postfix)
    }
}

private fun setIfNotEmpty(dest: MutableMap, key: String, data: Map) {
    if (data.isNotEmpty())
        dest[key] = JSONObjectValue(data.mapValues { StringValue(it.value) })
}

fun nativeString(json: Map, key: String): String? {
    val keyValue = json[key] ?: return null

    if (keyValue !is StringValue)
        throw ContractException("Expected $key to be a string value")

    return keyValue.string
}

fun requestFromJSON(json: Map) =
    HttpRequest()
        .updateMethod(
            nativeString(json, "method")
                ?: throw ContractException("http-request must contain a key named method whose value is the method in the request")
        )
        .updatePath(nativeString(json, "path") ?: "/")
        .updateQueryParams(nativeStringStringMap(json, "query"))
        .setHeaders(nativeStringStringMap(json, "headers"))
        .let { httpRequest ->
            when {
                FORM_FIELDS_JSON_KEY in json -> httpRequest.copy(
                    formFields = nativeStringStringMap(
                        json,
                        FORM_FIELDS_JSON_KEY
                    )
                )

                MULTIPART_FORMDATA_JSON_KEY in json -> {
                    val parts = arrayValue(
                        json.getValue(MULTIPART_FORMDATA_JSON_KEY),
                        "$MULTIPART_FORMDATA_JSON_KEY must be a json array."
                    )

                    val multiPartData: List = parts.list.map {
                        val part = objectValue(it, "All multipart parts must be json object values.")

                        val multiPartSpec = part.jsonObject
                        val name = nativeString(multiPartSpec, "name")
                            ?: throw ContractException("One of the multipart entries does not have a name key")

                        parsePartType(multiPartSpec, name)
                    }

                    httpRequest.copy(multiPartFormData = httpRequest.multiPartFormData.plus(multiPartData))
                }

                "body" in json -> {
                    val body = notNull(
                        json.getOrDefault("body", NullValue),
                        "Either body should have a value or the key should be absent from http-response"
                    )
                    httpRequest.updateBody(body)
                }

                else -> httpRequest
            }
        }

private fun parsePartType(multiPartSpec: Map, name: String): MultiPartFormDataValue {
    return when {
        multiPartSpec.containsKey("content") -> MultiPartContentValue(
            name,
            multiPartSpec.getValue("content"),
            specifiedContentType = multiPartSpec["contentType"]?.toStringLiteral()
        )

        multiPartSpec.containsKey("filename") -> MultiPartFileValue(
            name,
            multiPartSpec.getValue("filename").toStringLiteral().removePrefix("@"),
            multiPartSpec["contentType"]?.toStringLiteral(),
            multiPartSpec["contentEncoding"]?.toStringLiteral()
        )

        else -> throw ContractException("Multipart entry $name must have either a content key or a filename key")
    }
}

fun objectValue(value: Value, errorMessage: String): JSONObjectValue {
    if (value !is JSONObjectValue)
        throw ContractException(errorMessage)

    return value
}

fun arrayValue(value: Value, errorMessage: String): JSONArrayValue {
    if (value !is JSONArrayValue)
        throw ContractException(errorMessage)

    return value
}

fun notNull(value: Value, errorMessage: String): Value {
    if (value is NullValue)
        throw ContractException(errorMessage)

    return value
}

internal fun nativeStringStringMap(json: Map, key: String): Map {
    val queryValue = json[key] ?: return emptyMap()

    if (queryValue !is JSONObjectValue)
        throw ContractException("Expected $key to be a json object")

    return queryValue.jsonObject.mapValues { it.value.toString() }
}

internal fun startLinesWith(str: String, startValue: String) =
    str.split("\n").joinToString("\n") { "$startValue$it" }

fun toGherkinClauses(request: HttpRequest): Triple, Map, ExampleDeclarations> {
    return Triple(
        emptyList(),
        emptyMap(),
        UseExampleDeclarations()
    ).let { (clauses, types, exampleDeclaration) ->
        val (newClauses, newTypes, newExamples) = firstLineToGherkin(request, types, exampleDeclaration)
        Triple(clauses.plus(newClauses), newTypes, newExamples)
    }.let { (clauses, types, examples) ->
        val (newClauses, newTypes, newExamples) = headersToGherkin(
            request.headers,
            "request-header",
            types,
            examples,
            When
        )
        Triple(clauses.plus(newClauses), newTypes, newExamples)
    }.let { (clauses, types, examples) ->
        val (newClauses, newTypes, newExamples) = bodyToGherkin(request, types, examples)
        Triple(clauses.plus(newClauses), newTypes, newExamples)
    }.let { (clauses, types, examples) ->
        Triple(clauses, types, examples)
    }
}

fun stringMapToValueMap(stringStringMap: Map) =
    stringStringMap.mapValues { guessType(parsedValue(it.value)) }

fun queryParamsToValueMap(queryParams: QueryParameters) =
    queryParams.paramPairs.map { (key, value) -> key to guessType(parsedValue(value)) }.toMap()

fun bodyToGherkin(
    request: HttpRequest,
    types: Map,
    exampleDeclarations: ExampleDeclarations
): Triple, Map, ExampleDeclarations> {
    return when {
        request.multiPartFormData.isNotEmpty() -> multiPartFormDataToGherkin(
            request.multiPartFormData,
            types,
            exampleDeclarations
        )

        request.formFields.isNotEmpty() -> formFieldsToGherkin(request.formFields, types, exampleDeclarations)
        else -> requestBodyToGherkinClauses(request.body, types, exampleDeclarations)
    }
}

fun multiPartFormDataToGherkin(
    multiPartFormData: List,
    types: Map,
    exampleDeclarations: ExampleDeclarations
): Triple, Map, ExampleDeclarations> {
    return multiPartFormData.fold(
        Triple(
            emptyList(),
            types,
            exampleDeclarations
        )
    ) { (clauses, newTypes, examples), part ->
        part.toClauseData(clauses, newTypes, examples)
    }
}

fun firstLineToGherkin(
    request: HttpRequest,
    types: Map,
    exampleDeclarationsStore: ExampleDeclarations
): Triple, Map, ExampleDeclarations> {
    val method = request.method ?: throw ContractException("Can't generate a spec file without the http method.")

    if (request.path == null)
        throw ContractException("Can't generate a contract without the url.")

    val (query, newTypes, newExamples) = when {
        request.queryParams.isNotEmpty() -> {
            val (dictionaryType, newTypes, examples) = dictionaryToDeclarations(
                queryParamsToValueMap(request.queryParams),
                types,
                exampleDeclarationsStore
            )

            val query =
                dictionaryType.entries.joinToString("&") { (key, typeDeclaration) -> "$key=${typeDeclaration.pattern}" }
            Triple("?$query", newTypes, examples)
        }

        else -> Triple("", emptyMap(), exampleDeclarationsStore)
    }

    val path = "${escapeSpaceInPath(request.path)}$query"

    val requestLineGherkin = GherkinClause("$method $path", When)

    return Triple(listOf(requestLineGherkin), newTypes, newExamples)
}

fun formFieldsToGherkin(
    formFields: Map,
    types: Map,
    exampleDeclarations: ExampleDeclarations
): Triple, Map, ExampleDeclarations> {
    val (dictionaryTypeMap, newTypes, newExamples) = dictionaryToDeclarations(
        stringMapToValueMap(formFields),
        types,
        exampleDeclarations
    )

    val formFieldClauses =
        dictionaryTypeMap.entries.map { entry -> GherkinClause("form-field ${entry.key} ${entry.value.pattern}", When) }

    return Triple(formFieldClauses, newTypes, exampleDeclarations.plus(newExamples))
}

fun listOfExcludedHeaders(): List = HttpHeaders.UnsafeHeadersList.plus(
    arrayOf(
        HttpHeaders.ContentLength,
        HttpHeaders.ContentType,
        HttpHeaders.TransferEncoding,
        HttpHeaders.Upgrade
    )
).distinct().map { it.lowercase() }

fun escapeSpaceInPath(path: String): String {
    return path.split("/").joinToString("/") { segment ->
        URLEncoder.encode(segment, StandardCharsets.UTF_8.toString()).replace("+", "%20")
    }
}

fun urlDecodePathSegments(url: String): String {
    if("://" !in url)
        return decodePath(url)

    return URLParts(url).withDecodedPathSegments()
}

fun decodePath(path: String): String {
    return path.split("/").joinToString("/") { segment ->
        URLDecoder.decode(segment, StandardCharsets.UTF_8.toString())
    }
}

fun singleLineJson(json: String): String {
    return json
        .replace(Regex("\\s*([{}\\[\\]:,])\\s*"), "$1") // Remove spaces around structural characters
        .replace(Regex("\\s+"), " ") // Replace any remaining sequences of whitespace with a single space
}

fun formatJson(json: String): String {
    return if (getBooleanValue(SPECMATIC_PRETTY_PRINT, true))
        json
    else
        singleLineJson(json)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy