diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3c14d..d4731a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- NetworkMock: an operation can now declare narrow request-body match constraints — required + top-level fields and/or a discriminator field's value, read from its + `requestBody.content..schema` — to disambiguate operations that would otherwise + collide on path, method, and query alone (e.g. two specs sharing a host, each declaring + `POST /api/payments`, differing only by body shape). Not full JSON Schema validation; an + operation declaring no `requestBody`, or one with neither a required field nor a usable + discriminator, matches any body, same as before. `Operation` gains `requestBodyMatch: + RequestBodyMatch?` (new public class); `RequestMatcher` gains `matchesRequestBody`; + `MockConfigRepository.findMatchingMock` gains an optional `requestBody: String?` parameter. + `devview-networkmock-ktor`'s plugin reads the request body only when it's already a fully + in-memory `OutgoingContent.ByteArrayContent` — a pure, repeatable read that never consumes or + mutates anything the real network call still needs to send. See + `docs/modules/networkmock-core.md`'s new "Request body matching" section. + (`devview-networkmock-core`, `devview-networkmock-ktor`, #83) - NetworkMock: a status code with a declared `content..schema` but no `examples` now synthesizes a placeholder response body instead of being unmockable — primitives, `enum` (first value), `object`/`array` (recursively, by declared `type` or by the mere presence of diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index dd3d766..73d648a 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -145,7 +145,7 @@ package com.worldline.devview.networkmock.core.model { } @androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class Operation { - ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); + ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); method public String component1(); method public String component2(); method public String component3(); @@ -154,13 +154,15 @@ package com.worldline.devview.networkmock.core.model { method public Long? component6(); method public String? component7(); method public Double? component8(); - method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate); + method public com.worldline.devview.networkmock.core.model.RequestBodyMatch? component9(); + method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); method @InaccessibleFromKotlin public Long? getDelayMs(); method @InaccessibleFromKotlin public Double? getFailureRate(); method @InaccessibleFromKotlin public String getName(); method @InaccessibleFromKotlin public String getOperationId(); method @InaccessibleFromKotlin public String getPath(); method @InaccessibleFromKotlin public java.util.Map? getQueryParameters(); + method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.RequestBodyMatch? getRequestBodyMatch(); method @InaccessibleFromKotlin public String? getVersion(); property public Long? delayMs; property public Double? failureRate; @@ -169,6 +171,7 @@ package com.worldline.devview.networkmock.core.model { property public String operationId; property public String path; property public java.util.Map? queryParameters; + property public com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch; property public String? version; } @@ -257,6 +260,21 @@ package com.worldline.devview.networkmock.core.model { property public java.util.List responses; } + @androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class RequestBodyMatch { + ctor public RequestBodyMatch(); + ctor public RequestBodyMatch(optional java.util.List requiredFields, optional String? discriminatorField, optional String? discriminatorValue); + method public java.util.List component1(); + method public String? component2(); + method public String? component3(); + method public com.worldline.devview.networkmock.core.model.RequestBodyMatch copy(optional java.util.List requiredFields, optional String? discriminatorField, optional String? discriminatorValue); + method @InaccessibleFromKotlin public String? getDiscriminatorField(); + method @InaccessibleFromKotlin public String? getDiscriminatorValue(); + method @InaccessibleFromKotlin public java.util.List getRequiredFields(); + property public String? discriminatorField; + property public String? discriminatorValue; + property public java.util.List requiredFields; + } + public enum StatusCodeFamily { method @InaccessibleFromKotlin public String getDisplayName(); property public String displayName; @@ -280,7 +298,7 @@ package com.worldline.devview.networkmock.core.repository { public final class MockConfigRepository { ctor public MockConfigRepository(java.util.List specPaths, com.worldline.devview.networkmock.core.NetworkMockResourceLoader resourceLoader); method public suspend Object? discoverResponseFiles(com.worldline.devview.networkmock.core.model.OperationKey key, kotlin.coroutines.Continuation>); - method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map> queryParameters, kotlin.coroutines.Continuation); + method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map> queryParameters, optional String? requestBody, kotlin.coroutines.Continuation); method public void invalidate(); method @KotlinOnly public suspend Object? loadConfiguration(kotlin.coroutines.Continuation>); method public suspend Object? loadMockResponse(com.worldline.devview.networkmock.core.model.OperationKey key, int statusCode, String exampleName, kotlin.coroutines.Continuation); @@ -303,6 +321,7 @@ package com.worldline.devview.networkmock.core.repository { public final class RequestMatcher { method public boolean matchesPath(String configPath, String requestPath); method public boolean matchesQueryParams(java.util.Map? configQueryParams, java.util.Map> requestQueryParams); + method public boolean matchesRequestBody(com.worldline.devview.networkmock.core.model.RequestBodyMatch? configMatch, String? requestBody); field public static final com.worldline.devview.networkmock.core.repository.RequestMatcher INSTANCE; } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt index b2ddfa6..e51f7aa 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt @@ -51,6 +51,36 @@ public data class ApiSpec( val delayMs: Long? = null ) +/** + * Narrow request-body match constraints for an [Operation], built from its OpenAPI + * `requestBody.content..schema` (see + * [com.worldline.devview.networkmock.core.openapi.OpenApiParser]). + * + * This is **not** JSON Schema validation — only required-field presence and, optionally, a + * single discriminator field's value are checked (see + * [com.worldline.devview.networkmock.core.repository.RequestMatcher.matchesRequestBody]), + * consistent with how lenient this library's existing path/query matching already is. + * + * @property requiredFields Top-level property names the request body's JSON object must + * contain, from the schema's `required` array. Empty if the schema declares none. + * @property discriminatorField The schema's `discriminator.propertyName`, or `null` if the + * schema declares no discriminator. When non-null, the request body must contain this + * property (with any value, unless [discriminatorValue] narrows it further) to match. + * @property discriminatorValue The literal value [discriminatorField] must equal, sourced from + * that property's own single-value `enum` at parse time — `null` if the discriminator + * property doesn't declare one, in which case only [discriminatorField]'s *presence* is + * checked, not its value. + * @see Operation.requestBodyMatch + * @see com.worldline.devview.networkmock.core.repository.RequestMatcher.matchesRequestBody + */ +@Immutable +@Serializable +public data class RequestBodyMatch( + val requiredFields: List = emptyList(), + val discriminatorField: String? = null, + val discriminatorValue: String? = null +) + /** * A single mockable API operation, parsed from one `paths..` entry. * @@ -79,6 +109,12 @@ public data class ApiSpec( * extension. `null` (the default) means every request behaves normally. Unlike [delayMs], * this has no spec-wide default on [ApiSpec] — "some percentage of everything fails" is a * much blunter tool than "this specific flaky endpoint fails sometimes". + * @property requestBodyMatch Narrow request-body match constraints (required fields and/or a + * discriminator value), or `null` if this operation declares no `requestBody`, its schema + * yields nothing to check, or the schema simply isn't declared — an operation with `null` + * here matches any request body, mirroring how `null` [queryParameters] matches any query + * string. Exists to disambiguate operations that would otherwise collide on path, method, + * and query alone (see [RequestBodyMatch]). * @see ApiSpec * @see com.worldline.devview.networkmock.core.repository.RequestMatcher */ @@ -92,7 +128,8 @@ public data class Operation( val queryParameters: Map? = null, val delayMs: Long? = null, val version: String? = null, - val failureRate: Double? = null + val failureRate: Double? = null, + val requestBodyMatch: RequestBodyMatch? = null ) /** 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 4008ba3..aafb2eb 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 @@ -21,11 +21,11 @@ import kotlinx.serialization.Serializable * that kaml does not provide for `kotlinx.serialization.json.JsonElement`-shaped values. * * Only fields consumed by [OpenApiParser] are modeled. Everything else in a real spec - * (`deprecated`, `tags`, `security`, request bodies, …) is silently ignored via lenient/ - * non-strict decoding — this parser mocks, it does not validate. [SchemaObject] is the one - * exception: a `content..schema` is read to *synthesize* a response body when a - * spec declares no `examples` for a status code (see [SchemaSynthesizer]) — still not - * validation, just a fallback so a schema-only response isn't unmockable. + * (`deprecated`, `tags`, `security`, …) is silently ignored via lenient/non-strict decoding — + * this parser mocks, it does not validate. [SchemaObject] is the one exception, read in two + * narrow ways: to *synthesize* a response body when a spec declares no `examples` for a status + * code (see [SchemaSynthesizer]), and to build a [RequestBodyObject]'s match constraints (see + * [OpenApiParser]'s request-body matching scope decision) — neither is full validation. */ @Serializable internal data class OpenApiDocument( @@ -73,6 +73,7 @@ internal data class OperationObject( val operationId: String? = null, val summary: String? = null, val parameters: List = emptyList(), + val requestBody: RequestBodyObject? = null, val responses: Map = emptyMap(), @SerialName("x-devview") val xDevview: DevViewExtension? = null ) @@ -134,14 +135,32 @@ internal data class ExampleObject( val externalValue: String? = null ) +/** + * A `requestBody` declaration for an operation, or a `$ref` to one under + * `components.requestBodies`. + * + * Only [content] is modeled — read for its `.schema`, and only to build the + * declaring operation's [com.worldline.devview.networkmock.core.model.RequestBodyMatch] (see + * [OpenApiParser]'s request-body matching scope decision). No other `requestBody` field + * (`description`, `required`) is read. + */ +@Serializable +internal data class RequestBodyObject( + @SerialName("\$ref") val ref: String? = null, + val content: Map = emptyMap() +) + /** * A JSON Schema (OpenAPI's constrained subset of it) declaration, or a `$ref` to one under - * `components.schemas`. Read only to synthesize a placeholder response body when a - * `content.` declares a [schema] but no `examples` — see [SchemaSynthesizer]. + * `components.schemas`. Read in two narrow, non-validating ways: to synthesize a placeholder + * response body when a `content.` declares a [schema] but no `examples` (see + * [SchemaSynthesizer]), and to build a [RequestBodyObject]'s + * [com.worldline.devview.networkmock.core.model.RequestBodyMatch] (see [OpenApiParser]'s + * request-body matching scope decision). * - * Deliberately not a full JSON Schema model: no `required`, `additionalProperties`, - * `minimum`/`maximum`, string patterns, etc. — anything that would matter for *validation* - * rather than *synthesizing one plausible value*. + * Deliberately not a full JSON Schema model: no `additionalProperties`, `minimum`/`maximum`, + * string patterns, etc. — anything that would matter for *validation* rather than *synthesizing + * one plausible value* or checking narrow request-body match constraints. * * @property type The schema's declared type (`"string"`, `"integer"`, `"number"`, `"boolean"`, * `"object"`, or `"array"`). May be absent when [properties] or [items] alone implies it. @@ -160,7 +179,13 @@ internal data class ExampleObject( * @property oneOf Alternative schemas; [SchemaSynthesizer] synthesizes the first declared * variant regardless of [discriminator] (see [DiscriminatorObject]'s KDoc for why). * @property discriminator Parsed but not currently used to select a `oneOf` variant — there is - * no concrete request/response data at spec-parse time to disambiguate against. + * no concrete request/response data at spec-parse time to disambiguate against. Read for + * request-body matching, though (see [required]): its [DiscriminatorObject.propertyName] + * becomes a [com.worldline.devview.networkmock.core.model.RequestBodyMatch.discriminatorField]. + * @property required Property names a request-body schema declares as required, read only to + * build [com.worldline.devview.networkmock.core.model.RequestBodyMatch.requiredFields] — + * [SchemaSynthesizer] ignores this entirely, a synthesized response always includes every + * [properties] entry regardless of whether it's "required". */ @Serializable internal data class SchemaObject( @@ -173,7 +198,8 @@ internal data class SchemaObject( val format: String? = null, val allOf: List? = null, val oneOf: List? = null, - val discriminator: DiscriminatorObject? = null + val discriminator: DiscriminatorObject? = null, + val required: List? = null ) /** @@ -192,7 +218,8 @@ internal data class ComponentsObject( val responses: Map = emptyMap(), val examples: Map = emptyMap(), val headers: Map = emptyMap(), - val schemas: Map = emptyMap() + val schemas: Map = emptyMap(), + val requestBodies: 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 ab8b5e6..f388d43 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 @@ -3,6 +3,7 @@ package com.worldline.devview.networkmock.core.openapi import com.worldline.devview.networkmock.core.NetworkMockResourceLoader import com.worldline.devview.networkmock.core.model.ApiSpec import com.worldline.devview.networkmock.core.model.Operation +import com.worldline.devview.networkmock.core.model.RequestBodyMatch import kotlinx.serialization.json.Json /** @@ -57,7 +58,14 @@ internal data class ResolvedResponse( * Each hop's fragment must declare the section the caller expects (e.g. a response `$ref` * must point into `components/responses`), so a same-named entry in a different section * is never silently conflated with the one actually referenced. - * - Request bodies are not read at all (see #83, explicitly out of scope for 0.2.0). + * - `requestBody` matching is deliberately narrow (see #83): only a + * `requestBody.content..schema`'s `required` field list and, optionally, a single + * discriminator property's literal value (from that property's own single-value `enum`) are + * read into [RequestBodyMatch] — not full JSON Schema validation. An operation whose schema + * yields neither a required field nor a usable discriminator value has `requestBodyMatch == + * null` (matches any body), same as an operation declaring no `requestBody` at all. Only one + * media type is read per `requestBody` (`application/json` if declared, otherwise whichever + * is declared first). */ internal object OpenApiParser { /** @@ -108,7 +116,11 @@ internal object OpenApiParser { queryParameters = queryParameters, delayMs = rawOperation.xDevview?.delayMs, version = versionPattern.find(input = path)?.groupValues?.get(index = 1), - failureRate = rawOperation.xDevview?.failureRate + failureRate = rawOperation.xDevview?.failureRate, + requestBodyMatch = context.buildRequestBodyMatch( + raw = rawOperation.requestBody, + document = document + ) ) responseIndex[operationId] = context.resolveResponseIndex( @@ -145,6 +157,15 @@ internal object OpenApiParser { @Suppress("DocumentationOverPrivateProperty") private const val SYNTHESIZED_EXAMPLE_NAME = "default" + /** + * The media type [ParseContext.buildRequestBodyMatch] prefers when a `requestBody` declares + * more than one — this library assumes one dominant request content type per operation, + * mirroring how [ParameterObject.example] models a single literal value rather than a + * per-media-type one. + */ + @Suppress("DocumentationOverPrivateProperty") + private const val APPLICATION_JSON = "application/json" + /** * Extracts a display-only `v{n}` version tag from a `/v{n}/` path segment (see * [Operation.version]). Not currently configurable — see the KDoc there. @@ -289,6 +310,62 @@ internal object OpenApiParser { ) } + /** Resolves a `requestBody`'s own `$ref` (if any) via [resolveRef] against `components.requestBodies`. */ + @Suppress("DocumentationOverPrivateFunction") + private suspend fun resolveRequestBody( + raw: RequestBodyObject, + document: OpenApiDocument + ): RequestBodyObject { + val ref = raw.ref ?: return raw + return resolveRef( + ref = ref, + document = document, + section = "requestBodies", + componentsOf = { it.components.requestBodies }, + refOf = { it.ref } + ) + } + + /** + * Builds the [RequestBodyMatch] for an operation's [RequestBodyObject], or `null` if + * the operation declares no `requestBody`, its chosen media type (see [APPLICATION_JSON]) + * has no `schema`, or the resolved schema yields nothing to check — no `required` fields + * and no usable discriminator (see [RequestBodyMatch]'s KDoc for what "usable" means). + */ + @Suppress("DocumentationOverPrivateFunction") + suspend fun buildRequestBodyMatch( + raw: RequestBodyObject?, + document: OpenApiDocument + ): RequestBodyMatch? { + val requestBody = raw?.let { resolveRequestBody(raw = it, document = document) } + ?: return null + val rawSchema = + (requestBody.content[APPLICATION_JSON] ?: requestBody.content.values.firstOrNull()) + ?.schema + ?: return null + val schema = resolveSchema(raw = rawSchema, document = document) + + val requiredFields = schema.required.orEmpty() + val discriminatorField = schema.discriminator?.propertyName?.takeIf { it.isNotBlank() } + val discriminatorValue = discriminatorField + ?.let { field -> + schema.properties + ?.get(key = field) + ?.enum + ?.firstOrNull() + } + + return if (requiredFields.isEmpty() && discriminatorField == null) { + null + } else { + RequestBodyMatch( + requiredFields = requiredFields, + discriminatorField = discriminatorField, + discriminatorValue = discriminatorValue + ) + } + } + /** Resolves each declared header's `$ref` (if any) down to its literal `example` value. */ @Suppress("DocumentationOverPrivateFunction") private suspend fun resolveHeaders( 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 9a7e769..4f7b010 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 @@ -106,21 +106,27 @@ public class MockConfigRepository( * Finds a matching operation for an incoming HTTP request. * * Specs are checked in configuration order. Within a spec whose [servers][com.worldline.devview.networkmock.core.model.ApiSpec.servers] - * include a hostname matching [host], the first operation whose path, method, and query - * parameters all match wins. If no operation in that spec matches, the next spec is - * tried — two specs may legitimately share a hostname, and the first spec that actually - * has a matching operation wins. + * include a hostname matching [host], the first operation whose path, method, query + * parameters, and (if it declares constraints) request body all match wins. If no operation + * in that spec matches, the next spec is tried — two specs may legitimately share a + * hostname, and the first spec that actually has a matching operation wins. Request-body + * matching only disambiguates operations that already collide on path/method/query — see + * [com.worldline.devview.networkmock.core.model.RequestBodyMatch]. * * @param host The request hostname (e.g., `"staging.api.example.com"`) * @param path The request path (e.g., `"/v1/users/123"`) * @param method The HTTP method (e.g., `"GET"`, `"POST"`) + * @param requestBody The request body as text, or `null` if none was read. Only checked + * against operations that declare their own [com.worldline.devview.networkmock.core.model.RequestBodyMatch] + * — operations without one match regardless of this value. * @return A [MockMatch] if a matching operation is found, or `null` otherwise */ public suspend fun findMatchingMock( host: String, path: String, method: String, - queryParameters: Map> = emptyMap() + queryParameters: Map> = emptyMap(), + requestBody: String? = null ): MockMatch? { val config = loadConfiguration().getOrNull() ?: return null @@ -136,6 +142,10 @@ public class MockConfigRepository( RequestMatcher.matchesQueryParams( configQueryParams = operation.queryParameters, requestQueryParams = queryParameters + ) && + RequestMatcher.matchesRequestBody( + configMatch = operation.requestBodyMatch, + requestBody = requestBody ) } ?: return@firstNotNullOfOrNull null diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt index 6435d7b..417d7a3 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcher.kt @@ -1,5 +1,11 @@ package com.worldline.devview.networkmock.core.repository +import com.worldline.devview.networkmock.core.model.RequestBodyMatch +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + /** * Utility object for matching HTTP request paths against configured endpoint paths. * @@ -148,6 +154,57 @@ public object RequestMatcher { } } + /** + * Checks if a request body satisfies an operation's [RequestBodyMatch] constraints, if any. + * + * This is **not** JSON Schema validation — only [RequestBodyMatch.requiredFields] presence + * and, if declared, [RequestBodyMatch.discriminatorField]'s value are checked, mirroring how + * lenient [matchesPath]/[matchesQueryParams] already are. + * + * ## Matching Rules + * 1. `null` [configMatch] always matches — an operation with no declared `requestBody` + * constraints matches any body (or none at all). + * 2. A `null` [requestBody], or one that isn't a valid JSON object, never matches a non-null + * [configMatch] — there's nothing to check required fields or a discriminator against. + * 3. Every [RequestBodyMatch.requiredFields] entry must be present as a top-level key. + * 4. If [RequestBodyMatch.discriminatorField] is declared, it must be present as a + * top-level key; if [RequestBodyMatch.discriminatorValue] is also declared, that key's + * value must equal it exactly (as a JSON primitive's textual content). + * + * @param configMatch The operation's declared constraints, or `null` if it declares none + * @param requestBody The actual incoming request body as text, or `null` if none was read + * @return `true` if [requestBody] satisfies [configMatch] (or [configMatch] is `null`) + */ + public fun matchesRequestBody(configMatch: RequestBodyMatch?, requestBody: String?): Boolean { + if (configMatch == null) return true + val json = requestBody?.let { parseJsonObject(requestBody = it) } ?: return false + + val hasRequiredFields = configMatch.requiredFields.all { field -> + json.containsKey(key = field) + } + val matchesDiscriminator = configMatch.discriminatorField?.let { field -> + val actualValue = (json[field] as? JsonPrimitive)?.content + actualValue != null && + ( + configMatch.discriminatorValue == null || + actualValue == configMatch.discriminatorValue + ) + } ?: true + + return hasRequiredFields && matchesDiscriminator + } + + /** + * Parses [requestBody] as a JSON object for [matchesRequestBody], or `null` if it isn't + * valid JSON, or is valid JSON that isn't an object (e.g. a bare array or primitive). + */ + @Suppress("DocumentationOverPrivateFunction") + private fun parseJsonObject(requestBody: String): JsonObject? = try { + Json.parseToJsonElement(string = requestBody) as? JsonObject + } catch (@Suppress("SwallowedException") e: SerializationException) { + null + } + /** * Checks if a path segment is a parameter (enclosed in curly braces). * 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 cc1c221..ca675f1 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 @@ -255,6 +255,214 @@ class MockConfigRepositoryTest { noMatch.shouldBeNull() } + @Test + fun `parses required fields from a requestBody schema into requestBodyMatch`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "email"] + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + val operation = config.specs[0].operations.single() + operation.requestBodyMatch?.requiredFields shouldContainExactly listOf("name", "email") + operation.requestBodyMatch?.discriminatorField.shouldBeNull() + } + + @Test + fun `parses discriminator field and its single-value enum from a requestBody schema`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "createCardPayment", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { + "type": { "type": "string", "enum": ["card"] } + } + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + val requestBodyMatch = config.specs[0].operations.single().requestBodyMatch + requestBodyMatch?.discriminatorField shouldBe "type" + requestBodyMatch?.discriminatorValue shouldBe "card" + } + + @Test + fun `requestBodyMatch is null when an operation declares no requestBody`() = runTest { + val repository = createRepository(resources = baseResources()) + + val config = repository.loadConfiguration().getOrThrow() + + config.specs[0].operations.first { it.operationId == "getUser" } + .requestBodyMatch.shouldBeNull() + } + + @Test + fun `requestBodyMatch is null when the requestBody schema has neither required fields nor a discriminator`() = + runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + + config.specs[0].operations.single().requestBodyMatch.shouldBeNull() + } + + @Test + fun `findMatchingMock disambiguates two operations colliding on path and method by request body shape`() = + runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/payments/card": { + "post": { + "operationId": "payByCard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["card"] } } + } + } + } + }, + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val matchesCard = repository.findMatchingMock( + host = "api.example.com", + path = "/api/payments/card", + method = "POST", + requestBody = """{"type":"card","number":"4242"}""" + ) + val doesNotMatchOtherType = repository.findMatchingMock( + host = "api.example.com", + path = "/api/payments/card", + method = "POST", + requestBody = """{"type":"bank_transfer"}""" + ) + + matchesCard?.operationId shouldBe "payByCard" + doesNotMatchOtherType.shouldBeNull() + } + + @Test + fun `findMatchingMock resolves a dollar-ref'd requestBody schema via components schemas`() = runTest { + val spec = $$""" + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "post": { + "operationId": "createUser", + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/NewUser" } + } + } + }, + "responses": {} + } + } + }, + "components": { + "schemas": { + "NewUser": { "type": "object", "required": ["email"] } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val matches = repository.findMatchingMock( + host = "api.example.com", + path = "/api/users", + method = "POST", + requestBody = """{"email":"bob@example.com"}""" + ) + val noMatch = repository.findMatchingMock( + host = "api.example.com", + path = "/api/users", + method = "POST", + requestBody = """{"name":"Bob"}""" + ) + + matches?.operationId shouldBe "createUser" + noMatch.shouldBeNull() + } + @Test fun `findMatchingMock picks the first spec that has a matching operation when hosts collide`() = runTest { @@ -454,7 +662,7 @@ class MockConfigRepositoryTest { @Test fun `local dollar-ref to a components response resolves correctly`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -463,7 +671,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/UserOk" } + "200": { "$ref": "#/components/responses/UserOk" } } } } @@ -501,7 +709,7 @@ class MockConfigRepositoryTest { @Test fun `external dollar-ref to another file's components resolves correctly`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -510,7 +718,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "./common.json#/components/responses/UserOk" } + "200": { "$ref": "./common.json#/components/responses/UserOk" } } } } @@ -556,7 +764,7 @@ class MockConfigRepositoryTest { @Test fun `dollar-ref naming the wrong components section is rejected even if a same-named entry exists there`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -565,7 +773,7 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/parameters/UserOk" } + "200": { "$ref": "#/components/parameters/UserOk" } } } } @@ -600,7 +808,7 @@ class MockConfigRepositoryTest { @Test fun `local dollar-ref chain of two hops resolves to the final non-ref entry`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -609,14 +817,14 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/A" } + "200": { "$ref": "#/components/responses/A" } } } } }, "components": { "responses": { - "A": { "${'$'}ref": "#/components/responses/B" }, + "A": { "$ref": "#/components/responses/B" }, "B": { "content": { "application/json": { @@ -647,7 +855,7 @@ class MockConfigRepositoryTest { @Test fun `cyclic dollar-ref chain fails clearly instead of hanging`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -656,15 +864,15 @@ class MockConfigRepositoryTest { "get": { "operationId": "getUser", "responses": { - "200": { "${'$'}ref": "#/components/responses/A" } + "200": { "$ref": "#/components/responses/A" } } } } }, "components": { "responses": { - "A": { "${'$'}ref": "#/components/responses/B" }, - "B": { "${'$'}ref": "#/components/responses/A" } + "A": { "$ref": "#/components/responses/B" }, + "B": { "$ref": "#/components/responses/A" } } } } @@ -762,7 +970,7 @@ class MockConfigRepositoryTest { @Test fun `discoverResponseFiles resolves a dollar-ref'd schema via components schemas before synthesizing`() = runTest { - val spec = """ + val spec = $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -774,7 +982,7 @@ class MockConfigRepositoryTest { "200": { "content": { "application/json": { - "schema": { "${'$'}ref": "#/components/schemas/User" } + "schema": { "$ref": "#/components/schemas/User" } } } } @@ -802,6 +1010,98 @@ class MockConfigRepositoryTest { responses.single().content shouldBe """{"id":0}""" } + @Test + fun `parses a real-world YAML spec with dollar-ref schemas declared after paths`() = runTest { + // Mirrors a shape seen in real API docs: a YAML spec whose components.schemas section + // sits after paths, a requestBody with its own $ref'd schema and a requestBody.required + // boolean (a different concept from schema.required, and not modeled at all - must be + // silently ignored), three status codes all $ref-ing the *same* response schema, a + // folded (unquoted, line-wrapped) summary string, a double-quoted description with a + // backslash line continuation, and a tags block sequence (unmodeled until #116/PR 10). + val yamlSpec = $$""" + info: + title: Example + servers: + - url: https://api.example.com + paths: + /api/v1/authentication/mobile-auth/login: + post: + operationId: mobileLogin + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MobileLoginRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MobileLoginResponse" + description: "Successful call, returns a challenge that needs to be signed\ + \ to complete the activation" + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/MobileLoginResponse" + description: User not authenticated + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/MobileLoginResponse" + description: Invalid parameters + summary: Init mobile authentication activation workflow. It will reset any previously + activated mobile authentication for this user and device. + tags: + - Authentication V1 + - Authentication + components: + schemas: + MobileLoginRequest: + type: object + required: + - deviceId + properties: + deviceId: + type: string + MobileLoginResponse: + type: object + properties: + challenge: + type: string + expiresInSeconds: + type: integer + """.trimIndent() + val yamlSpecPath = "specs/mobile-auth.yaml" + val repository = MockConfigRepository( + specPaths = listOf(yamlSpecPath), + resourceLoader = RecordingResourceLoader(resources = mapOf(yamlSpecPath to yamlSpec)) + ) + + val config = repository.loadConfiguration().getOrThrow() + val operation = config.specs[0].operations.single() + + operation.operationId shouldBe "mobileLogin" + operation.path shouldBe "/api/v1/authentication/mobile-auth/login" + operation.method shouldBe HttpMethod.Post + // Folded YAML scalar: the line break becomes a single space. + operation.name shouldBe "Init mobile authentication activation workflow. It will reset " + + "any previously activated mobile authentication for this user and device." + operation.requestBodyMatch?.requiredFields shouldContainExactly listOf("deviceId") + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "example", operationId = "mobileLogin") + ) + + responses shouldHaveSize 3 + responses.map { it.statusCode }.sorted() shouldContainExactly listOf(200, 401, 422) + responses.all { it.isSynthesized } shouldBe true + responses.all { it.content == """{"challenge":"string","expiresInSeconds":0}""" } shouldBe true + } + @Test fun `discoverResponseFiles returns responses sorted by status code`() = runTest { val repository = createRepository(resources = baseResources()) @@ -926,7 +1226,7 @@ class MockConfigRepositoryTest { @Test fun `loadMockResponse resolves a dollar-ref'd header via components`() = runTest { val resources = mapOf( - SPEC_PATH to """ + SPEC_PATH to $$""" { "info": { "title": "Example" }, "servers": [ { "url": "https://api.example.com" } ], @@ -937,7 +1237,7 @@ class MockConfigRepositoryTest { "responses": { "200": { "headers": { - "X-RateLimit-Remaining": { "${'$'}ref": "#/components/headers/RateLimit" } + "X-RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit" } }, "content": { "application/json": { diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt index 0595e53..202bbc0 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/RequestMatcherTest.kt @@ -1,5 +1,6 @@ package com.worldline.devview.networkmock.core.repository +import com.worldline.devview.networkmock.core.model.RequestBodyMatch import io.kotest.matchers.shouldBe import kotlin.test.Test @@ -431,5 +432,135 @@ class RequestMatcherTest { } // endregion + + // region Request body matching + + @Test + fun `matchesRequestBody returns true when configMatch is null regardless of body`() { + RequestMatcher.matchesRequestBody( + configMatch = null, + requestBody = """{"anything":"goes"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody(configMatch = null, requestBody = null) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when configMatch is non-null but requestBody is null`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = null + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when requestBody is not valid JSON`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = "not json" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when requestBody is valid JSON but not an object`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = """["name"]""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns true when all required fields are present`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name", "email")), + requestBody = """{"name":"Bob","email":"bob@example.com"}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when a required field is missing`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name", "email")), + requestBody = """{"name":"Bob"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody ignores the required field's actual value and only checks presence`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch(requiredFields = listOf("name")), + requestBody = """{"name":null}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns true when discriminator field matches expected value`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"type":"card","number":"4242"}""" + ) shouldBe true + } + + @Test + fun `matchesRequestBody returns false when discriminator field has a different value`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"type":"bank_transfer","iban":"..."}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody returns false when discriminator field is absent from the body`() { + RequestMatcher.matchesRequestBody( + configMatch = RequestBodyMatch( + discriminatorField = "type", + discriminatorValue = "card" + ), + requestBody = """{"number":"4242"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody only checks discriminator field presence when no discriminatorValue is declared`() { + val configMatch = RequestBodyMatch(discriminatorField = "type", discriminatorValue = null) + + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"type":"anything"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"other":"field"}""" + ) shouldBe false + } + + @Test + fun `matchesRequestBody requires both required fields and discriminator when both are declared`() { + val configMatch = RequestBodyMatch( + requiredFields = listOf("amount"), + discriminatorField = "type", + discriminatorValue = "card" + ) + + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"amount":100,"type":"card"}""" + ) shouldBe true + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"type":"card"}""" + ) shouldBe false + RequestMatcher.matchesRequestBody( + configMatch = configMatch, + requestBody = """{"amount":100,"type":"bank_transfer"}""" + ) shouldBe false + } + + // endregion } 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 0fcf2e9..313b628 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 @@ -19,6 +19,7 @@ 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.content.OutgoingContent import io.ktor.http.headersOf import io.mockk.coEvery import io.mockk.every @@ -300,6 +301,173 @@ class NetworkMockPluginTest { // endregion + // region Request body matching + + @Test + fun requestBodyDisambiguation_selectsCorrectOperationByDiscriminatorValue() = runTest { + val resources = requestBodyDisambiguationResources() + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "card-payByCard" to OperationMockState.Mock(statusCode = 200, exampleName = "default"), + "bank-payByBankTransfer" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = networkEngine(body = """{"source":"network"}"""), + configRepository = requestBodyDisambiguationRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state) + ) + + val cardResponse = client.post(urlString = "https://staging.api.example.com/api/payments") { + setBody("""{"type":"card","number":"4242"}""") + } + val bankResponse = client.post(urlString = "https://staging.api.example.com/api/payments") { + setBody("""{"type":"bank_transfer","iban":"DE00"}""") + } + + cardResponse.body() shouldBe """{"method":"card"}""" + bankResponse.body() shouldBe """{"method":"bank_transfer"}""" + } + + @Test + fun requestBodyDisambiguation_fallsBackToNetworkWithOriginalBodyIntact_whenNoShapeMatches() = runTest { + val resources = requestBodyDisambiguationResources() + var capturedBody: String? = null + val engine = MockEngine { request -> + capturedBody = (request.body as? OutgoingContent.ByteArrayContent) + ?.bytes() + ?.decodeToString() + respond( + content = """{"source":"network"}""", + status = HttpStatusCode.OK, + headers = headersOf("Content-Type", "application/json") + ) + } + val state = NetworkMockState( + globalMockingEnabled = true, + operationStates = mapOf( + "card-payByCard" to OperationMockState.Mock(statusCode = 200, exampleName = "default"), + "bank-payByBankTransfer" to OperationMockState.Mock(statusCode = 200, exampleName = "default") + ) + ) + val client = buildClient( + engine = engine, + configRepository = requestBodyDisambiguationRepository(resources = resources), + stateRepository = stateRepositoryMock(state = state) + ) + val originalBody = """{"type":"crypto","wallet":"abc123"}""" + + val response: HttpResponse = client.post( + urlString = "https://staging.api.example.com/api/payments" + ) { + setBody(originalBody) + } + + // No declared operation's requestBody shape matches "crypto" - falls through to network, + // and the original body must still reach it byte-for-byte, unconsumed. + response.body() shouldBe """{"source":"network"}""" + capturedBody shouldBe originalBody + } + + /** + * Two specs, sharing a host and declaring the identical `POST /api/payments` path/method, + * disambiguated only by a `type` discriminator in their respective `requestBody` schemas - + * the scenario [MockConfigRepository.findMatchingMock]'s own KDoc describes for why + * request-body matching exists. + */ + private fun requestBodyDisambiguationResources(): Map { + val cardSpec = """ + { + "info": { "title": "Card" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "payByCard", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["card"] } } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { "externalValue": "/files/networkmocks/responses/payByCard-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + val bankSpec = """ + { + "info": { "title": "Bank" }, + "servers": [ { "url": "https://staging.api.example.com" } ], + "paths": { + "/api/payments": { + "post": { + "operationId": "payByBankTransfer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "discriminator": { "propertyName": "type" }, + "properties": { "type": { "type": "string", "enum": ["bank_transfer"] } } + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "externalValue": "/files/networkmocks/responses/payByBankTransfer-200.json" + } + } + } + } + } + } + } + } + } + } + """.trimIndent() + return mapOf( + "files/networkmocks/specs/card.json" to cardSpec, + "files/networkmocks/specs/bank.json" to bankSpec, + "files/networkmocks/responses/payByCard-200.json" to """{"method":"card"}""", + "files/networkmocks/responses/payByBankTransfer-200.json" to """{"method":"bank_transfer"}""" + ) + } + + private fun requestBodyDisambiguationRepository(resources: Map): MockConfigRepository = + MockConfigRepository( + specPaths = listOf( + "files/networkmocks/specs/card.json", + "files/networkmocks/specs/bank.json" + ), + resourceLoader = KtorPluginTestData.resourceLoader(resources = resources) + ) + + // endregion + // region Failure simulation @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 3827c6f..198a50b 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 @@ -63,6 +63,10 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * - **Global Toggle**: Master switch to enable/disable all mocking * - **Path Parameters**: Supports path parameters like `/users/{userId}` * - **Multiple Hosts**: Can mock different hosts (staging, production, etc.) + * - **Request Body Disambiguation**: When two operations would otherwise collide on path, + * method, and query, a request body's shape (required fields, and/or a discriminator field's + * value — see [com.worldline.devview.networkmock.core.model.RequestBodyMatch]) picks the + * right one. Narrow matching, not full schema validation. * - **State Persistence**: Mock configuration persists across app restarts * - **Failure Simulation**: An operation can deterministically simulate a network failure * (see [com.worldline.devview.networkmock.core.model.OperationMockState.Failure]), or fail a @@ -71,7 +75,8 @@ public data class NetworkMockPluginConfig(internal val config: NetworkMockConfig * ## How It Works * 1. Plugin intercepts every HTTP request using Ktor's `HttpSend` mechanism * 2. Checks if global mocking is enabled via DataStore state - * 3. Attempts to match the request (host, path, method) to a configured endpoint + * 3. Attempts to match the request (host, path, method, query, and — for operations that + * declare their own constraints — request body) to a configured endpoint * 4. If matched and mock is enabled for that endpoint, loads and returns the mock response * 5. Otherwise, proceeds with the actual network call * @@ -172,6 +177,7 @@ public val NetworkMockPlugin: HttpClientPlugin key to values } + val requestBodyText = extractRequestBodyText(content = request.body) val currentState = cachedState.value ?: stateRepository.getState() @@ -184,7 +190,8 @@ public val NetworkMockPlugin: HttpClientPlugin.content..examples.`** → one entry per response variant this operation can mock; `externalValue` points at the response body file on disk. By convention the primary/original response for a status code is named `"default"`. - **`parameters` with `in: query`** → a literal `example` value on a query parameter becomes a required match for that operation (e.g. `listUsers` only matches requests carrying `?type=user`). +- **`requestBody.content..schema`** → optionally disambiguates operations that collide on path/method/query — see [Request body matching](#request-body-matching) below. - **`x-devview.delayMs`** → simulated response delay, at the document root (spec-wide default) and/or per operation (overrides the default). See [x-devview extension](#x-devview-extension) below. - **`{param}` placeholders**: Path segments like `{userId}` match any value during request matching. @@ -65,11 +66,12 @@ configurable. ## Request Matching -`MockConfigRepository.findMatchingMock(host, path, method, queryParameters)` resolves a mock in three steps: +`MockConfigRepository.findMatchingMock(host, path, method, queryParameters, requestBody)` resolves a mock in four steps: 1. **Hostname match** — compares the request host (case-insensitive) against every hostname declared in the spec's `servers[]`. If two specs both declare a matching hostname, the first spec (in configuration order) that also has a matching operation wins. 2. **Path match** — splits path by `/`, compares segment by segment; `{param}` segments match any value; non-param segments are case-sensitive. 3. **Method match** — case-sensitive exact match. Use uppercase (`"GET"`, `"POST"`). +4. **Request body match** (only for operations that declare one — see below) — narrow, non-validating checks against the operation's declared `requestBody` schema. `Operation.method` is typed as `HttpMethod`, a small value class modeled after Ktor's own `io.ktor.http.HttpMethod` (open set, `HttpMethod.Get`/`.Post`/etc. constants, plus @@ -80,6 +82,52 @@ receives the raw wire value from `devview-networkmock-ktor`. There is no stored active-server selection. The matching server is determined purely from the request hostname at interception time. +### Request body matching + +An operation's `requestBody.content..schema` (`application/json` if declared, +otherwise whichever media type comes first) is read into a narrow `RequestBodyMatch` — this +exists purely to **disambiguate** operations that would otherwise collide on path, method, and +query alone (e.g. two specs sharing a host, each declaring `POST /api/payments`, differing only +by body shape); it is not a substitute for path/method/query matching, and an operation without +`requestBodyMatch` still matches any body. + +```json +"requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["amount"], + "discriminator": { "propertyName": "type" }, + "properties": { + "type": { "type": "string", "enum": ["card"] } + } + } + } + } +} +``` + +Two things are read from the schema, both deliberately narrow (not full JSON Schema validation): + +| Schema field | Becomes | Matching rule | +|---|---|---| +| `required` | `RequestBodyMatch.requiredFields` | every listed property must be a top-level key in the request body | +| `discriminator.propertyName` | `RequestBodyMatch.discriminatorField` | that property must be a top-level key | +| that property's own single-value `enum` | `RequestBodyMatch.discriminatorValue` | if present, the key's value must equal it exactly | + +If the schema yields neither a required field nor a usable discriminator (no `discriminator`, or +one whose property doesn't declare a single-value `enum`), `requestBodyMatch` is `null` — same +as an operation declaring no `requestBody` at all. `$ref`s (both the `requestBody` itself, under +`components.requestBodies`, and its schema, under `components.schemas`) resolve the same way as +elsewhere in this document. + +`devview-networkmock-ktor`'s plugin only reads the request body when it's already a fully +in-memory `OutgoingContent.ByteArrayContent` (the shape Ktor's content negotiation produces for +a JSON-serialized body) — a streaming or multipart body is never touched, and `requestBodyMatch` +simply doesn't apply to it (treated as no body). Reading it is a pure, repeatable operation that +never consumes or mutates anything the real network call still needs to send. + ## Response Variant Discovery Response bodies live wherever `externalValue` points them — the sample app uses `composeResources/files/networkmocks/responses/{specId}/{operationId}/{operationId}-{status}[-{suffix}].json`, but this is only a convention, not a requirement.