Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- NetworkMock: mocked responses now serve the `Content-Type` derived from the spec's declared
media type (`responses.<code>.content.<mediaType>`, previously always hardcoded to
`application/json`) plus any additional headers declared on `responses.<code>.headers` — a
header's literal `example` value is served as-is, mirroring how query-parameter matching
already reads a parameter's `example`. `$ref`'d headers resolve against `components.headers`.
`MockResponse` gains `contentType` (default `"application/json"`) and `headers` (default
empty) properties. (`devview-networkmock-core`, `devview-networkmock-ktor`, #87)
- NetworkMock: a "Reload Config" toolbar action, and `MockConfigRepository.invalidate()` /
`NetworkMockViewModel.reloadConfiguration()`, to re-read and re-parse the configured OpenAPI
specs without restarting the app — previously the parsed config was cached forever after the
Expand Down
12 changes: 9 additions & 3 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -89,25 +89,31 @@ package com.worldline.devview.networkmock.core.model {
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class MockResponse {
ctor public MockResponse(int statusCode, String exampleName, String displayName, String content);
ctor public MockResponse(int statusCode, String exampleName, String displayName, String content, optional String contentType, optional java.util.Map<java.lang.String,java.lang.String> headers);
method public int component1();
method public String component2();
method public String component3();
method public String component4();
method public com.worldline.devview.networkmock.core.model.MockResponse copy(optional int statusCode, optional String exampleName, optional String displayName, optional String content);
method public String component5();
method public java.util.Map<java.lang.String,java.lang.String> component6();
method public com.worldline.devview.networkmock.core.model.MockResponse copy(optional int statusCode, optional String exampleName, optional String displayName, optional String content, optional String contentType, optional java.util.Map<java.lang.String,java.lang.String> headers);
method @InaccessibleFromKotlin public String getContent();
method @InaccessibleFromKotlin public String getContentType();
method @InaccessibleFromKotlin public String getDisplayName();
method @InaccessibleFromKotlin public String getExampleName();
method @InaccessibleFromKotlin public java.util.Map<java.lang.String,java.lang.String> getHeaders();
method @InaccessibleFromKotlin public int getStatusCode();
property public String content;
property public String contentType;
property public String displayName;
property public String exampleName;
property public java.util.Map<java.lang.String,java.lang.String> headers;
property public int statusCode;
field public static final com.worldline.devview.networkmock.core.model.MockResponse.Companion Companion;
}

public static final class MockResponse.Companion {
method public com.worldline.devview.networkmock.core.model.MockResponse create(int statusCode, String exampleName, String content, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.String> statusTextProvider);
method public com.worldline.devview.networkmock.core.model.MockResponse create(int statusCode, String exampleName, String content, optional String contentType, optional java.util.Map<java.lang.String,java.lang.String> headers, optional kotlin.jvm.functions.Function1<? super java.lang.Integer,java.lang.String> statusTextProvider);
}

@kotlinx.serialization.Serializable public final class NetworkMockState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import kotlinx.serialization.Serializable
* @property displayName Human-readable name for UI display (e.g. `"Success (200)"`,
* `"Not Found - Detailed (404)"`)
* @property content The raw response body, read from the example's `externalValue` file
* @property contentType The response's declared media type (the key under `responses.<code>.content`
* in the spec, e.g. `"application/json"`). Defaults to `"application/json"` for response
* variants built without one (e.g. hand-built test fixtures).
* @property headers Response headers declared on `responses.<code>.headers` in the spec
* (name to literal `example` value). Does not include `Content-Type`, which is carried
* separately by [contentType]. Empty if the spec declares none.
* @see com.worldline.devview.networkmock.core.repository.MockConfigRepository
*/
@Immutable
Expand All @@ -26,7 +32,9 @@ public data class MockResponse(
val statusCode: Int,
val exampleName: String,
val displayName: String,
val content: String
val content: String,
val contentType: String = "application/json",
val headers: Map<String, String> = emptyMap()
) {
public companion object {
/**
Expand All @@ -42,6 +50,8 @@ public data class MockResponse(
* @param statusCode The HTTP status code
* @param exampleName The OpenAPI example name
* @param content The raw response body
* @param contentType The response's declared media type. Defaults to `"application/json"`.
* @param headers Response headers declared on `responses.<code>.headers`. Defaults to none.
* @param statusTextProvider Optional lambda that maps a status code to its display
* text. Defaults to the built-in [getStatusText] mapping.
* @return A [MockResponse] with a generated [MockResponse.displayName]
Expand All @@ -50,6 +60,8 @@ public data class MockResponse(
statusCode: Int,
exampleName: String,
content: String,
contentType: String = "application/json",
headers: Map<String, String> = emptyMap(),
statusTextProvider: (Int) -> String = ::getStatusText
): MockResponse = MockResponse(
statusCode = statusCode,
Expand All @@ -59,7 +71,9 @@ public data class MockResponse(
exampleName = exampleName,
statusTextProvider = statusTextProvider
),
content = content
content = content,
contentType = contentType,
headers = headers
)

@Suppress("DocumentationOverPrivateFunction")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,21 @@ internal data class ParameterObject(
@Serializable
internal data class ResponseObject(
@SerialName("\$ref") val ref: String? = null,
val content: Map<String, MediaTypeObject> = emptyMap()
val content: Map<String, MediaTypeObject> = emptyMap(),
val headers: Map<String, HeaderObject> = emptyMap()
)

/**
* A response header declaration, or a `$ref` to one under `components.headers`.
*
* [example] is the only field this parser reads — the literal value served as the header's
* value — mirroring how [ParameterObject.example] is read for query parameters rather than a
* `schema`-nested value.
*/
@Serializable
internal data class HeaderObject(
@SerialName("\$ref") val ref: String? = null,
val example: String? = null
)

@Serializable
Expand All @@ -118,7 +132,8 @@ internal data class ExampleObject(
internal data class ComponentsObject(
val parameters: Map<String, ParameterObject> = emptyMap(),
val responses: Map<String, ResponseObject> = emptyMap(),
val examples: Map<String, ExampleObject> = emptyMap()
val examples: Map<String, ExampleObject> = emptyMap(),
val headers: Map<String, HeaderObject> = emptyMap()
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import com.worldline.devview.networkmock.core.model.ApiSpec
import com.worldline.devview.networkmock.core.model.Operation
import kotlinx.serialization.json.Json

/**
* A resolved response variant: the file path to load via [NetworkMockResourceLoader.load],
* its declared media type, and any headers declared on the enclosing `responses.<code>` —
* everything [com.worldline.devview.networkmock.core.repository.MockConfigRepository] needs
* to build a [com.worldline.devview.networkmock.core.model.MockResponse] without touching an
* OpenAPI-shaped type itself (see #73's pure-seam requirement).
*/
internal data class ResolvedResponse(
val path: String,
val contentType: String,
val headers: Map<String, String>
)

/**
* Parses an OpenAPI 3.x document (JSON or YAML) into DevView's internal model.
*
Expand All @@ -29,12 +42,11 @@ import kotlinx.serialization.json.Json
internal object OpenApiParser {
/**
* @property apiSpec The public model built from the document.
* @property responseIndex `operationId -> statusCode -> exampleName -> resolved
* externalValue path`, ready to pass straight to [NetworkMockResourceLoader.load].
* @property responseIndex `operationId -> statusCode -> exampleName -> `[ResolvedResponse].
*/
data class ParsedSpec(
val apiSpec: ApiSpec,
val responseIndex: Map<String, Map<Int, Map<String, String>>>
val responseIndex: Map<String, Map<Int, Map<String, ResolvedResponse>>>
)

/**
Expand All @@ -52,7 +64,7 @@ internal object OpenApiParser {
val document = decodeDocument(path = specPath, bytes = resourceLoader.load(path = specPath))

val operations = mutableListOf<Operation>()
val responseIndex = mutableMapOf<String, Map<Int, Map<String, String>>>()
val responseIndex = mutableMapOf<String, Map<Int, Map<String, ResolvedResponse>>>()

for ((path, pathItem) in document.paths) {
for ((method, rawOperation) in pathItem.operationsByMethod()) {
Expand Down Expand Up @@ -147,8 +159,8 @@ internal object OpenApiParser {
suspend fun resolveResponseIndex(
responses: Map<String, ResponseObject>,
document: OpenApiDocument
): Map<Int, Map<String, String>> {
val result = mutableMapOf<Int, Map<String, String>>()
): Map<Int, Map<String, ResolvedResponse>> {
val result = mutableMapOf<Int, Map<String, ResolvedResponse>>()
for ((codeText, rawResponse) in responses) {
val statusCode = codeText.toIntOrNull() ?: continue
val response = if (rawResponse.ref != null) {
Expand All @@ -160,9 +172,11 @@ internal object OpenApiParser {
rawResponse
}

val examplesForCode = mutableMapOf<String, String>()
for (mediaType in response.content.values) {
for ((exampleName, rawExample) in mediaType.examples) {
val headers = resolveHeaders(raw = response.headers, document = document)

val examplesForCode = mutableMapOf<String, ResolvedResponse>()
for ((mediaType, media) in response.content) {
for ((exampleName, rawExample) in media.examples) {
val example = if (rawExample.ref != null) {
resolveRef(
ref = rawExample.ref,
Expand All @@ -172,9 +186,10 @@ internal object OpenApiParser {
rawExample
}
val externalValue = example.externalValue ?: continue
examplesForCode[exampleName] = resolvePath(
baseDir = baseDir,
ref = externalValue
examplesForCode[exampleName] = ResolvedResponse(
path = resolvePath(baseDir = baseDir, ref = externalValue),
contentType = mediaType,
headers = headers
)
}
}
Expand All @@ -185,6 +200,21 @@ internal object OpenApiParser {
return result
}

/** Resolves each declared header's `$ref` (if any) down to its literal `example` value. */
@Suppress("DocumentationOverPrivateFunction")
private suspend fun resolveHeaders(
raw: Map<String, HeaderObject>,
document: OpenApiDocument
): Map<String, String> = raw
.mapNotNull { (name, rawHeader) ->
val header = if (rawHeader.ref != null) {
resolveRef(ref = rawHeader.ref, document = document) { it.components.headers }
} else {
rawHeader
}
header.example?.let { name to it }
}.toMap()

/**
* Resolves a `$ref` string to its target, either locally (within [document]) or in
* another file, exactly one level deep — the resolved object's own `$ref` (if any)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.worldline.devview.networkmock.core.model.MockMatch
import com.worldline.devview.networkmock.core.model.MockResponse
import com.worldline.devview.networkmock.core.model.OperationKey
import com.worldline.devview.networkmock.core.openapi.OpenApiParser
import com.worldline.devview.networkmock.core.openapi.ResolvedResponse
import kotlinx.serialization.SerializationException

private val logger = Logger.withTag(tag = "DevViewNetworkMock")
Expand All @@ -22,7 +23,7 @@ private val logger = Logger.withTag(tag = "DevViewNetworkMock")
*
* Format parsing is delegated entirely to [OpenApiParser] — this repository never sees
* OpenAPI-shaped types itself, only the resulting [MockConfiguration] and a plain response
* index (`specId -> operationId -> statusCode -> exampleName -> file path`) used by
* index (`specId -> operationId -> statusCode -> exampleName -> `[ResolvedResponse]) used by
* [discoverResponseFiles] and [loadMockResponse].
*
* This repository is intentionally agnostic of any specific HTTP client implementation — it
Expand All @@ -44,9 +45,10 @@ public class MockConfigRepository(
// Cache the loaded configuration to avoid re-parsing every spec on every call.
private var cachedConfig: MockConfiguration? = null

/** `specId -> operationId -> statusCode -> exampleName -> resolved response file path`. */
/** `specId -> operationId -> statusCode -> exampleName -> ResolvedResponse`. */
@Suppress("DocumentationOverPrivateProperty")
private var responseIndex: Map<String, Map<String, Map<Int, Map<String, String>>>> = emptyMap()
private var responseIndex: Map<String, Map<String, Map<Int, Map<String, ResolvedResponse>>>> =
emptyMap()

/**
* Clears the cached configuration, forcing the next [loadConfiguration] call to re-read
Expand Down Expand Up @@ -171,9 +173,9 @@ public class MockConfigRepository(
) ?: return emptyList()
return variantsByStatusCode
.flatMap { (statusCode, examplesByName) ->
examplesByName.mapNotNull { (exampleName, path) ->
examplesByName.mapNotNull { (exampleName, resolved) ->
loadResponseFromPath(
path = path,
resolved = resolved,
statusCode = statusCode,
exampleName = exampleName
)
Expand All @@ -196,22 +198,32 @@ public class MockConfigRepository(
exampleName: String
): MockResponse? {
loadConfiguration()
val path = responseIndex[key.specId]
val resolved = responseIndex[key.specId]
?.get(key = key.operationId)
?.get(key = statusCode)
?.get(key = exampleName)
?: return null
return loadResponseFromPath(path = path, statusCode = statusCode, exampleName = exampleName)
return loadResponseFromPath(
resolved = resolved,
statusCode = statusCode,
exampleName = exampleName
)
}

@Suppress("DocumentationOverPrivateFunction")
private suspend fun loadResponseFromPath(
path: String,
resolved: ResolvedResponse,
statusCode: Int,
exampleName: String
): MockResponse? = try {
val content = resourceLoader.load(path = path).decodeToString()
MockResponse.create(statusCode = statusCode, exampleName = exampleName, content = content)
val content = resourceLoader.load(path = resolved.path).decodeToString()
MockResponse.create(
statusCode = statusCode,
exampleName = exampleName,
content = content,
contentType = resolved.contentType,
headers = resolved.headers
)
} catch (@Suppress("SwallowedException") e: IllegalStateException) {
null
}
Expand Down
Loading
Loading