Spring Data REST (Spring Boot)

Introduction

Spring Data REST builds on top of Spring Data repositories, analyzes your application’s domain model and exposes hypermedia-driven HTTP resources for aggregates contained in the model.

Reference

  • Spring Data REST (spring.io)

https://spring.io/projects/spring-data-rest

  • Spring Data REST Reference Documentation (spring.io)

https://docs.spring.io/spring-data/rest/docs/current/reference/html/#reference

  • Introduction to Spring Data REST (Baeldung)

https://www.baeldung.com/spring-data-rest-intro

Configuration

pom.xml

HAL Browser is deprecated, using hal-explorer instead

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-data-rest</artifactId>

</dependency>

<dependency>

<groupId>org.springframework.data</groupId>

<artifactId>spring-data-rest-hal-explorer</artifactId>

</dependency>


application.properties

Tested w/ Spring Boot 2.4.1:

...

#-------------------------------------------------------------

# 5. Data properties

#-------------------------------------------------------------

# Base path to be used by Spring Data REST to expose repository resources.

spring.data.rest.base-path=/r/merida

...


RepositoryRestConfig.java

This sample will make it available at path /<context>/r/merida; eg:

http://localhost:8080/r/merida

Tested w/ Spring Boot 2.4.1:

package edu.cou.app;


import org.springframework.beans.factory.annotation.Value;

import org.springframework.data.rest.core.config.RepositoryRestConfiguration;

import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;

import org.springframework.stereotype.Component;

import org.springframework.web.servlet.config.annotation.CorsRegistry;


/**

* Spring Data REST config.

*/

@Component

public class RepositoryRestConfig implements RepositoryRestConfigurer {


/** Data REST base path */

@Value("${spring.data.rest.base-path:'/r/merida'}")

private String basePathDataRest;


@Override

public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config,

CorsRegistry cors) {

/*- [DISABLE if the application doesn't properly secure the Data REST API]

config.disableDefaultExposure()

*/


//

config.setBasePath(this.basePathDataRest);


/*- This setting would make HAL Explorer to fail.

* The short answer is, you can't use spring-data-rest without hateoas:

* https://stackoverflow.com/a/27094939/1323562

*

* config.setDefaultMediaType(MediaType.APPLICATION_JSON)

*/


/*- This setting does not seem to do anything. */

config.useHalAsDefaultJsonMediaType(false);

}


}


WebSecurityX3Config.java

Let's allow access to the HAL Explorer (it doesn't imply access to the data rest).

Tested w/ Spring Boot 2.4.1:

package edu.cou.app;


import javax.inject.Inject;


import org.springframework.beans.factory.annotation.Value;

import org.springframework.context.annotation.Configuration;

import org.springframework.core.annotation.Order;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import org.springframework.security.config.http.SessionCreationPolicy;


import lombok.extern.slf4j.Slf4j;


/**

* HAL Explorer (before Data REST).<br>

* http://localhost:8080/r/merida/explorer<br>

* <br>

* Remark: Not working on AWS because it redirects to the private load balancer; although Data REST works fine. Eg:<br>

* GET

* https://ecs-lb-private-appdev-4e1eee8814aa2427.elb.eu-west-1.amazonaws.com/r/merida/explorer/index.html

*

*

* <pre>

* /r/merida > Not OAuth token nor JWT

* /r/merida/explorer/** > Not OAuth token nor JWT

* </pre>

*

* <br>

* Note: Order lower values have higher priority. Higher values will only execute if no adapter with

* lower value matches.<br>

*/

@Slf4j

@Configuration

@Order(3)

@EnableWebSecurity

public class WebSecurityX3Config extends WebSecurityConfigurerAdapter {


/** Data REST base path */

private String basePathDataRest;


/**

* Constructor

*

* @param basePathDataRest -

*/

@Inject

public WebSecurityX3Config(

@Value("${spring.data.rest.base-path:'/r/merida'}") String basePathDataRest) {

this.basePathDataRest = basePathDataRest;

}


@Override

protected void configure(HttpSecurity http) throws Exception {

http.requestMatchers().antMatchers(basePathDataRest, basePathDataRest + "/explorer/**") // NOSONAR

.and() //

.authorizeRequests().anyRequest().permitAll() // NOSONAR

.and()//

.csrf().disable().headers().frameOptions().disable()//

.and()//

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//

;


if (log.isDebugEnabled()) {

log.debug("END WebSecurityX3Config.configure");

}

}


}


