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;
}
/** Finds an article that belongs to THIS author, or null. */
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 ]
/**
* Shared load. This one function loads an author by id and publishes it. Every
* author-scoped endpoint (get the author, get the author's article, create an
* article for the author) reuses it. There is one place that turns an authorId
* into an Author, and one place that decides a missing author is a 404.
*/
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 ]
/**
* Global load. Loads any article by id, regardless of author. Used by
* GET /article/{articleId}. Contrast with {@link LoadAuthorsArticle}, which
* only finds an article that belongs to a given author.
*/
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:
/**
* Ownership-scoped load. Consumes the author loaded by {@link LoadAuthor} and
* finds the article <em>within that author</em>. An article that exists but
* belongs to a different author is not found, so the endpoint returns 404.
*
* <p>Do NOT replace this with the global {@link LoadArticle}. The global load
* would return another author's article under
* {@code /author/{authorId}/article/{articleId}}, which leaks data across
* authors. The scoping is the contract of the nested URL, so the scoped load is
* a distinct function.
*/
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:
/**
* Builds a new article and attaches it to the author loaded by
* {@link LoadAuthor}. It consumes two variables: the parent author and the
* validated request body. The author comes from the URL, not the body.
*/
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.

