Skip to content

Commit

Permalink
Merge branch 'refs/heads/main' into triangulate_bcc_commands
Browse files Browse the repository at this point in the history
  • Loading branch information
nashjain committed Aug 23, 2024
2 parents 7962cc1 + 0f2682b commit 9f6610e
Show file tree
Hide file tree
Showing 104 changed files with 4,311 additions and 648 deletions.
12 changes: 6 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ FROM ubuntu:22.04

WORKDIR /usr/src/app

# Install OpenJDK 17, git, and curl in a single RUN command
RUN apt-get update && \
apt-get install -y openjdk-17-jre && \
rm -rf /var/lib/apt/lists/*

RUN apt-get update && \
apt-get install -y git && \
rm -rf /var/lib/apt/lists/*
apt-get install -y --no-install-recommends openjdk-17-jre git curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Copy the Specmatic JAR file into the container
COPY ./application/build/libs/specmatic.jar /usr/src/app/specmatic.jar

# Set the entrypoint to run the Specmatic JAR
ENTRYPOINT ["java", "-jar", "/usr/src/app/specmatic.jar"]

CMD []
2 changes: 1 addition & 1 deletion application/src/main/kotlin/application/StubCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ class StubCommand : Callable<Unit> {

private fun startServer() {
val workingDirectory = WorkingDirectory()
val stubData = stubLoaderEngine.loadStubs(contractSources, exampleDirs)
val stubData = stubLoaderEngine.loadStubs(contractSources, exampleDirs, specmaticConfigPath)

val certInfo = CertInfo(keyStoreFile, keyStoreDir, keyStorePassword, keyStoreAlias, keyPassword)

Expand Down
13 changes: 10 additions & 3 deletions application/src/main/kotlin/application/StubLoaderEngine.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package application

import io.specmatic.core.Feature
import io.specmatic.core.getConfigFileName
import io.specmatic.core.loadSpecmaticConfigOrDefault
import io.specmatic.core.log.logger
import io.specmatic.core.utilities.ContractPathData
import io.specmatic.mock.ScenarioStub
Expand All @@ -11,15 +13,20 @@ import java.io.File

@Component
class StubLoaderEngine {
fun loadStubs(contractPathDataList: List<ContractPathData>, dataDirs: List<String>): List<Pair<Feature, List<ScenarioStub>>> {
fun loadStubs(contractPathDataList: List<ContractPathData>, dataDirs: List<String>, specmaticConfigPath: String? = null): List<Pair<Feature, List<ScenarioStub>>> {
contractPathDataList.forEach { contractPath ->
if(!File(contractPath.path).exists()) {
logger.log("$contractPath does not exist.")
}
}

val specmaticConfig = loadSpecmaticConfigOrDefault(specmaticConfigPath ?: getConfigFileName())

return when {
dataDirs.isNotEmpty() -> loadContractStubsFromFiles(contractPathDataList, dataDirs)
else -> loadContractStubsFromImplicitPaths(contractPathDataList)
dataDirs.isNotEmpty() -> {
loadContractStubsFromFiles(contractPathDataList, dataDirs, specmaticConfig)
}
else -> loadContractStubsFromImplicitPaths(contractPathDataList, specmaticConfig)
}
}
}
6 changes: 4 additions & 2 deletions application/src/test/kotlin/application/StubCommandTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ internal class StubCommandTest {
every {
stubLoaderEngine.loadStubs(
listOf(contractPath).map { ContractPathData("", it) },
emptyList()
emptyList(),
any()
)
}.returns(stubInfo)

Expand Down Expand Up @@ -201,7 +202,8 @@ internal class StubCommandTest {
every {
stubLoaderEngine.loadStubs(
listOf(contractPath).map { ContractPathData("", it) },
emptyList()
emptyList(),
any()
)
}.returns(stubInfo)

Expand Down
78 changes: 64 additions & 14 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import io.cucumber.messages.IdGenerator.Incrementing
import io.cucumber.messages.types.*
import io.cucumber.messages.types.Examples
import io.specmatic.core.utilities.*
import io.specmatic.stub.stringToMockScenario
import io.swagger.v3.oas.models.*
import io.swagger.v3.oas.models.headers.Header
import io.swagger.v3.oas.models.info.Info
Expand Down Expand Up @@ -267,10 +266,11 @@ data class Feature(
fun matchingStub(
request: HttpRequest,
response: HttpResponse,
mismatchMessages: MismatchMessages = DefaultMismatchMessages
mismatchMessages: MismatchMessages = DefaultMismatchMessages,
dictionary: Map<String, Value> = emptyMap()
): HttpStubData {
try {
val results = stubMatchResult(request, response, mismatchMessages)
val results = stubMatchResult(request, response.substituteDictionaryValues(dictionary), mismatchMessages)

return results.find {
it.first != null
Expand Down Expand Up @@ -439,17 +439,67 @@ data class Feature(
fun matchingStub(
scenarioStub: ScenarioStub,
mismatchMessages: MismatchMessages = DefaultMismatchMessages
): HttpStubData =
matchingStub(
scenarioStub.request,
scenarioStub.response,
mismatchMessages
).copy(
delayInMilliseconds = scenarioStub.delayInMilliseconds,
requestBodyRegex = scenarioStub.requestBodyRegex?.let { Regex(it) },
stubToken = scenarioStub.stubToken,
data = scenarioStub.data
)
): HttpStubData {
val dictionary = specmaticConfig.stub.dictionary?.let { parsedJSONObject(File(it).readText()).jsonObject } ?: emptyMap()

val scenarioStub = scenarioStub.copy(dictionary = dictionary)

if(scenarios.isEmpty())
throw ContractException("No scenarios found in feature $name ($path)")

return if(scenarioStub.partial != null) {
val results = scenarios.asSequence().map { scenario ->
scenario.matchesTemplate(scenarioStub.partial) to scenario
}

val matchingScenario = results.filter { it.first is Result.Success }.map { it.second }.firstOrNull()

if(matchingScenario != null) {
val requestTypeWithAncestors =
matchingScenario.httpRequestPattern.copy(
headersPattern = matchingScenario.httpRequestPattern.headersPattern.copy(
ancestorHeaders = matchingScenario.httpRequestPattern.headersPattern.pattern
)
)

val responseTypeWithAncestors =
matchingScenario.httpResponsePattern.copy(
headersPattern = matchingScenario.httpResponsePattern.headersPattern.copy(
ancestorHeaders = matchingScenario.httpResponsePattern.headersPattern.pattern
)
)

HttpStubData(
requestTypeWithAncestors,
HttpResponse(),
matchingScenario.resolver,
responsePattern = responseTypeWithAncestors,
scenario = matchingScenario,
partial = scenarioStub.partial.copy(response = scenarioStub.partial.response.substituteDictionaryValues(scenarioStub.dictionary)),
data = scenarioStub.data,
dictionary = scenarioStub.dictionary
)
}
else {
val failures = Results(results.map { it.first }.filterIsInstance<Result.Failure>().toList()).withoutFluff()

throw NoMatchingScenario(failures, msg = "Could not load partial example ${scenarioStub.filePath}")
}
} else {
matchingStub(
scenarioStub.request,
scenarioStub.response,
mismatchMessages,
scenarioStub.dictionary
).copy(
delayInMilliseconds = scenarioStub.delayInMilliseconds,
requestBodyRegex = scenarioStub.requestBodyRegex?.let { Regex(it) },
stubToken = scenarioStub.stubToken,
data = scenarioStub.data,
dictionary = scenarioStub.dictionary
)
}
}

fun clearServerState() {
serverState = emptyMap()
Expand Down
43 changes: 42 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpHeadersPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ data class HttpHeadersPattern(
)
}


val headersWithRelevantKeys = when {
ancestorHeaders != null -> withoutIgnorableHeaders(headers, ancestorHeaders)
else -> withoutContentTypeGeneratedBySpecmatic(headers, pattern)
Expand Down Expand Up @@ -287,6 +286,48 @@ data class HttpHeadersPattern(

}
}

fun fillInTheBlanks(headers: Map<String, String>, dictionary: Map<String, Value>, resolver: Resolver): ReturnValue<Map<String, String>> {
val headersToConsider = ancestorHeaders?.let {
headers.filterKeys { key -> key in it || "$key?" in it }
} ?: headers

val map: Map<String, ReturnValue<String>> = headersToConsider.mapValues { (headerName, headerValue) ->
val headerPattern = pattern.get(headerName) ?: pattern.get("$headerName?") ?: return@mapValues HasFailure(Result.Failure(resolver.mismatchMessages.unexpectedKey("header", headerName)))

if(headerName in dictionary) {
val dictionaryValue = dictionary.getValue(headerName)
val matchResult = headerPattern.matches(dictionaryValue, resolver)

if(matchResult is Result.Failure)
HasFailure(matchResult)
else
HasValue(dictionaryValue.toStringLiteral())
} else {
exception { headerPattern.parse(headerValue, resolver) }?.let { return@mapValues HasException(it) }

HasValue(headerValue)
}.breadCrumb(headerName)
}

val headersInPartialR = map.mapFold()

val missingHeadersR = pattern.filterKeys { !it.endsWith("?") && it !in headers }.mapValues { (headerName, headerPattern) ->
val generatedValue = dictionary[headerName]?.let { dictionaryValue ->
val matchResult = headerPattern.matches(dictionaryValue, resolver)
if(matchResult is Result.Failure)
HasFailure(matchResult)
else
HasValue(dictionaryValue.toStringLiteral())
} ?: HasValue(headerPattern.generate(resolver).toStringLiteral())

generatedValue.breadCrumb(headerName)
}.mapFold()

return headersInPartialR.combine(missingHeadersR) { headersInPartial, missingHeaders ->
headersInPartial + missingHeaders
}
}
}

private fun parseOrString(pattern: Pattern, sampleValue: String, resolver: Resolver) =
Expand Down
17 changes: 16 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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
Expand Down Expand Up @@ -157,7 +159,7 @@ data class HttpRequest(
}

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

val firstPart = listOf(firstLine, headerString).joinToString("\n").trim()
val requestString = listOf(firstPart, "", bodyString).joinToString("\n")
Expand Down Expand Up @@ -619,3 +621,16 @@ fun decodePath(path: String): String {
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)
}
8 changes: 5 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.specmatic.core.pattern.*
import io.specmatic.core.value.StringValue
import io.ktor.util.*
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.Value

private const val MULTIPART_FORMDATA_BREADCRUMB = "MULTIPART-FORMDATA"
private const val FORM_FIELDS_BREADCRUMB = "FORM-FIELDS"
Expand Down Expand Up @@ -250,7 +251,7 @@ data class HttpRequestPattern(
resolver
)

requestPattern.copy(headersPattern = HttpHeadersPattern(headersFromRequest))
requestPattern.copy(headersPattern = HttpHeadersPattern(headersFromRequest, ancestorHeaders = this.headersPattern.pattern))
}

requestPattern = attempt(breadCrumb = "BODY") {
Expand Down Expand Up @@ -736,9 +737,10 @@ data class HttpRequestPattern(
runningRequest: HttpRequest,
originalRequest: HttpRequest,
resolver: Resolver,
data: JSONObjectValue
data: JSONObjectValue,
dictionary: Map<String, Value>
): Substitution {
return Substitution(runningRequest, originalRequest, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver, data)
return Substitution(runningRequest, originalRequest, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver, data, dictionary)
}

}
Expand Down
48 changes: 47 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.specmatic.conversions.guessType
import io.specmatic.core.GherkinSection.Then
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.pattern.Pattern
import io.specmatic.core.pattern.isPatternToken
import io.specmatic.core.pattern.parsedValue
import io.specmatic.core.value.*

Expand Down Expand Up @@ -58,7 +59,7 @@ data class HttpResponse(

val firstPart = listOf(statusLine, headerString).joinToString("\n").trim()

val formattedBody = body.toStringLiteral()
val formattedBody = formatJson(body.toStringLiteral())

val responseString = listOf(firstPart, "", formattedBody).joinToString("\n")
return startLinesWith(responseString, prefix)
Expand Down Expand Up @@ -167,8 +168,53 @@ data class HttpResponse(
}

private fun headersHasOnlyTextPlainContentTypeHeader() = headers.size == 1 && headers[CONTENT_TYPE] == "text/plain"

fun substituteDictionaryValues(value: JSONArrayValue, dictionary: Map<String, Value>): Value {
val newList = value.list.map { value ->
substituteDictionaryValues(value, dictionary)
}

return value.copy(newList)
}

fun substituteDictionaryValues(value: JSONObjectValue, dictionary: Map<String, Value>): Value {
val newMap = value.jsonObject.mapValues { (key, value) ->
if(value is StringValue && isVanillaPatternToken(value.string) && key in dictionary) {
dictionary.getValue(key)
} else value
}

return value.copy(newMap)
}

fun substituteDictionaryValues(value: Value, dictionary: Map<String, Value>): Value {
return when (value) {
is JSONObjectValue -> {
substituteDictionaryValues(value, dictionary)
}
is JSONArrayValue -> {
substituteDictionaryValues(value, dictionary)
}
else -> value
}
}

fun substituteDictionaryValues(dictionary: Map<String, Value>): HttpResponse {
val updatedHeaders = headers.mapValues { (headerName, headerValue) ->
if(isVanillaPatternToken(headerValue) && headerName in dictionary) {
dictionary.getValue(headerName).toStringLiteral()
} else headerValue
}

val updatedBody = substituteDictionaryValues(body, dictionary)

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

}

fun isVanillaPatternToken(token: String) = isPatternToken(token) && token.indexOf(':') < 0

fun nativeInteger(json: Map<String, Value>, key: String): Int? {
val keyValue = json[key] ?: return null

Expand Down
Loading

0 comments on commit 9f6610e

Please sign in to comment.