Related Entities: shared and scoped loads
The CRUD Orchestration tutorial built one resource from single-purpose functions. This tutorial adds a second, related entity and shows the two kinds of load that a relationship introduces.
- A shared load is one
Loadfunction reused by every endpoint that needs the same entity. HereLoadAuthorserves three endpoints. - An ownership-scoped load finds a child within its parent. A child that belongs to a different parent is not found. This is a different function from a global load, and using the wrong one is a real bug.
The domain
An author has many articles. An article belongs to one author. The author carries a helper that finds one of its own articles by id.
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();
public Author() {
}
public Author(Long id, String name) {
this.id = id;
this.name = name;
}
public Article getArticle(Long articleId) {
for (Article article : this.articles) {
if (article.getId().equals(articleId)) {
return article;
}
}
return null;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public List<Article> getArticles() {
return this.articles;
}
}
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String content;
@ManyToOne
@JoinColumn(name = "author_id")
private Author author;
public Article() {
}
public Article(Long id, String title, String content) {
this.id = id;
this.title = title;
this.content = content;
}
public Long getId() {
return this.id;
}
public String getTitle() {
return this.title;
}
public String getContent() {
return this.content;
}
public Author getAuthor() {
return this.author;
}
public void setAuthor(Author author) {
this.author = author;
}
}
A shared load: GET /author/{authorId}
Loading an author is one function. Every author-scoped endpoint reuses it.
load:
class: net.officefloor.tutorial.springrestrelationship.LoadAuthor
govern: [ readonly-transaction ]
next: send
send:
class: net.officefloor.tutorial.springrestrelationship.RespondWithAuthor
govern: [ readonly-transaction ]
public class LoadAuthor {
public void service(@PathVariable(name = "authorId") Long authorId,
AuthorRepository repository,
Out<Author> loaded) throws NotFoundException {
Author author = repository.findById(authorId)
.orElseThrow(() -> new NotFoundException(authorId));
loaded.set(author);
}
}
There is one place that turns an authorId into an Author, and one place that decides a missing author is a 404. The three endpoints below all start with this same function.
A global load: GET /article/{articleId}
Loading an article by its own id finds any article, whatever its author.
load:
class: net.officefloor.tutorial.springrestrelationship.LoadArticle
govern: [ readonly-transaction ]
next: send
send:
class: net.officefloor.tutorial.springrestrelationship.RespondWithArticle
govern: [ readonly-transaction ]
public class LoadArticle {
public void service(@PathVariable(name = "articleId") Long articleId,
ArticleRepository repository,
Out<Article> loaded) throws NotFoundException {
Article article = repository.findById(articleId)
.orElseThrow(() -> new NotFoundException(articleId));
loaded.set(article);
}
}
An ownership-scoped load: GET /author/{authorId}/article/{articleId}
The nested URL means "this author's article". So the pipeline loads the author first, then finds the article within that author.
loadAuthor:
class: net.officefloor.tutorial.springrestrelationship.LoadAuthor
govern: [ readonly-transaction ]
next: loadArticle
loadArticle:
class: net.officefloor.tutorial.springrestrelationship.LoadAuthorsArticle
govern: [ readonly-transaction ]
next: send
send:
class: net.officefloor.tutorial.springrestrelationship.RespondWithArticle
govern: [ readonly-transaction ]
LoadAuthor is reused unchanged. It publishes the Author. LoadAuthorsArticle then consumes that author and looks the article up in its collection:
public class LoadAuthorsArticle {
public void service(@Val Author author,
@PathVariable(name = "articleId") Long articleId,
Out<Article> loaded) throws NotFoundException {
Article article = author.getArticle(articleId);
if (article == null) {
throw new NotFoundException(articleId);
}
loaded.set(article);
}
}
An article that exists but belongs to a different author is not found here, so the endpoint returns 404. Do not use the global LoadArticle for this endpoint. It would return another author's article under this URL and leak data across authors. The scoping is the contract of the nested URL, so the scoped load is its own function.
RespondWithArticle is shared. Both the global and the scoped article endpoints end with an Article to render, so both reuse the same responder.
Create under a parent: POST /author/{authorId}/article
Creating an article for an author reuses LoadAuthor a third time. The author comes from the URL. The body is validated first, before the author is loaded, so an invalid body is a 400 even when the author does not exist.
validate:
class: net.officefloor.tutorial.springrestrelationship.ValidateArticle
govern: [ transaction ]
next: loadAuthor
loadAuthor:
class: net.officefloor.tutorial.springrestrelationship.LoadAuthor
govern: [ transaction ]
next: build
build:
class: net.officefloor.tutorial.springrestrelationship.BuildAuthorsArticle
govern: [ transaction ]
next: save
save:
class: net.officefloor.tutorial.springrestrelationship.SaveArticle
govern: [ transaction ]
next: send
send:
class: net.officefloor.tutorial.springrestrelationship.RespondWithArticleCreated
govern: [ transaction ]
BuildAuthorsArticle consumes two variables, the loaded author and the validated body, and attaches the new article to the author:
public class BuildAuthorsArticle {
public void service(@Val Author author, @Val ArticleRequest request, Out<Article> built) {
Article article = new Article(null, request.getTitle(), request.getContent());
article.setAuthor(author);
built.set(article);
}
}
Testing
The test exercises both loads. One case proves the scoping directly: an article of one author is found globally, is a 404 under a different author, and is found under its own author.
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestRelationshipTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private AuthorRepository authorRepository;
@Autowired
private ArticleRepository articleRepository;
@AfterEach
public void clearData() {
articleRepository.deleteAll();
authorRepository.deleteAll();
}
private Author saveAuthor(String name) {
return authorRepository.save(new Author(null, name));
}
private Article saveArticle(Author author, String title) {
Article article = new Article(null, title, "content");
article.setAuthor(author);
return articleRepository.save(article);
}
// Shared load: LoadAuthor -> RespondWithAuthor
@Test
public void getAuthor() throws Exception {
Author ada = saveAuthor("Ada");
mvc.perform(get("/author/" + ada.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Ada"));
}
@Test
public void getAuthorNotFound() throws Exception {
mvc.perform(get("/author/99999").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
// Global load: LoadArticle finds any article by id
@Test
public void getArticleGlobally() throws Exception {
Article article = saveArticle(saveAuthor("Ada"), "Analytical Engine");
mvc.perform(get("/article/" + article.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Analytical Engine"));
}
// Scoped load: the article is found within its own author
@Test
public void getAuthorsArticle() throws Exception {
Author ada = saveAuthor("Ada");
Article article = saveArticle(ada, "Analytical Engine");
mvc.perform(get("/author/" + ada.getId() + "/article/" + article.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Analytical Engine"))
.andExpect(jsonPath("$.authorId").value(ada.getId()));
}
// The ownership-scoping trap: an article of ANOTHER author is not found here,
// even though it exists globally. A global load would leak it.
@Test
public void authorsArticleIsScopedToTheAuthor() throws Exception {
Author ada = saveAuthor("Ada");
Author grace = saveAuthor("Grace");
Article gracesArticle = saveArticle(grace, "Compiler");
// exists globally
mvc.perform(get("/article/" + gracesArticle.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
// but is NOT Ada's article
mvc.perform(get("/author/" + ada.getId() + "/article/" + gracesArticle.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
// and IS Grace's article
mvc.perform(get("/author/" + grace.getId() + "/article/" + gracesArticle.getId()).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
}
// Create under a parent, reusing LoadAuthor
@Test
public void createArticleForAuthor() throws Exception {
Author ada = saveAuthor("Ada");
mvc.perform(post("/author/" + ada.getId() + "/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("Notes", "on the engine"))))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/author/" + ada.getId() + "/article/")))
.andExpect(jsonPath("$.title").value("Notes"))
.andExpect(jsonPath("$.authorId").value(ada.getId()));
}
@Test
public void createArticleForMissingAuthor() throws Exception {
mvc.perform(post("/author/99999/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("Notes", "content"))))
.andExpect(status().isNotFound());
}
// Validation runs before the author is loaded
@Test
public void createArticleInvalid() throws Exception {
mvc.perform(post("/author/99999/article")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new ArticleRequest("", "content"))))
.andExpect(status().isBadRequest())
.andExpect(content().string(containsString("title")));
}
}
Next
The Resolving References tutorial adds a Resolve function that enriches an entity with managed references, shared across create and update.

