Skip to content

Commit 4f2a96f

Browse files
authored
Support distinct null and absent payload properties (#3646)
To support PATCH-style API semantics, we need to be able to distinguish between a payload property being absent from the incoming JSON (in which case the existing value should be left as is) and the property being present with a value of `null` (in which case the client is asking us to remove the existing value). Jackson supports using the Java `Optional` class to distinguish those two cases, but it's a little cumbersome out of the box; add an extension method to simplify using it for PATCH-style operations. The distinction between "not required" and "can be null" needs to be reflected in the OpenAPI schema as well. We could do it by adding `@Schema(nullable = true)` to payload properties, but that'll add a lot of noise, so instead add logic to automatically mark all `Optional` payload properties as allowing null values.
1 parent e4fbbff commit 4f2a96f

File tree

2 files changed

+47
-0
lines changed

2 files changed

+47
-0
lines changed

src/main/kotlin/com/terraformation/backend/api/OpenApiConfig.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@ import io.swagger.v3.oas.models.media.StringSchema
1818
import io.swagger.v3.oas.models.security.SecurityScheme
1919
import jakarta.inject.Named
2020
import java.time.ZoneId
21+
import java.util.Optional
2122
import kotlin.reflect.KClass
2223
import kotlin.reflect.full.createType
2324
import kotlin.reflect.full.declaredMemberProperties
2425
import kotlin.reflect.full.findAnnotation
26+
import kotlin.reflect.full.isSubclassOf
2527
import kotlin.reflect.full.isSubtypeOf
2628
import kotlin.reflect.full.primaryConstructor
2729
import kotlin.reflect.full.superclasses
2830
import kotlin.reflect.jvm.javaField
31+
import kotlin.reflect.jvm.jvmErasure
2932
import kotlin.reflect.typeOf
3033
import org.jooq.DSLContext
3134
import org.springdoc.core.customizers.OpenApiCustomizer
@@ -157,6 +160,13 @@ class OpenApiConfig(private val keycloakInfo: KeycloakInfo) : OpenApiCustomizer
157160
if (propertySchema is ArraySchema && propertySchema.items is MapSchema) {
158161
propertySchema.items.additionalProperties = true
159162
}
163+
164+
// Optional<*> properties should be marked as nullable in the schema.
165+
if (
166+
propertySchema != null && property.returnType.jvmErasure.isSubclassOf(Optional::class)
167+
) {
168+
propertySchema.nullable = true
169+
}
160170
}
161171
}
162172
}

src/main/kotlin/com/terraformation/backend/util/Extensions.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import java.time.LocalDate
1313
import java.time.LocalTime
1414
import java.time.ZoneId
1515
import java.time.ZonedDateTime
16+
import java.util.Optional
1617
import org.geotools.api.referencing.FactoryException
1718
import org.geotools.api.referencing.crs.CoordinateReferenceSystem
1819
import org.geotools.geometry.jts.JTS
@@ -248,3 +249,39 @@ fun Geometry.nearlyCoveredBy(other: Geometry, minCoveragePercent: Double = 99.99
248249
*/
249250
fun Geometry.differenceNullable(other: Geometry?): Geometry =
250251
if (other != null) difference(other) else this
252+
253+
/**
254+
* Applies this `Optional` as a replacement for an existing value.
255+
*
256+
* This is primarily used with payloads for `PATCH` endpoints that can update nullable values. For
257+
* example, say an entity has a property `notes` of type `String?`. We'd want to handle a `PATCH`
258+
* request as follows:
259+
*
260+
* | JSON | Desired end result |
261+
* |--------------------|-------------------------|
262+
* | `{}` | Keep the original notes |
263+
* | `{"notes": null}` | Set notes to null |
264+
* | `{"notes": "bar"}` | Set notes to "bar" |
265+
*
266+
* If a payload class includes `notes: Optional<String>?`, Jackson (with the JDK8 module enabled)
267+
* deserializes it as follows:
268+
*
269+
* | JSON | Deserializes to |
270+
* |--------------------|------------------------------|
271+
* | `{}` | `notes = null` |
272+
* | `{"notes": null}` | `notes = Optional.empty()` |
273+
* | `{"notes": "bar"}` | `notes = Optional.of("bar")` |
274+
*
275+
* This method implements the patch semantics from the first table. Use it like this:
276+
* ```kotlin
277+
* val updatedModel = model.copy(
278+
* notes = payload.notes.patchNullable(model.notes),
279+
* )
280+
* ```
281+
*
282+
* Updates to non-nullable values can be handled by making the PATCH request payload fields nullable
283+
* and using `payloadField ?: originalValue`; Jackson will set the payload field to null if it's
284+
* absent. This will have the effect of silently ignoring attempts to set non-nullable values to
285+
* null, which should be acceptable in most cases.
286+
*/
287+
fun <T> Optional<T>?.patchNullable(original: T?): T? = if (this == null) original else orElse(null)

0 commit comments

Comments
 (0)