WebSecurityX4Config

Let's secure Data REST reusing an already existing custom authorization.

Tested w/ Spring Boot 2.4.1:

package edu.cou.app;


import javax.inject.Inject;


import org.springframework.beans.factory.annotation.Value;

import org.springframework.context.annotation.Configuration;

import org.springframework.core.annotation.Order;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;

import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import org.springframework.security.config.http.SessionCreationPolicy;


import edu.cou.app.security.oauth.OauthAuthenEntryPoint;

import edu.cou.app.security.oauth.OauthAuthenProvider;

import edu.cou.app.security.oauth.OauthSecurityConfigurer;

import lombok.extern.slf4j.Slf4j;


/**

* Data REST (after HAL Explorer).<br>

* http://localhost:8080/r/merida/explorer<br>

* <br>

* Remark: Mainly intended to be used from HAL Explorer

*

* <pre>

* /r/merida/** > App uses the OAuth token (header Authorization, eg: "Bearer f4k3")

* </pre>

*

* <br>

* Note: Order lower values have higher priority. Higher values will only execute if no adapter with

* lower value matches.<br>

* <br>

* Data REST HAL Explorer didn't work fine on AWS because links are generated with private load

* balancer address, instead of the public CloudFront one; although Data REST works fine.

*/

@Slf4j

@Configuration

@Order(4)

@EnableWebSecurity

public class WebSecurityX4Config extends WebSecurityConfigurerAdapter {


/** Data REST base path */

private String basePathDataRest;


/*- */

private OauthAuthenProvider oauthTokenProvider;


/**

* Constructor

*

* @param basePathDataRest -

* @param oauthTokenProvider -

*/

@Inject

public WebSecurityX4Config(

@Value("${spring.data.rest.base-path:'/r/merida'}") String basePathDataRest,

OauthAuthenProvider oauthTokenProvider) {

this.basePathDataRest = basePathDataRest;

this.oauthTokenProvider = oauthTokenProvider;

}


@Override

protected void configure(HttpSecurity http) throws Exception {

http.antMatcher(basePathDataRest + "/**")//

.authorizeRequests().anyRequest().hasAuthority("SUPER") // NOSONAR

.and()//

.apply(new OauthSecurityConfigurer(oauthTokenProvider))//

.and()//

.exceptionHandling().authenticationEntryPoint(new OauthAuthenEntryPoint())//

.and()//

.csrf().disable().headers().frameOptions().disable()//

.and()//

.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//

;


if (log.isDebugEnabled()) {

log.debug("END WebSecurityX4Config.configure");

}

}


}


HAL Explorer

With the previous configuration, the HAL Explorer is available at:

http://localhost:8080/r/merida

Since it is secured with an OAuth token, let's provide it a the 'Authorization' header.

Click 'Edit Headers' and enter the proper info, eg;

Key: Authorization

Value: Bearer 9MM_56qv4DKmy4eS_wUK0pxVsCWJZpsRz66p436wnNI.1jNIk-vxJsINvYNb83Wuv1HASnXmwvTMuSyRLnS71lA


The Spring Data API can also be queried without the HAL Explorer, for it, you can use a tool like the "Insomnia Core";

https://insomnia.rest/


Some cURL samples follow.

-List available repositories

curl "http://localhost:8080/r/merida" \

-H "Authorization: Bearer 9MM_56qv4DKmy4eS_wUK0pxVsCWJZpsRz66p436wnNI.1jNIk-vxJsINvYNb83Wuv1HASnXmwvTMuSyRLnS71lA"

-Query the 'app_request' table, using 'page', 'size' and 'sort' (ORDER BY updateIdp DESC, createInstant ASC)

curl "http://localhost:8080/r/merida/appRequests?page=0&size=8&sort=updateIdp,desc&sort=createInstant,asc" \

-H "Authorization: Bearer -EpmwQG01LfT6gRGujKkZQIUvqc9XYGvIY98dE_cA00.PX2mdS5_g9qjdkdcWzDYU6spkDb7DpWHAnGyGb8jJ34" \

-H "Accept: application/json"

-List available methods in JPA repository (it includes custom methods declared in the repository interface)

curl "http://localhost:8080/r/merida/appRequests/search" \

-H "Authorization: Bearer TV_yd-5G4or527ho6K4FQ6brt_qFlHOokhR0kTKz1OE.i3yK7nCHXp4Z8QMOiDvFMJ75LvdLgcbVNMeE6khdpfE"


