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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.springframework.data.rest.webmvc.jpa.Person;
import org.springframework.data.rest.webmvc.support.DefaultedPageable;
import org.springframework.data.rest.webmvc.support.ETag;
import org.springframework.data.rest.webmvc.support.ETagDoesntMatchException;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
Expand All @@ -62,6 +63,7 @@
*
* @author Oliver Gierke
* @author Jeremy Rickard
* @author Steve Rutherford
*/
@ContextConfiguration(classes = { ConfigurationCustomizer.class, JpaRepositoryConfig.class })
@Transactional
Expand Down Expand Up @@ -114,7 +116,7 @@ void setsExpandedSelfUriInLocationHeader() throws Exception {
.build(new Order(new Person()), entities.getRequiredPersistentEntity(Order.class)).build();

ResponseEntity<?> entity = controller.putItemResource(information, persistentEntityResource, 1L, assembler,
ETag.NO_ETAG, MediaType.APPLICATION_JSON_VALUE);
ETag.NO_ETAG, new HttpHeaders(), MediaType.APPLICATION_JSON_VALUE);

assertThat(entity.getHeaders().getLocation().toString()).doesNotEndWith("{?projection}");
}
Expand Down Expand Up @@ -198,7 +200,7 @@ void returnsBodyOnPutForUpdateIfAcceptHeaderPresentByDefault() throws Exception
.build(new Order(new Person()), entities.getRequiredPersistentEntity(Order.class)).build();

assertThat(controller.putItemResource(request, persistentEntityResource, order.getId(), assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody()).isTrue();
new HttpHeaders(), MediaType.APPLICATION_JSON_VALUE).hasBody()).isTrue();
}

@Test // DATAREST-34
Expand All @@ -209,7 +211,7 @@ void returnsBodyForCreatingPutIfAcceptHeaderPresentByDefault() throws HttpReques
.build(new Order(new Person()), entities.getRequiredPersistentEntity(Order.class)).forCreation();

assertThat(controller.putItemResource(request, persistentEntityResource, 1L, assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE).hasBody()).isTrue();
new HttpHeaders(), MediaType.APPLICATION_JSON_VALUE).hasBody()).isTrue();
}

@Test // DATAREST-34
Expand Down Expand Up @@ -283,7 +285,40 @@ void rejectsPutForCreationIfConfigured() throws HttpRequestMethodNotSupportedExc

assertThatExceptionOfType(HttpRequestMethodNotSupportedException.class) //
.isThrownBy(() -> controller.putItemResource(request, persistentEntityResource, 1L, assembler, ETag.NO_ETAG,
MediaType.APPLICATION_JSON_VALUE));
new HttpHeaders(), MediaType.APPLICATION_JSON_VALUE));
}

@Test // GH-xxxx
void putWithIfNoneMatchWildcardSucceedsWhenResourceDoesNotExist() throws HttpRequestMethodNotSupportedException {

RootResourceInformation request = getResourceInformation(Order.class);
PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getRequiredPersistentEntity(Order.class)).forCreation();

HttpHeaders headers = new HttpHeaders();
headers.setIfNoneMatch("*");

ResponseEntity<?> response = controller.putItemResource(request, persistentEntityResource, 999L, assembler,
ETag.NO_ETAG, headers, MediaType.APPLICATION_JSON_VALUE);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}

@Test // GH-xxxx
void putWithIfNoneMatchWildcardFailsWhenResourceAlreadyExists() throws Exception {

RootResourceInformation request = getResourceInformation(Order.class);
Order order = request.getInvoker().invokeSave(new Order(new Person()));

PersistentEntityResource persistentEntityResource = PersistentEntityResource
.build(new Order(new Person()), entities.getRequiredPersistentEntity(Order.class)).build();

HttpHeaders headers = new HttpHeaders();
headers.setIfNoneMatch("*");

assertThatExceptionOfType(ETagDoesntMatchException.class) //
.isThrownBy(() -> controller.putItemResource(request, persistentEntityResource, order.getId(), assembler,
ETag.NO_ETAG, headers, MediaType.APPLICATION_JSON_VALUE));
}

@TestFactory // #2225
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
* @author Greg Turnquist
* @author Jeremy Rickard
* @author Jeroen Reijn
* @author Steve Rutherford
*/
@RepositoryRestController
class RepositoryEntityController
Expand Down Expand Up @@ -334,14 +335,16 @@ public ResponseEntity<EntityModel<?>> getItemResource(RootResourceInformation re
* @param id
* @param assembler
* @param eTag
* @param requestHeaders
* @param acceptHeader
* @return
* @throws HttpRequestMethodNotSupportedException
*/
@RequestMapping(value = BASE_MAPPING + "/{id}", method = RequestMethod.PUT)
public ResponseEntity<? extends RepresentationModel<?>> putItemResource(RootResourceInformation resourceInformation,
PersistentEntityResource payload, @BackendId Serializable id, PersistentEntityResourceAssembler assembler,
ETag eTag, @RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader)
ETag eTag, @RequestHeader HttpHeaders requestHeaders,
@RequestHeader(value = ACCEPT_HEADER, required = false) String acceptHeader)
throws HttpRequestMethodNotSupportedException {

resourceInformation.verifySupportedMethod(HttpMethod.PUT, ResourceType.ITEM);
Expand All @@ -358,6 +361,13 @@ public ResponseEntity<? extends RepresentationModel<?>> putItemResource(RootReso
throw new IllegalStateException("Payload content must not be null");
}

// Enforce If-None-Match: * — only allow the operation if the resource does not already exist (RFC 7232 §3.2)
List<String> ifNoneMatch = requestHeaders.getIfNoneMatch();
if (!ifNoneMatch.isEmpty() && "*".equals(ifNoneMatch.get(0))) {
Object existingObject = invoker.invokeFindById(id).orElse(null);
ETag.WILDCARD_ETAG.verifyNoneMatch(resourceInformation.getPersistentEntity(), existingObject);
}

return payload.isNew()
? createAndReturn(objectToSave, invoker, assembler, config.returnBodyOnCreate(acceptHeader))
: saveAndReturn(objectToSave, invoker, PUT, assembler, config.returnBodyOnUpdate(acceptHeader));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
*
* @author Oliver Gierke
* @author Dario Seidl
* @author Steve Rutherford
*/
public final class ETag {

public static final ETag NO_ETAG = new ETag(null);
public static final ETag WILDCARD_ETAG = new ETag("*");

private final @Nullable String value;

Expand Down Expand Up @@ -108,6 +110,23 @@ public void verify(PersistentEntity<?, ?> entity, @Nullable Object target) {
}
}

/**
* Verifies that the given target does not exist when this {@link ETag} is a wildcard ({@code *}), raising an
* {@link ETagDoesntMatchException} if the resource already exists. This implements the {@code If-None-Match: *}
* semantics for {@code PUT} requests as defined in RFC 7232 §3.2: the operation should only proceed if the target
* resource does not currently exist.
*
* @param entity must not be {@literal null}.
* @param target the existing domain object, or {@literal null} if the resource does not exist.
* @throws ETagDoesntMatchException if this is a wildcard ETag and the target already exists.
*/
public void verifyNoneMatch(PersistentEntity<?, ?> entity, @Nullable Object target) {

if (this == WILDCARD_ETAG && target != null) {
throw new ETagDoesntMatchException(target, this);
}
}

/**
* Returns whether the {@link ETag} matches the given {@link PersistentEntity} and target. A more dissenting way of
* checking matches as it does not match if the ETag is {@link #NO_ETAG}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
*
* @author Pablo Lozano
* @author Oliver Gierke
* @author Steve Rutherford
*/
@ExtendWith(MockitoExtension.class)
class ETagUnitTests {
Expand Down Expand Up @@ -138,6 +139,29 @@ void doesNotAddHeaderForNoETag() {
assertThat(headers.getFirst("ETag")).isNull();
}

@Test // GH-xxxx
void wildcardETagVerifyNoneMatchThrowsWhenResourceExists() {

assertThatExceptionOfType(ETagDoesntMatchException.class) //
.isThrownBy(() -> ETag.WILDCARD_ETAG.verifyNoneMatch(context.getRequiredPersistentEntity(Sample.class),
new Sample(0L)));
}

@Test // GH-xxxx
void wildcardETagVerifyNoneMatchSucceedsWhenResourceDoesNotExist() {
ETag.WILDCARD_ETAG.verifyNoneMatch(context.getRequiredPersistentEntity(Sample.class), null);
}

@Test // GH-xxxx
void noETagVerifyNoneMatchDoesNotRejectExistingResource() {
ETag.NO_ETAG.verifyNoneMatch(context.getRequiredPersistentEntity(Sample.class), new Sample(0L));
}

@Test // GH-xxxx
void wildcardETagFromStringIsRecognized() {
assertThat(ETag.from("*")).isEqualTo(ETag.WILDCARD_ETAG);
}

// tag::versioned-sample[]
class Sample {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ curl -v -H 'If-None-Match: <value of previous etag>' ...

The preceding command (by default) runs a `GET`. Spring Data REST checks for `If-None-Match` headers while doing a `GET`. If the header matches the ETag, it concludes that nothing has changed and, instead of sending a copy of the resource, sends back an HTTP `304 Not Modified` status code. Semantically, it reads "`If this supplied header value does not match the server-side version, send the whole resource. Otherwise, do not send anything.`"

[[conditional.if-none-match-wildcard]]
=== `If-None-Match: *` with `PUT`

The special wildcard value `*` for `If-None-Match` can be used with a `PUT` request to ensure the operation only succeeds if the target resource does **not** already exist (as defined in https://tools.ietf.org/html/rfc7232#section-3.2[RFC 7232 §3.2]). This is useful for safe resource creation at a known URI without accidentally overwriting an existing resource.

====
----
curl -v -X PUT -H 'If-None-Match: *' -H 'Content-Type: application/json' \
-d '{ ... }' http://localhost:8080/orders/42
----
====

If the resource at the given URI does not yet exist, the `PUT` proceeds and creates it (returning `201 Created`). If the resource already exists, Spring Data REST returns `412 Precondition Failed`, preventing an unintended overwrite.

NOTE: This POJO is from an `ETag`-based unit test, so it does not have `@Entity` (JPA) or `@Document` (MongoDB) annotations, as expected in application code. It focuses solely on how a field with `@Version` results in an `ETag` header.

[[conditional.if-modified-since]]
Expand Down