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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<mediaType>.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.<mediaType>.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
Expand Down
25 changes: 22 additions & 3 deletions devview-networkmock-core/api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<java.lang.String,java.lang.String>? 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<java.lang.String,java.lang.String>? 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();
Expand All @@ -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<java.lang.String,java.lang.String>? 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<java.lang.String,java.lang.String>? 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<java.lang.String,java.lang.String>? 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;
Expand All @@ -169,6 +171,7 @@ package com.worldline.devview.networkmock.core.model {
property public String operationId;
property public String path;
property public java.util.Map<java.lang.String,java.lang.String>? queryParameters;
property public com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch;
property public String? version;
}

Expand Down Expand Up @@ -257,6 +260,21 @@ package com.worldline.devview.networkmock.core.model {
property public java.util.List<com.worldline.devview.networkmock.core.model.OperationMockState.Mock> responses;
}

@androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class RequestBodyMatch {
ctor public RequestBodyMatch();
ctor public RequestBodyMatch(optional java.util.List<java.lang.String> requiredFields, optional String? discriminatorField, optional String? discriminatorValue);
method public java.util.List<java.lang.String> component1();
method public String? component2();
method public String? component3();
method public com.worldline.devview.networkmock.core.model.RequestBodyMatch copy(optional java.util.List<java.lang.String> requiredFields, optional String? discriminatorField, optional String? discriminatorValue);
method @InaccessibleFromKotlin public String? getDiscriminatorField();
method @InaccessibleFromKotlin public String? getDiscriminatorValue();
method @InaccessibleFromKotlin public java.util.List<java.lang.String> getRequiredFields();
property public String? discriminatorField;
property public String? discriminatorValue;
property public java.util.List<java.lang.String> requiredFields;
}

public enum StatusCodeFamily {
method @InaccessibleFromKotlin public String getDisplayName();
property public String displayName;
Expand All @@ -280,7 +298,7 @@ package com.worldline.devview.networkmock.core.repository {
public final class MockConfigRepository {
ctor public MockConfigRepository(java.util.List<java.lang.String> specPaths, com.worldline.devview.networkmock.core.NetworkMockResourceLoader resourceLoader);
method public suspend Object? discoverResponseFiles(com.worldline.devview.networkmock.core.model.OperationKey key, kotlin.coroutines.Continuation<? super java.util.List<com.worldline.devview.networkmock.core.model.MockResponse>>);
method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map<java.lang.String,? extends java.util.List<java.lang.String>> queryParameters, kotlin.coroutines.Continuation<? super com.worldline.devview.networkmock.core.model.MockMatch?>);
method public suspend Object? findMatchingMock(String host, String path, String method, optional java.util.Map<java.lang.String,? extends java.util.List<java.lang.String>> queryParameters, optional String? requestBody, kotlin.coroutines.Continuation<? super com.worldline.devview.networkmock.core.model.MockMatch?>);
method public void invalidate();
method @KotlinOnly public suspend Object? loadConfiguration(kotlin.coroutines.Continuation<? super kotlin.Result<com.worldline.devview.networkmock.core.model.MockConfiguration>>);
method public suspend Object? loadMockResponse(com.worldline.devview.networkmock.core.model.OperationKey key, int statusCode, String exampleName, kotlin.coroutines.Continuation<? super com.worldline.devview.networkmock.core.model.MockResponse?>);
Expand All @@ -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<java.lang.String,java.lang.String>? configQueryParams, java.util.Map<java.lang.String,? extends java.util.List<java.lang.String>> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<mediaType>.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<String> = emptyList(),
val discriminatorField: String? = null,
val discriminatorValue: String? = null
)

/**
* A single mockable API operation, parsed from one `paths.<path>.<method>` entry.
*
Expand Down Expand Up @@ -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
*/
Expand All @@ -92,7 +128,8 @@ public data class Operation(
val queryParameters: Map<String, String>? = null,
val delayMs: Long? = null,
val version: String? = null,
val failureRate: Double? = null
val failureRate: Double? = null,
val requestBodyMatch: RequestBodyMatch? = null
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<mediaType>.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(
Expand Down Expand Up @@ -73,6 +73,7 @@ internal data class OperationObject(
val operationId: String? = null,
val summary: String? = null,
val parameters: List<ParameterObject> = emptyList(),
val requestBody: RequestBodyObject? = null,
val responses: Map<String, ResponseObject> = emptyMap(),
@SerialName("x-devview") val xDevview: DevViewExtension? = null
)
Expand Down Expand Up @@ -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 `<mediaType>.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<String, MediaTypeObject> = 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.<mediaType>` 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.<mediaType>` 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.
Expand All @@ -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(
Expand All @@ -173,7 +198,8 @@ internal data class SchemaObject(
val format: String? = null,
val allOf: List<SchemaObject>? = null,
val oneOf: List<SchemaObject>? = null,
val discriminator: DiscriminatorObject? = null
val discriminator: DiscriminatorObject? = null,
val required: List<String>? = null
)

/**
Expand All @@ -192,7 +218,8 @@ internal data class ComponentsObject(
val responses: Map<String, ResponseObject> = emptyMap(),
val examples: Map<String, ExampleObject> = emptyMap(),
val headers: Map<String, HeaderObject> = emptyMap(),
val schemas: Map<String, SchemaObject> = emptyMap()
val schemas: Map<String, SchemaObject> = emptyMap(),
val requestBodies: Map<String, RequestBodyObject> = emptyMap()
)

/**
Expand Down
Loading
Loading