REST architecture – Conditional requests

Mechanizm Conditional requests czyli warunkowych zapytań służy do optymalizacji żądań HTTP. Bardzo często odpytujemy o dany zasób po mimo iż jego stan się nie zmienił. W odpowiedzi dostajemy cały zasób i musimy zaimplementować logikę porównywania czy pojawiła się jakaś zmiana w obiekcie. Jednakże standard HTTP dostarcza nam mechanizmy do realizacji tego zagadnienia, są to słabe oraz silne nagłówki cachowania.

Mechanizm ten powinien być wykonywany dla operacji odczytu (ang. read). W momencie wysłania warunkowego żądania sprawdzany jest żądany zasób, czy zmienił się w stosunku do ostatniego wywołania. Nie jest to sprawdzanie bajt po bajcie tylko odbywa się to przy wykorzystaniu validatorów. Rozróżniamy dwa typy validatorów:

  • data ostatniej modyfikacji zawarta w nagłówku Last-Modified
  • wartość typu String przechowująca numer wersji nazywana Entity Tag lub ETag

Samo porównywanie wartości może odbywać się na dwa sposoby:

  • słaba walidacja kiedy mogą wystąpić “drobne” różnice
  • silna walidacja kiedy każdy bajt musi się zgadzać

HTTP domyślnie używa silnej walidacji. Natomiast do wykonywanie warunkowych zapytań wykorzystujemy warunkowe nagłówki:

  • If-None-Match dla ETagów
  • If-Modified-Since dla Last-Modified

Przykładowe flow znajduje się poniżej. Pierwsze wykonanie to wysłanie żądania GET o dowolny zasób. W odpowiedzi dostajemy ten zasób wraz z dwoma nagłówkami ETag oraz Last-Modified. Jeden z nich jest nadmiarowy, do realizacji warunkowych żądań wystarczy jeden mechanizm, ja po prostu chciałem zaprezentować dwa od razu.

Teraz jeśli chcemy sprawdzić czy zasób się zmienił nie musimy ponownie odpytywać o cały zasób. Wystarczy wykorzystać odpowiednie nagłówki. Dla ETag należy dodać If-None-Match z wartością otrzymaną za pierwszym razem natomiast dla Last-Modified należy użyć If-Modified-Since z datą otrzymaną wcześniej. Jeśli zasób się nie zmienił powinniśmy otrzymać kod 304 Not Modified. W przypadku gdy zasób zmienił się od tego czasu otrzymamy kod 200 wraz z tym zasobem oraz nową wersją ETagu i z zaktualizowaną datą Last-Modified.

W Springu możemy zrealizować ETagi na dwa sposoby. Pierwszy z nich to wykorzystanie adnotacji @Version z pakietu javax.persistence, która umieszczona na polu przechowującym wersję będzie ją automatycznie aktualizować:

@Data
@Entity
@RequiredArgsConstructor
public class Todo {

    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;

    private String content;

    public Todo(String content) {
        this.content = content;
    }

Natomiast drugim sposobem jest dodanie beany ShallowEtagHeaderFilter:

@Bean
public Filter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
}

Jedyną różnicą pomiędzy tymi rozwiązaniami jest rodzaj generowanego ETagu. W przypadku @Version tag generowany jest od zera. W drugim rozwiązaniu tag wygląda na losowy ciąg na przykład: 03f91e84687eb66da6e5479c563dd6c9d. Aby sprawdzić poprawność działania można wykorzystać POSTMan’a lub jak najbardziej testy!

@Test
public void shouldReturnETag() throws Exception {
    todoRepository.save(new Todo("content"));

    MockHttpServletResponse response = mvc.perform(get("/todos/1"))
            .andExpect(header().string(ETAG, is(notNullValue())))
            .andReturn().getResponse();

    mvc.perform(get("/todos/1").header(IF_NONE_MATCH, response.getHeader(ETAG)))
            .andExpect(status().isNotModified())
            .andExpect(header().string(ETAG, is(notNullValue())))
            .andReturn().getResponse();
}

Jeśli natomiast chcemy wykorzystać mechanizm If-Modified-Since musimy wykorzystać audyty JPA. Mechanizm audytów będzie automatycznie aktualizował pole które przechowuje datę ostatnie modyfikacji:

@Data
@Entity
@RequiredArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class Todo {

    @Id @GeneratedValue
    private Long id;

    @JsonIgnore
    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    private String content;

    public Todo(String content) {
        this.content = content;
    }
}

Należy pamiętać także o dodaniu adnotacji @EnableJpaAuditing na naszej aplikacji. Test do sprawdzenia działania:

@Test
public void shouldReturnLastModified() throws Exception {
    todoRepository.save(new Todo("content"));

    MockHttpServletResponse response = mvc.perform(get("/todos/1"))
            .andExpect(header().string(LAST_MODIFIED, is(notNullValue())))
            .andReturn().getResponse();

    mvc.perform(get("/todos/1").header(IF_MODIFIED_SINCE, response.getHeader(LAST_MODIFIED)))
            .andExpect(status().isNotModified())
            .andExpect(header().string(LAST_MODIFIED, is(notNullValue())))
            .andReturn().getResponse();
}