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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`devview-networkmock-ktor`, #89)

### Fixed
- NetworkMock: `OpenApiParser`'s `$ref` resolution now disambiguates by the full
`components.<section>` a fragment names, not just its trailing name — a `$ref` whose fragment
points at an unexpected section (e.g. `components/parameters/Foo` where a `components/responses`
entry was expected) is rejected with a clear error instead of being silently resolved against
whatever section the call site happened to expect, so a same-named entry in a different section
can never be conflated with the one actually referenced. `$ref` chains — an entry that itself
declares another `$ref` — are now followed until a non-ref entry is reached (previously only one
level deep), guarded against cycles: a circular `$ref` chain now fails with a clear error instead
of hanging. (`devview-networkmock-core`)
- NetworkMock: replaced ~35 unconditional `println` calls in `MockConfigRepository` and
`NetworkMockPlugin` with gated [Kermit](https://github.com/touchlab/Kermit) logging
(tag `DevViewNetworkMock`), consolidating the plugin's multi-line per-request trace into one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ internal data class ResolvedResponse(
* - Response bodies are sourced only from `examples.<name>.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).
* - `$ref` and `externalValue` both resolve relative to the file that declares them, exactly
* one level deep, into `#/components/<kind>/<name>` — a `$ref` chain (a component that
* itself points at another `$ref`) is not followed.
* - `$ref` and `externalValue` both resolve relative to the file that declares them, into
* `#/components/<section>/<name>` — 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).
*/
Expand Down Expand Up @@ -154,7 +157,13 @@ internal object OpenApiParser {
document: OpenApiDocument
): ParameterObject {
val ref = raw.ref ?: return raw
return resolveRef(ref = ref, document = document) { it.components.parameters }
return resolveRef(
ref = ref,
document = document,
section = "parameters",
componentsOf = { it.components.parameters },
refOf = { it.ref }
)
}

suspend fun resolveResponseIndex(
Expand All @@ -167,8 +176,11 @@ internal object OpenApiParser {
val response = if (rawResponse.ref != null) {
resolveRef(
ref = rawResponse.ref,
document = document
) { it.components.responses }
document = document,
section = "responses",
componentsOf = { it.components.responses },
refOf = { it.ref }
)
} else {
rawResponse
}
Expand All @@ -181,8 +193,11 @@ internal object OpenApiParser {
val example = if (rawExample.ref != null) {
resolveRef(
ref = rawExample.ref,
document = document
) { it.components.examples }
document = document,
section = "examples",
componentsOf = { it.components.examples },
refOf = { it.ref }
)
} else {
rawExample
}
Expand All @@ -209,7 +224,13 @@ internal object OpenApiParser {
): Map<String, String> = raw
.mapNotNull { (name, rawHeader) ->
val header = if (rawHeader.ref != null) {
resolveRef(ref = rawHeader.ref, document = document) { it.components.headers }
resolveRef(
ref = rawHeader.ref,
document = document,
section = "headers",
componentsOf = { it.components.headers },
refOf = { it.ref }
)
} else {
rawHeader
}
Expand All @@ -218,33 +239,84 @@ internal object OpenApiParser {

/**
* Resolves a `$ref` string to its target, either locally (within [document]) or in
* another file, exactly one level deep — the resolved object's own `$ref` (if any)
* is not followed further.
* another file, following a chain of `$ref`s — an entry that itself declares a `$ref`
* is resolved again — until a non-ref entry is reached.
*
* [section] is the `components.<section>` key every hop's fragment must declare (e.g.
* `"responses"`); a fragment naming a different section (`#/components/schemas/Foo`
* when a `"responses"` entry was expected) is rejected, so a same-named entry in a
* different section is never silently conflated with the one actually referenced.
* [componentsOf] selects the matching `components.<section>` map from a document, and
* [refOf] extracts a resolved entry's own `$ref` (if any) so the chain can continue.
*
* @throws IllegalStateException if a `$ref` cannot be resolved, names an unexpected
* section, or the chain revisits a `(document, fragment)` pair already seen (a cycle).
*/
@Suppress("DocumentationOverPrivateFunction")
private suspend fun <T> resolveRef(
ref: String,
document: OpenApiDocument,
componentsOf: (OpenApiDocument) -> Map<String, T>
section: String,
componentsOf: (OpenApiDocument) -> Map<String, T>,
refOf: (T) -> String?
): T {
val (targetDocument, fragment) = if (ref.startsWith(prefix = "#/")) {
document to ref.removePrefix(prefix = "#/")
} else {
val filePath = ref.substringBefore(delimiter = "#")
val fragment = ref
.substringAfter(
delimiter = "#",
missingDelimiterValue = ""
).removePrefix(prefix = "/")
loadExternalDocument(filePath = filePath) to fragment
}
val visited = mutableSetOf<Pair<OpenApiDocument, String>>()
var currentDocument = document
var currentRef = ref

while (true) {
val (targetDocument, fragment) = locate(
ref = currentRef,
document = currentDocument
)

if (!visited.add(element = targetDocument to fragment)) {
error(
message = "Unresolvable \$ref '$ref': cyclic reference detected — " +
"'$currentRef' revisits an already-resolved fragment."
)
}

val segments = fragment.split("/")
val name = segments.lastOrNull()
?: error(
message = "Unresolvable \$ref '$currentRef': fragment has no component name."
)
val actualSection = segments.getOrNull(index = segments.size - 2)
?: error(
message = "Unresolvable \$ref '$currentRef': fragment has no component section."
)
if (actualSection != section) {
error(
message = "Unresolvable \$ref '$currentRef': expected a '$section' entry " +
"but the fragment points into '$actualSection'."
)
}

val segments = fragment.split("/")
val name = segments.lastOrNull()
?: error(message = "Unresolvable \$ref '$ref': fragment has no component name.")
val entry = componentsOf(targetDocument)[name]
?: error(
message = "Unresolvable \$ref '$currentRef': no such entry in components.$section."
)

return componentsOf(targetDocument)[name]
?: error(message = "Unresolvable \$ref '$ref': no such entry in components.")
val nestedRef = refOf(entry) ?: return entry
currentDocument = targetDocument
currentRef = nestedRef
}
}

/** Splits [ref] into the document it targets and its fragment, loading an external file if needed. */
@Suppress("DocumentationOverPrivateFunction")
private suspend fun locate(
ref: String,
document: OpenApiDocument
): Pair<OpenApiDocument, String> = if (ref.startsWith(prefix = "#/")) {
document to ref.removePrefix(prefix = "#/")
} else {
val filePath = ref.substringBefore(delimiter = "#")
val fragment = ref
.substringAfter(delimiter = "#", missingDelimiterValue = "")
.removePrefix(prefix = "/")
loadExternalDocument(filePath = filePath) to fragment
}

private suspend fun loadExternalDocument(filePath: String): OpenApiDocument {
Expand Down
Loading
Loading