Composite key trick

Data REST doesn't support composite URL composite keys out of the box. This trick makes it work.


For every entity id, implement toString and convert:

package edu.cou.myapp.entity;


import java.io.Serializable;

import java.time.LocalDate;


import javax.persistence.Column;

import javax.persistence.Embeddable;


import org.springframework.core.convert.converter.Converter;


import lombok.Data;

import lombok.EqualsAndHashCode;

import lombok.NoArgsConstructor;

import lombok.NonNull;


/**

* (at)NonNull used because of lombok constructor

*/

@Data

@NoArgsConstructor

@EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true)

@Embeddable

public class MyentityId implements Serializable,

Converter<String, MyentityId> {


/**

*

*/

private static final long serialVersionUID = 4788158162253861260L;


/**

* Constructor, required.

*

* @param tfCalendarStructureCode -

* @param defenseDate -

*/

public MyentityId(Long tfCalendarStructureCode, LocalDate defenseDate) {

this();

this.tfCalendarStructureCode = tfCalendarStructureCode;

this.defenseDate = defenseDate;

}


/** pk1 */

@EqualsAndHashCode.Include

@NonNull

@Column(name = "tf_calendar_structure_code")

private Long tfCalendarStructureCode;


/**

* pk2

*/

@EqualsAndHashCode.Include

@NonNull

@Column(name = "defense_date")

private LocalDate defenseDate;


/*-

* Data REST convenient path string.<br>

* Sample: tfCalendarStructureCode,27,defenseDate,2021-03-25

*/

@Override

public String toString() {

return String.format("tfCalendarStructureCode,%d,defenseDate,%tF", tfCalendarStructureCode,

defenseDate);

}


/*- Data REST conversion back from toString() to object.

* Register at RepositoryRestConfig.configureConversionService */

@Override

public CalendariTfEstructuraDiesId convert(String s) {

String[] parts = s.split(",");

String[] ld2 = parts[3].split("-");

return new CalendariTfEstructuraDiesId(Long.valueOf(parts[1]), LocalDate.of(Integer.valueOf(

ld2[0]).intValue(), Integer.valueOf(ld2[1]).intValue(), Integer.valueOf(ld2[2])

.intValue()));

}


}


Unit test that verifies the round trip o == convert(o.toString)

package edu.cou.myapp.entity;


import org.junit.jupiter.api.Assertions;

import org.junit.jupiter.api.Test;

import java.time.LocalDate;

import lombok.val;


/**

*/

public class MyEntityIdTest {


/**

* Data REST, guarantee o = convert(o.toString())

*/

@Test

void dataRestPathTest() {

val o1 = new MyEntityIdId(Long.valueOf(27L), LocalDate.of(2021, 3, 25));

val o2 = o1.convert(o1.toString());

Assertions.assertEquals(o1, o2);

}


}


Finally, for register the converter(s) at the Data REST configuration. Eg:

package edu.cou.myapp;


import org.springframework.core.convert.support.ConfigurableConversionService;

import org.springframework.data.rest.core.config.RepositoryRestConfiguration;

import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;

import org.springframework.stereotype.Component;

import org.springframework.web.servlet.config.annotation.CorsRegistry;


import edu.cou.myapp.entity.MyEntityId;

import edu.coy.myapp.security.WebSecurityConfig;


/**

* Data REST config.

*/

@Component

public class RepositoryRestConfig implements RepositoryRestConfigurer {


/**

* Data REST. Register converters for all entities (at)EmbeddedId. The converter must implement

* Converter<String, EntityId>

*/

@Override

public void configureConversionService(ConfigurableConversionService conversionService) {

...other converters registered

conversionService.addConverter(new MyEntityId());

...other converters to register

}


@Override

public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config,

CorsRegistry cors) {

/*- [DISABLED BECAUSE the application doesn't currently properly secure the Data REST API]

*/

config.disableDefaultExposure();


//

config.setBasePath(WebSecurityConfig.BASE_PATH_DATA_REST);


/*- This setting would make HAL Explorer to fail.

* The short answer is, you can't use spring-data-rest without hateoas:

* https://stackoverflow.com/a/27094939/1323562

*

* config.setDefaultMediaType(MediaType.APPLICATION_JSON)

*/


/*- This setting does not seem to do anything. */

config.useHalAsDefaultJsonMediaType(false);

}


}






