Skip to content

feat: match operations on request body shape - #133

Open
MaxMichel2 wants to merge 4 commits into
feat/openapi-schema-synthesisfrom
feat/networkmock-request-body-matching
Open

MaxMichel2 wants to merge 4 commits into
feat/openapi-schema-synthesisfrom
feat/networkmock-request-body-matching

Conversation

@MaxMichel2

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #132 (PR chain: #131#132 → this) — merge in order.

Two operations that would otherwise collide on path, method, and query (e.g. two specs sharing a host, each declaring POST /api/payments) can now be disambiguated by their requestBody shape — required fields and/or a discriminator field's value, read narrowly from the operation's declared schema (not full JSON Schema validation).

What changed

  • RequestBodyMatch (new public class in devview-networkmock-core): requiredFields: List<String>, discriminatorField: String?, discriminatorValue: String?.
  • Operation.requestBodyMatch: RequestBodyMatch?null when the operation declares no requestBody, or its schema yields nothing to check (no required fields, no usable discriminator).
  • OpenApiDocument: added RequestBodyObject ($ref + content), SchemaObject.required, OperationObject.requestBody, ComponentsObject.requestBodies. requestBody's own $ref and its schema's $ref both resolve through the existing resolveRef chain-following machinery from fix: resolve dollar-ref chains and disambiguate component sections #131.
  • OpenApiParser's ParseContext gains resolveRequestBody and buildRequestBodyMatch — the latter prefers application/json among declared media types, falling back to whichever is declared first; the discriminator's expected value comes from that property's own single-value enum (documented simplification, consistent with feat: synthesise response bodies from OpenAPI schemas #82/feat: allOf / oneOf / discriminator resolution #84's oneOf+discriminator handling).
  • RequestMatcher.matchesRequestBody(configMatch, requestBody)null config always matches; a non-null config requires every required field present and, if a discriminator field is declared, that field present (and equal to the declared value, if one was resolvable).
  • MockConfigRepository.findMatchingMock gains an optional requestBody: String? parameter, checked alongside the existing path/method/query checks in spec.operations.firstOrNull { ... }.
  • devview-networkmock-ktor's NetworkMockPlugin reads the request body via a new extractRequestBodyTextonly when it's already an OutgoingContent.ByteArrayContent (the shape Ktor's content negotiation produces for a JSON-serialized body). bytes() on that type is a pure, repeatable read of bytes already fully materialized in memory — not a stream — so this never consumes or mutates anything the later execute(requestBuilder) call still needs to send. Any other content shape (streaming, multipart, no content) returns null, i.e. is treated as "no body" rather than risking corruption of live traffic.

Scope decisions

  • No match on body mismatch falls through to the network (not to some other operation without a body constraint) — the simpler, more predictable default, consistent with how an unmatched path/method/query already behaves.
  • Discriminator matching only checks a single literal value, sourced from the discriminator property's own single-value enum at parse time — there's no concrete request data available at spec-parse time to do anything smarter, mirroring the same simplification already made for oneOf+discriminator response synthesis in feat: synthesise response bodies from OpenAPI schemas #82/feat: allOf / oneOf / discriminator resolution #84.
  • Only one media type's schema is read per requestBody (application/json preferred), mirroring how query-parameter matching already reads a single literal example value rather than modeling every media type.

Public API

devview-networkmock-core/api/api.txt — purely additive: new RequestBodyMatch class, Operation.requestBodyMatch (+ component9/copy param), RequestMatcher.matchesRequestBody, MockConfigRepository.findMatchingMock's new optional requestBody param. docs/modules/networkmock-core.md updated with a new "Request body matching" section.

Tests

  • RequestMatcherTest.kt: 12 new tests for matchesRequestBody — null config, null/invalid/non-object body, required-field presence/absence, discriminator match/mismatch/absence, discriminator-without-value (presence-only), and both constraints combined.
  • MockConfigRepositoryTest.kt: 5 new tests — parsing required into requestBodyMatch, parsing a discriminator's single-value enum, requestBodyMatch staying null when there's nothing to check or no requestBody at all, findMatchingMock disambiguating by discriminator value, and resolving a $ref'd requestBody schema via components.schemas.
  • NetworkMockPluginTest.kt: 2 new tests — two specs sharing a host, disambiguated only by body shape, both resolving to the correct mock; and a request whose body matches no declared shape falling through to the network with its original body reaching the MockEngine byte-for-byte, unconsumed.

Verification

.\gradlew.bat "detektFull" "-Pandroidx.baselineprofile.skipgeneration"
.\gradlew.bat "cleanTestAndroidHostTest" "testAndroidHostTest" "-Pandroidx.baselineprofile.skipgeneration"
.\gradlew.bat ":konsist:test" "-Pandroidx.baselineprofile.skipgeneration"
.\gradlew.bat ":devview-networkmock:compileAndroidDeviceTest" "-Pandroidx.baselineprofile.skipgeneration"
.\gradlew.bat ":sample:androidApp:assembleDebug" "-Pandroidx.baselineprofile.skipgeneration"
.\gradlew.bat ":devview-networkmock-core:metalavaGenerateSignature" ":devview-networkmock-ktor:metalavaGenerateSignature" "-Pandroidx.baselineprofile.skipgeneration"

All green — detekt clean, full test suite passing (including the two new plugin tests proving both disambiguation and body-integrity-on-fallback), konsist clean, device tests compile, sample app builds. devview-networkmock-ktor/api/api.txt has no diff (no new public API there).

🤖 Generated with GitHub Copilot in JetBrains AI, based on a handoff plan authored by Claude Code.

Two operations that collide on path, method, and query (e.g. two specs sharing a host, each declaring POST /api/payments) can now be disambiguated by their requestBody shape. A new RequestBodyMatch (requiredFields, discriminatorField, discriminatorValue) is built from an operation requestBody content schema (application/json preferred, otherwise the first declared media type): required fields from the schema required array, and a discriminator literal value from that property own single-value enum. This is narrow matching, not full JSON Schema validation - an operation whose schema yields neither is unaffected (requestBodyMatch stays null, matching any body, same as declaring no requestBody at all).

OpenApiDocument gains RequestBodyObject and SchemaObject.required; OperationObject gains requestBody; ComponentsObject gains requestBodies for ref resolution via the existing resolveRef machinery. RequestMatcher gains matchesRequestBody; MockConfigRepository.findMatchingMock gains an optional requestBody parameter threaded into its operation-matching check.

devview-networkmock-ktor plugin reads the request body only when it is already a fully in-memory OutgoingContent.ByteArrayContent, the shape Ktor content negotiation produces for a JSON-serialized body - bytes() is a pure, repeatable read, so this never consumes or mutates anything execute() still needs to send. Any other content shape (streaming, multipart, none) is treated as no body rather than risking corruption of live traffic.

api.txt regenerated for devview-networkmock-core; docs/modules/networkmock-core.md gains a Request body matching section.
Answers a question about whether the schema synthesis and requestBody matching work raised in PR review: a real-world YAML spec (schemas at the end of the file, three response codes ref-ing the same schema, a requestBody with its own ref and a requestBody.required boolean, folded summary strings, tags block sequence) now has explicit regression coverage.

No existing test previously exercised the full YAML pipeline end to end (isYaml sniffing, kaml decode, ref resolution, schema synthesis) - only JSON string fixtures were used throughout MockConfigRepositoryTest, plus an unrelated kaml library smoke test. Confirms strictMode false lenient decoding, order-independent components.schemas lookup, and SchemaSynthesizer resolving a bare top-level dollar-ref before checking type/properties all work together as intended - no code changes needed, this closes a coverage gap only.
@MaxMichel2

MaxMichel2 commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

Re: does schema synthesis handle real-world specs with dollar-ref'd schemas defined at the end of the file?

Yes - confirmed and now covered by a regression test (7236295). The full pipeline already handles this by construction:

  • components.schemas (and any other components section) is decoded into a plain Map, so lookup by name is entirely order-independent - it does not matter whether components appears before or after paths in the source YAML/JSON text.
  • SchemaSynthesizer.synthesize resolves a schema's own dollar-ref first (val resolved = resolveSchema(schema)), before inspecting type/properties/enum/etc, so a response whose schema is a bare dollar-ref (not nested under properties) resolves and synthesizes correctly.
  • YAML decoding uses kaml with strictMode = false (mirroring JSON's ignoreUnknownKeys = true), so unmodeled fields in a real spec - description, requestBody.required (a boolean, unrelated to schema.required's list of property names), tags, folded/quoted multi-line strings - are silently ignored rather than failing to parse.

Gap found while checking this: no existing test exercised the full YAML pipeline end-to-end (extension sniffing -> kaml decode -> ref resolution -> synthesis) - only JSON string fixtures were used throughout MockConfigRepositoryTest, plus an unrelated kaml library smoke test. Added \parses a real-world YAML spec with dollar-ref schemas declared after paths\ mirroring the exact shape from the question (requestBody with its own dollar-ref + required boolean, three response codes dollar-ref-ing the same response schema, folded summary, tags block sequence) - passed first try, no code changes needed.

Kotlin 2.2+'s multi-dollar string literals (dollar-dollar-quote-quote-quote) let a JSON/YAML fixture write a literal dollar-ref directly instead of the escaped dollar-quote-dollar-quote form every dollar-ref-containing fixture in this file needed before. Purely a readability cleanup of existing test fixtures across the dollar-ref chain and requestBody-matching tests plus the new YAML regression test - no behavior change, no new coverage.

Verified: detektFull, cleanTestAndroidHostTest, testAndroidHostTest all green (44/44 in MockConfigRepositoryTest).
@MaxMichel2
MaxMichel2 added this pull request to stack #130 September 23, 2026 15:23
…n/Native)

Kotlin/Native's frontend rejects certain punctuation in identifiers derived
from backtick-quoted function names, even though the JVM/Android backends
accept them fine. RequestMatcherTest's `matchesRequestBody ignores the
required field's actual value, only checks presence` test tripped this on
iosSimulatorArm64/iosArm64/iosX64 (compileTestKotlinIos*): 'Name contains
illegal characters: ,.'.

Renamed to replace the comma with 'and' - no other backtick test name in the
repo contains a comma (checked repo-wide). Verified locally:
:devview-networkmock-core:compileTestKotlinIosSimulatorArm64 now succeeds.
@MaxMichel2 MaxMichel2 added this to the 0.2.0 milestone Sep 23, 2026
@MaxMichel2 MaxMichel2 self-assigned this Sep 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant