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 Load function reused by every endpoint that needs the same entity. Here LoadAuthor serves 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.

Tutorial Source

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.