From cb9237e63da5674001c26e138245b7c59ab3f49d Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Tue, 22 Sep 2026 11:58:49 +0200 Subject: [PATCH] :sparkles: Serve response headers and content type declared in the spec Mocked responses always carried a single hardcoded Content-Type: application/json header, with no way to declare additional headers per response - and even the spec's own declared media type (the key under responses..content) was discarded during parsing. - OpenApiDocument: HeaderObject DTO, ResponseObject.headers. - OpenApiParser.resolveResponseIndex now threads the media-type key and resolves declared headers (a header's literal example value, same pattern as query parameter matching) into a new ResolvedResponse record, replacing the bare file-path string the response index used to carry. Header $refs resolve against components.headers. - MockResponse gains contentType (default "application/json") and headers (default empty) properties, threaded through MockConfigRepository and into the Ktor plugin's synthetic HttpResponseData via a HeadersBuilder - declared headers merge over the Content-Type default, and an explicit Content-Type header in the spec wins over the media-type-derived one. New public API on MockResponse is additive/defaulted - no existing call site needed updating. api.txt regenerated. Closes #87. Also closes gap 7 of #91 (no assertion on response headers/content-type in NetworkMockPluginTest.kt). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 ++ devview-networkmock-core/api/api.txt | 12 ++- .../networkmock/core/model/MockResponse.kt | 18 +++- .../core/openapi/OpenApiDocument.kt | 19 +++- .../networkmock/core/openapi/OpenApiParser.kt | 54 +++++++--- .../core/repository/MockConfigRepository.kt | 32 ++++-- .../repository/MockConfigRepositoryTest.kt | 98 +++++++++++++++++++ devview-networkmock-ktor/CLAUDE.md | 2 +- .../ktor/plugin/NetworkMockPluginTest.kt | 80 +++++++++++++++ .../ktor/plugin/NetworkMockPlugin.kt | 26 +++-- docs/modules/networkmock-core.md | 28 +++++- docs/modules/networkmock-ktor.md | 2 +- 12 files changed, 338 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1096f4..b460fba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..content.`, previously always hardcoded to + `application/json`) plus any additional headers declared on `responses..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 diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index 1be31938..e43d492c 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -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 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 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 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 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 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 statusTextProvider); + method public com.worldline.devview.networkmock.core.model.MockResponse create(int statusCode, String exampleName, String content, optional String contentType, optional java.util.Map headers, optional kotlin.jvm.functions.Function1 statusTextProvider); } @kotlinx.serialization.Serializable public final class NetworkMockState { diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt index 4e729a2c..09d7daa0 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockResponse.kt @@ -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..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..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 @@ -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 = emptyMap() ) { public companion object { /** @@ -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..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] @@ -50,6 +60,8 @@ public data class MockResponse( statusCode: Int, exampleName: String, content: String, + contentType: String = "application/json", + headers: Map = emptyMap(), statusTextProvider: (Int) -> String = ::getStatusText ): MockResponse = MockResponse( statusCode = statusCode, @@ -59,7 +71,9 @@ public data class MockResponse( exampleName = exampleName, statusTextProvider = statusTextProvider ), - content = content + content = content, + contentType = contentType, + headers = headers ) @Suppress("DocumentationOverPrivateFunction") diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt index 47c0e31c..2d8d1da6 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt @@ -94,7 +94,21 @@ internal data class ParameterObject( @Serializable internal data class ResponseObject( @SerialName("\$ref") val ref: String? = null, - val content: Map = emptyMap() + val content: Map = emptyMap(), + val headers: Map = 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 @@ -118,7 +132,8 @@ internal data class ExampleObject( internal data class ComponentsObject( val parameters: Map = emptyMap(), val responses: Map = emptyMap(), - val examples: Map = emptyMap() + val examples: Map = emptyMap(), + val headers: Map = emptyMap() ) /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt index a739d36f..2fb05b47 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt @@ -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.` — + * 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 +) + /** * Parses an OpenAPI 3.x document (JSON or YAML) into DevView's internal model. * @@ -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>> + val responseIndex: Map>> ) /** @@ -52,7 +64,7 @@ internal object OpenApiParser { val document = decodeDocument(path = specPath, bytes = resourceLoader.load(path = specPath)) val operations = mutableListOf() - val responseIndex = mutableMapOf>>() + val responseIndex = mutableMapOf>>() for ((path, pathItem) in document.paths) { for ((method, rawOperation) in pathItem.operationsByMethod()) { @@ -147,8 +159,8 @@ internal object OpenApiParser { suspend fun resolveResponseIndex( responses: Map, document: OpenApiDocument - ): Map> { - val result = mutableMapOf>() + ): Map> { + val result = mutableMapOf>() for ((codeText, rawResponse) in responses) { val statusCode = codeText.toIntOrNull() ?: continue val response = if (rawResponse.ref != null) { @@ -160,9 +172,11 @@ internal object OpenApiParser { rawResponse } - val examplesForCode = mutableMapOf() - for (mediaType in response.content.values) { - for ((exampleName, rawExample) in mediaType.examples) { + val headers = resolveHeaders(raw = response.headers, document = document) + + val examplesForCode = mutableMapOf() + for ((mediaType, media) in response.content) { + for ((exampleName, rawExample) in media.examples) { val example = if (rawExample.ref != null) { resolveRef( ref = rawExample.ref, @@ -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 ) } } @@ -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, + document: OpenApiDocument + ): Map = 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) diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt index 87937f58..3e8c167e 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepository.kt @@ -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") @@ -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 @@ -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>>> = emptyMap() + private var responseIndex: Map>>> = + emptyMap() /** * Clears the cached configuration, forcing the next [loadConfiguration] call to re-read @@ -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 ) @@ -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 } diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index ce57becc..59e33f9e 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -548,6 +548,104 @@ class MockConfigRepositoryTest { response?.statusCode shouldBe 200 response?.displayName shouldBe "Success (200)" response?.content shouldBe """{"id":1}""" + response?.contentType shouldBe "application/json" + response?.headers shouldBe emptyMap() + } + + @Test + fun `loadMockResponse resolves declared content type and headers`() = runTest { + val resources = mapOf( + SPEC_PATH to """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "headers": { + "X-RateLimit-Remaining": { "example": "42" }, + "Cache-Control": { "example": "no-store" } + }, + "content": { + "application/vnd.example+json": { + "examples": { + "default": { "externalValue": "/responses/getUser-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent(), + "responses/getUser-200.json" to """{"id":1}""" + ) + val repository = createRepository(resources = resources) + + val response = repository.loadMockResponse( + key = OperationKey(specId = "example", operationId = "getUser"), + statusCode = 200, + exampleName = "default" + ) + + response?.contentType shouldBe "application/vnd.example+json" + response?.headers shouldBe mapOf( + "X-RateLimit-Remaining" to "42", + "Cache-Control" to "no-store" + ) + } + + @Test + fun `loadMockResponse resolves a dollar-ref'd header via components`() = runTest { + val resources = mapOf( + SPEC_PATH to """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "headers": { + "X-RateLimit-Remaining": { "${'$'}ref": "#/components/headers/RateLimit" } + }, + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/responses/getUser-200.json" } + } + } + } + } + } + } + } + }, + "components": { + "headers": { + "RateLimit": { "example": "10" } + } + } + } + """.trimIndent(), + "responses/getUser-200.json" to """{"id":1}""" + ) + val repository = createRepository(resources = resources) + + val response = repository.loadMockResponse( + key = OperationKey(specId = "example", operationId = "getUser"), + statusCode = 200, + exampleName = "default" + ) + + response?.headers shouldBe mapOf("X-RateLimit-Remaining" to "10") } @Test diff --git a/devview-networkmock-ktor/CLAUDE.md b/devview-networkmock-ktor/CLAUDE.md index 8a8ea00f..0cbfb7a8 100644 --- a/devview-networkmock-ktor/CLAUDE.md +++ b/devview-networkmock-ktor/CLAUDE.md @@ -39,7 +39,7 @@ The plugin hooks into Ktor's `HttpSend` phase during `install`: 6. If matched, `currentState.getOperationState(match.key)` is read: - `OperationMockState.Network` or `null` → real network. - `OperationMockState.Mock(statusCode, exampleName)` → load that declared response variant via `mockRepository.loadMockResponse(key, statusCode, exampleName)`. -7. On a successful load, `createMockHttpClientCall(...)` builds a `MockHttpClientCall` with `HttpResponseData` (HTTP/1.1, empty headers, `ByteReadChannel` body) and returns it — **no network call is made**. +7. On a successful load, `createMockHttpClientCall(...)` builds a `MockHttpClientCall` with `HttpResponseData` (HTTP/1.1, `MockResponse.contentType` as `Content-Type` merged with any `MockResponse.headers`, `ByteReadChannel` body) and returns it — **no network call is made**. 8. On any failure (variant not declared in the spec, exception) → falls back to real network and logs; never throws. ## Non-obvious Patterns and Constraints diff --git a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt index ced5298a..db2ab9e8 100644 --- a/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt +++ b/devview-networkmock-ktor/src/androidHostTest/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPluginTest.kt @@ -14,6 +14,7 @@ import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.mockk.coEvery @@ -136,6 +137,85 @@ class NetworkMockPluginTest { // endregion + // region Response headers and content type + + @Test + fun returnsMockResponse_withDefaultContentTypeHeader() = runTest { + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + // getUser's 200 response declares no explicit headers - Content-Type still defaults. + response.headers[HttpHeaders.ContentType] shouldBe "application/json" + } + + @Test + fun returnsMockResponse_withDeclaredHeadersAndContentType() = runTest { + val specWithHeaders = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "headers": { + "X-RateLimit-Remaining": { "example": "42" } + }, + "content": { + "application/vnd.example+json": { + "examples": { + "default": { "externalValue": "/files/networkmocks/responses/getUser-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + val resources = mapOf( + KtorPluginTestData.SPEC_PATH to specWithHeaders, + "files/networkmocks/responses/getUser-200.json" to """{"id":1,"name":"Alice"}""" + ) + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "example-getUser" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(), + configRepository = configRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state) + ) + + val response: HttpResponse = client.get( + urlString = "https://staging.api.example.com/api/users/42" + ) + + response.headers["X-RateLimit-Remaining"] shouldBe "42" + response.headers[HttpHeaders.ContentType] shouldBe "application/vnd.example+json" + } + + // endregion + // region Non-matching requests pass through @Test diff --git a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt index a530a446..d8f3369a 100644 --- a/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt +++ b/devview-networkmock-ktor/src/commonMain/kotlin/com/worldline/devview/networkmock/ktor/plugin/NetworkMockPlugin.kt @@ -12,15 +12,14 @@ import io.ktor.client.request.HttpRequest import io.ktor.client.request.HttpRequestData import io.ktor.client.request.HttpResponseData import io.ktor.client.statement.HttpResponse -import io.ktor.http.ContentType import io.ktor.http.Headers +import io.ktor.http.HeadersBuilder import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpProtocolVersion import io.ktor.http.HttpStatusCode import io.ktor.http.Url import io.ktor.http.content.OutgoingContent -import io.ktor.http.headersOf import io.ktor.util.AttributeKey import io.ktor.util.Attributes import io.ktor.util.date.GMTDate @@ -224,7 +223,9 @@ public val NetworkMockPlugin: HttpClientPlugin ): HttpClientCall { val responseData = HttpResponseData( statusCode = statusCode, requestTime = GMTDate(), - headers = headersOf( - name = HttpHeaders.ContentType, - value = ContentType.Application.Json.toString() - ), + headers = HeadersBuilder() + .apply { + set(name = HttpHeaders.ContentType, value = contentType) + headers.forEach { (name, value) -> set(name = name, value = value) } + }.build(), version = HttpProtocolVersion.HTTP_1_1, body = ByteReadChannel(content = content.encodeToByteArray()), callContext = requestData.executionContext diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index 873c7adc..11b7add5 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -95,9 +95,35 @@ MockConfigRepository( ) ``` +### Response headers and content type + +`responses..headers` declares extra headers to serve alongside a mocked response, and the +media type key under `responses..content` (e.g. `application/json`) becomes the response's +`Content-Type` — both are threaded through to the app via the Ktor plugin's synthetic response. + +```json +"200": { + "headers": { + "X-RateLimit-Remaining": { "example": "42" } + }, + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "responses/getUser-200.json" } + } + } + } +} +``` + +Like [query parameters](#request-matching), a header's literal `example` value is the only field +read — no `schema` resolution. `Content-Type` defaults to `application/json` when a response +declares no content at all; a spec can still override it explicitly by declaring its own +`Content-Type` entry under `headers`, which wins over the media-type-derived default. + ### `$ref` resolution -Parameters, responses, and examples may be declared via `$ref` instead of inline: +Parameters, responses, examples, and headers may be declared via `$ref` instead of inline: - **Local**: `"$ref": "#/components/parameters/UserId"` resolves against the same document's `components`. - **External**: `"$ref": "./common.json#/components/responses/Error"` loads another file (relative to the spec's own location) via the same `NetworkMockResourceLoader`. diff --git a/docs/modules/networkmock-ktor.md b/docs/modules/networkmock-ktor.md index 1b4683ea..4cf01d6e 100644 --- a/docs/modules/networkmock-ktor.md +++ b/docs/modules/networkmock-ktor.md @@ -49,7 +49,7 @@ For every outgoing request, the plugin: - `Mock(statusCode, exampleName)` → loads that declared response variant and returns a synthetic response. 6. On any error (undeclared variant, missing file, exception) → falls back to the real network and logs the reason. **The plugin never throws.** -Mock responses are returned with HTTP/1.1 status, an empty header set, and the response body as the content. +Mock responses are returned with HTTP/1.1 status, `Content-Type` set from the response's declared media type (defaulting to `application/json`), any additional headers declared on `responses..headers`, and the response body as the content. See [Response headers and content type](networkmock-core.md#response-headers-and-content-type). Each intercepted request logs exactly one line through [Kermit](https://github.com/touchlab/Kermit) (tag `DevViewNetworkMock`, `debug` level, `warn` for a failed mock load) — e.g. `GET /v1/users/42 -> MOCK 200/default` or `-> NETWORK (no operation match)`. No response body content is ever logged. See [Logging](networkmock-core.md#logging) for how to adjust verbosity or route these into `devview-consolelogger`.