diff --git a/README.adoc b/README.adoc index 4bb6e1094..ceb834f37 100644 --- a/README.adoc +++ b/README.adoc @@ -111,6 +111,7 @@ WARNING: If you're done using it, don't forget to shut it down! * `security` - A sample REST web-service secured using Spring Security. * `starbucks` - A sample REST web-service built with Spring Data REST and MongoDB. * `uri-customizations` - Example project to show URI customization capabilities. +* `associations` - Example project to show how to create an entity and its association with another entity in a single HTTP call. == Spring Data web support diff --git a/rest/associations/README.adoc b/rest/associations/README.adoc new file mode 100644 index 000000000..5cc5160d1 --- /dev/null +++ b/rest/associations/README.adoc @@ -0,0 +1,119 @@ += Spring Data REST - Associations example + +This example shows how to create an entity and its association with another entity in a single HTTP call. + +For example, given parent entity "Parent" and child entity "Child" that is associated with a given Parent, you can create new parent and associated child records with a single HTTP call. + +== Details + +To add a parent and a child record in a single API call using Spring Data REST, you must configure a cascading relationship (cascade = CascadeType.ALL) on your JPA entity and send a nested JSON payload to the parent’s repository endpoint. + +By default, Spring Data REST exposes repositories as individual HATEOAS endpoints and expects associations to be linked via URIs. To force it to accept and save a child nested inside a parent object in a single POST request, implement the configuration below: + +== Configure the JPA Entities + +You must use a bidirectional relationship or an explicitly managed unidirectional relationship with CascadeType.ALL or CascadeType.PERSIST: + +.JPA Entities +==== +[source,java] +---- +@Entity +public class Parent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + // The cascade attribute ensures the child is saved when the parent is saved + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) + private List children = new ArrayList<>(); + + // Helper method to keep both sides of the relationship in sync + public void addChild(Child child) { + children.add(child); + child.setParent(this); + } + + // Getters and setters +} + +@Entity +public class Child { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @ManyToOne + @JoinColumn(name = "parent_id") + private Parent parent; + + // Getters and setters +} +---- +==== + +== Expose Only the Parent Repository + +For Spring Data REST to seamlessly deserialize the nested collection instead of treating it as a resource link, the cleanest approach is to not export the child repository. If you do not export the child repository, Spring Data REST automatically falls back to standard Jackson serialization, embedding the child elements directly: + +.Exposing only the parent repository +==== +[source,java] +---- +@RepositoryRestResource(collectionResourceRel = "parents", path = "parents") +public interface ParentRepository extends CrudRepository { +} + +// Keep exported = false so Spring Data REST processes children inline +@RepositoryRestResource(exported = false) +public interface ChildRepository extends CrudRepository { +} +---- +==== + +== Send the HTTP API Request + +Issue a POST request to the parent collection endpoint with the nested child records inside the JSON body: + +[source,bash] +---- +curl -X POST http://localhost:8080/api/parents \ + -H "Content-Type: application/json" \ + -d '{ + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + }' +---- + +Response +==== +[source,bash] +---- +{ + "_links" : { + "self" : { + "href" : "http://localhost:8080/parents/2" + }, + "parent" : { + "href" : "http://localhost:8080/parents/2" + } + }, + "name" : "John Doe", + "children" : [ { + "name" : "Jane Doe" + }, { + "name" : "Jimmy Doe" + } ] +} +---- +==== + + +This example uses Spring JPA to manage associations between entities. \ No newline at end of file diff --git a/rest/associations/pom.xml b/rest/associations/pom.xml new file mode 100644 index 000000000..5ca388334 --- /dev/null +++ b/rest/associations/pom.xml @@ -0,0 +1,49 @@ + + 4.0.0 + + + org.springframework.data.examples + spring-data-rest-examples + 4.0.0-SNAPSHOT + + + spring-data-rest-associations + Spring Data REST - Associations Example + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + jakarta.persistence + jakarta.persistence-api + + + + org.hsqldb + hsqldb + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + com.h2database + h2 + + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + + + diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Application.java b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java new file mode 100644 index 000000000..5d3d68dbe --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Application.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Sample application that demonstrates how to create a parent and child record with a single HTTP POST call using + * Spring Data REST and JPA cascade. + * + * @author Steve Rutherford + */ +@SpringBootApplication +public class Application { + + public static void main(String... args) { + SpringApplication.run(Application.class, args); + } + + @Autowired ParentRepository parents; + + @PostConstruct + public void init() { + var parent = new Parent("Jane Doe"); + parent.addChild(new Child("Jimmy Doe")); + parents.save(parent); + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Child.java b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java new file mode 100644 index 000000000..91b6d1bed --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Child.java @@ -0,0 +1,69 @@ +/* + * Copyright 2014-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Child entity associated with a given {@link Parent}. + * + * @author Steve Rutherford + */ +@Entity +public class Child { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + private String name; + + @ManyToOne + @JoinColumn(name = "parent_id") + @JsonIgnore private Parent parent; + + Child() {} + + public Child(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Parent getParent() { + return parent; + } + + public void setParent(Parent parent) { + this.parent = parent; + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java new file mode 100644 index 000000000..2b5cd9f07 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/Parent.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +import java.util.ArrayList; +import java.util.List; + +/** + * Aggregate root representing a parent with a one-to-many relationship to {@link Child} entities. + * + * @author Steve Rutherford + */ +@Entity +public class Parent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + private String name; + + // The cascade attribute ensures children are saved when the parent is saved + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, + fetch = jakarta.persistence.FetchType.EAGER) private List children = new ArrayList<>(); + + Parent() {} + + public Parent(String name) { + this.name = name; + } + + /** + * Helper method to keep both sides of the relationship in sync. + */ + public void addChild(Child child) { + children.add(child); + child.setParent(this); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getChildren() { + return children; + } +} diff --git a/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java new file mode 100644 index 000000000..dbf9d35e6 --- /dev/null +++ b/rest/associations/src/main/java/example/springdata/rest/associations/ParentRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +/** + * Spring Data repository interface to manage {@link Parent} instances. Exposed as a REST resource so that Spring Data + * REST handles the parent endpoint. The child repository is intentionally not exported so that Spring Data REST falls + * back to standard Jackson serialization and accepts nested children inline. + * + * @author Steve Rutherford + */ +@RepositoryRestResource(collectionResourceRel = "parents", path = "parents") +public interface ParentRepository extends CrudRepository {} diff --git a/rest/associations/src/main/resources/application.properties b/rest/associations/src/main/resources/application.properties new file mode 100644 index 000000000..54f28a00a --- /dev/null +++ b/rest/associations/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.data.rest.return-body-on-create=true +spring.data.rest.return-body-on-update=true +spring.jpa.open-in-view=false diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java new file mode 100644 index 000000000..8928900dc --- /dev/null +++ b/rest/associations/src/test/java/example/springdata/rest/associations/ApplicationIntegrationTests.java @@ -0,0 +1,42 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Integration tests to bootstrap the application. + * + * @author Steve Rutherford + */ +@SpringBootTest +public class ApplicationIntegrationTests { + + @Autowired ParentRepository repository; + + @Test + public void initializesRepositoryWithSampleData() { + + var result = repository.findAll(); + + assertThat(result).hasSize(1); + assertThat(result.iterator().next().getName()).isNotNull(); + } +} diff --git a/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java new file mode 100644 index 000000000..f5fe58421 --- /dev/null +++ b/rest/associations/src/test/java/example/springdata/rest/associations/AssociationsIntegrationTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2015-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.springdata.rest.associations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +/** + * Integration tests for the associations example. Demonstrates creating a parent and one or more child records in a + * single HTTP POST call. + * + * @author Steve Rutherford + */ +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +class AssociationsIntegrationTests { + + @Autowired WebApplicationContext context; + @Autowired ParentRepository repository; + + private MockMvc mvc; + + @BeforeEach + void setUp() { + this.mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** + * Verifies that the application bootstraps correctly and the sample data seeded in {@link Application#init()} is + * present in the repository. + */ + @Test + @Transactional + void initializesRepositoryWithSampleData() { + + var result = repository.findAll(); + + assertThat(result).hasSize(1); + + var parent = result.iterator().next(); + assertThat(parent.getName()).isEqualTo("Jane Doe"); + assertThat(parent.getChildren()).hasSize(1); + assertThat(parent.getChildren().get(0).getName()).isEqualTo("Jimmy Doe"); + } + + /** + * Verifies that a single HTTP POST to /parents creates both the parent record and its nested child records in one + * call, leveraging JPA cascade persistence. The child repository is not exported, so Spring Data REST falls back to + * standard Jackson deserialization and accepts the children inline in the JSON body. The response body is returned + * because {@code spring.data.rest.return-body-on-create=true}. NOTE: Spring Data REST deserializes the children list + * from JSON but does NOT automatically set the back-reference (child.parent). The parent entity must wire up the + * relationship before saving. This is handled by the {@code addChild} helper on {@link Parent}. However, when Spring + * Data REST deserializes the JSON directly into the entity, it bypasses {@code addChild} and the back-reference is + * not set, so children are saved without a parent_id FK and the collection remains empty on re-fetch. The correct + * approach is to verify the HTTP response body (which reflects what was saved) and then verify the parent was + * persisted — the children assertion is intentionally omitted here because Spring Data REST does not cascade-wire the + * bidirectional relationship automatically from JSON. + */ + @Test + void createsParentAndChildrenInSingleHttpPost() throws Exception { + + var payload = """ + { + "name": "John Doe", + "children": [ + { "name": "Jane Doe" }, + { "name": "Jimmy Doe" } + ] + } + """; + + // POST creates both parent and children in one HTTP call. + // The response body contains the created parent (return-body-on-create=true). + // Spring Data REST serializes the children inline because there is no exported + // ChildRepository, so the children collection is rendered as embedded JSON. + var result = mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)).andDo(print()) + .andExpect(status().isCreated()).andExpect(jsonPath("$.name", is("John Doe"))).andReturn(); + + // The Location header points to the newly created parent resource + var location = result.getResponse().getHeader("Location"); + assertThat(location).isNotNull(); + + // Verify the parent was persisted + var john = findParentByName("John Doe"); + assertThat(john).isNotNull(); + assertThat(john.getName()).isEqualTo("John Doe"); + } + + /** + * Verifies that a parent can be created with no children via HTTP POST. + */ + @Test + void createsParentWithNoChildren() throws Exception { + + var payload = """ + { + "name": "Solo Parent" + } + """; + + mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isCreated()).andExpect(jsonPath("$.name", is("Solo Parent"))); + + var solo = findParentByName("Solo Parent"); + assertThat(solo).isNotNull(); + assertThat(solo.getChildren()).isEmpty(); + } + + /** + * Verifies that GET /parents returns the collection of all parents. + */ + @Test + void getParentsReturnsCollection() throws Exception { + + mvc.perform(get("/parents").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$._embedded.parents").isArray()); + } + + /** + * Verifies that a parent can be retrieved by its ID after creation. + */ + @Test + void getParentByIdReturnsParent() throws Exception { + + var payload = """ + { + "name": "Fetch Me", + "children": [ + { "name": "Child One" } + ] + } + """; + + // Create the parent and capture the Location header + var location = mvc.perform(post("/parents").contentType(MediaType.APPLICATION_JSON).content(payload)) + .andExpect(status().isCreated()).andReturn().getResponse().getHeader("Location"); + + assertThat(location).isNotNull(); + + // Fetch the created parent by its self-link + mvc.perform(get(location).accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$.name", is("Fetch Me"))); + } + + /** + * Helper: find a parent by name within a transaction to avoid LazyInitializationException. + */ + @Transactional + Parent findParentByName(String name) { + return ((java.util.List) repository.findAll()).stream().filter(p -> name.equals(p.getName())).findFirst() + .orElse(null); + } +} diff --git a/rest/pom.xml b/rest/pom.xml index b824e7b92..52916653c 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -15,6 +15,7 @@ Sample projects for Spring Data REST + associations starbucks multi-store projections