Amazon AWS CloudFront / API Gateway issue

HAL Explorer might not work on AWS because it redirects to the private load balancer, instead of cloudfront, and removes '/app_back' (although Data REST works fine), e.g:

GET https://ecs-lb-private-appdev-271eee8814aa2427.elb.eu-west-1.amazonaws.com/r/merida/explorer/index.html


Examples

1a) [POST] Composite key

Creation of a new record specifying its 3 field composite key (JSON attribute 'id'):

curl -L -X POST 'http://localhost:8080/r/merida/enrollments/' -H 'Content-Type: application/json' --data-raw '{

"id": {"academicRecordCode": 1894571,"calendarCode": "20201","numMatricula": 10},

"teachingStartDate": "2009-01-26",

"enrollmentDate": "2020-10-03T09:38:58",

"teachingEndDate": "2100-02-22"

}'


2a) [GET] Pageable (page, size & sort)

curl --request GET \

--url 'https://myapp.cou.edu/myapp_back/r/merida/myEntitys?page=0&size=2&sort=fieldOne%2Cdesc&sort=fieldTwo%2Casc' \

--header 'Authorization: Bearer wfakeZA'

Note: The equivalent Pageable JSON object is:

{

"page": 0,

"size": 2,

"sort": [

"fieldOne,desc",

"fieldTwo,asc"

]

}


2b) [GET] Composite key

Using the composite key trick, this example shows a primary key of 3 fields separated by commas:

curl -L -X GET 'http://localhost:8080/r/merida/enrollments/academicRecordCode,1794231,calendarCode,20201,numMatricula,10'


3a) [PUT] Replace the state of the target resource with the supplied request body

Let's release the liquibase lock, which using SQL would be:

UPDATE DATABASECHANGELOGLOCK SET LOCKED=0, LOCKGRANTED=null, LOCKEDBY=null WHERE ID=1

With Spring REST is (assuming it's available at the context path '/r/merida'):

The trick here is to use the http verb 'PUT' in order to update the record, see screenshot below

Once the 'Make Request' button is clicked, a request executed similar to the following:

curl "http://localhost:8080/r/merida/databaseChangelogLocks/1" -X PUT -H "Accept: application/hal+json, application/json, */*; q=0.01" -H "Content-Type: application/json" -H "X-Requested-With: XMLHttpRequest" -H "Authorization: Bearer f4k3.i3yK7nCHXp4Z8QMOiDvFMJ75LvdLgcbVNMeE6khdpfE" --data "{"

" ""locked"": 0,"

" ""lockGranted"": null,"

" ""lockedBy"": null"

"}"

which will replace the content of the database record with identifier '1' (indicated as the last part of the url path).


3b) [PUT] Insert new record

A new record can be inserted also using the 'PUT' verb, in a similar way that updating an existing record works.

The trick here is specifying the record id in the URL path. The screenshot below will create the record with id 72

4a) [PATCH] Partially updates the resources state

The PATCH verb can be used in the same way that the PUT one to modify resources, but it only updates the specified attributes (partial update).

Eg using the HAL form:

{

"daxResourceCode": 94873

}


Eg using cURL:

Request must specify the Content-Type header and it provides an OAuth token as Authorization header.

curl -X PATCH 'https://localhost:8080/merida/databaseChangeLogLocks/72' -H 'Authorization: Bearer g1w0T2Jpb9_eepfIvZ9Z-Yd4V9zNzTUjJ5dSxFKneNA.RyTZAhYM87_Y2S-diBEYISDTxss2LKl6rAPOp8shfpI' -H 'Content-Type: application/json; charset=utf-8' --data-raw $'{"lockedby": "John Doe", "lockgranted": "2019-07-31T11:32:27.000Z", "locked": 1}'


4b) [PATCH] Composite key

Using the composite key trick, this example shows a primary key of 3 fields separated by commas:

curl -L -X PATCH 'http://localhost:8080/r/merida/enrollments/academicRecordCode,1794571,calendarCode,20201,numMatricula,10' -H 'Content-Type: application/json' --data-raw '{

"updateIdp": 392876

}'

5a) [DELETE] Composite key

Using the composite key trick, this example shows a primary key of 3 fields separated by commas:

curl -L -X DELETE 'http://localhost:8080/r/merida/enrollments/academicRecordCode,1794231,calendarCode,20201,numMatricula,10'