+ * The
+ * refresh
+ * parameter, which controls when the changes to the index will become visible for search operations,
+ * is set
+ * as "wait_for",
+ * meaning that the indexing request will return after the next refresh. Usually this is not recommended,
+ * as it slows down the application, but in this case it's required since the frontends will try to
+ * retrieve
+ * the article immediately after its creation.
+ *
+ * @return the new article.
+ */
+ public Article newArticle(ArticleCreationDTO articleDTO, Author author) throws IOException {
+
+ // Checking if slug would be unique
+ String slug = generateAndCheckSlug(articleDTO.title());
+
+ Instant now = Instant.now();
+ Article article = new Article(articleDTO, slug, now, now, author);
+
+ IndexRequest articleReq = IndexRequest.of((id -> id
+ .index(ARTICLES)
+ .refresh(Refresh.WaitFor)
+ .document(article)));
+
+ esClient.index(articleReq);
+
+ return article;
+ }
+
+ /**
+ * Simple term query (see {@link UserService#findUserByUsername(String)}) to find an article
+ * given its unique slug.
+ *
+ * @return a pair containing the article and its id,
+ */
+ public ArticleIdPair findArticleBySlug(String slug) throws IOException {
+
+ SearchResponse getArticle = esClient.search(ss -> ss
+ .index(ARTICLES)
+ .query(q -> q
+ .term(t -> t
+ .field("slug.keyword")
+ .value(slug))
+ )
+ , Article.class);
+
+ if (getArticle.hits().hits().isEmpty()) {
+ return null;
+ }
+ return new ArticleIdPair(extractSource(getArticle), extractId(getArticle));
+ }
+
+ /**
+ * See {@link ArticleService#updateArticle(String, Article)} (String, User)}
+ *
+ * Updates an article, checking if the author is the same and if the new title's slug would be unique.
+ *
+ * @return the updated user.
+ */
+ public ArticleDTO updateArticle(ArticleUpdateDTO article, String slug, Author author) throws IOException {
+
+ // Getting original article from slug
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article oldArticle = articlePair.article();
+
+ // Checking if author is the same
+ if (!oldArticle.author().username().equals(author.username())) {
+ throw new UnauthorizedException("Cannot modify article from another author");
+ }
+
+ String newSlug = slug;
+ // If title is being changed, checking if new slug would be unique
+ if (!isNullOrBlank(article.title()) && !article.title().equals(oldArticle.title())) {
+ newSlug = generateAndCheckSlug(article.title());
+ }
+
+ Instant updatedAt = Instant.now();
+
+ // Null/blank check for every optional field
+ Article updatedArticle = new Article(newSlug,
+ isNullOrBlank(article.title()) ? oldArticle.title() : article.title(),
+ isNullOrBlank(article.description()) ? oldArticle.description() : article.description(),
+ isNullOrBlank(article.body()) ? oldArticle.body() : article.body(),
+ oldArticle.tagList(), oldArticle.createdAt(),
+ updatedAt, oldArticle.favorited(), oldArticle.favoritesCount(),
+ oldArticle.favoritedBy(), oldArticle.author());
+
+ updateArticle(id, updatedArticle);
+ return new ArticleDTO(updatedArticle);
+ }
+
+ /**
+ * Updates an article, given the updated object and its unique id.
+ */
+ private void updateArticle(String id, Article updatedArticle) throws IOException {
+ UpdateResponse upArticle = esClient.update(up -> up
+ .index(ARTICLES)
+ .id(id)
+ .doc(updatedArticle)
+ , Article.class);
+ if (!upArticle.result().name().equals("Updated")) {
+ throw new RuntimeException("Article update failed");
+ }
+ }
+
+ /**
+ * Deletes an article, using the slug to identify it, and all of its comments.
+ *
+ * Delete queries are very similar to search queries,
+ * here a term query (see {@link UserService#findUserByUsername(String)}) is used to match the
+ * correct article.
+ *
+ * The refresh value (see {@link ArticleService#newArticle(ArticleCreationDTO, Author)}) is
+ * set as "wait_for" for both delete queries, since the frontend will perform a get operation
+ * immediately after. The syntax for setting it as "wait_for" is different from the index operation,
+ * but the result is the same.
+ */
+ public void deleteArticle(String slug, Author author) throws IOException {
+
+ // Getting article from slug
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ Article article = articlePair.article();
+
+ // Checking if author is the same
+ if (!article.author().username().equals(author.username())) {
+ throw new UnauthorizedException("Cannot delete article from another author");
+ }
+
+ DeleteByQueryResponse deleteArticle = esClient.deleteByQuery(d -> d
+ .index(ARTICLES)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .term(t -> t
+ .field("slug.keyword")
+ .value(slug))
+ ));
+ if (deleteArticle.deleted() < 1) {
+ throw new RuntimeException("Failed to delete article");
+ }
+
+ // Delete every comment to the article, using a term query
+ // that will match all comments with the same articleSlug
+ DeleteByQueryResponse deleteCommentsByArticle = esClient.deleteByQuery(d -> d
+ .index(COMMENTS)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .term(t -> t
+ .field("articleSlug.keyword")
+ .value(slug))
+ ));
+ }
+
+ /**
+ * Adds the requesting user to the article's favoritedBy list.
+ *
+ * @return the target article.
+ */
+ public Article markArticleAsFavorite(String slug, String username) throws IOException {
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article article = articlePair.article();
+
+ // Checking if article was already marked as favorite
+ if (article.favoritedBy().contains(username)) {
+ return article;
+ }
+
+ article.favoritedBy().add(username);
+ Article updatedArticle = new Article(article.slug(), article.title(),
+ article.description(),
+ article.body(), article.tagList(), article.createdAt(), article.updatedAt(),
+ true, article.favoritesCount() + 1, article.favoritedBy(), article.author());
+
+ updateArticle(id, updatedArticle);
+ return updatedArticle;
+ }
+
+ /**
+ * Removes the requesting user from the article's favoritedBy list.
+ *
+ * @return the target article.
+ */
+ public Article removeArticleFromFavorite(String slug, String username) throws IOException {
+ ArticleIdPair articlePair = Optional.ofNullable(findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"));
+ String id = articlePair.id();
+ Article article = articlePair.article();
+
+ // Checking if article was not marked as favorite before
+ if (!article.favoritedBy().contains(username)) {
+ return article;
+ }
+
+ article.favoritedBy().remove(username);
+ int favoriteCount = article.favoritesCount() - 1;
+ boolean favorited = article.favorited();
+ if (favoriteCount == 0) {
+ favorited = false;
+ }
+
+ Article updatedArticle = new Article(article.slug(), article.title(),
+ article.description(),
+ article.body(), article.tagList(), article.createdAt(), article.updatedAt(), favorited,
+ favoriteCount, article.favoritedBy(), article.author());
+
+ updateArticle(id, updatedArticle);
+ return updatedArticle;
+ }
+
+ /**
+ * Builds a search query using the filters the user is passing to retrieve all the matching articles.
+ *
+ * Since all the parameters are optional, the query must be build conditionally, adding one parameter
+ * at a time to the "conditions" array.
+ * Using a
+ * match
+ * query instead of a
+ * term
+ * query to allow the use of a single word for searching phrases,
+ * for example, filtering for articles with the "cat" tag will also return articles with the "cat food"
+ * tag.
+ *
+ * The articles are then sorted by the time they were last updated.
+ *
+ * @return a list containing all articles, filtered.
+ */
+ public ArticlesDTO findArticles(String tag, String author, String favorited, Integer limit,
+ Integer offset,
+ Optional user) throws IOException {
+ List conditions = new ArrayList<>();
+
+ if (!isNullOrBlank(tag)) {
+ conditions.add(new Builder()
+ .field("tagList")
+ .query(tag).build()._toQuery());
+ }
+ if (!isNullOrBlank(author)) {
+ conditions.add(new Builder()
+ .field("author.username")
+ .query(author).build()._toQuery());
+ }
+ // Alternative equivalent syntax to build the match query without using the Builder explicitly
+ if (!isNullOrBlank(favorited)) {
+ conditions.add(MatchQuery.of(mq -> mq
+ .field("favoritedBy")
+ .query(favorited))
+ ._toQuery());
+ }
+
+ Query query = new Query.Builder().bool(b -> b.should(conditions)).build();
+
+ SearchResponse getArticle = esClient.search(ss -> ss
+ .index(ARTICLES)
+ .size(limit) // how many results to return
+ .from(offset) // starting point
+ .query(query)
+ .sort(srt -> srt
+ .field(fld -> fld
+ .field("updatedAt")
+ .order(SortOrder.Desc))) // last updated first
+ , Article.class);
+
+ // Making the output adhere to the API specification
+ return new ArticlesDTO(getArticle.hits().hits()
+ .stream()
+ .map(Hit::source)
+ // If tag specified, put that tag first in the array
+ .peek(a -> {
+ if (!isNullOrBlank(tag) && a.tagList().contains(tag)) {
+ Collections.swap(a.tagList(), a.tagList().indexOf(tag), 0);
+ }
+ })
+ .map(ArticleForListDTO::new)
+ // If auth was provided, filling the "following" field of "Author" accordingly
+ .map(a -> {
+ if (user.isPresent()) {
+ boolean following = user.get().following().contains(a.author().username());
+ return new ArticleForListDTO(a, new Author(a.author().username(),
+ a.author().email(), a.author().bio(), following));
+ }
+ return a;
+ })
+ .collect(Collectors.toList()), getArticle.hits().hits().size());
+ }
+
+ /**
+ * Searches the article index for articles created by multiple users,
+ * using a
+ * terms query,
+ * which works like a
+ * term query
+ * that can match multiple values for the same field.
+ *
+ * The fields of the nested object "author" are easily accessible using the dot notation, for example
+ * "author.username".
+ *
+ * The articles are sorted by the time they were last updated.
+ *
+ * @return a list of articles from followed users.
+ */
+ public ArticlesDTO generateArticleFeed(User user) throws IOException {
+ // Preparing authors filter from user data
+ List authorsFilter = user.following().stream()
+ .map(FieldValue::of).toList();
+
+ SearchResponse articlesByAuthors = esClient.search(ss -> ss
+ .index(ARTICLES)
+ .query(q -> q
+ .bool(b -> b
+ .filter(f -> f
+ .terms(t -> t
+ .field("author.username.keyword")
+ .terms(TermsQueryField.of(tqf -> tqf.value(authorsFilter)))
+ ))))
+ .sort(srt -> srt
+ .field(fld -> fld
+ .field("updatedAt")
+ .order(SortOrder.Desc)))
+ , Article.class);
+
+ return new ArticlesDTO(articlesByAuthors.hits().hits()
+ .stream()
+ .map(Hit::source)
+ .map(ArticleForListDTO::new)
+ // Filling the "following" field of "Author" accordingly
+ .map(a -> {
+ boolean following = user.following().contains(a.author().username());
+ return new ArticleForListDTO(a, new Author(a.author().username(),
+ a.author().email(), a.author().bio(), following));
+ })
+ .collect(Collectors.toList()), articlesByAuthors.hits().hits().size());
+ }
+
+
+ /**
+ * Searches the article index to retrieve a list of each distinct tag, using an
+ * aggregation ,
+ * more specifically a
+ * terms aggregation
+ *
+ * The resulting list of tags is sorted by document count (how many times they appear in different
+ * documents).
+ *
+ * @return a list of all tags.
+ */
+ public TagsDTO findAllTags() throws IOException {
+
+ // If alphabetical order is preferred, use "_key" instead
+ NamedValue sort = new NamedValue<>("_count", SortOrder.Desc);
+
+ SearchResponse aggregateTags = esClient.search(s -> s
+ .index(ARTICLES)
+ .size(0) // this is needed avoid returning the search result, which is not necessary here
+ .aggregations("tags", agg -> agg
+ .terms(ter -> ter
+ .field("tagList.keyword")
+ .order(sort))
+ ),
+ Aggregation.class
+ );
+
+ return new TagsDTO(aggregateTags.aggregations().get("tags")
+ .sterms().buckets()
+ .array().stream()
+ .map(st -> st.key().stringValue())
+ .collect(Collectors.toList())
+ );
+ }
+
+ /**
+ * Uses the Slugify library to generate the slug of the input string, then checks its uniqueness.
+ *
+ * @return the "slugified" string.
+ */
+ private String generateAndCheckSlug(String title) throws IOException {
+ String slug = Slugify.builder().build().slugify(title);
+ if (Objects.nonNull(findArticleBySlug(slug))) {
+ throw new ResourceAlreadyExistsException("Article slug already exists, please change the title");
+ }
+ return slug;
+ }
+
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java
new file mode 100644
index 000000000..24b87b8da
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/CommentService.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.db;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch._types.Refresh;
+import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse;
+import co.elastic.clients.elasticsearch.core.IndexRequest;
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import realworld.document.article.ArticleCreationDTO;
+import realworld.document.comment.Comment;
+import realworld.document.comment.CommentCreationDTO;
+import realworld.document.comment.CommentForListDTO;
+import realworld.document.comment.CommentsDTO;
+import realworld.document.user.Author;
+import realworld.document.user.RegisterDTO;
+import realworld.document.user.User;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import static realworld.constant.Constants.COMMENTS;
+
+@Service
+public class CommentService {
+
+ private final ElasticsearchClient esClient;
+
+ @Autowired
+ public CommentService(ElasticsearchClient esClient) {
+ this.esClient = esClient;
+ }
+
+ /**
+ * Creates a new comment and saves it into the comment index.
+ * The refresh value is specified for the same reason explained in
+ * {@link ArticleService#newArticle(ArticleCreationDTO, Author)}
+ *
+ * @return the newly created comment.
+ */
+ public Comment newComment(CommentCreationDTO commentDTO, String slug, User user) throws IOException {
+ // assuming you cannot follow yourself
+ Author commentAuthor = new Author(user, false);
+ Instant now = Instant.now();
+
+ // pre-generating id since it's a field in the comment class
+ Long commentId = Long.valueOf(String.valueOf(new SecureRandom().nextLong()).substring(0, 15));
+ Comment comment = new Comment(commentId, now, now, commentDTO.body(), commentAuthor,
+ slug);
+
+ IndexRequest commentReq = IndexRequest.of((id -> id
+ .index(COMMENTS)
+ .refresh(Refresh.WaitFor)
+ .document(comment)));
+
+
+ esClient.index(commentReq);
+
+ return comment;
+ }
+
+ /**
+ * Deletes a specific comment making sure that the user performing the operation is the author of the
+ * comment itself.
+ *
+ * A boolean query similar to the one used in {@link UserService#newUser(RegisterDTO)} is used,
+ * matching both the comment id and the author's username, with a difference: here "must" is used
+ * instead of "should", meaning that the documents must match both conditions at the same time.
+ *
+ * @return The authenticated user.
+ */
+ public void deleteComment(String commentId, String username) throws IOException {
+
+ DeleteByQueryResponse deleteComment = esClient.deleteByQuery(ss -> ss
+ .index(COMMENTS)
+ .waitForCompletion(true)
+ .refresh(true)
+ .query(q -> q
+ .bool(b -> b
+ .must(m -> m
+ .term(t -> t
+ .field("id")
+ .value(commentId))
+ ).must(m -> m
+ .term(t -> t
+ .field("author.username.keyword")
+ .value(username))))
+ ));
+ if (deleteComment.deleted() < 1) {
+ throw new RuntimeException("Failed to delete comment");
+ }
+ }
+
+ /**
+ * Retrieves all comments with the same articleSlug value using a term query
+ * (see {@link UserService#findUserByUsername(String)}).
+ *
+ * @return a list of comment belonging to a single article.
+ */
+ public CommentsDTO findAllCommentsByArticle(String slug, Optional user) throws IOException {
+ SearchResponse commentsByArticle = esClient.search(s -> s
+ .index(COMMENTS)
+ .query(q -> q
+ .term(t -> t
+ .field("articleSlug.keyword")
+ .value(slug))
+ )
+ , Comment.class);
+
+ return new CommentsDTO(commentsByArticle.hits().hits().stream()
+ .map(x -> new CommentForListDTO(x.source()))
+ .map(c -> {
+ if (user.isPresent()) {
+ boolean following = user.get().following().contains(c.author().username());
+ return new CommentForListDTO(c.id(), c.createdAt(), c.updatedAt(), c.body(),
+ new Author(c.author().username(), c.author().email(), c.author().bio(),
+ following));
+ }
+ return c;
+ })
+ .collect(Collectors.toList()));
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java
new file mode 100644
index 000000000..e9270c701
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/ElasticClient.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.db;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import co.elastic.clients.transport.ElasticsearchTransport;
+import co.elastic.clients.transport.endpoints.BooleanResponse;
+import co.elastic.clients.transport.rest_client.RestClientTransport;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.apache.http.Header;
+import org.apache.http.HttpHost;
+import org.apache.http.message.BasicHeader;
+import org.elasticsearch.client.RestClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.io.IOException;
+
+import static realworld.constant.Constants.*;
+
+@Configuration
+public class ElasticClient {
+
+ @Value("${elasticsearch.server.url}")
+ private String serverUrl;
+
+ @Value("${elasticsearch.api.key}")
+ private String apiKey;
+
+ /**
+ * Creates the ElasticsearchClient and the indexes needed
+ *
+ * @return a configured ElasticsearchClient
+ */
+ @Bean
+ public ElasticsearchClient elasticRestClient() throws IOException {
+
+ // Create the low-level client
+ RestClient restClient = RestClient
+ .builder(HttpHost.create(serverUrl))
+ .setDefaultHeaders(new Header[]{
+ new BasicHeader("Authorization", "ApiKey " + apiKey)
+ })
+ .build();
+
+ // The transport layer of the Elasticsearch client requires a json object mapper to
+ // define how to serialize/deserialize java objects. The mapper can be customized by adding
+ // modules, for example since the Article and Comment object both have Instant fields, the
+ // JavaTimeModule is added to provide support for java 8 Time classes, which the mapper itself does
+ // not support.
+ ObjectMapper mapper = JsonMapper.builder()
+ .addModule(new JavaTimeModule())
+ .build();
+
+ // Create the transport with the Jackson mapper
+ ElasticsearchTransport transport = new RestClientTransport(
+ restClient, new JacksonJsonpMapper(mapper));
+
+ // Create the API client
+ ElasticsearchClient esClient = new ElasticsearchClient(transport);
+
+ // Creating the indexes
+ createSimpleIndex(esClient, USERS);
+ createIndexWithDateMapping(esClient, ARTICLES);
+ createIndexWithDateMapping(esClient, COMMENTS);
+
+ return esClient;
+ }
+
+ /**
+ * Plain simple
+ * index
+ * creation with an
+ *
+ * exists check
+ */
+ private void createSimpleIndex(ElasticsearchClient esClient, String index) throws IOException {
+ BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index));
+ if (!indexRes.value()) {
+ esClient.indices().create(c -> c
+ .index(index));
+ }
+ }
+
+ /**
+ * If no explicit mapping is defined, elasticsearch will dynamically map types when converting data to
+ * the json
+ * format. Adding explicit mapping to the date fields assures that no precision will be lost. More
+ * information about
+ * dynamic
+ * field mapping, more on mapping date
+ * format
+ */
+ private void createIndexWithDateMapping(ElasticsearchClient esClient, String index) throws IOException {
+ BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index));
+ if (!indexRes.value()) {
+ esClient.indices().create(c -> c
+ .index(index)
+ .mappings(m -> m
+ .properties("createdAt", p -> p
+ .date(d -> d.format("strict_date_optional_time")))
+ .properties("updatedAt", p -> p
+ .date(d -> d.format("strict_date_optional_time")))));
+
+ }
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java b/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java
new file mode 100644
index 000000000..b35ae4225
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/db/UserService.java
@@ -0,0 +1,407 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.db;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch._types.Refresh;
+import co.elastic.clients.elasticsearch.core.IndexRequest;
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+import co.elastic.clients.elasticsearch.core.UpdateResponse;
+import co.elastic.clients.elasticsearch.core.search.Hit;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.impl.TextCodec;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import realworld.document.exception.ResourceAlreadyExistsException;
+import realworld.document.exception.ResourceNotFoundException;
+import realworld.document.exception.UnauthorizedException;
+import realworld.document.user.*;
+import realworld.utils.UserIdPair;
+
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
+import java.time.Instant;
+import java.util.*;
+
+import static realworld.constant.Constants.USERS;
+import static realworld.utils.Utility.*;
+
+@Service
+public class UserService {
+
+ private final ElasticsearchClient esClient;
+
+ @Value("${jwt.signing.key}")
+ private String jwtSigningKey;
+
+ @Autowired
+ public UserService(ElasticsearchClient esClient) {
+ this.esClient = esClient;
+ }
+
+ /**
+ * Inserts a new User into the "users" index, checking beforehand whether the username and email
+ * are unique.
+ *
+ * See {@link UserService#findUserByUsername(String)} for details on how the term query works.
+ *
+ * Combining multiple term queries into a single
+ * boolean query with "should" occur
+ * to match documents fulfilling either conditions to check the uniqueness of the new email and username.
+ *
+ * When the new user document is created, it is left up to elasticsearch to create a unique
+ * id field , since there's no user field that is guaranteed not to be updated/modified.
+ *
+ * @return The newly registered user.
+ */
+ public User newUser(RegisterDTO user) throws IOException {
+
+ // Checking uniqueness of both email and username
+ SearchResponse checkUser = esClient.search(ss -> ss
+ .index(USERS)
+ .query(q -> q
+ .bool(b -> b
+ .should(s -> s
+ .term(t -> t
+ .field("email.keyword")
+ .value(user.email()))
+ ).should(s -> s
+ .term(t -> t
+ .field("username.keyword")
+ .value(user.username())))))
+ , User.class);
+
+ checkUser.hits().hits().stream()
+ .map(Hit::source)
+ .filter(x -> x.username().equals(user.username()))
+ .findFirst()
+ .ifPresent(x -> {
+ throw new ResourceAlreadyExistsException("Username already exists");
+ });
+
+ checkUser.hits().hits().stream()
+ .map(Hit::source)
+ .filter(x -> x.email().equals(user.email()))
+ .findFirst()
+ .ifPresent(x -> {
+ throw new ResourceAlreadyExistsException("Email already used");
+ });
+
+ // Building user's JWT, with no expiration since it's not requested
+ String jws = Jwts.builder()
+ .setIssuer("rw-backend")
+ .setSubject(user.email())
+ .claim("name", user.username())
+ .claim("scope", "user")
+ .setIssuedAt(Date.from(Instant.now()))
+ .signWith(
+ SignatureAlgorithm.HS256,
+ TextCodec.BASE64.decode(jwtSigningKey)
+ )
+ .compact();
+
+ // Hashing the password, storing the salt with the user
+ SecureRandom secureRandom = new SecureRandom();
+ byte[] salt = new byte[16];
+ secureRandom.nextBytes(salt);
+ String hashedPw = hashUserPw(user.password(), salt);
+
+ User newUser = new User(user.username(), user.email(),
+ hashedPw, jws, "", "", salt, new ArrayList<>());
+
+ // Creating the index request
+ IndexRequest userReq = IndexRequest.of((id -> id
+ .index(USERS)
+ .refresh(Refresh.WaitFor)
+ .document(newUser)));
+
+ // Indexing the request (inserting it into to database)
+ esClient.index(userReq);
+
+ return newUser;
+ }
+
+ /**
+ * Using a simple term query (see {@link UserService#findUserByUsername(String)} for details)
+ * to find the user using the same unique email as the one provided. The password is then hashed and
+ * checked after the search.
+ *
+ * @return The authenticated user.
+ */
+ public User authenticateUser(LoginDTO login) throws IOException {
+
+ SearchResponse getUser = esClient.search(ss -> ss
+ .index(USERS)
+ .query(q -> q
+ .term(t -> t
+ .field("email.keyword")
+ .value(login.email())))
+ , User.class);
+
+
+ if (getUser.hits().hits().isEmpty()) {
+ throw new ResourceNotFoundException("Email not found");
+ }
+
+ // Check if the hashed password matches the one provided
+ User user = extractSource(getUser);
+ String hashedPw = hashUserPw(login.password(), user.salt());
+
+ if (!hashedPw.equals(user.password())) {
+ throw new UnauthorizedException("Wrong password");
+ }
+ return user;
+ }
+
+ /**
+ * Deserializing and checking the token, then performing a term query
+ * (see {@link UserService#findUserByUsername(String)} for details) using the token string to retrieve
+ * the corresponding user.
+ *
+ * @return a pair containing the result of the term query, a single user, with its id.
+ */
+ public UserIdPair findUserByToken(String auth) throws IOException {
+ String token;
+ try {
+ token = auth.split(" ")[1];
+ Jwts.parser()
+ .setSigningKey(TextCodec.BASE64.decode(jwtSigningKey))
+ .parse(token);
+ } catch (Exception e) {
+ throw new UnauthorizedException("Token missing or not recognised");
+ }
+
+ SearchResponse getUser = esClient.search(ss -> ss
+ .index(USERS)
+ .query(q -> q
+ .term(t -> t
+ .field("token.keyword")
+ .value(token))
+ )
+ , User.class);
+
+ if (getUser.hits().hits().isEmpty()) {
+ throw new ResourceNotFoundException("Token not assigned to any user");
+ }
+ return new UserIdPair(extractSource(getUser), extractId(getUser));
+ }
+
+ /**
+ * See {@link UserService#updateUser(String, User)}
+ *
+ * Updates a user, checking before if the new username or email would be unique.
+ *
+ * @return the updated user.
+ */
+ public User updateUser(UserDTO userDTO, String auth) throws IOException {
+
+ UserIdPair userPair = findUserByToken(auth);
+ User user = userPair.user();
+
+ // If the username or email are updated, checking uniqueness
+ if (!isNullOrBlank(userDTO.username()) && !userDTO.username().equals(user.username())) {
+ UserIdPair newUsernameSearch = findUserByUsername(userDTO.username());
+ if (Objects.nonNull(newUsernameSearch)) {
+ throw new ResourceAlreadyExistsException("Username already exists");
+ }
+ }
+
+ if (!isNullOrBlank(userDTO.email()) && !userDTO.email().equals(user.email())) {
+ UserIdPair newUsernameSearch = findUserByEmail(userDTO.username());
+ if (Objects.nonNull(newUsernameSearch)) {
+ throw new ResourceAlreadyExistsException("Email already in use");
+ }
+ }
+
+ // Null/blank check for every optional field
+ User updatedUser = new User(isNullOrBlank(userDTO.username()) ? user.username() :
+ userDTO.username(),
+ isNullOrBlank(userDTO.email()) ? user.email() : userDTO.email(),
+ user.password(), user.token(),
+ isNullOrBlank(userDTO.bio()) ? user.bio() : userDTO.bio(),
+ isNullOrBlank(userDTO.image()) ? user.image() : userDTO.image(),
+ user.salt(), user.following());
+
+ updateUser(userPair.id(), updatedUser);
+ return updatedUser;
+ }
+
+ /**
+ * Updates a user, given the updated object and its unique id.
+ */
+ private void updateUser(String id, User user) throws IOException {
+ UpdateResponse upUser = esClient.update(up -> up
+ .index(USERS)
+ .id(id)
+ .doc(user)
+ , User.class);
+ if (!upUser.result().name().equals("Updated")) {
+ throw new RuntimeException("User update failed");
+ }
+ }
+
+ /**
+ * Retrieves data for the requested user and the asking user to provide profile information.
+ *
+ * @return the requested user's profile.
+ */
+ public Profile findUserProfile(String username, String auth) throws IOException {
+
+ UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username))
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+ User targetUser = targetUserPair.user();
+
+ // Checking if the user is followed by who's asking
+ UserIdPair askingUserPair = findUserByToken(auth);
+ boolean following = askingUserPair.user().following().contains(targetUser.username());
+
+ return new Profile(targetUser, following);
+ }
+
+ /**
+ * Adds the targed user to the asking user's list of followed profiles.
+ *
+ * @return the target user's profile.
+ */
+ public Profile followUser(String username, String auth) throws IOException {
+
+ UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username))
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+ User targetUser = targetUserPair.user();
+
+ UserIdPair askingUserPair = findUserByToken(auth);
+ User askingUser = askingUserPair.user();
+
+ if (askingUser.username().equals(targetUser.username())) {
+ throw new RuntimeException("Cannot follow yourself!");
+ }
+
+ // Add followed user to list if not already present
+ if (!askingUser.following().contains(targetUser.username())) {
+ askingUser.following().add(targetUser.username());
+
+ updateUser(askingUserPair.id(), askingUser);
+ }
+
+ return new Profile(targetUser, true);
+ }
+
+ /**
+ * Removes the targed user from the asking user's list of followed profiles.
+ *
+ * @return the target user's profile.
+ */
+ public Profile unfollowUser(String username, String auth) throws IOException {
+ UserIdPair targetUserPair = Optional.ofNullable(findUserByUsername(username))
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+ User targetUser = targetUserPair.user();
+
+ UserIdPair askingUserPair = findUserByToken(auth);
+ User askingUser = askingUserPair.user();
+
+ // Remove followed user to list if not already present
+ if (askingUser.following().contains(targetUser.username())) {
+ askingUser.following().remove(targetUser.username());
+
+ updateUser(askingUserPair.id(), askingUser);
+ }
+
+ return new Profile(targetUser, false);
+ }
+
+ /**
+ * Searches the "users" index for a document containing the exact same username.
+ *
+ * A
+ * term query
+ * means that it will find only results that match character by character.
+ *
+ * Using the
+ * keyword
+ * property of the field allows to use the original value of the string while querying, instead of the
+ * processed/tokenized value.
+ *
+ * @return a pair containing the result of the term query, a single user, with its id.
+ */
+ public UserIdPair findUserByUsername(String username) throws IOException {
+ // Simple term query to match exactly the username string
+ SearchResponse getUser = esClient.search(ss -> ss
+ .index(USERS)
+ .query(q -> q
+ .term(t -> t
+ .field("username.keyword")
+ .value(username)))
+ , User.class);
+ if (getUser.hits().hits().isEmpty()) {
+ return null;
+ }
+ return new UserIdPair(extractSource(getUser), extractId(getUser));
+ }
+
+ /**
+ * Searches the "users" index for a document containing the exact same email.
+ * See {@link UserService#findUserByUsername(String)} for details.
+ *
+ * @return the result of the term query, a single user, with its id.
+ */
+ private UserIdPair findUserByEmail(String email) throws IOException {
+ // Simple term query to match exactly the email string
+ SearchResponse getUser = esClient.search(ss -> ss
+ .index(USERS)
+ .query(q -> q
+ .term(t -> t
+ .field("email.keyword")
+ .value(email)))
+ , User.class);
+ if (getUser.hits().hits().isEmpty()) {
+ return null;
+ }
+ return new UserIdPair(extractSource(getUser), extractId(getUser));
+ }
+
+ /**
+ * Hashes a string using the PBKDF2 method.
+ *
+ * @return the hashed string.
+ */
+ private String hashUserPw(String password, byte[] salt) {
+
+ KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, 65536, 128);
+ String hashedPw = null;
+ try {
+ SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
+ byte[] hash = secretKeyFactory.generateSecret(keySpec).getEncoded();
+ Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
+ hashedPw = encoder.encodeToString(hash);
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new RuntimeException(e);
+ }
+ return hashedPw;
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java
new file mode 100644
index 000000000..b06213bb4
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/Article.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+public record Article(
+ String slug,
+ String title,
+ String description,
+ String body,
+ List tagList,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ boolean favorited,
+ int favoritesCount,
+ List favoritedBy,
+ Author author) {
+
+ public Article(ArticleCreationDTO article, String slug, Instant createdAt, Instant updatedAt,
+ Author author) {
+ this(slug, article.title(), article.description(), article.body(), article.tagList(), createdAt,
+ updatedAt, false, 0, new ArrayList<>(), author);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java
new file mode 100644
index 000000000..94d23084a
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleCreationDTO.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+@JsonTypeName("article")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record ArticleCreationDTO(@NotNull String title, @NotNull String description, @NotNull String body,
+ List tagList) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java
new file mode 100644
index 000000000..94796110e
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleDTO.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+import java.util.List;
+
+@JsonTypeName("article")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record ArticleDTO(
+ String slug,
+ String title,
+ String description,
+ String body,
+ List tagList,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ boolean favorited,
+ int favoritesCount,
+ Author author) {
+
+
+ public ArticleDTO(Article article) {
+ this(article.slug(), article.title(), article.description(), article.body(), article.tagList(),
+ article.createdAt(), article.updatedAt(),
+ article.favorited(), article.favoritesCount(), article.author());
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java
new file mode 100644
index 000000000..a602ac426
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleForListDTO.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+import java.util.List;
+
+public record ArticleForListDTO(
+ String slug,
+ String title,
+ String description,
+ String body,
+ List tagList,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ boolean favorited,
+ int favoritesCount,
+ Author author) {
+
+
+ public ArticleForListDTO(Article article) {
+ this(article.slug(), article.title(), article.description(), article.body(), article.tagList(),
+ article.createdAt(), article.updatedAt(),
+ article.favorited(), article.favoritesCount(), article.author());
+ }
+
+ public ArticleForListDTO(ArticleForListDTO article, Author author) {
+ this(article.slug(), article.title(), article.description(), article.body(), article.tagList(),
+ article.createdAt(), article.updatedAt(), article.favorited(), article.favoritesCount(),
+ author);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java
new file mode 100644
index 000000000..3cbcb0b3e
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticleUpdateDTO.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+
+@JsonTypeName("article")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record ArticleUpdateDTO(String title, String description, String body) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java
new file mode 100644
index 000000000..46411646c
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/ArticlesDTO.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import java.util.List;
+
+public record ArticlesDTO(List articles, int articlesCount) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java
new file mode 100644
index 000000000..72c3edc2b
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/article/TagsDTO.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.article;
+
+import java.util.List;
+
+public record TagsDTO(List tags) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java
new file mode 100644
index 000000000..682c1ecd1
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/Comment.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.comment;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+
+public record Comment(
+ Long id,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ String body,
+ Author author,
+ String articleSlug) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java
new file mode 100644
index 000000000..0cca6a407
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentCreationDTO.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.comment;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.constraints.NotNull;
+
+@JsonTypeName("comment")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record CommentCreationDTO(@NotNull String body) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java
new file mode 100644
index 000000000..b18db813e
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentDTO.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.comment;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+
+@JsonTypeName("comment")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record CommentDTO(
+ Long id,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ String body,
+ Author author) {
+
+ public CommentDTO(Comment comment) {
+ this(comment.id(), comment.createdAt(),
+ comment.updatedAt(), comment.body(),
+ comment.author());
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java
new file mode 100644
index 000000000..f5111a2fc
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentForListDTO.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.comment;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import realworld.document.user.Author;
+
+import java.time.Instant;
+
+public record CommentForListDTO(
+ Long id,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant createdAt,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS", timezone = "UTC")
+ Instant updatedAt,
+ String body,
+ Author author) {
+
+ public CommentForListDTO(Comment comment) {
+ this(comment.id(), comment.createdAt(),
+ comment.updatedAt(), comment.body(),
+ comment.author());
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java
new file mode 100644
index 000000000..80bc5d802
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/comment/CommentsDTO.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.comment;
+
+import java.util.List;
+
+public record CommentsDTO(List comments) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java
new file mode 100644
index 000000000..ebf0e497f
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceAlreadyExistsException.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.exception;
+
+public class ResourceAlreadyExistsException extends RuntimeException {
+
+ public ResourceAlreadyExistsException(String message) {
+ super(message);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java
new file mode 100644
index 000000000..b861cc55e
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/ResourceNotFoundException.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.exception;
+
+public class ResourceNotFoundException extends RuntimeException {
+
+ public ResourceNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java
new file mode 100644
index 000000000..d94f896d4
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/exception/UnauthorizedException.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.exception;
+
+public class UnauthorizedException extends RuntimeException {
+
+ public UnauthorizedException(String message) {
+ super(message);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java
new file mode 100644
index 000000000..b2bf8afa5
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Author.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+public record Author(String username, String email, String bio, boolean following) {
+
+ public Author(User user, boolean following) {
+ this(user.username(), user.email(), user.bio(), following);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java
new file mode 100644
index 000000000..300748b77
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/LoginDTO.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.constraints.NotNull;
+
+@JsonTypeName("user")
+@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME)
+public record LoginDTO(@NotNull String email, @NotNull String password) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java
new file mode 100644
index 000000000..a62704f4a
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/Profile.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+
+@JsonTypeName("profile")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record Profile(String username, String image, String bio, boolean following) {
+
+ public Profile(User user, boolean following) {
+ this(user.username(), user.image(), user.bio(), following);
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java
new file mode 100644
index 000000000..96cae013b
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/RegisterDTO.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+import jakarta.validation.constraints.NotNull;
+
+@JsonTypeName("user")
+@JsonTypeInfo(include = JsonTypeInfo.As.WRAPPER_OBJECT, use = JsonTypeInfo.Id.NAME)
+public record RegisterDTO(@NotNull String username, @NotNull String email, @NotNull String password) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java
new file mode 100644
index 000000000..bbcb967f7
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/User.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+import java.util.List;
+
+public record User(
+ String username,
+ String email,
+ String password,
+ String token,
+ String bio,
+ String image,
+ byte[] salt,
+ List following) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java
new file mode 100644
index 000000000..92ff08939
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/document/user/UserDTO.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.document.user;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+
+@JsonTypeName("user")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record UserDTO(
+ String username,
+ String email,
+ String token,
+ String bio,
+ String image) {
+
+ public UserDTO(User user) {
+ this(user.username(), user.email(), user.token(), user.bio(), user.image());
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java
new file mode 100644
index 000000000..81f4f113d
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/ArticleIdPair.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.utils;
+
+import realworld.document.article.Article;
+
+public record ArticleIdPair(Article article, String id) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java
new file mode 100644
index 000000000..d8ffb1ecc
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/UserIdPair.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.utils;
+
+import realworld.document.user.User;
+
+public record UserIdPair(User user, String id) {
+}
diff --git a/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java b/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java
new file mode 100644
index 000000000..700f85725
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/main/java/realworld/utils/Utility.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.utils;
+
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+
+import java.util.Objects;
+
+public class Utility {
+
+ public static boolean isNullOrBlank(String s) {
+ return Objects.isNull(s) || s.isBlank();
+ }
+
+ /**
+ * Utility method to be used for single result queries.
+ *
+ * @return The document id.
+ */
+ public static String extractId(SearchResponse searchResponse) {
+ return searchResponse.hits().hits().getFirst().id();
+ }
+
+ /**
+ * Utility method to be used for single result queries.
+ *
+ * @return An object of the class that was specified in the query definition.
+ */
+ public static TDocument extractSource(SearchResponse searchResponse) {
+ return searchResponse.hits().hits().getFirst().source();
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java
new file mode 100644
index 000000000..fb4940ac4
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/ElasticClientTest.java
@@ -0,0 +1,113 @@
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.db;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import co.elastic.clients.transport.ElasticsearchTransport;
+import co.elastic.clients.transport.endpoints.BooleanResponse;
+import co.elastic.clients.transport.rest_client.RestClientTransport;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import org.apache.http.HttpHost;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.elasticsearch.client.RestClient;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.testcontainers.elasticsearch.ElasticsearchContainer;
+
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.time.Duration;
+
+import static realworld.constant.Constants.*;
+
+@Configuration
+public class ElasticClientTest {
+
+ @Bean
+ public ElasticsearchClient elasticRestClient() throws IOException {
+ // Creating the testcontainer
+ String image = "docker.elastic.co/elasticsearch/elasticsearch:8.11.4";
+ ElasticsearchContainer container = new ElasticsearchContainer(image)
+ .withEnv("ES_JAVA_OPTS", "-Xms256m -Xmx256m")
+ .withEnv("path.repo", "/tmp") // for snapshots
+ .withStartupTimeout(Duration.ofSeconds(30))
+ .withPassword("changeme");
+ container.start();
+
+ // Connection settings
+ int port = container.getMappedPort(9200);
+ HttpHost host = new HttpHost("localhost", port, "https");
+ SSLContext sslContext = container.createSslContextFromCa();
+
+ BasicCredentialsProvider credsProv = new BasicCredentialsProvider();
+ credsProv.setCredentials(
+ AuthScope.ANY, new UsernamePasswordCredentials("elastic", "changeme")
+ );
+
+ // Building the rest client
+ RestClient restClient = RestClient.builder(host)
+ .setHttpClientConfigCallback(hc -> hc
+ .setDefaultCredentialsProvider(credsProv)
+ .setSSLContext(sslContext)
+ )
+ .build();
+ ObjectMapper mapper = JsonMapper.builder()
+ .addModule(new JavaTimeModule())
+ .build();
+ ElasticsearchTransport transport = new RestClientTransport(restClient,
+ new JacksonJsonpMapper(mapper));
+ ElasticsearchClient esClient = new ElasticsearchClient(transport);
+
+ // Creating the indexes
+ createSimpleIndex(esClient, USERS);
+ createIndexWithDateMapping(esClient, ARTICLES);
+ createIndexWithDateMapping(esClient, COMMENTS);
+
+ return esClient;
+ }
+
+ private void createSimpleIndex(ElasticsearchClient esClient, String index) throws IOException {
+ BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index));
+ if (!indexRes.value()) {
+ esClient.indices().create(c -> c
+ .index(index));
+ }
+ }
+
+ private void createIndexWithDateMapping(ElasticsearchClient esClient, String index) throws IOException {
+ BooleanResponse indexRes = esClient.indices().exists(ex -> ex.index(index));
+ if (!indexRes.value()) {
+ esClient.indices().create(c -> c
+ .index(index)
+ .mappings(m -> m
+ .properties("createdAt", p -> p
+ .date(d -> d))
+ .properties("updatedAt", p -> p
+ .date(d -> d))));
+
+ }
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java b/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java
new file mode 100644
index 000000000..dde02c678
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/test/java/realworld/db/UserServiceTest.java
@@ -0,0 +1,64 @@
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.db;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+import realworld.document.user.LoginDTO;
+import realworld.document.user.RegisterDTO;
+import realworld.document.user.User;
+import realworld.document.user.UserDTO;
+
+import java.io.IOException;
+import java.util.Objects;
+
+// This test uses test container, therefore the Docker engine needs to be installed to run it
+// The testcontainer will take ~30 seconds to start
+@TestPropertySource(locations = "classpath:test.properties")
+@SpringBootTest(classes = {UserService.class, UserServiceTest.class, ElasticClientTest.class})
+public class UserServiceTest {
+
+ @Autowired
+ private UserService service;
+
+ @Test
+ public void testCreateUpdateUser() throws IOException {
+ RegisterDTO register = new RegisterDTO("user", "mail", "pw");
+ User result = service.newUser(register);
+ assert (result.username().equals(register.username()));
+ assert (result.email().equals(register.email()));
+ assert (Objects.nonNull(result.token()));
+ String token = "Token " + result.token();
+
+ LoginDTO login = new LoginDTO("mail", "pw");
+ result = service.authenticateUser(login);
+ assert (result.username().equals(register.username()));
+
+ UserDTO update = new UserDTO("new-user", "mail", "", "bio", "image");
+ result = service.updateUser(update, token);
+ assert (result.username().equals(update.username()));
+ assert (result.email().equals(update.email()));
+ assert (result.bio().equals(update.bio()));
+ assert (result.image().equals(update.image()));
+ }
+}
diff --git a/examples/realworld-app/rw-database/src/test/resources/test.properties b/examples/realworld-app/rw-database/src/test/resources/test.properties
new file mode 100644
index 000000000..3b3058aed
--- /dev/null
+++ b/examples/realworld-app/rw-database/src/test/resources/test.properties
@@ -0,0 +1,4 @@
+###
+# Test properties
+###
+jwt.signing.key=c3VjaGFteXN0ZXJ5b3Vyc3VwZXJzZWNyZXR3b3c=
diff --git a/examples/realworld-app/rw-logo.png b/examples/realworld-app/rw-logo.png
new file mode 100644
index 000000000..99e5ed76a
Binary files /dev/null and b/examples/realworld-app/rw-logo.png differ
diff --git a/examples/realworld-app/rw-rest/build.gradle b/examples/realworld-app/rw-rest/build.gradle
new file mode 100644
index 000000000..8b17e2b26
--- /dev/null
+++ b/examples/realworld-app/rw-rest/build.gradle
@@ -0,0 +1,22 @@
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '3.2.1'
+ id 'io.spring.dependency-management' version '1.1.4'
+}
+
+group = 'realworldapp'
+version = '0.0.1-SNAPSHOT'
+
+java {
+ sourceCompatibility = '21'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation('org.springframework.boot:spring-boot-starter-web:3.2.0')
+ implementation('realworldapp:rw-database')
+}
+
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java
new file mode 100644
index 000000000..9e207c7e2
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ArticleController.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import realworld.db.ArticleService;
+import realworld.db.CommentService;
+import realworld.db.UserService;
+import realworld.document.article.*;
+import realworld.document.comment.Comment;
+import realworld.document.comment.CommentCreationDTO;
+import realworld.document.comment.CommentDTO;
+import realworld.document.comment.CommentsDTO;
+import realworld.document.exception.ResourceNotFoundException;
+import realworld.document.user.Author;
+import realworld.document.user.User;
+import realworld.utils.UserIdPair;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import static realworld.utils.Utility.isNullOrBlank;
+
+@CrossOrigin
+@RestController
+@RequestMapping("/articles")
+public class ArticleController {
+
+ private final ArticleService articleService;
+ private final UserService userService;
+ private final CommentService commentService;
+
+ Logger logger = LoggerFactory.getLogger(UserController.class);
+
+ @Autowired
+ public ArticleController(ArticleService articleService, UserService userService,
+ CommentService commentService) {
+ this.articleService = articleService;
+ this.userService = userService;
+ this.commentService = commentService;
+ }
+
+ @PostMapping()
+ public ResponseEntity newArticle(@RequestBody ArticleCreationDTO req, @RequestHeader(
+ "Authorization") String auth) throws IOException {
+ UserIdPair userPair = userService.findUserByToken(auth);
+ Author author = new Author(userPair.user(), false);
+
+ Article res = articleService.newArticle(req, author);
+ logger.debug("Created new article: {}", res.slug());
+ return ResponseEntity.ok(new ArticleDTO(res));
+ }
+
+ @GetMapping("/{slug}")
+ public ResponseEntity findArticleBySlug(@PathVariable String slug) throws IOException {
+ Article res = Optional.ofNullable(articleService.findArticleBySlug(slug))
+ .orElseThrow(() -> new ResourceNotFoundException("Article not found"))
+ .article();
+ logger.debug("Retrieved article: {}", slug);
+ return ResponseEntity.ok(new ArticleDTO(res));
+ }
+
+ @GetMapping()
+ public ResponseEntity findArticles(@RequestParam(required = false) String tag,
+ @RequestParam(required = false) String author,
+ @RequestParam(required = false) String favorited,
+ @RequestParam(required = false) Integer limit,
+ @RequestParam(required = false) Integer offset,
+ @RequestHeader(value = "Authorization", required =
+ false) String auth) throws IOException {
+ Optional user = Optional.empty();
+ if (!isNullOrBlank(auth)) {
+ user = Optional.of(userService.findUserByToken(auth).user());
+ }
+ ArticlesDTO res = articleService.findArticles(tag, author, favorited, limit, offset, user);
+ logger.debug("Returned article list");
+ return ResponseEntity.ok(res);
+ }
+
+ @GetMapping("/feed")
+ public ResponseEntity generateFeed(@RequestHeader("Authorization") String auth) throws IOException {
+ User user = userService.findUserByToken(auth).user();
+
+ ArticlesDTO res = articleService.generateArticleFeed(user);
+ logger.debug("Generated feed");
+ return ResponseEntity.ok(res);
+ }
+
+ @PostMapping("/{slug}/favorite")
+ public ResponseEntity markArticleAsFavorite(@PathVariable String slug, @RequestHeader(
+ "Authorization") String auth) throws IOException {
+ String username = userService.findUserByToken(auth).user().username();
+
+ Article res = articleService.markArticleAsFavorite(slug, username);
+ logger.debug("Set article: {} as favorite", slug);
+ return ResponseEntity.ok(new ArticleDTO(res));
+ }
+
+ @DeleteMapping("/{slug}/favorite")
+ public ResponseEntity removeArticleFromFavorite(@PathVariable String slug, @RequestHeader(
+ "Authorization") String auth) throws IOException {
+ String username = userService.findUserByToken(auth).user().username();
+
+ Article res = articleService.removeArticleFromFavorite(slug, username);
+ logger.debug("Removed article: {} from favorites", slug);
+ return ResponseEntity.ok(new ArticleDTO(res));
+ }
+
+ @PutMapping("/{slug}")
+ public ResponseEntity updateArticle(@RequestBody ArticleUpdateDTO req,
+ @PathVariable String slug, @RequestHeader(
+ "Authorization") String auth) throws IOException {
+ UserIdPair userPair = userService.findUserByToken(auth);
+ Author author = new Author(userPair.user(), false);
+
+ ArticleDTO res = articleService.updateArticle(req, slug, author);
+ logger.debug("Updated article: {}", slug);
+ return ResponseEntity.ok(res);
+ }
+
+ @DeleteMapping("/{slug}")
+ public ResponseEntity deleteArticle(@PathVariable String slug,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ UserIdPair userPair = userService.findUserByToken(auth);
+ Author author = new Author(userPair.user(), false);
+
+ articleService.deleteArticle(slug, author);
+ logger.debug("Deleted article: {}", slug);
+ return ResponseEntity.ok().build();
+ }
+
+ @PostMapping("/{slug}/comments")
+ public ResponseEntity commentArticle(@PathVariable String slug,
+ @RequestBody CommentCreationDTO comment,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ // Checking if the article exists
+ articleService.findArticleBySlug(slug);
+ // Getting the comment's author
+ User user = userService.findUserByToken(auth).user();
+
+ Comment res = commentService.newComment(comment, slug, user);
+ logger.debug("Commented article: {}", slug);
+ return ResponseEntity.ok(new CommentDTO(res));
+ }
+
+ @GetMapping("/{slug}/comments")
+ public ResponseEntity allCommentsByArticle(@PathVariable String slug, @RequestHeader(
+ value = "Authorization", required = false) String auth) throws IOException {
+ Optional user = Optional.empty();
+ if (!isNullOrBlank(auth)) {
+ user = Optional.of(userService.findUserByToken(auth).user());
+ }
+ CommentsDTO res = commentService.findAllCommentsByArticle(slug, user);
+ logger.debug("Comments for article: {}", slug);
+ return ResponseEntity.ok(res);
+ }
+
+ @DeleteMapping("/{slug}/comments/{commentId}")
+ public ResponseEntity deleteComment(@PathVariable String slug, @PathVariable String commentId,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ String username = userService.findUserByToken(auth).user().username();
+
+ commentService.deleteComment(commentId, username);
+ logger.debug("Deleted comment: {} from article {}", commentId, slug);
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java
new file mode 100644
index 000000000..65ad334ca
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/ProfileController.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import realworld.db.UserService;
+import realworld.document.user.Profile;
+
+import java.io.IOException;
+
+@CrossOrigin
+@RestController
+@RequestMapping("/profiles")
+public class ProfileController {
+
+ private UserService service;
+
+ Logger logger = LoggerFactory.getLogger(UserController.class);
+
+ @Autowired
+ public ProfileController(UserService service) {
+ this.service = service;
+ }
+
+ @GetMapping("/{username}")
+ public ResponseEntity get(@PathVariable String username,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ Profile res = service.findUserProfile(username, auth);
+ logger.debug("Returning profile for user {}", res.username());
+ return ResponseEntity.ok(res);
+ }
+
+ @PostMapping("/{username}/follow")
+ public ResponseEntity follow(@PathVariable String username,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ Profile res = service.followUser(username, auth);
+ logger.debug("Following user {}", res.username());
+ return ResponseEntity.ok(res);
+ }
+
+ @DeleteMapping("/{username}/follow")
+ public ResponseEntity unfollow(@PathVariable String username,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ Profile res = service.unfollowUser(username, auth);
+ logger.debug("Unfollowing user {}", res.username());
+ return ResponseEntity.ok(res);
+ }
+}
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java
new file mode 100644
index 000000000..7fd8452d7
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/TagsController.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import realworld.db.ArticleService;
+import realworld.document.article.TagsDTO;
+
+import java.io.IOException;
+
+@CrossOrigin
+@RestController
+@RequestMapping("/tags")
+public class TagsController {
+
+ private final ArticleService service;
+
+ Logger logger = LoggerFactory.getLogger(UserController.class);
+
+ @Autowired
+ public TagsController(ArticleService service) {
+ this.service = service;
+ }
+
+ @GetMapping()
+ public ResponseEntity get() throws IOException {
+ TagsDTO res = service.findAllTags();
+ logger.debug("Retrieved all tags");
+ return ResponseEntity.ok(res);
+ }
+}
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java
new file mode 100644
index 000000000..2d6e7def5
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/UserController.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import realworld.db.UserService;
+import realworld.document.user.LoginDTO;
+import realworld.document.user.RegisterDTO;
+import realworld.document.user.User;
+import realworld.document.user.UserDTO;
+
+import java.io.IOException;
+
+@CrossOrigin
+@RestController
+@RequestMapping()
+public class UserController {
+
+ private UserService service;
+
+ Logger logger = LoggerFactory.getLogger(UserController.class);
+
+ @Autowired
+ public UserController(UserService service) {
+ this.service = service;
+ }
+
+ @PostMapping("/users")
+ public ResponseEntity register(@RequestBody RegisterDTO req) throws IOException {
+ User res = service.newUser(req);
+ logger.debug("Registered new user {}", req.username());
+ return ResponseEntity.ok(new UserDTO(res));
+ }
+
+ @PostMapping("users/login")
+ public ResponseEntity login(@RequestBody LoginDTO req) throws IOException {
+ User res = service.authenticateUser(req);
+ logger.debug("User {} logged in", res.username());
+ return ResponseEntity.ok(new UserDTO(res));
+ }
+
+ @GetMapping("/user")
+ public ResponseEntity find(@RequestHeader("Authorization") String auth) throws IOException {
+ User res = service.findUserByToken(auth).user();
+ logger.debug("Returning info about user {}", res.username());
+ return ResponseEntity.ok(new UserDTO(res));
+
+ }
+
+ @PutMapping("/user")
+ public ResponseEntity update(@RequestBody UserDTO req,
+ @RequestHeader("Authorization") String auth) throws IOException {
+ User res = service.updateUser(req, auth);
+ logger.debug("Updated info for user {}", req.username());
+ return ResponseEntity.ok(new UserDTO(res));
+
+ }
+}
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java
new file mode 100644
index 000000000..1390744a4
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestError.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest.error;
+
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
+import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
+import com.fasterxml.jackson.annotation.JsonTypeName;
+
+import java.util.List;
+
+@JsonTypeName("errors")
+@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME)
+public record RestError(List body) {
+}
+
diff --git a/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java
new file mode 100644
index 000000000..b09b505e2
--- /dev/null
+++ b/examples/realworld-app/rw-rest/src/main/java/realworld/rest/error/RestExceptionHandler.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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
+ *
+ * http://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 realworld.rest.error;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+import realworld.document.exception.ResourceAlreadyExistsException;
+import realworld.document.exception.ResourceNotFoundException;
+import realworld.document.exception.UnauthorizedException;
+
+import java.io.IOException;
+import java.util.List;
+
+@ControllerAdvice
+public class RestExceptionHandler
+ extends ResponseEntityExceptionHandler {
+
+ @ExceptionHandler(value
+ = {IOException.class})
+ protected ResponseEntity