@@ -278,25 +278,11 @@ object HttpSemanticsOracle {
278278 schema : RestSchema ? = null
279279 ): Boolean {
280280
281- if (individual.size() < 2 ) return false
282-
283- val actions = individual.seeMainExecutableActions()
284- val put = actions[actions.size - 2 ]
285- val get = actions[actions.size - 1 ]
286-
287- if (put.verb != HttpVerb .PUT ) return false
288- if (get.verb != HttpVerb .GET ) return false
289-
290- if (! put.usingSameResolvedPath(get)) return false
291-
292- if (put.auth.isDifferentFrom(get.auth)) return false
293-
294- val resPut = actionResults.find { it.sourceLocalId == put.getLocalId() } as RestCallResult ?
295- ? : return false
296- val resGet = actionResults.find { it.sourceLocalId == get.getLocalId() } as RestCallResult ?
297- ? : return false
281+ val (put, get, resPut, resGet) = findPutGetPair(individual, actionResults) ? : return false
298282
299283 if (! StatusGroup .G_2xx .isInGroup(resPut.getStatusCode())) return false
284+ // if put returned 2xx but entity does not exist afterwards
285+ if (resGet.getStatusCode() == 404 ) return true
300286 if (! StatusGroup .G_2xx .isInGroup(resGet.getStatusCode())) return false
301287
302288 val bodyParam = put.parameters.find { it is BodyParam } as BodyParam ?
@@ -307,33 +293,75 @@ object HttpSemanticsOracle {
307293 val putBody = extractRequestBody(put)
308294 val getBody = resGet.getBody()
309295
310- if (putBody.isNullOrEmpty() && ! getBody.isNullOrEmpty()) return true
311-
312- // if putBody is not empty but getBody is.
296+ // PUT sent content but GET body is empty -> sent fields definitely missing
313297 if (! putBody.isNullOrEmpty() && getBody.isNullOrEmpty()) return true
314-
315- if (putBody.isNullOrEmpty() || getBody.isNullOrEmpty()) return false
298+ if (getBody.isNullOrEmpty()) return false
316299
317300 val sentFields = extractSentFieldNames(put)
318- val allPutSchemaFields = extractModifiedFieldNames(put)
319- if (sentFields.isEmpty() && allPutSchemaFields.isEmpty()) return false
320-
321- // Wiped = in PUT schema but not sent. PUT semantics say the server must
322- // clear these. We only check wiped fields the GET schema actually exposes,
323- // otherwise write-only fields (e.g. passwords) would cause false positives.
324- val wipedCandidates = allPutSchemaFields - sentFields
325- val wipedFields = if (schema != null && wipedCandidates.isNotEmpty()) {
326- val getSchemaFields = extractGetResponseSchemaFields(schema, get)
327- if (getSchemaFields.isEmpty()) emptySet() else wipedCandidates intersect getSchemaFields
328- } else {
329- emptySet()
301+ val allPutSchemaFields = extractModifiedFieldNames(put).ifEmpty {
302+ schema?.let { extractPutRequestSchemaFields(it, put) } ? : emptySet()
330303 }
304+ if (sentFields.isEmpty() && allPutSchemaFields.isEmpty()) {
305+ // no information to verify against; flag only when PUT sent nothing either
306+ return putBody.isNullOrEmpty()
307+ }
308+
309+ val wipedFields = computeWipedFields(allPutSchemaFields - sentFields, schema, get)
331310
332- return hasMismatchedPutFields(putBody, getBody, sentFields, wipedFields, bodyParam)
311+ return hasMismatchedPutFields(putBody ? : " " , getBody, sentFields, wipedFields, bodyParam)
333312 }
334313
314+ private data class PutGetPair (
315+ val put : RestCallAction ,
316+ val get : RestCallAction ,
317+ val resPut : RestCallResult ,
318+ val resGet : RestCallResult
319+ )
320+
335321 /* *
336- * Dispatches the field-level checks based on the request body content type.
322+ * Validates and extracts the trailing PUT/GET pair from the individual.
323+ * Returns null if any structural or authorization precondition fails.
324+ */
325+ private fun findPutGetPair (
326+ individual : RestIndividual ,
327+ actionResults : List <ActionResult >
328+ ): PutGetPair ? {
329+ if (individual.size() < 2 ) return null
330+
331+ val actions = individual.seeMainExecutableActions()
332+ val put = actions[actions.size - 2 ]
333+ val get = actions[actions.size - 1 ]
334+
335+ if (put.verb != HttpVerb .PUT ) return null
336+ if (get.verb != HttpVerb .GET ) return null
337+ if (! put.usingSameResolvedPath(get)) return null
338+ if (put.auth.isDifferentFrom(get.auth)) return null
339+
340+ val resPut = actionResults.find { it.sourceLocalId == put.getLocalId() } as RestCallResult ?
341+ ? : return null
342+ val resGet = actionResults.find { it.sourceLocalId == get.getLocalId() } as RestCallResult ?
343+ ? : return null
344+
345+ return PutGetPair (put, get, resPut, resGet)
346+ }
347+
348+ /* *
349+ * Wiped candidates are restricted to fields the GET schema actually exposes, otherwise
350+ * write-only fields (e.g. passwords) would cause false positives.
351+ */
352+ private fun computeWipedFields (
353+ candidates : Set <String >,
354+ schema : RestSchema ? ,
355+ get : RestCallAction
356+ ): Set <String > {
357+ if (candidates.isEmpty() || schema == null ) return emptySet()
358+ val getSchemaFields = extractGetResponseSchemaFields(schema, get)
359+ if (getSchemaFields.isEmpty()) return emptySet()
360+ return candidates intersect getSchemaFields
361+ }
362+
363+ /* *
364+ * Unified field-level comparison for JSON, XML and form-encoded PUT bodies.
337365 *
338366 * @param sentFields fields whose values must match between PUT and GET
339367 * @param wipedFields fields that must be absent (or null) in the GET response
@@ -345,40 +373,22 @@ object HttpSemanticsOracle {
345373 wipedFields : Set <String >,
346374 bodyParam : BodyParam ? = null
347375 ): Boolean {
348- return when {
349- bodyParam == null || bodyParam.isJson() ->
350- hasMismatchedPutFieldsStructured(OutputFormatter .JSON_FORMATTER , putBody, getBody, sentFields, wipedFields)
351- bodyParam.isXml() ->
352- hasMismatchedPutFieldsStructured(OutputFormatter .XML_FORMATTER , putBody, getBody, sentFields, wipedFields)
353- bodyParam.isForm() ->
354- hasMismatchedPutFieldsForm(putBody, getBody, sentFields, wipedFields)
355- else -> false
356- }
357- }
358-
359- private fun hasMismatchedPutFieldsStructured (
360- formatter : OutputFormatter ,
361- putBody : String ,
362- getBody : String ,
363- sentFields : Set <String >,
364- wipedFields : Set <String >
365- ): Boolean {
366376
367377 // sent fields: PUT value must equal GET value
368378 if (sentFields.isNotEmpty()) {
369- val fieldsPut = formatter.readFields (putBody, sentFields) ? : return false
379+ val fieldsPut = readPutFields (putBody, bodyParam , sentFields) ? : return false
370380 if (fieldsPut.isNotEmpty()) {
371- val fieldsGet = formatter.readFields (getBody, fieldsPut.keys.toSet()) ? : return false
381+ val fieldsGet = readGetFields (getBody, fieldsPut.keys) ? : return true
372382 for ((field, valuePut) in fieldsPut) {
373- val valueGet = fieldsGet[field] ? : return true // sent but missing in GET
383+ val valueGet = fieldsGet[field] ? : return true
374384 if (valuePut != valueGet) return true
375385 }
376386 }
377387 }
378388
379389 // wiped fields: must be absent or null in GET
380390 if (wipedFields.isNotEmpty()) {
381- val getWiped = formatter.readFields (getBody, wipedFields) ? : return false
391+ val getWiped = readGetFields (getBody, wipedFields) ? : return false
382392 for (field in wipedFields) {
383393 if (isWipedFieldStillPresent(getWiped[field])) return true
384394 }
@@ -388,39 +398,36 @@ object HttpSemanticsOracle {
388398 }
389399
390400 /* *
391- * Handles the case where the PUT request body is form-encoded .
392- * The GET response format is auto-detected by trying JSON then XML .
401+ * Extracts field values from a PUT request body according to its content type .
402+ * Returns null if the body cannot be parsed by the chosen reader .
393403 */
394- private fun hasMismatchedPutFieldsForm (
404+ private fun readPutFields (
395405 putBody : String ,
396- getBody : String ,
397- sentFields : Set <String >,
398- wipedFields : Set <String >
399- ): Boolean {
400- val formFields = parseFormBody(putBody)
401- if (formFields.isEmpty() && sentFields.isNotEmpty()) return false
402-
403- for (formatter in listOf (OutputFormatter .JSON_FORMATTER , OutputFormatter .XML_FORMATTER )) {
404- val fieldsGetSent = if (sentFields.isEmpty()) emptyMap()
405- else formatter.readFields(getBody, sentFields) ? : continue
406-
407- // sent fields
408- for (field in sentFields) {
409- val valuePut = formFields[field] ? : continue
410- val valueGet = fieldsGetSent[field] ? : return true
411- if (valuePut != valueGet) return true
412- }
413-
414- // wiped fields
415- if (wipedFields.isNotEmpty()) {
416- val getWiped = formatter.readFields(getBody, wipedFields) ? : return false
417- for (field in wipedFields) {
418- if (isWipedFieldStillPresent(getWiped[field])) return true
419- }
420- }
421- return false
406+ bodyParam : BodyParam ? ,
407+ fieldNames : Set <String >
408+ ): Map <String , String >? = when {
409+ bodyParam == null || bodyParam.isJson() ->
410+ OutputFormatter .JSON_FORMATTER .readFields(putBody, fieldNames)
411+ bodyParam.isXml() ->
412+ OutputFormatter .XML_FORMATTER .readFields(putBody, fieldNames)
413+ bodyParam.isForm() -> {
414+ val parsed = parseFormBody(putBody)
415+ if (parsed.isEmpty()) null
416+ else fieldNames.mapNotNull { f -> parsed[f]?.let { f to it } }.toMap()
422417 }
423- return false
418+ else -> null
419+ }
420+
421+ /* *
422+ * Extracts field values from a GET response body, auto-detecting JSON or XML.
423+ * Returns null if neither formatter can parse the body.
424+ */
425+ private fun readGetFields (
426+ getBody : String ,
427+ fieldNames : Set <String >
428+ ): Map <String , String >? {
429+ return OutputFormatter .JSON_FORMATTER .readFields(getBody, fieldNames)
430+ ? : OutputFormatter .XML_FORMATTER .readFields(getBody, fieldNames)
424431 }
425432
426433 /* *
@@ -562,6 +569,28 @@ object HttpSemanticsOracle {
562569 return false
563570 }
564571
572+ /* *
573+ * Returns the property names from the PUT request body schema in the OpenAPI spec.
574+ * Used as a fallback to determine writable fields when no BodyParam is present on the action.
575+ */
576+ internal fun extractPutRequestSchemaFields (
577+ schema : RestSchema ,
578+ put : RestCallAction
579+ ): Set <String > {
580+
581+ val openAPI = schema.main.schemaParsed
582+ val pathItem = openAPI.paths?.get(put.path.toString()) ? : return emptySet()
583+ val op = pathItem.put ? : return emptySet()
584+ val requestBody = op.requestBody ? : return emptySet()
585+ val mediaType = requestBody.content?.values?.firstOrNull() ? : return emptySet()
586+ val rawSchema = mediaType.schema ? : return emptySet()
587+ val resolved = rawSchema.`$ref`?.let {
588+ SchemaUtils .getReferenceSchema(schema, schema.main, it, mutableListOf ())
589+ } ? : rawSchema
590+
591+ return resolved.properties?.keys?.toSet() ? : emptySet()
592+ }
593+
565594 /* *
566595 * Returns the property names from the GET 2xx response schema in the OpenAPI spec.
567596 * Empty set if unresolvable, which makes callers skip wiped-field checks.
0 commit comments