diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f58a7e..0c3c14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- 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 + `properties`/`items`), `allOf` (properties merged; conflicting definitions across members + throw a clear error), and `oneOf` (first declared variant; `discriminator` is parsed but + doesn't yet steer variant selection). Deliberately narrow, not full JSON Schema conformance — + see `docs/modules/networkmock-core.md`'s new "Schema-based response synthesis" section. + `MockResponse` gains `isSynthesized: Boolean` (default `false`); the operation picker page + shows a small "Generated" badge on a synthesized response's row. + (`devview-networkmock-core`, `devview-networkmock`, #82, #84) - NetworkMock: an operation can now be configured as a **sequence** — an ordered list of responses it advances through one step per matched request, sticking on the last step once exhausted rather than looping back to the start. Useful for polling flows (order status, diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index a16487a..dd3d766 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -96,31 +96,34 @@ 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, optional String contentType, optional java.util.Map headers); + ctor public MockResponse(int statusCode, String exampleName, String displayName, String content, optional String contentType, optional java.util.Map headers, optional boolean isSynthesized); method public int component1(); method public String component2(); method public String component3(); method public String component4(); 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 public boolean component7(); + 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, optional boolean isSynthesized); 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(); + method @InaccessibleFromKotlin public boolean isSynthesized(); property public String content; property public String contentType; property public String displayName; property public String exampleName; property public java.util.Map headers; + property public boolean isSynthesized; 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 String contentType, optional java.util.Map headers, 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 boolean isSynthesized, 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 09d7daa..f1b5780 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 @@ -24,6 +24,11 @@ import kotlinx.serialization.Serializable * @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. + * @property isSynthesized Whether [content] was generated from a declared `schema` (see + * `com.worldline.devview.networkmock.core.openapi.SchemaSynthesizer`) rather than authored by + * the spec as an `examples..externalValue` file. `false` for every response variant + * sourced the normal way — this only ever flips to `true` for a status code that declared a + * `schema` but no `examples` of its own. * @see com.worldline.devview.networkmock.core.repository.MockConfigRepository */ @Immutable @@ -34,7 +39,8 @@ public data class MockResponse( val displayName: String, val content: String, val contentType: String = "application/json", - val headers: Map = emptyMap() + val headers: Map = emptyMap(), + val isSynthesized: Boolean = false ) { public companion object { /** @@ -52,6 +58,8 @@ public data class MockResponse( * @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 isSynthesized Whether [content] was schema-synthesized rather than author-provided. + * Defaults to `false`. * @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] @@ -62,6 +70,7 @@ public data class MockResponse( content: String, contentType: String = "application/json", headers: Map = emptyMap(), + isSynthesized: Boolean = false, statusTextProvider: (Int) -> String = ::getStatusText ): MockResponse = MockResponse( statusCode = statusCode, @@ -73,7 +82,8 @@ public data class MockResponse( ), content = content, contentType = contentType, - headers = headers + headers = headers, + isSynthesized = isSynthesized ) @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 8af9359..4008ba3 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,8 +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, schemas, …) is silently ignored via - * lenient/non-strict decoding — this parser mocks, it does not validate. + * (`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. */ @Serializable internal data class OpenApiDocument( @@ -112,7 +115,10 @@ internal data class HeaderObject( ) @Serializable -internal data class MediaTypeObject(val examples: Map = emptyMap()) +internal data class MediaTypeObject( + val examples: Map = emptyMap(), + val schema: SchemaObject? = null +) /** * A named response example, or a `$ref` to one under `components.examples`. @@ -128,12 +134,65 @@ internal data class ExampleObject( val externalValue: String? = null ) +/** + * 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]. + * + * 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*. + * + * @property type The schema's declared type (`"string"`, `"integer"`, `"number"`, `"boolean"`, + * `"object"`, or `"array"`). May be absent when [properties] or [items] alone implies it. + * @property enum If non-empty, [SchemaSynthesizer] uses the first declared value verbatim + * instead of a generic placeholder for [type] `"string"`. + * @property properties For `type: object` (or when present at all, regardless of [type]): + * each property's own schema, synthesized recursively. + * @property items For `type: array` (or when present at all, regardless of [type]): the + * schema of a single array element — [SchemaSynthesizer] produces a one-element array. + * @property nullable Read but not acted on: [SchemaSynthesizer] always synthesizes a real + * value, even for a nullable schema — this library mocks, it does not test null-handling. + * @property format Read but not currently used by [SchemaSynthesizer] — reserved for a future + * format-aware placeholder (e.g. `"date-time"`, `"uuid"`). + * @property allOf Member schemas merged into one effective object schema — see + * [SchemaSynthesizer] for the conflicting-property-definition error case. + * @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. + */ +@Serializable +internal data class SchemaObject( + @SerialName("\$ref") val ref: String? = null, + val type: String? = null, + val enum: List? = null, + val properties: Map? = null, + val items: SchemaObject? = null, + val nullable: Boolean? = null, + val format: String? = null, + val allOf: List? = null, + val oneOf: List? = null, + val discriminator: DiscriminatorObject? = null +) + +/** + * A `oneOf` discriminator declaration — identifies which property carries the type tag, and + * optionally maps its values to explicit `components.schemas` names. See [SchemaObject.discriminator]. + */ +@Serializable +internal data class DiscriminatorObject( + val propertyName: String = "", + val mapping: Map? = null +) + @Serializable internal data class ComponentsObject( val parameters: Map = emptyMap(), val responses: Map = emptyMap(), val examples: Map = emptyMap(), - val headers: Map = emptyMap() + val headers: Map = emptyMap(), + val schemas: 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 c664b86..ab8b5e6 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 @@ -6,14 +6,27 @@ 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.` — + * A resolved response variant's actual content source: either a file on disk (an OpenAPI + * example's `externalValue`) or an in-memory body synthesized once from a declared `schema` + * when the status code has no `examples` (see [SchemaSynthesizer]). + */ +internal sealed interface ResponseContent { + /** Loaded via [NetworkMockResourceLoader.load] at [path]. */ + data class FromFile(val path: String) : ResponseContent + + /** Already-serialized JSON text, produced once by [SchemaSynthesizer] and cached here. */ + data class Synthesized(val json: String) : ResponseContent +} + +/** + * A resolved response variant: its actual [content] (a file to load, or an already-synthesized + * body), 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 content: ResponseContent, val contentType: String, val headers: Map ) @@ -30,17 +43,21 @@ internal data class ResolvedResponse( * ## Scope decisions (deliberate, not oversights) * - Query-parameter matching values come from a parameter's top-level `example` field only * (not `schema.example`/`schema.default`) — see [ParameterObject]. - * - Response bodies are sourced only from `examples..externalValue`; an example - * declared with an inline `value` is skipped, since this library keeps response bodies as - * external files (see the epic's format decisions). + * - Response bodies are sourced from `examples..externalValue` first; a status code with + * declared `examples` never falls back to schema synthesis, even for a media type within that + * same status code that has a `schema` but no `examples` of its own. An example declared with + * an inline `value` is skipped, since this library keeps response bodies as external files + * (see the epic's format decisions). + * - A status code with **no** `examples` at all but a declared `content..schema` + * synthesizes one placeholder body per such media type instead of being unmockable — see + * [SchemaSynthesizer]. Deliberately narrow (not full JSON Schema conformance); see its KDoc. * - `$ref` and `externalValue` both resolve relative to the file that declares them, into * `#/components/
/` — a `$ref` chain (a component that itself points at * another `$ref`) is followed until a non-ref entry is reached, guarded against cycles. * 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. - * - No schema resolution of any kind — this parser mocks, it does not validate or synthesize - * bodies (see #82/#83/#84, explicitly out of scope for 0.2.0). + * - Request bodies are not read at all (see #83, explicitly out of scope for 0.2.0). */ internal object OpenApiParser { /** @@ -120,6 +137,14 @@ internal object OpenApiParser { private val json = Json { ignoreUnknownKeys = true } + /** + * The example name a synthesized response body (see [SchemaSynthesizer]) is stored under — + * `"default"`, matching this codebase's own convention for the primary/original response + * for a status code (see [com.worldline.devview.networkmock.core.model.MockResponse.exampleName]). + */ + @Suppress("DocumentationOverPrivateProperty") + private const val SYNTHESIZED_EXAMPLE_NAME = "default" + /** * Extracts a display-only `v{n}` version tag from a `/v{n}/` path segment (see * [Operation.version]). Not currently configurable — see the KDoc there. @@ -186,28 +211,14 @@ internal object OpenApiParser { } 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, - document = document, - section = "examples", - componentsOf = { it.components.examples }, - refOf = { it.ref } - ) - } else { - rawExample - } - val externalValue = example.externalValue ?: continue - examplesForCode[exampleName] = ResolvedResponse( - path = resolvePath(baseDir = baseDir, ref = externalValue), - contentType = mediaType, - headers = headers - ) - } + examplesForCode += resolveMediaTypeResponses( + mediaType = mediaType, + media = media, + document = document, + headers = headers + ) } if (examplesForCode.isNotEmpty()) { result[statusCode] = examplesForCode @@ -216,6 +227,68 @@ internal object OpenApiParser { return result } + /** + * Resolves every declared `examples.` for [mediaType], falling back to one + * schema-synthesized body (see [SchemaSynthesizer], stored under [SYNTHESIZED_EXAMPLE_NAME]) + * when [media] declares no examples of its own but does declare a `schema`. + */ + @Suppress("DocumentationOverPrivateFunction") + private suspend fun resolveMediaTypeResponses( + mediaType: String, + media: MediaTypeObject, + document: OpenApiDocument, + headers: Map + ): Map { + val resolved = mutableMapOf() + for ((exampleName, rawExample) in media.examples) { + val example = if (rawExample.ref != null) { + resolveRef( + ref = rawExample.ref, + document = document, + section = "examples", + componentsOf = { it.components.examples }, + refOf = { it.ref } + ) + } else { + rawExample + } + val externalValue = example.externalValue ?: continue + resolved[exampleName] = ResolvedResponse( + content = ResponseContent.FromFile( + path = resolvePath(baseDir = baseDir, ref = externalValue) + ), + contentType = mediaType, + headers = headers + ) + } + + val schema = media.schema + if (resolved.isEmpty() && schema != null) { + val synthesized = SchemaSynthesizer.synthesize( + schema = schema, + resolveSchema = { resolveSchema(raw = it, document = document) } + ) + resolved[SYNTHESIZED_EXAMPLE_NAME] = ResolvedResponse( + content = ResponseContent.Synthesized(json = synthesized.toString()), + contentType = mediaType, + headers = headers + ) + } + return resolved + } + + /** Resolves a schema's own `$ref` (if any) via [resolveRef] against `components.schemas`. */ + suspend fun resolveSchema(raw: SchemaObject, document: OpenApiDocument): SchemaObject { + val ref = raw.ref ?: return raw + return resolveRef( + ref = ref, + document = document, + section = "schemas", + componentsOf = { it.components.schemas }, + refOf = { it.ref } + ) + } + /** 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/openapi/SchemaSynthesizer.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/SchemaSynthesizer.kt new file mode 100644 index 0000000..6edca91 --- /dev/null +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/SchemaSynthesizer.kt @@ -0,0 +1,147 @@ +package com.worldline.devview.networkmock.core.openapi + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +/** + * Synthesizes a placeholder response body from a [SchemaObject] — used by [OpenApiParser] when + * an operation's `responses..content.` declares a `schema` but no `examples`, + * so that operation isn't left with zero mockable variants (see #82/#84). + * + * This is deliberately narrow, not full JSON Schema synthesis: primitives, `enum`, `object`, + * `array`, `allOf` (merged), and `oneOf` (first variant) — see [synthesize]'s KDoc for the exact + * rules per shape. Anything outside that (e.g. a schema with none of `type`/`properties`/`items`/ + * `enum`/`allOf`/`oneOf` declared) is a clear [IllegalStateException], not a guess. + */ +internal object SchemaSynthesizer { + /** + * Synthesizes one plausible [JsonElement] for [schema], resolving `$ref`s via [resolveSchema] + * (typically [OpenApiParser]'s own ref-resolution, reused rather than duplicated — see + * `ParseContext.resolveSchema`) wherever a nested schema is encountered. + * + * Resolution order once `$ref` is resolved: `allOf` (merge), then `oneOf` (first variant), + * then `enum` (first value), then `object`/`array` shape (by declared `type` or by the mere + * presence of `properties`/`items`), then primitive `type`s. [SchemaObject.nullable] is + * ignored — a real value is always produced, never a JSON `null`; this library mocks + * responses, it doesn't exercise null-handling. + * + * @param schema The schema to synthesize a value for. + * @param resolveSchema Resolves a schema's own `$ref` (if any) to its target; returns the + * schema unchanged when it has none. + * @throws IllegalStateException if [schema] declares an `allOf` with conflicting property + * definitions across members, an `array` with no `items`, a `oneOf` with no variants, or + * a shape this function doesn't recognize (no `type`/`properties`/`items`/`enum`/`allOf`/ + * `oneOf`, or an unsupported `type` string). + * + * `NamedArguments` is suppressed below because [resolveSchema] is a function-type parameter — + * invoking it (`resolveSchema(schema)`) calls `Function1.invoke`, whose single parameter has + * no name to reference, the same class of exception as `NetworkMockPlugin`'s `IOException` one. + */ + @Suppress("NamedArguments") + suspend fun synthesize( + schema: SchemaObject, + resolveSchema: suspend (SchemaObject) -> SchemaObject + ): JsonElement { + val resolved = resolveSchema(schema) + return when { + resolved.allOf != null -> synthesizeAllOf( + members = resolved.allOf, + resolveSchema = resolveSchema + ) + resolved.oneOf != null -> synthesizeOneOf( + members = resolved.oneOf, + resolveSchema = resolveSchema + ) + !resolved.enum.isNullOrEmpty() -> JsonPrimitive(resolved.enum.first()) + resolved.type == "object" || resolved.properties != null -> + synthesizeObject(schema = resolved, resolveSchema = resolveSchema) + resolved.type == "array" || resolved.items != null -> + synthesizeArray(schema = resolved, resolveSchema = resolveSchema) + resolved.type == "string" -> JsonPrimitive("string") + resolved.type == "integer" || resolved.type == "number" -> JsonPrimitive(0) + resolved.type == "boolean" -> JsonPrimitive(false) + else -> error( + message = + "Cannot synthesize a response body for schema (type='${resolved.type}'): " + + "no enum/object/array/primitive shape declared." + ) + } + } + + private suspend fun synthesizeObject( + schema: SchemaObject, + resolveSchema: suspend (SchemaObject) -> SchemaObject + ): JsonElement = buildJsonObject { + schema.properties?.forEach { (name, propertySchema) -> + put( + key = name, + element = synthesize(schema = propertySchema, resolveSchema = resolveSchema) + ) + } + } + + private suspend fun synthesizeArray( + schema: SchemaObject, + resolveSchema: suspend (SchemaObject) -> SchemaObject + ): JsonElement { + val items = schema.items + ?: error( + message = "Cannot synthesize an array response body: schema declares no 'items'." + ) + return buildJsonArray { + add(element = synthesize(schema = items, resolveSchema = resolveSchema)) + } + } + + /** + * Merges every member's [SchemaObject.properties] into one effective object schema. Two + * members declaring the *same* property with *different* schemas is a spec authoring error + * this function refuses to guess through — see the thrown message. + */ + @Suppress("DocumentationOverPrivateFunction", "NamedArguments") + private suspend fun synthesizeAllOf( + members: List, + resolveSchema: suspend (SchemaObject) -> SchemaObject + ): JsonElement { + val merged = mutableMapOf() + for (member in members) { + val resolvedMember = resolveSchema(member) + for ((name, propertySchema) in resolvedMember.properties.orEmpty()) { + val existing = merged[name] + if (existing != null && existing != propertySchema) { + error( + message = + "Cannot merge allOf: conflicting definitions for property '$name' " + + "across members." + ) + } + merged[name] = propertySchema + } + } + return buildJsonObject { + for ((name, propertySchema) in merged) { + put( + key = name, + element = synthesize(schema = propertySchema, resolveSchema = resolveSchema) + ) + } + } + } + + /** + * Synthesizes the first declared `oneOf` variant, regardless of [SchemaObject.discriminator] + * — see [SchemaObject.discriminator]'s KDoc for why a discriminator can't currently steer + * variant selection at spec-parse time. + */ + @Suppress("DocumentationOverPrivateFunction") + private suspend fun synthesizeOneOf( + members: List, + resolveSchema: suspend (SchemaObject) -> SchemaObject + ): JsonElement { + val first = members.firstOrNull() + ?: error(message = "Cannot synthesize a oneOf response body: no variants declared.") + return synthesize(schema = first, resolveSchema = resolveSchema) + } +} 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 3e8c167..9a7e769 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 @@ -8,6 +8,7 @@ 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 com.worldline.devview.networkmock.core.openapi.ResponseContent import kotlinx.serialization.SerializationException private val logger = Logger.withTag(tag = "DevViewNetworkMock") @@ -216,13 +217,17 @@ public class MockConfigRepository( statusCode: Int, exampleName: String ): MockResponse? = try { - val content = resourceLoader.load(path = resolved.path).decodeToString() + val content = when (val source = resolved.content) { + is ResponseContent.FromFile -> resourceLoader.load(path = source.path).decodeToString() + is ResponseContent.Synthesized -> source.json + } MockResponse.create( statusCode = statusCode, exampleName = exampleName, content = content, contentType = resolved.contentType, - headers = resolved.headers + headers = resolved.headers, + isSynthesized = resolved.content is ResponseContent.Synthesized ) } catch (@Suppress("SwallowedException") e: IllegalStateException) { null diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/openapi/SchemaSynthesizerTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/openapi/SchemaSynthesizerTest.kt new file mode 100644 index 0000000..e0e0083 --- /dev/null +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/openapi/SchemaSynthesizerTest.kt @@ -0,0 +1,229 @@ +package com.worldline.devview.networkmock.core.openapi + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +class SchemaSynthesizerTest { + + /** No `$ref`s in these fixtures — every schema is passed through unchanged. */ + private val noRefs: suspend (SchemaObject) -> SchemaObject = { it } + + // region primitives + + @Test + fun `synthesizes a placeholder string for a string schema`() = runTest { + val result = SchemaSynthesizer.synthesize( + schema = SchemaObject(type = "string"), + resolveSchema = noRefs + ) + + result shouldBe JsonPrimitive("string") + } + + @Test + fun `synthesizes zero for an integer schema`() = runTest { + val result = SchemaSynthesizer.synthesize( + schema = SchemaObject(type = "integer"), + resolveSchema = noRefs + ) + + result shouldBe JsonPrimitive(0) + } + + @Test + fun `synthesizes zero for a number schema`() = runTest { + val result = SchemaSynthesizer.synthesize( + schema = SchemaObject(type = "number"), + resolveSchema = noRefs + ) + + result shouldBe JsonPrimitive(0) + } + + @Test + fun `synthesizes false for a boolean schema`() = runTest { + val result = SchemaSynthesizer.synthesize( + schema = SchemaObject(type = "boolean"), + resolveSchema = noRefs + ) + + result shouldBe JsonPrimitive(false) + } + + // endregion + + // region enum + + @Test + fun `synthesizes the first enum value instead of a generic placeholder`() = runTest { + val schema = SchemaObject(type = "string", enum = listOf("ACTIVE", "INACTIVE")) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe JsonPrimitive("ACTIVE") + } + + // endregion + + // region object + + @Test + fun `synthesizes a nested object by recursively synthesizing each property`() = runTest { + val schema = SchemaObject( + type = "object", + properties = mapOf( + "id" to SchemaObject(type = "integer"), + "name" to SchemaObject(type = "string") + ) + ) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe buildJsonObject { + put("id", JsonPrimitive(0)) + put("name", JsonPrimitive("string")) + } + } + + @Test + fun `treats a schema with properties but no declared type as an object`() = runTest { + val schema = SchemaObject(properties = mapOf("id" to SchemaObject(type = "integer"))) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe buildJsonObject { put("id", JsonPrimitive(0)) } + } + + // endregion + + // region array + + @Test + fun `synthesizes a single-element array from items`() = runTest { + val schema = SchemaObject(type = "array", items = SchemaObject(type = "string")) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe buildJsonArray { add(JsonPrimitive("string")) } + } + + @Test + fun `throws when an array schema declares no items`() = runTest { + shouldThrow { + SchemaSynthesizer.synthesize(schema = SchemaObject(type = "array"), resolveSchema = noRefs) + } + } + + // endregion + + // region allOf + + @Test + fun `allOf merges every members properties into one effective object`() = runTest { + val schema = SchemaObject( + allOf = listOf( + SchemaObject(properties = mapOf("id" to SchemaObject(type = "integer"))), + SchemaObject(properties = mapOf("name" to SchemaObject(type = "string"))) + ) + ) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe buildJsonObject { + put("id", JsonPrimitive(0)) + put("name", JsonPrimitive("string")) + } + } + + @Test + fun `allOf throws on conflicting property definitions across members`() = runTest { + val schema = SchemaObject( + allOf = listOf( + SchemaObject(properties = mapOf("id" to SchemaObject(type = "integer"))), + SchemaObject(properties = mapOf("id" to SchemaObject(type = "string"))) + ) + ) + + shouldThrow { + SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + } + } + + @Test + fun `allOf tolerates the same property redeclared identically across members`() = runTest { + val schema = SchemaObject( + allOf = listOf( + SchemaObject(properties = mapOf("id" to SchemaObject(type = "integer"))), + SchemaObject(properties = mapOf("id" to SchemaObject(type = "integer"))) + ) + ) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe buildJsonObject { put("id", JsonPrimitive(0)) } + } + + // endregion + + // region oneOf + + @Test + fun `oneOf without a discriminator synthesizes the first declared variant`() = runTest { + val schema = SchemaObject(oneOf = listOf(SchemaObject(type = "string"), SchemaObject(type = "integer"))) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe JsonPrimitive("string") + } + + @Test + fun `oneOf with a discriminator still synthesizes the first declared variant`() = runTest { + val schema = SchemaObject( + oneOf = listOf(SchemaObject(type = "string"), SchemaObject(type = "integer")), + discriminator = DiscriminatorObject(propertyName = "type") + ) + + val result = SchemaSynthesizer.synthesize(schema = schema, resolveSchema = noRefs) + + result shouldBe JsonPrimitive("string") + } + + @Test + fun `throws when oneOf declares no variants`() = runTest { + shouldThrow { + SchemaSynthesizer.synthesize(schema = SchemaObject(oneOf = emptyList()), resolveSchema = noRefs) + } + } + + // endregion + + // region dollar-ref resolution and unrecognized shapes + + @Test + fun `resolves a dollar-ref via the provided resolveSchema callback before synthesizing`() = runTest { + val target = SchemaObject(type = "string") + val schema = SchemaObject(ref = "#/components/schemas/Foo") + + val result = SchemaSynthesizer.synthesize( + schema = schema, + resolveSchema = { if (it.ref == "#/components/schemas/Foo") target else it } + ) + + result shouldBe JsonPrimitive("string") + } + + @Test + fun `throws when a schema declares no recognizable shape`() = runTest { + shouldThrow { + SchemaSynthesizer.synthesize(schema = SchemaObject(), resolveSchema = noRefs) + } + } + + // endregion +} + 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 889aa73..cc1c221 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 @@ -677,6 +677,131 @@ class MockConfigRepositoryTest { result.exceptionOrNull()?.message.orEmpty() shouldContain "cyclic reference detected" } + @Test + fun `discoverResponseFiles synthesizes a body from schema when a status code declares no examples`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "example", operationId = "getUser") + ) + + responses shouldHaveSize 1 + val synthesized = responses.single() + synthesized.isSynthesized shouldBe true + synthesized.content shouldBe """{"id":0,"name":"string"}""" + } + + @Test + fun `discoverResponseFiles prefers declared examples over schema synthesis`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "type": "object" }, + "examples": { + "default": { "externalValue": "/responses/getUser-200.json" } + } + } + } + } + } + } + } + } + } + """.trimIndent() + val repository = createRepository( + resources = mapOf(SPEC_PATH to spec, "responses/getUser-200.json" to """{"id":1}""") + ) + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "example", operationId = "getUser") + ) + + responses shouldHaveSize 1 + val response = responses.single() + response.isSynthesized shouldBe false + response.content shouldBe """{"id":1}""" + } + + @Test + fun `discoverResponseFiles resolves a dollar-ref'd schema via components schemas before synthesizing`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users/{userId}": { + "get": { + "operationId": "getUser", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "${'$'}ref": "#/components/schemas/User" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { "id": { "type": "integer" } } + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val responses = repository.discoverResponseFiles( + key = OperationKey(specId = "example", operationId = "getUser") + ) + + responses shouldHaveSize 1 + responses.single().content shouldBe """{"id":0}""" + } + @Test fun `discoverResponseFiles returns responses sorted by status code`() = runTest { val repository = createRepository(resources = baseResources()) diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt index 9489d98..41f9ca9 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/components/MockItem.kt @@ -3,9 +3,11 @@ package com.worldline.devview.networkmock.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Visibility @@ -43,7 +45,9 @@ import com.worldline.devview.utils.preview.BooleanPreviewParameterProvider /** * A single selectable response variant row in the operation picker sheet. * - * @param mockResponse The response variant this row represents + * @param mockResponse The response variant this row represents. A "Generated" badge is shown + * when [MockResponse.isSynthesized] is `true` — see + * `com.worldline.devview.networkmock.core.openapi.SchemaSynthesizer`. * @param onClick Called when the row itself is tapped — activates this response and closes the sheet * @param isMarkedForPreview Whether this response is currently marked for the preview/compare page * @param onToggleMarkedForPreview Called when the trailing preview toggle is tapped @@ -67,6 +71,7 @@ internal fun MockItem( contentColor = contentColorForStatusCode(statusCode = mockResponse.statusCode), containerColor = containerColorForStatusCode(statusCode = mockResponse.statusCode), label = mockResponse.displayName, + isSynthesized = mockResponse.isSynthesized, selected = selected, onClick = onClick, isMarkedForPreview = isMarkedForPreview, @@ -135,7 +140,8 @@ private fun MockItemContent( isMarkedForPreview: Boolean, onToggleMarkedForPreview: (() -> Unit)?, previewToggleTestTag: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isSynthesized: Boolean = false ) { Row( modifier = Modifier @@ -170,6 +176,21 @@ private fun MockItemContent( text = label, style = MaterialTheme.typography.bodyLargeEmphasized ) + if (isSynthesized) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(size = 4.dp) + ).padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "Generated", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } if (selected) { Icon( imageVector = Icons.Rounded.Check, @@ -281,3 +302,18 @@ private fun MockItemMarkedForPreviewPreview( } } } + +@Preview(locale = "en") +@Composable +private fun MockItemSynthesizedPreview() { + MaterialTheme { + Surface { + MockItem( + mockResponse = MockResponse.fake().first().copy(isSynthesized = true), + onClick = {}, + isMarkedForPreview = false, + onToggleMarkedForPreview = {} + ) + } + } +} diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index 999c87c..1cbdde5 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -133,6 +133,54 @@ circular reference. Each ref's fragment must name the section its context expect `$ref` must point into `components/responses`), so a same-named entry in a different section is never resolved by mistake. +### Schema-based response synthesis + +A status code with **no** `examples` at all, but a declared `content..schema`, +synthesizes one placeholder body per such media type instead of being unmockable: + +```json +"200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "status": { "type": "string", "enum": ["ACTIVE", "INACTIVE"] } + } + } + } + } +} +``` + +synthesizes `{"id":0,"status":"ACTIVE"}` under the example name `"default"`. A status code that +declares **any** `examples` for a media type never falls back to synthesis for that media type, +even if it also declares a `schema` — an author-provided example always wins. `MockResponse.isSynthesized` +is `true` for a synthesized body, `false` otherwise; the operation picker page shows a small +"Generated" badge on a synthesized response's row. + +This is deliberately narrow, not full JSON Schema conformance — no `required`, +`additionalProperties`, string patterns, `minimum`/`maximum`, etc. (anything that would matter for +*validating* a body rather than *synthesizing one plausible value*): + +| Shape | Synthesized value | +|---|---| +| `string` | `"string"`, or the first `enum` value if declared | +| `integer` / `number` | `0` | +| `boolean` | `false` | +| `object` (or any schema with `properties`) | each property synthesized recursively | +| `array` (or any schema with `items`) | a single-element array of the synthesized item | +| `allOf` | member schemas' properties merged into one object; conflicting property definitions across members throw a clear error | +| `oneOf` | the first declared variant — `discriminator` is parsed but doesn't currently steer variant selection, since there's no concrete request/response data at spec-parse time to disambiguate against | + +`nullable` is read but ignored — a real value is always synthesized, never a JSON `null`, since +this library mocks responses rather than exercising null-handling. A schema shape outside this +list (or a schema declaring none of `type`/`properties`/`items`/`enum`/`allOf`/`oneOf`) throws a +clear error rather than guessing. `$ref`s inside a schema (including nested ones under +`properties`/`items`/`allOf`/`oneOf`) resolve against `components/schemas` the same way as +elsewhere in this document. + ### x-devview extension Vanilla OpenAPI has no field for response delay simulation or failure injection, so both live under the standard `x-`-prefixed [Specification Extensions](https://spec.openapis.org/oas/v3.1.0#specification-extensions) mechanism: